diff --git a/packages/forms/src/directives/shared.ts b/packages/forms/src/directives/shared.ts index 8823b628189a8..a930311f91e9c 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 1613e356b4b88..8e3096956f13f 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 5199aaac5baff..a424a5619ad77 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 892527d969db7..f8de924e3f142 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; }