diff --git a/app/assets/javascripts/repository/components/breadcrumbs.vue b/app/assets/javascripts/repository/components/breadcrumbs.vue
index db84e2b59121ed4cf7be697341186e0c5a53eae9..d3717f10ec7ba0e670ee08f4197b8e00e84b2302 100644
--- a/app/assets/javascripts/repository/components/breadcrumbs.vue
+++ b/app/assets/javascripts/repository/components/breadcrumbs.vue
@@ -9,11 +9,13 @@ import {
} from '@gitlab/ui';
import permissionsQuery from 'shared_queries/repository/permissions.query.graphql';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
+import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __ } from '../../locale';
import getRefMixin from '../mixins/get_ref';
import projectPathQuery from '../queries/project_path.query.graphql';
import projectShortPathQuery from '../queries/project_short_path.query.graphql';
import UploadBlobModal from './upload_blob_modal.vue';
+import NewDirectoryModal from './new_directory_modal.vue';
const ROW_TYPES = {
header: 'header',
@@ -21,6 +23,7 @@ const ROW_TYPES = {
};
const UPLOAD_BLOB_MODAL_ID = 'modal-upload-blob';
+const NEW_DIRECTORY_MODAL_ID = 'modal-new-directory';
export default {
components: {
@@ -30,6 +33,7 @@ export default {
GlDropdownItem,
GlIcon,
UploadBlobModal,
+ NewDirectoryModal,
},
apollo: {
projectShortPath: {
@@ -54,7 +58,7 @@ export default {
directives: {
GlModal: GlModalDirective,
},
- mixins: [getRefMixin],
+ mixins: [getRefMixin, glFeatureFlagsMixin()],
props: {
currentPath: {
type: String,
@@ -121,8 +125,14 @@ export default {
required: false,
default: '',
},
+ newDirPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
},
uploadBlobModalId: UPLOAD_BLOB_MODAL_ID,
+ newDirectoryModalId: NEW_DIRECTORY_MODAL_ID,
data() {
return {
projectShortPath: '',
@@ -160,6 +170,13 @@ export default {
showUploadModal() {
return this.canEditTree && !this.$apollo.queries.userPermissions.loading;
},
+ showNewDirectoryModal() {
+ return (
+ this.glFeatures.newDirModal &&
+ this.canEditTree &&
+ !this.$apollo.queries.userPermissions.loading
+ );
+ },
dropdownItems() {
const items = [];
@@ -185,15 +202,26 @@ export default {
text: __('Upload file'),
modalId: UPLOAD_BLOB_MODAL_ID,
},
- {
+ );
+
+ if (this.glFeatures.newDirModal) {
+ items.push({
+ attrs: {
+ href: '#modal-create-new-dir',
+ },
+ text: __('New directory'),
+ modalId: NEW_DIRECTORY_MODAL_ID,
+ });
+ } else {
+ items.push({
attrs: {
href: '#modal-create-new-dir',
'data-target': '#modal-create-new-dir',
'data-toggle': 'modal',
},
text: __('New directory'),
- },
- );
+ });
+ }
} else if (this.canCreateMrFromFork) {
items.push(
{
@@ -306,5 +334,14 @@ export default {
:can-push-code="canPushCode"
:path="uploadPath"
/>
+
diff --git a/app/assets/javascripts/repository/components/new_directory_modal.vue b/app/assets/javascripts/repository/components/new_directory_modal.vue
new file mode 100644
index 0000000000000000000000000000000000000000..6c5797bf5b20dfcfd7cac425d0540abf1823aaf0
--- /dev/null
+++ b/app/assets/javascripts/repository/components/new_directory_modal.vue
@@ -0,0 +1,183 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ $options.i18n.NEW_BRANCH_IN_FORK }}
+
+
+
+
diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js
index 70952c8413b34ce1d91821f7ef40c752618b4981..152fabbd7cc647c628325715de1f382ea10c11cd 100644
--- a/app/assets/javascripts/repository/constants.js
+++ b/app/assets/javascripts/repository/constants.js
@@ -10,6 +10,9 @@ export const SECONDARY_OPTIONS_TEXT = __('Cancel');
export const COMMIT_LABEL = __('Commit message');
export const TARGET_BRANCH_LABEL = __('Target branch');
export const TOGGLE_CREATE_MR_LABEL = __('Start a new merge request with these changes');
+export const NEW_BRANCH_IN_FORK = __(
+ 'A new branch will be created in your fork and a new merge request will be started.',
+);
export const COMMIT_MESSAGE_SUBJECT_MAX_LENGTH = 52;
export const COMMIT_MESSAGE_BODY_MAX_LENGTH = 72;
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 60a1a0443f7f7b7f4b39ad8c9e07c96947b3975a..45e026ad69560dd674a09e5b2722183791ce2d78 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -120,6 +120,7 @@ export default function setupVueRepositoryList() {
forkNewDirectoryPath,
forkUploadBlobPath,
uploadPath,
+ newDirPath,
},
});
},
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index cb0e1900e484b1029b811d792d8befbafdd2b6d3..a76d45411dd768dfd3324035dfe13ba7199a382c 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -18,6 +18,7 @@ class Projects::TreeController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:lazy_load_commits, @project, default_enabled: :yaml)
push_frontend_feature_flag(:paginated_tree_graphql_query, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:new_dir_modal, @project, default_enabled: :yaml)
end
feature_category :source_code_management
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index 7c7e6457020871150ca3999bf60627b089dc2571..26da0436dd83a3faeb977a903a4a3479a6b5825d 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -38,6 +38,7 @@ class ProjectsController < Projects::ApplicationController
push_frontend_feature_flag(:refactor_text_viewer, @project, default_enabled: :yaml)
push_frontend_feature_flag(:increase_page_size_exponentially, @project, default_enabled: :yaml)
push_frontend_feature_flag(:paginated_tree_graphql_query, @project, default_enabled: :yaml)
+ push_frontend_feature_flag(:new_dir_modal, @project, default_enabled: :yaml)
end
layout :determine_layout
diff --git a/app/views/layouts/_startup_js.html.haml b/app/views/layouts/_startup_js.html.haml
index b7dd3a9556cc2920fc7916e8ba4896f0b569ae9b..0d5f6bbe25be8490664a41185f220c8f2dcda915 100644
--- a/app/views/layouts/_startup_js.html.haml
+++ b/app/views/layouts/_startup_js.html.haml
@@ -25,6 +25,7 @@
headers: {
"Content-Type": "application/json",
...headers,
+ }
};
gl.startup_graphql_calls = gl.startup_graphql_calls.map(call => ({
diff --git a/app/views/projects/_files.html.haml b/app/views/projects/_files.html.haml
index 597a22bf34a075566daf455386c22029c11165cf..cdcc98552f90b93eeedb7311324373aebc31dde1 100644
--- a/app/views/projects/_files.html.haml
+++ b/app/views/projects/_files.html.haml
@@ -20,5 +20,6 @@
= render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout), project_buttons: true
#js-tree-list{ data: vue_file_list_data(project, ref) }
- - if can_edit_tree?
+ - if !Feature.enabled?(:new_dir_modal, default_enabled: :yaml) && can_edit_tree?
= render 'projects/blob/new_dir'
+
diff --git a/config/feature_flags/development/new_dir_modal.yml b/config/feature_flags/development/new_dir_modal.yml
new file mode 100644
index 0000000000000000000000000000000000000000..12d007209b70b2585a08e9e574d5c67af05e5e8c
--- /dev/null
+++ b/config/feature_flags/development/new_dir_modal.yml
@@ -0,0 +1,8 @@
+---
+name: new_dir_modal
+introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/71154
+rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/341675
+milestone: '14.4'
+type: development
+group: group::source code
+default_enabled: true
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 7e38030b870ca952426e12ce7bbe8ff5ceb303f5..6ed55aed21138987160043d1c5e412ddf6caf3cd 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -13272,6 +13272,9 @@ msgstr ""
msgid "Error creating label."
msgstr ""
+msgid "Error creating new directory. Please try again."
+msgstr ""
+
msgid "Error creating new iteration"
msgstr ""
diff --git a/spec/features/projects/files/user_creates_directory_spec.rb b/spec/features/projects/files/user_creates_directory_spec.rb
index 46b93d738e11c8e7126e6914aeb46c0bfa316646..5ad7641a5be0f329816d3139c43a99688210cf81 100644
--- a/spec/features/projects/files/user_creates_directory_spec.rb
+++ b/spec/features/projects/files/user_creates_directory_spec.rb
@@ -98,12 +98,14 @@
expect(page).to have_content(fork_message)
find('.add-to-tree').click
+ wait_for_requests
click_link('New directory')
fill_in(:dir_name, with: 'new_directory')
fill_in(:commit_message, with: 'New commit message', visible: true)
click_button('Create directory')
fork = user.fork_of(project2.reload)
+ wait_for_requests
expect(current_path).to eq(project_new_merge_request_path(fork))
end
diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/breadcrumbs_spec.js
index 0733cffe4f41a882982463044d3f928699e2471a..eb957c635ac4ff0ee0531692bb1e48fd9179d52d 100644
--- a/spec/frontend/repository/components/breadcrumbs_spec.js
+++ b/spec/frontend/repository/components/breadcrumbs_spec.js
@@ -2,6 +2,7 @@ import { GlDropdown } from '@gitlab/ui';
import { shallowMount, RouterLinkStub } from '@vue/test-utils';
import Breadcrumbs from '~/repository/components/breadcrumbs.vue';
import UploadBlobModal from '~/repository/components/upload_blob_modal.vue';
+import NewDirectoryModal from '~/repository/components/new_directory_modal.vue';
const defaultMockRoute = {
name: 'blobPath',
@@ -10,7 +11,7 @@ const defaultMockRoute = {
describe('Repository breadcrumbs component', () => {
let wrapper;
- const factory = (currentPath, extraProps = {}, mockRoute = {}) => {
+ const factory = (currentPath, extraProps = {}, mockRoute = {}, newDirModal = true) => {
const $apollo = {
queries: {
userPermissions: {
@@ -34,10 +35,12 @@ describe('Repository breadcrumbs component', () => {
},
$apollo,
},
+ provide: { glFeatures: { newDirModal } },
});
};
const findUploadBlobModal = () => wrapper.find(UploadBlobModal);
+ const findNewDirectoryModal = () => wrapper.find(NewDirectoryModal);
afterEach(() => {
wrapper.destroy();
@@ -121,4 +124,37 @@ describe('Repository breadcrumbs component', () => {
expect(findUploadBlobModal().exists()).toBe(true);
});
});
+
+ describe('renders the new directory modal', () => {
+ describe('with the feature flag enabled', () => {
+ beforeEach(() => {
+ window.gon.features = {
+ newDirModal: true,
+ };
+ factory('/', { canEditTree: true });
+ });
+
+ it('does not render the modal while loading', () => {
+ expect(findNewDirectoryModal().exists()).toBe(false);
+ });
+
+ it('renders the modal once loaded', async () => {
+ wrapper.setData({ $apollo: { queries: { userPermissions: { loading: false } } } });
+
+ await wrapper.vm.$nextTick();
+
+ expect(findNewDirectoryModal().exists()).toBe(true);
+ });
+ });
+
+ describe('with the feature flag disabled', () => {
+ it('does not render the modal', () => {
+ window.gon.features = {
+ newDirModal: false,
+ };
+ factory('/', { canEditTree: true }, {}, {}, false);
+ expect(findNewDirectoryModal().exists()).toBe(false);
+ });
+ });
+ });
});
diff --git a/spec/frontend/repository/components/new_directory_modal_spec.js b/spec/frontend/repository/components/new_directory_modal_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..fe7f024e3eaaa0e77865be6ce4dcb0e41353f267
--- /dev/null
+++ b/spec/frontend/repository/components/new_directory_modal_spec.js
@@ -0,0 +1,203 @@
+import { GlModal, GlFormTextarea, GlToggle } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import waitForPromises from 'helpers/wait_for_promises';
+import createFlash from '~/flash';
+import httpStatusCodes from '~/lib/utils/http_status';
+import { visitUrl } from '~/lib/utils/url_utility';
+import NewDirectoryModal from '~/repository/components/new_directory_modal.vue';
+
+jest.mock('~/flash');
+jest.mock('~/lib/utils/url_utility', () => ({
+ visitUrl: jest.fn(),
+}));
+
+const initialProps = {
+ modalTitle: 'Create New Directory',
+ modalId: 'modal-new-directory',
+ commitMessage: 'Add new directory',
+ targetBranch: 'some-target-branch',
+ originalBranch: 'master',
+ canPushCode: true,
+ path: 'create_dir',
+};
+
+const defaultFormValue = {
+ dirName: 'foo',
+ originalBranch: initialProps.originalBranch,
+ branchName: initialProps.targetBranch,
+ commitMessage: initialProps.commitMessage,
+ createNewMr: true,
+};
+
+describe('NewDirectoryModal', () => {
+ let wrapper;
+ let mock;
+
+ const createComponent = (props = {}) => {
+ wrapper = shallowMount(NewDirectoryModal, {
+ propsData: {
+ ...initialProps,
+ ...props,
+ },
+ attrs: {
+ static: true,
+ visible: true,
+ },
+ });
+ };
+
+ const findModal = () => wrapper.findComponent(GlModal);
+ const findDirName = () => wrapper.find('[name="dir_name"]');
+ const findBranchName = () => wrapper.find('[name="branch_name"]');
+ const findCommitMessage = () => wrapper.findComponent(GlFormTextarea);
+ const findMrToggle = () => wrapper.findComponent(GlToggle);
+
+ const fillForm = async (inputValue = {}) => {
+ const {
+ dirName = defaultFormValue.dirName,
+ branchName = defaultFormValue.branchName,
+ commitMessage = defaultFormValue.commitMessage,
+ createNewMr = true,
+ } = inputValue;
+
+ await findDirName().vm.$emit('input', dirName);
+ await findBranchName().vm.$emit('input', branchName);
+ await findCommitMessage().vm.$emit('input', commitMessage);
+ await findMrToggle().vm.$emit('change', createNewMr);
+ await nextTick;
+ };
+
+ const submitForm = async () => {
+ const mockEvent = { preventDefault: jest.fn() };
+ findModal().vm.$emit('primary', mockEvent);
+ await waitForPromises();
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders modal component', () => {
+ createComponent();
+
+ const { modalTitle: title } = initialProps;
+
+ expect(findModal().props()).toMatchObject({
+ title,
+ size: 'md',
+ actionPrimary: {
+ text: NewDirectoryModal.i18n.PRIMARY_OPTIONS_TEXT,
+ },
+ actionCancel: {
+ text: 'Cancel',
+ },
+ });
+ });
+
+ describe('form', () => {
+ it.each`
+ component | defaultValue | canPushCode | targetBranch | originalBranch | exist
+ ${findDirName} | ${undefined} | ${true} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${true}
+ ${findBranchName} | ${initialProps.targetBranch} | ${true} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${true}
+ ${findBranchName} | ${undefined} | ${false} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${false}
+ ${findCommitMessage} | ${initialProps.commitMessage} | ${true} | ${initialProps.targetBranch} | ${initialProps.originalBranch} | ${true}
+ ${findMrToggle} | ${'true'} | ${true} | ${'new-target-branch'} | ${'master'} | ${true}
+ ${findMrToggle} | ${'true'} | ${true} | ${'master'} | ${'master'} | ${true}
+ `(
+ 'has the correct form fields ',
+ ({ component, defaultValue, canPushCode, targetBranch, originalBranch, exist }) => {
+ createComponent({
+ canPushCode,
+ targetBranch,
+ originalBranch,
+ });
+ const formField = component();
+
+ if (!exist) {
+ expect(formField.exists()).toBe(false);
+ return;
+ }
+
+ expect(formField.exists()).toBe(true);
+ expect(formField.attributes('value')).toBe(defaultValue);
+ },
+ );
+ });
+
+ describe('form submission', () => {
+ beforeEach(async () => {
+ mock = new MockAdapter(axios);
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('valid form', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('passes the formData', async () => {
+ const {
+ dirName,
+ branchName,
+ commitMessage,
+ originalBranch,
+ createNewMr,
+ } = defaultFormValue;
+ mock.onPost(initialProps.path).reply(httpStatusCodes.OK, {});
+ await fillForm();
+ await submitForm();
+
+ expect(mock.history.post[0].data.get('dir_name')).toEqual(dirName);
+ expect(mock.history.post[0].data.get('branch_name')).toEqual(branchName);
+ expect(mock.history.post[0].data.get('commit_message')).toEqual(commitMessage);
+ expect(mock.history.post[0].data.get('original_branch')).toEqual(originalBranch);
+ expect(mock.history.post[0].data.get('create_merge_request')).toEqual(String(createNewMr));
+ });
+
+ it('does not submit "create_merge_request" formData if createNewMr is not checked', async () => {
+ mock.onPost(initialProps.path).reply(httpStatusCodes.OK, {});
+ await fillForm({ createNewMr: false });
+ await submitForm();
+ expect(mock.history.post[0].data.get('create_merge_request')).toBeNull();
+ });
+
+ it('redirects to the new directory', async () => {
+ const response = { filePath: 'new-dir-path' };
+ mock.onPost(initialProps.path).reply(httpStatusCodes.OK, response);
+
+ await fillForm({ dirName: 'foo', branchName: 'master', commitMessage: 'foo' });
+ await submitForm();
+
+ expect(visitUrl).toHaveBeenCalledWith(response.filePath);
+ });
+ });
+
+ describe('invalid form', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('disables submit button', async () => {
+ await fillForm({ dirName: '', branchName: '', commitMessage: '' });
+ expect(findModal().props('actionPrimary').attributes[0].disabled).toBe(true);
+ });
+
+ it('creates a flash error', async () => {
+ mock.onPost(initialProps.path).timeout();
+
+ await fillForm({ dirName: 'foo', branchName: 'master', commitMessage: 'foo' });
+ await submitForm();
+
+ expect(createFlash).toHaveBeenCalledWith({
+ message: NewDirectoryModal.i18n.ERROR_MESSAGE,
+ });
+ });
+ });
+ });
+});