From 8fb83ea1b524663bd804908308d528c338450270 Mon Sep 17 00:00:00 2001 From: Sonu Kapoor Date: Wed, 30 Sep 2020 12:17:04 -0400 Subject: [PATCH] feat(forms): introduce min and max validators (#39063) This commit adds the missing `min` and `max` validators. BREAKING CHANGE: Previously `min` and `max` attributes defined on the `` were ignored by Forms module. Now presence of these attributes would trigger min/max validation logic (in case `formControl`, `formControlName` or `ngModel` directives are also present on a given input) and corresponding form control status would reflect that. Fixes #16352 PR Close #39063 --- goldens/public-api/forms/forms.d.ts | 10 + packages/forms/src/directives.ts | 4 +- packages/forms/src/directives/validators.ts | 183 +++++++ packages/forms/src/forms.ts | 2 +- .../forms/test/reactive_integration_spec.ts | 235 +++++++- .../forms/test/template_integration_spec.ts | 507 +++++++++++++++++- 6 files changed, 936 insertions(+), 5 deletions(-) diff --git a/goldens/public-api/forms/forms.d.ts b/goldens/public-api/forms/forms.d.ts index 251ce95472..8116d8cec0 100644 --- a/goldens/public-api/forms/forms.d.ts +++ b/goldens/public-api/forms/forms.d.ts @@ -337,6 +337,11 @@ export declare class MaxLengthValidator implements Validator, OnChanges { validate(control: AbstractControl): ValidationErrors | null; } +export declare class MaxValidator extends AbstractValidatorDirective implements OnChanges { + max: string | number; + ngOnChanges(changes: SimpleChanges): void; +} + export declare class MinLengthValidator implements Validator, OnChanges { minlength: string | number; ngOnChanges(changes: SimpleChanges): void; @@ -344,6 +349,11 @@ export declare class MinLengthValidator implements Validator, OnChanges { validate(control: AbstractControl): ValidationErrors | null; } +export declare class MinValidator extends AbstractValidatorDirective implements OnChanges { + min: string | number; + ngOnChanges(changes: SimpleChanges): void; +} + export declare const NG_ASYNC_VALIDATORS: InjectionToken<(Function | Validator)[]>; export declare const NG_VALIDATORS: InjectionToken<(Function | Validator)[]>; diff --git a/packages/forms/src/directives.ts b/packages/forms/src/directives.ts index 1ef6b4cef9..2be224939f 100644 --- a/packages/forms/src/directives.ts +++ b/packages/forms/src/directives.ts @@ -24,7 +24,7 @@ import {FormGroupDirective} from './directives/reactive_directives/form_group_di import {FormArrayName, FormGroupName} from './directives/reactive_directives/form_group_name'; import {NgSelectOption, SelectControlValueAccessor} from './directives/select_control_value_accessor'; import {NgSelectMultipleOption, SelectMultipleControlValueAccessor} from './directives/select_multiple_control_value_accessor'; -import {CheckboxRequiredValidator, EmailValidator, MaxLengthValidator, MinLengthValidator, PatternValidator, RequiredValidator} from './directives/validators'; +import {CheckboxRequiredValidator, EmailValidator, MaxLengthValidator, MaxValidator, MinLengthValidator, MinValidator, PatternValidator, RequiredValidator} from './directives/validators'; export {CheckboxControlValueAccessor} from './directives/checkbox_value_accessor'; export {ControlValueAccessor} from './directives/control_value_accessor'; @@ -63,6 +63,8 @@ export const SHARED_FORM_DIRECTIVES: Type[] = [ PatternValidator, CheckboxRequiredValidator, EmailValidator, + MinValidator, + MaxValidator, ]; export const TEMPLATE_DRIVEN_DIRECTIVES: Type[] = [NgModel, NgModelGroup, NgForm]; diff --git a/packages/forms/src/directives/validators.ts b/packages/forms/src/directives/validators.ts index b9b4040d6f..8fc41b430f 100644 --- a/packages/forms/src/directives/validators.ts +++ b/packages/forms/src/directives/validators.ts @@ -69,6 +69,189 @@ export interface Validator { registerOnValidatorChange?(fn: () => void): void; } +/** + * A base class for Validator-based Directives. The class contains common logic shared across such + * Directives. + * + * For internal use only, this class is not intended for use outside of the Forms package. + */ +@Directive() +abstract class AbstractValidatorDirective implements Validator { + private _validator: ValidatorFn = Validators.nullValidator; + private _onChange!: () => void; + + /** + * Name of an input that matches directive selector attribute (e.g. `minlength` for + * `MinLengthDirective`). An input with a given name might contain configuration information (like + * `minlength='10'`) or a flag that indicates whether validator should be enabled (like + * `[required]='false'`). + * + * @internal + */ + abstract inputName: string; + + /** + * Creates an instance of a validator (specific to a directive that extends this base class). + * + * @internal + */ + abstract createValidator(input: unknown): ValidatorFn; + + /** + * Performs the necessary input normalization based on a specific logic of a Directive. + * For example, the function might be used to convert string-based representation of the + * `minlength` input to an integer value that can later be used in the `Validators.minLength` + * validator. + * + * @internal + */ + abstract normalizeInput(input: unknown): unknown; + + /** + * Helper function invoked from child classes to process changes (from `ngOnChanges` hook). + * @nodoc + */ + handleChanges(changes: SimpleChanges): void { + if (this.inputName in changes) { + const input = this.normalizeInput(changes[this.inputName].currentValue); + this._validator = this.createValidator(input); + if (this._onChange) { + this._onChange(); + } + } + } + + /** @nodoc */ + validate(control: AbstractControl): ValidationErrors|null { + return this._validator(control); + } + + /** @nodoc */ + registerOnValidatorChange(fn: () => void): void { + this._onChange = fn; + } +} + +/** + * @description + * Provider which adds `MaxValidator` to the `NG_VALIDATORS` multi-provider list. + */ +export const MAX_VALIDATOR: StaticProvider = { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MaxValidator), + multi: true +}; + +/** + * A directive which installs the {@link MaxValidator} for any `formControlName`, + * `formControl`, or control with `ngModel` that also has a `max` attribute. + * + * @see [Form Validation](guide/form-validation) + * + * @usageNotes + * + * ### Adding a max validator + * + * The following example shows how to add a max validator to an input attached to an + * ngModel binding. + * + * ```html + * + * ``` + * + * @ngModule ReactiveFormsModule + * @ngModule FormsModule + * @publicApi + */ +@Directive({ + selector: + 'input[type=number][max][formControlName],input[type=number][max][formControl],input[type=number][max][ngModel]', + providers: [MAX_VALIDATOR], + host: {'[attr.max]': 'max ? max : null'} +}) +export class MaxValidator extends AbstractValidatorDirective implements OnChanges { + /** + * @description + * Tracks changes to the max bound to this directive. + */ + @Input() max!: string|number; + /** @internal */ + inputName = 'max'; + /** @internal */ + normalizeInput = (input: string): number => parseInt(input, 10); + /** @internal */ + createValidator = (max: number): ValidatorFn => Validators.max(max); + /** + * Declare `ngOnChanges` lifecycle hook at the main directive level (vs keeping it in base class) + * to avoid differences in handling inheritance of lifecycle hooks between Ivy and ViewEngine in + * AOT mode. This could be refactored once ViewEngine is removed. + * @nodoc + */ + ngOnChanges(changes: SimpleChanges): void { + this.handleChanges(changes); + } +} + +/** + * @description + * Provider which adds `MinValidator` to the `NG_VALIDATORS` multi-provider list. + */ +export const MIN_VALIDATOR: StaticProvider = { + provide: NG_VALIDATORS, + useExisting: forwardRef(() => MinValidator), + multi: true +}; + +/** + * A directive which installs the {@link MinValidator} for any `formControlName`, + * `formControl`, or control with `ngModel` that also has a `min` attribute. + * + * @see [Form Validation](guide/form-validation) + * + * @usageNotes + * + * ### Adding a min validator + * + * The following example shows how to add a min validator to an input attached to an + * ngModel binding. + * + * ```html + * + * ``` + * + * @ngModule ReactiveFormsModule + * @ngModule FormsModule + * @publicApi + */ +@Directive({ + selector: + 'input[type=number][min][formControlName],input[type=number][min][formControl],input[type=number][min][ngModel]', + providers: [MIN_VALIDATOR], + host: {'[attr.min]': 'min ? min : null'} +}) +export class MinValidator extends AbstractValidatorDirective implements OnChanges { + /** + * @description + * Tracks changes to the min bound to this directive. + */ + @Input() min!: string|number; + /** @internal */ + inputName = 'min'; + /** @internal */ + normalizeInput = (input: string): number => parseInt(input, 10); + /** @internal */ + createValidator = (min: number): ValidatorFn => Validators.min(min); + /** + * Declare `ngOnChanges` lifecycle hook at the main directive level (vs keeping it in base class) + * to avoid differences in handling inheritance of lifecycle hooks between Ivy and ViewEngine in + * AOT mode. This could be refactored once ViewEngine is removed. + * @nodoc + */ + ngOnChanges(changes: SimpleChanges): void { + this.handleChanges(changes); + } +} + /** * @description * An interface implemented by classes that perform asynchronous validation. diff --git a/packages/forms/src/forms.ts b/packages/forms/src/forms.ts index aff0c28a27..248d07a0b6 100644 --- a/packages/forms/src/forms.ts +++ b/packages/forms/src/forms.ts @@ -43,7 +43,7 @@ export {FormGroupName} from './directives/reactive_directives/form_group_name'; export {NgSelectOption, SelectControlValueAccessor} from './directives/select_control_value_accessor'; export {SelectMultipleControlValueAccessor} from './directives/select_multiple_control_value_accessor'; export {ɵNgSelectMultipleOption} from './directives/select_multiple_control_value_accessor'; -export {AsyncValidator, AsyncValidatorFn, CheckboxRequiredValidator, EmailValidator, MaxLengthValidator, MinLengthValidator, PatternValidator, RequiredValidator, ValidationErrors, Validator, ValidatorFn} from './directives/validators'; +export {AsyncValidator, AsyncValidatorFn, CheckboxRequiredValidator, EmailValidator, MaxLengthValidator, MaxValidator, MinLengthValidator, MinValidator, PatternValidator, RequiredValidator, ValidationErrors, Validator, ValidatorFn} from './directives/validators'; export {FormBuilder} from './form_builder'; export {AbstractControl, AbstractControlOptions, FormArray, FormControl, FormGroup} from './model'; export {NG_ASYNC_VALIDATORS, NG_VALIDATORS, Validators} from './validators'; diff --git a/packages/forms/test/reactive_integration_spec.ts b/packages/forms/test/reactive_integration_spec.ts index fcdb1c6c45..63c1993b06 100644 --- a/packages/forms/test/reactive_integration_spec.ts +++ b/packages/forms/test/reactive_integration_spec.ts @@ -10,7 +10,7 @@ import {ɵgetDOM as getDOM} from '@angular/common'; import {Component, Directive, forwardRef, Input, NgModule, OnDestroy, Type} from '@angular/core'; import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; import {expect} from '@angular/core/testing/src/testing_internal'; -import {AbstractControl, AsyncValidator, AsyncValidatorFn, COMPOSITION_BUFFER_MODE, ControlValueAccessor, DefaultValueAccessor, FormArray, FormControl, FormControlDirective, FormControlName, FormGroup, FormGroupDirective, FormsModule, NG_ASYNC_VALIDATORS, NG_VALIDATORS, NG_VALUE_ACCESSOR, ReactiveFormsModule, Validator, Validators} from '@angular/forms'; +import {AbstractControl, AsyncValidator, AsyncValidatorFn, COMPOSITION_BUFFER_MODE, ControlValueAccessor, DefaultValueAccessor, FormArray, FormControl, FormControlDirective, FormControlName, FormGroup, FormGroupDirective, FormsModule, MaxValidator, MinValidator, NG_ASYNC_VALIDATORS, NG_VALIDATORS, NG_VALUE_ACCESSOR, ReactiveFormsModule, Validator, Validators} from '@angular/forms'; import {By} from '@angular/platform-browser/src/dom/debug/by'; import {dispatchEvent, sortedClassList} from '@angular/platform-browser/testing/src/browser_util'; import {merge, NEVER, of, Subscription, timer} from 'rxjs'; @@ -2346,6 +2346,211 @@ const ValueAccessorB = createControlValueAccessor('[cva-b]'); expect(resultArr.length) .toEqual(2, `Expected original observable to be canceled on the next value change.`); })); + + describe('min and max validators', () => { + function getComponent(dir: string): Type { + return dir === 'formControl' ? MinMaxFormControlComp : MinMaxFormControlNameComp; + } + // Run tests for both `FormControlName` and `FormControl` directives + ['formControl', 'formControlName'].forEach((dir: string) => { + it('should validate max', () => { + const fixture = initTest(getComponent(dir)); + const control = new FormControl(5); + fixture.componentInstance.control = control; + fixture.componentInstance.form = new FormGroup({'pin': control}); + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + const form = fixture.componentInstance.form; + + expect(input.value).toEqual('5'); + expect(form.valid).toBeTruthy(); + expect(form.controls.pin.errors).toBeNull(); + + input.value = 2; + dispatchEvent(input, 'input'); + expect(form.value).toEqual({pin: 2}); + expect(form.valid).toBeTruthy(); + expect(form.controls.pin.errors).toBeNull(); + + fixture.componentInstance.max = 1; + fixture.detectChanges(); + + expect(form.valid).toBeFalse(); + expect(form.controls.pin.errors).toEqual({max: {max: 1, actual: 2}}); + }); + + it('should apply max validation when control value is defined as a string', () => { + const fixture = initTest(getComponent(dir)); + const control = new FormControl('5'); + fixture.componentInstance.control = control; + fixture.componentInstance.form = new FormGroup({'pin': control}); + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + const form = fixture.componentInstance.form; + + expect(input.value).toEqual('5'); + expect(form.valid).toBeTruthy(); + expect(form.controls.pin.errors).toBeNull(); + + input.value = '2'; + dispatchEvent(input, 'input'); + expect(form.value).toEqual({pin: 2}); + expect(form.valid).toBeTruthy(); + expect(form.controls.pin.errors).toBeNull(); + + fixture.componentInstance.max = 1; + fixture.detectChanges(); + expect(form.valid).toBeFalse(); + expect(form.controls.pin.errors).toEqual({max: {max: 1, actual: 2}}); + }); + + it('should validate min', () => { + const fixture = initTest(getComponent(dir)); + const control = new FormControl(5); + fixture.componentInstance.control = control; + fixture.componentInstance.form = new FormGroup({'pin': control}); + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + const form = fixture.componentInstance.form; + + expect(input.value).toEqual('5'); + expect(form.valid).toBeTruthy(); + expect(form.controls.pin.errors).toBeNull(); + + input.value = 2; + dispatchEvent(input, 'input'); + expect(form.value).toEqual({pin: 2}); + expect(form.valid).toBeTruthy(); + expect(form.controls.pin.errors).toBeNull(); + + fixture.componentInstance.min = 5; + fixture.detectChanges(); + expect(form.valid).toBeFalse(); + expect(form.controls.pin.errors).toEqual({min: {min: 5, actual: 2}}); + }); + + it('should apply min validation when control value is defined as a string', () => { + const fixture = initTest(getComponent(dir)); + const control = new FormControl('5'); + fixture.componentInstance.control = control; + fixture.componentInstance.form = new FormGroup({'pin': control}); + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + const form = fixture.componentInstance.form; + + expect(input.value).toEqual('5'); + expect(form.valid).toBeTruthy(); + expect(form.controls.pin.errors).toBeNull(); + + input.value = '2'; + dispatchEvent(input, 'input'); + expect(form.value).toEqual({pin: 2}); + expect(form.valid).toBeTruthy(); + expect(form.controls.pin.errors).toBeNull(); + + fixture.componentInstance.min = 5; + fixture.detectChanges(); + expect(form.valid).toBeFalse(); + expect(form.controls.pin.errors).toEqual({min: {min: 5, actual: 2}}); + }); + + it('should run min/max validation for empty values', () => { + const fixture = initTest(getComponent(dir)); + const minValidateFnSpy = spyOn(MinValidator.prototype, 'validate'); + const maxValidateFnSpy = spyOn(MaxValidator.prototype, 'validate'); + + const control = new FormControl(); + fixture.componentInstance.control = control; + fixture.componentInstance.form = new FormGroup({'pin': control}); + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + const form = fixture.componentInstance.form; + + expect(input.value).toEqual(''); + expect(form.valid).toBeTruthy(); + expect(form.controls.pin.errors).toBeNull(); + expect(minValidateFnSpy).toHaveBeenCalled(); + expect(maxValidateFnSpy).toHaveBeenCalled(); + }); + + it('should run min/max validation when constraints are represented as strings', () => { + const fixture = initTest(getComponent(dir)); + const control = new FormControl(5); + + // Run tests when min and max are defined as strings. + fixture.componentInstance.min = '1'; + fixture.componentInstance.max = '10'; + + fixture.componentInstance.control = control; + fixture.componentInstance.form = new FormGroup({'pin': control}); + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + const form = fixture.componentInstance.form; + + expect(input.value).toEqual('5'); + expect(form.valid).toBeTruthy(); + expect(form.controls.pin.errors).toBeNull(); + + input.value = 2; // inside [1, 10] range + dispatchEvent(input, 'input'); + expect(form.value).toEqual({pin: 2}); + expect(form.valid).toBeTruthy(); + expect(form.controls.pin.errors).toBeNull(); + + input.value = -2; // outside [1, 10] range + dispatchEvent(input, 'input'); + expect(form.value).toEqual({pin: -2}); + expect(form.valid).toBeFalse(); + expect(form.controls.pin.errors).toEqual({min: {min: 1, actual: -2}}); + + input.value = 20; // outside [1, 10] range + dispatchEvent(input, 'input'); + expect(form.valid).toBeFalse(); + expect(form.controls.pin.errors).toEqual({max: {max: 10, actual: 20}}); + }); + + it('should run min/max validation for negative values', () => { + const fixture = initTest(getComponent(dir)); + const control = new FormControl(-30); + fixture.componentInstance.control = control; + fixture.componentInstance.form = new FormGroup({'pin': control}); + fixture.componentInstance.min = -20; + fixture.componentInstance.max = -10; + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + const form = fixture.componentInstance.form; + + expect(input.value).toEqual('-30'); + expect(form.valid).toBeFalse(); + expect(form.controls.pin.errors).toEqual({min: {min: -20, actual: -30}}); + + input.value = -15; + dispatchEvent(input, 'input'); + expect(form.value).toEqual({pin: -15}); + expect(form.valid).toBeTruthy(); + expect(form.controls.pin.errors).toBeNull(); + + input.value = -5; + dispatchEvent(input, 'input'); + expect(form.value).toEqual({pin: -5}); + expect(form.valid).toBeFalse(); + expect(form.controls.pin.errors).toEqual({max: {max: -10, actual: -5}}); + + input.value = 0; + dispatchEvent(input, 'input'); + expect(form.value).toEqual({pin: 0}); + expect(form.valid).toBeFalse(); + expect(form.controls.pin.errors).toEqual({max: {max: -10, actual: 0}}); + }); + }); + }); }); describe('errors', () => { @@ -4461,3 +4666,31 @@ class NgForFormControlWithValidators { form = new FormGroup({login: new FormControl('a')}); logins = ['a', 'b', 'c']; } + +@Component({ + selector: 'min-max-form-control-name', + template: ` +
+ +
` +}) +class MinMaxFormControlNameComp { + control!: FormControl; + form!: FormGroup; + min: number|string = 1; + max: number|string = 10; +} + +@Component({ + selector: 'min-max-form-control', + template: ` +
+ +
` +}) +class MinMaxFormControlComp { + control!: FormControl; + form!: FormGroup; + min: number|string = 1; + max: number|string = 10; +} \ No newline at end of file diff --git a/packages/forms/test/template_integration_spec.ts b/packages/forms/test/template_integration_spec.ts index 6f77c5698f..8e441c27f9 100644 --- a/packages/forms/test/template_integration_spec.ts +++ b/packages/forms/test/template_integration_spec.ts @@ -7,9 +7,9 @@ */ import {ɵgetDOM as getDOM} from '@angular/common'; -import {Component, Directive, forwardRef, Type} from '@angular/core'; +import {Component, Directive, forwardRef, Input, Type, ViewChild} from '@angular/core'; import {ComponentFixture, fakeAsync, TestBed, tick, waitForAsync} from '@angular/core/testing'; -import {AbstractControl, AsyncValidator, COMPOSITION_BUFFER_MODE, FormControl, FormsModule, NG_ASYNC_VALIDATORS, NgForm, NgModel} from '@angular/forms'; +import {AbstractControl, AsyncValidator, COMPOSITION_BUFFER_MODE, ControlValueAccessor, FormControl, FormsModule, MaxValidator, MinValidator, NG_ASYNC_VALIDATORS, NG_VALIDATORS, NG_VALUE_ACCESSOR, NgForm, NgModel} from '@angular/forms'; import {By} from '@angular/platform-browser/src/dom/debug/by'; import {dispatchEvent, sortedClassList} from '@angular/platform-browser/testing/src/browser_util'; import {merge} from 'rxjs'; @@ -1511,6 +1511,462 @@ import {NgModelCustomComp, NgModelCustomWrapper} from './value_accessor_integrat expect(onNgModelChange).toHaveBeenCalledTimes(2); tick(); })); + + it('should validate max', fakeAsync(() => { + const fixture = initTest(NgModelMaxValidator); + fixture.componentInstance.max = 10; + fixture.detectChanges(); + tick(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + const form = fixture.debugElement.children[0].injector.get(NgForm); + + input.value = ''; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(true); + expect(form.controls.max.errors).toBeNull(); + + input.value = 11; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(false); + expect(form.controls.max.errors).toEqual({max: {max: 10, actual: 11}}); + + input.value = 9; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(true); + expect(form.controls.max.errors).toBeNull(); + })); + + it('should apply max validation when control value is defined as a string', fakeAsync(() => { + const fixture = initTest(NgModelMaxValidator); + fixture.componentInstance.max = 10; + fixture.detectChanges(); + tick(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + const form = fixture.debugElement.children[0].injector.get(NgForm); + + input.value = '11'; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(false); + expect(form.controls.max.errors).toEqual({max: {max: 10, actual: 11}}); + + input.value = '9'; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(true); + expect(form.controls.max.errors).toBeNull(); + })); + + it('should re-validate if max changes', fakeAsync(() => { + const fixture = initTest(NgModelMaxValidator); + fixture.componentInstance.max = 10; + fixture.detectChanges(); + tick(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + const form = fixture.debugElement.children[0].injector.get(NgForm); + + input.value = 11; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(false); + expect(form.controls.max.errors).toEqual({max: {max: 10, actual: 11}}); + + input.value = 9; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(true); + expect(form.controls.max.errors).toBeNull(); + + fixture.componentInstance.max = 5; + fixture.detectChanges(); + expect(form.valid).toEqual(false); + expect(form.controls.max.errors).toEqual({max: {max: 5, actual: 9}}); + })); + + it('should validate min', fakeAsync(() => { + const fixture = initTest(NgModelMinValidator); + fixture.componentInstance.min = 10; + fixture.detectChanges(); + tick(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + const form = fixture.debugElement.children[0].injector.get(NgForm); + + input.value = ''; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(true); + expect(form.controls.min.errors).toBeNull(); + + input.value = 11; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(true); + expect(form.controls.min.errors).toBeNull(); + + input.value = 9; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(false); + expect(form.controls.min.errors).toEqual({min: {min: 10, actual: 9}}); + })); + + it('should apply min validation when control value is defined as a string', fakeAsync(() => { + const fixture = initTest(NgModelMinValidator); + fixture.componentInstance.min = 10; + fixture.detectChanges(); + tick(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + const form = fixture.debugElement.children[0].injector.get(NgForm); + + input.value = '11'; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(true); + expect(form.controls.min.errors).toBeNull(); + + input.value = '9'; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(false); + expect(form.controls.min.errors).toEqual({min: {min: 10, actual: 9}}); + })); + + it('should re-validate if min changes', fakeAsync(() => { + const fixture = initTest(NgModelMinValidator); + fixture.componentInstance.min = 10; + fixture.detectChanges(); + tick(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + const form = fixture.debugElement.children[0].injector.get(NgForm); + + input.value = 11; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(true); + expect(form.controls.min.errors).toBeNull(); + + input.value = 9; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(false); + expect(form.controls.min.errors).toEqual({min: {min: 10, actual: 9}}); + + fixture.componentInstance.min = 9; + fixture.detectChanges(); + expect(form.valid).toEqual(true); + expect(form.controls.min.errors).toBeNull(); + })); + + it('should not include the min and max validators when using another directive with the same properties', + fakeAsync(() => { + const fixture = initTest(NgModelNoMinMaxValidator); + const validateFnSpy = spyOn(MaxValidator.prototype, 'validate'); + + fixture.componentInstance.min = 10; + fixture.componentInstance.max = 20; + fixture.detectChanges(); + tick(); + + const min = fixture.debugElement.query(By.directive(MinValidator)); + expect(min).toBeNull(); + + const max = fixture.debugElement.query(By.directive(MaxValidator)); + expect(max).toBeNull(); + + const cd = fixture.debugElement.query(By.directive(CustomDirective)); + expect(cd).toBeDefined(); + + expect(validateFnSpy).not.toHaveBeenCalled(); + })); + + it('should not include the min and max validators when using a custom component with the same properties', + fakeAsync(() => { + @Directive({ + selector: 'my-custom-component', + providers: [{ + provide: NG_VALUE_ACCESSOR, + multi: true, + useExisting: forwardRef(() => MyCustomComponentDirective), + }] + }) + class MyCustomComponentDirective implements ControlValueAccessor { + @Input() min!: number; + @Input() max!: number; + + writeValue(obj: any): void {} + registerOnChange(fn: any): void {} + registerOnTouched(fn: any): void {} + } + + @Component({ + template: ` + + + + ` + }) + class AppComponent { + } + + const fixture = initTest(AppComponent, MyCustomComponentDirective); + const validateFnSpy = spyOn(MaxValidator.prototype, 'validate'); + + fixture.detectChanges(); + tick(); + + const mv = fixture.debugElement.query(By.directive(MaxValidator)); + expect(mv).toBeNull(); + + const cd = fixture.debugElement.query(By.directive(CustomDirective)); + expect(cd).toBeDefined(); + + expect(validateFnSpy).not.toHaveBeenCalled(); + })); + + it('should not include the min and max validators for inputs with type range', + fakeAsync(() => { + @Component({template: ''}) + class AppComponent { + } + + const fixture = initTest(AppComponent); + const maxValidateFnSpy = spyOn(MaxValidator.prototype, 'validate'); + const minValidateFnSpy = spyOn(MinValidator.prototype, 'validate'); + + fixture.detectChanges(); + tick(); + + const maxValidator = fixture.debugElement.query(By.directive(MaxValidator)); + expect(maxValidator).toBeNull(); + + const minValidator = fixture.debugElement.query(By.directive(MinValidator)); + expect(minValidator).toBeNull(); + + expect(maxValidateFnSpy).not.toHaveBeenCalled(); + expect(minValidateFnSpy).not.toHaveBeenCalled(); + })); + + ['number', 'string'].forEach((inputType: string) => { + it(`should validate min and max when constraints are represented using a ${inputType}`, + fakeAsync(() => { + const fixture = initTest(NgModelMinMaxValidator); + + fixture.componentInstance.min = inputType === 'string' ? '5' : 5; + fixture.componentInstance.max = inputType === 'string' ? '10' : 10; + + fixture.detectChanges(); + tick(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + const form = fixture.debugElement.children[0].injector.get(NgForm); + + input.value = ''; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(true); + expect(form.controls.min_max.errors).toBeNull(); + + input.value = 11; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(false); + expect(form.controls.min_max.errors).toEqual({max: {max: 10, actual: 11}}); + + input.value = 4; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(false); + expect(form.controls.min_max.errors).toEqual({min: {min: 5, actual: 4}}); + + input.value = 9; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(true); + expect(form.controls.min_max.errors).toBeNull(); + })); + }); + it('should validate min and max', fakeAsync(() => { + const fixture = initTest(NgModelMinMaxValidator); + fixture.componentInstance.min = 5; + fixture.componentInstance.max = 10; + fixture.detectChanges(); + tick(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + const form = fixture.debugElement.children[0].injector.get(NgForm); + + input.value = ''; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(true); + expect(form.controls.min_max.errors).toBeNull(); + + input.value = 11; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(false); + expect(form.controls.min_max.errors).toEqual({max: {max: 10, actual: 11}}); + + input.value = 4; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(false); + expect(form.controls.min_max.errors).toEqual({min: {min: 5, actual: 4}}); + + input.value = 9; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(true); + expect(form.controls.min_max.errors).toBeNull(); + })); + + it('should apply min and max validation when control value is defined as a string', + fakeAsync(() => { + const fixture = initTest(NgModelMinMaxValidator); + fixture.componentInstance.min = 5; + fixture.componentInstance.max = 10; + fixture.detectChanges(); + tick(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + const form = fixture.debugElement.children[0].injector.get(NgForm); + + input.value = ''; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(true); + expect(form.controls.min_max.errors).toBeNull(); + + input.value = '11'; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(false); + expect(form.controls.min_max.errors).toEqual({max: {max: 10, actual: 11}}); + + input.value = '4'; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(false); + expect(form.controls.min_max.errors).toEqual({min: {min: 5, actual: 4}}); + + input.value = '9'; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(true); + expect(form.controls.min_max.errors).toBeNull(); + })); + + it('should re-validate if min/max changes', fakeAsync(() => { + const fixture = initTest(NgModelMinMaxValidator); + fixture.componentInstance.min = 5; + fixture.componentInstance.max = 10; + fixture.detectChanges(); + tick(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + const form = fixture.debugElement.children[0].injector.get(NgForm); + + input.value = 10; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(true); + expect(form.controls.min_max.errors).toBeNull(); + + input.value = 12; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(false); + expect(form.controls.min_max.errors).toEqual({max: {max: 10, actual: 12}}); + + fixture.componentInstance.max = 12; + fixture.detectChanges(); + expect(form.valid).toEqual(true); + expect(form.controls.min_max.errors).toBeNull(); + + input.value = 5; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(true); + expect(form.controls.min_max.errors).toBeNull(); + + input.value = 0; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(false); + expect(form.controls.min_max.errors).toEqual({min: {min: 5, actual: 0}}); + + fixture.componentInstance.min = 0; + fixture.detectChanges(); + expect(form.valid).toEqual(true); + expect(form.controls.min_max.errors).toBeNull(); + })); + + it('should run min/max validation for empty values ', fakeAsync(() => { + const fixture = initTest(NgModelMinMaxValidator); + fixture.componentInstance.min = 5; + fixture.componentInstance.max = 10; + fixture.detectChanges(); + tick(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + const form = fixture.debugElement.children[0].injector.get(NgForm); + + const maxValidateFnSpy = spyOn(MaxValidator.prototype, 'validate'); + const minValidateFnSpy = spyOn(MinValidator.prototype, 'validate'); + + input.value = ''; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toEqual(true); + expect(form.controls.min_max.errors).toBeNull(); + + expect(maxValidateFnSpy).toHaveBeenCalled(); + expect(minValidateFnSpy).toHaveBeenCalled(); + })); + + it('should run min/max validation for negative values', fakeAsync(() => { + const fixture = initTest(NgModelMinMaxValidator); + fixture.componentInstance.min = -20; + fixture.componentInstance.max = -10; + fixture.detectChanges(); + tick(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + const form = fixture.debugElement.children[0].injector.get(NgForm); + + input.value = '-30'; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toBeFalse(); + expect(form.controls.min_max.errors).toEqual({min: {min: -20, actual: -30}}); + + input.value = -15; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toBeTruthy(); + expect(form.controls.min_max.errors).toBeNull(); + + input.value = -5; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toBeFalse(); + expect(form.controls.min_max.errors).toEqual({max: {max: -10, actual: -5}}); + + input.value = 0; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + expect(form.valid).toBeFalse(); + expect(form.controls.min_max.errors).toEqual({max: {max: -10, actual: 0}}); + })); }); describe('IME events', () => { @@ -1879,3 +2335,50 @@ class NgModelChangesForm { class NgModelChangeState { onNgModelChange = () => {}; } + +@Component({ + selector: 'ng-model-max', + template: `
` +}) +class NgModelMaxValidator { + max!: number; +} + +@Component({ + selector: 'ng-model-min', + template: `
` +}) +class NgModelMinValidator { + min!: number; +} + +@Component({ + selector: 'ng-model-min-max', + template: ` +
` +}) +class NgModelMinMaxValidator { + min!: number|string; + max!: number|string; +} + +@Directive({selector: '[myDir]'}) +class CustomDirective { + @Input() min!: number; + @Input() max!: number; +} + +@Component({ + selector: 'ng-model-no-min-max', + template: ` +
+ + +
+ `, +}) +class NgModelNoMinMaxValidator { + min!: number; + max!: number; + @ViewChild('myDir') myDir: any; +}