From fa497bff44d1c70c831a44bd4731037aa72240af Mon Sep 17 00:00:00 2001 From: Maxime ROBERT Date: Thu, 29 Aug 2019 06:58:13 +0100 Subject: [PATCH] feat(lib): formGroup validator `oneOf` This closes cloudnc/ngx-sub-form#91 --- README.md | 3 +- .../src/lib/ngx-sub-form-utils.ts | 13 ++ .../src/lib/ngx-sub-form.component.spec.ts | 122 +++++++++++++++++- .../src/lib/ngx-sub-form.component.ts | 67 +++++++++- src/app/app.spec.e2e.ts | 15 +++ .../droid-listing/droid-product.component.ts | 10 +- .../listing-form/listing-form.component.ts | 7 + .../vehicle-product.component.ts | 8 +- src/readme/password-sub-form.component.ts | 2 +- 9 files changed, 241 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 3986a8ed..826f74f3 100644 --- a/README.md +++ b/README.md @@ -521,6 +521,7 @@ export class CrewMemberComponent extends NgxSubFormComponent { - `emitNullOnDestroy`: By default is set to `true` for `NgxSubFormComponent`, `NgxSubFormRemapComponent` and to `false` for `NgxRootFormComponent` and `NgxAutomaticRootFormComponent`. When set to `true`, if the sub form component is being destroyed, it will emit one last value: `null`. It might be useful to set it to `false` for e.g. when you've got a form accross multiple tabs and once a part of the form is filled you want to destroy it - `emitInitialValueOnInit`: By default is set to `true` for `NgxSubFormComponent`, `NgxSubFormRemapComponent` and to `false` for `NgxRootFormComponent` and `NgxAutomaticRootFormComponent`. When set to `true`, the sub form component will emit the first value straight away (default one unless the component above as a value already set on the `formControl`) +- `ngxSubFormValidators`: An object containing validator methods. Currently, only`oneOf`is available. It lets you specify on a`formGroup`using`getFormGroupControlOptions` that amongst some properties, exactly one should be defined (nothing more, nothing less) **Hooks** @@ -553,7 +554,7 @@ class PasswordSubFormComponent extends NgxSubFormComponent { }; } - public getFormGroupControlOptions(): FormGroupOptions { + protected getFormGroupControlOptions(): FormGroupOptions { return { validators: [ formGroup => { diff --git a/projects/ngx-sub-form/src/lib/ngx-sub-form-utils.ts b/projects/ngx-sub-form/src/lib/ngx-sub-form-utils.ts index 7995c58c..c5c118af 100644 --- a/projects/ngx-sub-form/src/lib/ngx-sub-form-utils.ts +++ b/projects/ngx-sub-form/src/lib/ngx-sub-form-utils.ts @@ -75,6 +75,18 @@ export class MissingFormControlsError extends Error { } } +export class OneOfValidatorRequiresMoreThanOneFieldError extends Error { + constructor() { + super(`"oneOf" validator requires to have at least 2 keys`); + } +} + +export class OneOfValidatorUnknownFieldError extends Error { + constructor(field: string) { + super(`"oneOf" validator requires to keys from the FormInterface and "${field}" is not`); + } +} + export const NGX_SUB_FORM_HANDLE_VALUE_CHANGES_RATE_STRATEGIES = { debounce: (time: number): ReturnType['handleEmissionRate']> => obs => obs.pipe(debounce(() => timer(time))), @@ -85,6 +97,7 @@ export const NGX_SUB_FORM_HANDLE_VALUE_CHANGES_RATE_STRATEGIES = { * If the component already has a `ngOnDestroy` method defined, it will call this first. * Note that the component *must* implement OnDestroy for this to work (the typings will enforce this anyway) */ +/** @internal */ export function takeUntilDestroyed(component: OnDestroy): (source: Observable) => Observable { return (source: Observable): Observable => { const onDestroy = new Subject(); diff --git a/projects/ngx-sub-form/src/lib/ngx-sub-form.component.spec.ts b/projects/ngx-sub-form/src/lib/ngx-sub-form.component.spec.ts index 32acd300..e7559851 100644 --- a/projects/ngx-sub-form/src/lib/ngx-sub-form.component.spec.ts +++ b/projects/ngx-sub-form/src/lib/ngx-sub-form.component.spec.ts @@ -1,6 +1,6 @@ /// -import { FormControl, Validators, FormArray } from '@angular/forms'; +import { FormControl, Validators, FormArray, ValidatorFn } from '@angular/forms'; import { FormGroupOptions, NgxSubFormComponent, @@ -11,6 +11,8 @@ import { ArrayPropertyKey, ArrayPropertyValue, NgxFormWithArrayControls, + OneOfValidatorRequiresMoreThanOneFieldError, + OneOfValidatorUnknownFieldError, } from '../public_api'; import { Observable } from 'rxjs'; @@ -442,10 +444,37 @@ describe(`NgxSubFormComponent`, () => { } } + interface DroidForm { + assassinDroid: { type: 'Assassin' }; + medicalDroid: { type: 'Medical' }; + } + + class DroidFormComponent extends NgxSubFormComponent { + protected getFormControls() { + return { + assassinDroid: new FormControl(null), + medicalDroid: new FormControl(null), + }; + } + + public getFormGroupControlOptions(): FormGroupOptions { + return { + validators: [this.ngxSubFormValidators.oneOf([['assassinDroid', 'medicalDroid']])], + }; + } + + // testing utility + public setValidatorOneOf(keysArray: (keyof DroidForm)[][]): void { + this.formGroup.setValidators([(this.ngxSubFormValidators.oneOf(keysArray) as unknown) as ValidatorFn]); + } + } + let validatedSubComponent: ValidatedSubComponent; + let droidFormComponent: DroidFormComponent; beforeEach((done: () => void) => { validatedSubComponent = new ValidatedSubComponent(); + droidFormComponent = new DroidFormComponent(); // we have to call `updateValueAndValidity` within the constructor in an async way // and here we need to wait for it to run @@ -473,6 +502,97 @@ describe(`NgxSubFormComponent`, () => { }, 0); }, 0); }); + + describe('ngxSubFormValidators', () => { + it('oneOf should throw an error if no value or only one in the array', () => { + expect(() => droidFormComponent.setValidatorOneOf(undefined as any)).toThrow( + new OneOfValidatorRequiresMoreThanOneFieldError(), + ); + + expect(() => droidFormComponent.setValidatorOneOf([])).toThrow( + new OneOfValidatorRequiresMoreThanOneFieldError(), + ); + + expect(() => droidFormComponent.setValidatorOneOf([[]])).toThrow( + new OneOfValidatorRequiresMoreThanOneFieldError(), + ); + + expect(() => droidFormComponent.setValidatorOneOf([['assassinDroid']])).toThrow( + new OneOfValidatorRequiresMoreThanOneFieldError(), + ); + + expect(() => droidFormComponent.setValidatorOneOf([['assassinDroid', 'medicalDroid']])).not.toThrow(); + }); + + it('oneOf should throw an error if there is an unknown key', () => { + droidFormComponent.setValidatorOneOf([['unknown 1' as any, 'unknown 2' as any]]); + expect(() => droidFormComponent.formGroup.updateValueAndValidity()).toThrow( + new OneOfValidatorUnknownFieldError('unknown 1'), + ); + + droidFormComponent.setValidatorOneOf([['assassinDroid', 'unknown 2' as any]]); + expect(() => droidFormComponent.formGroup.updateValueAndValidity()).toThrow( + new OneOfValidatorUnknownFieldError('unknown 2'), + ); + }); + + it('oneOf should return an object (representing the error) if all the values are null', (done: () => void) => { + const spyOnChange = jasmine.createSpy(); + droidFormComponent.registerOnChange(spyOnChange); + + droidFormComponent.formGroup.patchValue({ assassinDroid: null, medicalDroid: null }); + + setTimeout(() => { + expect(droidFormComponent.validate()).toEqual({ formGroup: { oneOf: [['assassinDroid', 'medicalDroid']] } }); + expect(droidFormComponent.formGroupErrors).toEqual({ + formGroup: { oneOf: [['assassinDroid', 'medicalDroid']] }, + }); + + droidFormComponent.formGroup.patchValue({ + assassinDroid: { type: 'Assassin' }, + medicalDroid: null, + }); + setTimeout(() => { + expect(droidFormComponent.validate()).toEqual(null); + expect(droidFormComponent.formGroupErrors).toEqual(null); + done(); + }, 0); + }, 0); + }); + + it('oneOf should return an object (error) if more than one value are not [null or undefined]', (done: () => void) => { + const spyOnChange = jasmine.createSpy(); + droidFormComponent.registerOnChange(spyOnChange); + + droidFormComponent.formGroup.patchValue({ assassinDroid: null, medicalDroid: null }); + + setTimeout(() => { + expect(droidFormComponent.validate()).toEqual({ formGroup: { oneOf: [['assassinDroid', 'medicalDroid']] } }); + + droidFormComponent.formGroup.patchValue({ + assassinDroid: { type: 'Assassin' }, + medicalDroid: { type: 'Medical' }, + }); + + setTimeout(() => { + expect(droidFormComponent.validate()).toEqual({ + formGroup: { oneOf: [['assassinDroid', 'medicalDroid']] }, + }); + + droidFormComponent.formGroup.patchValue({ + assassinDroid: null, + medicalDroid: { type: 'Medical' }, + }); + + setTimeout(() => { + expect(droidFormComponent.validate()).toEqual(null); + + done(); + }, 0); + }, 0); + }, 0); + }); + }); }); }); diff --git a/projects/ngx-sub-form/src/lib/ngx-sub-form.component.ts b/projects/ngx-sub-form/src/lib/ngx-sub-form.component.ts index ea082dda..6e9710b3 100644 --- a/projects/ngx-sub-form/src/lib/ngx-sub-form.component.ts +++ b/projects/ngx-sub-form/src/lib/ngx-sub-form.component.ts @@ -21,8 +21,16 @@ import { isNullOrUndefined, ControlsType, ArrayPropertyKey, + OneOfValidatorRequiresMoreThanOneFieldError, + OneOfValidatorUnknownFieldError, } from './ngx-sub-form-utils'; -import { FormGroupOptions, NgxFormWithArrayControls, OnFormUpdate, TypedFormGroup } from './ngx-sub-form.types'; +import { + FormGroupOptions, + NgxFormWithArrayControls, + OnFormUpdate, + TypedFormGroup, + TypedValidatorFn, +} from './ngx-sub-form.types'; type MapControlFunction = (ctrl: AbstractControl, key: keyof FormInterface) => MapValue; type FilterControlFunction = (ctrl: AbstractControl, key: keyof FormInterface) => boolean; @@ -66,6 +74,63 @@ export abstract class NgxSubFormComponent { + if (!keysArray || !keysArray.length || keysArray.some(keys => !keys || keys.length < 2)) { + throw new OneOfValidatorRequiresMoreThanOneFieldError(); + } + + return (formGroup: TypedFormGroup) => { + const oneOfErrors: (keyof FormInterface)[][] = keysArray.reduce( + (acc, keys) => { + if (!keys.length) { + return acc; + } + + let nbNotNull = 0; + let cpt = 0; + + while (cpt < keys.length && nbNotNull < 2) { + const key: keyof FormInterface = keys[cpt]; + + const control: AbstractControl | null = formGroup.get(key as string); + + if (!control) { + throw new OneOfValidatorUnknownFieldError(key as string); + } + + if (!isNullOrUndefined(control.value)) { + nbNotNull++; + } + + cpt++; + } + + if (nbNotNull !== 1) { + acc.push(keys); + } + + return acc; + }, + [] as (keyof FormInterface)[][], + ); + + if (oneOfErrors.length === 0) { + return null; + } + + return { + oneOf: oneOfErrors, + }; + }; + }, + }; + // when developing the lib it's a good idea to set the formGroup type // to current + `| undefined` to catch a bunch of possible issues // see @note form-group-undefined diff --git a/src/app/app.spec.e2e.ts b/src/app/app.spec.e2e.ts index 6e6b79f2..dea5115e 100644 --- a/src/app/app.spec.e2e.ts +++ b/src/app/app.spec.e2e.ts @@ -81,6 +81,9 @@ context(`EJawa demo`, () => { DOM.createNewButton.click(); DOM.form.errors.obj.should('eql', { + formGroup: { + oneOf: [['vehicleProduct', 'droidProduct']], + }, listingType: { required: true, }, @@ -98,7 +101,13 @@ context(`EJawa demo`, () => { DOM.form.elements.selectListingTypeByType(ListingType.VEHICLE); DOM.form.errors.obj.should('eql', { + formGroup: { + oneOf: [['vehicleProduct', 'droidProduct']], + }, vehicleProduct: { + formGroup: { + oneOf: [['speeder', 'spaceship']], + }, vehicleType: { required: true, }, @@ -180,7 +189,13 @@ context(`EJawa demo`, () => { DOM.form.elements.selectListingTypeByType(ListingType.DROID); DOM.form.errors.obj.should('eql', { + formGroup: { + oneOf: [['vehicleProduct', 'droidProduct']], + }, droidProduct: { + formGroup: { + oneOf: [['assassinDroid', 'astromechDroid', 'protocolDroid', 'medicalDroid']], + }, droidType: { required: true, }, diff --git a/src/app/main/listing/listing-form/droid-listing/droid-product.component.ts b/src/app/main/listing/listing-form/droid-listing/droid-product.component.ts index fe252544..72df0a7e 100644 --- a/src/app/main/listing/listing-form/droid-listing/droid-product.component.ts +++ b/src/app/main/listing/listing-form/droid-listing/droid-product.component.ts @@ -1,6 +1,6 @@ import { Component } from '@angular/core'; import { FormControl, Validators } from '@angular/forms'; -import { Controls, NgxSubFormRemapComponent, subformComponentProviders } from 'ngx-sub-form'; +import { Controls, NgxSubFormRemapComponent, subformComponentProviders, FormGroupOptions } from 'ngx-sub-form'; import { AssassinDroid, AstromechDroid, @@ -64,4 +64,12 @@ export class DroidProductComponent extends NgxSubFormRemapComponent { + return { + validators: [ + this.ngxSubFormValidators.oneOf([['assassinDroid', 'astromechDroid', 'protocolDroid', 'medicalDroid']]), + ], + }; + } } diff --git a/src/app/main/listing/listing-form/listing-form.component.ts b/src/app/main/listing/listing-form/listing-form.component.ts index 6d1357d7..e0dba5cd 100644 --- a/src/app/main/listing/listing-form/listing-form.component.ts +++ b/src/app/main/listing/listing-form/listing-form.component.ts @@ -7,6 +7,7 @@ import { // NGX_SUB_FORM_HANDLE_VALUE_CHANGES_RATE_STRATEGIES, DataInput, NgxRootFormComponent, + FormGroupOptions, } from 'ngx-sub-form'; import { tap } from 'rxjs/operators'; import { ListingType, OneListing } from 'src/app/interfaces/listing.interface'; @@ -89,4 +90,10 @@ export class ListingFormComponent extends NgxRootFormComponent { + return { + validators: [this.ngxSubFormValidators.oneOf([['vehicleProduct', 'droidProduct']])], + }; + } } diff --git a/src/app/main/listing/listing-form/vehicle-listing/vehicle-product.component.ts b/src/app/main/listing/listing-form/vehicle-listing/vehicle-product.component.ts index 828cdf75..b033e0fb 100644 --- a/src/app/main/listing/listing-form/vehicle-listing/vehicle-product.component.ts +++ b/src/app/main/listing/listing-form/vehicle-listing/vehicle-product.component.ts @@ -1,6 +1,6 @@ import { Component } from '@angular/core'; import { FormControl, Validators } from '@angular/forms'; -import { Controls, NgxSubFormRemapComponent, subformComponentProviders } from 'ngx-sub-form'; +import { Controls, NgxSubFormRemapComponent, subformComponentProviders, FormGroupOptions } from 'ngx-sub-form'; import { OneVehicle, Spaceship, Speeder, VehicleType } from 'src/app/interfaces/vehicle.interface'; import { UnreachableCase } from 'src/app/shared/utils'; @@ -47,4 +47,10 @@ export class VehicleProductComponent extends NgxSubFormRemapComponent { + return { + validators: [this.ngxSubFormValidators.oneOf([['speeder', 'spaceship']])], + }; + } } diff --git a/src/readme/password-sub-form.component.ts b/src/readme/password-sub-form.component.ts index bc989861..d663c73d 100644 --- a/src/readme/password-sub-form.component.ts +++ b/src/readme/password-sub-form.component.ts @@ -21,7 +21,7 @@ class PasswordSubFormComponent extends NgxSubFormComponent { }; } - public getFormGroupControlOptions(): FormGroupOptions { + protected getFormGroupControlOptions(): FormGroupOptions { return { validators: [ formGroup => {