diff --git a/src/components/accordion/accordion-transition.ts b/src/components/accordion/accordion-transition.ts index b76dbaa9f..e7f6bf050 100644 --- a/src/components/accordion/accordion-transition.ts +++ b/src/components/accordion/accordion-transition.ts @@ -1,7 +1,7 @@ import Vue, { PluginObject, VNode, VNodeData, VueConstructor } from 'vue'; - import { ACCORDION_TRANSITION_NAME } from '../component-names'; + interface MAccordionTransitionProps { heightDelta?: number; transition?: boolean; @@ -35,7 +35,7 @@ export const MAccordionTransition: VueConstructor = Vue.extend({ el.style.removeProperty('height'); }, beforeLeave(el: HTMLElement): void { - el.style.height = el.scrollHeight + 'px'; + el.style.height = parseInt((window.getComputedStyle(el).height as string), 10) + 'px'; if (props.transition === false && el.classList.contains(CLASS_HAS_TRANSITION)) { el.classList.remove(CLASS_HAS_TRANSITION); } diff --git a/src/components/accordion/accordion.html b/src/components/accordion/accordion.html index 74ac05eaf..6423ba82f 100644 --- a/src/components/accordion/accordion.html +++ b/src/components/accordion/accordion.html @@ -17,8 +17,12 @@ ref="accordionHeader">
- - + +
-
+
diff --git a/src/components/form/__snapshots__/form.spec.ts.snap b/src/components/form/__snapshots__/form.spec.ts.snap index 7d4684c86..a1726d868 100644 --- a/src/components/form/__snapshots__/form.spec.ts.snap +++ b/src/components/form/__snapshots__/form.spec.ts.snap @@ -1,15 +1,20 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`MForm When the form has no required fields, then it should not show a required label 1`] = `
`; +exports[`MForm When the form has no required fields, then it should not show a required label 1`] = ` +
+ +
+`; exports[`MForm When the form has required fields, then it should show a required label 1`] = ` -
-

* m-form:required

+ + +

* m-form:required

`; exports[`MForm should render correctly 1`] = ` -
-

* m-form:required

+ +
`; diff --git a/src/components/form/form-field.spec.ts b/src/components/form/form-field.spec.ts new file mode 100644 index 000000000..ea0c24378 --- /dev/null +++ b/src/components/form/form-field.spec.ts @@ -0,0 +1,87 @@ +import { createLocalVue, mount, Wrapper } from '@vue/test-utils'; +import Vue, { VueConstructor } from 'vue'; +import { resetModulPlugins } from '../../../tests/helpers/component'; +import { FORM_FIELD_NAME } from '../../directives/directive-names'; +import { Form } from '../../utils/form/form'; +import { FormFieldValidation } from '../../utils/form/form-field-validation/form-field-validation'; +import { FormField } from '../../utils/form/form-field/form-field'; +import { ModulVue } from '../../utils/vue/vue'; +import TextfieldPlugin from '../textfield/textfield'; +import { FormFieldDirective } from './form-field'; + +let mockFormField: any = {}; + +jest.mock('../../utils/form/form-field/form-field', () => { + return { + FormField: jest.fn().mockImplementation(() => { + return mockFormField; + }) + }; +}); + +describe('form-field', () => { + let element: Wrapper; + let localVue: VueConstructor; + + beforeEach(() => { + resetModulPlugins(); + localVue = createLocalVue(); + localVue.directive(FORM_FIELD_NAME, FormFieldDirective); + localVue.use(TextfieldPlugin); + }); + + describe(`The form validate its fields`, () => { + mockFormField = { + shouldFocus: false, + isTouched: false, + hasError: true, + touched: false, + touch: jest.fn() + }; + + let formField: FormField; + let form: Form; + + beforeEach(() => { + formField = new FormField(() => undefined, [(value: any) => { + return new FormFieldValidation(true, [''], ['']); + }]); + + form = new Form({ + 'a-field': formField + }); + + element = mount( + { + template: ``, + data(): any { + return { + form: form + }; + } + }, + { localVue: localVue } + ); + }); + + it(`the element should have the focus if first invalid`, async () => { + const spy: any = jest.spyOn(element.find({ ref: 'field' }).element, 'focus'); + form.focusFirstFieldWithError(); + expect(mockFormField.shouldFocus).toBe(true); + + await element.vm.$forceUpdate(); + + expect(mockFormField.shouldFocus).toBe(false); + expect(spy).toHaveBeenCalled(); + }); + + it(`it should touch the form field on blur`, () => { + const spy: any = jest.spyOn(mockFormField, 'touch'); + + element.find({ ref: 'field' }).element.focus(); + element.find({ ref: 'field' }).element.blur(); + + expect(spy).toBeCalled(); + }); + }); +}); diff --git a/src/components/form/form-field.ts b/src/components/form/form-field.ts new file mode 100644 index 000000000..df719b8e0 --- /dev/null +++ b/src/components/form/form-field.ts @@ -0,0 +1,44 @@ +import { FormField } from 'src/utils/form/form-field/form-field'; +import { DirectiveOptions, VNode, VNodeDirective } from 'vue'; + +let touchFormField: any; + +export const FormFieldDirective: DirectiveOptions = { + inserted( + el: HTMLElement, + binding: VNodeDirective, + vnode: VNode + ): void { + const formField: FormField = binding.value; + touchFormField = () => formField.touch(); + + el.addEventListener('blur', touchFormField, true); + }, + update( + el: HTMLElement, + binding: VNodeDirective, + vnode: VNode + ): void { + const formField: FormField = binding.value; + + if (formField.shouldFocus) { + const selector: string = 'input, textarea, [contenteditable=true]'; + let container: HTMLDivElement = document.createElement('div'); + container.appendChild(el); + + const elements: NodeListOf = container.querySelectorAll(selector); + + if (elements.length > 0) { + elements[0].focus(); + } + + formField.shouldFocus = false; + } + }, + unbind( + el: HTMLElement, + binding: VNodeDirective + ): void { + el.removeEventListener('blur', touchFormField, true); + } +}; diff --git a/src/components/form/form.html b/src/components/form/form.html index 2698e040a..55bd4acc5 100644 --- a/src/components/form/form.html +++ b/src/components/form/form.html @@ -1,19 +1,23 @@
- -
    -
  • {{ error }}
  • -
-
- -

- * - {{ 'm-form:required' | f-m-i18n }} + + +

    +
  • +
+ + +

+ * + {{ 'm-form:required' | f-m-i18n }}

diff --git a/src/components/form/form.sandbox.html b/src/components/form/form.sandbox.html index b15ed04c6..2fcee7a85 100644 --- a/src/components/form/form.sandbox.html +++ b/src/components/form/form.sandbox.html @@ -1,39 +1,52 @@
- + v-m-form-field="form.get('titleField')"> + - + v-m-form-field="form.get('descriptionField')"> + + + + + + - + v-m-form-field="form.get('locationField')"> = new FormField((): string => this.title, ValidationSandbox.validateTitle); - descriptionField: FormField = new FormField((): string => this.description, ValidationSandbox.validateDescription); - locationField: FormField = new FormField((): string => this.location, ValidationSandbox.validateLocation); - form: Form = new Form([this.titleField, this.descriptionField, this.locationField]); - maxTitleLength: number = ValidationSandbox.maxTitleLength; thresholdTitle: number = ValidationSandbox.thresholdTitle; maxDescriptionLength: number = ValidationSandbox.maxDescriptionLength; thresholdDescription: number = ValidationSandbox.thresholdDescription; + minDescriptionLength: number = ValidationSandbox.minDescriptionLength; minLocationLength: number = ValidationSandbox.minLocationLength; maxLocationLength: number = ValidationSandbox.maxLocationLength; thresholdLocation: number = ValidationSandbox.thresholdLocation; @@ -31,12 +29,22 @@ export class MFormSandbox extends Vue { inputMaxWidthLarge: InputMaxWidth = InputMaxWidth.Large; inputMaxWidthSmall: InputMaxWidth = InputMaxWidth.Small; + form: Form = new Form({ + 'titleField': new FormField((): string => this.title, [ValidationSandbox.validateRequired('Title'), ValidationSandbox.validateMaxLength('Title', this.maxTitleLength)]), + 'descriptionField': new FormField((): string => this.description, [ValidationSandbox.validateRequired('Description'), ValidationSandbox.validateMinLength('Description', this.minDescriptionLength), ValidationSandbox.validateMaxLength('Description', this.maxDescriptionLength)]), + 'locationField': new FormField((): string => this.location, ValidationSandbox.validateLocation()), + 'passwordField': new FormField((): string => ''), + 'confirmPasswordField': new FormField((): string => '') + }, [ValidationSandbox.validatePasswordMatch]); + submit(): void { const data: any = { - title: this.titleField.value, - description: this.descriptionField.value, - location: this.locationField.value + title: this.form.get('titleField').value, + description: this.form.get('descriptionField').value, + location: this.form.get('locationField').value, + password: this.form.get('passwordField').value }; + this.$emit('submit', data); this.formSent = data; } @@ -45,46 +53,82 @@ export class MFormSandbox extends Vue { class ValidationSandbox { static readonly maxTitleLength: number = 8; static readonly thresholdTitle: number = 6; + static readonly minDescriptionLength: number = 10; static readonly maxDescriptionLength: number = 20; static readonly thresholdDescription: number = 16; - static readonly minLocationLength: number = 10; + static readonly minLocationLength: number = 16; static readonly maxLocationLength: number = 40; static readonly thresholdLocation: number = 30; - static validateTitle(title: string): FormFieldState { - if (!title) { - return new FormFieldState(true, 'Title is required.', 'This field is required.'); - } else if (title.length > ValidationSandbox.maxTitleLength) { - let error: string = `Title can be at most ${ValidationSandbox.maxTitleLength} characters.`; - return new FormFieldState(true, error, error); - } - return new FormFieldState(); + + static validateRequired(fieldName: string): FieldValidationCallback { + return (value: any) => { + if (!value) { + return new FormFieldValidation(true, [`${fieldName} is required.`], ['This field is required.']); + } + return new FormFieldValidation(); + }; } - static validateDescription(description: string): FormFieldState { - if (description.length > ValidationSandbox.maxDescriptionLength) { - let error: string = `Description can be at most ${ValidationSandbox.maxDescriptionLength} characters.`; - return new FormFieldState(true, error, error); - } - return new FormFieldState(); + static validateMaxLength(fieldName: string, maxLength: number): FieldValidationCallback { + return (value: any) => { + if (value.length > maxLength) { + let error: string = `${fieldName} can be at most ${maxLength} characters.`; + return new FormFieldValidation(true, [error], [error]); + } + return new FormFieldValidation(); + }; + } + + static validateMinLength(fieldName: string, minLength: number): FieldValidationCallback { + return (value: any) => { + if (value.length < minLength) { + let error: string = `${fieldName} can be at least ${minLength} characters.`; + return new FormFieldValidation(true, [error], [error]); + } + return new FormFieldValidation(); + }; + } + + // Other pattern + static validateLocation(): FieldValidationCallback[] { + return [(value: any) => { + let validation: FormFieldValidation = new FormFieldValidation(); + if (!value) { + validation.isError = true; + validation.errorMessages = ['This field is required.']; + validation.errorMessagesSummary = [`Location is required.`]; + } + + if (value.length < this.minLocationLength) { + let error: string = `Location can be at least ${this.minLocationLength} characters.`; + validation.isError = true; + validation.errorMessages = validation.errorMessages.concat([error]); + validation.errorMessagesSummary = validation.errorMessagesSummary.concat([error]); + } + + if (value.length > this.maxLocationLength) { + validation.isError = true; + let error: string = `Location can be at most ${this.maxLocationLength} characters.`; + validation.errorMessages = validation.errorMessages.concat([error]); + validation.errorMessagesSummary = validation.errorMessagesSummary.concat([error]); + } + + return validation; + }]; } - static validateLocation(location: string): FormFieldState { - if (!location) { - return new FormFieldState(true, 'Location is required.', 'Title is required.'); - } else if (location.length < ValidationSandbox.minLocationLength) { - let error: string = `Location can be at least ${ValidationSandbox.minLocationLength} characters.`; - return new FormFieldState(true, error, error); - } else if (location.length > ValidationSandbox.maxLocationLength) { - let error: string = `Location can be at most ${ValidationSandbox.maxLocationLength} characters.`; - return new FormFieldState(true, error, error); + static validatePasswordMatch(form: Form): FormValidation { + if (form.get('passwordField').value !== form.get('confirmPasswordField').value) { + return new FormValidation(true, 'Passwords must match'); } - return new FormFieldState(); + return new FormValidation(); } } const MFormSandboxPlugin: PluginObject = { install(v, options): void { + v.use(FormPlugin); v.component(`${FORM}-sandbox`, MFormSandbox); } }; diff --git a/src/components/form/form.scss b/src/components/form/form.scss new file mode 100644 index 000000000..eb6ce43a8 --- /dev/null +++ b/src/components/form/form.scss @@ -0,0 +1,29 @@ +@import 'commons'; +@import '../accordion/accordion-transition'; + +.m-form { + &__message-summary { + margin-bottom: $m-spacing--m; + + ul { + margin: 0; + padding: 0; + list-style: none; + } + } +} + +.required { + margin: 0; + text-align: right; + + &__marker { + color: $m-color--accent; + font-weight: $m-font-weight--black; + } + + &__label { + font-size: $m-font-size--s; + color: $m-color--text-light; + } +} diff --git a/src/components/form/form.spec.ts b/src/components/form/form.spec.ts index dbdf0f2eb..a4a950653 100644 --- a/src/components/form/form.spec.ts +++ b/src/components/form/form.spec.ts @@ -4,20 +4,31 @@ import { createLocalVue, mount, RefSelector, Wrapper } from '@vue/test-utils'; import Vue, { VueConstructor } from 'vue'; import { renderComponent } from '../../../tests/helpers/render'; import { Form } from '../../utils/form/form'; -import { FormFieldState } from '../../utils/form/form-field-state/form-field-state'; -import { FormField } from '../../utils/form/form-field/form-field'; -import uuid from '../../utils/uuid/uuid'; +import { FormFieldValidation } from '../../utils/form/form-field-validation/form-field-validation'; +import { FieldValidationCallback, FormField } from '../../utils/form/form-field/form-field'; import FormPlugin, { MForm } from './form'; -jest.mock('../../utils/uuid/uuid'); -(uuid.generate as jest.Mock).mockReturnValue('uuid'); +let mockForm: any = {}; +jest.mock('../../utils/form/form', () => { + return { + Form: jest.fn().mockImplementation(() => { + return mockForm; + }) + }; +}); +let HTML_ELEMENT: HTMLElement; const ERROR_MESSAGE: string = 'ERROR'; const ERROR_MESSAGE_SUMMARY: string = 'ERROR MESSAGE SUMMARY'; const REF_SUMMARY: RefSelector = { ref: 'summary' }; -const FORM: Form = new Form([ - new FormField((): string => '', (): FormFieldState => new FormFieldState()) -]); + +let fieldValidation: FormFieldValidation; + +let FORM: Form; +let formHasError: boolean = false; +let nbFieldsThatHasError: number = 0; +let nbOfErrors: number = 0; +let mockGetErrorsForSummary: jest.Mock = jest.fn(() => []); describe(`MForm`, () => { let wrapper: Wrapper; @@ -32,9 +43,35 @@ describe(`MForm`, () => { }); }; + const initialiseForm: Function = (multiple: boolean): void => { + const VALIDATION_FUNCTION: FieldValidationCallback = (): FormFieldValidation => fieldValidation; + if (multiple) { + FORM = new Form({ + 'a-field': new FormField((): string => '', [VALIDATION_FUNCTION]), + 'another-field': new FormField((): string => '', [VALIDATION_FUNCTION]) + }); + } else { + FORM = new Form({ + 'a-field': new FormField((): string => '', [VALIDATION_FUNCTION]) + }); + } + }; + beforeEach(() => { localVue = createLocalVue(); localVue.use(FormPlugin); + mockForm = { + id: 'uuid', + hasError: formHasError, + reset: jest.fn(), + nbFieldsThatHasError, + nbOfErrors, + getErrorsForSummary: mockGetErrorsForSummary, + focusFirstFieldWithError: jest.fn(), + validateAll: jest.fn() + }; + ((Form as unknown) as jest.Mock).mockClear(); + initialiseForm(); initialiserWrapper(); }); @@ -43,12 +80,12 @@ describe(`MForm`, () => { }); it(`When the form has required fields, then it should show a required label`, async () => { - wrapper.setProps({ hasRequiredFields: true }); + wrapper.setProps({ requiredMarker: true }); expect(await renderComponent(wrapper.vm)).toMatchSnapshot(); }); it(`When the form has no required fields, then it should not show a required label`, async () => { - wrapper.setProps({ hasRequiredFields: false }); + wrapper.setProps({ requiredMarker: false }); expect(await renderComponent(wrapper.vm)).toMatchSnapshot(); }); @@ -66,31 +103,41 @@ describe(`MForm`, () => { describe(`When there is one error`, () => { beforeEach(() => { - wrapper.setProps({ - form: new Form([ - new FormField((): string => '', (): FormFieldState => new FormFieldState(true, ERROR_MESSAGE_SUMMARY, ERROR_MESSAGE)) - ]) + nbFieldsThatHasError = 1; + mockGetErrorsForSummary = jest.fn(() => { + return [ERROR_MESSAGE_SUMMARY]; }); + initialiseForm(); + initialiserWrapper(); + }); + + it(`Then the summary of errors is not shown`, async () => { wrapper.trigger('submit'); + + expect(wrapper.find(REF_SUMMARY).exists()).toBeFalsy(); }); - it(`Then the submit event is not sent to the parent`, () => { - expect(wrapper.emitted('submit')).toBeFalsy(); + it(`Then it focuses on the first error`, () => { + wrapper.trigger('submit'); + + expect(mockForm.focusFirstFieldWithError).toHaveBeenCalledTimes(1); }); - it(`Then the summary of errors is not shown`, async () => { - expect(wrapper.find(REF_SUMMARY).exists()).toBeFalsy(); + it(`Then the submit event is not sent to the parent`, () => { + wrapper.trigger('submit'); + + expect(wrapper.emitted('submit')).toBeFalsy(); }); }); describe(`When there are multiple errors`, () => { beforeEach(() => { - wrapper.setProps({ - form: new Form([ - new FormField((): string => '', (): FormFieldState => new FormFieldState(true, ERROR_MESSAGE_SUMMARY, ERROR_MESSAGE)), - new FormField((): string => '', (): FormFieldState => new FormFieldState(true, ERROR_MESSAGE_SUMMARY, ERROR_MESSAGE)) - ]) + nbFieldsThatHasError = 2; + mockGetErrorsForSummary = jest.fn(() => { + return [ERROR_MESSAGE_SUMMARY, ERROR_MESSAGE_SUMMARY]; }); + initialiseForm(true); + initialiserWrapper(); wrapper.trigger('submit'); }); diff --git a/src/components/form/form.ts b/src/components/form/form.ts index c4f75d259..f2dc09098 100644 --- a/src/components/form/form.ts +++ b/src/components/form/form.ts @@ -1,49 +1,49 @@ import { PluginObject } from 'vue'; import { Component, Emit, Prop } from 'vue-property-decorator'; +import { FORM_FIELD_NAME } from '../../directives/directive-names'; import { Form } from '../../utils/form/form'; import { ModulVue } from '../../utils/vue/vue'; import { FORM } from '../component-names'; import I18nPlugin from '../i18n/i18n'; import MessagePlugin, { MMessageState } from '../message/message'; -import WithRender from './form.html'; +import { FormFieldDirective } from './form-field'; +import WithRender from './form.html?style=./form.scss'; @WithRender @Component export class MForm extends ModulVue { @Prop() - form: Form; + public form: Form; - @Prop({ default: true }) - hasRequiredFields: boolean; - - messageStateEror: MMessageState = MMessageState.Error; + @Prop() + public requiredMarker: boolean; - errors: string[] = []; + public messageStateError: MMessageState = MMessageState.Error; - get hasErrors(): boolean { - return this.errors.length > 1; - } + public errors: string[] = []; @Emit('submit') - onSubmit(): void { } + public onSubmit(): void { } @Emit('reset') - onReset(): void { } + public onReset(): void { } + + public get hasErrors(): boolean { + return this.errors.length > 0; + } - submit(): void { + public submit(): void { if (this.form) { this.errors = []; this.form.validateAll(); - if (this.form.nbFieldsThatHasError === 0) { + if (this.form.nbFieldsThatHasError === 0 && this.form.nbOfErrors === 0) { this.onSubmit(); } else if (this.form.nbFieldsThatHasError === 1) { - setTimeout(() => { - let fieldWithError: HTMLElement | null = this.$el.querySelector('.m--has-error input, .m--has-error textarea'); - if (fieldWithError) { - (fieldWithError).focus(); - } - }); + if (this.form.nbOfErrors > 0) { + this.errors = this.form.getErrorsForSummary(); + } + this.form.focusFirstFieldWithError(); } else { this.errors = this.form.getErrorsForSummary(); } @@ -52,7 +52,7 @@ export class MForm extends ModulVue { } } - reset(): void { + public reset(): void { this.errors = []; if (this.form) { @@ -69,6 +69,7 @@ const FormPlugin: PluginObject = { v.prototype.$log.debug(FORM, 'plugin.install'); v.use(I18nPlugin); v.use(MessagePlugin); + v.directive(FORM_FIELD_NAME, FormFieldDirective); v.component(FORM, MForm); } }; diff --git a/src/directives/directive-names.ts b/src/directives/directive-names.ts index e8b16cad1..823d76bc7 100644 --- a/src/directives/directive-names.ts +++ b/src/directives/directive-names.ts @@ -4,6 +4,7 @@ export const DRAGGABLE_NAME: string = 'm-draggable'; export const DROPPABLE_GROUP_NAME: string = 'm-droppable-group'; export const DROPPABLE_NAME: string = 'm-droppable'; export const FILE_DROP_NAME: string = 'm-file-drop'; +export const FORM_FIELD_NAME: string = 'm-form-field'; export const I18N_NAME: string = 'm-i18n'; export const POPUP_NAME: string = 'm-popup'; export const REMOVE_USER_SELECT_NAME: string = 'm-remove-user-select'; diff --git a/src/directives/i18n/i18n.spec.ts b/src/directives/i18n/i18n.spec.ts index 2da5e7fb9..ee3562fc9 100644 --- a/src/directives/i18n/i18n.spec.ts +++ b/src/directives/i18n/i18n.spec.ts @@ -1,11 +1,11 @@ import { mount, Wrapper } from '@vue/test-utils'; import Vue from 'vue'; - import { resetModulPlugins } from '../../../tests/helpers/component'; import { addMessages } from '../../../tests/helpers/lang'; import I18nPlugin, { FormatMode, I18nPluginOptions } from '../../utils/i18n/i18n'; import I18nDirectivePlugin from './i18n'; + describe(`Étant donné la directive v-m-i18n`, () => { let element: Wrapper; beforeEach(() => { diff --git a/src/utils/form/form-field-state/form-field-state.spec.ts b/src/utils/form/form-field-state/form-field-state.spec.ts index 9c0d4886e..e69e1ea11 100644 --- a/src/utils/form/form-field-state/form-field-state.spec.ts +++ b/src/utils/form/form-field-state/form-field-state.spec.ts @@ -2,9 +2,9 @@ import { FormFieldState } from './form-field-state'; describe(`FormFieldState`, () => { it(`Default parameters are setup`, () => { - let etatChampFormulaire: FormFieldState = new FormFieldState(); - expect(etatChampFormulaire.hasError).toBeFalsy(); - expect(etatChampFormulaire.errorMessageSummary).toBe(''); - expect(etatChampFormulaire.errorMessage).toBe(''); + let formFieldState: FormFieldState = new FormFieldState(); + expect(formFieldState.hasError).toBeFalsy(); + expect(formFieldState.errorMessagesSummary).toEqual([]); + expect(formFieldState.errorMessages).toEqual([]); }); }); diff --git a/src/utils/form/form-field-state/form-field-state.ts b/src/utils/form/form-field-state/form-field-state.ts index 749577c99..ce5b1cd03 100644 --- a/src/utils/form/form-field-state/form-field-state.ts +++ b/src/utils/form/form-field-state/form-field-state.ts @@ -5,8 +5,8 @@ export class FormFieldState { /** * * @param hasError the field has (at least one) error - * @param errorMessageSummary Message to show in summary - * @param errorMessage message to show next to the field + * @param errorMessagesSummary Messages to show in summary + * @param errorMessage messages to show next to the field */ - constructor(public hasError: boolean = false, public errorMessageSummary: string = '', public errorMessage: string = '') { } + constructor(public hasError: boolean = false, public errorMessagesSummary: string[] = [], public errorMessages: string[] = []) { } } diff --git a/src/utils/form/form-field-validation/form-field-validation.spec.ts b/src/utils/form/form-field-validation/form-field-validation.spec.ts new file mode 100644 index 000000000..a08242207 --- /dev/null +++ b/src/utils/form/form-field-validation/form-field-validation.spec.ts @@ -0,0 +1,10 @@ +import { FormFieldValidation } from './form-field-validation'; + +describe(`FormFieldValidation`, () => { + it(`Default parameters are setup`, () => { + let formFieldValidation: FormFieldValidation = new FormFieldValidation(); + expect(formFieldValidation.isError).toBeFalsy(); + expect(formFieldValidation.errorMessagesSummary).toEqual([]); + expect(formFieldValidation.errorMessages).toEqual([]); + }); +}); diff --git a/src/utils/form/form-field-validation/form-field-validation.ts b/src/utils/form/form-field-validation/form-field-validation.ts new file mode 100644 index 000000000..c7bb8d353 --- /dev/null +++ b/src/utils/form/form-field-validation/form-field-validation.ts @@ -0,0 +1,12 @@ +/** + * Form Field Validation Class + */ +export class FormFieldValidation { + /** + * + * @param isError the validation is in error + * @param errorMessagesSummary Messages to show in summary + * @param errorMessages messages to show next to the field + */ + constructor(public isError: boolean = false, public errorMessagesSummary: string[] = [], public errorMessages: string[] = []) { } +} diff --git a/src/utils/form/form-field/form-field.spec.ts b/src/utils/form/form-field/form-field.spec.ts index a104ee806..0401a9445 100644 --- a/src/utils/form/form-field/form-field.spec.ts +++ b/src/utils/form/form-field/form-field.spec.ts @@ -1,18 +1,20 @@ -import { FormFieldState } from '../form-field-state/form-field-state'; -import { FormField } from './form-field'; +import { InputManagement } from '../../../mixins/input-management/input-management'; +import { FormFieldValidation } from '../form-field-validation/form-field-validation'; +import { FieldValidationCallback, FormField } from './form-field'; -let validationState: FormFieldState; +let validationState: FormFieldValidation; let formField: FormField; +let HTML_ELEMENT: HTMLElement = new InputManagement() as any as HTMLElement; const NEW_FIELD_VALUE: string = 'NEW VALUE'; const ERROR_MESSAGE: string = 'ERROR'; const ERROR_MESSAGE_SUMMARY: string = 'ERROR SUMMARY'; const FIELD_VALUE: string = 'VALUE'; -const FONCTION_VALIDATION: () => FormFieldState = (): FormFieldState => validationState; +const VALIDATION_FUNCTION: FieldValidationCallback[] = [(): FormFieldValidation => validationState]; describe(`FormField`, () => { describe(`When we create a new instance with a value and a validating function`, () => { beforeEach(() => { - formField = new FormField((): string => FIELD_VALUE, FONCTION_VALIDATION); + formField = new FormField((): string => FIELD_VALUE, VALIDATION_FUNCTION, { messageAfterTouched: false }); }); it(`Then the value is the field is the one passed`, () => { @@ -21,13 +23,13 @@ describe(`FormField`, () => { it(`Then the state of the field is initiated`, () => { expect(formField.hasError).toBeFalsy(); - expect(formField.errorMessageSummary).toBe(''); + expect(formField.errorMessageSummary).toEqual([]); expect(formField.errorMessage).toBe(''); }); describe(`When we validate a valid field`, () => { beforeEach(() => { - validationState = new FormFieldState(); + validationState = new FormFieldValidation(); formField.validate(); }); @@ -37,14 +39,14 @@ describe(`FormField`, () => { it(`Then the state of the field contains no errors`, () => { expect(formField.hasError).toBeFalsy(); - expect(formField.errorMessageSummary).toBe(''); + expect(formField.errorMessageSummary).toEqual([]); expect(formField.errorMessage).toBe(''); }); }); describe(`When we validate an invalid field`, () => { beforeEach(() => { - validationState = new FormFieldState(true, ERROR_MESSAGE_SUMMARY, ERROR_MESSAGE); + validationState = new FormFieldValidation(true, [ERROR_MESSAGE_SUMMARY], [ERROR_MESSAGE]); formField.validate(); }); @@ -54,14 +56,35 @@ describe(`FormField`, () => { it(`Then the state of the field contains errors`, () => { expect(formField.hasError).toBeTruthy(); - expect(formField.errorMessageSummary).toBe(ERROR_MESSAGE_SUMMARY); + expect(formField.errorMessageSummary).toEqual([ERROR_MESSAGE_SUMMARY]); + expect(formField.errorMessage).toBe(ERROR_MESSAGE); + }); + }); + + describe(`When we validate an invalid field with messageAfterTouched = true`, () => { + beforeEach(() => { + formField = new FormField((): string => FIELD_VALUE, VALIDATION_FUNCTION, { messageAfterTouched: true }); + validationState = new FormFieldValidation(true, [ERROR_MESSAGE_SUMMARY], [ERROR_MESSAGE]); + }); + + it(`Then the state of the field contains no errors if not touched`, () => { + formField.validate(); + expect(formField.hasError).toBe(true); + expect(formField.errorMessageSummary).toEqual([ERROR_MESSAGE_SUMMARY]); + expect(formField.errorMessage).toBe(''); + }); + + it(`Then the state of the field contains errors if touched`, () => { + formField.touch(); + expect(formField.hasError).toBe(true); + expect(formField.errorMessageSummary).toEqual([ERROR_MESSAGE_SUMMARY]); expect(formField.errorMessage).toBe(ERROR_MESSAGE); }); }); describe(`When we change the value of the field with a valid value`, () => { beforeEach(() => { - validationState = new FormFieldState(); + validationState = new FormFieldValidation(); formField.value = NEW_FIELD_VALUE; }); @@ -71,14 +94,14 @@ describe(`FormField`, () => { it(`Then the state of the field no longer contains errors`, () => { expect(formField.hasError).toBeFalsy(); - expect(formField.errorMessageSummary).toBe(''); + expect(formField.errorMessageSummary).toEqual([]); expect(formField.errorMessage).toBe(''); }); }); describe(`When we change the value for an invalid value`, () => { beforeEach(() => { - validationState = new FormFieldState(true, ERROR_MESSAGE_SUMMARY, ERROR_MESSAGE); + validationState = new FormFieldValidation(true, [ERROR_MESSAGE_SUMMARY], [ERROR_MESSAGE]); formField.value = NEW_FIELD_VALUE; }); @@ -88,7 +111,7 @@ describe(`FormField`, () => { it(`Then the state of the field now contains errors`, () => { expect(formField.hasError).toBeTruthy(); - expect(formField.errorMessageSummary).toBe(ERROR_MESSAGE_SUMMARY); + expect(formField.errorMessageSummary).toEqual([ERROR_MESSAGE_SUMMARY]); expect(formField.errorMessage).toBe(ERROR_MESSAGE); }); @@ -103,7 +126,7 @@ describe(`FormField`, () => { it(`The state of the field no longer contains errors`, () => { expect(formField.hasError).toBeFalsy(); - expect(formField.errorMessageSummary).toBe(''); + expect(formField.errorMessageSummary).toEqual([]); expect(formField.errorMessage).toBe(''); }); }); diff --git a/src/utils/form/form-field/form-field.ts b/src/utils/form/form-field/form-field.ts index e3b634f78..a3dd26dc0 100644 --- a/src/utils/form/form-field/form-field.ts +++ b/src/utils/form/form-field/form-field.ts @@ -1,5 +1,11 @@ import { FormFieldState } from '../form-field-state/form-field-state'; +import { FormFieldValidation } from '../form-field-validation/form-field-validation'; +export interface FormFieldOptions { + messageAfterTouched?: boolean; +} + +export type FieldValidationCallback = (value: any) => FormFieldValidation; /** * Form Field Class */ @@ -7,15 +13,25 @@ export class FormField { private internalValue: T; private oldValue: T; private internalState: FormFieldState; + private messageAfterTouched: boolean = true; + private touched: boolean = false; + private shouldFocusInternal: boolean = false; /** * - * @param value function called to initialize the value of a field + * @param accessCallback function called to initialize the value of a field * @param validationCallback function called to validate + * @param options options for the field */ - constructor(public accessCallback: () => T, public validationCallback?: (value: T) => FormFieldState) { + constructor(public accessCallback: () => T, public validationCallback: FieldValidationCallback[] = [], options?: FormFieldOptions) { + this.internalValue = accessCallback(); this.internalState = new FormFieldState(); + + if (options) { + this.messageAfterTouched = typeof options.messageAfterTouched === undefined ? + this.messageAfterTouched : options.messageAfterTouched!; + } } /** @@ -25,6 +41,9 @@ export class FormField { return this.internalValue; } + /** + * set the value of the field + */ set value(newValue: T) { this.change(newValue); } @@ -36,29 +55,77 @@ export class FormField { return this.internalState.hasError; } + /** + * indicates if the field is touched + */ + get isTouched(): boolean { + return this.touched; + } + + /** + * if the field should focus + */ + get shouldFocus(): boolean { + return this.shouldFocusInternal; + } + + /** + * set should focus on field + */ + set shouldFocus(value: boolean) { + this.shouldFocusInternal = value; + } + /** * message to show under the form field */ get errorMessage(): string { - return this.internalState.errorMessage; + let errorMessageToShow: string = ''; + + if (this.hasError && ((this.messageAfterTouched && this.touched) || !this.messageAfterTouched)) { + errorMessageToShow = this.internalState.errorMessages[0]; + } + + return errorMessageToShow; } /** * message to show in the error summary */ - get errorMessageSummary(): string { - return this.internalState.errorMessageSummary; + get errorMessageSummary(): string[] { + return this.internalState.errorMessagesSummary; } /** * execute validations */ validate(): void { - if (this.validationCallback) { - this.changeState(this.validationCallback(this.internalValue)); + if (this.validationCallback.length > 0) { + let newState: FormFieldState = new FormFieldState(); + this.validationCallback.forEach((validationFunction) => { + let validation: FormFieldValidation = validationFunction(this.internalValue); + if (validation.isError) { + newState.hasError = true; + } + if (validation.errorMessages.length > 0) { + newState.errorMessages = newState.errorMessages.concat(validation.errorMessages); + } + if (validation.errorMessagesSummary.length > 0) { + newState.errorMessagesSummary = newState.errorMessagesSummary.concat(validation.errorMessagesSummary); + } + }); + this.changeState(newState); } } + /** + * mark the field as touched and trigger validation + */ + touch(): void { + this.touched = true; + this.validate(); + } + /** * reset the field without validating */ @@ -66,6 +133,7 @@ export class FormField { this.internalValue = this.accessCallback(); this.oldValue = this.internalValue; this.internalState = new FormFieldState(); + this.touched = false; } /** @@ -82,7 +150,7 @@ export class FormField { private changeState(etat: FormFieldState): void { this.internalState.hasError = etat.hasError; - this.internalState.errorMessage = etat.errorMessage; - this.internalState.errorMessageSummary = etat.errorMessageSummary; + this.internalState.errorMessages = etat.errorMessages; + this.internalState.errorMessagesSummary = etat.errorMessagesSummary; } } diff --git a/src/utils/form/form-state/form-state.spec.ts b/src/utils/form/form-state/form-state.spec.ts new file mode 100644 index 000000000..2d70c5249 --- /dev/null +++ b/src/utils/form/form-state/form-state.spec.ts @@ -0,0 +1,9 @@ +import { FormState } from './form-state'; + +describe(`FormState`, () => { + it(`Default parameters are setup`, () => { + let formState: FormState = new FormState(); + expect(formState.hasErrors).toBeFalsy(); + expect(formState.errorMessages.length).toBe(0); + }); +}); diff --git a/src/utils/form/form-state/form-state.ts b/src/utils/form/form-state/form-state.ts new file mode 100644 index 000000000..bca3056b3 --- /dev/null +++ b/src/utils/form/form-state/form-state.ts @@ -0,0 +1,12 @@ +/** + * Form State Class + */ +export class FormState { + /** + * + * @param hasErrors The form has (at least one) error + * @param errorMessage Messages list to show in summary + */ + constructor(public hasErrors: boolean = false, public errorMessages: string[] = []) { } +} + diff --git a/src/utils/form/form-validation/form-validation.spec.ts b/src/utils/form/form-validation/form-validation.spec.ts new file mode 100644 index 000000000..1092977b7 --- /dev/null +++ b/src/utils/form/form-validation/form-validation.spec.ts @@ -0,0 +1,9 @@ +import { FormValidation } from './form-validation'; + +describe(`FormValidation`, () => { + it(`Default parameters are setup`, () => { + let formValidation: FormValidation = new FormValidation(); + expect(formValidation.hasError).toBeFalsy(); + expect(formValidation.errorMessage).toBe(''); + }); +}); diff --git a/src/utils/form/form-validation/form-validation.ts b/src/utils/form/form-validation/form-validation.ts new file mode 100644 index 000000000..d96b7c62c --- /dev/null +++ b/src/utils/form/form-validation/form-validation.ts @@ -0,0 +1,11 @@ +/** + * Form validation class + */ +export class FormValidation { + /** + * + * @param hasError The validation has an error + * @param errorMessage Message to show in summary + */ + constructor(public hasError: boolean = false, public errorMessage: string = '') { } +} diff --git a/src/utils/form/form.spec.ts b/src/utils/form/form.spec.ts index 5b9330e52..b9e9a238c 100644 --- a/src/utils/form/form.spec.ts +++ b/src/utils/form/form.spec.ts @@ -1,24 +1,54 @@ // tslint:disable:no-identical-functions no-big-function import { Form } from './form'; -import { FormFieldState } from './form-field-state/form-field-state'; -import { FormField } from './form-field/form-field'; +import { FormFieldValidation } from './form-field-validation/form-field-validation'; +import { FieldValidationCallback, FormField } from './form-field/form-field'; +import { FormValidation } from './form-validation/form-validation'; + +let mockFormField: any = {}; +jest.mock('./form-field/form-field', () => { + return { + FormField: jest.fn().mockImplementation(() => { + return mockFormField; + }) + }; +}); -let validationState: FormFieldState; -let formField: FormField; +let fieldValidation: FormFieldValidation; let form: Form; +let formFieldHasError: boolean = false; -const ERROR_MESSAGE: string = 'ERROR'; const ERROR_MESSAGE_SUMMARY: string = 'ERROR SUMMARY'; const FIELD_VALUE: string = 'VALUE'; -const VALIDATING_FUNCTION: () => FormFieldState = (): FormFieldState => validationState; +const VALIDATING_FUNCTION: FieldValidationCallback = (): FormFieldValidation => fieldValidation; + +const mockFieldValidationWithError: FormFieldValidation = { + isError: true, + errorMessages: ['errorMessage'], + errorMessagesSummary: ['errorMessageSummary'] +}; +let errorFormField: FormField; +let formFieldSummaryErrors: string[]; describe(`Form`, () => { + beforeEach(() => { + mockFormField = { + hasError: formFieldHasError, + reset: jest.fn(), + errorMessageSummary: formFieldSummaryErrors, + validate: jest.fn(), + focusThisField: jest.fn(), + touch: jest.fn(), + shouldFocus: false + }; + ((FormField as unknown) as jest.Mock).mockClear(); + }); + describe(`When the form contains no fields with errors`, () => { beforeEach(() => { - validationState = new FormFieldState(); - formField = new FormField((): string => FIELD_VALUE, VALIDATING_FUNCTION); - form = new Form([formField]); + form = new Form({ + 'a-field': new FormField((): string => FIELD_VALUE, [VALIDATING_FUNCTION]) + }); }); it(`Then the form is valid`, () => { @@ -45,11 +75,36 @@ describe(`Form`, () => { }); }); + describe(`When the form contains no error`, () => { + beforeEach(() => { + form = new Form({}, [() => new FormValidation()]); + }); + + it(`Then the form is valid`, () => { + expect(form.isValid).toBeTruthy(); + }); + }); + + describe(`When the form has error`, () => { + beforeEach(() => { + form = new Form({}, [() => new FormValidation(true, ERROR_MESSAGE_SUMMARY)]); + form.validateAll(); + }); + + it(`Then the form is invalid`, () => { + expect(form.isValid).toBeFalsy(); + }); + + it(`getErrors returns 1 error message`, () => { + expect(form.getErrorsForSummary()).toEqual([ERROR_MESSAGE_SUMMARY]); + }); + }); + describe(`When at least one field has errors`, () => { beforeEach(() => { - validationState = new FormFieldState(true, ERROR_MESSAGE_SUMMARY, ERROR_MESSAGE); - formField = new FormField((): string => FIELD_VALUE, VALIDATING_FUNCTION); - form = new Form([formField]); + form = new Form({ + 'a-field': new FormField((): string => FIELD_VALUE, [() => mockFieldValidationWithError]) + }); }); it(`Then the form is valid at first`, () => { @@ -62,32 +117,46 @@ describe(`Form`, () => { describe(`When we validate all fields`, () => { beforeEach(() => { + formFieldHasError = true; + formFieldSummaryErrors = [ERROR_MESSAGE_SUMMARY]; form.validateAll(); }); - it(`Then the form is invalid`, () => { - expect(form.isValid).toBeFalsy(); + it(`validates each fields`, () => { + expect(mockFormField.touch).toHaveBeenCalledTimes(1); + }); + + it(`getErrorsForSummary returns 1 error message`, () => { + expect(form.getErrorsForSummary()).toEqual([ERROR_MESSAGE_SUMMARY]); }); it(`nbFieldsThatHasError returns 1`, () => { expect(form.nbFieldsThatHasError).toBe(1); }); - it(`getErrorsForSummary returns 1 error message`, () => { - expect(form.getErrorsForSummary()).toEqual([ERROR_MESSAGE_SUMMARY]); + it(`Then the form is invalid`, () => { + expect(form.isValid).toBeFalsy(); + }); + + it(`Then the form can focus the first field in error`, () => { + form.focusFirstFieldWithError(); + + expect(mockFormField.shouldFocus).toBe(true); }); }); }); describe(`When we reset the form`, () => { it(`Then each fields are reset`, () => { - formField = new FormField((): string => FIELD_VALUE, VALIDATING_FUNCTION); - form = new Form([formField, formField]); - FormField.prototype.reset = jest.fn(); + form = new Form({ + 'a-field': new FormField((): string => FIELD_VALUE, [VALIDATING_FUNCTION]), + 'another-field': new FormField((): string => FIELD_VALUE, [VALIDATING_FUNCTION]) + }); + const spy: jest.SpyInstance = jest.spyOn(mockFormField, 'reset'); form.reset(); - expect(FormField.prototype.reset).toHaveBeenCalledTimes(form.fields.length); + expect(spy).toHaveBeenCalledTimes(form.fields.length); }); }); }); diff --git a/src/utils/form/form.ts b/src/utils/form/form.ts index 72d32f83b..014a7b0e5 100644 --- a/src/utils/form/form.ts +++ b/src/utils/form/form.ts @@ -1,5 +1,10 @@ import uuid from '../uuid/uuid'; import { FormField } from './form-field/form-field'; +import { FormState } from './form-state/form-state'; +import { FormValidation } from './form-validation/form-validation'; + +export type FormValidationCallback = (formInstance: Form) => FormValidation; +export type FormFieldGroup = { [name: string]: FormField; }; /** * Form Class @@ -7,12 +12,24 @@ import { FormField } from './form-field/form-field'; export class Form { public id: string; + private internalState: FormState; + /** * - * @param fields fields that are in the form + * @param fieldGroup the group of field that populate the form + * @param validationCallbacks a list of function to call to verify the form's state */ - constructor(public fields: FormField[]) { + constructor(private fieldGroup: FormFieldGroup, private validationCallbacks: FormValidationCallback[] = []) { this.id = uuid.generate(); + this.internalState = new FormState(); + } + + /** + * return the form fields + */ + get fields(): FormField[] { + return Object.keys(this.fieldGroup) + .map((name: string): FormField => this.fieldGroup[name]); } /** @@ -22,11 +39,33 @@ export class Form { return this.fields.filter((field: FormField) => field.hasError).length; } + /** + * Number of form errors + */ + get nbOfErrors(): number { + return this.internalState.errorMessages.length; + } + /** * return true if the form contains no field with errors */ get isValid(): boolean { - return this.nbFieldsThatHasError === 0; + return this.nbFieldsThatHasError === 0 && this.nbOfErrors === 0; + } + + /** + * Return the formField with the coresponding name + * + * @param formFieldName the name of the formfield to access + */ + get(formFieldName: string): FormField { + let formField: FormField = this.fieldGroup[formFieldName]; + + if (!formField) { + throw new Error('Trying to access an non existing form field'); + } + + return this.fieldGroup[formFieldName]; } /** @@ -36,21 +75,54 @@ export class Form { this.fields.forEach((field: FormField) => { field.reset(); }); + + this.internalState = new FormState(); } /** * returns all the messages that must be shown in the summary */ getErrorsForSummary(): string[] { - return this.fields.filter((field: FormField) => field.errorMessageSummary !== '').map((field: FormField) => field.errorMessageSummary); + let errorsSummary: string[] = this.internalState.errorMessages; + + this.fields.forEach((field: FormField) => { + errorsSummary.push(field.errorMessageSummary[0]); + }); + + return errorsSummary; + } + + focusFirstFieldWithError(): void { + let fieldWithError: FormField | undefined = this.fields.find(field => { + return field.hasError; + }); + + if (fieldWithError) { + fieldWithError.shouldFocus = true; + } } + /** * validate all fields in the form */ validateAll(): void { this.fields.forEach((field: FormField) => { - field.validate(); + field.touch(); + }); + + this.internalState = new FormState(); + this.validationCallbacks.forEach((validationCallback: FormValidationCallback) => { + this.changeState(validationCallback(this)); }); } + + private changeState(formValidation: FormValidation): void { + if (!formValidation.hasError) { + return; + } + + this.internalState.hasErrors = true; + this.internalState.errorMessages = this.internalState.errorMessages.concat(formValidation.errorMessage); + } } diff --git a/src/utils/toast/toast-service.sandbox.html b/src/utils/toast/toast-service.sandbox.html index d50178cbf..4b6e23ab1 100644 --- a/src/utils/toast/toast-service.sandbox.html +++ b/src/utils/toast/toast-service.sandbox.html @@ -8,6 +8,7 @@

Toast Manager

Show other toast Show a complex toast Show an error toast + Show toast with html

Show toast over a modal/overlay @@ -24,7 +25,7 @@

Toast Manager

- Remove active toast + Add HTML to toast

Clear diff --git a/src/utils/toast/toast-service.sandbox.ts b/src/utils/toast/toast-service.sandbox.ts index 0adb8866b..7c1f2c327 100644 --- a/src/utils/toast/toast-service.sandbox.ts +++ b/src/utils/toast/toast-service.sandbox.ts @@ -70,6 +70,14 @@ export class MToastServiceSandbox extends ModulVue { }); } + public html(): void { + this.$toast.show({ + text: '

You have too many errors

', + icon: true, + state: MToastState.Error + }); + } + public action(event: Event): void { alert(`Type of event ${event.type}`); } diff --git a/src/utils/toast/toast-service.ts b/src/utils/toast/toast-service.ts index 3e1bbc9b5..4ba385dbb 100644 --- a/src/utils/toast/toast-service.ts +++ b/src/utils/toast/toast-service.ts @@ -1,5 +1,5 @@ +import Vue from 'vue'; import { PluginObject } from 'vue/types/plugin'; -import { VNode } from 'vue/types/vnode'; import { MToast, MToastPosition, MToastState, MToastTimeout } from '../../components/toast/toast'; @@ -10,7 +10,7 @@ declare module 'vue/types/vue' { } export interface ToastParams { - text: string; + text: string; // Can be html, but must start with a root tag:

text of toast

actionLabel?: string; action?: (event: Event) => any; state?: MToastState; @@ -72,8 +72,12 @@ export class ToastService { toast.offset = toast.isTop ? this.baseTopPosition : '0'; - const vnode: VNode = toast.$createElement('p', [params.text]); - toast.$slots.default = [vnode]; + if (params.text.charAt(0) === '<') { + toast.$slots.default = [toast.$createElement(Vue.compile(params.text))]; + } else { + toast.$slots.default = [toast.$createElement('p', params.text)]; + } + return toast; } }