From 23329153382fb1530b36939d80d0740c44edeca0 Mon Sep 17 00:00:00 2001 From: Salome DO Date: Mon, 22 Apr 2024 12:38:32 +0200 Subject: [PATCH] feat: update forms doc --- apps/showcase/package.json | 1 + apps/showcase/src/app/app-routing.module.ts | 1 + apps/showcase/src/app/app.component.ts | 3 +- apps/showcase/src/app/forms/README.md | 5 + .../showcase/src/app/forms/forms.component.ts | 34 ++ apps/showcase/src/app/forms/forms.spec.ts | 23 + apps/showcase/src/app/forms/forms.style.scss | 3 + .../src/app/forms/forms.template.html | 51 ++ apps/showcase/src/app/forms/index.ts | 2 + .../showcase/forms-parent/README.md | 3 + .../forms-parent/contracts/form-models.ts | 17 + .../showcase/forms-parent/contracts/index.ts | 1 + .../forms-parent/forms-parent.component.ts | 142 +++++ .../forms-parent.localization.json | 14 + .../forms-parent/forms-parent.spec.ts | 23 + .../forms-parent/forms-parent.style.scss | 3 + .../forms-parent/forms-parent.template.html | 29 ++ .../forms-parent/forms-parent.translation.ts | 13 + .../forms-parent/forms-parent.validators.ts | 47 ++ .../components/showcase/forms-parent/index.ts | 2 + .../showcase/src/components/showcase/index.ts | 1 + .../forms-emergency-contact/README.md | 3 + .../forms-emergency-contact-pres.component.ts | 193 +++++++ ...s-emergency-contact-pres.localization.json | 34 ++ .../forms-emergency-contact-pres.spec.ts | 23 + .../forms-emergency-contact-pres.style.scss | 3 + ...forms-emergency-contact-pres.template.html | 55 ++ ...orms-emergency-contact-pres.translation.ts | 23 + .../forms-emergency-contact/index.ts | 2 + .../utilities/forms-personal-info/README.md | 3 + .../forms-personal-info-pres.component.ts | 227 ++++++++ .../forms-personal-info-pres.config.ts | 7 + ...forms-personal-info-pres.localization.json | 34 ++ .../forms-personal-info-pres.spec.ts | 23 + .../forms-personal-info-pres.style.scss | 3 + .../forms-personal-info-pres.template.html | 46 ++ .../forms-personal-info-pres.translation.ts | 23 + .../utilities/forms-personal-info/index.ts | 3 + .../src/components/utilities/index.ts | 2 + docs/forms/FORM_ERRORS.md | 472 +++++------------ docs/forms/FORM_STRUCTURE.md | 488 ++++-------------- .../FORM_SUBMIT_AND_INTERCOMMUNICATION.md | 370 ++++++------- docs/forms/FORM_VALIDATION.md | 329 ++++-------- docs/forms/README.md | 125 ++--- packages/@o3r/forms/README.md | 92 +--- yarn.lock | 3 +- 46 files changed, 1660 insertions(+), 1344 deletions(-) create mode 100644 apps/showcase/src/app/forms/README.md create mode 100644 apps/showcase/src/app/forms/forms.component.ts create mode 100644 apps/showcase/src/app/forms/forms.spec.ts create mode 100644 apps/showcase/src/app/forms/forms.style.scss create mode 100644 apps/showcase/src/app/forms/forms.template.html create mode 100644 apps/showcase/src/app/forms/index.ts create mode 100644 apps/showcase/src/components/showcase/forms-parent/README.md create mode 100644 apps/showcase/src/components/showcase/forms-parent/contracts/form-models.ts create mode 100644 apps/showcase/src/components/showcase/forms-parent/contracts/index.ts create mode 100644 apps/showcase/src/components/showcase/forms-parent/forms-parent.component.ts create mode 100644 apps/showcase/src/components/showcase/forms-parent/forms-parent.localization.json create mode 100644 apps/showcase/src/components/showcase/forms-parent/forms-parent.spec.ts create mode 100644 apps/showcase/src/components/showcase/forms-parent/forms-parent.style.scss create mode 100644 apps/showcase/src/components/showcase/forms-parent/forms-parent.template.html create mode 100644 apps/showcase/src/components/showcase/forms-parent/forms-parent.translation.ts create mode 100644 apps/showcase/src/components/showcase/forms-parent/forms-parent.validators.ts create mode 100644 apps/showcase/src/components/showcase/forms-parent/index.ts create mode 100644 apps/showcase/src/components/utilities/forms-emergency-contact/README.md create mode 100644 apps/showcase/src/components/utilities/forms-emergency-contact/forms-emergency-contact-pres.component.ts create mode 100644 apps/showcase/src/components/utilities/forms-emergency-contact/forms-emergency-contact-pres.localization.json create mode 100644 apps/showcase/src/components/utilities/forms-emergency-contact/forms-emergency-contact-pres.spec.ts create mode 100644 apps/showcase/src/components/utilities/forms-emergency-contact/forms-emergency-contact-pres.style.scss create mode 100644 apps/showcase/src/components/utilities/forms-emergency-contact/forms-emergency-contact-pres.template.html create mode 100644 apps/showcase/src/components/utilities/forms-emergency-contact/forms-emergency-contact-pres.translation.ts create mode 100644 apps/showcase/src/components/utilities/forms-emergency-contact/index.ts create mode 100644 apps/showcase/src/components/utilities/forms-personal-info/README.md create mode 100644 apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.component.ts create mode 100644 apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.config.ts create mode 100644 apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.localization.json create mode 100644 apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.spec.ts create mode 100644 apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.style.scss create mode 100644 apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.template.html create mode 100644 apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.translation.ts create mode 100644 apps/showcase/src/components/utilities/forms-personal-info/index.ts diff --git a/apps/showcase/package.json b/apps/showcase/package.json index ff9a029dcc..255191432b 100644 --- a/apps/showcase/package.json +++ b/apps/showcase/package.json @@ -54,6 +54,7 @@ "@o3r/configuration": "workspace:^", "@o3r/core": "workspace:^", "@o3r/dynamic-content": "workspace:^", + "@o3r/forms": "workspace:^", "@o3r/localization": "workspace:^", "@o3r/logger": "workspace:^", "@o3r/routing": "workspace:^", diff --git a/apps/showcase/src/app/app-routing.module.ts b/apps/showcase/src/app/app-routing.module.ts index 5fe834d86b..78722533f3 100644 --- a/apps/showcase/src/app/app-routing.module.ts +++ b/apps/showcase/src/app/app-routing.module.ts @@ -13,6 +13,7 @@ const appRoutes: Routes = [ {path: 'run-app-locally', loadComponent: () => import('./run-app-locally/index').then((m) => m.RunAppLocallyComponent), title: 'Otter Showcase - Run App Locally'}, {path: 'sdk', loadComponent: () => import('./sdk/index').then((m) => m.SdkComponent), title: 'Otter Showcase - SDK'}, {path: 'placeholder', loadComponent: () => import('./placeholder/index').then((m) => m.PlaceholderComponent), title: 'Otter Showcase - Placeholder'}, + {path: 'forms', loadComponent: () => import('./forms/index').then((m) => m.FormsComponent), title: 'Otter Showcase - Forms'}, {path: '**', redirectTo: '/home', pathMatch: 'full'} ]; diff --git a/apps/showcase/src/app/app.component.ts b/apps/showcase/src/app/app.component.ts index 6fbb700323..7c8656168a 100644 --- a/apps/showcase/src/app/app.component.ts +++ b/apps/showcase/src/app/app.component.ts @@ -31,7 +31,8 @@ export class AppComponent implements OnDestroy { { url: '/dynamic-content', label: 'Dynamic content' }, { url: '/component-replacement', label: 'Component replacement' }, { url: '/rules-engine', label: 'Rules engine' }, - { url: '/placeholder', label: 'Placeholder' } + { url: '/placeholder', label: 'Placeholder' }, + { url: '/forms', label: 'Forms' } ] }, { diff --git a/apps/showcase/src/app/forms/README.md b/apps/showcase/src/app/forms/README.md new file mode 100644 index 0000000000..0540727e6b --- /dev/null +++ b/apps/showcase/src/app/forms/README.md @@ -0,0 +1,5 @@ +# Forms + +A showcase page that demonstrates how to use the otter forms feature inside an application. + +The page contains both a step by step explanation to guide the users as well as a sample component that can be used as a reference and that illustrates the capabilities of the feature. diff --git a/apps/showcase/src/app/forms/forms.component.ts b/apps/showcase/src/app/forms/forms.component.ts new file mode 100644 index 0000000000..91757ea244 --- /dev/null +++ b/apps/showcase/src/app/forms/forms.component.ts @@ -0,0 +1,34 @@ +import { AsyncPipe } from '@angular/common'; +import { AfterViewInit, ChangeDetectionStrategy, Component, inject, QueryList, ViewChildren, ViewEncapsulation } from '@angular/core'; +import { RouterModule } from '@angular/router'; +import { O3rComponent } from '@o3r/core'; +import { CopyTextPresComponent, FormsParentComponent, IN_PAGE_NAV_PRES_DIRECTIVES, InPageNavLink, InPageNavLinkDirective, InPageNavPresService } from '../../components/index'; + +@O3rComponent({ componentType: 'Page' }) +@Component({ + selector: 'o3r-forms', + standalone: true, + imports: [ + RouterModule, + FormsParentComponent, + CopyTextPresComponent, + IN_PAGE_NAV_PRES_DIRECTIVES, + AsyncPipe + ], + templateUrl: './forms.template.html', + styleUrl: './forms.style.scss', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class FormsComponent implements AfterViewInit { + @ViewChildren(InPageNavLinkDirective) + private readonly inPageNavLinkDirectives!: QueryList; + + private readonly inPageNavPresService = inject(InPageNavPresService); + + public links$ = this.inPageNavPresService.links$; + + public ngAfterViewInit() { + this.inPageNavPresService.initialize(this.inPageNavLinkDirectives); + } +} diff --git a/apps/showcase/src/app/forms/forms.spec.ts b/apps/showcase/src/app/forms/forms.spec.ts new file mode 100644 index 0000000000..aab6032d72 --- /dev/null +++ b/apps/showcase/src/app/forms/forms.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterModule } from '@angular/router'; +import { mockTranslationModules } from '@o3r/testing/localization'; +import { FormsComponent } from './forms.component'; + +describe('FormsComponent', () => { + let component: FormsComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [RouterModule.forRoot([]), FormsComponent,...mockTranslationModules()] + }).compileComponents(); + + fixture = TestBed.createComponent(FormsComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/showcase/src/app/forms/forms.style.scss b/apps/showcase/src/app/forms/forms.style.scss new file mode 100644 index 0000000000..cce88abb0d --- /dev/null +++ b/apps/showcase/src/app/forms/forms.style.scss @@ -0,0 +1,3 @@ +o3r-forms { + +} diff --git a/apps/showcase/src/app/forms/forms.template.html b/apps/showcase/src/app/forms/forms.template.html new file mode 100644 index 0000000000..5656b64076 --- /dev/null +++ b/apps/showcase/src/app/forms/forms.template.html @@ -0,0 +1,51 @@ +

Forms

+
+
+ + +
+
+

Description

+
+

This module provides utilities to enhance the build of Angular reactive forms for specific use cases, including:

+
    +
  • A container/presenter structure for components
  • +
  • Handling form submission at page (or parent component) level
  • +
  • Displaying the error message outside the form
  • +
+
+ +

Example

+
+

+ In the following example, we have a parent component with two subcomponents, each containing a form. +

+

+ The first form requires the user to define their personal information (name and date of birth). + The second form requires the definition of the user's emergency contact information (name, phone number, and email address). + Both forms contain validators, such as certain fields being required or specific values having to follow a certain pattern. +

+

+ The submit of both forms is triggered at parent component level. +

+ +

+ Do not hesitate to run the application locally, if not installed yet, follow the instructions. +

+ Source code +
+

How to install

+ +

References

+
+ +
+
+
diff --git a/apps/showcase/src/app/forms/index.ts b/apps/showcase/src/app/forms/index.ts new file mode 100644 index 0000000000..8037f6234d --- /dev/null +++ b/apps/showcase/src/app/forms/index.ts @@ -0,0 +1,2 @@ +export * from './forms.component'; + diff --git a/apps/showcase/src/components/showcase/forms-parent/README.md b/apps/showcase/src/components/showcase/forms-parent/README.md new file mode 100644 index 0000000000..df9e42fafb --- /dev/null +++ b/apps/showcase/src/components/showcase/forms-parent/README.md @@ -0,0 +1,3 @@ +# FormsParent + +Showcase of an Otter component with forms diff --git a/apps/showcase/src/components/showcase/forms-parent/contracts/form-models.ts b/apps/showcase/src/components/showcase/forms-parent/contracts/form-models.ts new file mode 100644 index 0000000000..de28cbf4ea --- /dev/null +++ b/apps/showcase/src/components/showcase/forms-parent/contracts/form-models.ts @@ -0,0 +1,17 @@ +/** Model used to create Personal Info form */ +export interface PersonalInfo { + /** Name */ + name: string; + /** Date of birth */ + dateOfBirth: string; +} + +/** Model used to create Emergency Contact form */ +export interface EmergencyContact { + /** Emergency contact name */ + name: string; + /** Emergency contact phone number */ + phone: string; + /** Emergency contact email address */ + email: string; +} diff --git a/apps/showcase/src/components/showcase/forms-parent/contracts/index.ts b/apps/showcase/src/components/showcase/forms-parent/contracts/index.ts new file mode 100644 index 0000000000..1172e4a591 --- /dev/null +++ b/apps/showcase/src/components/showcase/forms-parent/contracts/index.ts @@ -0,0 +1 @@ +export * from './form-models'; diff --git a/apps/showcase/src/components/showcase/forms-parent/forms-parent.component.ts b/apps/showcase/src/components/showcase/forms-parent/forms-parent.component.ts new file mode 100644 index 0000000000..c829c0bc1d --- /dev/null +++ b/apps/showcase/src/components/showcase/forms-parent/forms-parent.component.ts @@ -0,0 +1,142 @@ +import { AsyncPipe, CommonModule, formatDate } from '@angular/common'; +import { ChangeDetectionStrategy, Component, Input, ViewEncapsulation } from '@angular/core'; +import { ReactiveFormsModule, UntypedFormControl } from '@angular/forms'; +import { O3rComponent } from '@o3r/core'; +import { Localization } from '@o3r/localization'; +import { CustomFormValidation } from '@o3r/forms'; +import { CopyTextPresComponent, FormsEmergencyContactPresComponent, FormsPersonalInfoPresComponent } from '../../utilities'; +import { EmergencyContact, PersonalInfo } from './contracts'; +import { FormsParentTranslation, translations } from '../forms-parent/forms-parent.translation'; +import { dateCustomValidator, formsParentValidatorGlobal } from '../forms-parent/forms-parent.validators'; + +@O3rComponent({ componentType: 'Component' }) +@Component({ + selector: 'o3r-forms-parent', + standalone: true, + imports: [ + AsyncPipe, + CommonModule, + CopyTextPresComponent, + FormsEmergencyContactPresComponent, + FormsPersonalInfoPresComponent, + ReactiveFormsModule + ], + templateUrl: '../forms-parent/forms-parent.template.html', + styleUrl: './forms-parent.style.scss', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class FormsParentComponent { + + /** Localization of the component */ + @Input() + @Localization('./forms-parent.localization.json') + public translations: FormsParentTranslation = translations; + + /** The personal info form object model */ + public personalInfo: PersonalInfo = { name: '', dateOfBirth: this.formatDate(Date.now()) }; + /** The emergency contact form object model */ + public emergencyContact: EmergencyContact = { name: '', phone: '', email: '' }; + + /** The form control object bind to the personal info component */ + public personalInfoFormControl: UntypedFormControl = new UntypedFormControl(this.personalInfo); + /** The form control object bind to the emergency contact component */ + public emergencyContactFormControl: UntypedFormControl = new UntypedFormControl(this.emergencyContact); + + public submittedFormValue = ''; + + public firstSubmit = true; + public firstEmergencyContactFormSubmit = true; + public firstPersonalInfoFormSubmit = true; + + private readonly forbiddenName = 'Test'; + + /** Form validators for personal info */ + public personalInfoValidators: CustomFormValidation = { + global: formsParentValidatorGlobal(this.forbiddenName, translations.globalForbiddenName, translations.globalForbiddenNameLong, { name: this.forbiddenName }), + fields: { + dateOfBirth: dateCustomValidator(translations.dateInThePast) + } + }; + /** Form validators for emergency contact */ + public emergencyContactValidators: CustomFormValidation = { + global: formsParentValidatorGlobal(this.forbiddenName, translations.globalForbiddenName, translations.globalForbiddenNameLong, { name: this.forbiddenName }) + }; + + private formatDate(dateTime: number) { + return formatDate(dateTime, 'yyyy-MM-dd', 'en-GB'); + } + + /** This will store the function to make the personal info form as dirty and touched */ + public _markPersonalInfoInteraction: () => void = () => {}; + /** This will store the function to make the emergency contact form as dirty and touched */ + public _markEmergencyContactInteraction: () => void = () => {}; + + /** + * Register the function to be called to mark the personal info form as touched and dirty + * + * @param fn + */ + public registerPersonalInfoInteraction(fn: () => void) { + this._markPersonalInfoInteraction = fn; + } + + /** + * Register the function to be called to mark the personal emergency contact form as touched and dirty + * + * @param fn + */ + public registerEmergencyContactInteraction(fn: () => void) { + this._markEmergencyContactInteraction = fn; + } + + /** submit function */ + public submitAction() { + if (this.firstSubmit) { + this._markPersonalInfoInteraction(); + this._markEmergencyContactInteraction(); + this.firstSubmit = false; + this.firstPersonalInfoFormSubmit = false; + this.firstEmergencyContactFormSubmit = false; + } + this.submitPersonalInfoForm(); + this.submitEmergencyContactForm(); + this.submittedFormValue = JSON.stringify(this.personalInfoFormControl.value) + '\n' + JSON.stringify(this.emergencyContactFormControl.value); + } + + /** Submit emergency contact form */ + public submitPersonalInfoForm() { + if (this.firstPersonalInfoFormSubmit) { + this._markPersonalInfoInteraction(); + this.firstPersonalInfoFormSubmit = false; + } + const isValid = !this.personalInfoFormControl.errors; + if (isValid) { + this.submittedFormValue = JSON.stringify(this.personalInfoFormControl.value); + // eslint-disable-next-line no-console + console.log('FORMS PARENT COMPONENT: personal info form status', this.personalInfoFormControl.status); + // eslint-disable-next-line no-console + console.log('FORMS PARENT COMPONENT: personal info form value', this.personalInfoFormControl.value); + } + // eslint-disable-next-line no-console + console.log('FORMS PARENT COMPONENT: personal info form is valid:', isValid); + } + + /** Submit emergency contact form */ + public submitEmergencyContactForm() { + if (this.firstEmergencyContactFormSubmit) { + this._markEmergencyContactInteraction(); + this.firstEmergencyContactFormSubmit = false; + } + const isValid = !this.emergencyContactFormControl.errors; + if (isValid) { + this.submittedFormValue = JSON.stringify(this.emergencyContactFormControl.value); + // eslint-disable-next-line no-console + console.log('FORMS PARENT COMPONENT: emergency contact form status', this.emergencyContactFormControl.status); + // eslint-disable-next-line no-console + console.log('FORMS PARENT COMPONENT: emergency contact form value', this.emergencyContactFormControl.value); + } + // eslint-disable-next-line no-console + console.log('FORMS PARENT COMPONENT: emergency contact form is valid:', isValid); + } +} diff --git a/apps/showcase/src/components/showcase/forms-parent/forms-parent.localization.json b/apps/showcase/src/components/showcase/forms-parent/forms-parent.localization.json new file mode 100644 index 0000000000..09e4443931 --- /dev/null +++ b/apps/showcase/src/components/showcase/forms-parent/forms-parent.localization.json @@ -0,0 +1,14 @@ +{ + "o3r-forms-parent.dateOfBirth.dateInThePast": { + "description": "Validator for date of birth", + "defaultValue": "Date of birth should be in the past" + }, + "o3r-forms-parent.globalForbiddenName": { + "description": "This validator will check if the name will be the given config", + "defaultValue": "Name cannot be { name }" + }, + "o3r-forms-parent.globalForbiddenName.long": { + "description": "This validator will check if the name will be the given config", + "defaultValue": "The value introduced for the name cannot be { name }" + } +} diff --git a/apps/showcase/src/components/showcase/forms-parent/forms-parent.spec.ts b/apps/showcase/src/components/showcase/forms-parent/forms-parent.spec.ts new file mode 100644 index 0000000000..6439f2ff62 --- /dev/null +++ b/apps/showcase/src/components/showcase/forms-parent/forms-parent.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { mockTranslationModules } from '@o3r/testing/localization'; +import { FormsParentComponent } from './forms-parent.component'; + +describe('FormsParentComponent', () => { + let component: FormsParentComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FormsParentComponent, ...mockTranslationModules(), ReactiveFormsModule] + }).compileComponents(); + + fixture = TestBed.createComponent(FormsParentComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/showcase/src/components/showcase/forms-parent/forms-parent.style.scss b/apps/showcase/src/components/showcase/forms-parent/forms-parent.style.scss new file mode 100644 index 0000000000..0a321466ba --- /dev/null +++ b/apps/showcase/src/components/showcase/forms-parent/forms-parent.style.scss @@ -0,0 +1,3 @@ +o3r-forms-parent { + // Your component custom SCSS +} diff --git a/apps/showcase/src/components/showcase/forms-parent/forms-parent.template.html b/apps/showcase/src/components/showcase/forms-parent/forms-parent.template.html new file mode 100644 index 0000000000..534883f1ad --- /dev/null +++ b/apps/showcase/src/components/showcase/forms-parent/forms-parent.template.html @@ -0,0 +1,29 @@ +
+
+
+ + +
+
+ + +
+
+
+ +
+
+ +
+
diff --git a/apps/showcase/src/components/showcase/forms-parent/forms-parent.translation.ts b/apps/showcase/src/components/showcase/forms-parent/forms-parent.translation.ts new file mode 100644 index 0000000000..84ffb85ec4 --- /dev/null +++ b/apps/showcase/src/components/showcase/forms-parent/forms-parent.translation.ts @@ -0,0 +1,13 @@ +import { Translation } from '@o3r/core'; + +export interface FormsParentTranslation extends Translation { + dateInThePast: string; + globalForbiddenName: string; + globalForbiddenNameLong: string; +} + +export const translations: FormsParentTranslation = { + dateInThePast: 'o3r-forms-parent.dateOfBirth.dateInThePast', + globalForbiddenName: 'o3r-forms-parent.globalForbiddenName', + globalForbiddenNameLong: 'o3r-forms-parent.globalForbiddenName.long' +}; diff --git a/apps/showcase/src/components/showcase/forms-parent/forms-parent.validators.ts b/apps/showcase/src/components/showcase/forms-parent/forms-parent.validators.ts new file mode 100644 index 0000000000..24605f48ee --- /dev/null +++ b/apps/showcase/src/components/showcase/forms-parent/forms-parent.validators.ts @@ -0,0 +1,47 @@ +import { formatDate } from '@angular/common'; +import { AbstractControl } from '@angular/forms'; +import { CustomErrors, CustomValidationFn } from '@o3r/forms'; + +/** + * Validator which checks that the name is not equal with the parameter 'valueToTest' + * + * @param valueToTest + * @param translationKey + * @param longTranslationKey + * @param translationParams + */ +export function formsParentValidatorGlobal(valueToTest: string, translationKey: string, longTranslationKey?: string, translationParams?: any): CustomValidationFn { + return (control: AbstractControl): CustomErrors | null => { + const value = control.value; + if (!value || !value.name) { + return null; + } + if (value.name !== valueToTest) { + return null; + } else { + return {customErrors: [{translationKey, longTranslationKey, translationParams}]}; + } + }; +} + +/** + * Validator which checks that the date of birth is not in the future + * + * @param translationKey + * @param longTranslationKey + * @param translationParams + */ +export function dateCustomValidator(translationKey: string, longTranslationKey?: string, translationParams?: any): CustomValidationFn { + return (control: AbstractControl): CustomErrors | null => { + const value: string = formatDate(control.value, 'yyyy-MM-dd', 'en-GB'); + if (!value) { + return null; + } + + if (value <= formatDate(Date.now(), 'yyyy-MM-dd', 'en-GB')) { + return null; + } else { + return {customErrors: [{translationKey, longTranslationKey, translationParams}]}; + } + }; +} diff --git a/apps/showcase/src/components/showcase/forms-parent/index.ts b/apps/showcase/src/components/showcase/forms-parent/index.ts new file mode 100644 index 0000000000..ddcd198fa7 --- /dev/null +++ b/apps/showcase/src/components/showcase/forms-parent/index.ts @@ -0,0 +1,2 @@ +export * from './forms-parent.component'; + diff --git a/apps/showcase/src/components/showcase/index.ts b/apps/showcase/src/components/showcase/index.ts index e0afc6c4c1..50b6a37e79 100644 --- a/apps/showcase/src/components/showcase/index.ts +++ b/apps/showcase/src/components/showcase/index.ts @@ -2,6 +2,7 @@ export * from './basic/index'; export * from './configuration/index'; export * from './design-token/index'; export * from './dynamic-content/index'; +export * from './forms-parent/index'; export * from './localization/index'; export * from './placeholder/index'; export * from './rules-engine/index'; diff --git a/apps/showcase/src/components/utilities/forms-emergency-contact/README.md b/apps/showcase/src/components/utilities/forms-emergency-contact/README.md new file mode 100644 index 0000000000..27e9c60aee --- /dev/null +++ b/apps/showcase/src/components/utilities/forms-emergency-contact/README.md @@ -0,0 +1,3 @@ +# FormsEmergencyContactPres + +Subcomponent with a form for the emergency contact diff --git a/apps/showcase/src/components/utilities/forms-emergency-contact/forms-emergency-contact-pres.component.ts b/apps/showcase/src/components/utilities/forms-emergency-contact/forms-emergency-contact-pres.component.ts new file mode 100644 index 0000000000..d5cd4fe9b5 --- /dev/null +++ b/apps/showcase/src/components/utilities/forms-emergency-contact/forms-emergency-contact-pres.component.ts @@ -0,0 +1,193 @@ +import { CommonModule, JsonPipe } from '@angular/common'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, forwardRef, inject, Input, type OnDestroy, type OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormBuilder, + FormControl, + FormGroup, + FormsModule, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ReactiveFormsModule, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { O3rComponent } from '@o3r/core'; +import { ControlFlatErrors, CustomFormValidation, getFlatControlErrors, markAllControlsDirtyAndTouched } from '@o3r/forms'; +import { Localization, LocalizationModule, Translatable } from '@o3r/localization'; +import { Subscription } from 'rxjs'; +import { EmergencyContact } from '../../showcase/forms-parent/contracts'; +import { DatePickerInputPresComponent } from '../date-picker-input'; +import { FormsEmergencyContactPresTranslation, translations } from './forms-emergency-contact-pres.translation'; + +@O3rComponent({ componentType: 'Component' }) +@Component({ + selector: 'o3r-forms-emergency-contact-pres', + standalone: true, + imports: [ + CommonModule, + DatePickerInputPresComponent, + FormsModule, + JsonPipe, + LocalizationModule, + ReactiveFormsModule + ], + templateUrl: './forms-emergency-contact-pres.template.html', + styleUrl: './forms-emergency-contact-pres.style.scss', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FormsEmergencyContactPresComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => FormsEmergencyContactPresComponent), + multi: true + } + ] +}) +export class FormsEmergencyContactPresComponent implements OnInit, OnDestroy, ControlValueAccessor, Validator, Translatable { + + private readonly subscription = new Subscription(); + + /** Localization of the component */ + @Input() + @Localization('./forms-emergency-contact-pres.localization.json') + public translations: FormsEmergencyContactPresTranslation = translations; + + /** ID of the parent component used to compute the ids of the form controls */ + @Input() public id!: string; + + /** Custom validators applied on the form */ + @Input() public customValidators?: CustomFormValidation; + + /** Register a function to be called when the submit is done outside of the presenter (from page) */ + @Output() public registerInteraction: EventEmitter<() => void> = new EventEmitter<() => void>(); + + /** Emit when the submit has been fired on the form */ + @Output() public submitEmergencyContactForm: EventEmitter = new EventEmitter(); + + protected changeDetector = inject(ChangeDetectorRef); + + /** Form group */ + public form: FormGroup<{ + name: FormControl; + phone: FormControl; + email: FormControl; + }> = inject(FormBuilder).group({ + name: new FormControl(''), + phone: new FormControl(''), + email: new FormControl('') + }); + + public componentSelector = 'o3r-forms-emergency-contact-pres'; + + public ngOnInit() { + this.applyValidation(); + this.subscription.add(this.form.valueChanges.subscribe((value) => this.propagateChange(value))); + this.registerInteraction.emit(() => { + markAllControlsDirtyAndTouched(this.form); + this.changeDetector.markForCheck(); + }); + } + + public ngOnDestroy() { + this.subscription.unsubscribe(); + } + + /** @inheritDoc */ + public writeValue(value?: any) { + if (value) { + this.form.setValue(value); + } + } + + /** Function registered to propagate a change to the parent */ + public propagateChange: any = () => { }; + /** Function registered to propagate touched to the parent */ + public propagateTouched: any = () => { }; + + /** @inheritDoc */ + public registerOnChange(fn: any) { + this.propagateChange = fn; + } + + /** @inheritDoc */ + public registerOnTouched(fn: any) { + this.propagateTouched = fn; + } + + /** + * Get custom validators and primitive validators and apply them on the form + */ + public applyValidation() { + const globalValidators = []; + if (this.customValidators && this.customValidators.global) { + globalValidators.push(this.customValidators.global); + } + this.form.setValidators(globalValidators); + + const nameValidators = [Validators.required]; + const phoneValidators = [Validators.required, Validators.pattern('^[0-9]{10}$')]; + const emailValidators = [Validators.email]; + if (this.customValidators && this.customValidators.fields) { + if (this.customValidators.fields.name) { + nameValidators.push(this.customValidators.fields.name); + } + if (this.customValidators.fields.phone) { + phoneValidators.push(this.customValidators.fields.phone); + } + if (this.customValidators.fields.email) { + emailValidators.push(this.customValidators.fields.email); + } + } + this.form.controls.name.setValidators(nameValidators); + this.form.controls.phone.setValidators(phoneValidators); + this.form.controls.email.setValidators(emailValidators); + this.form.updateValueAndValidity(); + } + + /** + * @inheritDoc + */ + public validate(_control: AbstractControl): ValidationErrors | null { + if (this.form.status === 'VALID') { + return null; + } + + const formErrors = getFlatControlErrors(this.form); + + const errors = formErrors.reduce((errorsMap: ValidationErrors, controlFlatErrors: ControlFlatErrors) => { + return { + ...errorsMap, + [controlFlatErrors.controlName || 'global']: { + htmlElementId: `${this.id}${controlFlatErrors.controlName || ''}`, + errorMessages: (controlFlatErrors.customErrors || []).concat( + controlFlatErrors.errors.map((error) => { + const translationKey = `${this.componentSelector}.${controlFlatErrors.controlName || ''}.${error.errorKey}`; + return { + translationKey, + longTranslationKey: this.translations[`${error.errorKey}Long`] || undefined, + validationError: error.validationError + }; + }) + ) + } + }; + }, {}); + + return errors; + } + + /** Submit emergency contact form */ + public submitForm() { + markAllControlsDirtyAndTouched(this.form); + this.form.updateValueAndValidity(); + this.submitEmergencyContactForm.emit(); + } +} diff --git a/apps/showcase/src/components/utilities/forms-emergency-contact/forms-emergency-contact-pres.localization.json b/apps/showcase/src/components/utilities/forms-emergency-contact/forms-emergency-contact-pres.localization.json new file mode 100644 index 0000000000..0644d84496 --- /dev/null +++ b/apps/showcase/src/components/utilities/forms-emergency-contact/forms-emergency-contact-pres.localization.json @@ -0,0 +1,34 @@ +{ + "o3r-forms-emergency-contact-pres.name.required": { + "description": "Required validator for the name", + "defaultValue": "The name is required" + }, + "o3r-forms-emergency-contact-pres.name.required.long": { + "description": "Required validator for the name", + "defaultValue": "You have to introduce a name for the emergency contact. Click here to navigate." + }, + "o3r-forms-emergency-contact-pres.phone.required": { + "description": "Required validator for the phone", + "defaultValue": "The phone number is required" + }, + "o3r-forms-emergency-contact-pres.phone.required.long": { + "description": "Required validator for the phone", + "defaultValue": "You have to introduce a phone number for the emergency contact. Click here to navigate." + }, + "o3r-forms-emergency-contact-pres.phone.pattern": { + "description": "Validator for phone pattern", + "defaultValue": "Invalid phone number pattern" + }, + "o3r-forms-emergency-contact-pres.phone.pattern.long": { + "description": "Validator for phone pattern", + "defaultValue": "The entered phone number has an invalid pattern." + }, + "o3r-forms-emergency-contact-pres.email.pattern": { + "description": "Validator for email pattern", + "defaultValue": "Invalid email address pattern" + }, + "o3r-forms-emergency-contact-pres.email.pattern.long": { + "description": "Validator for email pattern", + "defaultValue": "The entered email address has an invalid pattern." + } +} diff --git a/apps/showcase/src/components/utilities/forms-emergency-contact/forms-emergency-contact-pres.spec.ts b/apps/showcase/src/components/utilities/forms-emergency-contact/forms-emergency-contact-pres.spec.ts new file mode 100644 index 0000000000..46698c96ea --- /dev/null +++ b/apps/showcase/src/components/utilities/forms-emergency-contact/forms-emergency-contact-pres.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { mockTranslationModules } from '@o3r/testing/localization'; +import { FormsEmergencyContactPresComponent } from './forms-emergency-contact-pres.component'; + +describe('FormsEmergencyContactPresComponent', () => { + let component: FormsEmergencyContactPresComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FormsEmergencyContactPresComponent, ...mockTranslationModules(), ReactiveFormsModule] + }).compileComponents(); + + fixture = TestBed.createComponent(FormsEmergencyContactPresComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/showcase/src/components/utilities/forms-emergency-contact/forms-emergency-contact-pres.style.scss b/apps/showcase/src/components/utilities/forms-emergency-contact/forms-emergency-contact-pres.style.scss new file mode 100644 index 0000000000..2013c40ae8 --- /dev/null +++ b/apps/showcase/src/components/utilities/forms-emergency-contact/forms-emergency-contact-pres.style.scss @@ -0,0 +1,3 @@ +o3r-forms-emergency-contact-pres { + +} diff --git a/apps/showcase/src/components/utilities/forms-emergency-contact/forms-emergency-contact-pres.template.html b/apps/showcase/src/components/utilities/forms-emergency-contact/forms-emergency-contact-pres.template.html new file mode 100644 index 0000000000..7dd2817c5a --- /dev/null +++ b/apps/showcase/src/components/utilities/forms-emergency-contact/forms-emergency-contact-pres.template.html @@ -0,0 +1,55 @@ +
+

Emergency Contact Information

+
+
+
+ + +
+ +
+
+ {{translations.nameRequired | o3rTranslate}} +
+
+
+
+
+
+ + +
+ +
+
+ {{translations.phoneRequired | o3rTranslate}} +
+
+ {{translations.phonePattern | o3rTranslate}} +
+
+
+
+
+
+ + +
+
+ +
+
+ {{translations.emailPatternLong | o3rTranslate}} +
+
+
+
+
+
+ Global validator: {{form.errors | json}} +
+
+
+ +
+
diff --git a/apps/showcase/src/components/utilities/forms-emergency-contact/forms-emergency-contact-pres.translation.ts b/apps/showcase/src/components/utilities/forms-emergency-contact/forms-emergency-contact-pres.translation.ts new file mode 100644 index 0000000000..0b7b48c4c5 --- /dev/null +++ b/apps/showcase/src/components/utilities/forms-emergency-contact/forms-emergency-contact-pres.translation.ts @@ -0,0 +1,23 @@ +import { Translation } from '@o3r/core'; + +export interface FormsEmergencyContactPresTranslation extends Translation { + nameRequired: string; + nameRequiredLong: string; + phoneRequired: string; + phoneRequiredLong: string; + phonePattern: string; + phonePatternLong: string; + emailPattern: string; + emailPatternLong: string; +} + +export const translations: FormsEmergencyContactPresTranslation = { + nameRequired: 'o3r-forms-emergency-contact-pres.name.required', + nameRequiredLong: 'o3r-forms-emergency-contact-pres.name.required.long', + phoneRequired: 'o3r-forms-emergency-contact-pres.phone.required', + phoneRequiredLong: 'o3r-forms-emergency-contact-pres.phone.required.long', + phonePattern: 'o3r-forms-emergency-contact-pres.phone.pattern', + phonePatternLong: 'o3r-forms-emergency-contact-pres.phone.pattern.long', + emailPattern: 'o3r-forms-emergency-contact-pres.email.pattern', + emailPatternLong: 'o3r-forms-emergency-contact-pres.email.pattern.long' +}; diff --git a/apps/showcase/src/components/utilities/forms-emergency-contact/index.ts b/apps/showcase/src/components/utilities/forms-emergency-contact/index.ts new file mode 100644 index 0000000000..db804457de --- /dev/null +++ b/apps/showcase/src/components/utilities/forms-emergency-contact/index.ts @@ -0,0 +1,2 @@ +export * from './forms-emergency-contact-pres.component'; + diff --git a/apps/showcase/src/components/utilities/forms-personal-info/README.md b/apps/showcase/src/components/utilities/forms-personal-info/README.md new file mode 100644 index 0000000000..d2ce989b62 --- /dev/null +++ b/apps/showcase/src/components/utilities/forms-personal-info/README.md @@ -0,0 +1,3 @@ +# FormsPersonalInfoPres + +Subcomponent with a form for the personal information diff --git a/apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.component.ts b/apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.component.ts new file mode 100644 index 0000000000..3902b12cfb --- /dev/null +++ b/apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.component.ts @@ -0,0 +1,227 @@ +import { CommonModule, formatDate, JsonPipe } from '@angular/common'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, forwardRef, inject, Input, type OnDestroy, type OnInit, Output, ViewEncapsulation } from '@angular/core'; +import { + AbstractControl, + ControlValueAccessor, + FormBuilder, + FormControl, + FormGroup, + FormsModule, + NG_VALIDATORS, + NG_VALUE_ACCESSOR, + ReactiveFormsModule, + ValidationErrors, + Validator, + Validators +} from '@angular/forms'; +import { O3rComponent } from '@o3r/core'; +import { ControlFlatErrors, CustomFormValidation, FlatError, getFlatControlErrors, markAllControlsDirtyAndTouched } from '@o3r/forms'; +import { Localization, LocalizationModule, Translatable } from '@o3r/localization'; +import { Subscription } from 'rxjs'; +import { PersonalInfo } from '../../showcase/forms-parent/contracts'; +import { DatePickerInputPresComponent } from '../date-picker-input'; +import { FormsPersonalInfoPresConfig } from './forms-personal-info-pres.config'; +import { FormsPersonalInfoPresTranslation, translations } from './forms-personal-info-pres.translation'; + +@O3rComponent({ componentType: 'Component' }) +@Component({ + selector: 'o3r-forms-personal-info-pres', + standalone: true, + imports: [ + CommonModule, + DatePickerInputPresComponent, + FormsModule, + JsonPipe, + LocalizationModule, + ReactiveFormsModule + ], + templateUrl: './forms-personal-info-pres.template.html', + styleUrl: './forms-personal-info-pres.style.scss', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => FormsPersonalInfoPresComponent), + multi: true + }, + { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => FormsPersonalInfoPresComponent), + multi: true + } + ] +}) +export class FormsPersonalInfoPresComponent implements OnInit, OnDestroy, ControlValueAccessor, Validator, Translatable { + + private readonly subscription = new Subscription(); + + /** Localization of the component */ + @Input() + @Localization('./forms-personal-info-pres.localization.json') + public translations: FormsPersonalInfoPresTranslation = translations; + + /** ID of the parent component used to compute the ids of the form controls */ + @Input() public id!: string; + + /** Input configuration to override the default configuration of the component */ + @Input() public config!: FormsPersonalInfoPresConfig; + + /** Custom validators applied on the form */ + @Input() public customValidators?: CustomFormValidation; + + /** Register a function to be called when the submit is done outside of the presenter (from page) */ + @Output() public registerInteraction: EventEmitter<() => void> = new EventEmitter<() => void>(); + + /** Emit when the submit has been fired on the form */ + @Output() public submitPersonalInfoForm: EventEmitter = new EventEmitter(); + + protected changeDetector = inject(ChangeDetectorRef); + + /** Form group */ + public form: FormGroup<{ + name: FormControl; + dateOfBirth: FormControl; + }> = inject(FormBuilder).group({ + name: new FormControl(''), + dateOfBirth: new FormControl(this.formatDate(Date.now())) + }); + + public componentSelector = 'o3r-forms-personal-info-pres'; + + private formatDate(dateTime: number) { + return formatDate(dateTime, 'yyyy-MM-dd', 'en-GB'); + } + + public ngOnInit() { + this.applyValidation(); + this.subscription.add(this.form.valueChanges.subscribe((value) => this.propagateChange(value))); + this.registerInteraction.emit(() => { + markAllControlsDirtyAndTouched(this.form); + this.changeDetector.markForCheck(); + }); + } + + public ngOnDestroy() { + this.subscription.unsubscribe(); + } + + /** @inheritDoc */ + public writeValue(value?: any) { + if (value) { + this.form.setValue(value); + } + } + + /** Function registered to propagate a change to the parent */ + public propagateChange: any = () => { }; + /** Function registered to propagate touched to the parent */ + public propagateTouched: any = () => { }; + + /** @inheritDoc */ + public registerOnChange(fn: any) { + this.propagateChange = fn; + } + + /** @inheritDoc */ + public registerOnTouched(fn: any) { + this.propagateTouched = fn; + } + + /** + * Get custom validators and primitive validators and apply them on the form + */ + public applyValidation() { + const globalValidators = []; + if (this.customValidators && this.customValidators.global) { + globalValidators.push(this.customValidators.global); + } + this.form.setValidators(globalValidators); + + const nameValidators = [Validators.required]; + const dateOfBirthValidators = []; + if (this.config?.nameMaxLength) { + nameValidators.push(Validators.maxLength(this.config.nameMaxLength)); + } + if (this.customValidators && this.customValidators.fields) { + if (this.customValidators.fields.name) { + nameValidators.push(this.customValidators.fields.name); + } + if (this.customValidators.fields.dateOfBirth) { + dateOfBirthValidators.push(this.customValidators.fields.dateOfBirth); + } + } + this.form.controls.name.setValidators(nameValidators); + this.form.controls.dateOfBirth.setValidators(dateOfBirthValidators); + this.form.updateValueAndValidity(); + } + + /** + * @inheritDoc + */ + public validate(_control: AbstractControl): ValidationErrors | null { + if (this.form.status === 'VALID') { + return null; + } + + const formErrors = getFlatControlErrors(this.form); + + const errors = formErrors.reduce((errorsMap: ValidationErrors, controlFlatErrors: ControlFlatErrors) => { + return { + ...errorsMap, + [controlFlatErrors.controlName || 'global']: { + htmlElementId: `${this.id}${controlFlatErrors.controlName || ''}`, + errorMessages: (controlFlatErrors.customErrors || []).concat( + controlFlatErrors.errors.map((error) => { + const translationKey = `${this.componentSelector}.${controlFlatErrors.controlName || ''}.${error.errorKey}`; + return { + translationKey, + longTranslationKey: this.translations[`${error.errorKey}Long`] || undefined, + validationError: error.validationError, + translationParams: this.getTranslationParamsFromFlatErrors(controlFlatErrors.controlName || '', error) + }; + }) + ) + } + }; + }, {}); + + return errors; + } + + /** + * Create the translation parameters for each form control error + * + * @param controlName + * @param error + */ + public getTranslationParamsFromFlatErrors(controlName: string, error: FlatError) { + switch (controlName) { + case 'dateOfBirth': { + switch (error.errorKey) { + case 'max': + return { max: error.errorValue.max }; + default: + return {}; + } + } + case 'name': { + switch (error.errorKey) { + case 'maxlength': + return { requiredLength: error.errorValue.requiredLength }; + default: + return {}; + } + } + default: + return {}; + } + } + + /** Submit emergency contact form */ + public submitForm() { + markAllControlsDirtyAndTouched(this.form); + this.form.updateValueAndValidity(); + this.submitPersonalInfoForm.emit(); + } +} diff --git a/apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.config.ts b/apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.config.ts new file mode 100644 index 0000000000..e974c6c3b0 --- /dev/null +++ b/apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.config.ts @@ -0,0 +1,7 @@ +import { Configuration } from '@o3r/core'; + +/** Configuration of personal information */ +export interface FormsPersonalInfoPresConfig extends Configuration { + /** Requires the length of the name form control's value to be less than or equal to the provided number */ + nameMaxLength: number; +} diff --git a/apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.localization.json b/apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.localization.json new file mode 100644 index 0000000000..e3734fb876 --- /dev/null +++ b/apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.localization.json @@ -0,0 +1,34 @@ +{ + "o3r-forms-personal-info-pres.name.required": { + "description": "Required validator for the name", + "defaultValue": "The name is required" + }, + "o3r-forms-personal-info-pres.name.required.long": { + "description": "Required validator for the name", + "defaultValue": "You have to introduce a name. Click here to navigate." + }, + "o3r-forms-personal-info-pres.name.maxLength": { + "description": "Validator for the max length of the name", + "defaultValue": "The name should have a max length of { maxLength } characters" + }, + "o3r-forms-personal-info-pres.name.maxLength.long": { + "description": "Validator for the max length of the name", + "defaultValue": "The name should have a max length of { maxLength } characters. Click to navigate." + }, + "o3r-forms-personal-info-pres.dateOfBirth.date": { + "description": "Validator for date of birth", + "defaultValue": "The date introduced is not a valid date" + }, + "o3r-forms-personal-info-pres.dateOfBirth.date.long": { + "description": "Validator for date of birth", + "defaultValue": "The date introduced for the date of birth is not a valid. Click to navigate." + }, + "o3r-forms-personal-info-pres.dateOfBirth.max": { + "description": "Validator for date of birth month", + "defaultValue": "Max value for the month should be { max }" + }, + "o3r-forms-personal-info-pres.dateOfBirth.max.long": { + "description": "Validator for date of birth month; long message", + "defaultValue": "Max value for the month in date of birth should be { max }. Click to navigate." + } +} diff --git a/apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.spec.ts b/apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.spec.ts new file mode 100644 index 0000000000..dd1278ea4c --- /dev/null +++ b/apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { mockTranslationModules } from '@o3r/testing/localization'; +import { FormsPersonalInfoPresComponent } from './forms-personal-info-pres.component'; + +describe('FormsPersonalInfoPresComponent', () => { + let component: FormsPersonalInfoPresComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [FormsPersonalInfoPresComponent, ...mockTranslationModules(), ReactiveFormsModule] + }).compileComponents(); + + fixture = TestBed.createComponent(FormsPersonalInfoPresComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.style.scss b/apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.style.scss new file mode 100644 index 0000000000..f5a9bd0953 --- /dev/null +++ b/apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.style.scss @@ -0,0 +1,3 @@ +o3r-forms-personal-info-pres { + +} diff --git a/apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.template.html b/apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.template.html new file mode 100644 index 0000000000..c80c466cdd --- /dev/null +++ b/apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.template.html @@ -0,0 +1,46 @@ +
+

Personal Information

+
+
+
+ + +
+ +
+
+ {{translations.required | o3rTranslate}} +
+
+ {{translations.maxLength | o3rTranslate: {maxLength: form.controls.name.errors?.maxlength.requiredLength} }} +
+
+
+
+
+
+ + +
+
+
+
+ {{translations.date | o3rTranslate}} +
+
+ {{translations.max | o3rTranslate: {max: form.controls.dateOfBirth.errors?.max.max} }} +
+
+ {{customError.translationKey | o3rTranslate: customError.translationParams}} +
+
+
+
+
+ Global validator: {{form.errors | json}} +
+
+
+ +
+
diff --git a/apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.translation.ts b/apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.translation.ts new file mode 100644 index 0000000000..794d402514 --- /dev/null +++ b/apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.translation.ts @@ -0,0 +1,23 @@ +import { Translation } from '@o3r/core'; + +export interface FormsPersonalInfoPresTranslation extends Translation { + required: string; + requiredLong: string; + maxLength: string; + maxLengthLong: string; + date: string; + dateLong: string; + max: string; + maxLong: string; +} + +export const translations: FormsPersonalInfoPresTranslation = { + required: 'o3r-forms-personal-info-pres.name.required', + requiredLong: 'o3r-forms-personal-info-pres.name.required.long', + maxLength: 'o3r-forms-personal-info-pres.name.maxLength', + maxLengthLong: 'o3r-forms-personal-info-pres.name.maxLength.long', + date: 'o3r-forms-personal-info-pres.dateOfBirth.date', + dateLong: 'o3r-forms-personal-info-pres.dateOfBirth.date.long', + max: 'o3r-forms-personal-info-pres.dateOfBirth.max', + maxLong: 'o3r-forms-personal-info-pres.dateOfBirth.max.long' +}; diff --git a/apps/showcase/src/components/utilities/forms-personal-info/index.ts b/apps/showcase/src/components/utilities/forms-personal-info/index.ts new file mode 100644 index 0000000000..a8ebab5451 --- /dev/null +++ b/apps/showcase/src/components/utilities/forms-personal-info/index.ts @@ -0,0 +1,3 @@ +export * from './forms-personal-info-pres.component'; +export * from './forms-personal-info-pres.config'; + diff --git a/apps/showcase/src/components/utilities/index.ts b/apps/showcase/src/components/utilities/index.ts index 04ff4d9a45..d46746c31b 100644 --- a/apps/showcase/src/components/utilities/index.ts +++ b/apps/showcase/src/components/utilities/index.ts @@ -1,3 +1,5 @@ +export * from './forms-emergency-contact/index'; +export * from './forms-personal-info/index'; export * from './date-picker-input/index'; export * from './otter-picker/index'; export * from './copy-text/index'; diff --git a/docs/forms/FORM_ERRORS.md b/docs/forms/FORM_ERRORS.md index a48623e2b9..9b4e3d92b4 100644 --- a/docs/forms/FORM_ERRORS.md +++ b/docs/forms/FORM_ERRORS.md @@ -1,453 +1,211 @@ -[Form errors](#introduction) - -- [Form errors](#form-errors) - - [Form error store](#form-error-store) - - [Error object model](#error-object-model) - - [Creating error object](#creating-error-object) - - [Custom errors](#custom-errors) - - [Basic/primitive errors](#basicprimitive-errors) - - [Build error messages](#build-error-messages) - - [Display inline error messages](#display-inline-error-messages) - - [Basic errors](#basic-errors) - - [Custom errors](#custom-errors-1) - - [Add errors to the store](#add-errors-to-the-store) - - [Errors translation definition](#errors-translation-definition) - - [Custom errors](#custom-errors-2) - - [Primitive errors](#primitive-errors) - - - # Form errors -Handling the form errors in Otter context (container/presenter, localization ...), it's a bit different from creating a form in a component and do all the logic there. - - ### Form error store -To have the possibility to display inline error messages in the form and also in error panels (on the top of the page, above submit button ...) the best match is to have a dedicated store for the form errors. In this way we can listen to the store state and display the errors anywhere in the page. -The store is provided in __@o3r/forms__ package. See [Form Error Store](https://github.com/AmadeusITGroup/otter/blob/main/packages/@o3r/forms/src/stores/form-error-messages/form-error-messages.state.ts) for more details and state object model. - +To have the possibility of displaying inline error messages in the form and also in error panels (on the top of the page, above the submit button, etc.), +we recommend a dedicated NgRX store for the form errors. This way we can listen to the store state and display the errors anywhere in the page. +The store is provided in the __@o3r/forms__ package. See [Form Error Store](https://github.com/AmadeusITGroup/otter/blob/main/packages/@o3r/forms/src/stores/form-error-messages/form-error-messages.state.ts) +for more details and to view the state object model. #### Error object model -The store model object is __FormError__. See below the form errors object models. +The store model object extends the `FormError` interface. -- The __FormError__ contains an identifier for each component which has a form inside, plus the errors associated to that form. +- The `FormError` interface contains an identifier for each component that has a form inside, plus the errors (of type `ElementError`) associated to that form. -```typescript -/** Form's error messages identified by form id */ -export interface FormError { - /** Id of the form containing the form field/fields */ - formId: string; +- The `ElementError` interface contains all the errors associated to the HTML element. +It has an `htmlElementId` property, which is an identifier that can be used as an anchor link to focus on the HTML element where the validation failed. +It also contains the element's error message objects of type `ErrorMessageObject`. - /** Component's elements errors */ - errors: ElementError[]; -} -``` +- The `ErrorMessageObject` interface is associated to an error message on a field. It contains several properties to display the error message, including translation +keys to display the short or long version of the message, translation parameters of the error message, the original error object, etc. -- __ElementError__ -This object contains all the errors associated to the html element. -The identifier __htmlElementId__ can be used as an anchor link to focus on the html element on which the validation has failed +You can find these interfaces in the [@o3r/forms package](https://github.com/AmadeusITGroup/otter/blob/main/packages/%40o3r/forms/src/core/errors.ts). -```typescript -/** Error messages of the html element identified by its id */ -export interface ElementError { - /** Id of the html element on which the validation has failed */ - htmlElementId: string; +### Creating an error object - /** Element's error message objects */ - errorMessages: ErrorMessageObject[]; -} -``` +The input component has to implement the [Validator](https://angular.io/api/forms/NG_VALIDATORS) or the [AsyncValidator](https://angular.io/api/forms/NG_ASYNC_VALIDATORS) interface +in order to give us the possibility of defining the error object that will be returned by the form. -- __ErrorMessageObject__ - - associated to an error message on a field. - - It will contain: - - __translationKey__ for the error message - - __longTranslationKey__ used for a more detailed message on the same error - - __translationParams__ translations parameters - - __validationError__ original error object +The error message structure will be defined in the implementation of the `validate` function. -```typescript -/** The error object saved in the store for a specific element/formControl */ -export interface ErrorMessageObject { - /** - * Translation key of the short error message (e.g. used for inline errors) - * @example - * ```typescript - * translationKey = 'travelerForm.firstName.required'; // => corresponds to {'travelerForm.firstName.required': 'First name is required!'} in localization json; - * ``` - */ - translationKey: string; - - /** - * Translation key of the long error message (e.g. used on a message panel) - * @example - * ```typescript - * longTranslationKey = 'travelerForm.firstName.required.long'; // => corresponds to {'travelerForm.firstName.required.long': 'The first name in the registration form cannot be empty!'} - * // in localization json; - * ``` - * - */ - longTranslationKey?: string; - - /** Translation parameters of the error message; Used in the short message but also in the long message if needed */ - translationParams?: { [key: string]: any }; - - /** - * Original error object defined by the corresponding validator - * @note It's optional since custom errors don't need to provide the validation error - * @example - * ```typescript - * {required: true} - * ``` - * @example - * ```typescript - * {max: {max 12, actual: 31}} - * ``` - */ - validationError?: {[key: string]: any}; -} -``` +As the `validate` function should return a [ValidationErrors](https://angular.io/api/forms/ValidationErrors) object, which is a map of custom objects (of type `any`), +we can adapt the returned object to the store of error messages. This will ease the process of adding the errors in the store. - +We have to make sure that we provide the `htmlElementId` of the errors in the store that match the __HTML fields__. +To identify a field, we can generate an `id` in the parent component instance, provide it through the input mechanism, and concatenate it with the `formControlName`. +Since we will have a __unique id__ by instance of the parent component, we are sure to have unique HTML identifiers for the form fields. You can find an example of this implementation +in the subcomponents, such as the [personal information component](https://github.com/AmadeusITGroup/otter/tree/main/apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.component.ts), +of the forms page in the showcase application. -### Creating error object +The object returned by the `validate` function is the error object that is propagated to the parent component. -The presenter has to implement the [Validator](https://angular.io/api/forms/NG_VALIDATORS) or [AsyncValidator](https://angular.io/api/forms/NG_ASYNC_VALIDATORS) in order to give us the possibility to define the error object which will be returned by the form. -The error message structure will be defined in the implementation of __validate__ method. -As __validate__ function should return a [ValidationErrors](https://angular.io/api/forms/ValidationErrors) object, which is a map of custom objects (with type _any_), we can prepare the returned object for the store of error messages. This will ease the process of adding the errors in the store. -We have to make sure that we are providing the __htmlElementId__ for the errors in the store which is matching the __html field__. -For this, the presenter is receiving an __id__ as input and for each field we are concatenating the __id__ with the __formControlName__. As the container is setting a __unique id__ we are sure that we have uniques html ids for the form fields. -The object returned by the __validate__ is the error object which is propagated to the container. +#### Categories of error messages -There are 2 types of validators (see [Form Validation](./FORM_VALIDATION.md)), 2 categories of error messages: +There are two types of validators (see [Form Validation](./FORM_VALIDATION.md)) and therefore two categories of error messages: -- one for __custom errors__ - set on the container -- one for __primitive errors__ - computed in the presenter. +- __Custom error__ - defined in the parent component as it holds the business logic +- __Primitive error__ - computed in the input component - +###### Custom errors -#### Custom errors +They are returned by __custom validators__ and have the type [CustomErrors](https://github.com/AmadeusITGroup/otter/blob/main/packages/@o3r/forms/src/core/custom-validation.ts) (defined in __@o3r/forms__). +This type uses the `customErrors` key with an `ErrorMessageObject` array, which has to contain all the custom errors of a form control or a group. -They are returned by __custom validators__ and have the type [CustomErrors](https://github.com/AmadeusITGroup/otter/blob/main/packages/@o3r/forms/src/core/custom-validation.ts) defined in __@o3r/forms__. -This one is using _customErrors_ key with an array of __ErrorMessageObject__ which has to contain all the custom errors for a form control or group. - -```typescript -/** - * The return of a custom validation - */ -export interface CustomErrors { - /** The custom errors coming from a validation fn */ - customErrors: ErrorMessageObject[]; -} -``` - -Error object model returned by the validator has to be compliant with the store model. +The error object model returned by the validator has to be compliant with the store model. ```typescript // Example of returned object by the custom validator -{customErrors: [{translationKey, longTranslationKey, translationParams}]}; +{ + customErrors: [{ + translationKey: 'o3r-error-message.confirmPassword.doesNotMatch', + longTranslationKey: 'o3r-long-error-message.confirmPassword.doesNotMatch', + translationParams: { + passwordLength: 10, + confirmPasswordLength: 5 + } + }] +}; ``` - +###### Basic/primitive errors -#### Basic/primitive errors - -The error object structure has to be created in the presenter because the __basic validators__ are defined at presenter level (see [FORM_VALIDATION](./FORM_VALIDATION.md)). - - +The error object structure has to be created in the input component because the __basic validators__ are defined at input component level (see [Form Validation](./FORM_VALIDATION.md)). #### Build error messages -We put in place a generic helper [__getFlatControlErrors__](https://github.com/AmadeusITGroup/otter/blob/main/packages/%40o3r/forms/src/core/helpers.ts) in __@o3r/forms__. -This gets a flattened list of all the errors in the form and it's descendants, concatenating the __custom errors__; The object returned by the helper has [ControlFlatErrors](https://github.com/AmadeusITGroup/otter/blob/main/packages/%40o3r/forms/src/core/flat-errors.ts) type. - -```typescript -/** - * Represents all errors (validation or custom ones) from a control. - * Useful for working with form errors - * @note The control may be form, therefore the controlName may be undefined - */ -export interface ControlFlatErrors { - /** The name of a field. e.g firstName, cardNumber. If it's a form, should be undefined - * @note For child fields, use [parentControlName].[fieldName]. e.g expiryDate.month - */ - controlName?: string; - /** List of customErrors (coming from custom validation) linked to the control */ - customErrors?: ErrorMessageObject[] | null; - /** The list of flatten errors linked to the control */ - errors: FlatError[]; -} -``` - -Example of __validate__ method implementation - -```typescript -/// ----> in the presenter class -import { ControlFlatErrors, CustomFormValidation, FlatError, getFlatControlErrors } from '@o3r/forms'; -... - -export class FormsPocPresComponent implements OnInit, Validator, FormsPocPresContext, ControlValueAccessor, Configurable, OnDestroy { - - /** - * Localization of the component - */ - @Input() - @Localization('./forms-poc-pres.localization.json') - public translations: FormsPocPresTranslation; - - /** Object used to compute the ids of the form controls */ - @Input() id: string; - - componentSelector: string = 'o3r-forms-poc-pres'; - - travelerForm: FormGroup; - - constructor(config: FormsPocPresConfig, private fb: FormBuilder, protected changeDetector: ChangeDetectorRef) { - this.config = config; - this.translations = translations; - // Create the form with no initial values - this.travelerForm = this.fb.group({ - firstName: null, - lastName: null, - dateOfBirth: null - }); - } +We put in place a generic helper [getFlatControlErrors](https://github.com/AmadeusITGroup/otter/blob/main/packages/%40o3r/forms/src/core/helpers.ts) in __@o3r/forms__. +This function gets a flattened list of all the errors from the form and its descendants and concatenates the __custom errors__. +The object returned by the helper is of type [ControlFlatErrors](https://github.com/AmadeusITGroup/otter/blob/main/packages/%40o3r/forms/src/core/flat-errors.ts), defined in __@o3r/forms__. - /** - * Return the errors for the validators applied global to the form plus the errors for each field - * - * @inheritDoc - */ - public validate(_control: AbstractControl): ValidationErrors | null { - if (this.travelerForm.status === 'VALID') { - return null; - } +You can find an example of the implementation of a `validate` [function](https://github.com/AmadeusITGroup/otter/tree/main/apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.component.ts) +in the forms example of the showcase application. - const formErrors = getFlatControlErrors(this.travelerForm); // ---> use the helper to get the flat list of errors for the form - - const errors = formErrors.reduce((errorsMap: ValidationErrors, controlFlatErrors: ControlFlatErrors) => { - return { - // ...errorsMap, - [controlFlatErrors.controlName || 'global']: { // ---> use the 'global' key for the errors applied on the root form - htmlElementId: `${this.id}${controlFlatErrors.controlName || ''}`, // ---> The html id of the element - errorMessages: (controlFlatErrors.customErrors || []).concat( // ---> errors associated to the html element ( custom errors plus basic ones ) - controlFlatErrors.errors.map((error) => { - // Translation key creation - // As the primitive errors are linked to the presenter we use the component selector, the control name and the error key, to compute the translationKey - // Ex: componentSelector= 'o3r-forms-poc-pres', controlName='firstName', error key {required: true} -> the error key is 'required' - // translationKey = 'o3r-forms-poc-pres.firstName.required' or something like 'o3r-forms-poc-pres.firstName:required' - const translationKey = `${this.componentSelector}.${controlFlatErrors.controlName}.${error.errorKey}`; - return { - translationKey, - // Check if we have a long translation key in the defined translations associated to the presenter - longTranslationKey: this.translations[`${translationKey}.long`] || undefined, - validationError: error.validationError, - translationParams: this.getTranslationParamsFromFlatErrors(controlFlatErrors.controlName || '', error) // ---> get the translation parameters for each control - }; - }) - ) - } - }; - }, {}); - - return errors; - } - - /** - * Create the translation parameters for each form control error - * @Note This is specific to the implementation of the form in each presenter - */ - getTranslationParamsFromFlatErrors(controlName: string, error: FlatError) { - switch (controlName) { - case 'dateOfBirth': { - switch (error.errorKey) { - case 'max': - return {max: error.errorValue.max}; - case 'min': - return {min: error.errorValue.min}; - default: - return {}; - } - } - case 'firstName': { - switch (error.errorKey) { - case 'maxlength': - return {requiredLength: error.errorValue.requiredLength}; - default: - return {}; - } - } - case 'dateOfBirth.month': { - // Use case for form subcontrols - switch (error.errorKey) { - case 'max': - return {maxMonthValue: error.errorValue.max}; - case 'min': - return {minMonthValue: error.errorValue.min}; - default: - return {}; - } - } - default: - return {}; - } - } -} -``` - -This is only an example of implementation. The _translationKey_ and _translationParams_ can be different implemented depending on the use cases. - +This is only an example of an implementation. The `translationKey` and `translationParams` can be implemented differently depending on the use cases. ### Display inline error messages - +Below, you will find examples of HTML implementations to display basic and custom errors as inline error messages. +Both of these examples can be found in the forms example of the showcase application linked above. #### Basic errors ```html -///----> presenter template - - - // use the translation object for the translationKey and get the translationParams from the error object returned by 'date-inline-input'. - {{translations.maxMonthInDate | o3rTranslate: {max: travelerForm.controls.dateOfBirth.errors?.max.max} }} - + + +
+ + {{translations.max | o3rTranslate: {max: form.controls.dateOfBirth.errors?.max.max} }} +
``` - - #### Custom errors ```html -///----> presenter template - - - // translation key and params are already accessible in the error object returned by the custom validator - {{customError.translationKey | o3rTranslate: customError.translationParams }} - + + +
+ + {{customError.translationKey | o3rTranslate: customError.translationParams}} +
``` - - ### Add errors to the store -As we already defined the error message object, as the return of __validate__ method in the presenter, we can get the error messages and add them to the store, in the container. Check the example below. +As we have already defined the error message object as the return of the `validate` function in the input component, we can get the error messages in the parent component and add them to the store. +Check the example below: ```typescript -/// ---> in the container +// ---> in the parent component ... /** The form object model */ -traveler: Traveler; - -/** The form control object bind to the presenter */ -mainFormControl: FormControl; +public exampleModel: ExampleModel; +/** The form control object bind to the input component */ +public exampleFormControl: FormControl; // Inject the store of form error messages -constructor(config: FormsPocContConfig, private store: Store) { -... -this.traveler = {firstName: '', lastName: 'TestUser', dateOfBirth: new utils.Date()}; -this.mainFormControl = new FormControl(this.traveler); +constructor(private store: Store) { + ... + this.exampleModel = { prop1: '', prop2: '' }; + this.exampleFormControl = new FormControl(this.exampleModel); } /** submit function */ -submitAction() { +public submitAction() { // ... - const isValid = !this.mainFormControl.errors; - if (!this.mainFormControl.errors) { - // ---> Submit logic here - // eslint-disable-next-line no-console - console.log('CONTAINER: form status and errors', this.mainFormControl.status, this.mainFormControl.errors); - // eslint-disable-next-line no-console - console.log('CONTAINER: submit logic here', this.mainFormControl.value); - } else { + if (this.exampleFormControl.errors) { const errors: FormError = { - formId: `${this.id}-my-form-example`, - errors: Object.keys(this.mainFormControl.errors).map((controlName: string) => { - const controlErrors = this.mainFormControl.errors![controlName]; + formId: `my-form-example`, + errors: Object.keys(this.exampleFormControl.errors).map((controlName: string) => { + const controlErrors = this.exampleFormControl.errors![controlName]; return {htmlElementId: controlErrors.htmlElementId, errorMessages: controlErrors.errorMessages}; }) }; - // Add the errors corresponding to travelerForm in the store + // Add the errors corresponding to the form in the store this.store.dispatch(new UpsertFormErrorMessagesEntities({entities: [errors]})); } - // Emit an event when the submit logic is done - this.onSubmitted.emit(isValid); } ``` -In the example above we save the errors in the store when we execute the submit action. It can be done at valueChanges or statusChanges. - +In the example above, we save the errors in the store when we execute the submit action. This action can be called at `valueChanges` or `statusChanges` in the input component. ### Errors translation definition -For the localization of the error messages we keep the same way we have today ([LOCALIZATION](../localization/LOCALIZATION.md)), but we have specific places where to define the default translations of error messages. - +For the localization of the error messages, we keep the same way of working as we have today (check out [LOCALIZATION](../localization/LOCALIZATION.md)). #### Custom errors -Because the form validation depends on business logic and the custom validators are created in the container (see: [Form Validation](./FORM_VALIDATION.md)) we have to provide an error message for each validator and to ensure that the message is translatable. -We have to add the default translation keys, corresponding to the custom validators __in the container__ (_container.localization.json_ file). +Because the form validation depends on business logic and the custom validators are created in the parent component (see [Form Validation](./FORM_VALIDATION.md)), +we have to provide an error message for each validator and ensure that the message is translatable. +We have to add the default translation keys, corresponding to the custom validators, to the localization file __in the parent component__. ```typescript - // ---> in container class - /** - * Localization of the component - */ - @Input() - @Localization('./forms-poc-cont.localization.json') // Here we will define the error messages translation keys - public translations: FormsPocContTranslation; +// ---> in parent component class +/** Localization of the component */ +@Input() +@Localization('./forms-example-cont.localization.json') // Here we will define the translation keys of the error messages +public translations: FormsExampleContTranslation; ``` -Default values for the custom errors +Default values have to be defined for the custom errors, for example: -```json -// ----> forms-poc-cont.localization.json -... -"travelerForm.dateOfBirth.max": { // ---> travelerForm is the name we have chosen for the form - "description": "Validator for date of birth month", - "defaultValue": "Max value for the month should be {{ max }}" +```json5 +// ----> forms-example-cont.localization.json +"form.dateOfBirth.dateInThePast": { + "description": "Validator for date of birth", + "defaultValue": "Date of birth should be in the past" +}, +"form.globalForbiddenName": { + "description": "This validator will check if the name will be the given config", + "defaultValue": "Name cannot be { name }" }, -"travelerForm.global": { // ---> validator for the root (global) form - "description": "This validator will check if the first name or last name will be 'TEST'", - "defaultValue": "First name and Last name cannot be {{forbiddenName}}" -} ... ``` - - #### Primitive errors -These validators are defined and applied at presenter level, so we have to define the translation of the error messages here. -Each possible validator should have a corresponding error message in __presenter.localization.json__ file. +These validators are defined and applied at input component level, so we have to define the translation of the error messages here. +Each possible validator should have a corresponding error message in the input component's localization file. ```typescript -// ---> in presenter class -/** - * Localization of the component - */ +// ---> in input component class +/** Localization of the component */ @Input() -@Localization('./forms-pres-cont.localization.json') // Here we will define the error messages translation keys -public translations: FormsPocPresTranslation; +@Localization('./forms-example-pres.localization.json') // Here we will define the translation keys of the error messages +public translations: FormsExamplePresTranslation; ``` -Default values for the custom errors +Default values have to be defined for the primitive errors, for example: -```json -// The first key is not related to forms -"o3r-forms-poc-pres.key.not.related.to.forms": { - "description": "Test Value with a translation", - "defaultValue": "This is a test value translated from the presenter" -}, -... -"o3r-forms-poc-pres.firstName.required": { - "description": "Required validator for the first name", - "defaultValue": "The first name is required" +```json5 +// ----> forms-example-pres.localization.json +"o3r-forms-example-pres.name.required": { + "description": "Required validator for the name", + "defaultValue": "The name is required" }, -"o3r-forms-poc-pres.firstName.maxlength": { - "description": "Maxlength validator for the first name", - "defaultValue": "The first name should have a max length of {{max}} characthers" +"o3r-forms-example-pres.name.maxLength": { + "description": "Validator for the max length of the name", + "defaultValue": "Max length of the name should be { maxLength }" }, ... ``` diff --git a/docs/forms/FORM_STRUCTURE.md b/docs/forms/FORM_STRUCTURE.md index 8ca948f510..a173a2384a 100644 --- a/docs/forms/FORM_STRUCTURE.md +++ b/docs/forms/FORM_STRUCTURE.md @@ -1,422 +1,122 @@ -[Forms structure](#form-structure) - 1. [Container/presenter and reactive forms](#container-presenter) - 1. [Form creation in container or in presenter?](#form-creation) - 2. [Data exchange between container and presenter](#data-exchange) - 1. [Basic case](#data-exchange-basic) - 2. [Complex case](#data-exchange-complex) - 3. [Component Creation](#component-creation) - 1. [Basic case](#basic-case) - 2. [Adding complexity ](#adding-complexity) - 1. [Basic structure](#basic-structure) - 2. [Include Basic validation](#basic-validation) - 1. [Validators definition](#validators-definition) - 2. [Apply validators ](#apply-validators) - 3. [Validators translations](#validators-translation) - 3. [Include Custom Validations](#custom-validators) - 1. [Validators definition](#custom-validators-definition) - 2. [Apply validators ](#custom-apply-validators) - 3. [Validators translations](#custom-validators-translations) - - # Forms structure -Angular provides two approaches for writing the forms, [template-driven forms](https://angular.io/guide/forms) and [model-driven or reactive forms](https://angular.io/guide/reactive-forms). -This documentation will help you with some best practices to be used at the build of Angular reactive forms components in Otter context. - - -## [Container/presenter](../components/COMPONENT_STRUCTURE.md) and reactive forms -Container/presenter architecture was put in place to ensure the best re-usability/sharing - -### Form creation in container or in presenter? +This documentation will help you with some best practices to use when building Angular reactive forms components that have a parent/input component structure (such as [container/presenter](../components/COMPONENT_STRUCTURE.md)). +Note that this structure is simply an implementation of the `ControlValueAccessor` pattern, but we have chosen to guide you with an [example implemented in the showcase application](https://github.com/AmadeusITGroup/otter/tree/main/apps/showcase/src/app/forms). -* The __form creation__ (it can be a [__FormGroup__](https://angular.io/api/forms/FormGroup) or [__FormArray__](https://angular.io/api/forms/FormArray) or [__FormControl__](https://angular.io/api/forms/FormControl)) should be done __in the presenter__ because: - * it's up to the presenter to decide how the data will be displayed/computed. An example is a date which can be displayed in one input field - ([FormControl](https://angular.io/api/forms/FormControl)) in one presenter, or in one [FormGroup](https://angular.io/api/forms/FormGroup) containing 3 FormControls, corresponding to 3 input fields, in other presenter (the container needs only a date). - * we will not use the formGroup / formArray / formControl object as a two-way data binding object between the container and the presenter. -* The __container__ needs only the value and in some specific cases the errors propagated from the presenter. If needed it can set the default value +## Parent/input component and reactive forms +A parent/input component architecture was put in place to ensure the best reusability and sharing of components. -From now on we will refer as __form presenter object__ the __formGroup__ or __formArray__ or __formControl__ created in the presenter. +### Form creation in the parent component or input component? - -### Data exchange between container and presenter in forms context - -#### Simple cases -The need in this case is to display the inline errors, check the form validity and emit the form value. -* The presenter containing the form should: - * handle the display of form errors - * trigger the form submit - * check the form validity - * use an event emitter to propagate the form value to the container -* The container should intercept the propagated value and execute the submit logic - -#### Complex cases -This case includes the simple case plus the display of a messages panel containing the form errors and the flexibility to submit from the presenter or from the page. -* The presenter containing the form should: - * implement [ControlValueAccessor](https://angular.io/api/forms/ControlValueAccessor). It will propagate all the __value/status changes__ done inside the __presenter form object__ to the parent, in our case the container. - In this way it will behave as an __HTML input element__ on which we can __apply__ the [ngModel](https://angular.io/api/forms/NgModel) directive, or we can bind a [FormControl](https://angular.io/api/forms/FormControl#description). - * implement [Validator](https://angular.io/api/forms/Validator) interface, if your form validators are only synchronous or [AsyncValidator](https://angular.io/api/forms/AsyncValidator) interface if the form needs asynchronous validators. See [FORM_VALIDATION](./FORM_VALIDATION.md) for more details about validation in Otter. - * Implementing this interface gives us the possibility to define, in the __validate__ method, the error object model which will be propagated to the parent/container. See [FORM_ERRORS](./FORM_ERRORS.md) for details. -* The container will apply a [Form Control Directive](https://angular.io/api/forms/FormControlDirective) to the presenter form to have the possibility to: - * set the default value for the presenter form object if needed. - * listen to the valueChanges if needed - * listen status changes if needed - * easily get the errors propagated by the presenter +* The __form creation__ (it can be a [__FormGroup__](https://angular.io/api/forms/FormGroup) or [__FormArray__](https://angular.io/api/forms/FormArray) + or [__FormControl__](https://angular.io/api/forms/FormControl)) should be done __in the input component__ because: + * It is up to the input component to decide how the data will be displayed/computed. For example, a date can be displayed in an input field + ([FormControl](https://angular.io/api/forms/FormControl)) in one input component or in a [FormGroup](https://angular.io/api/forms/FormGroup) containing + 3 input fields in another input component (the parent component only needs a date value). + * We will not use the `formGroup` / `formArray` / `formControl` object as a two-way data binding object between the parent component and the input component. +* The __parent component__ only needs the value and, in some specific cases, the errors propagated from the input component. If needed, it can set the default value. -We prefer to use the __formControl__ rather than __ngModel__ because we can easily listen to the valueChanges or status changes of the presenter form. -Another constraint is that it's easier to identify the container context for the CMS, with one implementation (See [Component Structure](../components/COMPONENT_STRUCTURE.md) for details about the component context). +From now on we will refer to __input component form object__ as the `formGroup` or `formArray` or `formControl` created in the input component. - -### Component creation -__Component__ here, refers a container and a presenter components. +### Data exchange between parent component and input component - -#### Basic case -In this case the only need we have is to implement a form, display the inline errors, check the form validity and do something with the form value. -In this case for the presenter: -* __form__ is __created__ here -* __validators__ applied here (see [FORM_VALIDATION](./FORM_VALIDATION.md) for details and validator types, where they are created) -* __inline errors__ are handled here (see [FORM_ERRORS](./FORM_ERRORS.md) for details about the error messages translations) -* form validity will be checked here -* it will trigger the submission and emit the form value - -The container: -* capture the form value emitted -* execute the submit logic +#### Simple cases +In a simple case, the purpose of the data exchange is to display the inline errors, check the form validity, and emit the form value. +* The input component containing the form should: + * Handle the display of form errors. + * Trigger the form submit. + * Check the form validity. + * Use an event emitter to propagate the form value to the parent component. +* The parent component should intercept the propagated value and execute the submit logic. -The difference from the default implementation of the [forms in angular](https://angular.io/guide/reactive-forms) is that we have to emit the form value from the container to the presenter, +#### Complex cases +Data exchange in a complex case has the same purpose as the simple case, plus the display of a message panel containing the form errors and the ability to submit the form from the input component or page. +* The input component containing the form should: + * Implement [ControlValueAccessor](https://angular.io/api/forms/ControlValueAccessor). It will propagate all the __value/status changes__ done inside the __input component form object__ to the parent component. + In this way, it will behave as an __input HTML element__ on which we can __apply__ the [ngModel](https://angular.io/api/forms/NgModel) directive, or we can bind a [FormControl](https://angular.io/api/forms/FormControl#description). + * Implement the [Validator](https://angular.io/api/forms/Validator) interface if your form validators are only synchronous or the [AsyncValidator](https://angular.io/api/forms/AsyncValidator) interface if the form needs asynchronous validators. + See [Form Validation](./FORM_VALIDATION.md) for more details about validation in Otter. + * Implementing this interface gives us the possibility to define, in the `validate` function, the error object model which will be propagated to the parent component. See the [form error documentation](./FORM_ERRORS.md) for details. +* The parent component will apply a [Form Control Directive](https://angular.io/api/forms/FormControlDirective) to the input component form to give the possibility to: + * Set the default value for the input component form object if needed. + * Listen to the value changes if needed. + * Listen to the status changes if needed. + * Easily get the errors propagated by the input component. + +We prefer to use the `formControl` rather than `ngModel` because we can easily listen to the `valueChanges` or `statusChanges` of the input component form. +Another constraint is that it's easier to identify the parent component context for the CMS (See [Component Structure](../components/COMPONENT_STRUCTURE.md) for details about the component context). + +## Component creation +Here, a __component__ refers to a parent component or an input component. + +### Basic case +In this case, all we need to do is to implement a form, display the inline errors, check the form validity, and do something with the form value. + +In the input component: +* The __form__ is __created__. +* The __validators__ are applied (see [Form Validation](./FORM_VALIDATION.md) for details about validators and where they are created). +* The __inline errors__ are handled (see [Form Errors](./FORM_ERRORS.md) for details about the error messages translations). +* The form validity will be checked. +* The submission is triggered and the form value is emitted. + +The parent component: +* Captures the form value emitted. +* Executes the submit logic. + +The difference from the default implementation of the [forms in Angular](https://angular.io/guide/reactive-forms) is that we have to emit the form value from the parent component to the input component, using an [@Output](https://angular.io/api/core/Output) event. -Another difference might be related to the custom validators, which we are suggesting to be created in the container because they can be related to the business logic -(Please have a look at the dedicated section for the forms validators: [FORM_VALIDATION](./FORM_VALIDATION.md)). +Another difference might be related to the custom validators, which we suggest to be created in the parent component because they can be related to the business logic. +(Please have a look at the [dedicated section](./FORM_VALIDATION.md) on the forms validators.). - -#### Adding complexity +### Adding complexity In addition to the simple case, if we need an __error message__ panel, which can be displayed anywhere in the page, -or we need __form submission__, done from the page, we came up with the following implementation. - - -##### 1.Basic structure -The form created in the presenter and the default value should have the same contract. The contract of a form is an interface which defines the form controls names and the type of the value which should be handled by each control. See the example of a component creation. - -The example is based on a form used to introduce data for a Traveler object -* Define the contract object -```typescript -// form object contract -export interface Traveler { - firstName: string; - lastName: string; - dateOfBirth: Date; -} -``` - * __Container__ class - * Create a form control to set the binding and the default data. - -```typescript -// in container class - - mainFormControl: FormControl; +or to __submit the form outside the component__, we must follow the more complex implementation described below. - constructor(config: FormsPocContConfig, private store: Store) { - ... - // Default value - this.traveler: Traveler = {firstName: '', lastName: 'TestUser', dateOfBirth: new Date()}; - // define the form control which will be bound to presenter with default value - this.mainFormControl = new FormControl(this.traveler); - ... - } +#### 1. Basic structure +The form created in the input component and the default value of the form control in the parent component should have the same contract. The contract of a form is an interface that defines +the names of the form controls and the type of value that should be handled by each control. -ngOnInit() { - this.subscriptions.push( - // Subscribe to any change done to the value of the form control applied to the presenter - this.mainFormControl.valueChanges.subscribe((value) => console.log(value)), - // Subscribe to the status change of the form control applied to the presenter - this.mainFormControl.statusChanges.subscribe((value) => console.log(value)) - ); - } -``` +Below is an example of a component creation based on a form used to introduce data for a `PersonalInfo` object. - * Register the form control in the template context to be recognized if we change the presenter. See [COMPONENT_STRUCTURE](../components/COMPONENT_STRUCTURE.md) for details about the template context. +__Define the contract object:__ +Below is an example of a model used to create a form that introduces data for a `PersonalInfo` object. ```typescript -// in container class - getFormsPocPresContext(overrideContext: Partial): TemplateContext { - return { - config: this.config.presFormsPocConfig || new FormsPocPresConfig(), - inputs: { - validators: this.validators, // ---> the validators applied to the form; we'll see this later - ...overrideContext - }, - outputs: { - onSubmit: this.onSubmit.bind(this), - registerInteraction: this.registerInteraction.bind(this) - }, - parentId: this.id, // ---> this id will be used by the presenter to create html element id's for the form controls inside (it has to be unique) - formControl: this.mainFormControl // ---> this filed is keeping the 'mainFormControl' object in the context. It is not used by the presenter - }; - } -``` -* Container template -```html - - - - - - - - - -``` -* __Presenter__ class - * Here we have to create the formGroup/formArray/formControl object - * Provide [NG_VALUE_ACCESSOR](https://angular.io/api/forms/NG_VALUE_ACCESSOR) - used to provide a [ControlValueAccessor](https://angular.io/api/forms/DefaultValueAccessor) for form controls, to write a value and listening to changes on input elements. - * Provide [NG_VALIDATORS](https://angular.io/api/forms/NG_VALIDATORS) This is an [InjectionToken](https://angular.io/api/core/InjectionToken) for registering additional synchronous validators used with forms. -```typescript -// in presenter class -@Component({ - selector: 'o3r-forms-poc-pres', - styleUrls: ['./forms-poc-pres.style.scss'], - templateUrl: './forms-poc-pres.template.html', - changeDetection: ChangeDetectionStrategy.OnPush, - providers: [ - { - provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => FormsPocPresComponent), - multi: true - }, - { - provide: NG_VALIDATORS, - useExisting: forwardRef(() => FormsPocPresComponent), - multi: true - } - ] -}) -export class FormsPocPresComponent implements OnInit, Validator, FormsPocPresContext, ControlValueAccessor, Configurable, OnDestroy { - /** Localization of the component */ - @Input() - @Localization('./forms-poc-pres.localization.json') - public translations: FormsPocPresTranslation; - - /** Object used to compute the ids of the form controls */ - @Input() id: string; - - /** Configuration of the component */ - @Input() public config: FormsPocPresConfig; - - /** Custom validators applied on the form */ - @Input() customValidators?: CustomFormValidation; // See more in ./FORM_VALIDATION.md - - /** Emit an event when the submit has been fired on the form */ - @Output() onSubmit: EventEmitter = new EventEmitter(); // See more in ./FORM_SUBMIT_AND_INTERCOMMUNICATION.md - - /** Register a function to be called when the submit is done outside of the presenter (from page) */ - @Output() registerInteraction: EventEmitter<() => void> = new EventEmitter<() => void>(); // See more in ./FORM_SUBMIT_AND_INTERCOMMUNICATION.md - - /** The form object */ - travelerForm: FormGroup; - - constructor() { - // Create the form having the Traveler contract - this.travelerForm = this.fb.group({ - firstName: null, - lastName: null, - dateOfBirth: null - }); - } - - ngOnInit() { - ... - this.subscriptions.push( - this.travelerForm.valueChanges - .pipe( - map((value) => { - const traveler: Traveler = {firstName: value.firstName, lastName: value.lastName, dateOfBirth: value.dateOfBirth}; - return traveler; - }) - ) - .subscribe((value) => { - this.propagateChange(value); // ---> Propagate the value to the parent - }) - ); - ... - } - ... - /** @inheritDoc - * Called when setting form value - */ - writeValue(value?: any) { - if (value) { - this.travelerForm.setValue(value); - } - } - ... - /** @inheritDoc - * Return the errors for the validators applied global to the form plus the errors for each field - */ - public validate(_control: AbstractControl): ValidationErrors | null { - ... // ----> See ./FORM_ERRORS.md for the implementation of this method - } +/** Model used to create Personal Info form */ +export interface PersonalInfo { + /** Name */ + name: string; + /** Date of birth */ + dateOfBirth: string; } ``` -* __Submit and Intercommunication__ - -In Otter context we have to handle specific cases for form submit and communication between __presenter/container/page__ -For the submit action we have to support 2 cases: -* __submit from page__ (app level) - there is no submit button in the presenter and the submit action is triggered at application level - * The __page__ triggers submit action > __Container__ receives the signal and executes the submit logic. Emits an event when the submit logic is finished. - - This is useful when you have multiple forms on a page and you want to trigger the submit for all in the same time. -* __submit from presenter__ - the submit button is displayed - * __Presenter__ - click on submit btn and emits an event > __Container__ receives the signal and executes the submit logic. Emits an event when the submit logic is finished. - -This section is explained in details in [FORM_SUBMIT&INTERCOMMUNICATION](./FORM_SUBMIT_AND_INTERCOMMUNICATION.md) section. - -##### 2. Include Basic validation -The validations on the form are improving overall data quality by validating user input for accuracy and completeness. -We are keeping the concept of validators from Angular forms. Please see [FormValidation](https://angular.io/guide/form-validation) and [Validators](https://angular.io/api/forms/Validators) in Angular for more details. +__Parent component class:__ +* Create a form control to set the binding and the default data. +* This form control will be passed as an input to the input component class through the HTML template. -In Otter context we call the __basic or primitive__, the validators which are using primitive values (string, number, booleans) as inputs for the validation function. - -These validators are defined and applied at presenter level. They can be set at form creation or later, depending on the use cases. -Validators values are given as a configuration on the presenter. This gave us the possibility to use the presenter with different set of validators. - - -###### Validators definition -```typescript -export interface FormsPocPresConfig extends Configuration { - ... - /** If true requires the control have a non-empty value */ - firstNameRequired: boolean; +You can find the implementation of a parent component class in the [showcase application](https://github.com/AmadeusITGroup/otter/tree/main/apps/showcase/src/components/showcase/forms/forms-pres.component.ts). - /** Requires the length of the control's value to be less than or equal to the provided number. */ - firstNameMaxLength?: number; - ... +__Input component class:__ + * Here we have to create the `formGroup`/`formArray`/`formControl` object. + * Provide [NG_VALUE_ACCESSOR](https://angular.io/api/forms/NG_VALUE_ACCESSOR) - used to provide a [ControlValueAccessor](https://angular.io/api/forms/DefaultValueAccessor) for form controls, to write a value and listen to changes on input elements. + * Provide [NG_VALIDATORS](https://angular.io/api/forms/NG_VALIDATORS) - this is an [InjectionToken](https://angular.io/api/core/InjectionToken) for registering additional synchronous validators used with forms. -export const FORMS_POC_PRES_DEFAULT_CONFIG: FormsPocPresConfig = { - ..., - firstNameRequired: true, - firstNameMaxLength: 5, - ... -}; -``` - - -###### Apply validators - - * __on presenter html__ -In the use case where we need to display inline errors, we have to apply directives corresponding to the validators on the html template (when it is possible), because Angular material needs the directives for the display of inline errors -```html - - -``` - * __on presenter class__ -```typescript - this.subscriptions.push( - this.config$.subscribe((config) => { - const firstNameValidators = []; - if (config.firstNameMaxLength) { - // Apply validator based on config - firstNameValidators.push(Validators.maxLength(this.config.firstNameMaxLength)); - } - // firstNameValidators.push(otherValidators) - if (firstNameValidators.length) { - this.travelerForm.controls.firstName.clearValidators(); - this.travelerForm.controls.firstName.setValidators(firstNameValidators) - } - }) - ); -``` +You can find the implementation of an input component class in the [showcase application](https://github.com/AmadeusITGroup/otter/tree/main/apps/showcase/src/components/utilities/forms-personal-info/forms-personal-info-pres.component.ts). - -###### Validators translations -For each defined validator we need a corresponding translation key for the error message. These keys have to be defined in the corresponding __localization.json__ file of the __presenter__. In this way the presenter is aware about its own validations/error messages. - -See [FORM_VALIDATIONS](./FORM_VALIDATION.md) for more details. - - -##### 3. Include Custom Validations -Since the built-in validators won't always match the exact use case of your application, sometimes you'll want to create a custom validator. See [Custom Validators](https://angular.io/guide/form-validation#custom-validators) in angular. -Our custom validators are usually related to the business logic or, they are applied to multiple fields/form controls. -As they are related to the business logic we will create them in the __container__ and pass them to the presenter via an input. The presenter is the one which applies them on the form. - - -###### Validators definition -The validation function can be defined anywhere, but it has to be added to the validators object in the container. -* Validation function -```typescript -/** Validator which checks that the firstname or lastname are not equal with the parameter 'valueToTest' */ -export function formsPocValidatorGlobal(valueToTest: string, translationKey: string, longTranslationKey?: string, translationParams?: any): CustomValidationFn { - return (control: AbstractControl): CustomErrors | null => { - const value: Traveler = control.value; - if (!value || !value.firstName) { - return null; - } - if (value.firstName !== valueToTest && value.lastName !== valueToTest) { - return null; - } else { - return {customErrors: [{translationKey, longTranslationKey, translationParams}]}; // ---> See more about the returned error model in ./FORM_ERRORS.md - } - }; -} -``` -* Container -```typescript -... -ngOnInit() { - this.validators = { // See more about validators type in ./FORM_VALIDATION.md - global: formsPocValidatorGlobal(this.config.forbiddenName, translations.globalForbiddenName, `${translations.globalForbiddenName}.long`, {name: 'Test'}), - fields: {dateOfBirth: dateCustomValidator(translations.dateInThePast) } - }; -... - getFormsPocPresContext(overrideContext: Partial): TemplateContext { - return { - ... - inputs: { - validators: this.validators // ---> the validators sent to be applied on the presenter; - }, - ... - }; - } -``` - - -###### __Apply__ validators: -The validators are applied to the form on the __presenter__ class. -```typescript -/** Custom validators applied on the form */ - @Input() customValidators?: CustomFormValidation; - - ngOnInit() { - ... - const firstNameValidators = []; // Validators for the firstName - if (this.config.firstNameMaxLength) { // Primivite validator - // Apply validator based on config - firstNameValidators.push(Validators.maxLength(this.config.firstNameMaxLength)); - } - // Apply custom validation - if (this.customValidators && this.customValidators.fields && this.customValidators.fields.firstName) { - firstNameValidators.push(this.customValidators.fields.firstName); - } - this.travelerForm.controls.firstName.setValidators(firstNameValidators); - } -``` +__Submit and Intercommunication:__ - -###### Validators translations -For each custom validator we need a corresponding translation key for the error message. -As they are defined in the container, the keys have to be defined in the corresponding __localization.json__ file of the __container__. -In this way the container knows about its own validations/error messages. +We have to handle specific cases for form submission and communication between __input component/parent component/page__. +For the submit action, we have to support two cases: +* __Submit from page__ (app level) - there is no submit button in the input component and the submit action is triggered at application level. + * The __page__ triggers the submit action. The __parent component__ receives the signal, executes the submit logic, and emits an event when the submit logic is finished. -See [FORM_VALIDATIONS](./FORM_VALIDATION.md) for more details. + This is useful when you have multiple forms on a page and you want to trigger the submit for all in the same time. +* __Submit from input component__ - the submit button is displayed. + * The submit button is clicked and the __input component__ emits an event. The __parent component__ receives the signal, executes the submit logic, and emits an event when the submit logic is finished. +This section is explained in details in the [Otter form submit and intercommunication documentation](./FORM_SUBMIT_AND_INTERCOMMUNICATION.md). +#### 2. Include validation +You can create basic or custom validators in your application, depending on the use cases. +You can find details on this in the [Otter form validation documentation](./FORM_VALIDATION.md). diff --git a/docs/forms/FORM_SUBMIT_AND_INTERCOMMUNICATION.md b/docs/forms/FORM_SUBMIT_AND_INTERCOMMUNICATION.md index 60031dc87a..b18cd8d889 100644 --- a/docs/forms/FORM_SUBMIT_AND_INTERCOMMUNICATION.md +++ b/docs/forms/FORM_SUBMIT_AND_INTERCOMMUNICATION.md @@ -1,257 +1,191 @@ -[Forms Submit and Intercommunication](#form-submit) - 1. [Container presenter context](#container-presenter) - 2. [Form submit](#form-submit) - 1. [Submit from page](#page-submit) - 2. [Submit from presenter](#presenter-submit) - 1. [Handle inline errors at submit](#handle-inline-error-submit) - - # Forms Submit and Intercommunication - -### Container presenter communication +## Parent component and input component communication -Having the __presenter__ implementing [ControlValueAccessor](https://angular.io/api/forms/ControlValueAccessor), it will __propagate__ all the __value/status changes__ done inside the __presenter form object__ to the parent, in our case the container. -In this way it will behave as an __HTML input element__ on which we can __bind__ a [FormControl](https://angular.io/api/forms/FormControl#description). -Also, the presenter is implementing [Validator](https://angular.io/api/forms/Validator) interface, if your form validators are only synchronous or [AsyncValidator](https://angular.io/api/forms/AsyncValidator) interface if the form needs asynchronous validators. See [FORM_VALIDATION](./FORM_VALIDATION.md) for more details about validation in Otter. -Implementing this interface gives us the possibility to define, in the __validate__ method, the error object model which will be __propagated__ to the parent/container. See [FORM_ERRORS](./FORM_ERRORS.md) for details. +Since the __input component__ implements [ControlValueAccessor](https://angular.io/api/forms/ControlValueAccessor), it will __propagate__ all the __value/status changes__ done inside the __form object__ to the parent component. +In this way, it will behave as an __input HTML element__ on which we can __bind__ a [FormControl](https://angular.io/api/forms/FormControl#description). +Also, the input component implements the [Validator](https://angular.io/api/forms/Validator) interface if your form validators are only synchronous or the [AsyncValidator](https://angular.io/api/forms/AsyncValidator) interface if the form needs asynchronous validators. +See [Form Validation](./FORM_VALIDATION.md) for more details about validation in Otter. +Implementing this interface gives us the possibility to define, in the `validate` function, the error object model which will be __propagated__ to the parent component. See [Form Errors](./FORM_ERRORS.md) for details. -The container will apply the [Form Control Directive](https://angular.io/api/forms/FormControlDirective) to the presenter html tag in order to: - * __set the default value__ for the presenter form object. - * __listen to the valueChanges__ - * __listen status changes__ - * easily __get the errors propagated__ by the presenter +The parent component will apply the [Form Control Directive](https://angular.io/api/forms/FormControlDirective) to the input component HTML tag in order to: + * __Set the default value__ for the input component form object. + * __Listen to the value changes__. + * __Listen to the status changes__. + * Easily __get the errors propagated__ by the input component. -See [FORM_STRUCTURE](./FORM_STRUCTURE.md) for more details. +See the [Otter form structure documentation](./FORM_STRUCTURE.md) for more details. - -### Form submit +## Form submit -For the forms submit actions we have to support 2 cases: -* submit __from the component__ - the submit button is displayed in the presenter -* submit __from the page__ (app level) - the button is hidden in the presenter and the submit action is triggered at application level +For the forms submit actions, we have to support two cases: +* Submit __from the component__: The submit button is displayed in the input component. +* Submit __from the page__ (application level): The button is hidden in the input component and the submit action is triggered at application level. -The display of the submit button should be configurable in the presenter. A config property has to be provided in the presenter configuration. +The display of the submit button should be configurable in the input component. A property has to be provided in the configuration. ```typescript -export interface FormsPocPresConfig extends Configuration { +export interface FormsExamplePresConfig extends Configuration { /** Configuration to show/hide the submit button */ showSubmitButton: boolean; ... } ``` -In both cases the submit logic is handled in the container. -When submit is triggered either by the presenter or the page, it is only notifying the container that a submit action was fired. The event is captured in the container and it is calling the execution of submit logic. -The container will handle business logic at submit and when it has finished, it will emit an event (__submitted__) with a boolean value (`true` if the submit is considered successful, `false` otherwise) which can be intercepted at page level. +In both cases, the submit logic is handled in the parent component. +When submit is triggered either by the input component or the page, it is only notifying the parent component that a submit action was fired. The event is captured in the parent, and it is calling the execution of submit logic. +The parent component will handle the business logic and when it has finished, it will emit an event (`submitted`) with a boolean value (`true` if the submit is considered successful, `false` otherwise) which can be intercepted at page level. - -#### Submit from page -In this case the submit button should be hidden in the presenter, so the submit will be triggered from page/parent component. -We propose a way of notifying the container that a submission has been triggered from the page. +### Submit from page +In this case, the submit button is hidden in the input component so the submit will be triggered from the page. +We propose a way of notifying the parent component that a submission has been triggered from the page. + +__Passing an observable as an input to the parent component__ +* In the page component template, the `submitTrigger$` observable is passed as input to the parent component. +```html + + + +``` + +* In the page component, we emit a new event each time we click on the `Next` button (which triggers a submit on the form). +```typescript +public submitTheForm$: Subject = new Subject(); + +public goNext() { + this.submitTheForm$.next(true); +} +``` + +* In the parent component, we receive the observable as an input, and we execute the submit logic each time the observable emits. +Note that we have put in place an [@AsyncInput](https://github.com/AmadeusITGroup/otter/blob/main/packages/%40o3r/forms/src/annotations/async-input.ts) decorator in __@o3r/forms__ to make sure that we will not have unhandled subscriptions if the reference of the input observable changes. +```typescript +import { AsyncInput } from '@o3r/forms'; +... + +export class FormsExampleContComponent implements OnInit, ... { + + /** Observable used to notify the component that a submit has been fired from the page */ + @Input() + @AsyncInput() + public submitTrigger$: Observable; -* __Passing an observable as an input to the container__ - * Page component template - -The _submitTrigger$_ observable is passed as input to the container. - ```typescript - - - - ``` - * In the page component we emit a new event each time we click on _Next_ button. We want that this, to trigger a submit on the form. - ```typescript - ... - submitTheForm$: Subject = new Subject(); ... - goNext() { - this.submitTheForm$.next(true); + + public ngOnInit() { ... + if (this.submitTrigger$) { + this.subscriptions.push( + this.submitTrigger$.subscribe((_value) => this.submitAction()) + ); + } } - onFormSubmitted(value: boolean) { - console.log('Form submitted result:', value); - ... + + public submitAction() { + // this contains the logic executed at submit } - ``` - * In the container we receive the observable as an input, and each time the observable emits we execute the submit logic. - Note that we have put in place an [@AsyncInput](https://github.com/AmadeusITGroup/otter/blob/main/packages/%40o3r/forms/src/annotations/async-input.ts) decorator in __@o3r/forms__ to make sure that we will not have unhandled subscriptions if the reference of the input observable changes. - ```typescript - ... - import { AsyncInput ...} from '@o3r/forms'; - ... - /** Observable used to notify the component that a submit has been fired from the page */ - @Input() - @AsyncInput() - public submitTrigger$: Observable; - - /** - * Emit an event when the submit has been fired from the component (block) - */ - @Output() onSubmitForm: EventEmitter = new EventEmitter(); - - /** - * Emit an event at the end of the submit executed logic - */ - @Output() onSubmitted: EventEmitter = new EventEmitter(); - - ... - ngOnInit() { - this.formsPocPresContext$.next(this.getFormsPocPresContext({})); - - if (this.submitTrigger$) { - this.subscriptions.push( - this.submitTrigger$.subscribe((_value) => { - this.submitAction(); - }) - ); - } - } - - submitAction() { - // this contains the logic executed at submit - ... - // Emit an event at the end of the submit logic execution - const isValid = true; // means that the submit logic is successful - this.onSubmitted.emit(isValid); - } - ``` +} +``` - -#### Submit from presenter -An event will be emitted when the submit of the form is fired (click on submit button, ENTER key ...), notifying the container about this. No logic is done at presenter level. -As in the page submit, the submit logic will be handled inside the container. -In the following example we are using the same function to execute the logic as in the page submit. +### Submit from input component +An event will be emitted when the submit of the form is fired (click on submit button, ENTER key, etc.), notifying the parent component about this. No logic is done at input component level. +As in the page submit, the submit logic will be handled inside the parent component. +In the following example, we are using the same function to execute the logic as in the page submit. -* Container component +* Parent component: ```typescript - ... - /** The form control object bind to the presenter */ - mainFormControl: FormControl; - ... - constructor(private store: Store, public changeDetector: ChangeDetectorRef) { - this.translations = translations; - this.traveler = {firstName: '', lastName: 'TestUser', dateOfBirth: new utils.Date()}; - this.mainFormControl = new FormControl(this.traveler); - } - ... -/** Submit event received from the presenter */ - onSubmit() { - this.onSubmitForm.emit(); - // Check that there is no submit from the page/parent component - if (!this.submitMe$) { // In this case we do not want to execute the submit logic, as it will be done when we submit from the page - this.submitAction(); - } +/** Observable used to notify the component that a submit has been fired from the page */ +@Input() +@AsyncInput() +public submitTrigger$: Observable; + +... + +/** Submit event received from the input component */ +public doSubmit() { + // Check that there is no submit from the page + // In this case we do not want to execute the submit logic, as it will be done when we submit from the page + if (!this.submitTrigger$) { + this.submitAction(); } +} - /** submit function */ - submitAction() { - // When submitting from page, call the function to mark the form in the presenter as dirty and touched - if (this.submitTrigger$) { // ---> this will be explained below - this._markInteraction(); - } - const isValid = !this.mainFormControl.errors; - if (!this.mainFormControl.errors) { - // put your submit logic here - } else { - const errors: FormError = { - formId: `${this.id}-my-form-example`, - errors: Object.keys(this.mainFormControl.errors).map((controlName: string) => { - const controlErrors = this.mainFormControl.errors![controlName]; - return {htmlElementId: controlErrors.htmlElementId, errorMessages: controlErrors.errorMessages}; - }) - }; - this.store.dispatch(new UpsertFormErrorMessagesEntities({entities: [errors]})); - } - this.onSubmitted.emit(isValid); +/** submit function */ +public submitAction() { + if (!this.exampleFormControl.errors) { + // put your submit logic here + } else { + // put your error logic here } +} ``` - ### Handle inline errors at submit, before interacting with the form -At the first display of the form there is no inline error shown. If there is no interaction with the form and submit is triggered, all invalid fields should display inline errors. -For this we have to mark the controls as touched and dirty before doing the submission. -If the submit button is in the presenter, we mark the controls as dirty and touched before doing the submission. +When the form is first displayed, no inline errors are shown. If there is no interaction with the form and the submit is triggered, all invalid fields should display inline errors. +To avoid this, we have to mark the controls as touched and dirty before doing the submission. We also do this if the submit button is in the input component. + +We need to __register a function__ to be called __to mark the controls__ from the input component as __dirty and touched__. So we emit an event with the callback function at the initialization +of the input component after we have created the form object. This function will be called in the parent component before executing the submit logic. -When the submit is done from the page we execute the submitAction in the container, and we have no access to the controls in the presenter. -We need to __register a function__ to be called __to mark the controls__ from the presenter as __dirty and touched__. So we emit an event with the callback function at the initialization of the presenter component after we have the form object (travelerForm here) created. This function will be called in the container before executing the submit logic. -* Presenter component +When the submit from the page is done, we execute the `submitAction` function in the parent component, and we have no access to the controls in the input component. + +* __Input component__: ```typescript +/** Register a function to be called to mark the controls as touched and dirty */ +@Output() registerInteraction: EventEmitter<() => void> = new EventEmitter<() => void>(); + +ngOnInit() { ... - /** Register a function to be called to mark the controls as touched and dirty */ - @Output() registerInteraction: EventEmitter<() => void> = new EventEmitter<() => void>(); - ... - ngOnInit() { - ... - this.registerInteraction.emit(() => { - markAllDirtyAndTouched(this.travelerForm); - this.changeDetector.markForCheck(); - }); - } + this.registerInteraction.emit(() => { + markAllControlsDirtyAndTouched(this.form); + this.changeDetector.markForCheck(); + }); +} ``` We have provided a helper called [markAllControlsDirtyAndTouched](https://github.com/AmadeusITGroup/otter/blob/main/packages/@o3r/forms/src/core/helpers.ts) in __@o3r/forms__ to mark the interaction with the form. -* Container component +* __Parent component template__: +```html + +``` + +* __Parent component__: ```typescript - ... - /** The form control object bind to the presenter */ - mainFormControl: FormControl; - - /** This will store the function to make the child form as dirty and touched */ - _markInteraction: () => void; - ... - constructor(private store: Store, public changeDetector: ChangeDetectorRef) { - this.translations = translations; - this.traveler = {firstName: '', lastName: 'TestUser', dateOfBirth: new utils.Date()}; - this.mainFormControl = new FormControl(this.traveler); - } - ... -/** Submit event received from the presenter */ - onSubmit() { - this.onSubmitForm.emit(); - // Check that there is no submit from the page/parent component - if (!this.submitMe$) { // In this case we do not want to execute the submit logic, as it will be done when we submit from the page - this.submitAction(); - } - } +/** Observable used to notify the component that a submit has been fired from the page */ +@Input() +@AsyncInput() +public submitTrigger$: Observable; - /** submit function */ - submitAction() { - // When submitting from page, call the function to mark the form in the presenter as dirty and touched - // It is not necessary to be called each time we submit. It is important to be called if the form is pristine - if (this.submitTrigger$) { - this._markInteraction(); - } - const isValid = !this.mainFormControl.errors; - if (!this.mainFormControl.errors) { - // put your submit logic here - } else { - const errors: FormError = { - formId: `${this.id}-my-form-example`, - errors: Object.keys(this.mainFormControl.errors).map((controlName: string) => { - const controlErrors = this.mainFormControl.errors![controlName]; - return {htmlElementId: controlErrors.htmlElementId, errorMessages: controlErrors.errorMessages}; - }) - }; - this.store.dispatch(new UpsertFormErrorMessagesEntities({entities: [errors]})); - } - this.onSubmitted.emit(isValid); - } - - /** Register the function to be called to mark the presenter as touched and dirty */ - registerInteraction(fn: () => void) { - this._markInteraction = fn; - } - - getFormsPocPresContext(overrideContext: Partial): TemplateContext { - return { - ... - outputs: { - onSubmit: this.onSubmit.bind(this), - registerInteraction: this.registerInteraction.bind(this) // ---> save the output function handler in the component context - }, - parentId: this.id, - formControl: this.mainFormControl - }; +/** This will store the function to make the child form as dirty and touched */ +public _markInteraction: () => void; + +/** Register the function to be called to mark the input component as touched and dirty */ +public registerInteraction(fn: () => void) { + this._markInteraction = fn; +} + +/** submit function */ +public submitAction() { + // When submitting from page, call the function to mark the form in the input component as dirty and touched + // It is not necessary to be called each time we submit. It is important to be called if the form is pristine + if (this.submitTrigger$) { + this._markInteraction(); } + // rest of the logic executed at submit +} ``` + +### What happens when you have multiple forms and want to submit? +We should avoid as much as possible having multiple _form_ tags in the same page because it adds a lot of complexity. +It would be better (if possible) to have only one _form_ tag that encapsulates everything and one submit action. + +If multiple forms are really necessary, we found the following solution: +* The submit button is hidden in the input components. +* The __submit__ is __triggered from the page__. +* An __observable__ to trigger the submit is passed as __input__ to the parent components. +* The `AsyncInput` decorator is provided by __@o3r/forms__ to be applied on the observable input to ensure performance. +* Submit form logic is executed on the parent components. +* Parent components emit events when the submit is done. +* The page captures the events and continues its logic. + +This can also be applied with a single form on the page, when you don't want a submit button in the input component. diff --git a/docs/forms/FORM_VALIDATION.md b/docs/forms/FORM_VALIDATION.md index 737cde413d..28f2bfba94 100644 --- a/docs/forms/FORM_VALIDATION.md +++ b/docs/forms/FORM_VALIDATION.md @@ -1,314 +1,179 @@ -[Form validators](#form-validators) - -- [Form validators](#form-validators) - - [Sync validators](#sync-validators) - - [Container/presenter context](#containerpresenter-context) - - [Basic validators](#basic-validators) - - [Validators definition](#validators-definition) - - [Apply validators](#apply-validators) - - [Validators translations](#validators-translations) - - [Custom Validators](#custom-validators) - - [Validators definition](#validators-definition-1) - - [Apply validators](#apply-validators-1) - - [Validators translations](#validators-translations-1) - - [Custom validation contracts available in @o3r/forms](#custom-validation-contracts-available-in-o3rforms) - - [Async Validators](#async-validators) - - - # Form validators -The validations on the form are improving overall data quality by validating user input for accuracy and completeness. -We are using the base concepts from Angular for the [form validation](https://angular.io/guide/form-validation), having default validators ( required, maxLength ...) but also custom validators (see Custom Validators in [form validation angular](https://angular.io/guide/form-validation)). - - +Form validations improve overall data quality by validating user input for accuracy and completeness. +We are using the core concepts from Angular for the [form validation](https://angular.io/guide/form-validation), with default validators (required, maxLength, etc.) +but also custom validators (see Custom Validators in [Angular form validation](https://angular.io/guide/form-validation)). ## Sync validators - - -### Container/presenter context - -In container/presenter context we have to decide where to create and how to apply the validators. -Having this situation we decided to split the validation in 2 parts: +### Context of parent component and input component -- __custom validators__ - the ones related to the business logic or applied to multiple form controls. As they are related to the business logic they have to be declared at container level. -- __primitive validators__ - simple configurable validators which will be declared at presenter level +In the parent/input component context, we have to decide where to create and how to apply the validators. +In this situation, we decided to split the validation into two parts: -The presenter will implement [Validator](https://angular.io/api/forms/NG_VALIDATORS) interface, meaning that we will have to implement __the validate__ method which help us in defining the error object structure. -(See [Form errors](./FORM_ERRORS.md) create error object section) -Each time the validate method is called the returned object is propagated to the parent (container in our case). If the object returned is _null_ the form status _VALID_. If the return is an object the form status is _INVALID_. +- __Custom validators__: The ones related to the business logic or applied to multiple form controls. As they are related to the business logic, they have to be declared at parent component level. +- __Primitive validators__: Simple and configurable validators which will be declared at input component level. - +The input component will implement the [Validator](https://angular.io/api/forms/NG_VALIDATORS) interface, meaning that we will have to implement the `validate` function which will help us in defining the error object structure. +(See the create error object section in [Form Errors](./FORM_ERRORS.md)). +Each time the `validate` function is called, the returned object is propagated to the parent component. If the returned object is `null`, the form status is `VALID`. Otherwise, the form status is `INVALID`. -#### Basic validators +### Basic validators We are keeping the concept of validators from Angular forms. Please see [FormValidation](https://angular.io/guide/form-validation) and [Validators](https://angular.io/api/forms/Validators) in Angular for more details. -In Otter context we call the __basic or primitive__, the validators which are using primitive values (string, number, booleans) as inputs for the validation function. +We call validators __basic or primitive__ if they are using primitive values (string, number, boolean) as inputs for the validation function. -These validators are defined and applied at presenter level. They can be set at form creation or later, depending on the use cases. -Validators values are given as a configuration on the presenter. This gave us the possibility to use the presenter with different set of validators. +These validators are defined and applied at input component level. They can be set at form creation or later, depending on the use cases. +Validator values are given as a configuration in the input component. This gives us the possibility of using the input component with different sets of validators. - +#### Define basic validators -##### Validators definition +Below is an example of validator values that are defined in the configuration of the input component: ```typescript -export interface FormsPocPresConfig extends Configuration { +export interface FormsExamplePresConfig extends Configuration { ... - /** If true requires the control have a non-empty value */ - firstNameRequired: boolean; - /** Requires the length of the control's value to be less than or equal to the provided number. */ firstNameMaxLength?: number; - ... - -export const FORMS_POC_PRES_DEFAULT_CONFIG: FormsPocPresConfig = { - ..., - firstNameRequired: true, - firstNameMaxLength: 5, - ... -}; +} ``` - - -##### Apply validators +#### Apply basic validators -The validation can be applied on the html template, it can be given at form creation or set later in the presenter. This depends on the use cases. +The validation can be applied in the HTML template, it can be given at form creation, or it can be set later in the input component. This depends on the use cases. -- __on presenter html__ -In the use case where we need to display inline errors, we have to apply directives corresponding to the validators on the html template (when it is possible), because Angular material needs the directives for the display of inline errors +* __Input component HTML__: +In the use case where we need to display inline errors, we have to apply directives corresponding to the validators in the HTML template (when it is possible), +because Angular material needs the directives for the display of inline errors. ```html - - -``` - -- __on presenter class__ - -```typescript - this.subscriptions.push( - this.config$.subscribe((config) => { - const firstNameValidators = []; - if (config.firstNameMaxLength) { - // Apply validator based on config - firstNameValidators.push(Validators.maxLength(this.config.firstNameMaxLength)); - } - // firstNameValidators.push(otherValidators) - if (firstNameValidators.length) { - this.travelerForm.controls.firstName.clearValidators(); - this.travelerForm.controls.firstName.setValidators(firstNameValidators) - } - }) - ); + + ``` - - -##### Validators translations - -For each defined validator we need a corresponding translation key for the error message. These keys have to be defined in the corresponding __localization.json__ file of the __presenter__. In this way the presenter is aware about its own validations/error messages. -See [FORM_ERRORS](./FORM_ERRORS.md) _Errors translation_ section for more details. - - - -#### Custom Validators - -Since the built-in validators won't always match the exact use case of your application, sometimes you'll want to create a custom validator. See [Custom Validators](https://angular.io/guide/form-validation#custom-validators) in angular. -Our custom validators are usually related to the business logic or, they are applied to multiple fields/form controls. -As they are related to the business logic we will create them in the __container__ and pass them to the presenter via an input. The presenter is the one which applies them on the form. - - - -##### Validators definition - -The validation function can be defined anywhere, but it has to be added to the validators object in the container. - -- Validation function +* __Input component class__: The validators are applied to the form in the __input component__ class. For example: ```typescript -/** Validator which checks that the firstname or lastname are not equal with the parameter 'valueToTest' */ -export function formsPocValidatorGlobal(valueToTest: string, translationKey: string, longTranslationKey?: string, translationParams?: any): CustomValidationFn { - return (control: AbstractControl): CustomErrors | null => { - const value: Traveler = control.value; - if (!value || !value.firstName) { - return null; - } - if (value.firstName !== valueToTest && value.lastName !== valueToTest) { - return null; - } else { - return {customErrors: [{translationKey, longTranslationKey, translationParams}]}; // ---> See more about the returned error model in ./FORM_ERRORS.md - } - }; +public ngOnInit() { + const nameValidators = []; + if (this.config?.nameMaxLength) { + // Apply validator based on config + nameValidators.push(Validators.maxLength(this.config.nameMaxLength)); + } + ... + this.form.controls.name.setValidators(nameValidators); } ``` -The object returned by the custom validator will be of type __ErrorMessageObject__ compatible with the form error store. (See [Form Errors](./FORM_ERRORS.md)) -The key _customErrors_ it is used to identify the custom errors in the errors returned by a form control; +#### Basic validators translations -- Container +For each defined validator, we need a corresponding translation key for the error message. +These keys have to be defined in the corresponding `localization.json` file of the __input component__. +This way the input component is aware about its own validations/error messages. -```typescript -// ... -/** Form validators */ -validators: CustomFormValidation; -// ... -ngOnInit() { - this.validators = { // ---> This object is passed as an input to the presenter - // Validator applied to the root (global) form - global: formsPocValidatorGlobal(this.config.forbiddenName, translations.globalForbiddenName, `${translations.globalForbiddenName}.long`, {name: 'Test'}), - // Validator applied on the dateOfBirth field - fields: {dateOfBirth: dateCustomValidator(translations.dateInThePast) } - }; - // ... - getFormsPocPresContext(overrideContext: Partial): TemplateContext { - return { - // ... - inputs: { - validators: this.validators // ---> the validators sent to be applied on the presenter; - }, - // ... - }; -} -``` +See the _Errors translation_ section in [Form Errors](./FORM_ERRORS.md) for more details. -[__CustomFormValidation__](https://github.com/AmadeusITGroup/otter/blob/main/packages/@o3r/forms/src/core/custom-validation.ts) is containing two entries, one for global (root) form validation and one for the other fields. -_Fields_ entry is receiving the form contract as generic type. +### Custom Validators -```typescript -/** Custom validation for the form */ -export interface CustomFormValidation { - /** Validation for each field */ - fields?: CustomFieldsValidation; - /** Global validation for the form */ - global?: CustomValidationFn; -} -``` +Since the built-in validators won't always match the exact use case of your application, sometimes you'll want to create a custom validator. +(See [Custom Validators](https://angular.io/guide/form-validation#custom-validators) in Angular). - +Our custom validators are usually related to the business logic, or they are applied to multiple fields/form controls. +Since they are related to the business logic, we will create them in the __parent component__ and pass them to the input component via an input. The input component is the one that applies them to the form. -##### Apply validators +#### Define custom validators -The validators are applied to the form on the __presenter__ class. +The validation function can be defined anywhere, but it has to be added to the validators object in the parent component. -```typescript - /** Custom validators applied on the form */ - @Input() customValidators?: CustomFormValidation; // ---> receives the Traveler contract - private customValidators$ = new BehaviorSubject(undefined); - - ngOnInit() { - ... - this.subscriptions.push( - combineLatest([this.config$, customValidators$]).subscribe(([config, customValidators]) => { - const firstNameValidators = []; - if (config.firstNameMaxLength) { // Primivite validator - // Apply validator based on config - firstNameValidators.push(Validators.maxLength(this.config.firstNameMaxLength)); - } - // Apply custom validation - if (customValidators && customValidators.fields && customValidators.fields.firstName) { - firstNameValidators.push(customValidators.fields.firstName); - } - // firstNameValidators.push(otherValidators) - if (firstNameValidators.length) { - this.travelerForm.controls.firstName.clearValidators(); - this.travelerForm.controls.firstName.setValidators(firstNameValidators) - } - }) - ); - } +* __Validation function__: - ngOnChanges(changes: SimpleChanges) { - if (changes.customValidators) { - this.customValidators$.next(this.customValidators); - } - } -``` +The object returned by the custom validator will be of type `ErrorMessageObject` compatible with the form error store. (See [Form Errors](./FORM_ERRORS.md)). +The key `customErrors` of this object is used to identify the custom errors in the errors returned by a form control. - +You can find an [example](https://github.com/AmadeusITGroup/otter/tree/main/apps/showcase/src/components/showcase/forms/forms-pres.validators.ts) of two custom validators in the forms example of the showcase application. -##### Validators translations +* __Parent component__: -For each custom validator we need a corresponding translation key for the error message. -As they are defined in the container, the keys have to be defined in the corresponding __localization.json__ file of the __container__. -In this way the container knows about its own validations/error messages. See [FORM_ERRORS](./FORM_ERRORS.md) _Errors translation_ section for more details. +The validators object in the parent component is of type [__CustomFormValidation__](https://github.com/AmadeusITGroup/otter/blob/main/packages/@o3r/forms/src/core/custom-validation.ts). +This interface contains two entries: one for global (root) form validation and one for the other fields. +The `fields` entry is receiving the form contract as generic type. - +The implementation of the two custom validators in the validators object of the parent component can be found in the [forms component of the showcase application](https://github.com/AmadeusITGroup/otter/tree/main/apps/showcase/src/components/showcase/forms/forms-pres.component.ts). -##### Custom validation contracts available in @o3r/forms +#### Apply custom validators -We have put in place a set of interfaces which will help us to define the custom validators and to keep the same structure in the framework. +The validators are applied to the form in the __input component__ class. For example: ```typescript -/** - * The return of a custom validation - */ -export interface CustomErrors { - /** The custom errors coming from a validation fn */ - customErrors: ErrorMessageObject[]; +/** Custom validators applied on the form */ +@Input() customValidators?: CustomFormValidation; // ---> receives the PersonalInfo contract + +public ngOnInit() { + /** Get custom validators and apply them on the form */ + const nameValidators = []; + if (this.customValidators && this.customValidators.fields && this.customValidators.fields.name) { + nameValidators.push(this.customValidators.fields.name); + } + ... + this.form.controls.name.setValidators(nameValidators); + this.form.updateValueAndValidity(); + ... } +``` -/** Custom validation function */ -export type CustomValidationFn = (control: AbstractControl) => CustomErrors | null; +#### Custom validators translations -/** Custom validation functions for each field of T model */ -export type CustomFieldsValidation = { [K in keyof T]?: CustomValidationFn }; +For each custom validator, we need a corresponding translation key for the error message. +Since they are defined in the parent component, the keys have to be defined in the corresponding `localization.json` file of the __parent component__. +This way the parent component knows about its own validations/error messages. +See the _Errors translation_ section in [Form Errors](./FORM_ERRORS.md) for more details. -/** Custom validation for the form */ -export interface CustomFormValidation { - /** Validation for each field */ - fields?: CustomFieldsValidation; - /** Global validation for the form */ - global?: CustomValidationFn; -} -``` +#### Custom validation contracts - +We have put in place a set of interfaces that will help us to define the custom validators and to keep the same structure in the framework. +You can find them in the [@o3r/forms package](https://github.com/AmadeusITGroup/otter/blob/main/packages/@o3r/forms/src/core/custom-validation.ts). ## Async Validators -When you need an asynchronous validator for your form, you have to make sure that the presenter will implement [AsyncValidator](https://angular.io/api/forms/NG_ASYNC_VALIDATORS) interface. -Here you will have also to implement __the validate__ method to define the error object structure. The error object has to be returned in a Promise or in an Observable which has to be completed. -The only difference from sync validators is the returned object of the __validate__ method. -Also, you have to provide the [NG_ASYNC_VALIDATORS](https://angular.io/api/forms/NG_ASYNC_VALIDATORS) token for the presenter. +When you need an asynchronous validator for your form, you have to make sure that the input component will implement the [AsyncValidator](https://angular.io/api/forms/NG_ASYNC_VALIDATORS) interface. +Here you will also have to implement the `validate` function to define the error object structure. The error object has to be returned in a Promise or in an Observable that must be completed. +The only difference from sync validators is the object returned by the `validate` function. +Also, you have to provide the [NG_ASYNC_VALIDATORS](https://angular.io/api/forms/NG_ASYNC_VALIDATORS) token for the input component. -For more details about the implementation have a look at [Async Validation in angular](https://angular.io/guide/form-validation#async-validation). +For more details about the implementation, have a look at [Async Validation in Angular](https://angular.io/guide/form-validation#async-validation). -The example below contains the two mandatory things to do when you need an async validator: provide _NG_ASYNC_VALIDATORS_ token and implement _validate_ method. +The example below contains the two mandatory tasks to do when you need an async validator: provide the `NG_ASYNC_VALIDATORS` token and implement the `validate` function. ```typescript @Component({ - selector: 'o3r-forms-poc-pres', - styleUrls: ['./forms-poc-pres.style.scss'], - templateUrl: './forms-poc-pres.template.html', + selector: 'o3r-forms-example-pres', + styleUrls: ['./forms-example-pres.style.scss'], + templateUrl: './forms-example-pres.template.html', changeDetection: ChangeDetectionStrategy.OnPush, providers: [ { provide: NG_VALUE_ACCESSOR, - useExisting: forwardRef(() => FormsPocPresComponent), + useExisting: forwardRef(() => FormsExamplePresComponent), multi: true }, { provide: NG_ASYNC_VALIDATORS, - useExisting: forwardRef(() => FormsPocPresComponent), + useExisting: forwardRef(() => FormsExamplePresComponent), multi: true } ] }) -export class FormsPocPresComponent implements OnInit, OnDestroy, Configurable, AsyncValidator, Translatable, FormsPocPresContext, ControlValueAccessor { -// ... - /** - * Return the errors for the validators applied global to the form plus the errors for each field - */ +export class FormsExamplePresComponent implements OnInit, AsyncValidator, ControlValueAccessor, ... { + // ... + /** Return the errors for the validators applied global to the form plus the errors for each field */ // ---> The implementation of this method is specific to each use case, the important thing is that it has to return a promise or an observable public validate(_control: AbstractControl): Observable | Promise { - return this.travelerForm.statusChanges.pipe( + return this.exampleFormControl.statusChanges.pipe( filter((status) => status !== 'PENDING'), map((status) => { if (status === 'INVALID') { - const allControls = Object.keys(this.travelerForm.controls); + const allControls = Object.keys(this.form.controls); return allControls.reduce( (currentError, controlName) => { ... @@ -322,5 +187,3 @@ export class FormsPocPresComponent implements OnInit, OnDestroy, Configurable