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 `<input type="number">`
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
This commit is contained in:
Sonu Kapoor 2020-09-30 12:17:04 -04:00 committed by Alex Rickabaugh
parent d067dc0cb9
commit 8fb83ea1b5
6 changed files with 936 additions and 5 deletions

View File

@ -337,6 +337,11 @@ export declare class MaxLengthValidator implements Validator, OnChanges {
validate(control: AbstractControl): ValidationErrors | null; 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 { export declare class MinLengthValidator implements Validator, OnChanges {
minlength: string | number; minlength: string | number;
ngOnChanges(changes: SimpleChanges): void; ngOnChanges(changes: SimpleChanges): void;
@ -344,6 +349,11 @@ export declare class MinLengthValidator implements Validator, OnChanges {
validate(control: AbstractControl): ValidationErrors | null; 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_ASYNC_VALIDATORS: InjectionToken<(Function | Validator)[]>;
export declare const NG_VALIDATORS: InjectionToken<(Function | Validator)[]>; export declare const NG_VALIDATORS: InjectionToken<(Function | Validator)[]>;

View File

@ -24,7 +24,7 @@ import {FormGroupDirective} from './directives/reactive_directives/form_group_di
import {FormArrayName, FormGroupName} from './directives/reactive_directives/form_group_name'; import {FormArrayName, FormGroupName} from './directives/reactive_directives/form_group_name';
import {NgSelectOption, SelectControlValueAccessor} from './directives/select_control_value_accessor'; import {NgSelectOption, SelectControlValueAccessor} from './directives/select_control_value_accessor';
import {NgSelectMultipleOption, SelectMultipleControlValueAccessor} from './directives/select_multiple_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 {CheckboxControlValueAccessor} from './directives/checkbox_value_accessor';
export {ControlValueAccessor} from './directives/control_value_accessor'; export {ControlValueAccessor} from './directives/control_value_accessor';
@ -63,6 +63,8 @@ export const SHARED_FORM_DIRECTIVES: Type<any>[] = [
PatternValidator, PatternValidator,
CheckboxRequiredValidator, CheckboxRequiredValidator,
EmailValidator, EmailValidator,
MinValidator,
MaxValidator,
]; ];
export const TEMPLATE_DRIVEN_DIRECTIVES: Type<any>[] = [NgModel, NgModelGroup, NgForm]; export const TEMPLATE_DRIVEN_DIRECTIVES: Type<any>[] = [NgModel, NgModelGroup, NgForm];

View File

@ -69,6 +69,189 @@ export interface Validator {
registerOnValidatorChange?(fn: () => void): void; 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
* <input type="number" ngModel max="4">
* ```
*
* @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
* <input type="number" ngModel min="4">
* ```
*
* @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 * @description
* An interface implemented by classes that perform asynchronous validation. * An interface implemented by classes that perform asynchronous validation.

View File

@ -43,7 +43,7 @@ export {FormGroupName} from './directives/reactive_directives/form_group_name';
export {NgSelectOption, SelectControlValueAccessor} from './directives/select_control_value_accessor'; export {NgSelectOption, SelectControlValueAccessor} from './directives/select_control_value_accessor';
export {SelectMultipleControlValueAccessor} from './directives/select_multiple_control_value_accessor'; export {SelectMultipleControlValueAccessor} from './directives/select_multiple_control_value_accessor';
export {ɵNgSelectMultipleOption} 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 {FormBuilder} from './form_builder';
export {AbstractControl, AbstractControlOptions, FormArray, FormControl, FormGroup} from './model'; export {AbstractControl, AbstractControlOptions, FormArray, FormControl, FormGroup} from './model';
export {NG_ASYNC_VALIDATORS, NG_VALIDATORS, Validators} from './validators'; export {NG_ASYNC_VALIDATORS, NG_VALIDATORS, Validators} from './validators';

View File

@ -10,7 +10,7 @@ import {ɵgetDOM as getDOM} from '@angular/common';
import {Component, Directive, forwardRef, Input, NgModule, OnDestroy, Type} from '@angular/core'; import {Component, Directive, forwardRef, Input, NgModule, OnDestroy, Type} from '@angular/core';
import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing'; import {ComponentFixture, fakeAsync, TestBed, tick} from '@angular/core/testing';
import {expect} from '@angular/core/testing/src/testing_internal'; 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 {By} from '@angular/platform-browser/src/dom/debug/by';
import {dispatchEvent, sortedClassList} from '@angular/platform-browser/testing/src/browser_util'; import {dispatchEvent, sortedClassList} from '@angular/platform-browser/testing/src/browser_util';
import {merge, NEVER, of, Subscription, timer} from 'rxjs'; import {merge, NEVER, of, Subscription, timer} from 'rxjs';
@ -2346,6 +2346,211 @@ const ValueAccessorB = createControlValueAccessor('[cva-b]');
expect(resultArr.length) expect(resultArr.length)
.toEqual(2, `Expected original observable to be canceled on the next value change.`); .toEqual(2, `Expected original observable to be canceled on the next value change.`);
})); }));
describe('min and max validators', () => {
function getComponent(dir: string): Type<MinMaxFormControlComp|MinMaxFormControlNameComp> {
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', () => { describe('errors', () => {
@ -4461,3 +4666,31 @@ class NgForFormControlWithValidators {
form = new FormGroup({login: new FormControl('a')}); form = new FormGroup({login: new FormControl('a')});
logins = ['a', 'b', 'c']; logins = ['a', 'b', 'c'];
} }
@Component({
selector: 'min-max-form-control-name',
template: `
<div [formGroup]="form">
<input type="number" formControlName="pin" [max]="max" [min]="min">
</div>`
})
class MinMaxFormControlNameComp {
control!: FormControl;
form!: FormGroup;
min: number|string = 1;
max: number|string = 10;
}
@Component({
selector: 'min-max-form-control',
template: `
<div [formGroup]="form">
<input type="number" [formControl]="control" [max]="max" [min]="min">
</div>`
})
class MinMaxFormControlComp {
control!: FormControl;
form!: FormGroup;
min: number|string = 1;
max: number|string = 10;
}

View File

@ -7,9 +7,9 @@
*/ */
import {ɵgetDOM as getDOM} from '@angular/common'; 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 {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 {By} from '@angular/platform-browser/src/dom/debug/by';
import {dispatchEvent, sortedClassList} from '@angular/platform-browser/testing/src/browser_util'; import {dispatchEvent, sortedClassList} from '@angular/platform-browser/testing/src/browser_util';
import {merge} from 'rxjs'; import {merge} from 'rxjs';
@ -1511,6 +1511,462 @@ import {NgModelCustomComp, NgModelCustomWrapper} from './value_accessor_integrat
expect(onNgModelChange).toHaveBeenCalledTimes(2); expect(onNgModelChange).toHaveBeenCalledTimes(2);
tick(); 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: `
<!-- no min/max validators should be matched on these elements -->
<my-custom-component name="min" ngModel [min]="min"></my-custom-component>
<my-custom-component name="max" ngModel [max]="max"></my-custom-component>
`
})
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: '<input type="range" min="10" max="20">'})
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', () => { describe('IME events', () => {
@ -1879,3 +2335,50 @@ class NgModelChangesForm {
class NgModelChangeState { class NgModelChangeState {
onNgModelChange = () => {}; onNgModelChange = () => {};
} }
@Component({
selector: 'ng-model-max',
template: `<form><input name="max" type="number" ngModel [max]="max"></form>`
})
class NgModelMaxValidator {
max!: number;
}
@Component({
selector: 'ng-model-min',
template: `<form><input name="min" type="number" ngModel [min]="min"></form>`
})
class NgModelMinValidator {
min!: number;
}
@Component({
selector: 'ng-model-min-max',
template: `
<form><input name="min_max" type="number" ngModel [min]="min" [max]="max"></form>`
})
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: `
<form>
<input name="min" type="text" ngModel [min]="min" myDir>
<input name="max" type="text" ngModel [max]="max" myDir>
</form>
`,
})
class NgModelNoMinMaxValidator {
min!: number;
max!: number;
@ViewChild('myDir') myDir: any;
}