diff options
author | Filipa Lacerda <filipa@gitlab.com> | 2017-10-24 09:06:55 +0000 |
---|---|---|
committer | Filipa Lacerda <filipa@gitlab.com> | 2017-10-24 09:06:55 +0000 |
commit | cc17067085cc61c99322eb5934b73bc30b3e1caf (patch) | |
tree | f9733b96dd8a969e4b929fbf18658fb02614f987 | |
parent | 4aaf4774a7af3614ac67149bcdef6c9b5ae5c2cd (diff) | |
parent | c147bccc45dc1a47b17a18d14169606470833d02 (diff) | |
download | gitlab-ce-cc17067085cc61c99322eb5934b73bc30b3e1caf.tar.gz |
Merge branch 'ph-multi-file-editor-new-file-folder-dropdown' into 'master'
Add new files & directories in the multi-file editor
Closes #38614
See merge request gitlab-org/gitlab-ce!14839
25 files changed, 708 insertions, 52 deletions
diff --git a/app/assets/javascripts/repo/components/new_dropdown/index.vue b/app/assets/javascripts/repo/components/new_dropdown/index.vue new file mode 100644 index 00000000000..3ccb50213ab --- /dev/null +++ b/app/assets/javascripts/repo/components/new_dropdown/index.vue @@ -0,0 +1,86 @@ +<script> + import RepoStore from '../../stores/repo_store'; + import RepoHelper from '../../helpers/repo_helper'; + import eventHub from '../../event_hub'; + import newModal from './modal.vue'; + + export default { + components: { + newModal, + }, + data() { + return { + openModal: false, + modalType: '', + currentPath: RepoStore.path, + }; + }, + methods: { + createNewItem(type) { + this.modalType = type; + this.toggleModalOpen(); + }, + toggleModalOpen() { + this.openModal = !this.openModal; + }, + createNewEntryInStore(name, type) { + RepoHelper.createNewEntry(name, type); + + this.toggleModalOpen(); + }, + }, + created() { + eventHub.$on('createNewEntry', this.createNewEntryInStore); + }, + beforeDestroy() { + eventHub.$off('createNewEntry', this.createNewEntryInStore); + }, + }; +</script> + +<template> + <div> + <ul class="breadcrumb repo-breadcrumb"> + <li class="dropdown"> + <button + type="button" + class="btn btn-default dropdown-toggle add-to-tree" + data-toggle="dropdown" + aria-label="Create new file or directory" + > + <i + class="fa fa-plus" + aria-hidden="true" + > + </i> + </button> + <ul class="dropdown-menu"> + <li> + <a + href="#" + role="button" + @click.prevent="createNewItem('blob')" + > + {{ __('New file') }} + </a> + </li> + <li> + <a + href="#" + role="button" + @click.prevent="createNewItem('tree')" + > + {{ __('New directory') }} + </a> + </li> + </ul> + </li> + </ul> + <new-modal + v-if="openModal" + :type="modalType" + :current-path="currentPath" + @toggle="toggleModalOpen" + /> + </div> +</template> diff --git a/app/assets/javascripts/repo/components/new_dropdown/modal.vue b/app/assets/javascripts/repo/components/new_dropdown/modal.vue new file mode 100644 index 00000000000..5ef629e0dde --- /dev/null +++ b/app/assets/javascripts/repo/components/new_dropdown/modal.vue @@ -0,0 +1,90 @@ +<script> + import { __ } from '../../../locale'; + import popupDialog from '../../../vue_shared/components/popup_dialog.vue'; + import eventHub from '../../event_hub'; + + export default { + props: { + currentPath: { + type: String, + required: true, + }, + type: { + type: String, + required: true, + }, + }, + data() { + return { + entryName: this.currentPath !== '' ? `${this.currentPath}/` : '', + }; + }, + components: { + popupDialog, + }, + methods: { + createEntryInStore() { + eventHub.$emit('createNewEntry', this.entryName, this.type); + }, + toggleModalOpen() { + this.$emit('toggle'); + }, + }, + computed: { + modalTitle() { + if (this.type === 'tree') { + return __('Create new directory'); + } + + return __('Create new file'); + }, + buttonLabel() { + if (this.type === 'tree') { + return __('Create directory'); + } + + return __('Create file'); + }, + formLabelName() { + if (this.type === 'tree') { + return __('Directory name'); + } + + return __('File name'); + }, + }, + mounted() { + this.$refs.fieldName.focus(); + }, + }; +</script> + +<template> + <popup-dialog + :title="modalTitle" + :primary-button-label="buttonLabel" + kind="success" + @toggle="toggleModalOpen" + @submit="createEntryInStore" + > + <form + class="form-horizontal" + slot="body" + @submit.prevent="createEntryInStore" + > + <fieldset class="form-group append-bottom-0"> + <label class="label-light col-sm-3"> + {{ formLabelName }} + </label> + <div class="col-sm-9"> + <input + type="text" + class="form-control" + v-model="entryName" + ref="fieldName" + /> + </div> + </fieldset> + </form> + </popup-dialog> +</template> diff --git a/app/assets/javascripts/repo/components/repo.vue b/app/assets/javascripts/repo/components/repo.vue index 6310bdb3270..788976a9804 100644 --- a/app/assets/javascripts/repo/components/repo.vue +++ b/app/assets/javascripts/repo/components/repo.vue @@ -46,6 +46,10 @@ export default { dialogSubmitted(status) { this.toggleDialogOpen(false); this.dialog.status = status; + + // remove tmp files + Helper.removeAllTmpFiles('openedFiles'); + Helper.removeAllTmpFiles('files'); }, toggleBlobView: Store.toggleBlobView, createNewBranch(branch) { diff --git a/app/assets/javascripts/repo/components/repo_commit_section.vue b/app/assets/javascripts/repo/components/repo_commit_section.vue index 185cd90ac06..0d6259a37a8 100644 --- a/app/assets/javascripts/repo/components/repo_commit_section.vue +++ b/app/assets/javascripts/repo/components/repo_commit_section.vue @@ -49,7 +49,7 @@ export default { // see https://docs.gitlab.com/ce/api/commits.html#create-a-commit-with-multiple-files-and-actions const commitMessage = this.commitMessage; const actions = this.changedFiles.map(f => ({ - action: 'update', + action: f.tempFile ? 'create' : 'update', file_path: f.path, content: f.newContent, })); @@ -62,7 +62,6 @@ export default { if (newBranch) { payload.start_branch = this.currentBranch; } - this.submitCommitsLoading = true; Service.commitFiles(payload) .then(() => { this.resetCommitState(); @@ -78,6 +77,8 @@ export default { }, tryCommit(e, skipBranchCheck = false, newBranch = false) { + this.submitCommitsLoading = true; + if (skipBranchCheck) { this.makeCommit(newBranch); } else { @@ -90,6 +91,7 @@ export default { this.makeCommit(newBranch); }) .catch(() => { + this.submitCommitsLoading = false; Flash('An error occurred while committing your changes'); }); } diff --git a/app/assets/javascripts/repo/components/repo_editor.vue b/app/assets/javascripts/repo/components/repo_editor.vue index 4639bee6d66..df4caba51d8 100644 --- a/app/assets/javascripts/repo/components/repo_editor.vue +++ b/app/assets/javascripts/repo/components/repo_editor.vue @@ -16,7 +16,7 @@ const RepoEditor = { }, mounted() { - Service.getRaw(this.activeFile.raw_path) + Service.getRaw(this.activeFile) .then((rawResponse) => { Store.blobRaw = rawResponse.data; Store.activeFile.plain = rawResponse.data; diff --git a/app/assets/javascripts/repo/components/repo_file_buttons.vue b/app/assets/javascripts/repo/components/repo_file_buttons.vue index 03cd219e718..c98f641c853 100644 --- a/app/assets/javascripts/repo/components/repo_file_buttons.vue +++ b/app/assets/javascripts/repo/components/repo_file_buttons.vue @@ -11,7 +11,12 @@ const RepoFileButtons = { mixins: [RepoMixin], computed: { - + showButtons() { + return this.activeFile.raw_path || + this.activeFile.blame_path || + this.activeFile.commits_path || + this.activeFile.permalink; + }, rawDownloadButtonLabel() { return this.binary ? 'Download' : 'Raw'; }, @@ -30,7 +35,10 @@ export default RepoFileButtons; </script> <template> - <div id="repo-file-buttons"> + <div + v-if="showButtons" + class="repo-file-buttons" + > <a :href="activeFile.raw_path" target="_blank" diff --git a/app/assets/javascripts/repo/components/repo_tab.vue b/app/assets/javascripts/repo/components/repo_tab.vue index 098715915b0..405d7b4cf86 100644 --- a/app/assets/javascripts/repo/components/repo_tab.vue +++ b/app/assets/javascripts/repo/components/repo_tab.vue @@ -18,8 +18,8 @@ const RepoTab = { }, changedClass() { const tabChangedObj = { - 'fa-times close-icon': !this.tab.changed, - 'fa-circle unsaved-icon': this.tab.changed, + 'fa-times close-icon': !this.tab.changed && !this.tab.tempFile, + 'fa-circle unsaved-icon': this.tab.changed || this.tab.tempFile, }; return tabChangedObj; }, @@ -30,7 +30,7 @@ const RepoTab = { Store.setActiveFiles(file); }, closeTab(file) { - if (file.changed) return; + if (file.changed || file.tempFile) return; Store.removeFromOpenedFiles(file); }, diff --git a/app/assets/javascripts/repo/helpers/repo_helper.js b/app/assets/javascripts/repo/helpers/repo_helper.js index f7b7f93e4b8..fb26f3b7380 100644 --- a/app/assets/javascripts/repo/helpers/repo_helper.js +++ b/app/assets/javascripts/repo/helpers/repo_helper.js @@ -1,4 +1,3 @@ -import { convertPermissionToBoolean } from '../../lib/utils/common_utils'; import Service from '../services/repo_service'; import Store from '../stores/repo_store'; import Flash from '../../flash'; @@ -8,6 +7,7 @@ const RepoHelper = { getDefaultActiveFile() { return { + id: '', active: true, binary: false, extension: '', @@ -62,6 +62,7 @@ const RepoHelper = { }); RepoHelper.updateHistoryEntry(tree.url, title); + Store.path = tree.path; }, setDirectoryToClosed(entry) { @@ -96,8 +97,8 @@ const RepoHelper = { .then((response) => { const data = response.data; if (response.headers && response.headers['page-title']) data.pageTitle = decodeURI(response.headers['page-title']); - if (response.headers && response.headers['is-root'] && !Store.isInitialRoot) { - Store.isRoot = convertPermissionToBoolean(response.headers['is-root']); + if (data.path && !Store.isInitialRoot) { + Store.isRoot = data.path === '/'; Store.isInitialRoot = Store.isRoot; } @@ -110,7 +111,7 @@ const RepoHelper = { RepoHelper.setBinaryDataAsBase64(data); Store.setViewToPreview(); } else if (!Store.isPreviewView() && !data.render_error) { - Service.getRaw(data.raw_path) + Service.getRaw(data) .then((rawResponse) => { Store.blobRaw = rawResponse.data; data.plain = rawResponse.data; @@ -138,6 +139,10 @@ const RepoHelper = { addToDirectory(file, data) { const tree = file || Store; + + // TODO: Figure out why `popstate` is being trigger in the specs + if (!tree.files) return; + const files = tree.files.concat(this.dataToListOfFiles(data, file ? file.level + 1 : 0)); tree.files = files; @@ -157,7 +162,18 @@ const RepoHelper = { }, serializeRepoEntity(type, entity, level = 0) { - const { id, url, name, icon, last_commit, tree_url } = entity; + const { + id, + url, + name, + icon, + last_commit, + tree_url, + path, + tempFile, + active, + opened, + } = entity; return { id, @@ -165,11 +181,14 @@ const RepoHelper = { name, url, tree_url, + path, level, + tempFile, icon: `fa-${icon}`, files: [], loading: false, - opened: false, + opened, + active, // eslint-disable-next-line camelcase lastCommit: last_commit ? { url: `${Store.projectUrl}/commit/${last_commit.id}`, @@ -213,7 +232,7 @@ const RepoHelper = { }, findOpenedFileFromActive() { - return Store.openedFiles.find(openedFile => Store.activeFile.url === openedFile.url); + return Store.openedFiles.find(openedFile => Store.activeFile.id === openedFile.id); }, getFileFromPath(path) { @@ -223,6 +242,76 @@ const RepoHelper = { loadingError() { Flash('Unable to load this content at this time.'); }, + openEditMode() { + Store.editMode = true; + Store.currentBlobView = 'repo-editor'; + }, + updateStorePath(path) { + Store.path = path; + }, + findOrCreateEntry(type, tree, name) { + let exists = true; + let foundEntry = tree.files.find(dir => dir.type === type && dir.name === name); + + if (!foundEntry) { + foundEntry = RepoHelper.serializeRepoEntity(type, { + id: name, + name, + path: tree.path ? `${tree.path}/${name}` : name, + icon: type === 'tree' ? 'folder' : 'file-text-o', + tempFile: true, + opened: true, + active: true, + }, tree.level !== undefined ? tree.level + 1 : 0); + + exists = false; + tree.files.push(foundEntry); + } + + return { + entry: foundEntry, + exists, + }; + }, + removeAllTmpFiles(storeFilesKey) { + Store[storeFilesKey] = Store[storeFilesKey].filter(f => !f.tempFile); + }, + createNewEntry(name, type) { + const originalPath = Store.path; + let entryName = name; + + if (entryName.indexOf(`${originalPath}/`) !== 0) { + this.updateStorePath(''); + } else { + entryName = entryName.replace(`${originalPath}/`, ''); + } + + if (entryName === '') return; + + const fileName = type === 'tree' ? '.gitkeep' : entryName; + let tree = Store; + + if (type === 'tree') { + const dirNames = entryName.split('/'); + + dirNames.forEach((dirName) => { + if (dirName === '') return; + + tree = this.findOrCreateEntry('tree', tree, dirName).entry; + }); + } + + if ((type === 'tree' && tree.tempFile) || type === 'blob') { + const file = this.findOrCreateEntry('blob', tree, fileName); + + if (!file.exists) { + this.setFile(file.entry, file.entry); + this.openEditMode(); + } + } + + this.updateStorePath(originalPath); + }, }; export default RepoHelper; diff --git a/app/assets/javascripts/repo/index.js b/app/assets/javascripts/repo/index.js index 85e960df497..72fc5a70648 100644 --- a/app/assets/javascripts/repo/index.js +++ b/app/assets/javascripts/repo/index.js @@ -6,6 +6,7 @@ import Store from './stores/repo_store'; import Repo from './components/repo.vue'; import RepoEditButton from './components/repo_edit_button.vue'; import newBranchForm from './components/new_branch_form.vue'; +import newDropdown from './components/new_dropdown/index.vue'; import Translate from '../vue_shared/translate'; function initDropdowns() { @@ -28,6 +29,7 @@ function setInitialStore(data) { Store.service = Service; Store.service.url = data.url; Store.service.refsUrl = data.refsUrl; + Store.path = data.currentPath; Store.projectId = data.projectId; Store.projectName = data.projectName; Store.projectUrl = data.projectUrl; @@ -63,6 +65,18 @@ function initRepoEditButton(el) { }); } +function initNewDropdown(el) { + return new Vue({ + el, + components: { + newDropdown, + }, + render(createElement) { + return createElement('new-dropdown'); + }, + }); +} + function initNewBranchForm() { const el = document.querySelector('.js-new-branch-dropdown'); @@ -86,6 +100,7 @@ function initNewBranchForm() { function initRepoBundle() { const repo = document.getElementById('repo'); const editButton = document.querySelector('.editable-mode'); + const newDropdownHolder = document.querySelector('.js-new-dropdown'); setInitialStore(repo.dataset); addEventsForNonVueEls(); initDropdowns(); @@ -95,6 +110,7 @@ function initRepoBundle() { initRepo(repo); initRepoEditButton(editButton); initNewBranchForm(); + initNewDropdown(newDropdownHolder); } $(initRepoBundle); diff --git a/app/assets/javascripts/repo/mixins/repo_mixin.js b/app/assets/javascripts/repo/mixins/repo_mixin.js index c8e8238a0d3..efeda426b96 100644 --- a/app/assets/javascripts/repo/mixins/repo_mixin.js +++ b/app/assets/javascripts/repo/mixins/repo_mixin.js @@ -8,7 +8,7 @@ const RepoMixin = { changedFiles() { const changedFileList = this.openedFiles - .filter(file => file.changed); + .filter(file => file.changed || file.tempFile); return changedFileList; }, }, diff --git a/app/assets/javascripts/repo/services/repo_service.js b/app/assets/javascripts/repo/services/repo_service.js index 786b5637493..c9fa5cc8bf8 100644 --- a/app/assets/javascripts/repo/services/repo_service.js +++ b/app/assets/javascripts/repo/services/repo_service.js @@ -16,8 +16,14 @@ const RepoService = { createBranchPath: '/api/:version/projects/:id/repository/branches', richExtensionRegExp: /md/, - getRaw(url) { - return axios.get(url, { + getRaw(file) { + if (file.tempFile) { + return Promise.resolve({ + data: '', + }); + } + + return axios.get(file.raw_path, { // Stop Axios from parsing a JSON file into a JS object transformResponse: [res => res], }); diff --git a/app/assets/javascripts/repo/stores/repo_store.js b/app/assets/javascripts/repo/stores/repo_store.js index 39e1b4e5849..38df1e3e0d2 100644 --- a/app/assets/javascripts/repo/stores/repo_store.js +++ b/app/assets/javascripts/repo/stores/repo_store.js @@ -39,6 +39,7 @@ const RepoStore = { newMrTemplateUrl: '', branchChanged: false, commitMessage: '', + path: '', loading: { tree: false, blob: false, @@ -77,21 +78,23 @@ const RepoStore = { } else if (file.newContent || file.plain) { RepoStore.blobRaw = file.newContent || file.plain; } else { - Service.getRaw(file.raw_path) + Service.getRaw(file) .then((rawResponse) => { RepoStore.blobRaw = rawResponse.data; Helper.findOpenedFileFromActive().plain = rawResponse.data; }).catch(Helper.loadingError); } - if (!file.loading) Helper.updateHistoryEntry(file.url, file.pageTitle || file.name); + if (!file.loading && !file.tempFile) { + Helper.updateHistoryEntry(file.url, file.pageTitle || file.name); + } RepoStore.binary = file.binary; RepoStore.setActiveLine(-1); }, setFileActivity(file, openedFile, i) { const activeFile = openedFile; - activeFile.active = file.url === activeFile.url; + activeFile.active = file.id === activeFile.id; if (activeFile.active) RepoStore.setActiveFile(activeFile, i); @@ -99,7 +102,7 @@ const RepoStore = { }, setActiveFile(activeFile, i) { - RepoStore.activeFile = Object.assign({}, RepoStore.activeFile, activeFile); + RepoStore.activeFile = Object.assign({}, Helper.getDefaultActiveFile(), activeFile); RepoStore.activeFileIndex = i; }, @@ -121,6 +124,11 @@ const RepoStore = { return openedFile.path !== file.path; }); + // remove the file from the sidebar if it is a tempFile + if (file.tempFile) { + RepoStore.files = RepoStore.files.filter(f => !(f.tempFile && f.path === file.path)); + } + // now activate the right tab based on what you closed. if (RepoStore.openedFiles.length === 0) { RepoStore.activeFile = {}; @@ -170,7 +178,7 @@ const RepoStore = { // getters isActiveFile(file) { - return file && file.url === RepoStore.activeFile.url; + return file && file.id === RepoStore.activeFile.id; }, isPreviewView() { diff --git a/app/assets/javascripts/vue_shared/components/popup_dialog.vue b/app/assets/javascripts/vue_shared/components/popup_dialog.vue index 7d8c5936b7d..9e8c10bdc1a 100644 --- a/app/assets/javascripts/vue_shared/components/popup_dialog.vue +++ b/app/assets/javascripts/vue_shared/components/popup_dialog.vue @@ -9,7 +9,7 @@ export default { }, text: { type: String, - required: true, + required: false, }, kind: { type: String, @@ -82,14 +82,15 @@ export default { type="button" class="btn" :class="btnCancelKindClass" - @click="emitSubmit(false)"> - {{closeButtonLabel}} + @click="close"> + {{ closeButtonLabel }} </button> - <button type="button" + <button + type="button" class="btn" :class="btnKindClass" @click="emitSubmit(true)"> - {{primaryButtonLabel}} + {{ primaryButtonLabel }} </button> </div> </div> diff --git a/app/assets/stylesheets/pages/repo.scss b/app/assets/stylesheets/pages/repo.scss index 97ca01f0f54..6a363b1710e 100644 --- a/app/assets/stylesheets/pages/repo.scss +++ b/app/assets/stylesheets/pages/repo.scss @@ -201,7 +201,7 @@ } } - #repo-file-buttons { + .repo-file-buttons { background-color: $white-light; padding: 5px 10px; border-top: 1px solid $white-normal; diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 183a6f88a6a..770381472c5 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -205,6 +205,7 @@ class Projects::BlobController < Projects::ApplicationController tree_path = path_segments.join('/') render json: json.merge( + id: @blob.id, path: blob.path, name: blob.name, extension: blob.extension, diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index 756f7e5df8c..f3719059f88 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -36,7 +36,6 @@ class Projects::TreeController < Projects::ApplicationController format.json do page_title @path.presence || _("Files"), @ref, @project.name_with_namespace - response.header['is-root'] = @path.empty? # n+1: https://gitlab.com/gitlab-org/gitlab-ce/issues/38261 Gitlab::GitalyClient.allow_n_plus_1_calls do diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 47f3f2b459a..7ea19e6c828 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -2,7 +2,9 @@ .tree-ref-holder = render 'shared/ref_switcher', destination: 'tree', path: @path, show_create: true - - unless show_new_repo? + - if show_new_repo? + .js-new-dropdown + - else = render 'projects/tree/old_tree_header' .tree-controls diff --git a/app/views/shared/repo/_repo.html.haml b/app/views/shared/repo/_repo.html.haml index 7185f5bcc5b..7861f92b33f 100644 --- a/app/views/shared/repo/_repo.html.haml +++ b/app/views/shared/repo/_repo.html.haml @@ -7,4 +7,5 @@ blob_url: namespace_project_blob_path(project.namespace, project, '{{branch}}'), new_mr_template_url: namespace_project_new_merge_request_path(project.namespace, project, merge_request: { source_branch: '{{source_branch}}' }), can_commit: (!!can_push_branch?(project, @ref)).to_s, - on_top_of_branch: (!!on_top_of_branch?(project, @ref)).to_s } } + on_top_of_branch: (!!on_top_of_branch?(project, @ref)).to_s, + current_path: @path } } diff --git a/spec/features/projects/tree/create_directory_spec.rb b/spec/features/projects/tree/create_directory_spec.rb new file mode 100644 index 00000000000..4c1fa5a666e --- /dev/null +++ b/spec/features/projects/tree/create_directory_spec.rb @@ -0,0 +1,43 @@ +require 'spec_helper' + +feature 'Multi-file editor new directory', :js do + include WaitForRequests + + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + + before do + project.add_master(user) + sign_in(user) + + page.driver.set_cookie('new_repo', 'true') + + visit project_tree_path(project, :master) + + wait_for_requests + end + + it 'creates directory in current directory' do + find('.add-to-tree').click + + click_link('New directory') + + page.within('.popup-dialog') do + find('.form-control').set('foldername') + + click_button('Create directory') + end + + fill_in('commit-message', with: 'commit message') + + click_button('Commit 1 file') + + expect(page).to have_content('Your changes have been committed') + expect(page).to have_selector('td', text: 'commit message') + + click_link('foldername') + + expect(page).to have_selector('td', text: 'commit message', count: 2) + expect(page).to have_selector('td', text: '.gitkeep') + end +end diff --git a/spec/features/projects/tree/create_file_spec.rb b/spec/features/projects/tree/create_file_spec.rb new file mode 100644 index 00000000000..a67ec891e7c --- /dev/null +++ b/spec/features/projects/tree/create_file_spec.rb @@ -0,0 +1,40 @@ +require 'spec_helper' + +feature 'Multi-file editor new file', :js do + include WaitForRequests + + let(:user) { create(:user) } + let(:project) { create(:project, :repository) } + + before do + project.add_master(user) + sign_in(user) + + page.driver.set_cookie('new_repo', 'true') + + visit project_tree_path(project, :master) + + wait_for_requests + end + + it 'creates file in current directory' do + find('.add-to-tree').click + + click_link('New file') + + page.within('.popup-dialog') do + find('.form-control').set('filename') + + click_button('Create file') + end + + find('.inputarea').send_keys('file content') + + fill_in('commit-message', with: 'commit message') + + click_button('Commit 1 file') + + expect(page).to have_content('Your changes have been committed') + expect(page).to have_selector('td', text: 'commit message') + end +end diff --git a/spec/javascripts/helpers/vue_mount_component_helper.js b/spec/javascripts/helpers/vue_mount_component_helper.js index d7a2e86771c..b71136c4114 100644 --- a/spec/javascripts/helpers/vue_mount_component_helper.js +++ b/spec/javascripts/helpers/vue_mount_component_helper.js @@ -1,4 +1,3 @@ -export default (Component, props = {}) => new Component({ +export default (Component, props = {}, el = null) => new Component({ propsData: props, -}).$mount(); - +}).$mount(el); diff --git a/spec/javascripts/repo/components/new_dropdown/index_spec.js b/spec/javascripts/repo/components/new_dropdown/index_spec.js new file mode 100644 index 00000000000..ddbfdab582d --- /dev/null +++ b/spec/javascripts/repo/components/new_dropdown/index_spec.js @@ -0,0 +1,191 @@ +import Vue from 'vue'; +import newDropdown from '~/repo/components/new_dropdown/index.vue'; +import RepoStore from '~/repo/stores/repo_store'; +import RepoHelper from '~/repo/helpers/repo_helper'; +import eventHub from '~/repo/event_hub'; +import createComponent from '../../../helpers/vue_mount_component_helper'; + +describe('new dropdown component', () => { + let vm; + + beforeEach(() => { + const component = Vue.extend(newDropdown); + + vm = createComponent(component); + }); + + afterEach(() => { + vm.$destroy(); + + RepoStore.files = []; + RepoStore.openedFiles = []; + RepoStore.setViewToPreview(); + }); + + it('renders new file and new directory links', () => { + expect(vm.$el.querySelectorAll('a')[0].textContent.trim()).toBe('New file'); + expect(vm.$el.querySelectorAll('a')[1].textContent.trim()).toBe('New directory'); + }); + + describe('createNewItem', () => { + it('sets modalType to blob when new file is clicked', () => { + vm.$el.querySelectorAll('a')[0].click(); + + expect(vm.modalType).toBe('blob'); + }); + + it('sets modalType to tree when new directory is clicked', () => { + vm.$el.querySelectorAll('a')[1].click(); + + expect(vm.modalType).toBe('tree'); + }); + + it('opens modal when link is clicked', (done) => { + vm.$el.querySelectorAll('a')[0].click(); + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.modal')).not.toBeNull(); + + done(); + }); + }); + }); + + describe('toggleModalOpen', () => { + it('closes modal after toggling', (done) => { + vm.toggleModalOpen(); + + Vue.nextTick() + .then(() => { + expect(vm.$el.querySelector('.modal')).not.toBeNull(); + }) + .then(vm.toggleModalOpen) + .then(() => { + expect(vm.$el.querySelector('.modal')).toBeNull(); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('createEntryInStore', () => { + ['tree', 'blob'].forEach((type) => { + describe(type, () => { + it('closes modal after creating file', () => { + vm.openModal = true; + + eventHub.$emit('createNewEntry', 'testing', type); + + expect(vm.openModal).toBeFalsy(); + }); + + it('sets editMode to true', () => { + eventHub.$emit('createNewEntry', 'testing', type); + + expect(RepoStore.editMode).toBeTruthy(); + }); + + it('toggles blob view', () => { + eventHub.$emit('createNewEntry', 'testing', type); + + expect(RepoStore.isPreviewView()).toBeFalsy(); + }); + + it('adds file into activeFiles', () => { + eventHub.$emit('createNewEntry', 'testing', type); + + expect(RepoStore.openedFiles.length).toBe(1); + }); + + it(`creates ${type} in the current stores path`, () => { + RepoStore.path = 'testing'; + + eventHub.$emit('createNewEntry', 'testing/app', type); + + expect(RepoStore.files[0].path).toBe('testing/app'); + expect(RepoStore.files[0].name).toBe('app'); + + if (type === 'tree') { + expect(RepoStore.files[0].files.length).toBe(1); + } + + RepoStore.path = ''; + }); + }); + }); + + describe('file', () => { + it('creates new file', () => { + eventHub.$emit('createNewEntry', 'testing', 'blob'); + + expect(RepoStore.files.length).toBe(1); + expect(RepoStore.files[0].name).toBe('testing'); + expect(RepoStore.files[0].type).toBe('blob'); + expect(RepoStore.files[0].tempFile).toBeTruthy(); + }); + + it('does not create temp file when file already exists', () => { + RepoStore.files.push(RepoHelper.serializeRepoEntity('blob', { + name: 'testing', + })); + + eventHub.$emit('createNewEntry', 'testing', 'blob'); + + expect(RepoStore.files.length).toBe(1); + expect(RepoStore.files[0].name).toBe('testing'); + expect(RepoStore.files[0].type).toBe('blob'); + expect(RepoStore.files[0].tempFile).toBeUndefined(); + }); + }); + + describe('tree', () => { + it('creates new tree', () => { + eventHub.$emit('createNewEntry', 'testing', 'tree'); + + expect(RepoStore.files.length).toBe(1); + expect(RepoStore.files[0].name).toBe('testing'); + expect(RepoStore.files[0].type).toBe('tree'); + expect(RepoStore.files[0].tempFile).toBeTruthy(); + expect(RepoStore.files[0].files.length).toBe(1); + expect(RepoStore.files[0].files[0].name).toBe('.gitkeep'); + }); + + it('creates multiple trees when entryName has slashes', () => { + eventHub.$emit('createNewEntry', 'app/test', 'tree'); + + expect(RepoStore.files.length).toBe(1); + expect(RepoStore.files[0].name).toBe('app'); + expect(RepoStore.files[0].files[0].name).toBe('test'); + expect(RepoStore.files[0].files[0].files[0].name).toBe('.gitkeep'); + }); + + it('creates tree in existing tree', () => { + RepoStore.files.push(RepoHelper.serializeRepoEntity('tree', { + name: 'app', + })); + + eventHub.$emit('createNewEntry', 'app/test', 'tree'); + + expect(RepoStore.files.length).toBe(1); + expect(RepoStore.files[0].name).toBe('app'); + expect(RepoStore.files[0].tempFile).toBeUndefined(); + expect(RepoStore.files[0].files[0].tempFile).toBeTruthy(); + expect(RepoStore.files[0].files[0].name).toBe('test'); + expect(RepoStore.files[0].files[0].files[0].name).toBe('.gitkeep'); + }); + + it('does not create new tree when already exists', () => { + RepoStore.files.push(RepoHelper.serializeRepoEntity('tree', { + name: 'app', + })); + + eventHub.$emit('createNewEntry', 'app', 'tree'); + + expect(RepoStore.files.length).toBe(1); + expect(RepoStore.files[0].name).toBe('app'); + expect(RepoStore.files[0].tempFile).toBeUndefined(); + expect(RepoStore.files[0].files.length).toBe(0); + }); + }); + }); +}); diff --git a/spec/javascripts/repo/components/new_dropdown/modal_spec.js b/spec/javascripts/repo/components/new_dropdown/modal_spec.js new file mode 100644 index 00000000000..4c5cdc47c6e --- /dev/null +++ b/spec/javascripts/repo/components/new_dropdown/modal_spec.js @@ -0,0 +1,76 @@ +import Vue from 'vue'; +import RepoStore from '~/repo/stores/repo_store'; +import modal from '~/repo/components/new_dropdown/modal.vue'; +import eventHub from '~/repo/event_hub'; +import createComponent from '../../../helpers/vue_mount_component_helper'; + +describe('new file modal component', () => { + const Component = Vue.extend(modal); + let vm; + + afterEach(() => { + vm.$destroy(); + + RepoStore.files = []; + RepoStore.openedFiles = []; + RepoStore.setViewToPreview(); + }); + + ['tree', 'blob'].forEach((type) => { + describe(type, () => { + beforeEach(() => { + vm = createComponent(Component, { + type, + currentPath: RepoStore.path, + }); + }); + + it(`sets modal title as ${type}`, () => { + const title = type === 'tree' ? 'directory' : 'file'; + + expect(vm.$el.querySelector('.modal-title').textContent.trim()).toBe(`Create new ${title}`); + }); + + it(`sets button label as ${type}`, () => { + const title = type === 'tree' ? 'directory' : 'file'; + + expect(vm.$el.querySelector('.btn-success').textContent.trim()).toBe(`Create ${title}`); + }); + + it(`sets form label as ${type}`, () => { + const title = type === 'tree' ? 'Directory' : 'File'; + + expect(vm.$el.querySelector('.label-light').textContent.trim()).toBe(`${title} name`); + }); + }); + }); + + it('focuses field on mount', () => { + document.body.innerHTML += '<div class="js-test"></div>'; + + vm = createComponent(Component, { + type: 'tree', + currentPath: RepoStore.path, + }, '.js-test'); + + expect(document.activeElement).toBe(vm.$refs.fieldName); + + vm.$el.remove(); + }); + + describe('createEntryInStore', () => { + it('emits createNewEntry event', () => { + spyOn(eventHub, '$emit'); + + vm = createComponent(Component, { + type: 'tree', + currentPath: RepoStore.path, + }); + vm.entryName = 'testing'; + + vm.createEntryInStore(); + + expect(eventHub.$emit).toHaveBeenCalledWith('createNewEntry', 'testing', 'tree'); + }); + }); +}); diff --git a/spec/javascripts/repo/components/repo_file_buttons_spec.js b/spec/javascripts/repo/components/repo_file_buttons_spec.js index 701c260224f..111c83ee50d 100644 --- a/spec/javascripts/repo/components/repo_file_buttons_spec.js +++ b/spec/javascripts/repo/components/repo_file_buttons_spec.js @@ -3,6 +3,15 @@ import repoFileButtons from '~/repo/components/repo_file_buttons.vue'; import RepoStore from '~/repo/stores/repo_store'; describe('RepoFileButtons', () => { + const activeFile = { + extension: 'md', + url: 'url', + raw_path: 'raw_path', + blame_path: 'blame_path', + commits_path: 'commits_path', + permalink: 'permalink', + }; + function createComponent() { const RepoFileButtons = Vue.extend(repoFileButtons); @@ -14,14 +23,6 @@ describe('RepoFileButtons', () => { }); it('renders Raw, Blame, History, Permalink and Preview toggle', () => { - const activeFile = { - extension: 'md', - url: 'url', - raw_path: 'raw_path', - blame_path: 'blame_path', - commits_path: 'commits_path', - permalink: 'permalink', - }; const activeFileLabel = 'activeFileLabel'; RepoStore.openedFiles = new Array(1); RepoStore.activeFile = activeFile; @@ -34,7 +35,6 @@ describe('RepoFileButtons', () => { const blame = vm.$el.querySelector('.blame'); const history = vm.$el.querySelector('.history'); - expect(vm.$el.id).toEqual('repo-file-buttons'); expect(raw.href).toMatch(`/${activeFile.raw_path}`); expect(raw.textContent.trim()).toEqual('Raw'); expect(blame.href).toMatch(`/${activeFile.blame_path}`); @@ -46,10 +46,6 @@ describe('RepoFileButtons', () => { }); it('triggers rawPreviewToggle on preview click', () => { - const activeFile = { - extension: 'md', - url: 'url', - }; RepoStore.openedFiles = new Array(1); RepoStore.activeFile = activeFile; RepoStore.editMode = true; @@ -65,10 +61,7 @@ describe('RepoFileButtons', () => { }); it('does not render preview toggle if not canPreview', () => { - const activeFile = { - extension: 'abcd', - url: 'url', - }; + activeFile.extension = 'js'; RepoStore.openedFiles = new Array(1); RepoStore.activeFile = activeFile; diff --git a/spec/javascripts/repo/components/repo_file_spec.js b/spec/javascripts/repo/components/repo_file_spec.js index 107f6797f8a..8403df9be64 100644 --- a/spec/javascripts/repo/components/repo_file_spec.js +++ b/spec/javascripts/repo/components/repo_file_spec.js @@ -7,6 +7,7 @@ import { file } from '../mock_data'; describe('RepoFile', () => { const updated = 'updated'; const otherFile = { + id: 'test', html: '<p class="file-content">html</p>', pageTitle: 'otherpageTitle', }; |