Commit 37143cf6 authored by Michael Lunøe's avatar Michael Lunøe Committed by Natalia Tepluhina

Feat(Cloud Activation Form): add validation

parent b272f265
import { merge } from 'lodash';
import { s__ } from '~/locale';
/**
......@@ -21,8 +20,15 @@ const defaultFeedbackMap = {
},
};
const getFeedbackForElement = (feedbackMap, el) =>
Object.values(feedbackMap).find((f) => f.isInvalid(el))?.message || el.validationMessage;
const getFeedbackForElement = (feedbackMap, el) => {
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 { target: formEl } = e;
......@@ -68,6 +74,7 @@ const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = fa
/**
* 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
* so it is possible to override a built-in message.
......@@ -75,7 +82,7 @@ const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = fa
* @example
* validate({
* tooLong: {
* check: el => el.validity.tooLong === true,
* isInvalid: el => el.validity.tooLong === true,
* message: 'Your custom feedback'
* }
* })
......@@ -91,7 +98,7 @@ const createValidator = (context, feedbackMap) => ({ el, reportInvalidInput = fa
* @returns {{ inserted: function, update: function }} validateDirective
*/
export default function initValidation(customFeedbackMap = {}) {
const feedbackMap = merge(defaultFeedbackMap, customFeedbackMap);
const feedbackMap = { ...defaultFeedbackMap, ...customFeedbackMap };
const elDataMap = new WeakMap();
return {
......
......@@ -20,6 +20,15 @@ import {
} from '../constants';
import { getErrorsAsData, getLicenseFromData, updateSubscriptionAppCache } from '../graphql/utils';
const feedbackMap = {
valueMissing: {
isInvalid: (el) => el.validity?.valueMissing,
},
patternMismatch: {
isInvalid: (el) => el.validity?.patternMismatch,
},
};
export default {
name: 'SubscriptionActivationForm',
components: {
......@@ -33,12 +42,14 @@ export default {
},
i18n: {
acceptTerms: subscriptionActivationForm.acceptTerms,
activationCodeFeedback: subscriptionActivationForm.activationCodeFeedback,
activateLabel,
activationCode: subscriptionActivationForm.activationCode,
acceptTermsFeedback: subscriptionActivationForm.acceptTermsFeedback,
pasteActivationCode: subscriptionActivationForm.pasteActivationCode,
},
directives: {
validation: validation(),
validation: validation(feedbackMap),
},
props: {
hideSubmitButton: {
......@@ -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
return this.form.fields.terms.state === false ? '' : 'gl-text-gray-900!';
},
isRequestingActivation() {
return this.isLoading;
},
},
methods: {
handleError(error) {
......@@ -132,6 +140,7 @@ export default {
<gl-form-group
class="gl-flex-grow-1"
:invalid-feedback="form.fields.activationCode.feedback"
:state="form.fields.activationCode.state"
data-testid="form-group-activation-code"
>
<label class="gl-w-full" for="activation-code-group">
......@@ -141,21 +150,29 @@ export default {
id="activation-code-group"
v-model.trim="form.fields.activationCode.value"
v-validation:[form.showValidation]
class="gl-mb-4"
:disabled="isLoading"
:placeholder="$options.i18n.pasteActivationCode"
:state="form.fields.activationCode.state"
:validation-message="$options.i18n.activationCodeFeedback"
name="activationCode"
class="gl-mb-4"
pattern="\w{24}"
required
/>
</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
id="subscription-form-terms-check"
v-model="form.fields.terms.value"
v-validation:[form.showValidation]
:state="form.fields.terms.state"
:validation-message="$options.i18n.acceptTermsFeedback"
name="terms"
required
>
......@@ -173,7 +190,7 @@ export default {
<gl-button
v-if="!hideSubmitButton"
:loading="isRequestingActivation"
:loading="isLoading"
category="primary"
class="gl-mt-6 js-no-auto-disable"
data-testid="activate-button"
......
......@@ -82,10 +82,14 @@ export const manualSyncFailureText = s__(
export const subscriptionActivationForm = {
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'),
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.',
),
acceptTermsFeedback: s__('SuperSonics|Please agree to the Subscription Agreement'),
};
export const subscriptionSyncStatus = {
......
......@@ -222,7 +222,7 @@ RSpec.describe 'Admin views Subscription', :js do
private
def fill_activation_form
fill_in 'activationCode', with: 'fake-activation-code'
fill_in 'activationCode', with: '00112233aaaassssddddffff'
check 'subscription-form-terms-check'
click_button 'Activate'
end
......
......@@ -8,12 +8,17 @@ import {
SUBSCRIPTION_ACTIVATION_FAILURE_EVENT,
SUBSCRIPTION_ACTIVATION_SUCCESS_EVENT,
subscriptionQueries,
subscriptionActivationForm,
} from 'ee/admin/subscriptions/show/constants';
import createMockApollo from 'helpers/mock_apollo_helper';
import { stubComponent } from 'helpers/stub_component';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { preventDefault, stopPropagation } from '../../test_helpers';
import { activateLicenseMutationResponse } from '../mock_data';
import {
activateLicenseMutationResponse,
fakeActivationCodeTrimmed,
fakeActivationCode,
} from '../mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
......@@ -21,9 +26,6 @@ localVue.use(VueApollo);
describe('SubscriptionActivationForm', () => {
let wrapper;
const fakeActivationCodeTrimmed = 'aaasddfffdddas';
const fakeActivationCode = `${fakeActivationCodeTrimmed} `;
const createMockApolloProvider = (resolverMock) => {
localVue.use(VueApollo);
return createMockApollo([[subscriptionQueries.mutation, resolverMock]]);
......@@ -32,8 +34,8 @@ describe('SubscriptionActivationForm', () => {
const findActivateButton = () => wrapper.findByTestId('activate-button');
const findAgreementCheckbox = () => wrapper.findComponent(GlFormCheckbox);
const findAgreementCheckboxInput = () => findAgreementCheckbox().find('input');
const findAgreementCheckboxFormGroupSpan = () =>
wrapper.findByTestId('form-group-terms').find('span');
const findAgreementCheckboxFormGroup = () => wrapper.findByTestId('form-group-terms');
const findAgreementCheckboxFormGroupSpan = () => findAgreementCheckboxFormGroup().find('span');
const findActivationCodeFormGroup = () => wrapper.findByTestId('form-group-activation-code');
const findActivationCodeInput = () => wrapper.findComponent(GlFormInput);
const findActivateSubscriptionForm = () => wrapper.findComponent(GlForm);
......@@ -102,17 +104,56 @@ describe('SubscriptionActivationForm', () => {
findActivateSubscriptionForm().vm.$emit('submit', createFakeEvent());
});
it('shows an error for the text field', () => {
expect(findActivationCodeFormGroup().text()).toContain('Please fill out this field.');
it('shows the help text 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(findAgreementCheckboxFormGroup().text()).toContain(
subscriptionActivationForm.acceptTermsFeedback,
);
});
it('does not perform any mutation', () => {
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', () => {
......
......@@ -139,3 +139,6 @@ export const activateLicenseMutationResponse = {
},
},
};
export const fakeActivationCodeTrimmed = 'aaaassssddddffff992200gg';
export const fakeActivationCode = ` ${fakeActivationCodeTrimmed} `;
......@@ -32765,6 +32765,9 @@ msgstr ""
msgid "SuperSonics|Plan"
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."
msgstr ""
......@@ -32795,6 +32798,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}."
msgstr ""
msgid "SuperSonics|The activation code should be a 24-character alphanumeric string"
msgstr ""
msgid "SuperSonics|There is a connectivity issue."
msgstr ""
......
......@@ -4,11 +4,13 @@ import validation, { initForm } from '~/vue_shared/directives/validation';
describe('validation directive', () => {
let wrapper;
const createComponentFactory = ({ inputAttributes, template, data }) => {
const defaultInputAttributes = {
type: 'text',
required: true,
};
const createComponentFactory = (options) => {
const {
inputAttributes = { type: 'text', required: true },
template,
data,
feedbackMap = {},
} = options;
const defaultTemplate = `
<form>
......@@ -18,11 +20,11 @@ describe('validation directive', () => {
const component = {
directives: {
validation: validation(),
validation: validation(feedbackMap),
},
data() {
return {
attributes: inputAttributes || defaultInputAttributes,
attributes: inputAttributes,
...data,
};
},
......@@ -32,8 +34,10 @@ describe('validation directive', () => {
wrapper = shallowMount(component, { attachTo: document.body });
};
const createComponent = ({ inputAttributes, showValidation, template } = {}) =>
createComponentFactory({
const createComponent = (options = {}) => {
const { inputAttributes, showValidation, template, feedbackMap } = options;
return createComponentFactory({
inputAttributes,
data: {
showValidation,
......@@ -48,10 +52,14 @@ describe('validation directive', () => {
},
},
template,
feedbackMap,
});
};
const createComponentWithInitForm = (options = {}) => {
const { inputAttributes, feedbackMap } = options;
const createComponentWithInitForm = ({ inputAttributes } = {}) =>
createComponentFactory({
return createComponentFactory({
inputAttributes,
data: {
form: initForm({
......@@ -68,7 +76,9 @@ describe('validation directive', () => {
<input v-validation:[form.showValidation] name="exampleField" v-bind="attributes" />
</form>
`,
feedbackMap,
});
};
afterEach(() => {
wrapper.destroy();
......@@ -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', () => {
it('sets the form fields correctly', () => {
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