diff options
47 files changed, 1527 insertions, 499 deletions
diff --git a/.gitlab/ci/frontend.gitlab-ci.yml b/.gitlab/ci/frontend.gitlab-ci.yml index 1feda7ed4d4..16b85696727 100644 --- a/.gitlab/ci/frontend.gitlab-ci.yml +++ b/.gitlab/ci/frontend.gitlab-ci.yml @@ -123,7 +123,7 @@ compile-assets pull-cache: - .use-pg9 dependencies: ["compile-assets", "compile-assets pull-cache", "setup-test-env"] -karma: +.karma-base: extends: .only-code-frontend-job-base variables: # we override the max_old_space_size to prevent OOM errors @@ -134,6 +134,9 @@ karma: - scripts/gitaly-test-spawn - date - bundle exec rake karma + +karma: + extends: .karma-base coverage: '/^Statements *: (\d+\.\d+%)/' artifacts: name: coverage-javascript @@ -146,7 +149,12 @@ karma: reports: junit: junit_karma.xml -jest: +karma-foss: + extends: + - .karma-base + - .only-ee-as-if-foss + +.jest-base: extends: .only-code-frontend-job-base script: - scripts/gitaly-test-spawn @@ -154,6 +162,14 @@ jest: - bundle exec rake frontend:fixtures - date - yarn jest --ci --coverage + cache: + key: jest + paths: + - tmp/jest/jest/ + policy: pull-push + +jest: + extends: .jest-base artifacts: name: coverage-frontend expire_in: 31d @@ -164,11 +180,13 @@ jest: - tmp/tests/frontend/ reports: junit: junit_jest.xml + +jest-foss: + extends: + - .jest-base + - .only-ee-as-if-foss cache: - key: jest - paths: - - tmp/jest/jest/ - policy: pull-push + policy: pull .qa-job-base: extends: diff --git a/.gitlab/ci/global.gitlab-ci.yml b/.gitlab/ci/global.gitlab-ci.yml index e082d584b0c..0c0591d3fdc 100644 --- a/.gitlab/ci/global.gitlab-ci.yml +++ b/.gitlab/ci/global.gitlab-ci.yml @@ -149,3 +149,8 @@ variables: - $CI_PROJECT_NAME == "gitlab" - $CI_PROJECT_NAME == "gitlab-ee" # Support former project name for forks/mirrors + +.only-ee-as-if-foss: + extends: .only-ee + variables: + IS_GITLAB_EE: '0' diff --git a/.gitlab/ci/rails.gitlab-ci.yml b/.gitlab/ci/rails.gitlab-ci.yml index c315501b0ba..73b649b4d14 100644 --- a/.gitlab/ci/rails.gitlab-ci.yml +++ b/.gitlab/ci/rails.gitlab-ci.yml @@ -74,6 +74,12 @@ setup-test-env: - .rspec-base - .use-pg9 +.rspec-base-pg9-foss: + extends: + - .rspec-base + - .use-pg9 + - .only-ee-as-if-foss + .rspec-base-pg10: extends: - .rspec-base @@ -84,14 +90,27 @@ rspec unit pg9: extends: .rspec-base-pg9 parallel: 20 +rspec unit pg9-foss: + extends: .rspec-base-pg9-foss + parallel: 20 + rspec integration pg9: extends: .rspec-base-pg9 parallel: 6 +rspec integration pg9-foss: + extends: .rspec-base-pg9-foss + parallel: 6 + rspec system pg9: extends: .rspec-base-pg9 parallel: 24 +# TODO: This requires FOSS assets +# rspec system pg9-foss: +# extends: .rspec-base-pg9-foss +# parallel: 24 + rspec unit pg10: extends: .rspec-base-pg10 parallel: 20 diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index b308cd9c236..db3ad0bb4c9 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -337,6 +337,7 @@ class GfmAutoComplete { }, // eslint-disable-next-line no-template-curly-in-string insertTpl: '${atwho-at}${title}', + limit: 20, callbacks: { ...this.getDefaultCallbacks(), beforeSave(merges) { diff --git a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue index 11d5d9639b6..6b2ef34c960 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/editor_header.vue @@ -43,7 +43,12 @@ export default { <template> <div class="d-flex ide-commit-editor-header align-items-center"> <file-icon :file-name="activeFile.name" :size="16" class="mr-2" /> - <strong class="mr-2"> {{ activeFile.path }} </strong> + <strong class="mr-2"> + <template v-if="activeFile.prevPath && activeFile.prevPath !== activeFile.path"> + {{ activeFile.prevPath }} → + </template> + {{ activeFile.path }} + </strong> <changed-file-icon :file="activeFile" :is-centered="false" /> <div class="ml-auto"> <button diff --git a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue index 47b205f0a75..230dfaf047b 100644 --- a/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue +++ b/app/assets/javascripts/ide/components/commit_sidebar/list_item.vue @@ -110,6 +110,9 @@ export default { > <span class="multi-file-commit-list-file-path d-flex align-items-center"> <file-icon :file-name="file.name" class="append-right-8" /> + <template v-if="file.prevName && file.prevName !== file.name"> + {{ file.prevName }} → + </template> {{ file.name }} </span> <div class="ml-auto d-flex align-items-center"> diff --git a/app/assets/javascripts/ide/components/file_row_extra.vue b/app/assets/javascripts/ide/components/file_row_extra.vue index 5819999a459..f0bedcfbd6b 100644 --- a/app/assets/javascripts/ide/components/file_row_extra.vue +++ b/app/assets/javascripts/ide/components/file_row_extra.vue @@ -34,6 +34,9 @@ export default { 'getUnstagedFilesCountForPath', 'getStagedFilesCountForPath', ]), + isTree() { + return this.file.type === 'tree'; + }, folderUnstagedCount() { return this.getUnstagedFilesCountForPath(this.file.path); }, @@ -58,10 +61,13 @@ export default { }); }, showTreeChangesCount() { - return this.file.type === 'tree' && this.changesCount > 0 && !this.file.opened; + return this.isTree && this.changesCount > 0 && !this.file.opened; + }, + isModified() { + return this.file.changed || this.file.tempFile || this.file.staged || this.file.prevPath; }, showChangedFileIcon() { - return this.file.changed || this.file.tempFile || this.file.staged; + return !this.isTree && this.isModified; }, }, }; diff --git a/app/assets/javascripts/ide/components/ide_tree_list.vue b/app/assets/javascripts/ide/components/ide_tree_list.vue index 1af86a94482..95782b2c88a 100644 --- a/app/assets/javascripts/ide/components/ide_tree_list.vue +++ b/app/assets/javascripts/ide/components/ide_tree_list.vue @@ -30,9 +30,6 @@ export default { showLoading() { return !this.currentTree || this.currentTree.loading; }, - actualTreeList() { - return this.currentTree.tree.filter(entry => !entry.moved); - }, }, mounted() { this.updateViewer(this.viewerType); @@ -57,9 +54,9 @@ export default { <slot name="header"></slot> </header> <div class="ide-tree-body h-100"> - <template v-if="actualTreeList.length"> + <template v-if="currentTree.tree.length"> <file-row - v-for="file in actualTreeList" + v-for="file in currentTree.tree" :key="file.key" :file="file" :level="0" diff --git a/app/assets/javascripts/ide/components/new_dropdown/modal.vue b/app/assets/javascripts/ide/components/new_dropdown/modal.vue index a2dd31aebd4..d2ed1fe3e55 100644 --- a/app/assets/javascripts/ide/components/new_dropdown/modal.vue +++ b/app/assets/javascripts/ide/components/new_dropdown/modal.vue @@ -91,7 +91,6 @@ export default { this.renameEntry({ path: this.entryModal.entry.path, name: entryName, - entryPath: null, parentPath, }), ) diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 802b7f1fa6f..3bf8308ccea 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -155,15 +155,7 @@ export default { this.editor.clearEditor(); - this.getFileData({ - path: this.file.path, - makeFileActive: false, - }) - .then(() => - this.getRawFileData({ - path: this.file.path, - }), - ) + this.fetchFileData() .then(() => { this.createEditorInstance(); }) @@ -179,6 +171,20 @@ export default { throw err; }); }, + fetchFileData() { + if (this.file.tempFile) { + return Promise.resolve(); + } + + return this.getFileData({ + path: this.file.path, + makeFileActive: false, + }).then(() => + this.getRawFileData({ + path: this.file.path, + }), + ); + }, createEditorInstance() { this.editor.dispose(); diff --git a/app/assets/javascripts/ide/lib/files.js b/app/assets/javascripts/ide/lib/files.js index 51278640b5b..e86dac20104 100644 --- a/app/assets/javascripts/ide/lib/files.js +++ b/app/assets/javascripts/ide/lib/files.js @@ -1,7 +1,5 @@ import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; -import { decorateData, sortTree } from '../stores/utils'; - -export const escapeFileUrl = fileUrl => encodeURIComponent(fileUrl).replace(/%2F/g, '/'); +import { decorateData, sortTree, escapeFileUrl } from '../stores/utils'; export const splitParent = path => { const idx = path.lastIndexOf('/'); diff --git a/app/assets/javascripts/ide/stores/actions.js b/app/assets/javascripts/ide/stores/actions.js index 8c0119a1fed..4e18ec58feb 100644 --- a/app/assets/javascripts/ide/stores/actions.js +++ b/app/assets/javascripts/ide/stores/actions.js @@ -9,6 +9,7 @@ import { decorateFiles } from '../lib/files'; import { stageKeys } from '../constants'; import service from '../services'; import router from '../ide_router'; +import eventHub from '../eventhub'; export const redirectToUrl = (self, url) => visitUrl(url); @@ -171,8 +172,10 @@ export const setCurrentBranchId = ({ commit }, currentBranchId) => { export const updateTempFlagForEntry = ({ commit, dispatch, state }, { file, tempFile }) => { commit(types.UPDATE_TEMP_FLAG, { path: file.path, tempFile }); - if (file.parentPath) { - dispatch('updateTempFlagForEntry', { file: state.entries[file.parentPath], tempFile }); + const parent = file.parentPath && state.entries[file.parentPath]; + + if (parent) { + dispatch('updateTempFlagForEntry', { file: parent, tempFile }); } }; @@ -199,51 +202,71 @@ export const openNewEntryModal = ({ commit }, { type, path = '' }) => { export const deleteEntry = ({ commit, dispatch, state }, path) => { const entry = state.entries[path]; - + const { prevPath, prevName, prevParentPath } = entry; + const isTree = entry.type === 'tree'; + + if (prevPath) { + dispatch('renameEntry', { + path, + name: prevName, + parentPath: prevParentPath, + }); + dispatch('deleteEntry', prevPath); + return; + } if (state.unusedSeal) dispatch('burstUnusedSeal'); if (entry.opened) dispatch('closeFile', entry); - if (entry.type === 'tree') { + if (isTree) { entry.tree.forEach(f => dispatch('deleteEntry', f.path)); } commit(types.DELETE_ENTRY, path); - dispatch('stageChange', path); + + // Only stage if we're not a directory or a new file + if (!isTree && !entry.tempFile) { + dispatch('stageChange', path); + } dispatch('triggerFilesChange'); }; export const resetOpenFiles = ({ commit }) => commit(types.RESET_OPEN_FILES); -export const renameEntry = ( - { dispatch, commit, state }, - { path, name, entryPath = null, parentPath }, -) => { - const entry = state.entries[entryPath || path]; +export const renameEntry = ({ dispatch, commit, state }, { path, name, parentPath }) => { + const entry = state.entries[path]; + const newPath = parentPath ? `${parentPath}/${name}` : name; - commit(types.RENAME_ENTRY, { path, name, entryPath, parentPath }); + commit(types.RENAME_ENTRY, { path, name, parentPath }); if (entry.type === 'tree') { - const slashedParentPath = parentPath ? `${parentPath}/` : ''; - const targetEntry = entryPath ? entryPath.split('/').pop() : name; - const newParentPath = `${slashedParentPath}${targetEntry}`; - - state.entries[entryPath || path].tree.forEach(f => { + state.entries[newPath].tree.forEach(f => { dispatch('renameEntry', { - path, - name, - entryPath: f.path, - parentPath: newParentPath, + path: f.path, + name: f.name, + parentPath: newPath, }); }); } else { - const newPath = parentPath ? `${parentPath}/${name}` : name; const newEntry = state.entries[newPath]; - commit(types.TOGGLE_FILE_CHANGED, { file: newEntry, changed: true }); + const isRevert = newPath === entry.prevPath; + const isReset = isRevert && !newEntry.changed && !newEntry.tempFile; + const isInChanges = state.changedFiles + .concat(state.stagedFiles) + .some(({ key }) => key === newEntry.key); + + if (isReset) { + commit(types.REMOVE_FILE_FROM_STAGED_AND_CHANGED, newEntry); + } else if (!isInChanges) { + commit(types.ADD_FILE_TO_CHANGED, newPath); + } + + if (!newEntry.tempFile) { + eventHub.$emit(`editor.update.model.dispose.${entry.key}`); + } - if (entry.opened) { + if (newEntry.opened) { router.push(`/project${newEntry.url}`); - commit(types.TOGGLE_FILE_OPEN, entry.path); } } diff --git a/app/assets/javascripts/ide/stores/actions/file.js b/app/assets/javascripts/ide/stores/actions/file.js index 7627b6e03af..59445afc7a4 100644 --- a/app/assets/javascripts/ide/stores/actions/file.js +++ b/app/assets/javascripts/ide/stores/actions/file.js @@ -5,7 +5,7 @@ import eventHub from '../../eventhub'; import service from '../../services'; import * as types from '../mutation_types'; import router from '../../ide_router'; -import { setPageTitle } from '../utils'; +import { setPageTitle, replaceFileUrl } from '../utils'; import { viewerTypes, stageKeys } from '../../constants'; export const closeFile = ({ commit, state, dispatch }, file) => { @@ -67,7 +67,7 @@ export const getFileData = ( commit(types.TOGGLE_LOADING, { entry: file }); - const url = file.prevPath ? file.url.replace(file.path, file.prevPath) : file.url; + const url = file.prevPath ? replaceFileUrl(file.url, file.path, file.prevPath) : file.url; return service .getFileData(joinPaths(gon.relative_url_root || '', url.replace('/-/', '/'))) @@ -186,11 +186,6 @@ export const discardFileChanges = ({ dispatch, state, commit, getters }, path) = dispatch('restoreTree', file.parentPath); } - if (file.movedPath) { - commit(types.DISCARD_FILE_CHANGES, file.movedPath); - commit(types.REMOVE_FILE_FROM_CHANGED, file.movedPath); - } - commit(types.DISCARD_FILE_CHANGES, path); commit(types.REMOVE_FILE_FROM_CHANGED, path); diff --git a/app/assets/javascripts/ide/stores/actions/project.js b/app/assets/javascripts/ide/stores/actions/project.js index dd8f17e4f3a..20887e7d0ac 100644 --- a/app/assets/javascripts/ide/stores/actions/project.js +++ b/app/assets/javascripts/ide/stores/actions/project.js @@ -92,13 +92,27 @@ export const showEmptyState = ({ commit, state }, { projectId, branchId }) => { }); }; -export const openBranch = ({ dispatch, state, getters }, { projectId, branchId, basePath }) => { - dispatch('setCurrentBranchId', branchId); +export const loadFile = ({ dispatch, state }, { basePath }) => { + if (basePath) { + const path = basePath.slice(-1) === '/' ? basePath.slice(0, -1) : basePath; + const treeEntryKey = Object.keys(state.entries).find( + key => key === path && !state.entries[key].pending, + ); + const treeEntry = state.entries[treeEntryKey]; - if (getters.emptyRepo) { - return dispatch('showEmptyState', { projectId, branchId }); + if (treeEntry) { + dispatch('handleTreeEntryAction', treeEntry); + } else { + dispatch('createTempEntry', { + name: path, + type: 'blob', + }); + } } - return dispatch('getBranchData', { +}; + +export const loadBranch = ({ dispatch }, { projectId, branchId }) => + dispatch('getBranchData', { projectId, branchId, }) @@ -107,42 +121,38 @@ export const openBranch = ({ dispatch, state, getters }, { projectId, branchId, projectId, branchId, }); - dispatch('getFiles', { + return dispatch('getFiles', { projectId, branchId, - }) - .then(() => { - if (basePath) { - const path = basePath.slice(-1) === '/' ? basePath.slice(0, -1) : basePath; - const treeEntryKey = Object.keys(state.entries).find( - key => key === path && !state.entries[key].pending, - ); - const treeEntry = state.entries[treeEntryKey]; - - if (treeEntry) { - dispatch('handleTreeEntryAction', treeEntry); - } else { - dispatch('createTempEntry', { - name: path, - type: 'blob', - }); - } - } - }) - .catch( - () => - new Error( - sprintf( - __('An error occurred whilst getting files for - %{branchId}'), - { - branchId: `<strong>${_.escape(projectId)}/${_.escape(branchId)}</strong>`, - }, - false, - ), - ), - ); + }); }) .catch(() => { dispatch('showBranchNotFoundError', branchId); + return Promise.reject(); }); + +export const openBranch = ({ dispatch, state, getters }, { projectId, branchId, basePath }) => { + const currentProject = state.projects[projectId]; + if (getters.emptyRepo) { + return dispatch('showEmptyState', { projectId, branchId }); + } + if (!currentProject || !currentProject.branches[branchId]) { + dispatch('setCurrentBranchId', branchId); + + return dispatch('loadBranch', { projectId, branchId }) + .then(() => dispatch('loadFile', { basePath })) + .catch( + () => + new Error( + sprintf( + __('An error occurred whilst getting files for - %{branchId}'), + { + branchId: `<strong>${_.escape(projectId)}/${_.escape(branchId)}</strong>`, + }, + false, + ), + ), + ); + } + return Promise.resolve(dispatch('loadFile', { basePath })); }; diff --git a/app/assets/javascripts/ide/stores/modules/commit/actions.js b/app/assets/javascripts/ide/stores/modules/commit/actions.js index f767ca92a56..e89ed49318b 100644 --- a/app/assets/javascripts/ide/stores/modules/commit/actions.js +++ b/app/assets/javascripts/ide/stores/modules/commit/actions.js @@ -154,8 +154,6 @@ export const commitChanges = ({ commit, state, getters, dispatch, rootState, roo .then(() => { commit(rootTypes.CLEAR_STAGED_CHANGES, null, { root: true }); - commit(rootTypes.CLEAR_REPLACED_FILES, null, { root: true }); - setTimeout(() => { commit(rootTypes.SET_LAST_COMMIT_MSG, '', { root: true }); }, 5000); diff --git a/app/assets/javascripts/ide/stores/mutation_types.js b/app/assets/javascripts/ide/stores/mutation_types.js index f021729c451..f0b4718d025 100644 --- a/app/assets/javascripts/ide/stores/mutation_types.js +++ b/app/assets/javascripts/ide/stores/mutation_types.js @@ -59,8 +59,7 @@ export const UPDATE_DELAY_VIEWER_CHANGE = 'UPDATE_DELAY_VIEWER_CHANGE'; export const CLEAR_STAGED_CHANGES = 'CLEAR_STAGED_CHANGES'; export const STAGE_CHANGE = 'STAGE_CHANGE'; export const UNSTAGE_CHANGE = 'UNSTAGE_CHANGE'; - -export const CLEAR_REPLACED_FILES = 'CLEAR_REPLACED_FILES'; +export const REMOVE_FILE_FROM_STAGED_AND_CHANGED = 'REMOVE_FILE_FROM_STAGED_AND_CHANGED'; export const UPDATE_FILE_AFTER_COMMIT = 'UPDATE_FILE_AFTER_COMMIT'; export const ADD_PENDING_TAB = 'ADD_PENDING_TAB'; @@ -79,5 +78,6 @@ export const SET_ERROR_MESSAGE = 'SET_ERROR_MESSAGE'; export const OPEN_NEW_ENTRY_MODAL = 'OPEN_NEW_ENTRY_MODAL'; export const DELETE_ENTRY = 'DELETE_ENTRY'; export const RENAME_ENTRY = 'RENAME_ENTRY'; +export const REVERT_RENAME_ENTRY = 'REVERT_RENAME_ENTRY'; export const RESTORE_TREE = 'RESTORE_TREE'; diff --git a/app/assets/javascripts/ide/stores/mutations.js b/app/assets/javascripts/ide/stores/mutations.js index ea125214ebb..2587b57a817 100644 --- a/app/assets/javascripts/ide/stores/mutations.js +++ b/app/assets/javascripts/ide/stores/mutations.js @@ -5,7 +5,14 @@ import mergeRequestMutation from './mutations/merge_request'; import fileMutations from './mutations/file'; import treeMutations from './mutations/tree'; import branchMutations from './mutations/branch'; -import { sortTree } from './utils'; +import { + sortTree, + replaceFileUrl, + swapInParentTreeWithSorting, + updateFileCollections, + removeFromParentTree, + pathsAreEqual, +} from './utils'; export default { [types.SET_INITIAL_DATA](state, data) { @@ -56,11 +63,6 @@ export default { stagedFiles: [], }); }, - [types.CLEAR_REPLACED_FILES](state) { - Object.assign(state, { - replacedFiles: [], - }); - }, [types.SET_ENTRIES](state, entries) { Object.assign(state, { entries, @@ -157,9 +159,14 @@ export default { changed: Boolean(changedFile), staged: false, replaces: false, - prevPath: '', - moved: false, lastCommitSha: lastCommit.commit.id, + + prevId: undefined, + prevPath: undefined, + prevName: undefined, + prevUrl: undefined, + prevKey: undefined, + prevParentPath: undefined, }); if (prevPath) { @@ -209,7 +216,9 @@ export default { entry.deleted = true; - parent.tree = parent.tree.filter(f => f.path !== entry.path); + if (parent) { + parent.tree = parent.tree.filter(f => f.path !== entry.path); + } if (entry.type === 'blob') { if (tempFile) { @@ -219,51 +228,61 @@ export default { } } }, - [types.RENAME_ENTRY](state, { path, name, entryPath = null, parentPath }) { - const oldEntry = state.entries[entryPath || path]; - const slashedParentPath = parentPath ? `${parentPath}/` : ''; - const newPath = entryPath - ? `${slashedParentPath}${oldEntry.name}` - : `${slashedParentPath}${name}`; + [types.RENAME_ENTRY](state, { path, name, parentPath }) { + const oldEntry = state.entries[path]; + const newPath = parentPath ? `${parentPath}/${name}` : name; + const isRevert = newPath === oldEntry.prevPath; - Vue.set(state.entries, newPath, { + const newUrl = replaceFileUrl(oldEntry.url, oldEntry.path, newPath); + + const newKey = oldEntry.key.replace(new RegExp(oldEntry.path, 'g'), newPath); + + const baseProps = { ...oldEntry, + name, id: newPath, - key: `${newPath}-${oldEntry.type}-${oldEntry.path}`, path: newPath, - name: entryPath ? oldEntry.name : name, - tempFile: true, - prevPath: oldEntry.tempFile ? null : oldEntry.path, - url: oldEntry.url.replace(new RegExp(`${oldEntry.path}/?$`), newPath), - tree: [], - raw: '', - opened: false, - parentPath, - }); + url: newUrl, + key: newKey, + parentPath: parentPath || '', + }; - oldEntry.moved = true; - oldEntry.movedPath = newPath; + const prevProps = + oldEntry.tempFile || isRevert + ? { + prevId: undefined, + prevPath: undefined, + prevName: undefined, + prevUrl: undefined, + prevKey: undefined, + prevParentPath: undefined, + } + : { + prevId: oldEntry.prevId || oldEntry.id, + prevPath: oldEntry.prevPath || oldEntry.path, + prevName: oldEntry.prevName || oldEntry.name, + prevUrl: oldEntry.prevUrl || oldEntry.url, + prevKey: oldEntry.prevKey || oldEntry.key, + prevParentPath: oldEntry.prevParentPath || oldEntry.parentPath, + }; - const parent = parentPath - ? state.entries[parentPath] - : state.trees[`${state.currentProjectId}/${state.currentBranchId}`]; - const newEntry = state.entries[newPath]; - - parent.tree = sortTree(parent.tree.concat(newEntry)); + Vue.set(state.entries, newPath, { + ...baseProps, + ...prevProps, + }); - if (newEntry.type === 'blob') { - state.changedFiles = state.changedFiles.concat(newEntry); + if (pathsAreEqual(oldEntry.parentPath, parentPath)) { + swapInParentTreeWithSorting(state, oldEntry.key, newPath, parentPath); + } else { + removeFromParentTree(state, oldEntry.key, oldEntry.parentPath); + swapInParentTreeWithSorting(state, oldEntry.key, newPath, parentPath); } - if (oldEntry.tempFile) { - const filterMethod = f => f.path !== oldEntry.path; - - state.openFiles = state.openFiles.filter(filterMethod); - state.changedFiles = state.changedFiles.filter(filterMethod); - parent.tree = parent.tree.filter(filterMethod); - - Vue.delete(state.entries, oldEntry.path); + if (oldEntry.type === 'blob') { + updateFileCollections(state, oldEntry.key, newPath); } + + Vue.delete(state.entries, oldEntry.path); }, ...projectMutations, diff --git a/app/assets/javascripts/ide/stores/mutations/file.js b/app/assets/javascripts/ide/stores/mutations/file.js index 1442ea7dbfa..8caeb2d73b2 100644 --- a/app/assets/javascripts/ide/stores/mutations/file.js +++ b/app/assets/javascripts/ide/stores/mutations/file.js @@ -138,8 +138,6 @@ export default { content: stagedFile ? stagedFile.content : state.entries[path].raw, changed: false, deleted: false, - moved: false, - movedPath: '', }); if (deleted) { @@ -179,11 +177,6 @@ export default { }); if (stagedFile) { - Object.assign(state, { - replacedFiles: state.replacedFiles.concat({ - ...stagedFile, - }), - }); Object.assign(stagedFile, { ...state.entries[path], }); @@ -252,4 +245,15 @@ export default { openFiles: state.openFiles.filter(f => f.key !== file.key), }); }, + [types.REMOVE_FILE_FROM_STAGED_AND_CHANGED](state, file) { + Object.assign(state, { + changedFiles: state.changedFiles.filter(f => f.key !== file.key), + stagedFiles: state.stagedFiles.filter(f => f.key !== file.key), + }); + + Object.assign(state.entries[file.path], { + changed: false, + staged: false, + }); + }, }; diff --git a/app/assets/javascripts/ide/stores/state.js b/app/assets/javascripts/ide/stores/state.js index c4da482bf0a..d400b9831a9 100644 --- a/app/assets/javascripts/ide/stores/state.js +++ b/app/assets/javascripts/ide/stores/state.js @@ -6,7 +6,6 @@ export default () => ({ currentMergeRequestId: '', changedFiles: [], stagedFiles: [], - replacedFiles: [], endpoints: {}, lastCommitMsg: '', lastCommitPath: '', diff --git a/app/assets/javascripts/ide/stores/utils.js b/app/assets/javascripts/ide/stores/utils.js index 52200ce7847..a8d8ff31afe 100644 --- a/app/assets/javascripts/ide/stores/utils.js +++ b/app/assets/javascripts/ide/stores/utils.js @@ -50,9 +50,7 @@ export const dataStructure = () => ({ lastOpenedAt: 0, mrChange: null, deleted: false, - prevPath: '', - movedPath: '', - moved: false, + prevPath: undefined, }); export const decorateData = entity => { @@ -129,7 +127,7 @@ export const commitActionForFile = file => { export const getCommitFiles = stagedFiles => stagedFiles.reduce((acc, file) => { - if (file.moved || file.type === 'tree') return acc; + if (file.type === 'tree') return acc; return acc.concat({ ...file, @@ -148,9 +146,9 @@ export const createCommitPayload = ({ commit_message: state.commitMessage || getters.preBuiltCommitMessage, actions: getCommitFiles(rootState.stagedFiles).map(f => ({ action: commitActionForFile(f), - file_path: f.moved ? f.movedPath : f.path, - previous_path: f.prevPath === '' ? undefined : f.prevPath, - content: f.prevPath ? null : f.content || undefined, + file_path: f.path, + previous_path: f.prevPath || undefined, + content: f.prevPath && !f.changed ? null : f.content || undefined, encoding: f.base64 ? 'base64' : 'text', last_commit_id: newBranch || f.deleted || f.prevPath || f.replaces ? undefined : f.lastCommitSha, @@ -213,3 +211,61 @@ export const mergeTrees = (fromTree, toTree) => { return toTree; }; + +export const escapeFileUrl = fileUrl => encodeURIComponent(fileUrl).replace(/%2F/g, '/'); + +export const replaceFileUrl = (url, oldPath, newPath) => { + // Add `/-/` so that we don't accidentally replace project path + const result = url.replace(`/-/${escapeFileUrl(oldPath)}`, `/-/${escapeFileUrl(newPath)}`); + + return result; +}; + +export const swapInStateArray = (state, arr, key, entryPath) => + Object.assign(state, { + [arr]: state[arr].map(f => (f.key === key ? state.entries[entryPath] : f)), + }); + +export const getEntryOrRoot = (state, path) => + path ? state.entries[path] : state.trees[`${state.currentProjectId}/${state.currentBranchId}`]; + +export const swapInParentTreeWithSorting = (state, oldKey, newPath, parentPath) => { + if (!newPath) { + return; + } + + const parent = getEntryOrRoot(state, parentPath); + + if (parent) { + const tree = parent.tree + // filter out old entry && new entry + .filter(({ key, path }) => key !== oldKey && path !== newPath) + // concat new entry + .concat(state.entries[newPath]); + + parent.tree = sortTree(tree); + } +}; + +export const removeFromParentTree = (state, oldKey, parentPath) => { + const parent = getEntryOrRoot(state, parentPath); + + if (parent) { + parent.tree = sortTree(parent.tree.filter(({ key }) => key !== oldKey)); + } +}; + +export const updateFileCollections = (state, key, entryPath) => { + ['openFiles', 'changedFiles', 'stagedFiles'].forEach(fileCollection => { + swapInStateArray(state, fileCollection, key, entryPath); + }); +}; + +export const cleanTrailingSlash = path => path.replace(/\/$/, ''); + +export const pathsAreEqual = (a, b) => { + const cleanA = a ? cleanTrailingSlash(a) : ''; + const cleanB = b ? cleanTrailingSlash(b) : ''; + + return cleanA === cleanB; +}; diff --git a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue index 54cd0c9c642..75c3c544c77 100644 --- a/app/assets/javascripts/vue_shared/components/changed_file_icon.vue +++ b/app/assets/javascripts/vue_shared/components/changed_file_icon.vue @@ -70,7 +70,13 @@ export default { return undefined; }, showIcon() { - return this.file.changed || this.file.tempFile || this.file.staged || this.file.deleted; + return ( + this.file.changed || + this.file.tempFile || + this.file.staged || + this.file.deleted || + this.file.prevPath + ); }, }, }; diff --git a/app/assets/javascripts/vue_shared/components/file_row.vue b/app/assets/javascripts/vue_shared/components/file_row.vue index f49e69c473b..341c9534763 100644 --- a/app/assets/javascripts/vue_shared/components/file_row.vue +++ b/app/assets/javascripts/vue_shared/components/file_row.vue @@ -131,7 +131,7 @@ export default { </script> <template> - <div v-if="!file.moved"> + <div> <file-header v-if="file.isHeader" :path="file.path" /> <div v-else diff --git a/app/assets/stylesheets/framework/animations.scss b/app/assets/stylesheets/framework/animations.scss index 6f5a2e561af..d222fc4aefe 100644 --- a/app/assets/stylesheets/framework/animations.scss +++ b/app/assets/stylesheets/framework/animations.scss @@ -11,25 +11,10 @@ @include webkit-prefix(animation-duration, 1s); @include webkit-prefix(animation-fill-mode, both); - &.infinite { - @include webkit-prefix(animation-iteration-count, infinite); - } - &.once { @include webkit-prefix(animation-iteration-count, 1); } - &.hinge { - @include webkit-prefix(animation-duration, 2s); - } - - &.flipOutX, - &.flipOutY, - &.bounceIn, - &.bounceOut { - @include webkit-prefix(animation-duration, 0.75s); - } - &.short { @include webkit-prefix(animation-duration, 321ms); @include webkit-prefix(animation-fill-mode, none); diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index 850df2885aa..1fbc61cd950 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -35,7 +35,7 @@ module Resolvers description: 'Issues closed after this date' argument :search, GraphQL::STRING_TYPE, # rubocop:disable Graphql/Descriptions required: false - argument :sort, Types::SortEnum, + argument :sort, Types::IssueSortEnum, description: 'Sort issues by this criteria', required: false, default_value: 'created_desc' diff --git a/app/graphql/types/issuable_sort_enum.rb b/app/graphql/types/issuable_sort_enum.rb new file mode 100644 index 00000000000..932e90c2d22 --- /dev/null +++ b/app/graphql/types/issuable_sort_enum.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + # rubocop: disable Graphql/AuthorizeTypes + class IssuableSortEnum < SortEnum + graphql_name 'IssuableSort' + description 'Values for sorting issuables' + end + # rubocop: enable Graphql/AuthorizeTypes +end diff --git a/app/graphql/types/issue_sort_enum.rb b/app/graphql/types/issue_sort_enum.rb new file mode 100644 index 00000000000..ad919b55481 --- /dev/null +++ b/app/graphql/types/issue_sort_enum.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module Types + # rubocop: disable Graphql/AuthorizeTypes + class IssueSortEnum < IssuableSortEnum + graphql_name 'IssueSort' + description 'Values for sorting issues' + end + # rubocop: enable Graphql/AuthorizeTypes +end diff --git a/changelogs/unreleased/17596-show-all-matching-labels-when-using-label-quick-action.yml b/changelogs/unreleased/17596-show-all-matching-labels-when-using-label-quick-action.yml new file mode 100644 index 00000000000..7618e89b7c9 --- /dev/null +++ b/changelogs/unreleased/17596-show-all-matching-labels-when-using-label-quick-action.yml @@ -0,0 +1,5 @@ +--- +title: Show 20 labels in dropdown instead of 5 +merge_request: 17596 +author: +type: fixed diff --git a/jest.config.js b/jest.config.js index 435dca876ce..c2a512e8afa 100644 --- a/jest.config.js +++ b/jest.config.js @@ -15,7 +15,10 @@ if (process.env.CI) { ]); } -let testMatch = ['<rootDir>/spec/frontend/**/*_spec.js', '<rootDir>/ee/spec/frontend/**/*_spec.js']; +let testMatch = ['<rootDir>/spec/frontend/**/*_spec.js']; +if (IS_EE) { + testMatch.push('<rootDir>/ee/spec/frontend/**/*_spec.js'); +} // workaround for eslint-import-resolver-jest only resolving in test files // see https://github.com/JoinColony/eslint-import-resolver-jest#note diff --git a/lib/gitlab/import_export/project_tree_restorer.rb b/lib/gitlab/import_export/project_tree_restorer.rb index edee4ba2486..9beb84a2422 100644 --- a/lib/gitlab/import_export/project_tree_restorer.rb +++ b/lib/gitlab/import_export/project_tree_restorer.rb @@ -120,7 +120,7 @@ module Gitlab end end - def remove_feature_dependent_sub_relations(_relation_item) + def remove_feature_dependent_sub_relations!(_relation_item) # no-op end @@ -191,7 +191,7 @@ module Gitlab # Avoid keeping a possible heavy object in memory once we are done with it while relation_item = tree_array.shift - remove_feature_dependent_sub_relations(relation_item) + remove_feature_dependent_sub_relations!(relation_item) # The transaction at this level is less speedy than one single transaction # But we can't have it in the upper level or GC won't get rid of the AR objects diff --git a/lib/gitlab/sidekiq_config.rb b/lib/gitlab/sidekiq_config.rb index c102fa14cfc..ffceeb68f20 100644 --- a/lib/gitlab/sidekiq_config.rb +++ b/lib/gitlab/sidekiq_config.rb @@ -5,7 +5,11 @@ require 'set' module Gitlab module SidekiqConfig - QUEUE_CONFIG_PATHS = %w[app/workers/all_queues.yml ee/app/workers/all_queues.yml].freeze + QUEUE_CONFIG_PATHS = begin + result = %w[app/workers/all_queues.yml] + result << 'ee/app/workers/all_queues.yml' if Gitlab.ee? + result + end.freeze # This method is called by `ee/bin/sidekiq-cluster` in EE, which runs outside # of bundler/Rails context, so we cannot use any gem or Rails methods. @@ -48,9 +52,11 @@ module Gitlab end def self.workers - @workers ||= - find_workers(Rails.root.join('app', 'workers')) + - find_workers(Rails.root.join('ee', 'app', 'workers')) + @workers ||= begin + result = find_workers(Rails.root.join('app', 'workers')) + result.concat(find_workers(Rails.root.join('ee', 'app', 'workers'))) if Gitlab.ee? + result + end end def self.find_workers(root) diff --git a/lib/tasks/frontend.rake b/lib/tasks/frontend.rake index 1cac7520227..6e90229830d 100644 --- a/lib/tasks/frontend.rake +++ b/lib/tasks/frontend.rake @@ -2,7 +2,10 @@ unless Rails.env.production? namespace :frontend do desc 'GitLab | Frontend | Generate fixtures for JavaScript tests' RSpec::Core::RakeTask.new(:fixtures, [:pattern]) do |t, args| - args.with_defaults(pattern: '{spec,ee/spec}/frontend/fixtures/*.rb') + directories = %w[spec] + directories << 'ee/spec' if Gitlab.ee? + directory_glob = "{#{directories.join(',')}}" + args.with_defaults(pattern: "#{directory_glob}/frontend/fixtures/*.rb") ENV['NO_KNAPSACK'] = 'true' t.pattern = args[:pattern] t.rspec_opts = '--format documentation' diff --git a/spec/controllers/projects/environments_controller_spec.rb b/spec/controllers/projects/environments_controller_spec.rb index d8e3ea8ba39..3fe5ff5feee 100644 --- a/spec/controllers/projects/environments_controller_spec.rb +++ b/spec/controllers/projects/environments_controller_spec.rb @@ -256,7 +256,7 @@ describe Projects::EnvironmentsController do it 'loads the terminals for the environment' do # In EE we have to stub EE::Environment since it overwrites the # "terminals" method. - expect_any_instance_of(defined?(EE) ? EE::Environment : Environment) + expect_any_instance_of(Gitlab.ee? ? EE::Environment : Environment) .to receive(:terminals) get :terminal, params: environment_params @@ -282,7 +282,7 @@ describe Projects::EnvironmentsController do it 'returns the first terminal for the environment' do # In EE we have to stub EE::Environment since it overwrites the # "terminals" method. - expect_any_instance_of(defined?(EE) ? EE::Environment : Environment) + expect_any_instance_of(Gitlab.ee? ? EE::Environment : Environment) .to receive(:terminals) .and_return([:fake_terminal]) diff --git a/spec/frontend/fixtures/autocomplete_sources.rb b/spec/frontend/fixtures/autocomplete_sources.rb index 9e04328e2b9..382eff02b0f 100644 --- a/spec/frontend/fixtures/autocomplete_sources.rb +++ b/spec/frontend/fixtures/autocomplete_sources.rb @@ -24,6 +24,10 @@ describe Projects::AutocompleteSourcesController, '(JavaScript fixtures)', type: create(:label, project: project, title: 'feature') create(:label, project: project, title: 'documentation') + create(:label, project: project, title: 'P1') + create(:label, project: project, title: 'P2') + create(:label, project: project, title: 'P3') + create(:label, project: project, title: 'P4') get :labels, format: :json, diff --git a/spec/frontend/ide/lib/files_spec.js b/spec/frontend/ide/lib/files_spec.js index 08a31318544..654dc6c13c8 100644 --- a/spec/frontend/ide/lib/files_spec.js +++ b/spec/frontend/ide/lib/files_spec.js @@ -1,6 +1,6 @@ import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; -import { decorateFiles, splitParent, escapeFileUrl } from '~/ide/lib/files'; -import { decorateData } from '~/ide/stores/utils'; +import { decorateFiles, splitParent } from '~/ide/lib/files'; +import { decorateData, escapeFileUrl } from '~/ide/stores/utils'; const TEST_BRANCH_ID = 'lorem-ipsum'; const TEST_PROJECT_ID = 10; diff --git a/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js index bf48d7bfdad..c1dcd4928a0 100644 --- a/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js +++ b/spec/javascripts/ide/components/commit_sidebar/list_item_spec.js @@ -2,12 +2,14 @@ import Vue from 'vue'; import store from '~/ide/stores'; import listItem from '~/ide/components/commit_sidebar/list_item.vue'; import router from '~/ide/ide_router'; +import { trimText } from 'spec/helpers/text_helper'; import { createComponentWithStore } from 'spec/helpers/vue_mount_component_helper'; import { file, resetStore } from '../../helpers'; describe('Multi-file editor commit sidebar list item', () => { let vm; let f; + let findPathEl; beforeEach(() => { const Component = Vue.extend(listItem); @@ -21,6 +23,8 @@ describe('Multi-file editor commit sidebar list item', () => { actionComponent: 'stage-button', activeFileKey: `staged-${f.key}`, }).$mount(); + + findPathEl = vm.$el.querySelector('.multi-file-commit-list-path'); }); afterEach(() => { @@ -29,15 +33,39 @@ describe('Multi-file editor commit sidebar list item', () => { resetStore(store); }); + const findPathText = () => trimText(findPathEl.textContent); + it('renders file path', () => { - expect(vm.$el.querySelector('.multi-file-commit-list-path').textContent).toContain(f.path); + expect(findPathText()).toContain(f.path); + }); + + it('correctly renders renamed entries', done => { + Vue.set(vm.file, 'prevName', 'Old name'); + + vm.$nextTick() + .then(() => { + expect(findPathText()).toEqual(`Old name → ${f.name}`); + }) + .then(done) + .catch(done.fail); + }); + + it('correctly renders entry, the name of which did not change after rename (as within a folder)', done => { + Vue.set(vm.file, 'prevName', f.name); + + vm.$nextTick() + .then(() => { + expect(findPathText()).toEqual(f.name); + }) + .then(done) + .catch(done.fail); }); it('opens a closed file in the editor when clicking the file path', done => { spyOn(vm, 'openPendingTab').and.callThrough(); spyOn(router, 'push'); - vm.$el.querySelector('.multi-file-commit-list-path').click(); + findPathEl.click(); setTimeout(() => { expect(vm.openPendingTab).toHaveBeenCalled(); @@ -52,7 +80,7 @@ describe('Multi-file editor commit sidebar list item', () => { spyOn(vm, 'updateViewer').and.callThrough(); spyOn(router, 'push'); - vm.$el.querySelector('.multi-file-commit-list-path').click(); + findPathEl.click(); setTimeout(() => { expect(vm.updateViewer).toHaveBeenCalledWith('diff'); diff --git a/spec/javascripts/ide/components/file_row_extra_spec.js b/spec/javascripts/ide/components/file_row_extra_spec.js index d7fed3f0681..86146fcef69 100644 --- a/spec/javascripts/ide/components/file_row_extra_spec.js +++ b/spec/javascripts/ide/components/file_row_extra_spec.js @@ -139,6 +139,27 @@ describe('IDE extra file row component', () => { done(); }); }); + + it('shows when file is renamed', done => { + vm.file.prevPath = 'original-file'; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.file-changed-icon')).not.toBe(null); + + done(); + }); + }); + + it('hides when file is renamed', done => { + vm.file.prevPath = 'original-file'; + vm.file.type = 'tree'; + + vm.$nextTick(() => { + expect(vm.$el.querySelector('.file-changed-icon')).toBe(null); + + done(); + }); + }); }); describe('merge request icon', () => { diff --git a/spec/javascripts/ide/components/ide_tree_list_spec.js b/spec/javascripts/ide/components/ide_tree_list_spec.js index 554bd1ae3b5..f63007c7dd2 100644 --- a/spec/javascripts/ide/components/ide_tree_list_spec.js +++ b/spec/javascripts/ide/components/ide_tree_list_spec.js @@ -58,20 +58,6 @@ describe('IDE tree list', () => { it('renders list of files', () => { expect(vm.$el.textContent).toContain('fileName'); }); - - it('does not render moved entries', done => { - const tree = [file('moved entry'), file('normal entry')]; - tree[0].moved = true; - store.state.trees['abcproject/master'].tree = tree; - const container = vm.$el.querySelector('.ide-tree-body'); - - vm.$nextTick(() => { - expect(container.children.length).toBe(1); - expect(vm.$el.textContent).not.toContain('moved entry'); - expect(vm.$el.textContent).toContain('normal entry'); - done(); - }); - }); }); describe('empty-branch state', () => { diff --git a/spec/javascripts/ide/components/repo_editor_spec.js b/spec/javascripts/ide/components/repo_editor_spec.js index 0701b773e17..d1b43df74b9 100644 --- a/spec/javascripts/ide/components/repo_editor_spec.js +++ b/spec/javascripts/ide/components/repo_editor_spec.js @@ -410,10 +410,23 @@ describe('RepoEditor', () => { describe('initEditor', () => { beforeEach(() => { + vm.file.tempFile = false; spyOn(vm.editor, 'createInstance'); spyOnProperty(vm, 'shouldHideEditor').and.returnValue(true); }); + it('does not fetch file information for temp entries', done => { + vm.file.tempFile = true; + + vm.initEditor(); + vm.$nextTick() + .then(() => { + expect(vm.getFileData).not.toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + it('is being initialised for files without content even if shouldHideEditor is `true`', done => { vm.file.content = ''; vm.file.raw = ''; @@ -429,16 +442,13 @@ describe('RepoEditor', () => { }); it('does not initialize editor for files already with content', done => { - expect(vm.getFileData.calls.count()).toEqual(1); - expect(vm.getRawFileData.calls.count()).toEqual(1); - vm.file.content = 'foo'; vm.initEditor(); vm.$nextTick() .then(() => { - expect(vm.getFileData.calls.count()).toEqual(1); - expect(vm.getRawFileData.calls.count()).toEqual(1); + expect(vm.getFileData).not.toHaveBeenCalled(); + expect(vm.getRawFileData).not.toHaveBeenCalled(); expect(vm.editor.createInstance).not.toHaveBeenCalled(); }) .then(done) @@ -446,23 +456,56 @@ describe('RepoEditor', () => { }); }); - it('calls removePendingTab when old file is pending', done => { - spyOnProperty(vm, 'shouldHideEditor').and.returnValue(true); - spyOn(vm, 'removePendingTab'); + describe('updates on file changes', () => { + beforeEach(() => { + spyOn(vm, 'initEditor'); + }); - vm.file.pending = true; + it('calls removePendingTab when old file is pending', done => { + spyOnProperty(vm, 'shouldHideEditor').and.returnValue(true); + spyOn(vm, 'removePendingTab'); - vm.$nextTick() - .then(() => { - vm.file = file('testing'); - vm.file.content = 'foo'; // need to prevent full cycle of initEditor + vm.file.pending = true; + + vm.$nextTick() + .then(() => { + vm.file = file('testing'); + vm.file.content = 'foo'; // need to prevent full cycle of initEditor - return vm.$nextTick(); - }) - .then(() => { - expect(vm.removePendingTab).toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); + return vm.$nextTick(); + }) + .then(() => { + expect(vm.removePendingTab).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + + it('does not call initEditor if the file did not change', done => { + Vue.set(vm, 'file', vm.file); + + vm.$nextTick() + .then(() => { + expect(vm.initEditor).not.toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + + it('calls initEditor when file key is changed', done => { + expect(vm.initEditor).not.toHaveBeenCalled(); + + Vue.set(vm, 'file', { + ...vm.file, + key: 'new', + }); + + vm.$nextTick() + .then(() => { + expect(vm.initEditor).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); }); }); diff --git a/spec/javascripts/ide/stores/actions/project_spec.js b/spec/javascripts/ide/stores/actions/project_spec.js index 8ecb6129c63..bcc7b5d5e46 100644 --- a/spec/javascripts/ide/stores/actions/project_spec.js +++ b/spec/javascripts/ide/stores/actions/project_spec.js @@ -6,8 +6,10 @@ import { createNewBranchFromDefault, showEmptyState, openBranch, + loadFile, + loadBranch, } from '~/ide/stores/actions'; -import store from '~/ide/stores'; +import { createStore } from '~/ide/stores'; import service from '~/ide/services'; import api from '~/api'; import router from '~/ide/ide_router'; @@ -16,8 +18,10 @@ import testAction from '../../../helpers/vuex_action_helper'; describe('IDE store project actions', () => { let mock; + let store; beforeEach(() => { + store = createStore(); mock = new MockAdapter(axios); store.state.projects['abc/def'] = { @@ -231,28 +235,139 @@ describe('IDE store project actions', () => { }); }); + describe('loadFile', () => { + beforeEach(() => { + Object.assign(store.state, { + entries: { + foo: { pending: false }, + 'foo/bar-pending': { pending: true }, + 'foo/bar': { pending: false }, + }, + }); + spyOn(store, 'dispatch'); + }); + + it('does nothing, if basePath is not given', () => { + loadFile(store, { basePath: undefined }); + + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + it('handles tree entry action, if basePath is given and the entry is not pending', () => { + loadFile(store, { basePath: 'foo/bar/' }); + + expect(store.dispatch).toHaveBeenCalledWith( + 'handleTreeEntryAction', + store.state.entries['foo/bar'], + ); + }); + + it('does not handle tree entry action, if entry is pending', () => { + loadFile(store, { basePath: 'foo/bar-pending/' }); + + expect(store.dispatch).not.toHaveBeenCalledWith('handleTreeEntryAction', jasmine.anything()); + }); + + it('creates a new temp file supplied via URL if the file does not exist yet', () => { + loadFile(store, { basePath: 'not-existent.md' }); + + expect(store.dispatch.calls.count()).toBe(1); + + expect(store.dispatch).not.toHaveBeenCalledWith('handleTreeEntryAction', jasmine.anything()); + + expect(store.dispatch).toHaveBeenCalledWith('createTempEntry', { + name: 'not-existent.md', + type: 'blob', + }); + }); + }); + + describe('loadBranch', () => { + const projectId = 'abc/def'; + const branchId = '123-lorem'; + + it('fetches branch data', done => { + spyOn(store, 'dispatch').and.returnValue(Promise.resolve()); + + loadBranch(store, { projectId, branchId }) + .then(() => { + expect(store.dispatch.calls.allArgs()).toEqual([ + ['getBranchData', { projectId, branchId }], + ['getMergeRequestsForBranch', { projectId, branchId }], + ['getFiles', { projectId, branchId }], + ]); + }) + .then(done) + .catch(done.fail); + }); + + it('shows an error if branch can not be fetched', done => { + spyOn(store, 'dispatch').and.returnValue(Promise.reject()); + + loadBranch(store, { projectId, branchId }) + .then(done.fail) + .catch(() => { + expect(store.dispatch.calls.allArgs()).toEqual([ + ['getBranchData', { projectId, branchId }], + ['showBranchNotFoundError', branchId], + ]); + done(); + }); + }); + }); + describe('openBranch', () => { + const projectId = 'abc/def'; + const branchId = '123-lorem'; + const branch = { - projectId: 'abc/def', - branchId: '123-lorem', + projectId, + branchId, }; beforeEach(() => { - store.state.entries = { - foo: { pending: false }, - 'foo/bar-pending': { pending: true }, - 'foo/bar': { pending: false }, - }; + Object.assign(store.state, { + entries: { + foo: { pending: false }, + 'foo/bar-pending': { pending: true }, + 'foo/bar': { pending: false }, + }, + }); + }); + + it('loads file right away if the branch has already been fetched', done => { + spyOn(store, 'dispatch'); + + Object.assign(store.state, { + projects: { + [projectId]: { + branches: { + [branchId]: { foo: 'bar' }, + }, + }, + }, + }); + + openBranch(store, branch) + .then(() => { + expect(store.dispatch.calls.allArgs()).toEqual([['loadFile', { basePath: undefined }]]); + }) + .then(done) + .catch(done.fail); }); describe('empty repo', () => { beforeEach(() => { spyOn(store, 'dispatch').and.returnValue(Promise.resolve()); - store.state.currentProjectId = 'abc/def'; - store.state.projects['abc/def'] = { - empty_repo: true, - }; + Object.assign(store.state, { + currentProjectId: 'abc/def', + projects: { + 'abc/def': { + empty_repo: true, + }, + }, + }); }); afterEach(() => { @@ -262,10 +377,7 @@ describe('IDE store project actions', () => { it('dispatches showEmptyState action right away', done => { openBranch(store, branch) .then(() => { - expect(store.dispatch.calls.allArgs()).toEqual([ - ['setCurrentBranchId', branch.branchId], - ['showEmptyState', branch], - ]); + expect(store.dispatch.calls.allArgs()).toEqual([['showEmptyState', branch]]); done(); }) .catch(done.fail); @@ -281,56 +393,14 @@ describe('IDE store project actions', () => { openBranch(store, branch) .then(() => { expect(store.dispatch.calls.allArgs()).toEqual([ - ['setCurrentBranchId', branch.branchId], - ['getBranchData', branch], - ['getMergeRequestsForBranch', branch], - ['getFiles', branch], + ['setCurrentBranchId', branchId], + ['loadBranch', { projectId, branchId }], + ['loadFile', { basePath: undefined }], ]); }) .then(done) .catch(done.fail); }); - - it('handles tree entry action, if basePath is given', done => { - openBranch(store, { ...branch, basePath: 'foo/bar/' }) - .then(() => { - expect(store.dispatch).toHaveBeenCalledWith( - 'handleTreeEntryAction', - store.state.entries['foo/bar'], - ); - }) - .then(done) - .catch(done.fail); - }); - - it('does not handle tree entry action, if entry is pending', done => { - openBranch(store, { ...branch, basePath: 'foo/bar-pending' }) - .then(() => { - expect(store.dispatch).not.toHaveBeenCalledWith( - 'handleTreeEntryAction', - jasmine.anything(), - ); - }) - .then(done) - .catch(done.fail); - }); - - it('creates a new file supplied via URL if the file does not exist yet', done => { - openBranch(store, { ...branch, basePath: 'not-existent.md' }) - .then(() => { - expect(store.dispatch).not.toHaveBeenCalledWith( - 'handleTreeEntryAction', - jasmine.anything(), - ); - - expect(store.dispatch).toHaveBeenCalledWith('createTempEntry', { - name: 'not-existent.md', - type: 'blob', - }); - }) - .then(done) - .catch(done.fail); - }); }); describe('non-existent branch', () => { @@ -342,9 +412,8 @@ describe('IDE store project actions', () => { openBranch(store, branch) .then(() => { expect(store.dispatch.calls.allArgs()).toEqual([ - ['setCurrentBranchId', branch.branchId], - ['getBranchData', branch], - ['showBranchNotFoundError', branch.branchId], + ['setCurrentBranchId', branchId], + ['loadBranch', { projectId, branchId }], ]); }) .then(done) diff --git a/spec/javascripts/ide/stores/actions_spec.js b/spec/javascripts/ide/stores/actions_spec.js index 8504fb3f42b..7e77b859fdd 100644 --- a/spec/javascripts/ide/stores/actions_spec.js +++ b/spec/javascripts/ide/stores/actions_spec.js @@ -13,12 +13,15 @@ import actions, { createTempEntry, } from '~/ide/stores/actions'; import axios from '~/lib/utils/axios_utils'; -import store from '~/ide/stores'; +import { createStore } from '~/ide/stores'; import * as types from '~/ide/stores/mutation_types'; import router from '~/ide/ide_router'; import { resetStore, file } from '../helpers'; import testAction from '../../helpers/vuex_action_helper'; import MockAdapter from 'axios-mock-adapter'; +import eventHub from '~/ide/eventhub'; + +const store = createStore(); describe('Multi-file store actions', () => { beforeEach(() => { @@ -451,6 +454,24 @@ describe('Multi-file store actions', () => { done, ); }); + + it('does not dispatch for parent, if parent does not exist', done => { + const f = { + ...file(), + path: 'test', + parentPath: 'testing', + }; + store.state.entries[f.path] = f; + + testAction( + updateTempFlagForEntry, + { file: f, tempFile: false }, + store.state, + [{ type: 'UPDATE_TEMP_FLAG', payload: { path: f.path, tempFile: false } }], + [], + done, + ); + }); }); describe('setCurrentBranchId', () => { @@ -540,82 +561,298 @@ describe('Multi-file store actions', () => { done, ); }); - }); - describe('renameEntry', () => { - it('renames entry', done => { - store.state.entries.test = { - tree: [], + it('if renamed, reverts the rename before deleting', () => { + const testEntry = { + path: 'test', + name: 'test', + prevPath: 'lorem/ipsum', + prevName: 'ipsum', + prevParentPath: 'lorem', }; + store.state.entries = { test: testEntry }; testAction( - renameEntry, - { path: 'test', name: 'new-name', entryPath: null, parentPath: 'parent-path' }, + deleteEntry, + testEntry.path, store.state, + [], [ { - type: types.RENAME_ENTRY, - payload: { path: 'test', name: 'new-name', entryPath: null, parentPath: 'parent-path' }, - }, - { - type: types.TOGGLE_FILE_CHANGED, + type: 'renameEntry', payload: { - file: store.state.entries['parent-path/new-name'], - changed: true, + path: testEntry.path, + name: testEntry.prevName, + parentPath: testEntry.prevParentPath, }, }, + { + type: 'deleteEntry', + payload: testEntry.prevPath, + }, ], - [{ type: 'triggerFilesChange' }], - done, ); }); + }); - it('renames all entries in tree', done => { - store.state.entries.test = { - type: 'tree', - tree: [ - { - path: 'tree-1', - }, - { - path: 'tree-2', + describe('renameEntry', () => { + describe('purging of file model cache', () => { + beforeEach(() => { + spyOn(eventHub, '$emit'); + }); + + it('does not purge model cache for temporary entries that got renamed', done => { + Object.assign(store.state.entries, { + test: { + ...file('test'), + key: 'foo-key', + type: 'blob', + tempFile: true, }, - ], - }; + }); - testAction( - renameEntry, - { path: 'test', name: 'new-name', parentPath: 'parent-path' }, - store.state, - [ - { - type: types.RENAME_ENTRY, - payload: { path: 'test', name: 'new-name', entryPath: null, parentPath: 'parent-path' }, + store + .dispatch('renameEntry', { + path: 'test', + name: 'new', + }) + .then(() => { + expect(eventHub.$emit.calls.allArgs()).not.toContain( + 'editor.update.model.dispose.foo-bar', + ); + }) + .then(done) + .catch(done.fail); + }); + + it('purges model cache for renamed entry', done => { + Object.assign(store.state.entries, { + test: { + ...file('test'), + key: 'foo-key', + type: 'blob', + tempFile: false, }, - ], - [ - { - type: 'renameEntry', - payload: { - path: 'test', - name: 'new-name', - entryPath: 'tree-1', - parentPath: 'parent-path/new-name', + }); + + store + .dispatch('renameEntry', { + path: 'test', + name: 'new', + }) + .then(() => { + expect(eventHub.$emit).toHaveBeenCalled(); + expect(eventHub.$emit).toHaveBeenCalledWith(`editor.update.model.dispose.foo-key`); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('single entry', () => { + let origEntry; + let renamedEntry; + + beforeEach(() => { + // Need to insert both because `testAction` doesn't actually call the mutation + origEntry = file('orig', 'orig', 'blob'); + renamedEntry = { + ...file('renamed', 'renamed', 'blob'), + prevKey: origEntry.key, + prevName: origEntry.name, + prevPath: origEntry.path, + }; + + Object.assign(store.state.entries, { + orig: origEntry, + renamed: renamedEntry, + }); + }); + + afterEach(() => { + resetStore(store); + }); + + it('by default renames an entry and adds to changed', done => { + testAction( + renameEntry, + { path: 'orig', name: 'renamed' }, + store.state, + [ + { + type: types.RENAME_ENTRY, + payload: { + path: 'orig', + name: 'renamed', + parentPath: undefined, + }, }, - }, - { - type: 'renameEntry', - payload: { - path: 'test', - name: 'new-name', - entryPath: 'tree-2', - parentPath: 'parent-path/new-name', + { + type: types.ADD_FILE_TO_CHANGED, + payload: 'renamed', + }, + ], + [{ type: 'triggerFilesChange' }], + done, + ); + }); + + it('if not changed, completely unstages entry if renamed to original', done => { + testAction( + renameEntry, + { path: 'renamed', name: 'orig' }, + store.state, + [ + { + type: types.RENAME_ENTRY, + payload: { + path: 'renamed', + name: 'orig', + parentPath: undefined, + }, + }, + { + type: types.REMOVE_FILE_FROM_STAGED_AND_CHANGED, + payload: origEntry, + }, + ], + [{ type: 'triggerFilesChange' }], + done, + ); + }); + + it('if already in changed, does not add to change', done => { + store.state.changedFiles.push(renamedEntry); + + testAction( + renameEntry, + { path: 'orig', name: 'renamed' }, + store.state, + [jasmine.objectContaining({ type: types.RENAME_ENTRY })], + [{ type: 'triggerFilesChange' }], + done, + ); + }); + + it('routes to the renamed file if the original file has been opened', done => { + Object.assign(store.state.entries.orig, { + opened: true, + url: '/foo-bar.md', + }); + + store + .dispatch('renameEntry', { + path: 'orig', + name: 'renamed', + }) + .then(() => { + expect(router.push.calls.count()).toBe(1); + expect(router.push).toHaveBeenCalledWith(`/project/foo-bar.md`); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('folder', () => { + let folder; + let file1; + let file2; + + beforeEach(() => { + folder = file('folder', 'folder', 'tree'); + file1 = file('file-1', 'file-1', 'blob', folder); + file2 = file('file-2', 'file-2', 'blob', folder); + + folder.tree = [file1, file2]; + + Object.assign(store.state.entries, { + [folder.path]: folder, + [file1.path]: file1, + [file2.path]: file2, + }); + }); + + it('updates entries in a folder correctly, when folder is renamed', done => { + store + .dispatch('renameEntry', { + path: 'folder', + name: 'new-folder', + }) + .then(() => { + const keys = Object.keys(store.state.entries); + + expect(keys.length).toBe(3); + expect(keys.indexOf('new-folder')).toBe(0); + expect(keys.indexOf('new-folder/file-1')).toBe(1); + expect(keys.indexOf('new-folder/file-2')).toBe(2); + }) + .then(done) + .catch(done.fail); + }); + + it('discards renaming of an entry if the root folder is renamed back to a previous name', done => { + const rootFolder = file('old-folder', 'old-folder', 'tree'); + const testEntry = file('test', 'test', 'blob', rootFolder); + + Object.assign(store.state, { + entries: { + 'old-folder': { + ...rootFolder, + tree: [testEntry], }, + 'old-folder/test': testEntry, }, - { type: 'triggerFilesChange' }, - ], - done, - ); + }); + + store + .dispatch('renameEntry', { + path: 'old-folder', + name: 'new-folder', + }) + .then(() => { + const { entries } = store.state; + + expect(Object.keys(entries).length).toBe(2); + expect(entries['old-folder']).toBeUndefined(); + expect(entries['old-folder/test']).toBeUndefined(); + + expect(entries['new-folder']).toBeDefined(); + expect(entries['new-folder/test']).toEqual( + jasmine.objectContaining({ + path: 'new-folder/test', + name: 'test', + prevPath: 'old-folder/test', + prevName: 'test', + }), + ); + }) + .then(() => + store.dispatch('renameEntry', { + path: 'new-folder', + name: 'old-folder', + }), + ) + .then(() => { + const { entries } = store.state; + + expect(Object.keys(entries).length).toBe(2); + expect(entries['new-folder']).toBeUndefined(); + expect(entries['new-folder/test']).toBeUndefined(); + + expect(entries['old-folder']).toBeDefined(); + expect(entries['old-folder/test']).toEqual( + jasmine.objectContaining({ + path: 'old-folder/test', + name: 'test', + prevPath: undefined, + prevName: undefined, + }), + ); + }) + .then(done) + .catch(done.fail); + }); }); }); diff --git a/spec/javascripts/ide/stores/modules/commit/actions_spec.js b/spec/javascripts/ide/stores/modules/commit/actions_spec.js index ffb97c85326..95d927065f0 100644 --- a/spec/javascripts/ide/stores/modules/commit/actions_spec.js +++ b/spec/javascripts/ide/stores/modules/commit/actions_spec.js @@ -1,5 +1,5 @@ import rootActions from '~/ide/stores/actions'; -import store from '~/ide/stores'; +import { createStore } from '~/ide/stores'; import service from '~/ide/services'; import router from '~/ide/ide_router'; import eventHub from '~/ide/eventhub'; @@ -11,6 +11,7 @@ import { resetStore, file } from 'spec/ide/helpers'; import testAction from '../../../../helpers/vuex_action_helper'; const TEST_COMMIT_SHA = '123456789'; +const store = createStore(); describe('IDE commit module actions', () => { beforeEach(() => { @@ -59,7 +60,9 @@ describe('IDE commit module actions', () => { }); it('sets shouldCreateMR to true if "Create new MR" option is visible', done => { - store.state.shouldHideNewMrOption = false; + Object.assign(store.state, { + shouldHideNewMrOption: false, + }); testAction( actions.updateCommitAction, @@ -78,7 +81,9 @@ describe('IDE commit module actions', () => { }); it('sets shouldCreateMR to false if "Create new MR" option is hidden', done => { - store.state.shouldHideNewMrOption = true; + Object.assign(store.state, { + shouldHideNewMrOption: true, + }); testAction( actions.updateCommitAction, @@ -172,24 +177,31 @@ describe('IDE commit module actions', () => { content: 'file content', }); - store.state.currentProjectId = 'abcproject'; - store.state.currentBranchId = 'master'; - store.state.projects.abcproject = { - web_url: 'web_url', - branches: { - master: { - workingReference: '', - commit: { - short_id: TEST_COMMIT_SHA, + Object.assign(store.state, { + currentProjectId: 'abcproject', + currentBranchId: 'master', + projects: { + abcproject: { + web_url: 'web_url', + branches: { + master: { + workingReference: '', + commit: { + short_id: TEST_COMMIT_SHA, + }, + }, }, }, }, - }; - store.state.stagedFiles.push(f, { - ...file('changedFile2'), - changed: true, + stagedFiles: [ + f, + { + ...file('changedFile2'), + changed: true, + }, + ], + openFiles: store.state.stagedFiles, }); - store.state.openFiles = store.state.stagedFiles; store.state.stagedFiles.forEach(stagedFile => { store.state.entries[stagedFile.path] = stagedFile; @@ -275,40 +287,40 @@ describe('IDE commit module actions', () => { document.body.innerHTML += '<div class="flash-container"></div>'; - store.state.currentProjectId = 'abcproject'; - store.state.currentBranchId = 'master'; - store.state.projects.abcproject = { - web_url: 'webUrl', - branches: { - master: { - workingReference: '1', - commit: { - id: TEST_COMMIT_SHA, - }, - }, - }, - }; - const f = { ...file('changed'), type: 'blob', active: true, lastCommitSha: TEST_COMMIT_SHA, }; - store.state.stagedFiles.push(f); - store.state.changedFiles = [ - { - ...f, - }, - ]; - store.state.openFiles = store.state.changedFiles; - store.state.openFiles.forEach(localF => { - store.state.entries[localF.path] = localF; + Object.assign(store.state, { + stagedFiles: [f], + changedFiles: [f], + openFiles: [f], + currentProjectId: 'abcproject', + currentBranchId: 'master', + projects: { + abcproject: { + web_url: 'webUrl', + branches: { + master: { + workingReference: '1', + commit: { + id: TEST_COMMIT_SHA, + }, + }, + }, + }, + }, }); store.state.commit.commitAction = '2'; store.state.commit.commitMessage = 'testing 123'; + + store.state.openFiles.forEach(localF => { + store.state.entries[localF.path] = localF; + }); }); afterEach(() => { diff --git a/spec/javascripts/ide/stores/mutations/file_spec.js b/spec/javascripts/ide/stores/mutations/file_spec.js index 064e66cef64..7c46bf55318 100644 --- a/spec/javascripts/ide/stores/mutations/file_spec.js +++ b/spec/javascripts/ide/stores/mutations/file_spec.js @@ -356,16 +356,16 @@ describe('IDE store file mutations', () => { }); describe('STAGE_CHANGE', () => { - it('adds file into stagedFiles array', () => { + beforeEach(() => { mutations.STAGE_CHANGE(localState, localFile.path); + }); + it('adds file into stagedFiles array', () => { expect(localState.stagedFiles.length).toBe(1); expect(localState.stagedFiles[0]).toEqual(localFile); }); it('updates stagedFile if it is already staged', () => { - mutations.STAGE_CHANGE(localState, localFile.path); - localFile.raw = 'testing 123'; mutations.STAGE_CHANGE(localState, localFile.path); @@ -373,19 +373,6 @@ describe('IDE store file mutations', () => { expect(localState.stagedFiles.length).toBe(1); expect(localState.stagedFiles[0].raw).toEqual('testing 123'); }); - - it('adds already-staged file to `replacedFiles`', () => { - localFile.raw = 'already-staged'; - - mutations.STAGE_CHANGE(localState, localFile.path); - - localFile.raw = 'testing 123'; - - mutations.STAGE_CHANGE(localState, localFile.path); - - expect(localState.replacedFiles.length).toBe(1); - expect(localState.replacedFiles[0].raw).toEqual('already-staged'); - }); }); describe('UNSTAGE_CHANGE', () => { diff --git a/spec/javascripts/ide/stores/mutations_spec.js b/spec/javascripts/ide/stores/mutations_spec.js index 2470c99e300..7dd5d323f69 100644 --- a/spec/javascripts/ide/stores/mutations_spec.js +++ b/spec/javascripts/ide/stores/mutations_spec.js @@ -79,16 +79,6 @@ describe('Multi-file store mutations', () => { }); }); - describe('CLEAR_REPLACED_FILES', () => { - it('clears replacedFiles array', () => { - localState.replacedFiles.push('a'); - - mutations.CLEAR_REPLACED_FILES(localState); - - expect(localState.replacedFiles.length).toBe(0); - }); - }); - describe('UPDATE_VIEWER', () => { it('sets viewer state', () => { mutations.UPDATE_VIEWER(localState, 'diff'); @@ -311,8 +301,7 @@ describe('Multi-file store mutations', () => { describe('UPDATE_FILE_AFTER_COMMIT', () => { it('updates URLs if prevPath is set', () => { const f = { - ...file(), - path: 'test', + ...file('test'), prevPath: 'testing-123', rawPath: `${gl.TEST_HOST}/testing-123`, permalink: `${gl.TEST_HOST}/testing-123`, @@ -325,19 +314,26 @@ describe('Multi-file store mutations', () => { mutations.UPDATE_FILE_AFTER_COMMIT(localState, { file: f, lastCommit: { commit: {} } }); - expect(f.rawPath).toBe(`${gl.TEST_HOST}/test`); - expect(f.permalink).toBe(`${gl.TEST_HOST}/test`); - expect(f.commitsPath).toBe(`${gl.TEST_HOST}/test`); - expect(f.blamePath).toBe(`${gl.TEST_HOST}/test`); - expect(f.replaces).toBe(false); + expect(f).toEqual( + jasmine.objectContaining({ + rawPath: `${gl.TEST_HOST}/test`, + permalink: `${gl.TEST_HOST}/test`, + commitsPath: `${gl.TEST_HOST}/test`, + blamePath: `${gl.TEST_HOST}/test`, + replaces: false, + prevId: undefined, + prevPath: undefined, + prevName: undefined, + prevUrl: undefined, + prevKey: undefined, + }), + ); }); }); describe('OPEN_NEW_ENTRY_MODAL', () => { it('sets entryModal', () => { - localState.entries.testPath = { - ...file(), - }; + localState.entries.testPath = file(); mutations.OPEN_NEW_ENTRY_MODAL(localState, { type: 'test', path: 'testPath' }); @@ -356,58 +352,178 @@ describe('Multi-file store mutations', () => { }; localState.currentProjectId = 'gitlab-ce'; localState.currentBranchId = 'master'; - localState.entries.oldPath = { - ...file(), - type: 'blob', - name: 'oldPath', - path: 'oldPath', - url: `${gl.TEST_HOST}/oldPath`, + localState.entries = { + oldPath: file('oldPath', 'oldPath', 'blob'), }; }); - it('creates new renamed entry', () => { + it('updates existing entry without creating a new one', () => { + mutations.RENAME_ENTRY(localState, { + path: 'oldPath', + name: 'newPath', + parentPath: '', + }); + + expect(localState.entries).toEqual({ + newPath: jasmine.objectContaining({ + path: 'newPath', + prevPath: 'oldPath', + }), + }); + }); + + it('correctly handles consecutive renames for the same entry', () => { mutations.RENAME_ENTRY(localState, { path: 'oldPath', name: 'newPath', + parentPath: '', + }); + + mutations.RENAME_ENTRY(localState, { + path: 'newPath', + name: 'newestPath', + parentPath: '', + }); + + expect(localState.entries).toEqual({ + newestPath: jasmine.objectContaining({ + path: 'newestPath', + prevPath: 'oldPath', + }), + }); + }); + + it('correctly handles the same entry within a consecutively renamed folder', () => { + const oldPath = file('root-folder/oldPath', 'root-folder/oldPath', 'blob'); + localState.entries = { + 'root-folder': { + ...file('root-folder', 'root-folder', 'tree'), + tree: [oldPath], + }, + 'root-folder/oldPath': oldPath, + }; + Object.assign(localState.entries['root-folder/oldPath'], { + parentPath: 'root-folder', + url: 'root-folder/oldPath-blob-root-folder/oldPath', + }); + + mutations.RENAME_ENTRY(localState, { + path: 'root-folder/oldPath', + name: 'renamed-folder/oldPath', entryPath: null, parentPath: '', }); + mutations.RENAME_ENTRY(localState, { + path: 'renamed-folder/oldPath', + name: 'simply-renamed/oldPath', + entryPath: null, + parentPath: '', + }); + + expect(localState.entries).toEqual({ + 'root-folder': jasmine.objectContaining({ + path: 'root-folder', + }), + 'simply-renamed/oldPath': jasmine.objectContaining({ + path: 'simply-renamed/oldPath', + prevPath: 'root-folder/oldPath', + }), + }); + }); + + it('renames entry, preserving old parameters', () => { + Object.assign(localState.entries.oldPath, { + url: `project/-/oldPath`, + }); + const oldPathData = localState.entries.oldPath; + + mutations.RENAME_ENTRY(localState, { + path: 'oldPath', + name: 'newPath', + parentPath: '', + }); + expect(localState.entries.newPath).toEqual({ - ...localState.entries.oldPath, + ...oldPathData, id: 'newPath', - name: 'newPath', - key: 'newPath-blob-oldPath', path: 'newPath', - tempFile: true, + name: 'newPath', + url: `project/-/newPath`, + key: jasmine.stringMatching('newPath'), + + prevId: 'oldPath', + prevName: 'oldPath', prevPath: 'oldPath', - tree: [], - parentPath: '', - url: `${gl.TEST_HOST}/newPath`, - moved: jasmine.anything(), - movedPath: jasmine.anything(), - opened: false, + prevUrl: `project/-/oldPath`, + prevKey: oldPathData.key, + prevParentPath: oldPathData.parentPath, }); }); - it('adds new entry to changedFiles', () => { - mutations.RENAME_ENTRY(localState, { path: 'oldPath', name: 'newPath' }); + it('does not store previous attributes on temp files', () => { + Object.assign(localState.entries.oldPath, { + tempFile: true, + }); + mutations.RENAME_ENTRY(localState, { + path: 'oldPath', + name: 'newPath', + entryPath: null, + parentPath: '', + }); - expect(localState.changedFiles.length).toBe(1); - expect(localState.changedFiles[0].path).toBe('newPath'); - }); + expect(localState.entries.newPath).not.toEqual( + jasmine.objectContaining({ + prevId: jasmine.anything(), + prevName: jasmine.anything(), + prevPath: jasmine.anything(), + prevUrl: jasmine.anything(), + prevKey: jasmine.anything(), + prevParentPath: jasmine.anything(), + }), + ); + }); + + it('properly handles files with spaces in name', () => { + const path = 'my fancy path'; + const newPath = 'new path'; + const oldEntry = { + ...file(path, path, 'blob'), + url: `project/-/${encodeURI(path)}`, + }; - it('sets oldEntry as moved', () => { - mutations.RENAME_ENTRY(localState, { path: 'oldPath', name: 'newPath' }); + localState.entries[path] = oldEntry; - expect(localState.entries.oldPath.moved).toBe(true); + mutations.RENAME_ENTRY(localState, { + path, + name: newPath, + entryPath: null, + parentPath: '', + }); + + expect(localState.entries[newPath]).toEqual({ + ...oldEntry, + id: newPath, + path: newPath, + name: newPath, + url: `project/-/new%20path`, + key: jasmine.stringMatching(newPath), + + prevId: path, + prevName: path, + prevPath: path, + prevUrl: `project/-/my%20fancy%20path`, + prevKey: oldEntry.key, + prevParentPath: oldEntry.parentPath, + }); }); - it('adds to parents tree', () => { - localState.entries.oldPath.parentPath = 'parentPath'; - localState.entries.parentPath = { - ...file(), + it('adds to parent tree', () => { + const parentEntry = { + ...file('parentPath', 'parentPath', 'tree'), + tree: [localState.entries.oldPath], }; + localState.entries.parentPath = parentEntry; mutations.RENAME_ENTRY(localState, { path: 'oldPath', @@ -416,7 +532,180 @@ describe('Multi-file store mutations', () => { parentPath: 'parentPath', }); - expect(localState.entries.parentPath.tree.length).toBe(1); + expect(parentEntry.tree.length).toBe(1); + expect(parentEntry.tree[0].name).toBe('newPath'); + }); + + it('sorts tree after renaming an entry', () => { + const alpha = file('alpha', 'alpha', 'blob'); + const beta = file('beta', 'beta', 'blob'); + const gamma = file('gamma', 'gamma', 'blob'); + localState.entries = { alpha, beta, gamma }; + + localState.trees['gitlab-ce/master'].tree = [alpha, beta, gamma]; + + mutations.RENAME_ENTRY(localState, { + path: 'alpha', + name: 'theta', + entryPath: null, + parentPath: '', + }); + + expect(localState.trees['gitlab-ce/master'].tree).toEqual([ + jasmine.objectContaining({ name: 'beta' }), + jasmine.objectContaining({ name: 'gamma' }), + jasmine.objectContaining({ + path: 'theta', + name: 'theta', + }), + ]); + }); + + it('updates openFiles with the renamed one if the original one is open', () => { + Object.assign(localState.entries.oldPath, { + opened: true, + type: 'blob', + }); + Object.assign(localState, { + openFiles: [localState.entries.oldPath], + }); + + mutations.RENAME_ENTRY(localState, { path: 'oldPath', name: 'newPath' }); + + expect(localState.openFiles.length).toBe(1); + expect(localState.openFiles[0].path).toBe('newPath'); + }); + + it('does not add renamed entry to changedFiles', () => { + mutations.RENAME_ENTRY(localState, { path: 'oldPath', name: 'newPath' }); + + expect(localState.changedFiles.length).toBe(0); + }); + + it('updates existing changedFiles entry with the renamed one', () => { + const origFile = { + ...file('oldPath', 'oldPath', 'blob'), + content: 'Foo', + }; + + Object.assign(localState, { + changedFiles: [origFile], + }); + Object.assign(localState.entries, { + oldPath: origFile, + }); + + mutations.RENAME_ENTRY(localState, { path: 'oldPath', name: 'newPath' }); + + expect(localState.changedFiles).toEqual([ + jasmine.objectContaining({ + path: 'newPath', + content: 'Foo', + }), + ]); + }); + + it('correctly saves original values if an entry is renamed multiple times', () => { + const original = { ...localState.entries.oldPath }; + const paramsToCheck = ['prevId', 'prevPath', 'prevName', 'prevUrl']; + const expectedObj = paramsToCheck.reduce( + (o, param) => ({ ...o, [param]: original[param.replace('prev', '').toLowerCase()] }), + {}, + ); + + mutations.RENAME_ENTRY(localState, { path: 'oldPath', name: 'newPath' }); + + expect(localState.entries.newPath).toEqual(jasmine.objectContaining(expectedObj)); + + mutations.RENAME_ENTRY(localState, { path: 'newPath', name: 'newer' }); + + expect(localState.entries.newer).toEqual(jasmine.objectContaining(expectedObj)); + }); + + describe('renaming back to original', () => { + beforeEach(() => { + const renamedEntry = { + ...file('renamed', 'renamed', 'blob'), + prevId: 'lorem/orig', + prevPath: 'lorem/orig', + prevName: 'orig', + prevUrl: 'project/-/loren/orig', + prevKey: 'lorem/orig', + prevParentPath: 'lorem', + }; + + localState.entries = { + renamed: renamedEntry, + }; + + mutations.RENAME_ENTRY(localState, { path: 'renamed', name: 'orig', parentPath: 'lorem' }); + }); + + it('renames entry and clears prev properties', () => { + expect(localState.entries).toEqual({ + 'lorem/orig': jasmine.objectContaining({ + id: 'lorem/orig', + path: 'lorem/orig', + name: 'orig', + prevId: undefined, + prevPath: undefined, + prevName: undefined, + prevUrl: undefined, + prevKey: undefined, + prevParentPath: undefined, + }), + }); + }); + }); + + describe('key updates', () => { + beforeEach(() => { + const rootFolder = file('rootFolder', 'rootFolder', 'tree'); + localState.entries = { + rootFolder, + oldPath: file('oldPath', 'oldPath', 'blob'), + 'oldPath.txt': file('oldPath.txt', 'oldPath.txt', 'blob'), + 'rootFolder/oldPath.md': file('oldPath.md', 'oldPath.md', 'blob', rootFolder), + }; + }); + + it('sets properly constucted key while preserving the original one', () => { + const key = 'oldPath.txt-blob-oldPath.txt'; + localState.entries['oldPath.txt'].key = key; + mutations.RENAME_ENTRY(localState, { path: 'oldPath.txt', name: 'newPath.md' }); + + expect(localState.entries['newPath.md'].key).toBe('newPath.md-blob-newPath.md'); + expect(localState.entries['newPath.md'].prevKey).toBe(key); + }); + + it('correctly updates key for an entry without an extension', () => { + localState.entries.oldPath.key = 'oldPath-blob-oldPath'; + mutations.RENAME_ENTRY(localState, { path: 'oldPath', name: 'newPath.md' }); + + expect(localState.entries['newPath.md'].key).toBe('newPath.md-blob-newPath.md'); + }); + + it('correctly updates key when new name does not have an extension', () => { + localState.entries['oldPath.txt'].key = 'oldPath.txt-blob-oldPath.txt'; + mutations.RENAME_ENTRY(localState, { path: 'oldPath.txt', name: 'newPath' }); + + expect(localState.entries.newPath.key).toBe('newPath-blob-newPath'); + }); + + it('correctly updates key when renaming an entry in a folder', () => { + localState.entries['rootFolder/oldPath.md'].key = + 'rootFolder/oldPath.md-blob-rootFolder/oldPath.md'; + mutations.RENAME_ENTRY(localState, { + path: 'rootFolder/oldPath.md', + name: 'newPath.md', + entryPath: null, + parentPath: 'rootFolder', + }); + + expect(localState.entries['rootFolder/newPath.md'].key).toBe( + 'rootFolder/newPath.md-blob-rootFolder/newPath.md', + ); + }); }); }); }); diff --git a/spec/javascripts/ide/stores/utils_spec.js b/spec/javascripts/ide/stores/utils_spec.js index 0fc9519a6bf..a477d4fc200 100644 --- a/spec/javascripts/ide/stores/utils_spec.js +++ b/spec/javascripts/ide/stores/utils_spec.js @@ -237,31 +237,6 @@ describe('Multi-file store utils', () => { }); describe('getCommitFiles', () => { - it('returns list of files excluding moved files', () => { - const files = [ - { - path: 'a', - type: 'blob', - deleted: true, - }, - { - path: 'c', - type: 'blob', - moved: true, - }, - ]; - - const flattendFiles = utils.getCommitFiles(files); - - expect(flattendFiles).toEqual([ - { - path: 'a', - type: 'blob', - deleted: true, - }, - ]); - }); - it('filters out folders from the list', () => { const files = [ { @@ -422,4 +397,204 @@ describe('Multi-file store utils', () => { expect(res[1].tree[0].opened).toEqual(true); }); }); + + describe('escapeFileUrl', () => { + it('encodes URL excluding the slashes', () => { + expect(utils.escapeFileUrl('/foo-bar/file.md')).toBe('/foo-bar/file.md'); + expect(utils.escapeFileUrl('foo bar/file.md')).toBe('foo%20bar/file.md'); + expect(utils.escapeFileUrl('foo/bar/file.md')).toBe('foo/bar/file.md'); + }); + }); + + describe('swapInStateArray', () => { + let localState; + + beforeEach(() => { + localState = []; + }); + + it('swaps existing entry with a new one', () => { + const file1 = { + ...file('old'), + key: 'foo', + }; + const file2 = file('new'); + const arr = [file1]; + + Object.assign(localState, { + dummyArray: arr, + entries: { + new: file2, + }, + }); + + utils.swapInStateArray(localState, 'dummyArray', 'foo', 'new'); + + expect(localState.dummyArray.length).toBe(1); + expect(localState.dummyArray[0]).toBe(file2); + }); + + it('does not add an item if it does not exist yet in array', () => { + const file1 = file('file'); + Object.assign(localState, { + dummyArray: [], + entries: { + file: file1, + }, + }); + + utils.swapInStateArray(localState, 'dummyArray', 'foo', 'file'); + + expect(localState.dummyArray.length).toBe(0); + }); + }); + + describe('swapInParentTreeWithSorting', () => { + let localState; + let branchInfo; + const currentProjectId = '123-foo'; + const currentBranchId = 'master'; + + beforeEach(() => { + localState = { + currentBranchId, + currentProjectId, + trees: { + [`${currentProjectId}/${currentBranchId}`]: { + tree: [], + }, + }, + entries: { + oldPath: file('oldPath', 'oldPath', 'blob'), + newPath: file('newPath', 'newPath', 'blob'), + parentPath: file('parentPath', 'parentPath', 'tree'), + }, + }; + branchInfo = localState.trees[`${currentProjectId}/${currentBranchId}`]; + }); + + it('does not change tree if newPath is not supplied', () => { + branchInfo.tree = [localState.entries.oldPath]; + + utils.swapInParentTreeWithSorting(localState, 'oldPath', undefined, undefined); + + expect(branchInfo.tree).toEqual([localState.entries.oldPath]); + }); + + describe('oldPath to replace is not defined: simple addition to tree', () => { + it('adds to tree on the state if there is no parent for the entry', () => { + expect(branchInfo.tree.length).toBe(0); + + utils.swapInParentTreeWithSorting(localState, undefined, 'oldPath', undefined); + + expect(branchInfo.tree.length).toBe(1); + expect(branchInfo.tree[0].name).toBe('oldPath'); + + utils.swapInParentTreeWithSorting(localState, undefined, 'newPath', undefined); + + expect(branchInfo.tree.length).toBe(2); + expect(branchInfo.tree).toEqual([ + jasmine.objectContaining({ name: 'newPath' }), + jasmine.objectContaining({ name: 'oldPath' }), + ]); + }); + + it('adds to parent tree if it is supplied', () => { + utils.swapInParentTreeWithSorting(localState, undefined, 'newPath', 'parentPath'); + + expect(localState.entries.parentPath.tree.length).toBe(1); + expect(localState.entries.parentPath.tree).toEqual([ + jasmine.objectContaining({ name: 'newPath' }), + ]); + + localState.entries.parentPath.tree = [localState.entries.oldPath]; + + utils.swapInParentTreeWithSorting(localState, undefined, 'newPath', 'parentPath'); + + expect(localState.entries.parentPath.tree.length).toBe(2); + expect(localState.entries.parentPath.tree).toEqual([ + jasmine.objectContaining({ name: 'newPath' }), + jasmine.objectContaining({ name: 'oldPath' }), + ]); + }); + }); + + describe('swapping of the items', () => { + it('swaps entries if both paths are supplied', () => { + branchInfo.tree = [localState.entries.oldPath]; + + utils.swapInParentTreeWithSorting(localState, localState.entries.oldPath.key, 'newPath'); + + expect(branchInfo.tree).toEqual([jasmine.objectContaining({ name: 'newPath' })]); + + utils.swapInParentTreeWithSorting(localState, localState.entries.newPath.key, 'oldPath'); + + expect(branchInfo.tree).toEqual([jasmine.objectContaining({ name: 'oldPath' })]); + }); + + it('sorts tree after swapping the entries', () => { + const alpha = file('alpha', 'alpha', 'blob'); + const beta = file('beta', 'beta', 'blob'); + const gamma = file('gamma', 'gamma', 'blob'); + const theta = file('theta', 'theta', 'blob'); + localState.entries = { alpha, beta, gamma, theta }; + + branchInfo.tree = [alpha, beta, gamma]; + + utils.swapInParentTreeWithSorting(localState, alpha.key, 'theta'); + + expect(branchInfo.tree).toEqual([ + jasmine.objectContaining({ name: 'beta' }), + jasmine.objectContaining({ name: 'gamma' }), + jasmine.objectContaining({ name: 'theta' }), + ]); + + utils.swapInParentTreeWithSorting(localState, gamma.key, 'alpha'); + + expect(branchInfo.tree).toEqual([ + jasmine.objectContaining({ name: 'alpha' }), + jasmine.objectContaining({ name: 'beta' }), + jasmine.objectContaining({ name: 'theta' }), + ]); + + utils.swapInParentTreeWithSorting(localState, beta.key, 'gamma'); + + expect(branchInfo.tree).toEqual([ + jasmine.objectContaining({ name: 'alpha' }), + jasmine.objectContaining({ name: 'gamma' }), + jasmine.objectContaining({ name: 'theta' }), + ]); + }); + }); + }); + + describe('cleanTrailingSlash', () => { + [ + { input: '', output: '' }, + { input: 'abc', output: 'abc' }, + { input: 'abc/', output: 'abc' }, + { input: 'abc/def', output: 'abc/def' }, + { input: 'abc/def/', output: 'abc/def' }, + ].forEach(({ input, output }) => { + it(`cleans trailing slash from string "${input}"`, () => { + expect(utils.cleanTrailingSlash(input)).toEqual(output); + }); + }); + }); + + describe('pathsAreEqual', () => { + [ + { args: ['abc', 'abc'], output: true }, + { args: ['abc', 'def'], output: false }, + { args: ['abc/', 'abc'], output: true }, + { args: ['abc/abc', 'abc'], output: false }, + { args: ['/', ''], output: true }, + { args: ['', '/'], output: true }, + { args: [false, '/'], output: true }, + ].forEach(({ args, output }) => { + it(`cleans and tests equality (${JSON.stringify(args)})`, () => { + expect(utils.pathsAreEqual(...args)).toEqual(output); + }); + }); + }); }); diff --git a/spec/javascripts/lazy_loader_spec.js b/spec/javascripts/lazy_loader_spec.js index f3fb792c62d..82ab73c2170 100644 --- a/spec/javascripts/lazy_loader_spec.js +++ b/spec/javascripts/lazy_loader_spec.js @@ -62,7 +62,7 @@ describe('LazyLoader', function() { waitForAttributeChange(newImg, ['data-src', 'src']), ]) .then(() => { - expect(LazyLoader.loadImage).toHaveBeenCalled(); + expect(LazyLoader.loadImage).toHaveBeenCalledWith(newImg); expect(newImg.getAttribute('src')).toBe(testPath); expect(newImg).toHaveClass('js-lazy-loaded'); done(); @@ -79,7 +79,7 @@ describe('LazyLoader', function() { scrollIntoViewPromise(newImg) .then(waitForPromises) .then(() => { - expect(LazyLoader.loadImage).not.toHaveBeenCalled(); + expect(LazyLoader.loadImage).not.toHaveBeenCalledWith(newImg); expect(newImg).not.toHaveClass('js-lazy-loaded'); done(); }) @@ -98,7 +98,7 @@ describe('LazyLoader', function() { scrollIntoViewPromise(newImg) .then(waitForPromises) .then(() => { - expect(LazyLoader.loadImage).not.toHaveBeenCalled(); + expect(LazyLoader.loadImage).not.toHaveBeenCalledWith(newImg); expect(newImg).not.toHaveClass('js-lazy-loaded'); done(); }) @@ -121,7 +121,7 @@ describe('LazyLoader', function() { ]) .then(waitForPromises) .then(() => { - expect(LazyLoader.loadImage).toHaveBeenCalled(); + expect(LazyLoader.loadImage).toHaveBeenCalledWith(newImg); expect(newImg).toHaveClass('js-lazy-loaded'); done(); }) @@ -156,7 +156,7 @@ describe('LazyLoader', function() { Promise.all([scrollIntoViewPromise(img), waitForAttributeChange(img, ['data-src', 'src'])]) .then(() => { - expect(LazyLoader.loadImage).toHaveBeenCalled(); + expect(LazyLoader.loadImage).toHaveBeenCalledWith(img); expect(img.getAttribute('src')).toBe(originalDataSrc); expect(img).toHaveClass('js-lazy-loaded'); done(); @@ -176,7 +176,7 @@ describe('LazyLoader', function() { waitForAttributeChange(newImg, ['data-src', 'src']), ]) .then(() => { - expect(LazyLoader.loadImage).toHaveBeenCalled(); + expect(LazyLoader.loadImage).toHaveBeenCalledWith(newImg); expect(newImg.getAttribute('src')).toBe(testPath); expect(newImg).toHaveClass('js-lazy-loaded'); done(); @@ -193,7 +193,7 @@ describe('LazyLoader', function() { scrollIntoViewPromise(newImg) .then(waitForPromises) .then(() => { - expect(LazyLoader.loadImage).not.toHaveBeenCalled(); + expect(LazyLoader.loadImage).not.toHaveBeenCalledWith(newImg); expect(newImg).not.toHaveClass('js-lazy-loaded'); done(); }) @@ -212,7 +212,7 @@ describe('LazyLoader', function() { scrollIntoViewPromise(newImg) .then(waitForPromises) .then(() => { - expect(LazyLoader.loadImage).not.toHaveBeenCalled(); + expect(LazyLoader.loadImage).not.toHaveBeenCalledWith(newImg); expect(newImg).not.toHaveClass('js-lazy-loaded'); done(); }) @@ -234,7 +234,7 @@ describe('LazyLoader', function() { waitForAttributeChange(newImg, ['data-src', 'src']), ]) .then(() => { - expect(LazyLoader.loadImage).toHaveBeenCalled(); + expect(LazyLoader.loadImage).toHaveBeenCalledWith(newImg); expect(newImg).toHaveClass('js-lazy-loaded'); done(); }) diff --git a/spec/javascripts/vue_shared/components/file_row_spec.js b/spec/javascripts/vue_shared/components/file_row_spec.js index 6abcac5c0ff..7da69e3fa84 100644 --- a/spec/javascripts/vue_shared/components/file_row_spec.js +++ b/spec/javascripts/vue_shared/components/file_row_spec.js @@ -90,19 +90,6 @@ describe('File row component', () => { expect(vm.$el.querySelector('.js-file-row-header')).not.toBe(null); }); - it('is not rendered for `moved` entries in subfolders', () => { - createComponent({ - file: { - path: 't5', - moved: true, - tree: [], - }, - level: 2, - }); - - expect(vm.$el.nodeType).not.toEqual(1); - }); - describe('new dropdown', () => { beforeEach(() => { createComponent({ diff --git a/spec/lib/gitlab_spec.rb b/spec/lib/gitlab_spec.rb index 7e318017a05..bb4f71982a0 100644 --- a/spec/lib/gitlab_spec.rb +++ b/spec/lib/gitlab_spec.rb @@ -150,6 +150,7 @@ describe Gitlab do describe '.ee?' do before do + stub_env('IS_GITLAB_EE', nil) # Make sure the ENV is clean described_class.instance_variable_set(:@is_ee, nil) end |