summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFilipa Lacerda <filipa@gitlab.com>2017-10-24 09:06:55 +0000
committerFilipa Lacerda <filipa@gitlab.com>2017-10-24 09:06:55 +0000
commitcc17067085cc61c99322eb5934b73bc30b3e1caf (patch)
treef9733b96dd8a969e4b929fbf18658fb02614f987
parent4aaf4774a7af3614ac67149bcdef6c9b5ae5c2cd (diff)
parentc147bccc45dc1a47b17a18d14169606470833d02 (diff)
downloadgitlab-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
-rw-r--r--app/assets/javascripts/repo/components/new_dropdown/index.vue86
-rw-r--r--app/assets/javascripts/repo/components/new_dropdown/modal.vue90
-rw-r--r--app/assets/javascripts/repo/components/repo.vue4
-rw-r--r--app/assets/javascripts/repo/components/repo_commit_section.vue6
-rw-r--r--app/assets/javascripts/repo/components/repo_editor.vue2
-rw-r--r--app/assets/javascripts/repo/components/repo_file_buttons.vue12
-rw-r--r--app/assets/javascripts/repo/components/repo_tab.vue6
-rw-r--r--app/assets/javascripts/repo/helpers/repo_helper.js103
-rw-r--r--app/assets/javascripts/repo/index.js16
-rw-r--r--app/assets/javascripts/repo/mixins/repo_mixin.js2
-rw-r--r--app/assets/javascripts/repo/services/repo_service.js10
-rw-r--r--app/assets/javascripts/repo/stores/repo_store.js18
-rw-r--r--app/assets/javascripts/vue_shared/components/popup_dialog.vue11
-rw-r--r--app/assets/stylesheets/pages/repo.scss2
-rw-r--r--app/controllers/projects/blob_controller.rb1
-rw-r--r--app/controllers/projects/tree_controller.rb1
-rw-r--r--app/views/projects/tree/_tree_header.html.haml4
-rw-r--r--app/views/shared/repo/_repo.html.haml3
-rw-r--r--spec/features/projects/tree/create_directory_spec.rb43
-rw-r--r--spec/features/projects/tree/create_file_spec.rb40
-rw-r--r--spec/javascripts/helpers/vue_mount_component_helper.js5
-rw-r--r--spec/javascripts/repo/components/new_dropdown/index_spec.js191
-rw-r--r--spec/javascripts/repo/components/new_dropdown/modal_spec.js76
-rw-r--r--spec/javascripts/repo/components/repo_file_buttons_spec.js27
-rw-r--r--spec/javascripts/repo/components/repo_file_spec.js1
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',
};