From ff5c58be6b843bc8f79613dbefc5e4951c125f3d Mon Sep 17 00:00:00 2001 From: Kara Date: Wed, 9 Aug 2017 15:41:53 -0700 Subject: [PATCH] feat(forms): add default updateOn values for groups and arrays (#18536) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit adds support for setting default `updateOn` values in `FormGroups` and `FormArrays`. If you set `updateOn` to ’blur’` at the group level, all child controls will default to `’blur’`, unless the child has explicitly specified a different `updateOn` value. ``` const c = new FormGroup({ one: new FormControl() }, {updateOn: blur}); ``` It's worth noting that parent groups will always update their value and validity immediately upon value/validity updates from children. In other words, if a group is set to update on blur and its children are individually set to update on change, the group will still update on change with its children; its default value will simply not be used. --- .../form_group_directive.ts | 2 +- packages/forms/src/directives/shared.ts | 6 +- packages/forms/src/model.ts | 54 ++++- packages/forms/test/form_control_spec.ts | 73 ++++++- .../forms/test/reactive_integration_spec.ts | 187 +++++++++++++++++- tools/public_api_guard/forms/forms.d.ts | 1 + 6 files changed, 302 insertions(+), 21 deletions(-) diff --git a/packages/forms/src/directives/reactive_directives/form_group_directive.ts b/packages/forms/src/directives/reactive_directives/form_group_directive.ts index 15d08eeab3..8598d083ae 100644 --- a/packages/forms/src/directives/reactive_directives/form_group_directive.ts +++ b/packages/forms/src/directives/reactive_directives/form_group_directive.ts @@ -150,7 +150,7 @@ export class FormGroupDirective extends ControlContainer implements Form, _syncPendingControls() { this.form._syncPendingControls(); this.directives.forEach(dir => { - if (dir.control._updateOn === 'submit') { + if (dir.control.updateOn === 'submit') { dir.viewToModelUpdate(dir.control._pendingValue); } }); diff --git a/packages/forms/src/directives/shared.ts b/packages/forms/src/directives/shared.ts index 428f502743..50651fe55e 100644 --- a/packages/forms/src/directives/shared.ts +++ b/packages/forms/src/directives/shared.ts @@ -84,7 +84,7 @@ function setUpViewChangePipeline(control: FormControl, dir: NgControl): void { control._pendingValue = newValue; control._pendingDirty = true; - if (control._updateOn === 'change') updateControl(control, dir); + if (control.updateOn === 'change') updateControl(control, dir); }); } @@ -92,8 +92,8 @@ function setUpBlurPipeline(control: FormControl, dir: NgControl): void { dir.valueAccessor !.registerOnTouched(() => { control._pendingTouched = true; - if (control._updateOn === 'blur') updateControl(control, dir); - if (control._updateOn !== 'submit') control.markAsTouched(); + if (control.updateOn === 'blur') updateControl(control, dir); + if (control.updateOn !== 'submit') control.markAsTouched(); }); } diff --git a/packages/forms/src/model.ts b/packages/forms/src/model.ts index b0d1e64058..acec83c417 100644 --- a/packages/forms/src/model.ts +++ b/packages/forms/src/model.ts @@ -118,6 +118,9 @@ export abstract class AbstractControl { /** @internal */ _onCollectionChange = () => {}; + /** @internal */ + _updateOn: FormHooks; + private _valueChanges: EventEmitter; private _statusChanges: EventEmitter; private _status: string; @@ -242,6 +245,15 @@ export abstract class AbstractControl { */ get statusChanges(): Observable { return this._statusChanges; } + /** + * Returns the update strategy of the `AbstractControl` (i.e. + * the event on which the control will update itself). + * Possible values: `'change'` (default) | `'blur'` | `'submit'` + */ + get updateOn(): FormHooks { + return this._updateOn ? this._updateOn : (this.parent ? this.parent.updateOn : 'change'); + } + /** * Sets the synchronous validators that are active on this control. Calling * this will overwrite any existing sync validators. @@ -624,6 +636,13 @@ export abstract class AbstractControl { /** @internal */ _registerOnCollectionChange(fn: () => void): void { this._onCollectionChange = fn; } + + /** @internal */ + _setUpdateStrategy(opts?: ValidatorFn|ValidatorFn[]|AbstractControlOptions|null): void { + if (isOptionsObj(opts) && (opts as AbstractControlOptions).updateOn != null) { + this._updateOn = (opts as AbstractControlOptions).updateOn !; + } + } } /** @@ -697,9 +716,6 @@ export class FormControl extends AbstractControl { /** @internal */ _onChange: Function[] = []; - /** @internal */ - _updateOn: FormHooks = 'change'; - /** @internal */ _pendingValue: any; @@ -841,7 +857,7 @@ export class FormControl extends AbstractControl { /** @internal */ _syncPendingControls(): boolean { - if (this._updateOn === 'submit') { + if (this.updateOn === 'submit') { this.setValue(this._pendingValue, {onlySelf: true, emitModelToViewChange: false}); if (this._pendingDirty) this.markAsDirty(); if (this._pendingTouched) this.markAsTouched(); @@ -859,12 +875,6 @@ export class FormControl extends AbstractControl { 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 !; - } - } } /** @@ -925,6 +935,17 @@ export class FormControl extends AbstractControl { * }, {validators: passwordMatchValidator, asyncValidators: otherValidator}); * ``` * + * The options object can also be used to set a default value for each child + * control's `updateOn` property. If you set `updateOn` to `'blur'` at the + * group level, all child controls will default to 'blur', unless the child + * has explicitly specified a different `updateOn` value. + * + * ```ts + * const c = new FormGroup({ + * one: new FormControl() + * }, {updateOn: 'blur'}); + * ``` + * * * **npm package**: `@angular/forms` * * @stable @@ -938,6 +959,7 @@ export class FormGroup extends AbstractControl { coerceToValidator(validatorOrOpts), coerceToAsyncValidator(asyncValidator, validatorOrOpts)); this._initObservables(); + this._setUpdateStrategy(validatorOrOpts); this._setUpControls(); this.updateValueAndValidity({onlySelf: true, emitEvent: false}); } @@ -1242,6 +1264,17 @@ export class FormGroup extends AbstractControl { * ], {validators: myValidator, asyncValidators: myAsyncValidator}); * ``` * + * The options object can also be used to set a default value for each child + * control's `updateOn` property. If you set `updateOn` to `'blur'` at the + * array level, all child controls will default to 'blur', unless the child + * has explicitly specified a different `updateOn` value. + * + * ```ts + * const c = new FormArray([ + * new FormControl() + * ], {updateOn: 'blur'}); + * ``` + * * ### Adding or removing controls * * To change the controls in the array, use the `push`, `insert`, or `removeAt` methods @@ -1263,6 +1296,7 @@ export class FormArray extends AbstractControl { coerceToValidator(validatorOrOpts), coerceToAsyncValidator(asyncValidator, validatorOrOpts)); this._initObservables(); + this._setUpdateStrategy(validatorOrOpts); this._setUpControls(); this.updateValueAndValidity({onlySelf: true, emitEvent: false}); } diff --git a/packages/forms/test/form_control_spec.ts b/packages/forms/test/form_control_spec.ts index a424a5619a..cd3d1fe07b 100644 --- a/packages/forms/test/form_control_spec.ts +++ b/packages/forms/test/form_control_spec.ts @@ -80,17 +80,84 @@ export function main() { it('should default to on change', () => { const c = new FormControl(''); - expect(c._updateOn).toEqual('change'); + 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'); + expect(c.updateOn).toEqual('change'); }); it('should set updateOn when updating on blur', () => { const c = new FormControl('', {updateOn: 'blur'}); - expect(c._updateOn).toEqual('blur'); + expect(c.updateOn).toEqual('blur'); + }); + + describe('in groups and arrays', () => { + it('should default to group updateOn when not set in control', () => { + const g = + new FormGroup({one: new FormControl(), two: new FormControl()}, {updateOn: 'blur'}); + + expect(g.get('one') !.updateOn).toEqual('blur'); + expect(g.get('two') !.updateOn).toEqual('blur'); + }); + + it('should default to array updateOn when not set in control', () => { + const a = new FormArray([new FormControl(), new FormControl()], {updateOn: 'blur'}); + + expect(a.get([0]) !.updateOn).toEqual('blur'); + expect(a.get([1]) !.updateOn).toEqual('blur'); + }); + + it('should set updateOn with nested groups', () => { + const g = new FormGroup( + { + group: new FormGroup({one: new FormControl(), two: new FormControl()}), + }, + {updateOn: 'blur'}); + + expect(g.get('group.one') !.updateOn).toEqual('blur'); + expect(g.get('group.two') !.updateOn).toEqual('blur'); + expect(g.get('group') !.updateOn).toEqual('blur'); + }); + + it('should set updateOn with nested arrays', () => { + const g = new FormGroup( + { + arr: new FormArray([new FormControl(), new FormControl()]), + }, + {updateOn: 'blur'}); + + expect(g.get(['arr', 0]) !.updateOn).toEqual('blur'); + expect(g.get(['arr', 1]) !.updateOn).toEqual('blur'); + expect(g.get('arr') !.updateOn).toEqual('blur'); + }); + + it('should allow control updateOn to override group updateOn', () => { + const g = new FormGroup( + {one: new FormControl('', {updateOn: 'change'}), two: new FormControl()}, + {updateOn: 'blur'}); + + expect(g.get('one') !.updateOn).toEqual('change'); + expect(g.get('two') !.updateOn).toEqual('blur'); + }); + + it('should set updateOn with complex setup', () => { + const g = new FormGroup({ + group: new FormGroup( + {one: new FormControl('', {updateOn: 'change'}), two: new FormControl()}, + {updateOn: 'blur'}), + groupTwo: new FormGroup({one: new FormControl()}, {updateOn: 'submit'}), + three: new FormControl() + }); + + expect(g.get('group.one') !.updateOn).toEqual('change'); + expect(g.get('group.two') !.updateOn).toEqual('blur'); + expect(g.get('groupTwo.one') !.updateOn).toEqual('submit'); + expect(g.get('three') !.updateOn).toEqual('change'); + }); + + }); }); diff --git a/packages/forms/test/reactive_integration_spec.ts b/packages/forms/test/reactive_integration_spec.ts index 4db317ca6e..a37ea8f458 100644 --- a/packages/forms/test/reactive_integration_spec.ts +++ b/packages/forms/test/reactive_integration_spec.ts @@ -931,7 +931,6 @@ export function main() { sub.unsubscribe(); }); - it('should mark as pristine properly if pending dirty', () => { const fixture = initTest(FormControlComp); const control = new FormControl('', {updateOn: 'blur'}); @@ -955,6 +954,94 @@ export function main() { expect(control.dirty).toBe(false, 'Expected pending dirty value to reset.'); }); + it('should update on blur with group updateOn', () => { + const fixture = initTest(FormGroupComp); + const control = new FormControl('', Validators.required); + const formGroup = new FormGroup({login: control}, {updateOn: 'blur'}); + fixture.componentInstance.form = formGroup; + 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 update on blur with array updateOn', () => { + const fixture = initTest(FormArrayComp); + const control = new FormControl('', Validators.required); + const cityArray = new FormArray([control], {updateOn: 'blur'}); + const formGroup = new FormGroup({cities: cityArray}); + fixture.componentInstance.form = formGroup; + fixture.componentInstance.cityArray = cityArray; + 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 allow child control updateOn blur to override group updateOn', () => { + const fixture = initTest(NestedFormGroupComp); + const loginControl = + new FormControl('', {validators: Validators.required, updateOn: 'change'}); + const passwordControl = new FormControl('', Validators.required); + const formGroup = new FormGroup( + {signin: new FormGroup({login: loginControl, password: passwordControl})}, + {updateOn: 'blur'}); + fixture.componentInstance.form = formGroup; + fixture.detectChanges(); + + const [loginInput, passwordInput] = fixture.debugElement.queryAll(By.css('input')); + loginInput.nativeElement.value = 'Nancy'; + dispatchEvent(loginInput.nativeElement, 'input'); + fixture.detectChanges(); + + expect(loginControl.value).toEqual('Nancy', 'Expected value change on input.'); + expect(loginControl.valid).toBe(true, 'Expected validation to run on input.'); + + passwordInput.nativeElement.value = 'Carson'; + dispatchEvent(passwordInput.nativeElement, 'input'); + fixture.detectChanges(); + + expect(passwordControl.value) + .toEqual('', 'Expected value to remain unchanged until blur.'); + expect(passwordControl.valid).toBe(false, 'Expected no validation to occur until blur.'); + + dispatchEvent(passwordInput.nativeElement, 'blur'); + fixture.detectChanges(); + + expect(passwordControl.value) + .toEqual('Carson', 'Expected value to change once control is blurred.'); + expect(passwordControl.valid) + .toBe(true, 'Expected validation to run once control is blurred.'); + }); + + }); describe('on submit', () => { @@ -1193,7 +1280,6 @@ export function main() { }); - it('should mark as untouched properly if pending touched', () => { const fixture = initTest(FormGroupComp); const formGroup = new FormGroup({login: new FormControl('', {updateOn: 'submit'})}); @@ -1216,6 +1302,99 @@ export function main() { expect(formGroup.touched).toBe(false, 'Expected touched to stay false on submit.'); }); + it('should update on submit with group updateOn', () => { + const fixture = initTest(FormGroupComp); + const control = new FormControl('', Validators.required); + const formGroup = new FormGroup({login: control}, {updateOn: 'submit'}); + fixture.componentInstance.form = formGroup; + 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 submit.'); + expect(control.valid).toBe(false, 'Expected no validation to occur until submit.'); + + dispatchEvent(input, 'blur'); + fixture.detectChanges(); + + expect(control.value).toEqual('', 'Expected value to remain unchanged until submit.'); + expect(control.valid).toBe(false, 'Expected no validation to occur until submit.'); + + const form = fixture.debugElement.query(By.css('form')).nativeElement; + dispatchEvent(form, 'submit'); + fixture.detectChanges(); + + expect(control.value).toEqual('Nancy', 'Expected value to change on submit.'); + expect(control.valid).toBe(true, 'Expected validation to run on submit.'); + + }); + + it('should update on submit with array updateOn', () => { + const fixture = initTest(FormArrayComp); + const control = new FormControl('', Validators.required); + const cityArray = new FormArray([control], {updateOn: 'submit'}); + const formGroup = new FormGroup({cities: cityArray}); + fixture.componentInstance.form = formGroup; + fixture.componentInstance.cityArray = cityArray; + 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 submit.'); + expect(control.valid).toBe(false, 'Expected no validation to occur until submit.'); + + + const form = fixture.debugElement.query(By.css('form')).nativeElement; + dispatchEvent(form, 'submit'); + fixture.detectChanges(); + + expect(control.value).toEqual('Nancy', 'Expected value to change once control on submit'); + expect(control.valid).toBe(true, 'Expected validation to run on submit.'); + + }); + + it('should allow child control updateOn submit to override group updateOn', () => { + const fixture = initTest(NestedFormGroupComp); + const loginControl = + new FormControl('', {validators: Validators.required, updateOn: 'change'}); + const passwordControl = new FormControl('', Validators.required); + const formGroup = new FormGroup( + {signin: new FormGroup({login: loginControl, password: passwordControl})}, + {updateOn: 'submit'}); + fixture.componentInstance.form = formGroup; + fixture.detectChanges(); + + const [loginInput, passwordInput] = fixture.debugElement.queryAll(By.css('input')); + loginInput.nativeElement.value = 'Nancy'; + dispatchEvent(loginInput.nativeElement, 'input'); + fixture.detectChanges(); + + expect(loginControl.value).toEqual('Nancy', 'Expected value change on input.'); + expect(loginControl.valid).toBe(true, 'Expected validation to run on input.'); + + passwordInput.nativeElement.value = 'Carson'; + dispatchEvent(passwordInput.nativeElement, 'input'); + fixture.detectChanges(); + + expect(passwordControl.value) + .toEqual('', 'Expected value to remain unchanged until submit.'); + expect(passwordControl.valid) + .toBe(false, 'Expected no validation to occur until submit.'); + + const form = fixture.debugElement.query(By.css('form')).nativeElement; + dispatchEvent(form, 'submit'); + fixture.detectChanges(); + + expect(passwordControl.value).toEqual('Carson', 'Expected value to change on submit.'); + expect(passwordControl.valid).toBe(true, 'Expected validation to run on submit.'); + }); + }); }); @@ -2008,13 +2187,13 @@ class NestedFormGroupComp { @Component({ selector: 'form-array-comp', template: ` -
+
-
` + ` }) class FormArrayComp { form: FormGroup; diff --git a/tools/public_api_guard/forms/forms.d.ts b/tools/public_api_guard/forms/forms.d.ts index e82c9caf13..e5d27c631f 100644 --- a/tools/public_api_guard/forms/forms.d.ts +++ b/tools/public_api_guard/forms/forms.d.ts @@ -14,6 +14,7 @@ export declare abstract class AbstractControl { readonly statusChanges: Observable; readonly touched: boolean; readonly untouched: boolean; + readonly updateOn: FormHooks; readonly valid: boolean; validator: ValidatorFn | null; readonly value: any;