From 1d9d02696eadbee2c2f719e432efca22f1e494e9 Mon Sep 17 00:00:00 2001 From: Dylan Hunn Date: Mon, 12 Jul 2021 10:41:02 -0700 Subject: [PATCH] feat(forms): add hasValidators, addValidators, and removeValidators methods (for both sync and async) (#42838) Several new functionalities are possible with this change: the most requested is that callers can now check whether a control has a required validator. Other uses include incrementally changing the validators set without doing an expensive operation to reset all validators. Closes #13461. PR Close #42838 --- goldens/public-api/forms/forms.md | 10 +- .../size-tracking/integration-payloads.json | 2 +- .../forms_reactive/bundle.golden_symbols.json | 12 ++ .../bundle.golden_symbols.json | 12 ++ packages/forms/src/model.ts | 122 +++++++++++++++--- packages/forms/src/validators.ts | 53 ++++++++ packages/forms/test/form_builder_spec.ts | 11 ++ packages/forms/test/form_control_spec.ts | 106 +++++++++++++++ 8 files changed, 309 insertions(+), 19 deletions(-) diff --git a/goldens/public-api/forms/forms.md b/goldens/public-api/forms/forms.md index d9d90bcbb9..332b1ddc0e 100644 --- a/goldens/public-api/forms/forms.md +++ b/goldens/public-api/forms/forms.md @@ -21,6 +21,8 @@ import { Version } from '@angular/core'; // @public export abstract class AbstractControl { constructor(validators: ValidatorFn | ValidatorFn[] | null, asyncValidators: AsyncValidatorFn | AsyncValidatorFn[] | null); + addAsyncValidators(validators: AsyncValidatorFn | AsyncValidatorFn[]): void; + addValidators(validators: ValidatorFn | ValidatorFn[]): void; get asyncValidator(): AsyncValidatorFn | null; set asyncValidator(asyncValidatorFn: AsyncValidatorFn | null); clearAsyncValidators(): void; @@ -39,7 +41,9 @@ export abstract class AbstractControl { readonly errors: ValidationErrors | null; get(path: Array | string): AbstractControl | null; getError(errorCode: string, path?: Array | string): any; + hasAsyncValidator(validator: AsyncValidatorFn): boolean; hasError(errorCode: string, path?: Array | string): boolean; + hasValidator(validator: ValidatorFn): boolean; get invalid(): boolean; markAllAsTouched(): void; markAsDirty(opts?: { @@ -62,15 +66,17 @@ export abstract class AbstractControl { abstract patchValue(value: any, options?: Object): void; get pending(): boolean; readonly pristine: boolean; + removeAsyncValidators(validators: AsyncValidatorFn | AsyncValidatorFn[]): void; + removeValidators(validators: ValidatorFn | ValidatorFn[]): void; abstract reset(value?: any, options?: Object): void; get root(): AbstractControl; - setAsyncValidators(newValidator: AsyncValidatorFn | AsyncValidatorFn[] | null): void; + setAsyncValidators(validators: AsyncValidatorFn | AsyncValidatorFn[] | null): void; setErrors(errors: ValidationErrors | null, opts?: { emitEvent?: boolean; }): void; // (undocumented) setParent(parent: FormGroup | FormArray): void; - setValidators(newValidator: ValidatorFn | ValidatorFn[] | null): void; + setValidators(validators: ValidatorFn | ValidatorFn[] | null): void; abstract setValue(value: any, options?: Object): void; readonly status: string; readonly statusChanges: Observable; diff --git a/goldens/size-tracking/integration-payloads.json b/goldens/size-tracking/integration-payloads.json index 6fccdecb4b..b5c279699f 100644 --- a/goldens/size-tracking/integration-payloads.json +++ b/goldens/size-tracking/integration-payloads.json @@ -49,7 +49,7 @@ "master": { "uncompressed": { "runtime-es2015": 1150, - "main-es2015": 162346, + "main-es2015": 163007, "polyfills-es2015": 36975 } } diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index 7c2107d7b8..1358a6e54f 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -665,6 +665,9 @@ { "name": "addToViewTree" }, + { + "name": "addValidators" + }, { "name": "allocExpando" }, @@ -1082,6 +1085,9 @@ { "name": "hasValidLength" }, + { + "name": "hasValidator" + }, { "name": "hostReportError" }, @@ -1271,6 +1277,9 @@ { "name": "makeRecord" }, + { + "name": "makeValidatorsArray" + }, { "name": "map" }, @@ -1430,6 +1439,9 @@ { "name": "removeStyle" }, + { + "name": "removeValidators" + }, { "name": "renderComponent" }, diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index 19460caab7..5c1cb3f1eb 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -653,6 +653,9 @@ { "name": "addToViewTree" }, + { + "name": "addValidators" + }, { "name": "allocExpando" }, @@ -1046,6 +1049,9 @@ { "name": "hasTagAndTypeMatch" }, + { + "name": "hasValidator" + }, { "name": "hostReportError" }, @@ -1232,6 +1238,9 @@ { "name": "makeRecord" }, + { + "name": "makeValidatorsArray" + }, { "name": "map" }, @@ -1394,6 +1403,9 @@ { "name": "removeStyle" }, + { + "name": "removeValidators" + }, { "name": "renderComponent" }, diff --git a/packages/forms/src/model.ts b/packages/forms/src/model.ts index 039d66d9b7..ec29843288 100644 --- a/packages/forms/src/model.ts +++ b/packages/forms/src/model.ts @@ -11,7 +11,7 @@ import {Observable} from 'rxjs'; import {removeListItem} from './directives/shared'; import {AsyncValidatorFn, ValidationErrors, ValidatorFn} from './directives/validators'; -import {composeAsyncValidators, composeValidators, toObservable} from './validators'; +import {addValidators, composeAsyncValidators, composeValidators, hasValidator, makeValidatorsArray, removeValidators, toObservable} from './validators'; /** * Reports that a FormControl is valid, meaning that no errors exist in the input value. @@ -129,14 +129,12 @@ export interface AbstractControlOptions { updateOn?: 'change'|'blur'|'submit'; } - function isOptionsObj(validatorOrOpts?: ValidatorFn|ValidatorFn[]|AbstractControlOptions| null): validatorOrOpts is AbstractControlOptions { return validatorOrOpts != null && !Array.isArray(validatorOrOpts) && typeof validatorOrOpts === 'object'; } - /** * This is the base class for `FormControl`, `FormGroup`, and `FormArray`. * @@ -245,7 +243,9 @@ export abstract class AbstractControl { } /** - * The function that is used to determine the validity of this control synchronously. + * Returns the function that is used to determine the validity of this control synchronously. + * If multiple validators have been added, this will be a single composed function. + * See `Validators.compose()` for additional information. */ get validator(): ValidatorFn|null { return this._composedValidatorFn; @@ -255,7 +255,9 @@ export abstract class AbstractControl { } /** - * The function that is used to determine the validity of this control asynchronously. + * Returns the function that is used to determine the validity of this control asynchronously. + * If multiple validators have been added, this will be a single composed function. + * See `Validators.compose()` for additional information. */ get asyncValidator(): AsyncValidatorFn|null { return this._composedAsyncValidatorFn; @@ -425,32 +427,120 @@ export abstract class AbstractControl { /** * Sets the synchronous validators that are active on this control. Calling - * this overwrites any existing sync validators. + * this overwrites any existing synchronous validators. * * When you add or remove a validator at run time, you must call * `updateValueAndValidity()` for the new validation to take effect. * + * If you want to add a new validator without affecting existing ones, consider + * using `addValidators()` method instead. */ - setValidators(newValidator: ValidatorFn|ValidatorFn[]|null): void { - this._rawValidators = newValidator; - this._composedValidatorFn = coerceToValidator(newValidator); + setValidators(validators: ValidatorFn|ValidatorFn[]|null): void { + this._rawValidators = validators; + this._composedValidatorFn = coerceToValidator(validators); } /** - * Sets the async validators that are active on this control. Calling this - * overwrites any existing async validators. + * Sets the asynchronous validators that are active on this control. Calling this + * overwrites any existing asynchronous validators. * * When you add or remove a validator at run time, you must call * `updateValueAndValidity()` for the new validation to take effect. * + * If you want to add a new validator without affecting existing ones, consider + * using `addAsyncValidators()` method instead. */ - setAsyncValidators(newValidator: AsyncValidatorFn|AsyncValidatorFn[]|null): void { - this._rawAsyncValidators = newValidator; - this._composedAsyncValidatorFn = coerceToAsyncValidator(newValidator); + setAsyncValidators(validators: AsyncValidatorFn|AsyncValidatorFn[]|null): void { + this._rawAsyncValidators = validators; + this._composedAsyncValidatorFn = coerceToAsyncValidator(validators); } /** - * Empties out the sync validator list. + * Add a synchronous validator or validators to this control, without affecting other validators. + * + * When you add or remove a validator at run time, you must call + * `updateValueAndValidity()` for the new validation to take effect. + * + * Adding a validator that already exists will have no effect. If duplicate validator functions + * are present in the `validators` array, only the first instance would be added to a form + * control. + * + * @param validators The new validator function or functions to add to this control. + */ + addValidators(validators: ValidatorFn|ValidatorFn[]): void { + this.setValidators(addValidators(validators, this._rawValidators)); + } + + /** + * Add an asynchronous validator or validators to this control, without affecting other + * validators. + * + * When you add or remove a validator at run time, you must call + * `updateValueAndValidity()` for the new validation to take effect. + * + * Adding a validator that already exists will have no effect. + * + * @param validators The new asynchronous validator function or functions to add to this control. + */ + addAsyncValidators(validators: AsyncValidatorFn|AsyncValidatorFn[]): void { + this.setAsyncValidators(addValidators(validators, this._rawAsyncValidators)); + } + + /** + * Remove a synchronous validator from this control, without affecting other validators. + * Validators are compared by function reference; you must pass a reference to the exact same + * validator function as the one that was originally set. If a provided validator is not found, + * it is ignored. + * + * When you add or remove a validator at run time, you must call + * `updateValueAndValidity()` for the new validation to take effect. + * + * @param validators The validator or validators to remove. + */ + removeValidators(validators: ValidatorFn|ValidatorFn[]): void { + this.setValidators(removeValidators(validators, this._rawValidators)); + } + + /** + * Remove an asynchronous validator from this control, without affecting other validators. + * Validators are compared by function reference; you must pass a reference to the exact same + * validator function as the one that was originally set. If a provided validator is not found, it + * is ignored. + * + * When you add or remove a validator at run time, you must call + * `updateValueAndValidity()` for the new validation to take effect. + * + * @param validators The asynchronous validator or validators to remove. + */ + removeAsyncValidators(validators: AsyncValidatorFn|AsyncValidatorFn[]): void { + this.setAsyncValidators(removeValidators(validators, this._rawAsyncValidators)); + } + + /** + * Check whether a synchronous validator function is present on this control. The provided + * validator must be a reference to the exact same function that was provided. + * + * @param validator The validator to check for presence. Compared by function reference. + * @returns Whether the provided validator was found on this control. + */ + hasValidator(validator: ValidatorFn): boolean { + return hasValidator(this._rawValidators, validator); + } + + /** + * Check whether an asynchronous validator function is present on this control. The provided + * validator must be a reference to the exact same function that was provided. + * + * @param validator The asynchronous validator to check for presence. Compared by function + * reference. + * @returns Whether the provided asynchronous validator was found on this control. + */ + hasAsyncValidator(validator: AsyncValidatorFn): boolean { + return hasValidator(this._rawAsyncValidators, validator); + } + + /** + * Empties out the synchronous validator list. * * When you add or remove a validator at run time, you must call * `updateValueAndValidity()` for the new validation to take effect. @@ -1065,7 +1155,7 @@ export abstract class AbstractControl { * console.log(control.status); // 'DISABLED' * ``` * - * The following example initializes the control with a sync validator. + * The following example initializes the control with a synchronous validator. * * ```ts * const control = new FormControl('', Validators.required); diff --git a/packages/forms/src/validators.ts b/packages/forms/src/validators.ts index 009dbde39e..99200c8bca 100644 --- a/packages/forms/src/validators.ts +++ b/packages/forms/src/validators.ts @@ -663,3 +663,56 @@ export function getControlAsyncValidators(control: AbstractControl): AsyncValida AsyncValidatorFn[]|null { return (control as any)._rawAsyncValidators as AsyncValidatorFn | AsyncValidatorFn[] | null; } + +/** + * Accepts a singleton validator, an array, or null, and returns an array type with the provided + * validators. + * + * @param validators A validator, validators, or null. + * @returns A validators array. + */ +export function makeValidatorsArray(validators: T|T[]| + null): T[] { + if (!validators) return []; + return Array.isArray(validators) ? validators : [validators]; +} + +/** + * Determines whether a validator or validators array has a given validator. + * + * @param validators The validator or validators to compare against. + * @param validator The validator to check. + * @returns Whether the validator is present. + */ +export function hasValidator( + validators: T|T[]|null, validator: T): boolean { + return Array.isArray(validators) ? validators.includes(validator) : validators === validator; +} + +/** + * Combines two arrays of validators into one. If duplicates are provided, only one will be added. + * + * @param validators The new validators. + * @param currentValidators The base array of currrent validators. + * @returns An array of validators. + */ +export function addValidators( + validators: T|T[], currentValidators: T|T[]|null): T[] { + const current = makeValidatorsArray(currentValidators); + const validatorsToAdd = makeValidatorsArray(validators); + validatorsToAdd.forEach((v: T) => { + // Note: if there are duplicate entries in the new validators array, + // only the first one would be added to the current list of validarors. + // Duplicate ones would be ignored since `hasValidator` would detect + // the presence of a validator function and we update the current list in place. + if (!hasValidator(current, v)) { + current.push(v); + } + }); + return current; +} + +export function removeValidators( + validators: T|T[], currentValidators: T|T[]|null): T[] { + return makeValidatorsArray(currentValidators).filter(v => !hasValidator(validators, v)); +} diff --git a/packages/forms/test/form_builder_spec.ts b/packages/forms/test/form_builder_spec.ts index 0e0e100ab9..8fa6326e89 100644 --- a/packages/forms/test/form_builder_spec.ts +++ b/packages/forms/test/form_builder_spec.ts @@ -69,6 +69,17 @@ describe('Form Builder', () => { expect(g.controls['login'].asyncValidator).toBe(asyncValidator); }); + it('should support controls with validators that are later modified', () => { + const g = b.group({'login': b.control(null, syncValidator, asyncValidator)}); + expect(g.controls['login'].value).toBeNull(); + expect(g.controls['login'].validator).toBe(syncValidator); + expect(g.controls['login'].asyncValidator).toBe(asyncValidator); + g.controls['login'].addValidators(Validators.required); + expect(g.controls['login'].hasValidator(Validators.required)).toBe(true); + g.controls['login'].removeValidators(Validators.required); + expect(g.controls['login'].hasValidator(Validators.required)).toBe(false); + }); + it('should support controls with no validators and whose form state is undefined', () => { const g = b.group({'login': b.control(undefined)}); expect(g.controls['login'].value).toBeNull(); diff --git a/packages/forms/test/form_control_spec.ts b/packages/forms/test/form_control_spec.ts index 3584e963f1..0c8900a4d0 100644 --- a/packages/forms/test/form_control_spec.ts +++ b/packages/forms/test/form_control_spec.ts @@ -312,6 +312,57 @@ describe('FormControl', () => { c.setValidators([Validators.required]); expect(c.validator).not.toBe(null); }); + + it('should check presence of and remove a validator set in the control constructor', () => { + const c = new FormControl('', Validators.required); + expect(c.hasValidator(Validators.required)).toEqual(true); + + c.removeValidators(Validators.required); + expect(c.hasValidator(Validators.required)).toEqual(false); + + c.addValidators(Validators.required); + expect(c.hasValidator(Validators.required)).toEqual(true); + }); + + it('should check presence of and remove a validator set with addValidators', () => { + const c = new FormControl(''); + expect(c.hasValidator(Validators.required)).toEqual(false); + c.addValidators(Validators.required); + expect(c.hasValidator(Validators.required)).toEqual(true); + + c.removeValidators(Validators.required); + expect(c.hasValidator(Validators.required)).toEqual(false); + }); + + it('should check presence of and remove multiple validators set at the same time', () => { + const c = new FormControl('3'); + const minValidator = Validators.min(4); + c.addValidators([Validators.required, minValidator]); + expect(c.hasValidator(Validators.required)).toEqual(true); + expect(c.hasValidator(minValidator)).toEqual(true); + + c.removeValidators([Validators.required, minValidator]); + expect(c.hasValidator(Validators.required)).toEqual(false); + expect(c.hasValidator(minValidator)).toEqual(false); + }); + + it('should be able to remove a validator added multiple times', () => { + const c = new FormControl('', Validators.required); + c.addValidators(Validators.required); + c.addValidators(Validators.required); + expect(c.hasValidator(Validators.required)).toEqual(true); + + c.removeValidators(Validators.required); + expect(c.hasValidator(Validators.required)).toEqual(false); + }); + + it('should return false when checking presence of a validator not identical by reference', + () => { + const minValidator = Validators.min(5); + const c = new FormControl('1', minValidator); + expect(c.hasValidator(minValidator)).toEqual(true); + expect(c.hasValidator(Validators.min(5))).toEqual(false); + }); }); describe('asyncValidator', () => { @@ -508,6 +559,61 @@ describe('FormControl', () => { tick(); expect(c.status).toEqual('DISABLED'); })); + + it('should check presence of and remove a validator set in the control constructor', () => { + const asyncVal = asyncValidator('expected'); + const c = new FormControl('', null, asyncVal); + expect(c.hasAsyncValidator(asyncVal)).toEqual(true); + + c.removeAsyncValidators(asyncVal); + expect(c.hasAsyncValidator(asyncVal)).toEqual(false); + + c.addAsyncValidators(asyncVal); + expect(c.hasAsyncValidator(asyncVal)).toEqual(true); + }); + + it('should check presence of and remove a validator set with addValidators', () => { + const c = new FormControl(''); + const asyncVal = asyncValidator('expected'); + c.addAsyncValidators(asyncVal); + expect(c.hasAsyncValidator(asyncVal)).toEqual(true); + + c.removeAsyncValidators(asyncVal); + expect(c.hasAsyncValidator(asyncVal)).toEqual(false); + }); + + it('should check presence of and remove multiple validators set at the same time', () => { + const c = new FormControl('3'); + const asyncVal1 = asyncValidator('expected1'); + const asyncVal2 = asyncValidator('expected2'); + c.addAsyncValidators([asyncVal1, asyncVal2]); + expect(c.hasAsyncValidator(asyncVal1)).toEqual(true); + expect(c.hasAsyncValidator(asyncVal2)).toEqual(true); + + c.removeAsyncValidators([asyncVal1, asyncVal2]); + expect(c.hasAsyncValidator(asyncVal1)).toEqual(false); + expect(c.hasAsyncValidator(asyncVal2)).toEqual(false); + }); + + it('should be able to remove a validator added multiple times', () => { + const asyncVal = asyncValidator('expected'); + const c = new FormControl('', null, asyncVal); + c.addAsyncValidators(asyncVal); + c.addAsyncValidators(asyncVal); + expect(c.hasAsyncValidator(asyncVal)).toEqual(true); + + c.removeAsyncValidators(asyncVal); + expect(c.hasAsyncValidator(asyncVal)).toEqual(false); + }); + + it('should return false when checking presence of a validator not identical by reference', + () => { + const asyncVal = asyncValidator('expected'); + const asyncValDifferentFn = asyncValidator('expected'); + const c = new FormControl('1', null, asyncVal); + expect(c.hasAsyncValidator(asyncVal)).toEqual(true); + expect(c.hasAsyncValidator(asyncValDifferentFn)).toEqual(false); + }); }); describe('dirty', () => {