Commit 2459b152 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '323071-mlunoe-add-validation-to-cloud-license-form' into 'master'

Feat(Cloud Activation Form): add validation

See merge request gitlab-org/gitlab!70716
parents 71721e01 37143cf6
import { merge } from 'lodash';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
/** /**
...@@ -21,8 +20,15 @@ const defaultFeedbackMap = { ...@@ -21,8 +20,15 @@ const defaultFeedbackMap = {
}, },
}; };
const getFeedbackForElement = (feedbackMap, el) => const getFeedbackForElement = (feedbackMap, el) => {
Object.values(feedbackMap).find((f) => f.isInvalid(el))?.message || el.validationMessage; const field = Object.values(feedbackMap).find((f) => f.isInvalid(el));
let elMessage = null;
if (field) {
elMessage = el.getAttribute('validation-message');
}
return field?.message || elMessage || el.validationMessage;
};
const focusFirstInvalidInput = (e) => { const focusFirstInvalidInput = (e) => {
const { target: formEl } = e; const { target: formEl } = e;
...@@ -68,6 +74,7 @@ const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = fa ...@@ -68,6 +74,7 @@ const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = fa
/** /**
* Takes an object that allows to add or change custom feedback messages. * Takes an object that allows to add or change custom feedback messages.
* See possibilities here: https://developer.mozilla.org/en-US/docs/Web/API/ValidityState
* *
* The passed in object will be merged with the built-in feedback * The passed in object will be merged with the built-in feedback
* so it is possible to override a built-in message. * so it is possible to override a built-in message.
...@@ -75,7 +82,7 @@ const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = fa ...@@ -75,7 +82,7 @@ const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = fa
* @example * @example
* validate({ * validate({
* tooLong: { * tooLong: {
* check: el => el.validity.tooLong === true, * isInvalid: el => el.validity.tooLong === true,
* message: 'Your custom feedback' * message: 'Your custom feedback'
* } * }
* }) * })
...@@ -91,7 +98,7 @@ const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = fa ...@@ -91,7 +98,7 @@ const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = fa
* @returns {{ inserted: function, update: function }} validateDirective * @returns {{ inserted: function, update: function }} validateDirective
*/ */
export default function initValidation(customFeedbackMap = {}) { export default function initValidation(customFeedbackMap = {}) {
const feedbackMap = merge(defaultFeedbackMap, customFeedbackMap); const feedbackMap = { ...defaultFeedbackMap, ...customFeedbackMap };
const elDataMap = new WeakMap(); const elDataMap = new WeakMap();
return { return {
......
...@@ -20,6 +20,15 @@ import { ...@@ -20,6 +20,15 @@ import {
} from '../constants'; } from '../constants';
import { getErrorsAsData, getLicenseFromData, updateSubscriptionAppCache } from '../graphql/utils'; import { getErrorsAsData, getLicenseFromData, updateSubscriptionAppCache } from '../graphql/utils';
const feedbackMap = {
valueMissing: {
isInvalid: (el) => el.validity?.valueMissing,
},
patternMismatch: {
isInvalid: (el) => el.validity?.patternMismatch,
},
};
export default { export default {
name: 'SubscriptionActivationForm', name: 'SubscriptionActivationForm',
components: { components: {
...@@ -33,12 +42,14 @@ export default { ...@@ -33,12 +42,14 @@ export default {
}, },
i18n: { i18n: {
acceptTerms: subscriptionActivationForm.acceptTerms, acceptTerms: subscriptionActivationForm.acceptTerms,
activationCodeFeedback: subscriptionActivationForm.activationCodeFeedback,
activateLabel, activateLabel,
activationCode: subscriptionActivationForm.activationCode, activationCode: subscriptionActivationForm.activationCode,
acceptTermsFeedback: subscriptionActivationForm.acceptTermsFeedback,
pasteActivationCode: subscriptionActivationForm.pasteActivationCode, pasteActivationCode: subscriptionActivationForm.pasteActivationCode,
}, },
directives: { directives: {
validation: validation(), validation: validation(feedbackMap),
}, },
props: { props: {
hideSubmitButton: { hideSubmitButton: {
...@@ -74,9 +85,6 @@ export default { ...@@ -74,9 +85,6 @@ export default {
// by default, if the value is not false the text will look green, therefore we force it to gray-900 // by default, if the value is not false the text will look green, therefore we force it to gray-900
return this.form.fields.terms.state === false ? '' : 'gl-text-gray-900!'; return this.form.fields.terms.state === false ? '' : 'gl-text-gray-900!';
}, },
isRequestingActivation() {
return this.isLoading;
},
}, },
methods: { methods: {
handleError(error) { handleError(error) {
...@@ -132,6 +140,7 @@ export default { ...@@ -132,6 +140,7 @@ export default {
<gl-form-group <gl-form-group
class="gl-flex-grow-1" class="gl-flex-grow-1"
:invalid-feedback="form.fields.activationCode.feedback" :invalid-feedback="form.fields.activationCode.feedback"
:state="form.fields.activationCode.state"
data-testid="form-group-activation-code" data-testid="form-group-activation-code"
> >
<label class="gl-w-full" for="activation-code-group"> <label class="gl-w-full" for="activation-code-group">
...@@ -141,21 +150,29 @@ export default { ...@@ -141,21 +150,29 @@ export default {
id="activation-code-group" id="activation-code-group"
v-model.trim="form.fields.activationCode.value" v-model.trim="form.fields.activationCode.value"
v-validation:[form.showValidation] v-validation:[form.showValidation]
class="gl-mb-4"
:disabled="isLoading" :disabled="isLoading"
:placeholder="$options.i18n.pasteActivationCode" :placeholder="$options.i18n.pasteActivationCode"
:state="form.fields.activationCode.state" :state="form.fields.activationCode.state"
:validation-message="$options.i18n.activationCodeFeedback"
name="activationCode" name="activationCode"
class="gl-mb-4" pattern="\w{24}"
required required
/> />
</gl-form-group> </gl-form-group>
<gl-form-group class="gl-mb-0" data-testid="form-group-terms"> <gl-form-group
class="gl-mb-0"
:invalid-feedback="form.fields.terms.feedback"
:state="form.fields.terms.state"
data-testid="form-group-terms"
>
<gl-form-checkbox <gl-form-checkbox
id="subscription-form-terms-check" id="subscription-form-terms-check"
v-model="form.fields.terms.value" v-model="form.fields.terms.value"
v-validation:[form.showValidation] v-validation:[form.showValidation]
:state="form.fields.terms.state" :state="form.fields.terms.state"
:validation-message="$options.i18n.acceptTermsFeedback"
name="terms" name="terms"
required required
> >
...@@ -173,7 +190,7 @@ export default { ...@@ -173,7 +190,7 @@ export default {
<gl-button <gl-button
v-if="!hideSubmitButton" v-if="!hideSubmitButton"
:loading="isRequestingActivation" :loading="isLoading"
category="primary" category="primary"
class="gl-mt-6 js-no-auto-disable" class="gl-mt-6 js-no-auto-disable"
data-testid="activate-button" data-testid="activate-button"
......
...@@ -82,10 +82,14 @@ export const manualSyncFailureText = s__( ...@@ -82,10 +82,14 @@ export const manualSyncFailureText = s__(
export const subscriptionActivationForm = { export const subscriptionActivationForm = {
activationCode: s__('SuperSonics|Activation code'), activationCode: s__('SuperSonics|Activation code'),
activationCodeFeedback: s__(
'SuperSonics|The activation code should be a 24-character alphanumeric string',
),
pasteActivationCode: s__('SuperSonics|Paste your activation code'), pasteActivationCode: s__('SuperSonics|Paste your activation code'),
acceptTerms: s__( acceptTerms: s__(
'SuperSonics|I agree that my use of the GitLab Software is subject to the Subscription Agreement located at the %{linkStart}Terms of Service%{linkEnd}, unless otherwise agreed to in writing with GitLab.', 'SuperSonics|I agree that my use of the GitLab Software is subject to the Subscription Agreement located at the %{linkStart}Terms of Service%{linkEnd}, unless otherwise agreed to in writing with GitLab.',
), ),
acceptTermsFeedback: s__('SuperSonics|Please agree to the Subscription Agreement'),
}; };
export const subscriptionSyncStatus = { export const subscriptionSyncStatus = {
......
...@@ -222,7 +222,7 @@ RSpec.describe 'Admin views Subscription', :js do ...@@ -222,7 +222,7 @@ RSpec.describe 'Admin views Subscription', :js do
private private
def fill_activation_form def fill_activation_form
fill_in 'activationCode', with: 'fake-activation-code' fill_in 'activationCode', with: '00112233aaaassssddddffff'
check 'subscription-form-terms-check' check 'subscription-form-terms-check'
click_button 'Activate' click_button 'Activate'
end end
......
...@@ -8,12 +8,17 @@ import { ...@@ -8,12 +8,17 @@ import {
SUBSCRIPTION_ACTIVATION_FAILURE_EVENT, SUBSCRIPTION_ACTIVATION_FAILURE_EVENT,
SUBSCRIPTION_ACTIVATION_SUCCESS_EVENT, SUBSCRIPTION_ACTIVATION_SUCCESS_EVENT,
subscriptionQueries, subscriptionQueries,
subscriptionActivationForm,
} from 'ee/admin/subscriptions/show/constants'; } from 'ee/admin/subscriptions/show/constants';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import { stubComponent } from 'helpers/stub_component'; import { stubComponent } from 'helpers/stub_component';
import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { preventDefault, stopPropagation } from '../../test_helpers'; import { preventDefault, stopPropagation } from '../../test_helpers';
import { activateLicenseMutationResponse } from '../mock_data'; import {
activateLicenseMutationResponse,
fakeActivationCodeTrimmed,
fakeActivationCode,
} from '../mock_data';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(VueApollo); localVue.use(VueApollo);
...@@ -21,9 +26,6 @@ localVue.use(VueApollo); ...@@ -21,9 +26,6 @@ localVue.use(VueApollo);
describe('SubscriptionActivationForm', () => { describe('SubscriptionActivationForm', () => {
let wrapper; let wrapper;
const fakeActivationCodeTrimmed = 'aaasddfffdddas';
const fakeActivationCode = `${fakeActivationCodeTrimmed} `;
const createMockApolloProvider = (resolverMock) => { const createMockApolloProvider = (resolverMock) => {
localVue.use(VueApollo); localVue.use(VueApollo);
return createMockApollo([[subscriptionQueries.mutation, resolverMock]]); return createMockApollo([[subscriptionQueries.mutation, resolverMock]]);
...@@ -32,8 +34,8 @@ describe('SubscriptionActivationForm', () => { ...@@ -32,8 +34,8 @@ describe('SubscriptionActivationForm', () => {
const findActivateButton = () => wrapper.findByTestId('activate-button'); const findActivateButton = () => wrapper.findByTestId('activate-button');
const findAgreementCheckbox = () => wrapper.findComponent(GlFormCheckbox); const findAgreementCheckbox = () => wrapper.findComponent(GlFormCheckbox);
const findAgreementCheckboxInput = () => findAgreementCheckbox().find('input'); const findAgreementCheckboxInput = () => findAgreementCheckbox().find('input');
const findAgreementCheckboxFormGroupSpan = () => const findAgreementCheckboxFormGroup = () => wrapper.findByTestId('form-group-terms');
wrapper.findByTestId('form-group-terms').find('span'); const findAgreementCheckboxFormGroupSpan = () => findAgreementCheckboxFormGroup().find('span');
const findActivationCodeFormGroup = () => wrapper.findByTestId('form-group-activation-code'); const findActivationCodeFormGroup = () => wrapper.findByTestId('form-group-activation-code');
const findActivationCodeInput = () => wrapper.findComponent(GlFormInput); const findActivationCodeInput = () => wrapper.findComponent(GlFormInput);
const findActivateSubscriptionForm = () => wrapper.findComponent(GlForm); const findActivateSubscriptionForm = () => wrapper.findComponent(GlForm);
...@@ -102,17 +104,56 @@ describe('SubscriptionActivationForm', () => { ...@@ -102,17 +104,56 @@ describe('SubscriptionActivationForm', () => {
findActivateSubscriptionForm().vm.$emit('submit', createFakeEvent()); findActivateSubscriptionForm().vm.$emit('submit', createFakeEvent());
}); });
it('shows an error for the text field', () => { it('shows the help text field', () => {
expect(findActivationCodeFormGroup().text()).toContain('Please fill out this field.'); expect(findActivationCodeFormGroup().text()).toContain(
subscriptionActivationForm.activationCodeFeedback,
);
}); });
it('applies the correct class', () => { it('applies the correct class and shows help text field', () => {
expect(findAgreementCheckboxFormGroupSpan().attributes('class')).toBe(''); expect(findAgreementCheckboxFormGroupSpan().attributes('class')).toBe('');
expect(findAgreementCheckboxFormGroup().text()).toContain(
subscriptionActivationForm.acceptTermsFeedback,
);
}); });
it('does not perform any mutation', () => { it('does not perform any mutation', () => {
expect(mutationMock).toHaveBeenCalledTimes(0); expect(mutationMock).toHaveBeenCalledTimes(0);
}); });
describe('adds text that does not match the pattern', () => {
beforeEach(async () => {
await findActivationCodeInput().vm.$emit('input', `${fakeActivationCode}2021-asdf`);
});
it('shows the help text field', () => {
expect(findActivationCodeFormGroup().text()).toContain(
subscriptionActivationForm.activationCodeFeedback,
);
});
describe('corrects fields to be valid', () => {
beforeEach(async () => {
await findActivationCodeInput().vm.$emit('input', fakeActivationCode);
await findAgreementCheckboxInput().trigger('click');
});
it('hides the help text field', () => {
expect(findActivationCodeFormGroup().text()).not.toContain(
subscriptionActivationForm.activationCodeFeedback,
);
});
it('updates the validation class and hides help text field', () => {
expect(findAgreementCheckboxFormGroupSpan().attributes('class')).toBe(
'gl-text-gray-900!',
);
expect(findAgreementCheckboxFormGroup().text()).not.toContain(
subscriptionActivationForm.acceptTermsFeedback,
);
});
});
});
}); });
describe('activate the subscription', () => { describe('activate the subscription', () => {
......
...@@ -139,3 +139,6 @@ export const activateLicenseMutationResponse = { ...@@ -139,3 +139,6 @@ export const activateLicenseMutationResponse = {
}, },
}, },
}; };
export const fakeActivationCodeTrimmed = 'aaaassssddddffff992200gg';
export const fakeActivationCode = ` ${fakeActivationCodeTrimmed} `;
...@@ -32771,6 +32771,9 @@ msgstr "" ...@@ -32771,6 +32771,9 @@ msgstr ""
msgid "SuperSonics|Plan" msgid "SuperSonics|Plan"
msgstr "" msgstr ""
msgid "SuperSonics|Please agree to the Subscription Agreement"
msgstr ""
msgid "SuperSonics|Ready to get started? A GitLab plan is ideal for scaling organizations and for multi team usage." msgid "SuperSonics|Ready to get started? A GitLab plan is ideal for scaling organizations and for multi team usage."
msgstr "" msgstr ""
...@@ -32801,6 +32804,9 @@ msgstr "" ...@@ -32801,6 +32804,9 @@ msgstr ""
msgid "SuperSonics|The activation code is not valid. Please make sure to copy it exactly from the Customers Portal or confirmation email. Learn more about %{linkStart}activating your subscription%{linkEnd}." msgid "SuperSonics|The activation code is not valid. Please make sure to copy it exactly from the Customers Portal or confirmation email. Learn more about %{linkStart}activating your subscription%{linkEnd}."
msgstr "" msgstr ""
msgid "SuperSonics|The activation code should be a 24-character alphanumeric string"
msgstr ""
msgid "SuperSonics|There is a connectivity issue." msgid "SuperSonics|There is a connectivity issue."
msgstr "" msgstr ""
......
...@@ -4,11 +4,13 @@ import validation, { initForm } from '~/vue_shared/directives/validation'; ...@@ -4,11 +4,13 @@ import validation, { initForm } from '~/vue_shared/directives/validation';
describe('validation directive', () => { describe('validation directive', () => {
let wrapper; let wrapper;
const createComponentFactory = ({ inputAttributes, template, data }) => { const createComponentFactory = (options) => {
const defaultInputAttributes = { const {
type: 'text', inputAttributes = { type: 'text', required: true },
required: true, template,
}; data,
feedbackMap = {},
} = options;
const defaultTemplate = ` const defaultTemplate = `
<form> <form>
...@@ -18,11 +20,11 @@ describe('validation directive', () => { ...@@ -18,11 +20,11 @@ describe('validation directive', () => {
const component = { const component = {
directives: { directives: {
validation: validation(), validation: validation(feedbackMap),
}, },
data() { data() {
return { return {
attributes: inputAttributes || defaultInputAttributes, attributes: inputAttributes,
...data, ...data,
}; };
}, },
...@@ -32,8 +34,10 @@ describe('validation directive', () => { ...@@ -32,8 +34,10 @@ describe('validation directive', () => {
wrapper = shallowMount(component, { attachTo: document.body }); wrapper = shallowMount(component, { attachTo: document.body });
}; };
const createComponent = ({ inputAttributes, showValidation, template } = {}) => const createComponent = (options = {}) => {
createComponentFactory({ const { inputAttributes, showValidation, template, feedbackMap } = options;
return createComponentFactory({
inputAttributes, inputAttributes,
data: { data: {
showValidation, showValidation,
...@@ -48,10 +52,14 @@ describe('validation directive', () => { ...@@ -48,10 +52,14 @@ describe('validation directive', () => {
}, },
}, },
template, template,
feedbackMap,
}); });
};
const createComponentWithInitForm = (options = {}) => {
const { inputAttributes, feedbackMap } = options;
const createComponentWithInitForm = ({ inputAttributes } = {}) => return createComponentFactory({
createComponentFactory({
inputAttributes, inputAttributes,
data: { data: {
form: initForm({ form: initForm({
...@@ -68,7 +76,9 @@ describe('validation directive', () => { ...@@ -68,7 +76,9 @@ describe('validation directive', () => {
<input v-validation:[form.showValidation] name="exampleField" v-bind="attributes" /> <input v-validation:[form.showValidation] name="exampleField" v-bind="attributes" />
</form> </form>
`, `,
feedbackMap,
}); });
};
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
...@@ -209,6 +219,111 @@ describe('validation directive', () => { ...@@ -209,6 +219,111 @@ describe('validation directive', () => {
}); });
}); });
describe('with custom feedbackMap', () => {
const customMessage = 'Please fill out the name field.';
const template = `
<form>
<div v-validation:[showValidation]>
<input name="exampleField" v-bind="attributes" />
</div>
</form>
`;
beforeEach(() => {
const feedbackMap = {
valueMissing: {
isInvalid: (el) => el.validity?.valueMissing,
message: customMessage,
},
};
createComponent({
template,
inputAttributes: {
required: true,
},
feedbackMap,
});
});
describe('with invalid value', () => {
beforeEach(() => {
setValueAndTriggerValidation('');
});
it('should set correct field state', () => {
expect(getFormData().fields.exampleField).toEqual({
state: false,
feedback: customMessage,
});
});
});
describe('with valid value', () => {
beforeEach(() => {
setValueAndTriggerValidation('hello');
});
it('set the correct state', () => {
expect(getFormData().fields.exampleField).toEqual({
state: true,
feedback: '',
});
});
});
});
describe('with validation-message present on the element', () => {
const customMessage = 'The name field is required.';
const template = `
<form>
<div v-validation:[showValidation]>
<input name="exampleField" v-bind="attributes" validation-message="${customMessage}" />
</div>
</form>
`;
beforeEach(() => {
const feedbackMap = {
valueMissing: {
isInvalid: (el) => el.validity?.valueMissing,
},
};
createComponent({
template,
inputAttributes: {
required: true,
},
feedbackMap,
});
});
describe('with invalid value', () => {
beforeEach(() => {
setValueAndTriggerValidation('');
});
it('should set correct field state', () => {
expect(getFormData().fields.exampleField).toEqual({
state: false,
feedback: customMessage,
});
});
});
describe('with valid value', () => {
beforeEach(() => {
setValueAndTriggerValidation('hello');
});
it('set the correct state', () => {
expect(getFormData().fields.exampleField).toEqual({
state: true,
feedback: '',
});
});
});
});
describe('component using initForm', () => { describe('component using initForm', () => {
it('sets the form fields correctly', () => { it('sets the form fields correctly', () => {
createComponentWithInitForm(); createComponentWithInitForm();
......
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