From 0b5b867f8e4c557222b14f5801a42758f114cb8c Mon Sep 17 00:00:00 2001 From: Kara Date: Mon, 7 Aug 2017 15:39:25 -0700 Subject: [PATCH] feat(forms): add updateOn submit option to FormControls (#18514) --- .../form_group_directive.ts | 11 + packages/forms/src/directives/shared.ts | 22 +- packages/forms/src/model.ts | 50 ++- .../forms/test/reactive_integration_spec.ts | 348 +++++++++++++++++- 4 files changed, 411 insertions(+), 20 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 01fe3bf2af757..15d08eeab3935 100644 --- a/packages/forms/src/directives/reactive_directives/form_group_directive.ts +++ b/packages/forms/src/directives/reactive_directives/form_group_directive.ts @@ -134,6 +134,7 @@ export class FormGroupDirective extends ControlContainer implements Form, onSubmit($event: Event): boolean { this._submitted = true; + this._syncPendingControls(); this.ngSubmit.emit($event); return false; } @@ -145,6 +146,16 @@ export class FormGroupDirective extends ControlContainer implements Form, this._submitted = false; } + /** @internal */ + _syncPendingControls() { + this.form._syncPendingControls(); + this.directives.forEach(dir => { + if (dir.control._updateOn === 'submit') { + dir.viewToModelUpdate(dir.control._pendingValue); + } + }); + } + /** @internal */ _updateDomValue() { this.directives.forEach(dir => { diff --git a/packages/forms/src/directives/shared.ts b/packages/forms/src/directives/shared.ts index a930311f91e9c..428f50274302c 100644 --- a/packages/forms/src/directives/shared.ts +++ b/packages/forms/src/directives/shared.ts @@ -84,25 +84,25 @@ function setUpViewChangePipeline(control: FormControl, dir: NgControl): void { control._pendingValue = newValue; control._pendingDirty = true; - if (control._updateOn === 'change') { - dir.viewToModelUpdate(newValue); - control.markAsDirty(); - control.setValue(newValue, {emitModelToViewChange: false}); - } + if (control._updateOn === 'change') updateControl(control, dir); }); } 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(); + control._pendingTouched = true; + + if (control._updateOn === 'blur') updateControl(control, dir); + if (control._updateOn !== 'submit') control.markAsTouched(); }); } +function updateControl(control: FormControl, dir: NgControl): void { + dir.viewToModelUpdate(control._pendingValue); + if (control._pendingDirty) control.markAsDirty(); + control.setValue(control._pendingValue, {emitModelToViewChange: false}); +} + function setUpModelChangePipeline(control: FormControl, dir: NgControl): void { control.registerOnChange((newValue: any, emitModelEvent: boolean) => { // control -> view diff --git a/packages/forms/src/model.ts b/packages/forms/src/model.ts index 8e3096956f13f..b0d1e6405843a 100644 --- a/packages/forms/src/model.ts +++ b/packages/forms/src/model.ts @@ -78,7 +78,7 @@ function coerceToAsyncValidator( origAsyncValidator || null; } -export type FormHooks = 'change' | 'blur'; +export type FormHooks = 'change' | 'blur' | 'submit'; export interface AbstractControlOptions { validators?: ValidatorFn|ValidatorFn[]|null; @@ -108,6 +108,13 @@ function isOptionsObj( export abstract class AbstractControl { /** @internal */ _value: any; + + /** @internal */ + _pendingDirty: boolean; + + /** @internal */ + _pendingTouched: boolean; + /** @internal */ _onCollectionChange = () => {}; @@ -284,6 +291,7 @@ export abstract class AbstractControl { */ markAsUntouched(opts: {onlySelf?: boolean} = {}): void { this._touched = false; + this._pendingTouched = false; this._forEachChild( (control: AbstractControl) => { control.markAsUntouched({onlySelf: true}); }); @@ -316,6 +324,7 @@ export abstract class AbstractControl { */ markAsPristine(opts: {onlySelf?: boolean} = {}): void { this._pristine = true; + this._pendingDirty = false; this._forEachChild((control: AbstractControl) => { control.markAsPristine({onlySelf: true}); }); @@ -568,6 +577,9 @@ export abstract class AbstractControl { /** @internal */ abstract _allControlsDisabled(): boolean; + /** @internal */ + abstract _syncPendingControls(): boolean; + /** @internal */ _anyControlsHaveStatus(status: string): boolean { return this._anyControls((control: AbstractControl) => control.status === status); @@ -672,6 +684,9 @@ export abstract class AbstractControl { * const c = new FormControl('', { updateOn: 'blur' }); * ``` * + * You can also set `updateOn` to `'submit'`, which will delay value and validity + * updates until the parent form of the control fires a submit event. + * * See its superclass, {@link AbstractControl}, for more properties and methods. * * * **npm package**: `@angular/forms` @@ -688,9 +703,6 @@ export class FormControl extends AbstractControl { /** @internal */ _pendingValue: any; - /** @internal */ - _pendingDirty: boolean; - constructor( formState: any = null, validatorOrOpts?: ValidatorFn|ValidatorFn[]|AbstractControlOptions|null, @@ -782,7 +794,6 @@ 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); } @@ -828,6 +839,17 @@ export class FormControl extends AbstractControl { */ _forEachChild(cb: Function): void {} + /** @internal */ + _syncPendingControls(): boolean { + if (this._updateOn === 'submit') { + this.setValue(this._pendingValue, {onlySelf: true, emitModelToViewChange: false}); + if (this._pendingDirty) this.markAsDirty(); + if (this._pendingTouched) this.markAsTouched(); + return true; + } + return false; + } + private _applyFormState(formState: any) { if (this._isBoxedValue(formState)) { this._value = this._pendingValue = formState.value; @@ -1092,6 +1114,15 @@ export class FormGroup extends AbstractControl { }); } + /** @internal */ + _syncPendingControls(): boolean { + let subtreeUpdated = this._reduceChildren(false, (updated: boolean, child: AbstractControl) => { + return child._syncPendingControls() ? true : updated; + }); + if (subtreeUpdated) this.updateValueAndValidity({onlySelf: true}); + return subtreeUpdated; + } + /** @internal */ _throwIfControlMissing(name: string): void { if (!Object.keys(this.controls).length) { @@ -1404,6 +1435,15 @@ export class FormArray extends AbstractControl { }); } + /** @internal */ + _syncPendingControls(): boolean { + let subtreeUpdated = this.controls.reduce((updated: boolean, child: AbstractControl) => { + return child._syncPendingControls() ? true : updated; + }, false); + if (subtreeUpdated) this.updateValueAndValidity({onlySelf: true}); + return subtreeUpdated; + } + /** @internal */ _throwIfControlMissing(index: number): void { if (!this.controls.length) { diff --git a/packages/forms/test/reactive_integration_spec.ts b/packages/forms/test/reactive_integration_spec.ts index f8de924e3f142..4db317ca6ecea 100644 --- a/packages/forms/test/reactive_integration_spec.ts +++ b/packages/forms/test/reactive_integration_spec.ts @@ -12,8 +12,10 @@ import {AbstractControl, AsyncValidator, AsyncValidatorFn, COMPOSITION_BUFFER_MO import {By} from '@angular/platform-browser/src/dom/debug/by'; import {getDOM} from '@angular/platform-browser/src/dom/dom_adapter'; import {dispatchEvent} from '@angular/platform-browser/testing/src/browser_util'; +import {merge} from 'rxjs/observable/merge'; import {timer} from 'rxjs/observable/timer'; import {_do} from 'rxjs/operator/do'; + import {MyInput, MyInputForm} from './value_accessor_integration_spec'; export function main() { @@ -898,8 +900,320 @@ export function main() { dispatchEvent(input, 'blur'); fixture.detectChanges(); - expect(control.dirty).toBe(false, 'Expected pending dirty value to reset.'); + expect(input.value).toEqual('', 'Expected view value to reset'); expect(control.value).toBe(null, 'Expected pending value to reset.'); + expect(control.dirty).toBe(false, 'Expected pending dirty value to reset.'); + }); + + it('should not emit valueChanges or statusChanges until blur', () => { + const fixture = initTest(FormControlComp); + const control = new FormControl('', {validators: Validators.required, updateOn: 'blur'}); + fixture.componentInstance.control = control; + fixture.detectChanges(); + const values: string[] = []; + + const sub = + merge(control.valueChanges, control.statusChanges).subscribe(val => values.push(val)); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + input.value = 'Nancy'; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + + expect(values).toEqual([], 'Expected no valueChanges or statusChanges on input.'); + + dispatchEvent(input, 'blur'); + fixture.detectChanges(); + + expect(values).toEqual( + ['Nancy', 'VALID'], 'Expected valueChanges and statusChanges on blur.'); + + sub.unsubscribe(); + }); + + + it('should mark as pristine properly if pending dirty', () => { + const fixture = initTest(FormControlComp); + const control = new FormControl('', {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(); + + control.markAsPristine(); + expect(control.dirty).toBe(false, 'Expected control to become pristine.'); + + dispatchEvent(input, 'blur'); + fixture.detectChanges(); + + expect(control.dirty).toBe(false, 'Expected pending dirty value to reset.'); + }); + + }); + + describe('on submit', () => { + + it('should set initial value and validity on init', () => { + const fixture = initTest(FormGroupComp); + const form = new FormGroup({ + login: + new FormControl('Nancy', {validators: Validators.required, updateOn: 'submit'}) + }); + fixture.componentInstance.form = form; + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + expect(input.value).toEqual('Nancy', 'Expected initial value to propagate to view.'); + expect(form.value).toEqual({login: 'Nancy'}, 'Expected initial value to be set.'); + expect(form.valid).toBe(true, 'Expected form to run validation on initial value.'); + }); + + it('should not update value or validity until submit', () => { + const fixture = initTest(FormGroupComp); + const formGroup = new FormGroup( + {login: new FormControl('', {validators: Validators.required, 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(formGroup.value) + .toEqual({login: ''}, 'Expected form value to remain unchanged on input.'); + expect(formGroup.valid).toBe(false, 'Expected form validation not to run on input.'); + + dispatchEvent(input, 'blur'); + fixture.detectChanges(); + + expect(formGroup.value) + .toEqual({login: ''}, 'Expected form value to remain unchanged on blur.'); + expect(formGroup.valid).toBe(false, 'Expected form validation not to run on blur.'); + + const form = fixture.debugElement.query(By.css('form')).nativeElement; + dispatchEvent(form, 'submit'); + fixture.detectChanges(); + + expect(formGroup.value) + .toEqual({login: 'Nancy'}, 'Expected form value to update on submit.'); + expect(formGroup.valid).toBe(true, 'Expected form validation to run on submit.'); + }); + + it('should not update after submit until a second submit', () => { + const fixture = initTest(FormGroupComp); + const formGroup = new FormGroup( + {login: new FormControl('', {validators: Validators.required, 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(); + + const form = fixture.debugElement.query(By.css('form')).nativeElement; + dispatchEvent(form, 'submit'); + fixture.detectChanges(); + + input.value = ''; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + + expect(formGroup.value) + .toEqual({login: 'Nancy'}, 'Expected value not to change until a second submit.'); + expect(formGroup.valid) + .toBe(true, 'Expected validation not to run until a second submit.'); + + dispatchEvent(form, 'submit'); + fixture.detectChanges(); + + expect(formGroup.value) + .toEqual({login: ''}, 'Expected value to update on the second submit.'); + expect(formGroup.valid).toBe(false, 'Expected validation to run on a second submit.'); + }); + + it('should not wait for submit to set value programmatically', () => { + const fixture = initTest(FormGroupComp); + const formGroup = new FormGroup( + {login: new FormControl('', {validators: Validators.required, updateOn: 'submit'})}); + fixture.componentInstance.form = formGroup; + fixture.detectChanges(); + + formGroup.setValue({login: 'Nancy'}); + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + expect(input.value).toEqual('Nancy', 'Expected view value to update immediately.'); + expect(formGroup.value) + .toEqual({login: 'Nancy'}, 'Expected form value to update immediately.'); + expect(formGroup.valid).toBe(true, 'Expected form validation to run immediately.'); + }); + + it('should not update dirty until submit', () => { + const fixture = initTest(FormGroupComp); + const formGroup = new FormGroup({login: new FormControl('', {updateOn: 'submit'})}); + fixture.componentInstance.form = formGroup; + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + + expect(formGroup.dirty).toBe(false, 'Expected dirty not to change on input.'); + + dispatchEvent(input, 'blur'); + fixture.detectChanges(); + + expect(formGroup.dirty).toBe(false, 'Expected dirty not to change on blur.'); + + const form = fixture.debugElement.query(By.css('form')).nativeElement; + dispatchEvent(form, 'submit'); + fixture.detectChanges(); + + expect(formGroup.dirty).toBe(true, 'Expected dirty to update on submit.'); + }); + + it('should not update touched until submit', () => { + const fixture = initTest(FormGroupComp); + const formGroup = new FormGroup({login: new FormControl('', {updateOn: 'submit'})}); + fixture.componentInstance.form = formGroup; + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + dispatchEvent(input, 'blur'); + fixture.detectChanges(); + + expect(formGroup.touched).toBe(false, 'Expected touched not to change until submit.'); + + const form = fixture.debugElement.query(By.css('form')).nativeElement; + dispatchEvent(form, 'submit'); + fixture.detectChanges(); + + expect(formGroup.touched).toBe(true, 'Expected touched to update on submit.'); + }); + + it('should reset properly', () => { + const fixture = initTest(FormGroupComp); + const formGroup = new FormGroup( + {login: new FormControl('', {validators: Validators.required, 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(); + + dispatchEvent(input, 'blur'); + fixture.detectChanges(); + + formGroup.reset(); + fixture.detectChanges(); + + expect(input.value).toEqual('', 'Expected view value to reset.'); + expect(formGroup.value).toEqual({login: null}, 'Expected form value to reset'); + expect(formGroup.dirty).toBe(false, 'Expected dirty to stay false on reset.'); + expect(formGroup.touched).toBe(false, 'Expected touched to stay false on reset.'); + + const form = fixture.debugElement.query(By.css('form')).nativeElement; + dispatchEvent(form, 'submit'); + fixture.detectChanges(); + + expect(formGroup.value) + .toEqual({login: null}, 'Expected form value to stay empty on submit'); + expect(formGroup.dirty).toBe(false, 'Expected dirty to stay false on submit.'); + expect(formGroup.touched).toBe(false, 'Expected touched to stay false on submit.'); + }); + + it('should not emit valueChanges or statusChanges until submit', () => { + const fixture = initTest(FormGroupComp); + const control = + new FormControl('', {validators: Validators.required, updateOn: 'submit'}); + const formGroup = new FormGroup({login: control}); + fixture.componentInstance.form = formGroup; + fixture.detectChanges(); + + const values: string[] = []; + const streams = merge( + control.valueChanges, control.statusChanges, formGroup.valueChanges, + formGroup.statusChanges); + const sub = streams.subscribe(val => values.push(val)); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + input.value = 'Nancy'; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + + expect(values).toEqual([], 'Expected no valueChanges or statusChanges on input'); + + dispatchEvent(input, 'blur'); + fixture.detectChanges(); + + expect(values).toEqual([], 'Expected no valueChanges or statusChanges on blur'); + + const form = fixture.debugElement.query(By.css('form')).nativeElement; + dispatchEvent(form, 'submit'); + fixture.detectChanges(); + + expect(values).toEqual( + ['Nancy', 'VALID', {login: 'Nancy'}, 'VALID'], + 'Expected valueChanges and statusChanges to update on submit.'); + + sub.unsubscribe(); + }); + + it('should not run validation for onChange controls on submit', () => { + const validatorSpy = jasmine.createSpy('validator'); + const groupValidatorSpy = jasmine.createSpy('groupValidatorSpy'); + + const fixture = initTest(NestedFormGroupComp); + const formGroup = new FormGroup({ + signin: new FormGroup({login: new FormControl(), password: new FormControl()}), + email: new FormControl('', {updateOn: 'submit'}) + }); + fixture.componentInstance.form = formGroup; + fixture.detectChanges(); + + formGroup.get('signin.login') !.setValidators(validatorSpy); + formGroup.get('signin') !.setValidators(groupValidatorSpy); + + const form = fixture.debugElement.query(By.css('form')).nativeElement; + dispatchEvent(form, 'submit'); + fixture.detectChanges(); + + expect(validatorSpy).not.toHaveBeenCalled(); + expect(groupValidatorSpy).not.toHaveBeenCalled(); + + }); + + + it('should mark as untouched properly if pending touched', () => { + const fixture = initTest(FormGroupComp); + const formGroup = new FormGroup({login: new FormControl('', {updateOn: 'submit'})}); + fixture.componentInstance.form = formGroup; + fixture.detectChanges(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + dispatchEvent(input, 'blur'); + fixture.detectChanges(); + + formGroup.markAsUntouched(); + fixture.detectChanges(); + + expect(formGroup.touched).toBe(false, 'Expected group to become untouched.'); + + const form = fixture.debugElement.query(By.css('form')).nativeElement; + dispatchEvent(form, 'submit'); + fixture.detectChanges(); + + expect(formGroup.touched).toBe(false, 'Expected touched to stay false on submit.'); }); }); @@ -960,6 +1274,33 @@ export function main() { expect(input.selectionStart).toEqual(1); })); + it('should work with updateOn submit', fakeAsync(() => { + const fixture = initTest(FormGroupNgModel); + const formGroup = new FormGroup({login: new FormControl('', {updateOn: 'submit'})}); + fixture.componentInstance.form = formGroup; + fixture.componentInstance.login = 'initial'; + fixture.detectChanges(); + tick(); + + const input = fixture.debugElement.query(By.css('input')).nativeElement; + input.value = 'Nancy'; + dispatchEvent(input, 'input'); + fixture.detectChanges(); + tick(); + + expect(fixture.componentInstance.login) + .toEqual('initial', 'Expected ngModel value to remain unchanged on input.'); + + const form = fixture.debugElement.query(By.css('form')).nativeElement; + dispatchEvent(form, 'submit'); + fixture.detectChanges(); + tick(); + + expect(fixture.componentInstance.login) + .toEqual('Nancy', 'Expected ngModel value to update on submit.'); + + })); + }); describe('validations', () => { @@ -1697,13 +2038,12 @@ class FormArrayNestedGroup { cityArray: FormArray; } - @Component({ selector: 'form-group-ng-model', template: ` -
+
-
` + ` }) class FormGroupNgModel { form: FormGroup;