feat(forms): add updateOn blur option to FormControls (#18408)

By default, the value and validation status of a `FormControl` updates
whenever its value changes. If an application has heavy validation
requirements, updating on every text change can sometimes be too expensive.

This commit introduces a new option that improves performance by delaying
form control updates until the "blur" event.  To use it, set the `updateOn`
option to `blur` when instantiating the `FormControl`.

```ts
// example without validators
const c = new FormControl(, { updateOn: blur });

// example with validators
const c= new FormControl(, {
   validators: Validators.required,
   updateOn: blur
});
```

Like in AngularJS, setting `updateOn` to `blur` will delay the update of
the value as well as the validation status. Updating value and validity
together keeps the system easy to reason about, as the two will always be
in sync. It's  also worth noting that the value/validation pipeline does
still run when the form is initialized (in order to support initial values).

Closes #7113
This commit is contained in:
Kara 2017-08-02 18:10:10 -07:00 committed by Victor Berchet
parent 3a227a1f6f
commit 333a708bb6
4 changed files with 275 additions and 29 deletions

View File

@ -38,23 +38,10 @@ export function setUpControl(control: FormControl, dir: NgControl): void {
control.asyncValidator = Validators.composeAsync([control.asyncValidator !, dir.asyncValidator]); control.asyncValidator = Validators.composeAsync([control.asyncValidator !, dir.asyncValidator]);
dir.valueAccessor !.writeValue(control.value); dir.valueAccessor !.writeValue(control.value);
// view -> model setUpViewChangePipeline(control, dir);
dir.valueAccessor !.registerOnChange((newValue: any) => { setUpModelChangePipeline(control, dir);
dir.viewToModelUpdate(newValue);
control.markAsDirty();
control.setValue(newValue, {emitModelToViewChange: false});
});
// touched setUpBlurPipeline(control, dir);
dir.valueAccessor !.registerOnTouched(() => control.markAsTouched());
control.registerOnChange((newValue: any, emitModelEvent: boolean) => {
// control -> view
dir.valueAccessor !.writeValue(newValue);
// control -> ngModel
if (emitModelEvent) dir.viewToModelUpdate(newValue);
});
if (dir.valueAccessor !.setDisabledState) { if (dir.valueAccessor !.setDisabledState) {
control.registerOnDisabledChange( control.registerOnDisabledChange(
@ -92,6 +79,40 @@ export function cleanUpControl(control: FormControl, dir: NgControl) {
if (control) control._clearChangeFns(); if (control) control._clearChangeFns();
} }
function setUpViewChangePipeline(control: FormControl, dir: NgControl): void {
dir.valueAccessor !.registerOnChange((newValue: any) => {
control._pendingValue = newValue;
control._pendingDirty = true;
if (control._updateOn === 'change') {
dir.viewToModelUpdate(newValue);
control.markAsDirty();
control.setValue(newValue, {emitModelToViewChange: false});
}
});
}
function setUpBlurPipeline(control: FormControl, dir: NgControl): void {
dir.valueAccessor !.registerOnTouched(() => {
if (control._updateOn === 'blur') {
dir.viewToModelUpdate(control._pendingValue);
if (control._pendingDirty) control.markAsDirty();
control.setValue(control._pendingValue, {emitModelToViewChange: false});
}
control.markAsTouched();
});
}
function setUpModelChangePipeline(control: FormControl, dir: NgControl): void {
control.registerOnChange((newValue: any, emitModelEvent: boolean) => {
// control -> view
dir.valueAccessor !.writeValue(newValue);
// control -> ngModel
if (emitModelEvent) dir.viewToModelUpdate(newValue);
});
}
export function setUpFormContainer( export function setUpFormContainer(
control: FormGroup | FormArray, dir: AbstractFormGroupDirective | FormArrayName) { control: FormGroup | FormArray, dir: AbstractFormGroupDirective | FormArrayName) {
if (control == null) _throwError(dir, 'Cannot find control with'); if (control == null) _throwError(dir, 'Cannot find control with');

View File

@ -78,11 +78,15 @@ function coerceToAsyncValidator(
origAsyncValidator || null; origAsyncValidator || null;
} }
export type FormHooks = 'change' | 'blur';
export interface AbstractControlOptions { export interface AbstractControlOptions {
validators?: ValidatorFn|ValidatorFn[]|null; validators?: ValidatorFn|ValidatorFn[]|null;
asyncValidators?: AsyncValidatorFn|AsyncValidatorFn[]|null; asyncValidators?: AsyncValidatorFn|AsyncValidatorFn[]|null;
updateOn?: FormHooks;
} }
function isOptionsObj( function isOptionsObj(
validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null): boolean { validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null): boolean {
return validatorOrOpts != null && !Array.isArray(validatorOrOpts) && return validatorOrOpts != null && !Array.isArray(validatorOrOpts) &&
@ -659,6 +663,15 @@ export abstract class AbstractControl {
* }); * });
* ``` * ```
* *
* The options object can also be used to define when the control should update.
* By default, the value and validity of a control updates whenever the value
* changes. You can configure it to update on the blur event instead by setting
* the `updateOn` option to `'blur'`.
*
* ```ts
* const c = new FormControl('', { updateOn: 'blur' });
* ```
*
* See its superclass, {@link AbstractControl}, for more properties and methods. * See its superclass, {@link AbstractControl}, for more properties and methods.
* *
* * **npm package**: `@angular/forms` * * **npm package**: `@angular/forms`
@ -669,6 +682,15 @@ export class FormControl extends AbstractControl {
/** @internal */ /** @internal */
_onChange: Function[] = []; _onChange: Function[] = [];
/** @internal */
_updateOn: FormHooks = 'change';
/** @internal */
_pendingValue: any;
/** @internal */
_pendingDirty: boolean;
constructor( constructor(
formState: any = null, formState: any = null,
validatorOrOpts?: ValidatorFn|ValidatorFn[]|AbstractControlOptions|null, validatorOrOpts?: ValidatorFn|ValidatorFn[]|AbstractControlOptions|null,
@ -677,6 +699,7 @@ export class FormControl extends AbstractControl {
coerceToValidator(validatorOrOpts), coerceToValidator(validatorOrOpts),
coerceToAsyncValidator(asyncValidator, validatorOrOpts)); coerceToAsyncValidator(asyncValidator, validatorOrOpts));
this._applyFormState(formState); this._applyFormState(formState);
this._setUpdateStrategy(validatorOrOpts);
this.updateValueAndValidity({onlySelf: true, emitEvent: false}); this.updateValueAndValidity({onlySelf: true, emitEvent: false});
this._initObservables(); this._initObservables();
} }
@ -704,7 +727,7 @@ export class FormControl extends AbstractControl {
emitModelToViewChange?: boolean, emitModelToViewChange?: boolean,
emitViewToModelChange?: boolean emitViewToModelChange?: boolean
} = {}): void { } = {}): void {
this._value = value; this._value = this._pendingValue = value;
if (this._onChange.length && options.emitModelToViewChange !== false) { if (this._onChange.length && options.emitModelToViewChange !== false) {
this._onChange.forEach( this._onChange.forEach(
(changeFn) => changeFn(this._value, options.emitViewToModelChange !== false)); (changeFn) => changeFn(this._value, options.emitViewToModelChange !== false));
@ -759,6 +782,7 @@ export class FormControl extends AbstractControl {
reset(formState: any = null, options: {onlySelf?: boolean, emitEvent?: boolean} = {}): void { reset(formState: any = null, options: {onlySelf?: boolean, emitEvent?: boolean} = {}): void {
this._applyFormState(formState); this._applyFormState(formState);
this.markAsPristine(options); this.markAsPristine(options);
this._pendingDirty = false;
this.markAsUntouched(options); this.markAsUntouched(options);
this.setValue(this._value, options); this.setValue(this._value, options);
} }
@ -806,11 +830,17 @@ export class FormControl extends AbstractControl {
private _applyFormState(formState: any) { private _applyFormState(formState: any) {
if (this._isBoxedValue(formState)) { if (this._isBoxedValue(formState)) {
this._value = formState.value; this._value = this._pendingValue = formState.value;
formState.disabled ? this.disable({onlySelf: true, emitEvent: false}) : formState.disabled ? this.disable({onlySelf: true, emitEvent: false}) :
this.enable({onlySelf: true, emitEvent: false}); this.enable({onlySelf: true, emitEvent: false});
} else { } else {
this._value = formState; this._value = this._pendingValue = formState;
}
}
private _setUpdateStrategy(opts?: ValidatorFn|ValidatorFn[]|AbstractControlOptions|null): void {
if (isOptionsObj(opts) && (opts as AbstractControlOptions).updateOn != null) {
this._updateOn = (opts as AbstractControlOptions).updateOn !;
} }
} }
} }

View File

@ -76,7 +76,27 @@ export function main() {
}); });
describe('updateOn', () => {
it('should default to on change', () => {
const c = new FormControl('');
expect(c._updateOn).toEqual('change');
});
it('should default to on change with an options obj', () => {
const c = new FormControl('', {validators: Validators.required});
expect(c._updateOn).toEqual('change');
});
it('should set updateOn when updating on blur', () => {
const c = new FormControl('', {updateOn: 'blur'});
expect(c._updateOn).toEqual('blur');
});
});
describe('validator', () => { describe('validator', () => {
it('should run validator with the initial value', () => { it('should run validator with the initial value', () => {
const c = new FormControl('value', Validators.required); const c = new FormControl('value', Validators.required);
expect(c.valid).toEqual(true); expect(c.valid).toEqual(true);

View File

@ -731,6 +731,181 @@ export function main() {
}); });
describe('updateOn options', () => {
describe('on blur', () => {
it('should not update value or validity based on user input until blur', () => {
const fixture = initTest(FormControlComp);
const control = new FormControl('', {validators: Validators.required, updateOn: 'blur'});
fixture.componentInstance.control = control;
fixture.detectChanges();
const input = fixture.debugElement.query(By.css('input')).nativeElement;
input.value = 'Nancy';
dispatchEvent(input, 'input');
fixture.detectChanges();
expect(control.value).toEqual('', 'Expected value to remain unchanged until blur.');
expect(control.valid).toBe(false, 'Expected no validation to occur until blur.');
dispatchEvent(input, 'blur');
fixture.detectChanges();
expect(control.value)
.toEqual('Nancy', 'Expected value to change once control is blurred.');
expect(control.valid).toBe(true, 'Expected validation to run once control is blurred.');
});
it('should not update parent group value/validity from child until blur', () => {
const fixture = initTest(FormGroupComp);
const form = new FormGroup(
{login: new FormControl('', {validators: Validators.required, updateOn: 'blur'})});
fixture.componentInstance.form = form;
fixture.detectChanges();
const input = fixture.debugElement.query(By.css('input')).nativeElement;
input.value = 'Nancy';
dispatchEvent(input, 'input');
fixture.detectChanges();
expect(form.value)
.toEqual({login: ''}, 'Expected group value to remain unchanged until blur.');
expect(form.valid).toBe(false, 'Expected no validation to occur on group until blur.');
dispatchEvent(input, 'blur');
fixture.detectChanges();
expect(form.value)
.toEqual({login: 'Nancy'}, 'Expected group value to change once input blurred.');
expect(form.valid).toBe(true, 'Expected validation to run once input blurred.');
});
it('should not wait for blur event to update if value is set programmatically', () => {
const fixture = initTest(FormControlComp);
const control = new FormControl('', {validators: Validators.required, updateOn: 'blur'});
fixture.componentInstance.control = control;
fixture.detectChanges();
control.setValue('Nancy');
fixture.detectChanges();
const input = fixture.debugElement.query(By.css('input')).nativeElement;
expect(input.value).toEqual('Nancy', 'Expected value to propagate to view immediately.');
expect(control.value).toEqual('Nancy', 'Expected model value to update immediately.');
expect(control.valid).toBe(true, 'Expected validation to run immediately.');
});
it('should not update dirty state until control is blurred', () => {
const fixture = initTest(FormControlComp);
const control = new FormControl('', {updateOn: 'blur'});
fixture.componentInstance.control = control;
fixture.detectChanges();
expect(control.dirty).toBe(false, 'Expected control to start out pristine.');
const input = fixture.debugElement.query(By.css('input')).nativeElement;
input.value = 'Nancy';
dispatchEvent(input, 'input');
fixture.detectChanges();
expect(control.dirty).toBe(false, 'Expected control to stay pristine until blurred.');
dispatchEvent(input, 'blur');
fixture.detectChanges();
expect(control.dirty).toBe(true, 'Expected control to update dirty state when blurred.');
});
it('should continue waiting for blur to update if previously blurred', () => {
const fixture = initTest(FormControlComp);
const control =
new FormControl('Nancy', {validators: Validators.required, updateOn: 'blur'});
fixture.componentInstance.control = control;
fixture.detectChanges();
const input = fixture.debugElement.query(By.css('input')).nativeElement;
dispatchEvent(input, 'blur');
fixture.detectChanges();
dispatchEvent(input, 'focus');
input.value = '';
dispatchEvent(input, 'input');
fixture.detectChanges();
expect(control.value)
.toEqual('Nancy', 'Expected value to remain unchanged until second blur.');
expect(control.valid).toBe(true, 'Expected validation not to run until second blur.');
dispatchEvent(input, 'blur');
fixture.detectChanges();
expect(control.value).toEqual('', 'Expected value to update when blur occurs again.');
expect(control.valid).toBe(false, 'Expected validation to run when blur occurs again.');
});
it('should not use stale pending value if value set programmatically', () => {
const fixture = initTest(FormControlComp);
const control = new FormControl('', {validators: Validators.required, updateOn: 'blur'});
fixture.componentInstance.control = control;
fixture.detectChanges();
const input = fixture.debugElement.query(By.css('input')).nativeElement;
input.value = 'aa';
dispatchEvent(input, 'input');
fixture.detectChanges();
control.setValue('Nancy');
fixture.detectChanges();
dispatchEvent(input, 'blur');
fixture.detectChanges();
expect(input.value).toEqual('Nancy', 'Expected programmatic value to stick after blur.');
});
it('should set initial value and validity on init', () => {
const fixture = initTest(FormControlComp);
const control =
new FormControl('Nancy', {validators: Validators.maxLength(3), updateOn: 'blur'});
fixture.componentInstance.control = control;
fixture.detectChanges();
const input = fixture.debugElement.query(By.css('input')).nativeElement;
expect(input.value).toEqual('Nancy', 'Expected value to be set in the view.');
expect(control.value).toEqual('Nancy', 'Expected initial model value to be set.');
expect(control.valid).toBe(false, 'Expected validation to run on initial value.');
});
it('should reset properly', () => {
const fixture = initTest(FormControlComp);
const control = new FormControl('', {validators: Validators.required, updateOn: 'blur'});
fixture.componentInstance.control = control;
fixture.detectChanges();
const input = fixture.debugElement.query(By.css('input')).nativeElement;
input.value = 'aa';
dispatchEvent(input, 'input');
fixture.detectChanges();
dispatchEvent(input, 'blur');
fixture.detectChanges();
expect(control.dirty).toBe(true, 'Expected control to be dirty on blur.');
control.reset();
dispatchEvent(input, 'blur');
fixture.detectChanges();
expect(control.dirty).toBe(false, 'Expected pending dirty value to reset.');
expect(control.value).toBe(null, 'Expected pending value to reset.');
});
});
});
describe('ngModel interactions', () => { describe('ngModel interactions', () => {
it('should support ngModel for complex forms', fakeAsync(() => { it('should support ngModel for complex forms', fakeAsync(() => {
@ -1238,14 +1413,14 @@ export function main() {
TestBed.overrideComponent(FormGroupComp, { TestBed.overrideComponent(FormGroupComp, {
set: { set: {
template: ` template: `
<div [formGroup]="myGroup"> <div [formGroup]="form">
<input type="text" [(ngModel)]="data"> <input type="text" [(ngModel)]="data">
</div> </div>
` `
} }
}); });
const fixture = initTest(FormGroupComp); const fixture = initTest(FormGroupComp);
fixture.componentInstance.myGroup = new FormGroup({}); fixture.componentInstance.form = new FormGroup({});
expect(() => fixture.detectChanges()) expect(() => fixture.detectChanges())
.toThrowError(new RegExp( .toThrowError(new RegExp(
@ -1256,14 +1431,14 @@ export function main() {
TestBed.overrideComponent(FormGroupComp, { TestBed.overrideComponent(FormGroupComp, {
set: { set: {
template: ` template: `
<div [formGroup]="myGroup"> <div [formGroup]="form">
<input type="text" [(ngModel)]="data" [ngModelOptions]="{standalone: true}"> <input type="text" [(ngModel)]="data" [ngModelOptions]="{standalone: true}">
</div> </div>
` `
} }
}); });
const fixture = initTest(FormGroupComp); const fixture = initTest(FormGroupComp);
fixture.componentInstance.myGroup = new FormGroup({}); fixture.componentInstance.form = new FormGroup({});
expect(() => fixture.detectChanges()).not.toThrowError(); expect(() => fixture.detectChanges()).not.toThrowError();
}); });
@ -1272,7 +1447,7 @@ export function main() {
TestBed.overrideComponent(FormGroupComp, { TestBed.overrideComponent(FormGroupComp, {
set: { set: {
template: ` template: `
<div [formGroup]="myGroup"> <div [formGroup]="form">
<div formGroupName="person"> <div formGroupName="person">
<input type="text" [(ngModel)]="data"> <input type="text" [(ngModel)]="data">
</div> </div>
@ -1281,8 +1456,7 @@ export function main() {
} }
}); });
const fixture = initTest(FormGroupComp); const fixture = initTest(FormGroupComp);
const myGroup = new FormGroup({person: new FormGroup({})}); fixture.componentInstance.form = new FormGroup({person: new FormGroup({})});
fixture.componentInstance.myGroup = new FormGroup({person: new FormGroup({})});
expect(() => fixture.detectChanges()) expect(() => fixture.detectChanges())
.toThrowError(new RegExp( .toThrowError(new RegExp(
@ -1293,7 +1467,7 @@ export function main() {
TestBed.overrideComponent(FormGroupComp, { TestBed.overrideComponent(FormGroupComp, {
set: { set: {
template: ` template: `
<div [formGroup]="myGroup"> <div [formGroup]="form">
<div ngModelGroup="person"> <div ngModelGroup="person">
<input type="text" [(ngModel)]="data"> <input type="text" [(ngModel)]="data">
</div> </div>
@ -1302,7 +1476,7 @@ export function main() {
} }
}); });
const fixture = initTest(FormGroupComp); const fixture = initTest(FormGroupComp);
fixture.componentInstance.myGroup = new FormGroup({}); fixture.componentInstance.form = new FormGroup({});
expect(() => fixture.detectChanges()) expect(() => fixture.detectChanges())
.toThrowError( .toThrowError(
@ -1406,7 +1580,9 @@ export function main() {
// formControl should update normally // formControl should update normally
expect(fixture.componentInstance.control.value).toEqual('updatedValue'); expect(fixture.componentInstance.control.value).toEqual('updatedValue');
}); });
}); });
}); });
} }
@ -1470,7 +1646,6 @@ class FormControlComp {
class FormGroupComp { class FormGroupComp {
control: FormControl; control: FormControl;
form: FormGroup; form: FormGroup;
myGroup: FormGroup;
event: Event; event: Event;
} }