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 {
} 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"
/>
<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>
</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');
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;
......
......@@ -120,6 +120,7 @@ export default function setupVueRepositoryList() {
forkNewDirectoryPath,
forkUploadBlobPath,
uploadPath,
newDirPath,
},
});
},
......
......@@ -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
......
......@@ -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
......
......@@ -25,6 +25,7 @@
headers: {
"Content-Type": "application/json",
...headers,
}
};
gl.startup_graphql_calls = gl.startup_graphql_calls.map(call => ({
......
......@@ -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'
---
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 ""
msgid "Error creating label."
msgstr ""
msgid "Error creating new directory. Please try again."
msgstr ""
msgid "Error creating new iteration"
msgstr ""
......
......@@ -98,12 +98,14 @@ RSpec.describe 'Projects > Files > User creates a directory', :js do
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
......
......@@ -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);
});
});
});
});
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