From 60927386279a51d7df0c7edccf29101097a9dd21 Mon Sep 17 00:00:00 2001 From: Matthieu Riegler Date: Sat, 17 Feb 2024 22:32:41 +0100 Subject: [PATCH] feat(forms): Unified Control State Change Events This commit adds a global observable to subscribe to track changes around any `AbstractControl` (its children). This issue fixes #10887 --- goldens/public-api/forms/index.md | 43 +++ packages/forms/src/forms.ts | 2 +- packages/forms/src/model/abstract_model.ts | 325 ++++++++++++------ packages/forms/src/model/form_array.ts | 4 +- packages/forms/src/model/form_group.ts | 4 +- .../forms/test/reactive_integration_spec.ts | 234 ++++++++++++- 6 files changed, 505 insertions(+), 107 deletions(-) diff --git a/goldens/public-api/forms/index.md b/goldens/public-api/forms/index.md index c981503a8045a5..6e5c0d288f6d57 100644 --- a/goldens/public-api/forms/index.md +++ b/goldens/public-api/forms/index.md @@ -20,6 +20,12 @@ import { Renderer2 } from '@angular/core'; import { SimpleChanges } from '@angular/core'; import { Version } from '@angular/core'; +// @public (undocumented) +export interface AbstractChangeEvent { + // (undocumented) + changedControl: AbstractControl; +} + // @public export abstract class AbstractControl { constructor(validators: ValidatorFn | ValidatorFn[] | null, asyncValidators: AsyncValidatorFn | AsyncValidatorFn[] | null); @@ -29,6 +35,8 @@ export abstract class AbstractControl>; get dirty(): boolean; disable(opts?: { onlySelf?: boolean; @@ -185,6 +193,9 @@ export abstract class ControlContainer extends AbstractControlDirective { get path(): string[] | null; } +// @public (undocumented) +export type ControlEvent = ValueChangeEvent | PristineEvent | TouchedEvent | StatusEvent; + // @public export interface ControlValueAccessor { registerOnChange(fn: any): void; @@ -769,6 +780,14 @@ export class PatternValidator extends AbstractValidatorDirective { static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public (undocumented) +export interface PristineEvent extends AbstractChangeEvent { + // (undocumented) + type: 'pristine'; + // (undocumented) + value: boolean; +} + // @public export class RadioControlValueAccessor extends BuiltInControlValueAccessor implements ControlValueAccessor, OnDestroy, OnInit { constructor(renderer: Renderer2, elementRef: ElementRef, _registry: RadioControlRegistry, _injector: Injector); @@ -854,6 +873,22 @@ export class SelectMultipleControlValueAccessor extends BuiltInControlValueAcces // @public export type SetDisabledStateOption = 'whenDisabledForLegacyCode' | 'always'; +// @public (undocumented) +export interface StatusEvent extends AbstractChangeEvent { + // (undocumented) + type: 'status'; + // (undocumented) + value: FormControlStatus; +} + +// @public (undocumented) +export interface TouchedEvent extends AbstractChangeEvent { + // (undocumented) + type: 'touched'; + // (undocumented) + value: boolean; +} + // @public export type UntypedFormArray = FormArray; @@ -925,6 +960,14 @@ export class Validators { static requiredTrue(control: AbstractControl): ValidationErrors | null; } +// @public (undocumented) +export interface ValueChangeEvent extends AbstractChangeEvent { + // (undocumented) + type: 'value'; + // (undocumented) + value: T; +} + // @public (undocumented) export const VERSION: Version; diff --git a/packages/forms/src/forms.ts b/packages/forms/src/forms.ts index 84856652de0576..fd7de44a3e4b06 100644 --- a/packages/forms/src/forms.ts +++ b/packages/forms/src/forms.ts @@ -43,7 +43,7 @@ export {SelectMultipleControlValueAccessor, ɵNgSelectMultipleOption} from './di export {SetDisabledStateOption} from './directives/shared'; export {AsyncValidator, AsyncValidatorFn, CheckboxRequiredValidator, EmailValidator, MaxLengthValidator, MaxValidator, MinLengthValidator, MinValidator, PatternValidator, RequiredValidator, ValidationErrors, Validator, ValidatorFn} from './directives/validators'; export {ControlConfig, FormBuilder, NonNullableFormBuilder, UntypedFormBuilder, ɵElement} from './form_builder'; -export {AbstractControl, AbstractControlOptions, FormControlStatus, ɵCoerceStrArrToNumArr, ɵGetProperty, ɵNavigate, ɵRawValue, ɵTokenize, ɵTypedOrUntyped, ɵValue, ɵWriteable} from './model/abstract_model'; +export {AbstractChangeEvent, AbstractControl, AbstractControlOptions, ControlEvent, FormControlStatus, PristineEvent, StatusEvent, TouchedEvent, ValueChangeEvent, ɵCoerceStrArrToNumArr, ɵGetProperty, ɵNavigate, ɵRawValue, ɵTokenize, ɵTypedOrUntyped, ɵValue, ɵWriteable} from './model/abstract_model'; export {FormArray, isFormArray, UntypedFormArray, ɵFormArrayRawValue, ɵFormArrayValue} from './model/form_array'; export {FormControl, FormControlOptions, FormControlState, isFormControl, UntypedFormControl, ɵFormControlCtor} from './model/form_control'; export {FormGroup, FormRecord, isFormGroup, isFormRecord, UntypedFormGroup, ɵFormGroupRawValue, ɵFormGroupValue, ɵOptionalKeys} from './model/form_group'; diff --git a/packages/forms/src/model/abstract_model.ts b/packages/forms/src/model/abstract_model.ts index 2f0880eaecee33..063bac1b5468d2 100644 --- a/packages/forms/src/model/abstract_model.ts +++ b/packages/forms/src/model/abstract_model.ts @@ -65,6 +65,52 @@ export const DISABLED = 'DISABLED'; */ export type FormControlStatus = 'VALID'|'INVALID'|'PENDING'|'DISABLED'; + +/** + * @publicApi + */ +export interface AbstractChangeEvent { + changedControl: AbstractControl; +} + +/** + * @publicApi + */ +export interface ValueChangeEvent extends AbstractChangeEvent { + type: 'value'; + value: T; +} + +/** + * @publicApi + */ +export interface PristineEvent extends AbstractChangeEvent { + type: 'pristine'; + value: boolean; +} + +/** + * @publicApi + */ +export interface TouchedEvent extends AbstractChangeEvent { + type: 'touched'; + value: boolean; +} + +/** + * @publicApi + */ +export interface StatusEvent extends AbstractChangeEvent { + type: 'status'; + value: FormControlStatus; +} + +/** + * @publicApi + */ +export type ControlEvent = ValueChangeEvent|PristineEvent|TouchedEvent|StatusEvent; + + /** * Gets validators from either an options object or given validators. */ @@ -605,6 +651,8 @@ export abstract class AbstractControl; + public readonly controlStateChanges!: Observable>; + /** * Reports the update strategy of the `AbstractControl` (meaning * the event on which the control updates itself). @@ -797,11 +845,7 @@ export abstract class AbstractControl).touched = true; - - if (this._parent && !opts.onlySelf) { - this._parent.markAsTouched(opts); - } + this._markAsTouched(opts, this); } /** @@ -809,7 +853,7 @@ export abstract class AbstractControl control.markAllAsTouched()); } @@ -830,16 +874,7 @@ export abstract class AbstractControl).touched = false; - this._pendingTouched = false; - - this._forEachChild((control: AbstractControl) => { - control.markAsUntouched({onlySelf: true}); - }); - - if (this._parent && !opts.onlySelf) { - this._parent._updateTouched(opts); - } + this._markAsUntouched(opts, this); } /** @@ -856,11 +891,7 @@ export abstract class AbstractControl).pristine = false; - - if (this._parent && !opts.onlySelf) { - this._parent.markAsDirty(opts); - } + this._markAsDirty(opts, this); } /** @@ -880,16 +911,7 @@ export abstract class AbstractControl).pristine = true; - this._pendingDirty = false; - - this._forEachChild((control: AbstractControl) => { - control.markAsPristine({onlySelf: true}); - }); - - if (this._parent && !opts.onlySelf) { - this._parent._updatePristine(opts); - } + this._markAsPristine(opts, this); } /** @@ -909,15 +931,7 @@ export abstract class AbstractControl).status = PENDING; - - if (opts.emitEvent !== false) { - (this.statusChanges as EventEmitter).emit(this.status); - } - - if (this._parent && !opts.onlySelf) { - this._parent.markAsPending(opts); - } + this._markAsPending(opts, this); } /** @@ -938,24 +952,7 @@ export abstract class AbstractControl).status = DISABLED; - (this as Writable).errors = null; - this._forEachChild((control: AbstractControl) => { - control.disable({...opts, onlySelf: true}); - }); - this._updateValue(); - - if (opts.emitEvent !== false) { - (this.valueChanges as EventEmitter).emit(this.value); - (this.statusChanges as EventEmitter).emit(this.status); - } - - this._updateAncestors({...opts, skipPristineCheck}); - this._onDisabledChange.forEach((changeFn) => changeFn(true)); + this._disable(opts, this); } /** @@ -977,28 +974,18 @@ export abstract class AbstractControl).status = VALID; - this._forEachChild((control: AbstractControl) => { - control.enable({...opts, onlySelf: true}); - }); - this.updateValueAndValidity({onlySelf: true, emitEvent: opts.emitEvent}); - - this._updateAncestors({...opts, skipPristineCheck}); - this._onDisabledChange.forEach((changeFn) => changeFn(false)); + this._enable(opts, this); } private _updateAncestors( - opts: {onlySelf?: boolean, emitEvent?: boolean, skipPristineCheck?: boolean}): void { + opts: {onlySelf?: boolean, emitEvent?: boolean, skipPristineCheck?: boolean}, + changedControl: AbstractControl): void { if (this._parent && !opts.onlySelf) { this._parent.updateValueAndValidity(opts); if (!opts.skipPristineCheck) { - this._parent._updatePristine(); + this._parent._updatePristine({}, changedControl); } - this._parent._updateTouched(); + this._parent._updateTouched({}, changedControl); } } @@ -1049,27 +1036,7 @@ export abstract class AbstractControl).errors = this._runValidator(); - (this as Writable).status = this._calculateStatus(); - - if (this.status === VALID || this.status === PENDING) { - this._runAsyncValidator(opts.emitEvent); - } - } - - if (opts.emitEvent !== false) { - (this.valueChanges as EventEmitter).emit(this.value); - (this.statusChanges as EventEmitter).emit(this.status); - } - - if (this._parent && !opts.onlySelf) { - this._parent.updateValueAndValidity(opts); - } + this._updateValueAndValidity(opts, this); } /** @internal */ @@ -1138,7 +1105,7 @@ export abstract class AbstractControl).errors = errors; - this._updateControlsErrors(opts.emitEvent !== false); + this._updateControlsErrors(opts.emitEvent !== false, this); } /** @@ -1278,15 +1245,17 @@ export abstract class AbstractControl).status = this._calculateStatus(); if (emitEvent) { (this.statusChanges as EventEmitter).emit(this.status); + (this.controlStateChanges as EventEmitter>) + .emit({type: 'status', changedControl, value: this.status}); } if (this._parent) { - this._parent._updateControlsErrors(emitEvent); + this._parent._updateControlsErrors(emitEvent, changedControl); } } @@ -1294,6 +1263,7 @@ export abstract class AbstractControl).valueChanges = new EventEmitter(); (this as Writable).statusChanges = new EventEmitter(); + (this as Writable).controlStateChanges = new EventEmitter(); } @@ -1336,20 +1306,173 @@ export abstract class AbstractControl).errors = this._runValidator(); + (this as Writable).status = this._calculateStatus(); + + if (this.status === VALID || this.status === PENDING) { + this._runAsyncValidator(opts.emitEvent); + } + } + + if (opts.emitEvent !== false) { + (this.valueChanges as EventEmitter).emit(this.value); + (this.statusChanges as EventEmitter).emit(this.status); + (this.controlStateChanges as EventEmitter>) + .emit({type: 'value', changedControl, value: this.value}); + (this.controlStateChanges as EventEmitter>) + .emit({type: 'status', changedControl, value: this.status}); + } + + if (this._parent && !opts.onlySelf) { + this._parent._updateValueAndValidity(opts, changedControl); + } + } + + /** @internal */ + _enable(opts: {onlySelf?: boolean, emitEvent?: boolean}, changedControl: AbstractControl): void { + // If parent has been marked artificially dirty we don't want to re-calculate the + // parent's dirtiness based on the children. + const skipPristineCheck = this._parentMarkedDirty(opts.onlySelf); + + (this as Writable).status = VALID; + this._forEachChild((control: AbstractControl) => { + control.enable({...opts, onlySelf: true}); + }); + this.updateValueAndValidity({onlySelf: true, emitEvent: opts.emitEvent}); + + this._updateAncestors({...opts, skipPristineCheck}, this); + this._onDisabledChange.forEach((changeFn) => changeFn(false)); + } + + /** @internal */ + _disable(opts: {onlySelf?: boolean, emitEvent?: boolean}, changedControl: AbstractControl): void { + // If parent has been marked artificially dirty we don't want to re-calculate the + // parent's dirtiness based on the children. + const skipPristineCheck = this._parentMarkedDirty(opts.onlySelf); + + (this as Writable).status = DISABLED; + (this as Writable).errors = null; + this._forEachChild((control: AbstractControl) => { + control.disable({...opts, onlySelf: true}); + }); + this._updateValue(); + + if (opts.emitEvent !== false) { + (this.valueChanges as EventEmitter).emit(this.value); + (this.statusChanges as EventEmitter).emit(this.status); + (this.controlStateChanges as EventEmitter>) + .emit({type: 'status', changedControl, value: this.status}); + (this.controlStateChanges as EventEmitter>) + .emit({type: 'value', changedControl, value: this.value}); + } + + this._updateAncestors({...opts, skipPristineCheck}, this); + this._onDisabledChange.forEach((changeFn) => changeFn(true)); + } + + /** @internal */ + _markAsPending(opts: {onlySelf?: boolean, emitEvent?: boolean}, changedControl: AbstractControl): + void { + (this as Writable).status = PENDING; + + if (opts.emitEvent !== false) { + (this.statusChanges as EventEmitter).emit(this.status); + (this.controlStateChanges as EventEmitter>) + .emit({type: 'status', changedControl, value: this.status}); + } + + if (this._parent && !opts.onlySelf) { + this._parent._markAsPending(opts, changedControl); + } + } + + /** @internal */ + _markAsDirty(opts: {onlySelf?: boolean}, changedControl: AbstractControl): void { + (this as Writable).pristine = false; + + if (this._parent && !opts.onlySelf) { + this._parent._markAsDirty(opts, changedControl); + } + + (this.controlStateChanges as EventEmitter>) + .emit({type: 'pristine', changedControl, value: false}); + } + + /** @internal */ + _markAsPristine(opts: {onlySelf?: boolean}, changedControl: AbstractControl): void { + (this as Writable).pristine = true; + this._pendingDirty = false; + + this._forEachChild((control: AbstractControl) => { + control.markAsPristine({onlySelf: true}); + }); + + if (this._parent && !opts.onlySelf) { + this._parent._updatePristine(opts, this); + } + + (this.controlStateChanges as EventEmitter>) + .emit({type: 'pristine', changedControl, value: true}); + } + + /** @internal */ + _updatePristine(opts: {onlySelf?: boolean}, changedControl: AbstractControl): void { (this as Writable).pristine = !this._anyControlsDirty(); if (this._parent && !opts.onlySelf) { - this._parent._updatePristine(opts); + this._parent._updatePristine(opts, changedControl); + } + + (this.controlStateChanges as EventEmitter>) + .emit({type: 'pristine', changedControl, value: this.pristine}); + } + + /** @internal */ + _markAsTouched(opts: {onlySelf?: boolean}, changedControl: AbstractControl): void { + (this as Writable).touched = true; + + if (this._parent && !opts.onlySelf) { + this._parent._markAsTouched(opts, changedControl); } + + (this.controlStateChanges as EventEmitter>) + .emit({type: 'touched', changedControl, value: true}); } /** @internal */ - _updateTouched(opts: {onlySelf?: boolean} = {}): void { + _markAsUntouched(opts: {onlySelf?: boolean}, changedControl: AbstractControl|null): void { + (this as Writable).touched = false; + this._pendingTouched = false; + + this._forEachChild((control: AbstractControl) => { + control._markAsUntouched({onlySelf: true}, control); + }); + + if (this._parent && !opts.onlySelf) { + this._parent._updateTouched(opts, changedControl); + } + if (changedControl) { + (this.controlStateChanges as EventEmitter>) + .emit({type: 'touched', changedControl, value: false}); + } + } + /** @internal */ + _updateTouched(opts: {onlySelf?: boolean} = {}, changedControl: AbstractControl|null): void { (this as Writable).touched = this._anyControlsTouched(); + if (changedControl) { + (this.controlStateChanges as EventEmitter) + .emit({type: 'touched', changedControl, value: this.touched}); + } if (this._parent && !opts.onlySelf) { - this._parent._updateTouched(opts); + this._parent._updateTouched(opts, changedControl); } } diff --git a/packages/forms/src/model/form_array.ts b/packages/forms/src/model/form_array.ts index 0ae09e822795b8..68a8d72af8070a 100644 --- a/packages/forms/src/model/form_array.ts +++ b/packages/forms/src/model/form_array.ts @@ -394,8 +394,8 @@ export class FormArray = any> extends Abst this._forEachChild((control: AbstractControl, index: number) => { control.reset(value[index], {onlySelf: true, emitEvent: options.emitEvent}); }); - this._updatePristine(options); - this._updateTouched(options); + this._updatePristine(options, this); + this._updateTouched(options, this); this.updateValueAndValidity(options); } diff --git a/packages/forms/src/model/form_group.ts b/packages/forms/src/model/form_group.ts index 30c55930cf7c6c..6f6108f89c6f96 100644 --- a/packages/forms/src/model/form_group.ts +++ b/packages/forms/src/model/form_group.ts @@ -490,8 +490,8 @@ export class FormGroup { }); }); + describe('unified form state change', () => { + it('Single level Control should emit changes for itself', () => { + const fc = new FormControl('foo', Validators.required); + + const values: ControlEvent[] = []; + fc.controlStateChanges.subscribe(event => values.push(event)); + expect(values.length).toBe(0); + + fc.markAsTouched(); + expect(values.at(-1)).toEqual({changedControl: fc, type: 'touched', value: true}); + fc.markAsUntouched(); + expect(values.at(-1)).toEqual({changedControl: fc, type: 'touched', value: false}); + fc.markAsDirty(); + expect(values.at(-1)).toEqual({changedControl: fc, type: 'pristine', value: false}); + fc.markAsPristine(); + expect(values.at(-1)).toEqual({changedControl: fc, type: 'pristine', value: true}); + + fc.disable(); + expect(values.at(-2)).toEqual({changedControl: fc, type: 'status', value: 'DISABLED'}); + expect(values.at(-1)).toEqual({changedControl: fc, type: 'value', value: 'foo'}); + expect(values.length).toBe(6); + + fc.enable(); + expect(values.at(-1)).toEqual({changedControl: fc, type: 'status', value: 'VALID'}); + expect(values.at(-2)).toEqual({changedControl: fc, type: 'value', value: 'foo'}); + expect(values.length).toBe(8); + + fc.setValue(null); + expect(values.at(-1)).toEqual({changedControl: fc, type: 'status', value: 'INVALID'}); + expect(values.at(-2)).toEqual({changedControl: fc, type: 'value', value: null}); + expect(values.length).toBe(10); // setValue doesnt emit dirty or touched + + fc.setValue('bar'); + expect(values.at(-2)).toEqual({changedControl: fc, type: 'value', value: 'bar'}); + expect(values.at(-1)).toEqual({changedControl: fc, type: 'status', value: 'VALID'}); + expect(values.length).toBe(12); + + const subject = new Subject(); + const asyncValidator = () => subject.pipe(map(() => null)); + fc.addAsyncValidators(asyncValidator); + fc.updateValueAndValidity(); + + // Value is emitted because of updateValueAndValidity + expect(values.at(-2)).toEqual({changedControl: fc, type: 'value', value: 'bar'}); + expect(values.at(-1)).toEqual({changedControl: fc, type: 'status', value: 'PENDING'}); + subject.next(''); + subject.complete(); + expect(values.at(-1)).toEqual({changedControl: fc, type: 'status', value: 'VALID'}); + expect(values.length).toBe(15); + }); + + it('Nested formControl should emit changes for itself and its parent', () => { + const fc1 = new FormControl('foo', Validators.required); + const fc2 = new FormControl('bar', Validators.required); + const fg = new FormGroup({fc1, fc2}); + + const fc1Values: ControlEvent[] = []; + const fc2Values: ControlEvent[] = []; + const fgValues: ControlEvent[] = []; + fc1.controlStateChanges.subscribe(event => fc1Values.push(event)); + fc2.controlStateChanges.subscribe(event => fc2Values.push(event)); + fg.controlStateChanges.subscribe(event => fgValues.push(event)); + + fc1.setValue('bar'); + expect(fc1Values.at(-1)).toEqual({changedControl: fc1, type: 'status', value: 'VALID'}); + expect(fc1Values.at(-2)).toEqual({changedControl: fc1, type: 'value', value: 'bar'}); + expect(fc1Values.length).toBe(2); + + expect(fgValues.at(-1)).toEqual({changedControl: fc1, type: 'status', value: 'VALID'}); + expect(fgValues.at(-2)) + .toEqual({changedControl: fc1, type: 'value', value: {fc1: 'bar', fc2: 'bar'}}); + expect(fgValues.length).toBe(2); + }); + + it('Nested formControl should children as pristine as emit', () => { + const fc1 = new FormControl('foo', Validators.required); + const fc2 = new FormControl('bar', Validators.required); + const fg = new FormGroup({fc1, fc2}); + + const fc1Values: ControlEvent[] = []; + const fc2Values: ControlEvent[] = []; + const fgValues: ControlEvent[] = []; + fc1.controlStateChanges.subscribe(event => fc1Values.push(event)); + fc2.controlStateChanges.subscribe(event => fc2Values.push(event)); + fg.controlStateChanges.subscribe(event => fgValues.push(event)); + + fc1.setValue('bar'); + expect(fc1Values.length).toBe(2); + expect(fgValues.length).toBe(2); + + fg.markAsPristine(); + expect(fgValues.at(-1)).toEqual({changedControl: fg, type: 'pristine', value: true}); + expect(fc1Values.at(-1)).toEqual({changedControl: fc1, type: 'pristine', value: true}); + expect(fc2Values.at(-1)).toEqual({changedControl: fc2, type: 'pristine', value: true}); + + expect(fc1Values.length).toBe(3); + expect(fc2Values.length).toBe(1); + + // Marking children as pristine does not emit an event on the parent + expect(fgValues.length).toBe(3); + }); + + it('Nested formControl should emit dirty', () => { + const fc1 = new FormControl('foo', Validators.required); + const fc2 = new FormControl('bar', Validators.required); + const fg = new FormGroup({fc1, fc2}); + + const fc1Values: ControlEvent[] = []; + const fc2Values: ControlEvent[] = []; + const fgValues: ControlEvent[] = []; + fc1.controlStateChanges.subscribe(event => fc1Values.push(event)); + fc2.controlStateChanges.subscribe(event => fc2Values.push(event)); + fg.controlStateChanges.subscribe(event => fgValues.push(event)); + + fc1.markAsDirty(); + expect(fc1Values.length).toBe(1); + expect(fgValues.length).toBe(1); + expect(fc2Values.length).toBe(0); + + // changedControl is the child control + expect(fgValues.at(-1)).toEqual({changedControl: fc1, type: 'pristine', value: false}); + expect(fc1Values.at(-1)).toEqual({changedControl: fc1, type: 'pristine', value: false}); + }); + + it('Nested formControl should emit touched', () => { + const fc1 = new FormControl('foo', Validators.required); + const fc2 = new FormControl('bar', Validators.required); + const fg = new FormGroup({fc1, fc2}); + + const fc1Values: ControlEvent[] = []; + const fc2Values: ControlEvent[] = []; + const fgValues: ControlEvent[] = []; + fc1.controlStateChanges.subscribe(event => fc1Values.push(event)); + fc2.controlStateChanges.subscribe(event => fc2Values.push(event)); + fg.controlStateChanges.subscribe(event => fgValues.push(event)); + + fc1.markAsTouched(); + expect(fc1Values.length).toBe(1); + expect(fgValues.length).toBe(1); + expect(fc2Values.length).toBe(0); + + // changedControl is the child control + expect(fgValues.at(-1)).toEqual({changedControl: fc1, type: 'touched', value: true}); + expect(fc1Values.at(-1)).toEqual({changedControl: fc1, type: 'touched', value: true}); + }); + + it('Nested formControl should emit disabled', () => { + const fc1 = new FormControl('foo', Validators.required); + const fc2 = new FormControl('bar', Validators.required); + const fg = new FormGroup({fc1, fc2}); + + const fc1Values: ControlEvent[] = []; + const fc2Values: ControlEvent[] = []; + const fgValues: ControlEvent[] = []; + fc1.controlStateChanges.subscribe(event => fc1Values.push(event)); + fc2.controlStateChanges.subscribe(event => fc2Values.push(event)); + fg.controlStateChanges.subscribe(event => fgValues.push(event)); + + fc1.disable(); + expect(fc1Values.length).toBe(2); + expect(fgValues.length).toBe(4); + expect(fc2Values.length).toBe(0); + + expect(fgValues.at(-4)).toEqual({changedControl: fg, type: 'value', value: {fc2: 'bar'}}); + expect(fgValues.at(-3)).toEqual({changedControl: fg, type: 'status', value: 'VALID'}); + + // TODO: Check if this is expected + expect(fgValues.at(-2)).toEqual({changedControl: fc1, type: 'pristine', value: true}); + expect(fgValues.at(-1)).toEqual({changedControl: fc1, type: 'touched', value: false}); + + expect(fc1Values.at(-2)).toEqual({changedControl: fc1, type: 'status', value: 'DISABLED'}); + expect(fc1Values.at(-1)).toEqual({changedControl: fc1, type: 'value', value: 'foo'}); + }); + + it('Nested formControl should emit PENDING', () => { + const fc1 = new FormControl('foo', Validators.required); + const fc2 = new FormControl('bar', Validators.required); + const fg = new FormGroup({fc1, fc2}); + + const fc1Values: ControlEvent[] = []; + const fc2Values: ControlEvent[] = []; + const fgValues: ControlEvent[] = []; + fc1.controlStateChanges.subscribe(event => fc1Values.push(event)); + fc2.controlStateChanges.subscribe(event => fc2Values.push(event)); + fg.controlStateChanges.subscribe(event => fgValues.push(event)); + + fc1.markAsPending(); + expect(fgValues.at(-1)).toEqual({changedControl: fc1, type: 'status', value: 'PENDING'}); + + expect(fc1Values.at(-1)).toEqual({changedControl: fc1, type: 'status', value: 'PENDING'}); + expect(fc1Values.length).toBe(1); + expect(fgValues.length).toBe(1); + expect(fc2Values.length).toBe(0); + + + // Reseting to VALID + fc1.updateValueAndValidity(); + expect(fgValues.at(-2)) + .toEqual({changedControl: fc1, type: 'value', value: {fc1: 'foo', fc2: 'bar'}}); + expect(fgValues.at(-1)).toEqual({changedControl: fc1, type: 'status', value: 'VALID'}); + + expect(fc1Values.at(-2)).toEqual({changedControl: fc1, type: 'value', value: 'foo'}); + expect(fc1Values.at(-1)).toEqual({changedControl: fc1, type: 'status', value: 'VALID'}); + expect(fc1Values.length).toBe(3); + expect(fgValues.length).toBe(3); + expect(fc2Values.length).toBe(0); + + // Triggering PENDING again with the async validator now + const subject = new Subject(); + const asyncValidator = () => subject.pipe(map(() => null)); + fc1.addAsyncValidators(asyncValidator); + fc1.updateValueAndValidity(); + + expect(fc1Values.length).toBe(5); + expect(fgValues.length).toBe(5); + expect(fc2Values.length).toBe(0); + + expect(fgValues.at(-2)) + .toEqual({changedControl: fc1, type: 'value', value: {fc1: 'foo', fc2: 'bar'}}); + expect(fgValues.at(-1)).toEqual({changedControl: fc1, type: 'status', value: 'PENDING'}); + expect(fc1Values.at(-2)).toEqual({changedControl: fc1, type: 'value', value: 'foo'}); + expect(fc1Values.at(-1)).toEqual({changedControl: fc1, type: 'status', value: 'PENDING'}); + + subject.next(''); + subject.complete(); + expect(fgValues.at(-1)).toEqual({changedControl: fc1, type: 'status', value: 'VALID'}); + expect(fc1Values.at(-1)).toEqual({changedControl: fc1, type: 'status', value: 'VALID'}); + }); + }); + describe('setting status classes', () => { it('should not assign status on standalone
element', () => { @Component({