From 77b62a52c0dc86bc3947778d161087e970768789 Mon Sep 17 00:00:00 2001 From: Oussama Ben Brahim <14368835+benbraou@users.noreply.github.com> Date: Sat, 13 Jun 2020 21:47:30 +0200 Subject: [PATCH] fix(forms): handle form groups/arrays own pending async validation (#22575) introduce a boolean to track form groups/arrays own pending async validation to distinguish between pending state due to children and pending state due to own validation Fixes #10064 PR Close #22575 --- packages/forms/src/model.ts | 20 +- packages/forms/test/form_group_spec.ts | 565 ++++++++++++++++++ .../forms/test/reactive_integration_spec.ts | 88 ++- 3 files changed, 668 insertions(+), 5 deletions(-) diff --git a/packages/forms/src/model.ts b/packages/forms/src/model.ts index 7aefdd8b01..3bc064915d 100644 --- a/packages/forms/src/model.ts +++ b/packages/forms/src/model.ts @@ -137,6 +137,13 @@ export abstract class AbstractControl { // TODO(issue/24571): remove '!'. _pendingDirty!: boolean; + /** + * Indicates that a control has its own pending asynchronous validation in progress. + * + * @internal + */ + _hasOwnPendingAsyncValidator = false; + /** @internal */ // TODO(issue/24571): remove '!'. _pendingTouched!: boolean; @@ -675,15 +682,22 @@ export abstract class AbstractControl { private _runAsyncValidator(emitEvent?: boolean): void { if (this.asyncValidator) { (this as {status: string}).status = PENDING; + this._hasOwnPendingAsyncValidator = true; const obs = toObservable(this.asyncValidator(this)); - this._asyncValidationSubscription = - obs.subscribe((errors: ValidationErrors|null) => this.setErrors(errors, {emitEvent})); + this._asyncValidationSubscription = obs.subscribe((errors: ValidationErrors|null) => { + this._hasOwnPendingAsyncValidator = false; + // This will trigger the recalculation of the validation status, which depends on + // the state of the asynchronous validation (whether it is in progress or not). So, it is + // necessary that we have updated the `_hasOwnPendingAsyncValidator` boolean flag first. + this.setErrors(errors, {emitEvent}); + }); } } private _cancelExistingSubscription(): void { if (this._asyncValidationSubscription) { this._asyncValidationSubscription.unsubscribe(); + this._hasOwnPendingAsyncValidator = false; } } @@ -838,7 +852,7 @@ export abstract class AbstractControl { private _calculateStatus(): string { if (this._allControlsDisabled()) return DISABLED; if (this.errors) return INVALID; - if (this._anyControlsHaveStatus(PENDING)) return PENDING; + if (this._hasOwnPendingAsyncValidator || this._anyControlsHaveStatus(PENDING)) return PENDING; if (this._anyControlsHaveStatus(INVALID)) return INVALID; return VALID; } diff --git a/packages/forms/test/form_group_spec.ts b/packages/forms/test/form_group_spec.ts index 7f1eaf57cf..c46026e515 100644 --- a/packages/forms/test/form_group_spec.ts +++ b/packages/forms/test/form_group_spec.ts @@ -39,6 +39,39 @@ function asyncValidator(expected: string, timeouts = {}) { }; } +function simpleAsyncValidator({ + timeout = 0, + shouldFail, + customError = + { + async: true + } +}: {timeout?: number, shouldFail: boolean, customError?: any}) { + return (c: AbstractControl) => { + const res = shouldFail ? customError : null; + + if (timeout === 0) { + return of(res); + } + + let resolve: (result: any) => void = undefined!; + const promise = new Promise(res => { + resolve = res; + }); + + setTimeout(() => { + resolve(res); + }, timeout); + + return promise; + }; +} + +function currentStateOf(controls: AbstractControl[]): + {errors: any; pending: boolean; status: string;}[] { + return controls.map(c => ({errors: c.errors, pending: c.pending, status: c.status})); +} + function asyncValidatorReturningObservable(c: AbstractControl) { const e = new EventEmitter(); Promise.resolve(null).then(() => { @@ -981,6 +1014,538 @@ describe('FormGroup', () => { expect(g.errors).toEqual({'async': true}); expect(g.get('one')!.errors).toEqual({'async': true}); })); + + it('should handle successful async FormGroup resolving synchronously before a successful async child validator', + fakeAsync(() => { + const c = new FormControl( + 'fcValue', null!, simpleAsyncValidator({timeout: 1, shouldFail: false})); + const g = new FormGroup( + {'one': c}, null!, simpleAsyncValidator({timeout: 0, shouldFail: false})); + + // Initially, the form control validation is pending, and the form group own validation has + // synchronously resolved. Still, the form is in pending state due to its child + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // Group + {errors: null, pending: true, status: 'PENDING'}, // Control + ]); + + tick(1); + + // After 1ms, the form control validation has resolved + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: null, pending: false, status: 'VALID'}, // Group + {errors: null, pending: false, status: 'VALID'}, // Control + ]); + })); + + it('should handle successful async FormGroup resolving after a synchronously and successfully resolving child validator', + fakeAsync(() => { + const c = new FormControl( + 'fcValue', null!, simpleAsyncValidator({timeout: 0, shouldFail: false})); + const g = new FormGroup( + {'one': c}, null!, simpleAsyncValidator({timeout: 1, shouldFail: false})); + + // Initially, form control validator has synchronously resolved. However, g has its own + // pending validation + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // Group + {errors: null, pending: false, status: 'VALID'}, // Control + ]); + + tick(1); + + // After 1ms, the form group validation has resolved + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: null, pending: false, status: 'VALID'}, // Group + {errors: null, pending: false, status: 'VALID'}, // Control + ]); + })); + + it('should handle successful async FormGroup and child control validators resolving synchronously', + fakeAsync(() => { + const c = new FormControl( + 'fcValue', null!, simpleAsyncValidator({timeout: 0, shouldFail: false})); + const g = new FormGroup( + {'one': c}, null!, simpleAsyncValidator({timeout: 0, shouldFail: false})); + + // Both form control and form group successful async validators have resolved synchronously + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: null, pending: false, status: 'VALID'}, // Group + {errors: null, pending: false, status: 'VALID'}, // Control + ]); + })); + + it('should handle failing async FormGroup and failing child control validators resolving synchronously', + fakeAsync(() => { + const c = new FormControl( + 'fcValue', null!, simpleAsyncValidator({timeout: 0, shouldFail: true})); + const g = + new FormGroup({'one': c}, null!, simpleAsyncValidator({timeout: 0, shouldFail: true})); + + // FormControl async validator has executed and failed synchronously with the default error + // `{async: true}`. Next, the form group status is calculated. Since one of its children is + // failing, the form group itself is marked `INVALID`. And its asynchronous validation is + // not even triggered. Therefore, we end up with form group that is `INVALID` but whose + // errors are null (child errors do not propagate and own async validation not event + // triggered). + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: null, pending: false, status: 'INVALID'}, // Group + {errors: {async: true}, pending: false, status: 'INVALID'}, // Control + ]); + })); + + it('should handle failing async FormGroup and successful child control validators resolving synchronously', + fakeAsync(() => { + const c = new FormControl( + 'fcValue', null!, simpleAsyncValidator({timeout: 0, shouldFail: false})); + const g = + new FormGroup({'one': c}, null!, simpleAsyncValidator({timeout: 0, shouldFail: true})); + + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: {async: true}, pending: false, status: 'INVALID'}, // Group + {errors: null, pending: false, status: 'VALID'}, // Control + ]); + })); + + it('should handle failing async FormArray and successful children validators resolving synchronously', + fakeAsync(() => { + const c = new FormControl( + 'fcValue', null!, simpleAsyncValidator({timeout: 0, shouldFail: false})); + const g = new FormGroup( + {'one': c}, null!, simpleAsyncValidator({timeout: 0, shouldFail: false})); + + const c2 = + new FormControl('fcVal', null!, simpleAsyncValidator({timeout: 0, shouldFail: false})); + + const a = + new FormArray([g, c2], null!, simpleAsyncValidator({timeout: 0, shouldFail: true})); + + expect(currentStateOf([a, a.at(0)!, a.at(1)!])).toEqual([ + {errors: {async: true}, pending: false, status: 'INVALID'}, // Array + {errors: null, pending: false, status: 'VALID'}, // Group p + {errors: null, pending: false, status: 'VALID'}, // Control c2 + ]); + })); + + it('should handle failing FormGroup validator resolving after successful child validator', + fakeAsync(() => { + const c = new FormControl( + 'fcValue', null!, simpleAsyncValidator({timeout: 1, shouldFail: false})); + const g = + new FormGroup({'one': c}, null!, simpleAsyncValidator({timeout: 2, shouldFail: true})); + + // Initially, the form group and nested control are in pending state + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // Group + {errors: null, pending: true, status: 'PENDING'}, // Control + ]); + + tick(1); + + // After 1ms, only form control validation has resolved + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // Group + {errors: null, pending: false, status: 'VALID'}, // Control + ]); + + tick(1); + + // After 1ms, the form group validation fails + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: {async: true}, pending: false, status: 'INVALID'}, // Group + {errors: null, pending: false, status: 'VALID'}, // Control + ]); + })); + + it('should handle failing FormArray validator resolving after successful child validator', + fakeAsync(() => { + const c = new FormControl( + 'fcValue', null!, simpleAsyncValidator({timeout: 1, shouldFail: false})); + const a = new FormArray([c], null!, simpleAsyncValidator({timeout: 2, shouldFail: true})); + + // Initially, the form array and nested control are in pending state + expect(currentStateOf([a, a.at(0)!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // FormArray + {errors: null, pending: true, status: 'PENDING'}, // Control + ]); + + tick(1); + + // After 1ms, only form control validation has resolved + expect(currentStateOf([a, a.at(0)!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // FormArray + {errors: null, pending: false, status: 'VALID'}, // Control + ]); + + tick(1); + + // After 1ms, the form array validation fails + expect(currentStateOf([a, a.at(0)!])).toEqual([ + {errors: {async: true}, pending: false, status: 'INVALID'}, // FormArray + {errors: null, pending: false, status: 'VALID'}, // Control + ]); + })); + + it('should handle successful FormGroup validator resolving after successful child validator', + fakeAsync(() => { + const c = new FormControl( + 'fcValue', null!, simpleAsyncValidator({timeout: 1, shouldFail: false})); + const g = new FormGroup( + {'one': c}, null!, simpleAsyncValidator({timeout: 2, shouldFail: false})); + + // Initially, the form group and nested control are in pending state + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // Group + {errors: null, pending: true, status: 'PENDING'}, // Control + ]); + + tick(1); + + // After 1ms, only form control validation has resolved + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // Group + {errors: null, pending: false, status: 'VALID'}, // Control + ]); + + tick(1); + + // After 1ms, the form group validation resolves + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: null, pending: false, status: 'VALID'}, // Group + {errors: null, pending: false, status: 'VALID'}, // Control + ]); + })); + + it('should handle successful FormArray validator resolving after successful child validators', + fakeAsync(() => { + const c1 = new FormControl( + 'fcValue', null!, simpleAsyncValidator({timeout: 1, shouldFail: false})); + const g = new FormGroup( + {'one': c1}, null!, simpleAsyncValidator({timeout: 2, shouldFail: false})); + const c2 = + new FormControl('fcVal', null!, simpleAsyncValidator({timeout: 3, shouldFail: false})); + + const a = + new FormArray([g, c2], null!, simpleAsyncValidator({timeout: 4, shouldFail: false})); + + // Initially, the form array and the tested form group and form control c2 are in pending + // state + expect(currentStateOf([a, a.at(0)!, a.at(1)!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // FormArray + {errors: null, pending: true, status: 'PENDING'}, // g + {errors: null, pending: true, status: 'PENDING'}, // c2 + ]); + + tick(2); + + // After 2ms, g validation has resolved + expect(currentStateOf([a, a.at(0)!, a.at(1)!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // FormArray + {errors: null, pending: false, status: 'VALID'}, // g + {errors: null, pending: true, status: 'PENDING'}, // c2 + ]); + + tick(1); + + // After 1ms, c2 validation has resolved + expect(currentStateOf([a, a.at(0)!, a.at(1)!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // FormArray + {errors: null, pending: false, status: 'VALID'}, // g + {errors: null, pending: false, status: 'VALID'}, // c2 + ]); + + tick(1); + + // After 1ms, FormArray own validation has resolved + expect(currentStateOf([a, a.at(0)!, a.at(1)!])).toEqual([ + {errors: null, pending: false, status: 'VALID'}, // FormArray + {errors: null, pending: false, status: 'VALID'}, // g + {errors: null, pending: false, status: 'VALID'}, // c2 + ]); + })); + + it('should handle failing FormArray validator resolving after successful child validators', + fakeAsync(() => { + const c1 = new FormControl( + 'fcValue', null!, simpleAsyncValidator({timeout: 1, shouldFail: false})); + const g = new FormGroup( + {'one': c1}, null!, simpleAsyncValidator({timeout: 2, shouldFail: false})); + const c2 = + new FormControl('fcVal', null!, simpleAsyncValidator({timeout: 3, shouldFail: false})); + + const a = + new FormArray([g, c2], null!, simpleAsyncValidator({timeout: 4, shouldFail: true})); + + // Initially, the form array and the tested form group and form control c2 are in pending + // state + expect(currentStateOf([a, a.at(0)!, a.at(1)!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // FormArray + {errors: null, pending: true, status: 'PENDING'}, // g + {errors: null, pending: true, status: 'PENDING'}, // c2 + ]); + + tick(2); + + // After 2ms, g validation has resolved + expect(currentStateOf([a, a.at(0)!, a.at(1)!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // FormArray + {errors: null, pending: false, status: 'VALID'}, // g + {errors: null, pending: true, status: 'PENDING'}, // c2 + ]); + + tick(1); + + // After 1ms, c2 validation has resolved + expect(currentStateOf([a, a.at(0)!, a.at(1)!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // FormArray + {errors: null, pending: false, status: 'VALID'}, // g + {errors: null, pending: false, status: 'VALID'}, // c2 + ]); + + tick(1); + + // After 1ms, FormArray own validation has failed + expect(currentStateOf([a, a.at(0)!, a.at(1)!])).toEqual([ + {errors: {async: true}, pending: false, status: 'INVALID'}, // FormArray + {errors: null, pending: false, status: 'VALID'}, // g + {errors: null, pending: false, status: 'VALID'}, // c2 + ]); + })); + + it('should handle multiple successful FormGroup validators resolving after successful child validator', + fakeAsync(() => { + const c = new FormControl( + 'fcValue', null!, simpleAsyncValidator({timeout: 1, shouldFail: false})); + const g = new FormGroup({'one': c}, null!, [ + simpleAsyncValidator({timeout: 2, shouldFail: false}), + simpleAsyncValidator({timeout: 3, shouldFail: false}) + ]); + + // Initially, the form group and nested control are in pending state + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // Group + {errors: null, pending: true, status: 'PENDING'}, // Control + ]); + + tick(1); + + // After 1ms, only form control validation has resolved + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // Group + {errors: null, pending: false, status: 'VALID'}, // Control + ]); + + tick(1); + + // After 1ms, one form async validator has resolved but not the second + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // Group + {errors: null, pending: false, status: 'VALID'}, // Control + ]); + + tick(1); + + // After 1ms, the form group validation resolves + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: null, pending: false, status: 'VALID'}, // Group + {errors: null, pending: false, status: 'VALID'}, // Control + ]); + })); + + it('should handle multiple FormGroup validators (success then failure) resolving after successful child validator', + fakeAsync(() => { + const c = new FormControl( + 'fcValue', null!, simpleAsyncValidator({timeout: 1, shouldFail: false})); + const g = new FormGroup({'one': c}, null!, [ + simpleAsyncValidator({timeout: 2, shouldFail: false}), + simpleAsyncValidator({timeout: 3, shouldFail: true}) + ]); + + // Initially, the form group and nested control are in pending state + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // Group + {errors: null, pending: true, status: 'PENDING'}, // Control + ]); + + tick(1); + + // After 1ms, only form control validation has resolved + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // Group + {errors: null, pending: false, status: 'VALID'}, // Control + ]); + + tick(1); + + // After 1ms, one form async validator has resolved but not the second + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // Group + {errors: null, pending: false, status: 'VALID'}, // Control + ]); + + tick(1); + + // After 1ms, the form group validation fails + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: {async: true}, pending: false, status: 'INVALID'}, // Group + {errors: null, pending: false, status: 'VALID'}, // Control + ]); + })); + + + it('should handle multiple FormGroup validators (failure then success) resolving after successful child validator', + fakeAsync(() => { + const c = new FormControl( + 'fcValue', null!, simpleAsyncValidator({timeout: 1, shouldFail: false})); + const g = new FormGroup({'one': c}, null!, [ + simpleAsyncValidator({timeout: 2, shouldFail: true}), + simpleAsyncValidator({timeout: 3, shouldFail: false}) + ]); + + // Initially, the form group and nested control are in pending state + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // Group + {errors: null, pending: true, status: 'PENDING'}, // Control + ]); + + tick(1); + + // After 1ms, only form control validation has resolved + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // Group + {errors: null, pending: false, status: 'VALID'}, // Control + ]); + + + tick(1); + + // All async validators are composed into one function. So, after 2ms, the FormGroup g is + // still in pending state without errors + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // Group + {errors: null, pending: false, status: 'VALID'}, // Control + ]); + + tick(1); + + // After 1ms, the form group validation fails + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: {async: true}, pending: false, status: 'INVALID'}, // Group + {errors: null, pending: false, status: 'VALID'}, // Control + ]); + })); + + + it('should handle async validators in nested form groups / arrays', fakeAsync(() => { + const c1 = new FormControl( + 'fcValue', null!, simpleAsyncValidator({timeout: 1, shouldFail: false})); + + const g1 = new FormGroup( + {'one': c1}, null!, simpleAsyncValidator({timeout: 2, shouldFail: true})); + + const c2 = + new FormControl('fcVal', null!, simpleAsyncValidator({timeout: 3, shouldFail: false})); + + const g2 = + new FormArray([c2], null!, simpleAsyncValidator({timeout: 4, shouldFail: false})); + + const g = new FormGroup( + {'g1': g1, 'g2': g2}, null!, simpleAsyncValidator({timeout: 5, shouldFail: false})); + + // Initially, the form group and nested control are in pending state + expect(currentStateOf([g, g.get('g1')!, g.get('g2')!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // Group g + {errors: null, pending: true, status: 'PENDING'}, // Group g1 + {errors: null, pending: true, status: 'PENDING'}, // Group g2 + ]); + + tick(2); + + // After 2ms, g1 validation fails + expect(currentStateOf([g, g.get('g1')!, g.get('g2')!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // Group g + {errors: {async: true}, pending: false, status: 'INVALID'}, // Group g1 + {errors: null, pending: true, status: 'PENDING'}, // Group g2 + ]); + + tick(2); + + // After 2ms, g2 validation resolves + expect(currentStateOf([g, g.get('g1')!, g.get('g2')!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // Group g + {errors: {async: true}, pending: false, status: 'INVALID'}, // Group g1 + {errors: null, pending: false, status: 'VALID'}, // Group g2 + ]); + + tick(1); + + // After 1ms, g validation fails because g1 is invalid, but since errors do not cascade, so + // we still have null errors for g + expect(currentStateOf([g, g.get('g1')!, g.get('g2')!])).toEqual([ + {errors: null, pending: false, status: 'INVALID'}, // Group g + {errors: {async: true}, pending: false, status: 'INVALID'}, // Group g1 + {errors: null, pending: false, status: 'VALID'}, // Group g2 + ]); + })); + + it('should handle failing FormGroup validator resolving before successful child validator', + fakeAsync(() => { + const c = new FormControl( + 'fcValue', null!, simpleAsyncValidator({timeout: 2, shouldFail: false})); + const g = + new FormGroup({'one': c}, null!, simpleAsyncValidator({timeout: 1, shouldFail: true})); + + // Initially, the form group and nested control are in pending state + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // Group + {errors: null, pending: true, status: 'PENDING'}, // Control + ]); + + tick(1); + + // After 1ms, form group validation fails + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: {async: true}, pending: false, status: 'INVALID'}, // Group + {errors: null, pending: true, status: 'PENDING'}, // Control + ]); + + tick(1); + + // After 1ms, child validation resolves + expect(currentStateOf([g, g.get('one')!])).toEqual([ + {errors: {async: true}, pending: false, status: 'INVALID'}, // Group + {errors: null, pending: false, status: 'VALID'}, // Control + ]); + })); + + it('should handle failing FormArray validator resolving before successful child validator', + fakeAsync(() => { + const c = new FormControl( + 'fcValue', null!, simpleAsyncValidator({timeout: 2, shouldFail: false})); + const a = new FormArray([c], null!, simpleAsyncValidator({timeout: 1, shouldFail: true})); + + // Initially, the form array and nested control are in pending state + expect(currentStateOf([a, a.at(0)!])).toEqual([ + {errors: null, pending: true, status: 'PENDING'}, // FormArray + {errors: null, pending: true, status: 'PENDING'}, // Control + ]); + + tick(1); + + // After 1ms, form array validation fails + expect(currentStateOf([a, a.at(0)!])).toEqual([ + {errors: {async: true}, pending: false, status: 'INVALID'}, // FormArray + {errors: null, pending: true, status: 'PENDING'}, // Control + ]); + + tick(1); + + // After 1ms, child validation resolves + expect(currentStateOf([a, a.at(0)!])).toEqual([ + {errors: {async: true}, pending: false, status: 'INVALID'}, // FormArray + {errors: null, pending: false, status: 'VALID'}, // Control + ]); + })); }); describe('disable() & enable()', () => { diff --git a/packages/forms/test/reactive_integration_spec.ts b/packages/forms/test/reactive_integration_spec.ts index c49cd80159..b6e3fb3740 100644 --- a/packages/forms/test/reactive_integration_spec.ts +++ b/packages/forms/test/reactive_integration_spec.ts @@ -2074,6 +2074,72 @@ import {MyInput, MyInputForm} from './value_accessor_integration_spec'; expect(control.valid).toEqual(false); })); + it('should handle async validation changes in parent and child controls', fakeAsync(() => { + const fixture = initTest(FormGroupComp); + const control = new FormControl( + '', Validators.required, asyncValidator(c => !!c.value && c.value.length > 3, 100)); + const form = new FormGroup( + {'login': control}, null, + asyncValidator(c => c.get('login')!.value.includes('angular'), 200)); + fixture.componentInstance.form = form; + fixture.detectChanges(); + tick(); + + // Initially, the form is invalid because the nested mandatory control is empty + expect(control.hasError('required')).toEqual(true); + expect(form.value).toEqual({'login': ''}); + expect(form.invalid).toEqual(true); + + // Setting a value in the form control that will trigger the registered asynchronous + // validation + const input = fixture.debugElement.query(By.css('input')); + input.nativeElement.value = 'angul'; + dispatchEvent(input.nativeElement, 'input'); + + // The form control asynchronous validation is in progress (for 100 ms) + expect(control.pending).toEqual(true); + + tick(100); + + // Now the asynchronous validation has resolved, and since the form control value + // (`angul`) has a length > 3, the validation is successful + expect(control.invalid).toEqual(false); + + // Even if the child control is valid, the form control is pending because it is still + // waiting for its own validation + expect(form.pending).toEqual(true); + + tick(100); + + // Login form control is valid. However, the form control is invalid because `angul` does + // not include `angular` + expect(control.invalid).toEqual(false); + expect(form.pending).toEqual(false); + expect(form.invalid).toEqual(true); + + // Setting a value that would be trigger "VALID" form state + input.nativeElement.value = 'angular!'; + dispatchEvent(input.nativeElement, 'input'); + + // Since the form control value changed, its asynchronous validation runs for 100ms + expect(control.pending).toEqual(true); + + tick(100); + + // Even if the child control is valid, the form control is pending because it is still + // waiting for its own validation + expect(control.invalid).toEqual(false); + expect(form.pending).toEqual(true); + + tick(100); + + // Now, the form is valid because its own asynchronous validation has resolved + // successfully, because the form control value `angular` includes the `angular` string + expect(control.invalid).toEqual(false); + expect(form.pending).toEqual(false); + expect(form.invalid).toEqual(false); + })); + it('should cancel observable properly between validation runs', fakeAsync(() => { const fixture = initTest(FormControlComp); const resultArr: number[] = []; @@ -2383,18 +2449,36 @@ import {MyInput, MyInputForm} from './value_accessor_integration_spec'; }); } -function uniqLoginAsyncValidator(expectedValue: string, timeout: number = 0) { +/** + * Creates an async validator using a checker function, a timeout and the error to emit in case of + * validation failure + * + * @param checker A function to decide whether the validator will resolve with success or failure + * @param timeout When the validation will resolve + * @param error The error message to be emitted in case of validation failure + * + * @returns An async validator created using a checker function, a timeout and the error to emit in + * case of validation failure + */ +function asyncValidator( + checker: (c: AbstractControl) => boolean, timeout: number = 0, error: any = { + 'async': true + }) { return (c: AbstractControl) => { let resolve: (result: any) => void; const promise = new Promise(res => { resolve = res; }); - const res = (c.value == expectedValue) ? null : {'uniqLogin': true}; + const res = checker(c) ? null : error; setTimeout(() => resolve(res), timeout); return promise; }; } +function uniqLoginAsyncValidator(expectedValue: string, timeout: number = 0) { + return asyncValidator(c => c.value === expectedValue, timeout, {'uniqLogin': true}); +} + function observableValidator(resultArr: number[]): AsyncValidatorFn { return (c: AbstractControl) => { return timer(100).pipe(tap((resp: any) => resultArr.push(resp)));