Commit b0f1f2b4 authored by Jacques Erasmus's avatar Jacques Erasmus

Merge branch '15013-fork-form-validation' into 'master'

Add validation to fork form with validation directive

See merge request gitlab-org/gitlab!55838
parents 3718f4ec 6c1f5c1b
...@@ -20,6 +20,7 @@ import axios from '~/lib/utils/axios_utils'; ...@@ -20,6 +20,7 @@ import axios from '~/lib/utils/axios_utils';
import csrf from '~/lib/utils/csrf'; import csrf from '~/lib/utils/csrf';
import { redirectTo } from '~/lib/utils/url_utility'; import { redirectTo } from '~/lib/utils/url_utility';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import validation from '~/vue_shared/directives/validation';
const PRIVATE_VISIBILITY = 'private'; const PRIVATE_VISIBILITY = 'private';
const INTERNAL_VISIBILITY = 'internal'; const INTERNAL_VISIBILITY = 'internal';
...@@ -31,6 +32,13 @@ const ALLOWED_VISIBILITY = { ...@@ -31,6 +32,13 @@ const ALLOWED_VISIBILITY = {
public: [INTERNAL_VISIBILITY, PRIVATE_VISIBILITY, PUBLIC_VISIBILITY], public: [INTERNAL_VISIBILITY, PRIVATE_VISIBILITY, PUBLIC_VISIBILITY],
}; };
const initFormField = ({ value, required = true, skipValidation = false }) => ({
value,
required,
state: skipValidation ? true : null,
feedback: null,
});
export default { export default {
components: { components: {
GlForm, GlForm,
...@@ -46,6 +54,9 @@ export default { ...@@ -46,6 +54,9 @@ export default {
GlFormRadioGroup, GlFormRadioGroup,
GlFormSelect, GlFormSelect,
}, },
directives: {
validation: validation(),
},
inject: { inject: {
newGroupPath: { newGroupPath: {
default: '', default: '',
...@@ -77,7 +88,8 @@ export default { ...@@ -77,7 +88,8 @@ export default {
}, },
projectDescription: { projectDescription: {
type: String, type: String,
required: true, required: false,
default: '',
}, },
projectVisibility: { projectVisibility: {
type: String, type: String,
...@@ -85,16 +97,30 @@ export default { ...@@ -85,16 +97,30 @@ export default {
}, },
}, },
data() { data() {
const form = {
state: false,
showValidation: false,
fields: {
namespace: initFormField({
value: null,
}),
name: initFormField({ value: this.projectName }),
slug: initFormField({ value: this.projectPath }),
description: initFormField({
value: this.projectDescription,
required: false,
skipValidation: true,
}),
visibility: initFormField({
value: this.projectVisibility,
skipValidation: true,
}),
},
};
return { return {
isSaving: false, isSaving: false,
namespaces: [], namespaces: [],
selectedNamespace: {}, form,
fork: {
name: this.projectName,
slug: this.projectPath,
description: this.projectDescription,
visibility: this.projectVisibility,
},
}; };
}, },
computed: { computed: {
...@@ -106,7 +132,7 @@ export default { ...@@ -106,7 +132,7 @@ export default {
}, },
namespaceAllowedVisibility() { namespaceAllowedVisibility() {
return ( return (
ALLOWED_VISIBILITY[this.selectedNamespace.visibility] || ALLOWED_VISIBILITY[this.form.fields.namespace.value?.visibility] ||
ALLOWED_VISIBILITY[PUBLIC_VISIBILITY] ALLOWED_VISIBILITY[PUBLIC_VISIBILITY]
); );
}, },
...@@ -139,16 +165,17 @@ export default { ...@@ -139,16 +165,17 @@ export default {
}, },
}, },
watch: { watch: {
selectedNamespace(newVal) { // eslint-disable-next-line func-names
'form.fields.namespace.value': function (newVal) {
const { visibility } = newVal; const { visibility } = newVal;
if (this.projectAllowedVisibility.includes(visibility)) { if (this.projectAllowedVisibility.includes(visibility)) {
this.fork.visibility = visibility; this.form.fields.visibility.value = visibility;
} }
}, },
// eslint-disable-next-line func-names // eslint-disable-next-line func-names
'fork.name': function (newVal) { 'form.fields.name.value': function (newVal) {
this.fork.slug = kebabCase(newVal); this.form.fields.slug.value = kebabCase(newVal);
}, },
}, },
mounted() { mounted() {
...@@ -166,19 +193,25 @@ export default { ...@@ -166,19 +193,25 @@ export default {
); );
}, },
async onSubmit() { async onSubmit() {
this.form.showValidation = true;
if (!this.form.state) {
return;
}
this.isSaving = true; this.isSaving = true;
this.form.showValidation = false;
const { projectId } = this; const { projectId } = this;
const { name, slug, description, visibility } = this.fork; const { name, slug, description, visibility, namespace } = this.form.fields;
const { id: namespaceId } = this.selectedNamespace;
const postParams = { const postParams = {
id: projectId, id: projectId,
name, name: name.value,
namespace_id: namespaceId, namespace_id: namespace.value.id,
path: slug, path: slug.value,
description, description: description.value,
visibility, visibility: visibility.value,
}; };
const forkProjectPath = `/api/:version/projects/:id/fork`; const forkProjectPath = `/api/:version/projects/:id/fork`;
...@@ -198,16 +231,34 @@ export default { ...@@ -198,16 +231,34 @@ export default {
</script> </script>
<template> <template>
<gl-form method="POST" @submit.prevent="onSubmit"> <gl-form novalidate method="POST" @submit.prevent="onSubmit">
<input type="hidden" name="authenticity_token" :value="$options.csrf.token" /> <input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
<gl-form-group label="Project name" label-for="fork-name"> <gl-form-group
<gl-form-input id="fork-name" v-model="fork.name" data-testid="fork-name-input" required /> :label="__('Project name')"
label-for="fork-name"
:invalid-feedback="form.fields.name.feedback"
>
<gl-form-input
id="fork-name"
v-model="form.fields.name.value"
v-validation:[form.showValidation]
name="name"
data-testid="fork-name-input"
:state="form.fields.name.state"
required
/>
</gl-form-group> </gl-form-group>
<div class="gl-md-display-flex"> <div class="gl-md-display-flex">
<div class="gl-flex-basis-half"> <div class="gl-flex-basis-half">
<gl-form-group label="Project URL" label-for="fork-url" class="gl-md-mr-3"> <gl-form-group
:label="__('Project URL')"
label-for="fork-url"
class="gl-md-mr-3"
:state="form.fields.namespace.state"
:invalid-feedback="s__('ForkProject|Please select a namespace')"
>
<gl-form-input-group> <gl-form-input-group>
<template #prepend> <template #prepend>
<gl-input-group-text> <gl-input-group-text>
...@@ -216,9 +267,12 @@ export default { ...@@ -216,9 +267,12 @@ export default {
</template> </template>
<gl-form-select <gl-form-select
id="fork-url" id="fork-url"
v-model="selectedNamespace" v-model="form.fields.namespace.value"
v-validation:[form.showValidation]
name="namespace"
data-testid="fork-url-input" data-testid="fork-url-input"
data-qa-selector="fork_namespace_dropdown" data-qa-selector="fork_namespace_dropdown"
:state="form.fields.namespace.state"
required required
> >
<template slot="first"> <template slot="first">
...@@ -232,11 +286,19 @@ export default { ...@@ -232,11 +286,19 @@ export default {
</gl-form-group> </gl-form-group>
</div> </div>
<div class="gl-flex-basis-half"> <div class="gl-flex-basis-half">
<gl-form-group label="Project slug" label-for="fork-slug" class="gl-md-ml-3"> <gl-form-group
:label="__('Project slug')"
label-for="fork-slug"
class="gl-md-ml-3"
:invalid-feedback="form.fields.slug.feedback"
>
<gl-form-input <gl-form-input
id="fork-slug" id="fork-slug"
v-model="fork.slug" v-model="form.fields.slug.value"
v-validation:[form.showValidation]
data-testid="fork-slug-input" data-testid="fork-slug-input"
name="slug"
:state="form.fields.slug.state"
required required
/> />
</gl-form-group> </gl-form-group>
...@@ -250,11 +312,13 @@ export default { ...@@ -250,11 +312,13 @@ export default {
</gl-link> </gl-link>
</p> </p>
<gl-form-group label="Project description (optional)" label-for="fork-description"> <gl-form-group :label="__('Project description (optional)')" label-for="fork-description">
<gl-form-textarea <gl-form-textarea
id="fork-description" id="fork-description"
v-model="fork.description" v-model="form.fields.description.value"
data-testid="fork-description-textarea" data-testid="fork-description-textarea"
name="description"
:state="form.fields.description.state"
/> />
</gl-form-group> </gl-form-group>
...@@ -266,8 +330,9 @@ export default { ...@@ -266,8 +330,9 @@ export default {
</gl-link> </gl-link>
</label> </label>
<gl-form-radio-group <gl-form-radio-group
v-model="fork.visibility" v-model="form.fields.visibility.value"
data-testid="fork-visibility-radio-group" data-testid="fork-visibility-radio-group"
name="visibility"
required required
> >
<gl-form-radio <gl-form-radio
...@@ -291,6 +356,7 @@ export default { ...@@ -291,6 +356,7 @@ export default {
type="submit" type="submit"
category="primary" category="primary"
variant="confirm" variant="confirm"
class="js-no-auto-disable"
data-testid="submit-button" data-testid="submit-button"
data-qa-selector="fork_project_button" data-qa-selector="fork_project_button"
:loading="isSaving" :loading="isSaving"
......
...@@ -14071,6 +14071,9 @@ msgstr "" ...@@ -14071,6 +14071,9 @@ msgstr ""
msgid "ForkProject|Internal" msgid "ForkProject|Internal"
msgstr "" msgstr ""
msgid "ForkProject|Please select a namespace"
msgstr ""
msgid "ForkProject|Private" msgid "ForkProject|Private"
msgstr "" msgstr ""
......
...@@ -6,7 +6,7 @@ RSpec.describe 'Project fork' do ...@@ -6,7 +6,7 @@ RSpec.describe 'Project fork' do
include ProjectForksHelper include ProjectForksHelper
let(:user) { create(:user) } let(:user) { create(:user) }
let(:project) { create(:project, :public, :repository) } let(:project) { create(:project, :public, :repository, description: 'some description') }
before do before do
sign_in(user) sign_in(user)
......
import { GlForm, GlFormInputGroup, GlFormInput } from '@gitlab/ui'; import { GlFormInputGroup, GlFormInput, GlForm } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { mount, shallowMount } from '@vue/test-utils';
import axios from 'axios'; import axios from 'axios';
import AxiosMockAdapter from 'axios-mock-adapter'; import AxiosMockAdapter from 'axios-mock-adapter';
import { kebabCase } from 'lodash'; import { kebabCase } from 'lodash';
...@@ -43,8 +43,8 @@ describe('ForkForm component', () => { ...@@ -43,8 +43,8 @@ describe('ForkForm component', () => {
axiosMock.onGet(DEFAULT_PROPS.endpoint).replyOnce(statusCode, data); axiosMock.onGet(DEFAULT_PROPS.endpoint).replyOnce(statusCode, data);
}; };
const createComponent = (props = {}, data = {}) => { const createComponentFactory = (mountFn) => (props = {}, data = {}) => {
wrapper = shallowMount(ForkForm, { wrapper = mountFn(ForkForm, {
provide: { provide: {
newGroupPath: 'some/groups/path', newGroupPath: 'some/groups/path',
visibilityHelpPath: 'some/visibility/help/path', visibilityHelpPath: 'some/visibility/help/path',
...@@ -65,6 +65,9 @@ describe('ForkForm component', () => { ...@@ -65,6 +65,9 @@ describe('ForkForm component', () => {
}); });
}; };
const createComponent = createComponentFactory(shallowMount);
const createFullComponent = createComponentFactory(mount);
beforeEach(() => { beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios); axiosMock = new AxiosMockAdapter(axios);
window.gon = { window.gon = {
...@@ -99,44 +102,6 @@ describe('ForkForm component', () => { ...@@ -99,44 +102,6 @@ describe('ForkForm component', () => {
expect(cancelButton.attributes('href')).toBe(projectFullPath); expect(cancelButton.attributes('href')).toBe(projectFullPath);
}); });
it('make POST request with project param', async () => {
jest.spyOn(axios, 'post');
const namespaceId = 20;
mockGetRequest();
createComponent(
{},
{
selectedNamespace: {
id: namespaceId,
},
},
);
wrapper.find(GlForm).vm.$emit('submit', { preventDefault: () => {} });
const {
projectId,
projectDescription,
projectName,
projectPath,
projectVisibility,
} = DEFAULT_PROPS;
const url = `/api/${GON_API_VERSION}/projects/${projectId}/fork`;
const project = {
description: projectDescription,
id: projectId,
name: projectName,
namespace_id: namespaceId,
path: projectPath,
visibility: projectVisibility,
};
expect(axios.post).toHaveBeenCalledWith(url, project);
});
it('has input with csrf token', () => { it('has input with csrf token', () => {
mockGetRequest(); mockGetRequest();
createComponent(); createComponent();
...@@ -258,9 +223,7 @@ describe('ForkForm component', () => { ...@@ -258,9 +223,7 @@ describe('ForkForm component', () => {
projectVisibility: project, projectVisibility: project,
}, },
{ {
selectedNamespace: { form: { fields: { namespace: { value: { visibility: namespace } } } },
visibility: namespace,
},
}, },
); );
...@@ -274,34 +237,101 @@ describe('ForkForm component', () => { ...@@ -274,34 +237,101 @@ describe('ForkForm component', () => {
describe('onSubmit', () => { describe('onSubmit', () => {
beforeEach(() => { beforeEach(() => {
jest.spyOn(urlUtility, 'redirectTo').mockImplementation(); jest.spyOn(urlUtility, 'redirectTo').mockImplementation();
mockGetRequest();
createFullComponent(
{},
{
namespaces: MOCK_NAMESPACES_RESPONSE,
form: {
state: true,
},
},
);
}); });
it('redirect to POST web_url response', async () => { const selectedMockNamespaceIndex = 1;
const webUrl = `new/fork-project`; const namespaceId = MOCK_NAMESPACES_RESPONSE[selectedMockNamespaceIndex].id;
jest.spyOn(axios, 'post').mockResolvedValue({ data: { web_url: webUrl } }); const fillForm = async () => {
const namespaceOptions = findForkUrlInput().findAll('option');
mockGetRequest(); await namespaceOptions.at(selectedMockNamespaceIndex + 1).setSelected();
createComponent(); };
await wrapper.vm.onSubmit(); const submitForm = async () => {
await fillForm();
const form = wrapper.find(GlForm);
expect(urlUtility.redirectTo).toHaveBeenCalledWith(webUrl); await form.trigger('submit');
await wrapper.vm.$nextTick();
};
describe('with invalid form', () => {
it('does not make POST request', async () => {
jest.spyOn(axios, 'post');
expect(axios.post).not.toHaveBeenCalled();
});
it('does not redirect the current page', async () => {
await submitForm();
expect(urlUtility.redirectTo).not.toHaveBeenCalled();
});
}); });
it('display flash when POST is unsuccessful', async () => { describe('with valid form', () => {
const dummyError = 'Fork project failed'; beforeEach(() => {
fillForm();
});
jest.spyOn(axios, 'post').mockRejectedValue(dummyError); it('make POST request with project param', async () => {
jest.spyOn(axios, 'post');
await submitForm();
const {
projectId,
projectDescription,
projectName,
projectPath,
projectVisibility,
} = DEFAULT_PROPS;
const url = `/api/${GON_API_VERSION}/projects/${projectId}/fork`;
const project = {
description: projectDescription,
id: projectId,
name: projectName,
namespace_id: namespaceId,
path: projectPath,
visibility: projectVisibility,
};
mockGetRequest(); expect(axios.post).toHaveBeenCalledWith(url, project);
createComponent(); });
it('redirect to POST web_url response', async () => {
const webUrl = `new/fork-project`;
jest.spyOn(axios, 'post').mockResolvedValue({ data: { web_url: webUrl } });
await submitForm();
expect(urlUtility.redirectTo).toHaveBeenCalledWith(webUrl);
});
it('display flash when POST is unsuccessful', async () => {
const dummyError = 'Fork project failed';
jest.spyOn(axios, 'post').mockRejectedValue(dummyError);
await wrapper.vm.onSubmit(); await submitForm();
expect(urlUtility.redirectTo).not.toHaveBeenCalled(); expect(urlUtility.redirectTo).not.toHaveBeenCalled();
expect(createFlash).toHaveBeenCalledWith({ expect(createFlash).toHaveBeenCalledWith({
message: dummyError, message: dummyError,
});
}); });
}); });
}); });
......
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