From 333a708bb632d4258ecb5fd4a0e86229fe9d26e4 Mon Sep 17 00:00:00 2001 From: Kara Date: Wed, 2 Aug 2017 18:10:10 -0700 Subject: [PATCH] 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 --- packages/forms/src/directives/shared.ts | 53 +++-- packages/forms/src/model.ts | 36 +++- packages/forms/test/form_control_spec.ts | 20 ++ .../forms/test/reactive_integration_spec.ts | 195 +++++++++++++++++- 4 files changed, 275 insertions(+), 29 deletions(-) diff --git a/packages/forms/src/directives/shared.ts b/packages/forms/src/directives/shared.ts index 8823b62818..a930311f91 100644 --- a/packages/forms/src/directives/shared.ts +++ b/packages/forms/src/directives/shared.ts @@ -38,23 +38,10 @@ export function setUpControl(control: FormControl, dir: NgControl): void { control.asyncValidator = Validators.composeAsync([control.asyncValidator !, dir.asyncValidator]); dir.valueAccessor !.writeValue(control.value); - // view -> model - dir.valueAccessor !.registerOnChange((newValue: any) => { - dir.viewToModelUpdate(newValue); - control.markAsDirty(); - control.setValue(newValue, {emitModelToViewChange: false}); - }); + setUpViewChangePipeline(control, dir); + setUpModelChangePipeline(control, dir); - // touched - dir.valueAccessor !.registerOnTouched(() => control.markAsTouched()); - - control.registerOnChange((newValue: any, emitModelEvent: boolean) => { - // control -> view - dir.valueAccessor !.writeValue(newValue); - - // control -> ngModel - if (emitModelEvent) dir.viewToModelUpdate(newValue); - }); + setUpBlurPipeline(control, dir); if (dir.valueAccessor !.setDisabledState) { control.registerOnDisabledChange( @@ -92,6 +79,40 @@ export function cleanUpControl(control: FormControl, dir: NgControl) { 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( control: FormGroup | FormArray, dir: AbstractFormGroupDirective | FormArrayName) { if (control == null) _throwError(dir, 'Cannot find control with'); diff --git a/packages/forms/src/model.ts b/packages/forms/src/model.ts index 1613e356b4..8e3096956f 100644 --- a/packages/forms/src/model.ts +++ b/packages/forms/src/model.ts @@ -78,11 +78,15 @@ function coerceToAsyncValidator( origAsyncValidator || null; } +export type FormHooks = 'change' | 'blur'; + export interface AbstractControlOptions { validators?: ValidatorFn|ValidatorFn[]|null; asyncValidators?: AsyncValidatorFn|AsyncValidatorFn[]|null; + updateOn?: FormHooks; } + function isOptionsObj( validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null): boolean { 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. * * * **npm package**: `@angular/forms` @@ -669,6 +682,15 @@ export class FormControl extends AbstractControl { /** @internal */ _onChange: Function[] = []; + /** @internal */ + _updateOn: FormHooks = 'change'; + + /** @internal */ + _pendingValue: any; + + /** @internal */ + _pendingDirty: boolean; + constructor( formState: any = null, validatorOrOpts?: ValidatorFn|ValidatorFn[]|AbstractControlOptions|null, @@ -677,6 +699,7 @@ export class FormControl extends AbstractControl { coerceToValidator(validatorOrOpts), coerceToAsyncValidator(asyncValidator, validatorOrOpts)); this._applyFormState(formState); + this._setUpdateStrategy(validatorOrOpts); this.updateValueAndValidity({onlySelf: true, emitEvent: false}); this._initObservables(); } @@ -704,7 +727,7 @@ export class FormControl extends AbstractControl { emitModelToViewChange?: boolean, emitViewToModelChange?: boolean } = {}): void { - this._value = value; + this._value = this._pendingValue = value; if (this._onChange.length && options.emitModelToViewChange !== false) { this._onChange.forEach( (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 { this._applyFormState(formState); this.markAsPristine(options); + this._pendingDirty = false; this.markAsUntouched(options); this.setValue(this._value, options); } @@ -806,11 +830,17 @@ export class FormControl extends AbstractControl { private _applyFormState(formState: any) { if (this._isBoxedValue(formState)) { - this._value = formState.value; + this._value = this._pendingValue = formState.value; formState.disabled ? this.disable({onlySelf: true, emitEvent: false}) : this.enable({onlySelf: true, emitEvent: false}); } 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 !; } } } diff --git a/packages/forms/test/form_control_spec.ts b/packages/forms/test/form_control_spec.ts index 5199aaac5b..a424a5619a 100644 --- a/packages/forms/test/form_control_spec.ts +++ b/packages/forms/test/form_control_spec.ts @@ -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', () => { + it('should run validator with the initial value', () => { const c = new FormControl('value', Validators.required); expect(c.valid).toEqual(true); diff --git a/packages/forms/test/reactive_integration_spec.ts b/packages/forms/test/reactive_integration_spec.ts index 892527d969..f8de924e3f 100644 --- a/packages/forms/test/reactive_integration_spec.ts +++ b/packages/forms/test/reactive_integration_spec.ts @@ -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', () => { it('should support ngModel for complex forms', fakeAsync(() => { @@ -1238,14 +1413,14 @@ export function main() { TestBed.overrideComponent(FormGroupComp, { set: { template: ` -
+
` } }); const fixture = initTest(FormGroupComp); - fixture.componentInstance.myGroup = new FormGroup({}); + fixture.componentInstance.form = new FormGroup({}); expect(() => fixture.detectChanges()) .toThrowError(new RegExp( @@ -1256,14 +1431,14 @@ export function main() { TestBed.overrideComponent(FormGroupComp, { set: { template: ` -
+
` } }); const fixture = initTest(FormGroupComp); - fixture.componentInstance.myGroup = new FormGroup({}); + fixture.componentInstance.form = new FormGroup({}); expect(() => fixture.detectChanges()).not.toThrowError(); }); @@ -1272,7 +1447,7 @@ export function main() { TestBed.overrideComponent(FormGroupComp, { set: { template: ` -
+
@@ -1281,8 +1456,7 @@ export function main() { } }); const fixture = initTest(FormGroupComp); - const myGroup = new FormGroup({person: new FormGroup({})}); - fixture.componentInstance.myGroup = new FormGroup({person: new FormGroup({})}); + fixture.componentInstance.form = new FormGroup({person: new FormGroup({})}); expect(() => fixture.detectChanges()) .toThrowError(new RegExp( @@ -1293,7 +1467,7 @@ export function main() { TestBed.overrideComponent(FormGroupComp, { set: { template: ` -
+
@@ -1302,7 +1476,7 @@ export function main() { } }); const fixture = initTest(FormGroupComp); - fixture.componentInstance.myGroup = new FormGroup({}); + fixture.componentInstance.form = new FormGroup({}); expect(() => fixture.detectChanges()) .toThrowError( @@ -1406,7 +1580,9 @@ export function main() { // formControl should update normally expect(fixture.componentInstance.control.value).toEqual('updatedValue'); }); + }); + }); } @@ -1470,7 +1646,6 @@ class FormControlComp { class FormGroupComp { control: FormControl; form: FormGroup; - myGroup: FormGroup; event: Event; }