Commit 50001036 authored by Jacques Erasmus's avatar Jacques Erasmus

Merge branch '229683-new-directory-modal' into 'master'

Add new dir modal in vue

See merge request gitlab-org/gitlab!71154
parents 83dfd096 11560ea6
...@@ -9,11 +9,13 @@ import { ...@@ -9,11 +9,13 @@ import {
} from '@gitlab/ui'; } from '@gitlab/ui';
import permissionsQuery from 'shared_queries/repository/permissions.query.graphql'; import permissionsQuery from 'shared_queries/repository/permissions.query.graphql';
import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility'; import { joinPaths, escapeFileUrl } from '~/lib/utils/url_utility';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import { __ } from '../../locale'; import { __ } from '../../locale';
import getRefMixin from '../mixins/get_ref'; import getRefMixin from '../mixins/get_ref';
import projectPathQuery from '../queries/project_path.query.graphql'; import projectPathQuery from '../queries/project_path.query.graphql';
import projectShortPathQuery from '../queries/project_short_path.query.graphql'; import projectShortPathQuery from '../queries/project_short_path.query.graphql';
import UploadBlobModal from './upload_blob_modal.vue'; import UploadBlobModal from './upload_blob_modal.vue';
import NewDirectoryModal from './new_directory_modal.vue';
const ROW_TYPES = { const ROW_TYPES = {
header: 'header', header: 'header',
...@@ -21,6 +23,7 @@ const ROW_TYPES = { ...@@ -21,6 +23,7 @@ const ROW_TYPES = {
}; };
const UPLOAD_BLOB_MODAL_ID = 'modal-upload-blob'; const UPLOAD_BLOB_MODAL_ID = 'modal-upload-blob';
const NEW_DIRECTORY_MODAL_ID = 'modal-new-directory';
export default { export default {
components: { components: {
...@@ -30,6 +33,7 @@ export default { ...@@ -30,6 +33,7 @@ export default {
GlDropdownItem, GlDropdownItem,
GlIcon, GlIcon,
UploadBlobModal, UploadBlobModal,
NewDirectoryModal,
}, },
apollo: { apollo: {
projectShortPath: { projectShortPath: {
...@@ -54,7 +58,7 @@ export default { ...@@ -54,7 +58,7 @@ export default {
directives: { directives: {
GlModal: GlModalDirective, GlModal: GlModalDirective,
}, },
mixins: [getRefMixin], mixins: [getRefMixin, glFeatureFlagsMixin()],
props: { props: {
currentPath: { currentPath: {
type: String, type: String,
...@@ -121,8 +125,14 @@ export default { ...@@ -121,8 +125,14 @@ export default {
required: false, required: false,
default: '', default: '',
}, },
newDirPath: {
type: String,
required: false,
default: '',
},
}, },
uploadBlobModalId: UPLOAD_BLOB_MODAL_ID, uploadBlobModalId: UPLOAD_BLOB_MODAL_ID,
newDirectoryModalId: NEW_DIRECTORY_MODAL_ID,
data() { data() {
return { return {
projectShortPath: '', projectShortPath: '',
...@@ -160,6 +170,13 @@ export default { ...@@ -160,6 +170,13 @@ export default {
showUploadModal() { showUploadModal() {
return this.canEditTree && !this.$apollo.queries.userPermissions.loading; return this.canEditTree && !this.$apollo.queries.userPermissions.loading;
}, },
showNewDirectoryModal() {
return (
this.glFeatures.newDirModal &&
this.canEditTree &&
!this.$apollo.queries.userPermissions.loading
);
},
dropdownItems() { dropdownItems() {
const items = []; const items = [];
...@@ -185,15 +202,26 @@ export default { ...@@ -185,15 +202,26 @@ export default {
text: __('Upload file'), text: __('Upload file'),
modalId: UPLOAD_BLOB_MODAL_ID, 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: { attrs: {
href: '#modal-create-new-dir', href: '#modal-create-new-dir',
'data-target': '#modal-create-new-dir', 'data-target': '#modal-create-new-dir',
'data-toggle': 'modal', 'data-toggle': 'modal',
}, },
text: __('New directory'), text: __('New directory'),
}, });
); }
} else if (this.canCreateMrFromFork) { } else if (this.canCreateMrFromFork) {
items.push( items.push(
{ {
...@@ -306,5 +334,14 @@ export default { ...@@ -306,5 +334,14 @@ export default {
:can-push-code="canPushCode" :can-push-code="canPushCode"
:path="uploadPath" :path="uploadPath"
/> />
<new-directory-modal
v-if="showNewDirectoryModal"
:can-push-code="canPushCode"
:modal-id="$options.newDirectoryModalId"
:commit-message="__('Add new directory')"
:target-branch="selectedBranch"
:original-branch="originalBranch"
:path="newDirPath"
/>
</nav> </nav>
</template> </template>
<script>
import {
GlAlert,
GlForm,
GlModal,
GlFormGroup,
GlFormInput,
GlFormTextarea,
GlToggle,
} from '@gitlab/ui';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
import { visitUrl } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import {
SECONDARY_OPTIONS_TEXT,
COMMIT_LABEL,
TARGET_BRANCH_LABEL,
TOGGLE_CREATE_MR_LABEL,
NEW_BRANCH_IN_FORK,
} from '../constants';
const MODAL_TITLE = __('Create New Directory');
const PRIMARY_OPTIONS_TEXT = __('Create directory');
const DIR_LABEL = __('Directory name');
const ERROR_MESSAGE = __('Error creating new directory. Please try again.');
export default {
components: {
GlAlert,
GlModal,
GlForm,
GlFormGroup,
GlFormInput,
GlFormTextarea,
GlToggle,
},
i18n: {
DIR_LABEL,
COMMIT_LABEL,
TARGET_BRANCH_LABEL,
TOGGLE_CREATE_MR_LABEL,
NEW_BRANCH_IN_FORK,
PRIMARY_OPTIONS_TEXT,
ERROR_MESSAGE,
},
props: {
modalTitle: {
type: String,
default: MODAL_TITLE,
required: false,
},
modalId: {
type: String,
required: true,
},
primaryBtnText: {
type: String,
default: PRIMARY_OPTIONS_TEXT,
required: false,
},
commitMessage: {
type: String,
required: true,
},
targetBranch: {
type: String,
required: true,
},
originalBranch: {
type: String,
required: true,
},
path: {
type: String,
required: true,
},
canPushCode: {
type: Boolean,
required: true,
},
},
data() {
return {
dir: null,
commit: this.commitMessage,
target: this.targetBranch,
createNewMr: true,
loading: false,
};
},
computed: {
primaryOptions() {
return {
text: this.primaryBtnText,
attributes: [
{
variant: 'confirm',
loading: this.loading,
disabled: !this.formCompleted || this.loading,
},
],
};
},
cancelOptions() {
return {
text: SECONDARY_OPTIONS_TEXT,
attributes: [
{
disabled: this.loading,
},
],
};
},
showCreateNewMrToggle() {
return this.canPushCode;
},
formCompleted() {
return this.dir && this.commit && this.target;
},
},
methods: {
submitForm() {
this.loading = true;
const formData = new FormData();
formData.append('dir_name', this.dir);
formData.append('commit_message', this.commit);
formData.append('branch_name', this.target);
formData.append('original_branch', this.originalBranch);
if (this.createNewMr) {
formData.append('create_merge_request', this.createNewMr);
}
return axios
.post(this.path, formData)
.then((response) => {
visitUrl(response.data.filePath);
})
.catch(() => {
this.loading = false;
createFlash({ message: ERROR_MESSAGE });
});
},
},
};
</script>
<template>
<gl-form>
<gl-modal
:modal-id="modalId"
:title="modalTitle"
:action-primary="primaryOptions"
:action-cancel="cancelOptions"
@primary.prevent="submitForm"
>
<gl-form-group :label="$options.i18n.DIR_LABEL" label-for="dir_name">
<gl-form-input v-model="dir" :disabled="loading" name="dir_name" />
</gl-form-group>
<gl-form-group :label="$options.i18n.COMMIT_LABEL" label-for="commit_message">
<gl-form-textarea v-model="commit" name="commit_message" :disabled="loading" />
</gl-form-group>
<gl-form-group
v-if="canPushCode"
:label="$options.i18n.TARGET_BRANCH_LABEL"
label-for="branch_name"
>
<gl-form-input v-model="target" :disabled="loading" name="branch_name" />
</gl-form-group>
<gl-toggle
v-if="showCreateNewMrToggle"
v-model="createNewMr"
:disabled="loading"
:label="$options.i18n.TOGGLE_CREATE_MR_LABEL"
/>
<gl-alert v-if="!canPushCode" variant="info" :dismissible="false" class="gl-mt-3">
{{ $options.i18n.NEW_BRANCH_IN_FORK }}
</gl-alert>
</gl-modal>
</gl-form>
</template>
...@@ -10,6 +10,9 @@ export const SECONDARY_OPTIONS_TEXT = __('Cancel'); ...@@ -10,6 +10,9 @@ export const SECONDARY_OPTIONS_TEXT = __('Cancel');
export const COMMIT_LABEL = __('Commit message'); export const COMMIT_LABEL = __('Commit message');
export const TARGET_BRANCH_LABEL = __('Target branch'); export const TARGET_BRANCH_LABEL = __('Target branch');
export const TOGGLE_CREATE_MR_LABEL = __('Start a new merge request with these changes'); 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_SUBJECT_MAX_LENGTH = 52;
export const COMMIT_MESSAGE_BODY_MAX_LENGTH = 72; export const COMMIT_MESSAGE_BODY_MAX_LENGTH = 72;
......
...@@ -120,6 +120,7 @@ export default function setupVueRepositoryList() { ...@@ -120,6 +120,7 @@ export default function setupVueRepositoryList() {
forkNewDirectoryPath, forkNewDirectoryPath,
forkUploadBlobPath, forkUploadBlobPath,
uploadPath, uploadPath,
newDirPath,
}, },
}); });
}, },
......
...@@ -18,6 +18,7 @@ class Projects::TreeController < Projects::ApplicationController ...@@ -18,6 +18,7 @@ class Projects::TreeController < Projects::ApplicationController
before_action do before_action do
push_frontend_feature_flag(:lazy_load_commits, @project, default_enabled: :yaml) 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(:paginated_tree_graphql_query, @project, default_enabled: :yaml)
push_frontend_feature_flag(:new_dir_modal, @project, default_enabled: :yaml)
end end
feature_category :source_code_management feature_category :source_code_management
......
...@@ -38,6 +38,7 @@ class ProjectsController < Projects::ApplicationController ...@@ -38,6 +38,7 @@ class ProjectsController < Projects::ApplicationController
push_frontend_feature_flag(:refactor_text_viewer, @project, default_enabled: :yaml) 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(:increase_page_size_exponentially, @project, default_enabled: :yaml)
push_frontend_feature_flag(:paginated_tree_graphql_query, @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 end
layout :determine_layout layout :determine_layout
......
...@@ -25,6 +25,7 @@ ...@@ -25,6 +25,7 @@
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...headers, ...headers,
}
}; };
gl.startup_graphql_calls = gl.startup_graphql_calls.map(call => ({ gl.startup_graphql_calls = gl.startup_graphql_calls.map(call => ({
......
...@@ -20,5 +20,6 @@ ...@@ -20,5 +20,6 @@
= render 'stat_anchor_list', anchors: @project.statistics_buttons(show_auto_devops_callout: show_auto_devops_callout), project_buttons: true = 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) } #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' = render 'projects/blob/new_dir'
---
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
...@@ -13272,6 +13272,9 @@ msgstr "" ...@@ -13272,6 +13272,9 @@ msgstr ""
msgid "Error creating label." msgid "Error creating label."
msgstr "" msgstr ""
msgid "Error creating new directory. Please try again."
msgstr ""
msgid "Error creating new iteration" msgid "Error creating new iteration"
msgstr "" msgstr ""
......
...@@ -98,12 +98,14 @@ RSpec.describe 'Projects > Files > User creates a directory', :js do ...@@ -98,12 +98,14 @@ RSpec.describe 'Projects > Files > User creates a directory', :js do
expect(page).to have_content(fork_message) expect(page).to have_content(fork_message)
find('.add-to-tree').click find('.add-to-tree').click
wait_for_requests
click_link('New directory') click_link('New directory')
fill_in(:dir_name, with: 'new_directory') fill_in(:dir_name, with: 'new_directory')
fill_in(:commit_message, with: 'New commit message', visible: true) fill_in(:commit_message, with: 'New commit message', visible: true)
click_button('Create directory') click_button('Create directory')
fork = user.fork_of(project2.reload) fork = user.fork_of(project2.reload)
wait_for_requests
expect(current_path).to eq(project_new_merge_request_path(fork)) expect(current_path).to eq(project_new_merge_request_path(fork))
end end
......
...@@ -2,6 +2,7 @@ import { GlDropdown } from '@gitlab/ui'; ...@@ -2,6 +2,7 @@ import { GlDropdown } from '@gitlab/ui';
import { shallowMount, RouterLinkStub } from '@vue/test-utils'; import { shallowMount, RouterLinkStub } from '@vue/test-utils';
import Breadcrumbs from '~/repository/components/breadcrumbs.vue'; import Breadcrumbs from '~/repository/components/breadcrumbs.vue';
import UploadBlobModal from '~/repository/components/upload_blob_modal.vue'; import UploadBlobModal from '~/repository/components/upload_blob_modal.vue';
import NewDirectoryModal from '~/repository/components/new_directory_modal.vue';
const defaultMockRoute = { const defaultMockRoute = {
name: 'blobPath', name: 'blobPath',
...@@ -10,7 +11,7 @@ const defaultMockRoute = { ...@@ -10,7 +11,7 @@ const defaultMockRoute = {
describe('Repository breadcrumbs component', () => { describe('Repository breadcrumbs component', () => {
let wrapper; let wrapper;
const factory = (currentPath, extraProps = {}, mockRoute = {}) => { const factory = (currentPath, extraProps = {}, mockRoute = {}, newDirModal = true) => {
const $apollo = { const $apollo = {
queries: { queries: {
userPermissions: { userPermissions: {
...@@ -34,10 +35,12 @@ describe('Repository breadcrumbs component', () => { ...@@ -34,10 +35,12 @@ describe('Repository breadcrumbs component', () => {
}, },
$apollo, $apollo,
}, },
provide: { glFeatures: { newDirModal } },
}); });
}; };
const findUploadBlobModal = () => wrapper.find(UploadBlobModal); const findUploadBlobModal = () => wrapper.find(UploadBlobModal);
const findNewDirectoryModal = () => wrapper.find(NewDirectoryModal);
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
...@@ -121,4 +124,37 @@ describe('Repository breadcrumbs component', () => { ...@@ -121,4 +124,37 @@ describe('Repository breadcrumbs component', () => {
expect(findUploadBlobModal().exists()).toBe(true); 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);
});
});
});
}); });
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,
});
});
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment