Commit 41a515c8 authored by Dave Pisek's avatar Dave Pisek

Add and use constraints API vue directive for form validation

This commit adds a new vue-directive that allows to use the
native constraints API for validating html-form elements.

It also changes the DAST on-demand scans site-profile form to move
from manual form-validation to using the new directive.
parent 5ed6c6a1
import { merge } from 'lodash';
import { s__ } from '~/locale';
export const defaultValidationMessages = {
urlTypeMismatch: {
check: el => el.type === 'url' && el.validity?.typeMismatch,
message: s__('Please enter a valid URL format, ex: http://www.example.com/home'),
},
};
const getCustomValidationMessage = (feedback, el) =>
Object.values(feedback).find(f => f.check(el))?.message || '';
const focusFirstInvalidInput = e => {
const { target: formEl } = e;
const invalidInput = formEl.querySelector('input:invalid');
if (invalidInput) {
invalidInput.focus();
}
};
const createValidator = (context, validationMessages) => el => {
const { form } = context;
const { name } = el;
const isValid = el.checkValidity();
form.fields[name].state = isValid;
form.fields[name].feedback =
getCustomValidationMessage(validationMessages, el) || el.validationMessage;
form.state = !Object.values(form.fields).some(field => field.state === false);
return isValid;
};
export default function(customValidationMessages = {}) {
const feedback = merge(defaultValidationMessages, customValidationMessages);
const elDataMap = new WeakMap();
return {
inserted(el, binding, { context }) {
const { arg: showGlobalValidation } = binding;
const { form: formEl } = el;
const validate = createValidator(context, feedback);
const elData = { validate, isTouched: false, isBlurred: false };
elDataMap.set(el, elData);
el.addEventListener('input', function markAsTouched() {
elData.isTouched = true;
el.removeEventListener('input', markAsTouched);
});
el.addEventListener('blur', function markAsBlurred({ target }) {
if (elData.isTouched) {
elData.isBlurred = true;
validate(target);
// this event handler can be removed, since the live-feedback now takes over
el.removeEventListener('blur', markAsBlurred);
}
});
if (formEl) {
formEl.addEventListener('submit', focusFirstInvalidInput);
}
if (showGlobalValidation) {
validate(el);
}
},
update(el, binding) {
const { arg: showGlobalValidation } = binding;
const { validate, isTouched, isBlurred } = elDataMap.get(el);
// trigger live-feedback once the element has been touched an clicked way from
if (showGlobalValidation || (isTouched && isBlurred)) {
validate(el);
}
},
};
}
...@@ -12,10 +12,11 @@ import { ...@@ -12,10 +12,11 @@ import {
} from '@gitlab/ui'; } from '@gitlab/ui';
import * as Sentry from '~/sentry/wrapper'; import * as Sentry from '~/sentry/wrapper';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import { isAbsolute, redirectTo } from '~/lib/utils/url_utility'; import { redirectTo } from '~/lib/utils/url_utility';
import { serializeFormObject, isEmptyValue } from '~/lib/utils/forms'; import { serializeFormObject } from '~/lib/utils/forms';
import { fetchPolicies } from '~/lib/graphql'; import { fetchPolicies } from '~/lib/graphql';
import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import validation from '~/vue_shared/directives/validation';
import DastSiteValidation from './dast_site_validation.vue'; import DastSiteValidation from './dast_site_validation.vue';
import dastSiteProfileCreateMutation from '../graphql/dast_site_profile_create.mutation.graphql'; import dastSiteProfileCreateMutation from '../graphql/dast_site_profile_create.mutation.graphql';
import dastSiteProfileUpdateMutation from '../graphql/dast_site_profile_update.mutation.graphql'; import dastSiteProfileUpdateMutation from '../graphql/dast_site_profile_update.mutation.graphql';
...@@ -44,6 +45,9 @@ export default { ...@@ -44,6 +45,9 @@ export default {
GlToggle, GlToggle,
DastSiteValidation, DastSiteValidation,
}, },
directives: {
validation: validation(),
},
mixins: [glFeatureFlagsMixin()], mixins: [glFeatureFlagsMixin()],
props: { props: {
fullPath: { fullPath: {
...@@ -64,14 +68,18 @@ export default { ...@@ -64,14 +68,18 @@ export default {
const { name = '', targetUrl = '' } = this.siteProfile || {}; const { name = '', targetUrl = '' } = this.siteProfile || {};
const form = { const form = {
profileName: initField(name), state: false,
targetUrl: initField(targetUrl), showValidation: false,
fields: {
profileName: initField(name),
targetUrl: initField(targetUrl),
},
}; };
return { return {
fetchValidationTimeout: null, fetchValidationTimeout: null,
form, form,
initialFormValues: serializeFormObject(form), initialFormValues: serializeFormObject(form.fields),
isFetchingValidationStatus: false, isFetchingValidationStatus: false,
isValidatingSite: false, isValidatingSite: false,
isLoading: false, isLoading: false,
...@@ -90,7 +98,7 @@ export default { ...@@ -90,7 +98,7 @@ export default {
return Boolean(this.siteProfile?.id); return Boolean(this.siteProfile?.id);
}, },
isSiteValidationDisabled() { isSiteValidationDisabled() {
return !this.form.targetUrl.state || this.validationStatusMatches(INPROGRESS); return !this.form.fields.targetUrl.state || this.validationStatusMatches(INPROGRESS);
}, },
i18n() { i18n() {
const { isEdit } = this; const { isEdit } = this;
...@@ -119,20 +127,12 @@ export default { ...@@ -119,20 +127,12 @@ export default {
}; };
}, },
formTouched() { formTouched() {
return !isEqual(serializeFormObject(this.form), this.initialFormValues); return !isEqual(serializeFormObject(this.form.fields), this.initialFormValues);
},
formHasErrors() {
return Object.values(this.form).some(({ state }) => state === false);
},
someFieldEmpty() {
return Object.values(this.form).some(({ value }) => isEmptyValue(value));
}, },
isSubmitDisabled() { isSubmitDisabled() {
return ( return (
(this.isSiteValidationActive && !this.validationStatusMatches(PASSED)) || this.validationStatusMatches(INPROGRESS) ||
this.formHasErrors || (this.isSiteValidationActive && !this.validationStatusMatches(PASSED))
this.someFieldEmpty ||
this.validationStatusMatches(INPROGRESS)
); );
}, },
showValidationSection() { showValidationSection() {
...@@ -169,9 +169,9 @@ export default { ...@@ -169,9 +169,9 @@ export default {
: defaultDescription; : defaultDescription;
}, },
}, },
async created() { async mounted() {
if (this.isEdit) { if (this.isEdit) {
this.validateTargetUrl(); this.form.showValidation = true;
if (this.glFeatures.securityOnDemandScansSiteValidation) { if (this.glFeatures.securityOnDemandScansSiteValidation) {
await this.fetchValidationStatus(); await this.fetchValidationStatus();
...@@ -213,17 +213,6 @@ export default { ...@@ -213,17 +213,6 @@ export default {
validationStatusMatches(status) { validationStatusMatches(status) {
return this.validationStatus === status; return this.validationStatus === status;
}, },
validateTargetUrl() {
if (!isAbsolute(this.form.targetUrl.value)) {
this.form.targetUrl.state = false;
this.form.targetUrl.feedback = s__(
'DastProfiles|Please enter a valid URL format, ex: http://www.example.com/home',
);
return;
}
this.form.targetUrl.state = true;
this.form.targetUrl.feedback = null;
},
async fetchValidationStatus() { async fetchValidationStatus() {
this.isFetchingValidationStatus = true; this.isFetchingValidationStatus = true;
...@@ -238,7 +227,7 @@ export default { ...@@ -238,7 +227,7 @@ export default {
query: dastSiteValidationQuery, query: dastSiteValidationQuery,
variables: { variables: {
fullPath: this.fullPath, fullPath: this.fullPath,
targetUrl: this.form.targetUrl.value, targetUrl: this.form.fields.targetUrl.value,
}, },
fetchPolicy: fetchPolicies.NETWORK_ONLY, fetchPolicy: fetchPolicies.NETWORK_ONLY,
}); });
...@@ -269,7 +258,10 @@ export default { ...@@ -269,7 +258,10 @@ export default {
}, },
} = await this.$apollo.mutate({ } = await this.$apollo.mutate({
mutation: dastSiteTokenCreateMutation, mutation: dastSiteTokenCreateMutation,
variables: { projectFullPath: this.fullPath, targetUrl: this.form.targetUrl.value }, variables: {
projectFullPath: this.fullPath,
targetUrl: this.form.fields.targetUrl.value,
},
}); });
if (errors.length) { if (errors.length) {
this.showErrors({ message: errorMessage, errors }); this.showErrors({ message: errorMessage, errors });
...@@ -284,6 +276,12 @@ export default { ...@@ -284,6 +276,12 @@ export default {
} }
}, },
onSubmit() { onSubmit() {
this.form.showValidation = true;
if (!this.form.state) {
return;
}
this.isLoading = true; this.isLoading = true;
this.hideErrors(); this.hideErrors();
const { errorMessage } = this.i18n; const { errorMessage } = this.i18n;
...@@ -291,7 +289,7 @@ export default { ...@@ -291,7 +289,7 @@ export default {
const variables = { const variables = {
fullPath: this.fullPath, fullPath: this.fullPath,
...(this.isEdit ? { id: this.siteProfile.id } : {}), ...(this.isEdit ? { id: this.siteProfile.id } : {}),
...serializeFormObject(this.form), ...serializeFormObject(this.form.fields),
}; };
this.$apollo this.$apollo
...@@ -351,7 +349,7 @@ export default { ...@@ -351,7 +349,7 @@ export default {
</script> </script>
<template> <template>
<gl-form @submit.prevent="onSubmit"> <gl-form novalidate @submit.prevent="onSubmit">
<h2 class="gl-mb-6"> <h2 class="gl-mb-6">
{{ i18n.title }} {{ i18n.title }}
</h2> </h2>
...@@ -369,12 +367,19 @@ export default { ...@@ -369,12 +367,19 @@ export default {
</ul> </ul>
</gl-alert> </gl-alert>
<gl-form-group :label="s__('DastProfiles|Profile name')"> <gl-form-group
:label="s__('DastProfiles|Profile name')"
:invalid-feedback="form.fields.profileName.feedback"
>
<gl-form-input <gl-form-input
v-model="form.profileName.value" v-model="form.fields.profileName.value"
v-validation:[form.showValidation]
name="profileName"
class="mw-460" class="mw-460"
data-testid="profile-name-input" data-testid="profile-name-input"
type="text" type="text"
required
:state="form.fields.profileName.state"
/> />
</gl-form-group> </gl-form-group>
...@@ -382,7 +387,7 @@ export default { ...@@ -382,7 +387,7 @@ export default {
<gl-form-group <gl-form-group
data-testid="target-url-input-group" data-testid="target-url-input-group"
:invalid-feedback="form.targetUrl.feedback" :invalid-feedback="form.fields.targetUrl.feedback"
:description=" :description="
isSiteValidationActive && !isValidatingSite isSiteValidationActive && !isValidatingSite
? s__('DastProfiles|Validation must be turned off to change the target URL') ? s__('DastProfiles|Validation must be turned off to change the target URL')
...@@ -391,13 +396,15 @@ export default { ...@@ -391,13 +396,15 @@ export default {
:label="s__('DastProfiles|Target URL')" :label="s__('DastProfiles|Target URL')"
> >
<gl-form-input <gl-form-input
v-model="form.targetUrl.value" v-model="form.fields.targetUrl.value"
v-validation:[form.showValidation]
name="targetUrl"
class="mw-460" class="mw-460"
data-testid="target-url-input" data-testid="target-url-input"
required
type="url" type="url"
:state="form.targetUrl.state" :state="form.fields.targetUrl.state"
:disabled="isSiteValidationActive" :disabled="isSiteValidationActive"
@input="validateTargetUrl"
/> />
</gl-form-group> </gl-form-group>
...@@ -429,7 +436,7 @@ export default { ...@@ -429,7 +436,7 @@ export default {
:full-path="fullPath" :full-path="fullPath"
:token-id="tokenId" :token-id="tokenId"
:token="token" :token="token"
:target-url="form.targetUrl.value" :target-url="form.fields.targetUrl.value"
@success="onValidationSuccess" @success="onValidationSuccess"
/> />
</gl-collapse> </gl-collapse>
......
...@@ -124,34 +124,6 @@ describe('DastSiteProfileForm', () => { ...@@ -124,34 +124,6 @@ describe('DastSiteProfileForm', () => {
expect(wrapper.html()).not.toBe(''); expect(wrapper.html()).not.toBe('');
}); });
describe('submit button', () => {
beforeEach(() => {
createComponent();
});
describe('is disabled if', () => {
it('form contains errors', async () => {
findProfileNameInput().vm.$emit('input', profileName);
await findTargetUrlInput().vm.$emit('input', 'invalid URL');
expect(findSubmitButton().props('disabled')).toBe(true);
});
it('at least one field is empty', async () => {
findProfileNameInput().vm.$emit('input', '');
await findTargetUrlInput().vm.$emit('input', targetUrl);
expect(findSubmitButton().props('disabled')).toBe(true);
});
});
describe('is enabled if', () => {
it('all fields are filled in and valid', async () => {
findProfileNameInput().vm.$emit('input', profileName);
await findTargetUrlInput().vm.$emit('input', targetUrl);
expect(findSubmitButton().props('disabled')).toBe(false);
});
});
});
describe('target URL input', () => { describe('target URL input', () => {
const errorMessage = 'Please enter a valid URL format, ex: http://www.example.com/home'; const errorMessage = 'Please enter a valid URL format, ex: http://www.example.com/home';
...@@ -161,6 +133,7 @@ describe('DastSiteProfileForm', () => { ...@@ -161,6 +133,7 @@ describe('DastSiteProfileForm', () => {
it.each(['asd', 'example.com'])('is marked as invalid provided an invalid URL', async value => { it.each(['asd', 'example.com'])('is marked as invalid provided an invalid URL', async value => {
findTargetUrlInput().setValue(value); findTargetUrlInput().setValue(value);
findTargetUrlInput().trigger('blur');
await wrapper.vm.$nextTick(); await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain(errorMessage); expect(wrapper.text()).toContain(errorMessage);
...@@ -176,7 +149,8 @@ describe('DastSiteProfileForm', () => { ...@@ -176,7 +149,8 @@ describe('DastSiteProfileForm', () => {
describe('validation', () => { describe('validation', () => {
const enableValidationToggle = async () => { const enableValidationToggle = async () => {
await findTargetUrlInput().vm.$emit('input', targetUrl); await findTargetUrlInput().setValue(targetUrl);
await findTargetUrlInput().trigger('blur');
await findSiteValidationToggle().vm.$emit('change', true); await findSiteValidationToggle().vm.$emit('change', true);
}; };
...@@ -186,7 +160,7 @@ describe('DastSiteProfileForm', () => { ...@@ -186,7 +160,7 @@ describe('DastSiteProfileForm', () => {
${'Edit site profile'} | ${siteProfileOne} ${'Edit site profile'} | ${siteProfileOne}
`('$title with feature flag disabled', ({ siteProfile }) => { `('$title with feature flag disabled', ({ siteProfile }) => {
beforeEach(() => { beforeEach(() => {
createComponent({ createFullComponent({
provide: { provide: {
glFeatures: { securityOnDemandScansSiteValidation: false }, glFeatures: { securityOnDemandScansSiteValidation: false },
}, },
...@@ -208,7 +182,7 @@ describe('DastSiteProfileForm', () => { ...@@ -208,7 +182,7 @@ describe('DastSiteProfileForm', () => {
describe('with feature flag enabled', () => { describe('with feature flag enabled', () => {
beforeEach(() => { beforeEach(() => {
createComponent({ createFullComponent({
provide: { provide: {
glFeatures: { securityOnDemandScansSiteValidation: true }, glFeatures: { securityOnDemandScansSiteValidation: true },
}, },
...@@ -223,7 +197,9 @@ describe('DastSiteProfileForm', () => { ...@@ -223,7 +197,9 @@ describe('DastSiteProfileForm', () => {
it('toggle is disabled until target URL is valid', async () => { it('toggle is disabled until target URL is valid', async () => {
expect(findSiteValidationToggle().props('disabled')).toBe(true); expect(findSiteValidationToggle().props('disabled')).toBe(true);
await findTargetUrlInput().vm.$emit('input', targetUrl); await findTargetUrlInput().setValue(targetUrl);
await findTargetUrlInput().trigger('input');
await findTargetUrlInput().trigger('blur');
expect(findSiteValidationToggle().props('disabled')).toBe(false); expect(findSiteValidationToggle().props('disabled')).toBe(false);
}); });
...@@ -238,10 +214,10 @@ describe('DastSiteProfileForm', () => { ...@@ -238,10 +214,10 @@ describe('DastSiteProfileForm', () => {
await enableValidationToggle(); await enableValidationToggle();
await waitForPromises(); await waitForPromises();
expect(targetUrlInputGroup.attributes('description')).toBe( expect(targetUrlInputGroup.text()).toContain(
'Validation must be turned off to change the target URL', 'Validation must be turned off to change the target URL',
); );
expect(targetUrlInput.attributes('disabled')).toBe('true'); expect(targetUrlInput.attributes('disabled')).toBe('disabled');
}); });
it('checks the target URLs validation status when validation is enabled', async () => { it('checks the target URLs validation status when validation is enabled', async () => {
...@@ -331,11 +307,18 @@ describe('DastSiteProfileForm', () => { ...@@ -331,11 +307,18 @@ describe('DastSiteProfileForm', () => {
}); });
describe('submission', () => { describe('submission', () => {
const fillAndSubmitForm = async () => {
await findProfileNameInput().setValue(profileName);
findProfileNameInput().trigger('blur');
await findTargetUrlInput().setValue(targetUrl);
findTargetUrlInput().trigger('blur');
submitForm();
};
describe('on success', () => { describe('on success', () => {
beforeEach(() => { beforeEach(async () => {
findProfileNameInput().vm.$emit('input', profileName); await fillAndSubmitForm();
findTargetUrlInput().vm.$emit('input', targetUrl);
submitForm();
}); });
it('sets loading state', () => { it('sets loading state', () => {
...@@ -361,23 +344,22 @@ describe('DastSiteProfileForm', () => { ...@@ -361,23 +344,22 @@ describe('DastSiteProfileForm', () => {
}); });
describe('on top-level error', () => { describe('on top-level error', () => {
beforeEach(() => { beforeEach(async () => {
respondWith({ respondWith({
[mutationKind]: jest.fn().mockRejectedValue(new Error('GraphQL Network Error')), [mutationKind]: jest.fn().mockRejectedValue(new Error('GraphQL Network Error')),
}); });
const input = findTargetUrlInput(); await fillAndSubmitForm();
input.vm.$emit('input', targetUrl); await waitForPromises();
submitForm();
return waitForPromises();
}); });
it('resets loading state', () => { it('resets loading state', () => {
expect(findSubmitButton().props('loading')).toBe(false); expect(findSubmitButton().props('loading')).toBe(false);
}); });
it('shows an error alert', () => { it('shows an error alert', async () => {
await wrapper.vm.$nextTick();
expect(findAlert().exists()).toBe(true); expect(findAlert().exists()).toBe(true);
}); });
}); });
...@@ -385,16 +367,13 @@ describe('DastSiteProfileForm', () => { ...@@ -385,16 +367,13 @@ describe('DastSiteProfileForm', () => {
describe('on errors as data', () => { describe('on errors as data', () => {
const errors = ['error#1', 'error#2', 'error#3']; const errors = ['error#1', 'error#2', 'error#3'];
beforeEach(() => { beforeEach(async () => {
respondWith({ respondWith({
[mutationKind]: jest.fn().mockResolvedValue(responses[mutationKind](errors)), [mutationKind]: jest.fn().mockResolvedValue(responses[mutationKind](errors)),
}); });
const input = findTargetUrlInput(); await fillAndSubmitForm();
input.vm.$emit('input', targetUrl); await waitForPromises();
submitForm();
return waitForPromises();
}); });
it('resets loading state', () => { it('resets loading state', () => {
......
...@@ -8352,9 +8352,6 @@ msgstr "" ...@@ -8352,9 +8352,6 @@ msgstr ""
msgid "DastProfiles|Passive" msgid "DastProfiles|Passive"
msgstr "" msgstr ""
msgid "DastProfiles|Please enter a valid URL format, ex: http://www.example.com/home"
msgstr ""
msgid "DastProfiles|Please enter a valid timeout value" msgid "DastProfiles|Please enter a valid timeout value"
msgstr "" msgstr ""
...@@ -19893,6 +19890,9 @@ msgstr "" ...@@ -19893,6 +19890,9 @@ msgstr ""
msgid "Please enter a number greater than %{number} (from the project settings)" msgid "Please enter a number greater than %{number} (from the project settings)"
msgstr "" msgstr ""
msgid "Please enter a valid URL format, ex: http://www.example.com/home"
msgstr ""
msgid "Please enter a valid number" msgid "Please enter a valid number"
msgstr "" msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import validation from '~/vue_shared/directives/validation';
describe('validation directive', () => {
let wrapper;
const createComponent = ({ inputAttributes, showValidation } = {}) => {
const defaultInputAttributes = {
type: 'text',
required: true,
};
const component = {
directives: {
validation: validation(),
},
data() {
return {
attributes: inputAttributes || defaultInputAttributes,
showValidation,
form: {
state: null,
fields: {
exampleField: {
state: null,
feedback: '',
},
},
},
};
},
template: `
<form>
<input v-validation:[showValidation] name="exampleField" v-bind="attributes" />
</form>
`,
};
wrapper = shallowMount(component, { attachToDocument: true });
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
const getFormData = () => wrapper.vm.form;
const findForm = () => wrapper.find('form');
const findInput = () => wrapper.find('input');
describe.each([true, false])(
'with fields untouched and "showValidation" set to "%s"',
showValidation => {
beforeEach(() => {
createComponent({ showValidation });
});
it('sets the fields validity correctly', () => {
expect(getFormData().fields.exampleField).toEqual({
state: showValidation ? false : null,
feedback: showValidation ? expect.any(String) : '',
});
});
it('sets the form validity correctly', () => {
expect(getFormData().state).toBe(showValidation ? false : null);
});
},
);
describe.each`
inputAttributes | validValue | invalidValue
${{ required: true }} | ${'foo'} | ${''}
${{ type: 'url' }} | ${'http://foo.com'} | ${'foo'}
${{ type: 'number', min: 1, max: 5 }} | ${3} | ${0}
${{ type: 'number', min: 1, max: 5 }} | ${3} | ${6}
${{ pattern: 'foo|bar' }} | ${'bar'} | ${'quz'}
`(
'with input-attributes set to $inputAttributes',
({ inputAttributes, validValue, invalidValue }) => {
const setValueAndTriggerValidation = value => {
const input = findInput();
input.setValue(value);
input.trigger('blur');
};
beforeEach(() => {
createComponent({ inputAttributes });
});
describe('with valid value', () => {
beforeEach(() => {
setValueAndTriggerValidation(validValue);
});
it('leaves the field to be valid', () => {
expect(getFormData().fields.exampleField).toEqual({
state: true,
feedback: '',
});
});
it('leaves the form to be valid', () => {
expect(getFormData().state).toBe(true);
});
});
describe('with invalid value', () => {
beforeEach(() => {
setValueAndTriggerValidation(invalidValue);
});
it('sets the field to be invalid', () => {
expect(getFormData().fields.exampleField).toEqual({
state: false,
feedback: expect.any(String),
});
expect(getFormData().fields.exampleField.feedback.length).toBeGreaterThan(0);
});
it('sets the form to be invalid', () => {
expect(getFormData().state).toBe(false);
});
it('sets focus on the first invalid input when the form is submitted', () => {
findForm().trigger('submit');
expect(findInput().element).toBe(document.activeElement);
});
});
},
);
});
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