feat(forms): add default updateOn values for groups and arrays (#18536)

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.
This commit is contained in:
Kara 2017-08-09 15:41:53 -07:00 committed by Victor Berchet
parent dca50deae4
commit ff5c58be6b
6 changed files with 302 additions and 21 deletions

View File

@ -150,7 +150,7 @@ export class FormGroupDirective extends ControlContainer implements Form,
_syncPendingControls() { _syncPendingControls() {
this.form._syncPendingControls(); this.form._syncPendingControls();
this.directives.forEach(dir => { this.directives.forEach(dir => {
if (dir.control._updateOn === 'submit') { if (dir.control.updateOn === 'submit') {
dir.viewToModelUpdate(dir.control._pendingValue); dir.viewToModelUpdate(dir.control._pendingValue);
} }
}); });

View File

@ -84,7 +84,7 @@ function setUpViewChangePipeline(control: FormControl, dir: NgControl): void {
control._pendingValue = newValue; control._pendingValue = newValue;
control._pendingDirty = true; 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(() => { dir.valueAccessor !.registerOnTouched(() => {
control._pendingTouched = true; control._pendingTouched = true;
if (control._updateOn === 'blur') updateControl(control, dir); if (control.updateOn === 'blur') updateControl(control, dir);
if (control._updateOn !== 'submit') control.markAsTouched(); if (control.updateOn !== 'submit') control.markAsTouched();
}); });
} }

View File

@ -118,6 +118,9 @@ export abstract class AbstractControl {
/** @internal */ /** @internal */
_onCollectionChange = () => {}; _onCollectionChange = () => {};
/** @internal */
_updateOn: FormHooks;
private _valueChanges: EventEmitter<any>; private _valueChanges: EventEmitter<any>;
private _statusChanges: EventEmitter<any>; private _statusChanges: EventEmitter<any>;
private _status: string; private _status: string;
@ -242,6 +245,15 @@ export abstract class AbstractControl {
*/ */
get statusChanges(): Observable<any> { return this._statusChanges; } get statusChanges(): Observable<any> { 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 * Sets the synchronous validators that are active on this control. Calling
* this will overwrite any existing sync validators. * this will overwrite any existing sync validators.
@ -624,6 +636,13 @@ export abstract class AbstractControl {
/** @internal */ /** @internal */
_registerOnCollectionChange(fn: () => void): void { this._onCollectionChange = fn; } _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 */ /** @internal */
_onChange: Function[] = []; _onChange: Function[] = [];
/** @internal */
_updateOn: FormHooks = 'change';
/** @internal */ /** @internal */
_pendingValue: any; _pendingValue: any;
@ -841,7 +857,7 @@ export class FormControl extends AbstractControl {
/** @internal */ /** @internal */
_syncPendingControls(): boolean { _syncPendingControls(): boolean {
if (this._updateOn === 'submit') { if (this.updateOn === 'submit') {
this.setValue(this._pendingValue, {onlySelf: true, emitModelToViewChange: false}); this.setValue(this._pendingValue, {onlySelf: true, emitModelToViewChange: false});
if (this._pendingDirty) this.markAsDirty(); if (this._pendingDirty) this.markAsDirty();
if (this._pendingTouched) this.markAsTouched(); if (this._pendingTouched) this.markAsTouched();
@ -859,12 +875,6 @@ export class FormControl extends AbstractControl {
this._value = this._pendingValue = 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 !;
}
}
} }
/** /**
@ -925,6 +935,17 @@ export class FormControl extends AbstractControl {
* }, {validators: passwordMatchValidator, asyncValidators: otherValidator}); * }, {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` * * **npm package**: `@angular/forms`
* *
* @stable * @stable
@ -938,6 +959,7 @@ export class FormGroup extends AbstractControl {
coerceToValidator(validatorOrOpts), coerceToValidator(validatorOrOpts),
coerceToAsyncValidator(asyncValidator, validatorOrOpts)); coerceToAsyncValidator(asyncValidator, validatorOrOpts));
this._initObservables(); this._initObservables();
this._setUpdateStrategy(validatorOrOpts);
this._setUpControls(); this._setUpControls();
this.updateValueAndValidity({onlySelf: true, emitEvent: false}); this.updateValueAndValidity({onlySelf: true, emitEvent: false});
} }
@ -1242,6 +1264,17 @@ export class FormGroup extends AbstractControl {
* ], {validators: myValidator, asyncValidators: myAsyncValidator}); * ], {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 * ### Adding or removing controls
* *
* To change the controls in the array, use the `push`, `insert`, or `removeAt` methods * 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), coerceToValidator(validatorOrOpts),
coerceToAsyncValidator(asyncValidator, validatorOrOpts)); coerceToAsyncValidator(asyncValidator, validatorOrOpts));
this._initObservables(); this._initObservables();
this._setUpdateStrategy(validatorOrOpts);
this._setUpControls(); this._setUpControls();
this.updateValueAndValidity({onlySelf: true, emitEvent: false}); this.updateValueAndValidity({onlySelf: true, emitEvent: false});
} }

View File

@ -80,17 +80,84 @@ export function main() {
it('should default to on change', () => { it('should default to on change', () => {
const c = new FormControl(''); const c = new FormControl('');
expect(c._updateOn).toEqual('change'); expect(c.updateOn).toEqual('change');
}); });
it('should default to on change with an options obj', () => { it('should default to on change with an options obj', () => {
const c = new FormControl('', {validators: Validators.required}); 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', () => { it('should set updateOn when updating on blur', () => {
const c = new FormControl('', {updateOn: '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');
});
}); });
}); });

View File

@ -931,7 +931,6 @@ export function main() {
sub.unsubscribe(); sub.unsubscribe();
}); });
it('should mark as pristine properly if pending dirty', () => { it('should mark as pristine properly if pending dirty', () => {
const fixture = initTest(FormControlComp); const fixture = initTest(FormControlComp);
const control = new FormControl('', {updateOn: 'blur'}); const control = new FormControl('', {updateOn: 'blur'});
@ -955,6 +954,94 @@ export function main() {
expect(control.dirty).toBe(false, 'Expected pending dirty value to reset.'); 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', () => { describe('on submit', () => {
@ -1193,7 +1280,6 @@ export function main() {
}); });
it('should mark as untouched properly if pending touched', () => { it('should mark as untouched properly if pending touched', () => {
const fixture = initTest(FormGroupComp); const fixture = initTest(FormGroupComp);
const formGroup = new FormGroup({login: new FormControl('', {updateOn: 'submit'})}); 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.'); 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({ @Component({
selector: 'form-array-comp', selector: 'form-array-comp',
template: ` template: `
<div [formGroup]="form"> <form [formGroup]="form">
<div formArrayName="cities"> <div formArrayName="cities">
<div *ngFor="let city of cityArray.controls; let i=index"> <div *ngFor="let city of cityArray.controls; let i=index">
<input [formControlName]="i"> <input [formControlName]="i">
</div> </div>
</div> </div>
</div>` </form>`
}) })
class FormArrayComp { class FormArrayComp {
form: FormGroup; form: FormGroup;

View File

@ -14,6 +14,7 @@ export declare abstract class AbstractControl {
readonly statusChanges: Observable<any>; readonly statusChanges: Observable<any>;
readonly touched: boolean; readonly touched: boolean;
readonly untouched: boolean; readonly untouched: boolean;
readonly updateOn: FormHooks;
readonly valid: boolean; readonly valid: boolean;
validator: ValidatorFn | null; validator: ValidatorFn | null;
readonly value: any; readonly value: any;