From 6ab382e4f1f5f27f48482f84152191a6d44ac1c4 Mon Sep 17 00:00:00 2001 From: Dylan Hunn Date: Thu, 6 Oct 2022 22:39:43 -0700 Subject: [PATCH] fix(forms): call `setDisabledState` on `ControlValueAcessor` when control is enabled Previously, `setDisabledState` was never called when attached if the control is enabled. This PR fixes the bug, and creates a configuration option to opt-out of the fix. Fixes #35309. BREAKING CHANGE: setDisabledState will always be called when a `ControlValueAccessor` is attached. You can opt-out with `FormsModule.withConfig` or `ReactiveFormsModule.withConfig`. --- .circleci/config.yml | 2 +- .circleci/env.sh | 2 +- goldens/public-api/forms/index.md | 25 +++-- .../size-tracking/integration-payloads.json | 4 +- .../forms_reactive/bundle.golden_symbols.json | 6 ++ .../bundle.golden_symbols.json | 6 ++ packages/forms/src/directives.ts | 1 + packages/forms/src/directives/ng_form.ts | 10 +- packages/forms/src/directives/ng_model.ts | 10 +- .../form_control_directive.ts | 10 +- .../form_group_directive.ts | 12 ++- packages/forms/src/directives/shared.ts | 37 ++++++- packages/forms/src/form_providers.ts | 37 ++++++- packages/forms/src/forms.ts | 1 + .../test/value_accessor_integration_spec.ts | 101 ++++++++++++++++++ 15 files changed, 227 insertions(+), 37 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 38c0551ee472f..ad234db9eb617 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -36,7 +36,7 @@ var_4_win: &cache_key_win_fallback v10-angular-win-node-16-{{ checksum "month.tx # Cache key for the `components-repo-unit-tests` job. **Note** when updating the SHA in the # cache keys also update the SHA for the "COMPONENTS_REPO_COMMIT" environment variable. -var_5: &components_repo_unit_tests_cache_key v2-angular-components-{{ checksum "month.txt" }}-72547a41d4230cea0c6a5448e85bd60cfc26bd35 +var_5: &components_repo_unit_tests_cache_key v2-angular-components-{{ checksum "month.txt" }}-87eb708d162e89897e66809c371e3a1e079de962 var_6: &components_repo_unit_tests_cache_key_fallback v2-angular-components-{{ checksum "month.txt" }} # Workspace initially persisted by the `setup` job, and then enhanced by `build-npm-packages`. diff --git a/.circleci/env.sh b/.circleci/env.sh index e5568050f0561..7775f88692586 100755 --- a/.circleci/env.sh +++ b/.circleci/env.sh @@ -73,7 +73,7 @@ setPublicVar COMPONENTS_REPO_TMP_DIR "/tmp/angular-components-repo" setPublicVar COMPONENTS_REPO_URL "https://github.com/angular/components.git" setPublicVar COMPONENTS_REPO_BRANCH "main" # **NOTE**: When updating the commit SHA, also update the cache key in the CircleCI `config.yml`. -setPublicVar COMPONENTS_REPO_COMMIT "72547a41d4230cea0c6a5448e85bd60cfc26bd35" +setPublicVar COMPONENTS_REPO_COMMIT "87eb708d162e89897e66809c371e3a1e079de962" #################################################################################################### # Create shell script in /tmp for Bazel actions to access CI envs without diff --git a/goldens/public-api/forms/index.md b/goldens/public-api/forms/index.md index ced3a85904b41..304bf3b810583 100644 --- a/goldens/public-api/forms/index.md +++ b/goldens/public-api/forms/index.md @@ -340,7 +340,7 @@ export const FormControl: ɵFormControlCtor; // @public export class FormControlDirective extends NgControl implements OnChanges, OnDestroy { - constructor(validators: (Validator | ValidatorFn)[], asyncValidators: (AsyncValidator | AsyncValidatorFn)[], valueAccessors: ControlValueAccessor[], _ngModelWarningConfig: string | null); + constructor(validators: (Validator | ValidatorFn)[], asyncValidators: (AsyncValidator | AsyncValidatorFn)[], valueAccessors: ControlValueAccessor[], _ngModelWarningConfig: string | null, callSetDisabledState?: SetDisabledStateOption | undefined); get control(): FormControl; form: FormControl; set isDisabled(isDisabled: boolean); @@ -358,7 +358,7 @@ export class FormControlDirective extends NgControl implements OnChanges, OnDest // (undocumented) static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) - static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } // @public @@ -466,7 +466,7 @@ export class FormGroup; // (undocumented) - static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } // @public @@ -551,6 +551,9 @@ export interface FormRecord { // @public export class FormsModule { + static withConfig(opts: { + callSetDisabledState?: SetDisabledStateOption; + }): ModuleWithProviders; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; // (undocumented) @@ -631,7 +634,7 @@ export class NgControlStatusGroup extends AbstractControlStatus { // @public export class NgForm extends ControlContainer implements Form, AfterViewInit { - constructor(validators: (Validator | ValidatorFn)[], asyncValidators: (AsyncValidator | AsyncValidatorFn)[]); + constructor(validators: (Validator | ValidatorFn)[], asyncValidators: (AsyncValidator | AsyncValidatorFn)[], callSetDisabledState?: SetDisabledStateOption | undefined); addControl(dir: NgModel): void; addFormGroup(dir: NgModelGroup): void; get control(): FormGroup; @@ -662,12 +665,12 @@ export class NgForm extends ControlContainer implements Form, AfterViewInit { // (undocumented) static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) - static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } // @public export class NgModel extends NgControl implements OnChanges, OnDestroy { - constructor(parent: ControlContainer, validators: (Validator | ValidatorFn)[], asyncValidators: (AsyncValidator | AsyncValidatorFn)[], valueAccessors: ControlValueAccessor[], _changeDetectorRef?: ChangeDetectorRef | null | undefined); + constructor(parent: ControlContainer, validators: (Validator | ValidatorFn)[], asyncValidators: (AsyncValidator | AsyncValidatorFn)[], valueAccessors: ControlValueAccessor[], _changeDetectorRef?: ChangeDetectorRef | null | undefined, callSetDisabledState?: SetDisabledStateOption | undefined); // (undocumented) readonly control: FormControl; get formDirective(): any; @@ -692,7 +695,7 @@ export class NgModel extends NgControl implements OnChanges, OnDestroy { // (undocumented) static ɵdir: i0.ɵɵDirectiveDeclaration; // (undocumented) - static ɵfac: i0.ɵɵFactoryDeclaration; + static ɵfac: i0.ɵɵFactoryDeclaration; } // @public @@ -787,7 +790,8 @@ export class RangeValueAccessor extends BuiltInControlValueAccessor implements C // @public export class ReactiveFormsModule { static withConfig(opts: { - warnOnNgModelWithFormControl: 'never' | 'once' | 'always'; + warnOnNgModelWithFormControl?: 'never' | 'once' | 'always'; + callSetDisabledState?: SetDisabledStateOption; }): ModuleWithProviders; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; @@ -833,6 +837,9 @@ export class SelectMultipleControlValueAccessor extends BuiltInControlValueAcces static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export type SetDisabledStateOption = 'whenDisabledForLegacyCode' | 'always'; + // @public export type UntypedFormArray = FormArray; diff --git a/goldens/size-tracking/integration-payloads.json b/goldens/size-tracking/integration-payloads.json index 3b05f9f055de2..5d6632ba4f543 100644 --- a/goldens/size-tracking/integration-payloads.json +++ b/goldens/size-tracking/integration-payloads.json @@ -41,7 +41,7 @@ "forms": { "uncompressed": { "runtime": 1060, - "main": 155712, + "main": 156411, "polyfills": 33915 } }, @@ -68,4 +68,4 @@ "bundle": 1214857 } } -} +} \ No newline at end of file diff --git a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json index 56cdf848bc0ca..f3d24876d459f 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -56,6 +56,9 @@ { "name": "BuiltInControlValueAccessor" }, + { + "name": "CALL_SET_DISABLED_STATE" + }, { "name": "CHECKBOX_VALUE_ACCESSOR" }, @@ -1418,6 +1421,9 @@ { "name": "setDirectiveInputsWhichShadowsStyling" }, + { + "name": "setDisabledStateDefault" + }, { "name": "setIncludeViewProviders" }, diff --git a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json index 06ed7b924458a..d89114209186c 100644 --- a/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_template_driven/bundle.golden_symbols.json @@ -59,6 +59,9 @@ { "name": "BuiltInControlValueAccessor" }, + { + "name": "CALL_SET_DISABLED_STATE" + }, { "name": "CHECKBOX_VALUE_ACCESSOR" }, @@ -1394,6 +1397,9 @@ { "name": "setDirectiveInputsWhichShadowsStyling" }, + { + "name": "setDisabledStateDefault" + }, { "name": "setIncludeViewProviders" }, diff --git a/packages/forms/src/directives.ts b/packages/forms/src/directives.ts index 92af71a67ba17..59cfa802dc815 100644 --- a/packages/forms/src/directives.ts +++ b/packages/forms/src/directives.ts @@ -43,6 +43,7 @@ export {FormGroupDirective} from './directives/reactive_directives/form_group_di export {FormArrayName, FormGroupName} from './directives/reactive_directives/form_group_name'; export {NgSelectOption, SelectControlValueAccessor} from './directives/select_control_value_accessor'; export {NgSelectMultipleOption, SelectMultipleControlValueAccessor} from './directives/select_multiple_control_value_accessor'; +export {CALL_SET_DISABLED_STATE} from './directives/shared'; export const SHARED_FORM_DIRECTIVES: Type[] = [ NgNoValidate, diff --git a/packages/forms/src/directives/ng_form.ts b/packages/forms/src/directives/ng_form.ts index ae66484e01815..b0661b304cde7 100644 --- a/packages/forms/src/directives/ng_form.ts +++ b/packages/forms/src/directives/ng_form.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {AfterViewInit, Directive, EventEmitter, forwardRef, Inject, Input, Optional, Self} from '@angular/core'; +import {AfterViewInit, Directive, EventEmitter, forwardRef, inject, Inject, Input, Optional, Self} from '@angular/core'; import {AbstractControl, FormHooks} from '../model/abstract_model'; import {FormControl} from '../model/form_control'; @@ -18,7 +18,7 @@ import {Form} from './form_interface'; import {NgControl} from './ng_control'; import {NgModel} from './ng_model'; import {NgModelGroup} from './ng_model_group'; -import {setUpControl, setUpFormContainer, syncPendingControls} from './shared'; +import {CALL_SET_DISABLED_STATE, SetDisabledStateOption, setUpControl, setUpFormContainer, syncPendingControls} from './shared'; import {AsyncValidator, AsyncValidatorFn, Validator, ValidatorFn} from './validators'; export const formDirectiveProvider: any = { @@ -135,7 +135,9 @@ export class NgForm extends ControlContainer implements Form, AfterViewInit { constructor( @Optional() @Self() @Inject(NG_VALIDATORS) validators: (Validator|ValidatorFn)[], @Optional() @Self() @Inject(NG_ASYNC_VALIDATORS) asyncValidators: - (AsyncValidator|AsyncValidatorFn)[]) { + (AsyncValidator|AsyncValidatorFn)[], + @Optional() @Inject(CALL_SET_DISABLED_STATE) private callSetDisabledState?: + SetDisabledStateOption) { super(); this.form = new FormGroup({}, composeValidators(validators), composeAsyncValidators(asyncValidators)); @@ -191,7 +193,7 @@ export class NgForm extends ControlContainer implements Form, AfterViewInit { const container = this._findContainer(dir.path); (dir as {control: FormControl}).control = container.registerControl(dir.name, dir.control); - setUpControl(dir.control, dir); + setUpControl(dir.control, dir, this.callSetDisabledState); dir.control.updateValueAndValidity({emitEvent: false}); this._directives.add(dir); }); diff --git a/packages/forms/src/directives/ng_model.ts b/packages/forms/src/directives/ng_model.ts index f3fab973f26e4..5fb8bf5aac071 100644 --- a/packages/forms/src/directives/ng_model.ts +++ b/packages/forms/src/directives/ng_model.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {ChangeDetectorRef, Directive, EventEmitter, forwardRef, Host, Inject, Input, OnChanges, OnDestroy, Optional, Output, Self, SimpleChanges, ɵcoerceToBoolean as coerceToBoolean} from '@angular/core'; +import {ChangeDetectorRef, Directive, EventEmitter, forwardRef, Host, inject, Inject, Input, OnChanges, OnDestroy, Optional, Output, Self, SimpleChanges, ɵcoerceToBoolean as coerceToBoolean} from '@angular/core'; import {FormHooks} from '../model/abstract_model'; import {FormControl} from '../model/form_control'; @@ -18,7 +18,7 @@ import {ControlValueAccessor, NG_VALUE_ACCESSOR} from './control_value_accessor' import {NgControl} from './ng_control'; import {NgForm} from './ng_form'; import {NgModelGroup} from './ng_model_group'; -import {controlPath, isPropertyUpdated, selectValueAccessor, setUpControl} from './shared'; +import {CALL_SET_DISABLED_STATE, controlPath, isPropertyUpdated, selectValueAccessor, SetDisabledStateOption, setUpControl} from './shared'; import {formGroupNameException, missingNameException, modelParentException} from './template_driven_errors'; import {AsyncValidator, AsyncValidatorFn, Validator, ValidatorFn} from './validators'; @@ -210,7 +210,9 @@ export class NgModel extends NgControl implements OnChanges, OnDestroy { @Optional() @Self() @Inject(NG_ASYNC_VALIDATORS) asyncValidators: (AsyncValidator|AsyncValidatorFn)[], @Optional() @Self() @Inject(NG_VALUE_ACCESSOR) valueAccessors: ControlValueAccessor[], - @Optional() @Inject(ChangeDetectorRef) private _changeDetectorRef?: ChangeDetectorRef|null) { + @Optional() @Inject(ChangeDetectorRef) private _changeDetectorRef?: ChangeDetectorRef|null, + @Optional() @Inject(CALL_SET_DISABLED_STATE) private callSetDisabledState?: + SetDisabledStateOption) { super(); this._parent = parent; this._setValidators(validators); @@ -295,7 +297,7 @@ export class NgModel extends NgControl implements OnChanges, OnDestroy { } private _setUpStandalone(): void { - setUpControl(this.control, this); + setUpControl(this.control, this, this.callSetDisabledState); this.control.updateValueAndValidity({emitEvent: false}); } diff --git a/packages/forms/src/directives/reactive_directives/form_control_directive.ts b/packages/forms/src/directives/reactive_directives/form_control_directive.ts index 03d13b926be99..e3b7464e03843 100644 --- a/packages/forms/src/directives/reactive_directives/form_control_directive.ts +++ b/packages/forms/src/directives/reactive_directives/form_control_directive.ts @@ -6,14 +6,14 @@ * found in the LICENSE file at https://angular.io/license */ -import {Directive, EventEmitter, forwardRef, Inject, InjectionToken, Input, OnChanges, OnDestroy, Optional, Output, Self, SimpleChanges} from '@angular/core'; +import {Directive, EventEmitter, forwardRef, Inject, inject, InjectionToken, Input, OnChanges, OnDestroy, Optional, Output, Self, SimpleChanges} from '@angular/core'; import {FormControl} from '../../model/form_control'; import {NG_ASYNC_VALIDATORS, NG_VALIDATORS} from '../../validators'; import {ControlValueAccessor, NG_VALUE_ACCESSOR} from '../control_value_accessor'; import {NgControl} from '../ng_control'; import {disabledAttrWarning} from '../reactive_errors'; -import {_ngModelWarning, cleanUpControl, isPropertyUpdated, selectValueAccessor, setUpControl} from '../shared'; +import {_ngModelWarning, CALL_SET_DISABLED_STATE, cleanUpControl, isPropertyUpdated, selectValueAccessor, setDisabledStateDefault, SetDisabledStateOption, setUpControl} from '../shared'; import {AsyncValidator, AsyncValidatorFn, Validator, ValidatorFn} from '../validators'; @@ -108,7 +108,9 @@ export class FormControlDirective extends NgControl implements OnChanges, OnDest (AsyncValidator|AsyncValidatorFn)[], @Optional() @Self() @Inject(NG_VALUE_ACCESSOR) valueAccessors: ControlValueAccessor[], @Optional() @Inject(NG_MODEL_WITH_FORM_CONTROL_WARNING) private _ngModelWarningConfig: string| - null) { + null, + @Optional() @Inject(CALL_SET_DISABLED_STATE) private callSetDisabledState?: + SetDisabledStateOption) { super(); this._setValidators(validators); this._setAsyncValidators(asyncValidators); @@ -122,7 +124,7 @@ export class FormControlDirective extends NgControl implements OnChanges, OnDest if (previousForm) { cleanUpControl(previousForm, this, /* validateControlPresenceOnChange */ false); } - setUpControl(this.form, this); + setUpControl(this.form, this, this.callSetDisabledState); this.form.updateValueAndValidity({emitEvent: false}); } if (isPropertyUpdated(changes, this.viewModel)) { 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 171f5c407c0ce..1ef80e3af0a98 100644 --- a/packages/forms/src/directives/reactive_directives/form_group_directive.ts +++ b/packages/forms/src/directives/reactive_directives/form_group_directive.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {Directive, EventEmitter, forwardRef, Inject, Input, OnChanges, OnDestroy, Optional, Output, Self, SimpleChanges} from '@angular/core'; +import {Directive, EventEmitter, forwardRef, Inject, inject, Input, OnChanges, OnDestroy, Optional, Output, Self, SimpleChanges} from '@angular/core'; import {FormArray} from '../../model/form_array'; import {FormControl, isFormControl} from '../../model/form_control'; @@ -15,7 +15,7 @@ import {NG_ASYNC_VALIDATORS, NG_VALIDATORS} from '../../validators'; import {ControlContainer} from '../control_container'; import {Form} from '../form_interface'; import {missingFormException} from '../reactive_errors'; -import {cleanUpControl, cleanUpFormContainer, cleanUpValidators, removeListItem, setUpControl, setUpFormContainer, setUpValidators, syncPendingControls} from '../shared'; +import {CALL_SET_DISABLED_STATE, cleanUpControl, cleanUpFormContainer, cleanUpValidators, removeListItem, SetDisabledStateOption, setUpControl, setUpFormContainer, setUpValidators, syncPendingControls} from '../shared'; import {AsyncValidator, AsyncValidatorFn, Validator, ValidatorFn} from '../validators'; import {FormControlName} from './form_control_name'; @@ -96,7 +96,9 @@ export class FormGroupDirective extends ControlContainer implements Form, OnChan constructor( @Optional() @Self() @Inject(NG_VALIDATORS) validators: (Validator|ValidatorFn)[], @Optional() @Self() @Inject(NG_ASYNC_VALIDATORS) asyncValidators: - (AsyncValidator|AsyncValidatorFn)[]) { + (AsyncValidator|AsyncValidatorFn)[], + @Optional() @Inject(CALL_SET_DISABLED_STATE) private callSetDisabledState?: + SetDisabledStateOption) { super(); this._setValidators(validators); this._setAsyncValidators(asyncValidators); @@ -164,7 +166,7 @@ export class FormGroupDirective extends ControlContainer implements Form, OnChan */ addControl(dir: FormControlName): FormControl { const ctrl: any = this.form.get(dir.path); - setUpControl(ctrl, dir); + setUpControl(ctrl, dir, this.callSetDisabledState); ctrl.updateValueAndValidity({emitEvent: false}); this.directives.push(dir); return ctrl; @@ -312,7 +314,7 @@ export class FormGroupDirective extends ControlContainer implements Form, OnChan // taken care of in the `removeControl` method invoked when corresponding `formControlName` // directive instance is being removed (invoked from `FormControlName.ngOnDestroy`). if (isFormControl(newCtrl)) { - setUpControl(newCtrl, dir); + setUpControl(newCtrl, dir, this.callSetDisabledState); (dir as {control: FormControl}).control = newCtrl; } } diff --git a/packages/forms/src/directives/shared.ts b/packages/forms/src/directives/shared.ts index 30dee87a1d991..502dce2da1f67 100644 --- a/packages/forms/src/directives/shared.ts +++ b/packages/forms/src/directives/shared.ts @@ -6,7 +6,7 @@ * found in the LICENSE file at https://angular.io/license */ -import {ɵRuntimeError as RuntimeError} from '@angular/core'; +import {Inject, InjectionToken, ɵRuntimeError as RuntimeError} from '@angular/core'; import {RuntimeErrorCode} from '../errors'; import {AbstractControl} from '../model/abstract_model'; @@ -25,6 +25,30 @@ import {FormArrayName} from './reactive_directives/form_group_name'; import {ngModelWarning} from './reactive_errors'; import {AsyncValidatorFn, Validator, ValidatorFn} from './validators'; +/** + * Token to provide to allow SetDisabledState to always be called when a CVA is added, regardless of + * whether the control is disabled or enabled. + * + * @see `FormsModule.withConfig` + */ +export const CALL_SET_DISABLED_STATE = new InjectionToken( + 'CallSetDisabledState', {providedIn: 'root', factory: () => setDisabledStateDefault}); + +/** + * The type for CALL_SET_DISABLED_STATE. If `always`, then ControlValueAccessor will always call + * `setDisabledState` when attached, which is the most correct behavior. Otherwise, it will only be + * called when disabled, which is the legacy behavior for compatibility. + * + * @publicApi + * @see `FormsModule.withConfig` + */ +export type SetDisabledStateOption = 'whenDisabledForLegacyCode'|'always'; + +/** + * Whether to use the fixed setDisabledState behavior by default. + */ +export const setDisabledStateDefault: SetDisabledStateOption = 'always'; + export function controlPath(name: string|null, parent: ControlContainer): string[] { return [...parent.path!, name!]; } @@ -36,7 +60,9 @@ export function controlPath(name: string|null, parent: ControlContainer): string * @param control Form control instance that should be linked. * @param dir Directive that should be linked with a given control. */ -export function setUpControl(control: FormControl, dir: NgControl): void { +export function setUpControl( + control: FormControl, dir: NgControl, + callSetDisabledState: SetDisabledStateOption = setDisabledStateDefault): void { if (typeof ngDevMode === 'undefined' || ngDevMode) { if (!control) _throwError(dir, 'Cannot find control with'); if (!dir.valueAccessor) _throwError(dir, 'No value accessor for form control with'); @@ -46,8 +72,11 @@ export function setUpControl(control: FormControl, dir: NgControl): void { dir.valueAccessor!.writeValue(control.value); - if (control.disabled) { - dir.valueAccessor!.setDisabledState?.(true); + // The legacy behavior only calls the CVA's `setDisabledState` if the control is disabled. + // If the `callSetDisabledState` option is set to `always`, then this bug is fixed and + // the method is always called. + if (control.disabled || callSetDisabledState === 'always') { + dir.valueAccessor!.setDisabledState?.(control.disabled); } setUpViewChangePipeline(control, dir); diff --git a/packages/forms/src/form_providers.ts b/packages/forms/src/form_providers.ts index df86eb45bb76b..7f6d9e4ba6f88 100644 --- a/packages/forms/src/form_providers.ts +++ b/packages/forms/src/form_providers.ts @@ -9,6 +9,7 @@ import {ModuleWithProviders, NgModule} from '@angular/core'; import {InternalFormsSharedModule, NG_MODEL_WITH_FORM_CONTROL_WARNING, REACTIVE_DRIVEN_DIRECTIVES, TEMPLATE_DRIVEN_DIRECTIVES} from './directives'; +import {CALL_SET_DISABLED_STATE, setDisabledStateDefault, SetDisabledStateOption} from './directives/shared'; /** * Exports the required providers and directives for template-driven forms, @@ -27,6 +28,25 @@ import {InternalFormsSharedModule, NG_MODEL_WITH_FORM_CONTROL_WARNING, REACTIVE_ exports: [InternalFormsSharedModule, TEMPLATE_DRIVEN_DIRECTIVES] }) export class FormsModule { + /** + * @description + * Provides options for configuring the forms module. + * + * @param opts An object of configuration options + * * `callSetDisabledState` Configures whether to `always` call `setDisabledState`, which is more + * correct, or to only call it `whenDisabled`, which is the legacy behavior. + */ + static withConfig(opts: { + callSetDisabledState?: SetDisabledStateOption, + }): ModuleWithProviders { + return { + ngModule: FormsModule, + providers: [{ + provide: CALL_SET_DISABLED_STATE, + useValue: opts.callSetDisabledState ?? setDisabledStateDefault + }] + }; + } } /** @@ -54,14 +74,25 @@ export class ReactiveFormsModule { * @param opts An object of configuration options * * `warnOnNgModelWithFormControl` Configures when to emit a warning when an `ngModel` * binding is used with reactive form directives. + * * `callSetDisabledState` Configures whether to `always` call `setDisabledState`, which is more + * correct, or to only call it `whenDisabled`, which is the legacy behavior. */ static withConfig(opts: { - /** @deprecated as of v6 */ warnOnNgModelWithFormControl: 'never'|'once'|'always' - }): ModuleWithProviders { + /** @deprecated as of v6 */ warnOnNgModelWithFormControl?: 'never'|'once'| + 'always', + callSetDisabledState?: SetDisabledStateOption, + }): ModuleWithProviders { return { ngModule: ReactiveFormsModule, providers: [ - {provide: NG_MODEL_WITH_FORM_CONTROL_WARNING, useValue: opts.warnOnNgModelWithFormControl} + { + provide: NG_MODEL_WITH_FORM_CONTROL_WARNING, + useValue: opts.warnOnNgModelWithFormControl ?? 'always' + }, + { + provide: CALL_SET_DISABLED_STATE, + useValue: opts.callSetDisabledState ?? setDisabledStateDefault + } ] }; } diff --git a/packages/forms/src/forms.ts b/packages/forms/src/forms.ts index 799a395e7bff2..c77d059aadb05 100644 --- a/packages/forms/src/forms.ts +++ b/packages/forms/src/forms.ts @@ -40,6 +40,7 @@ export {FormGroupDirective} from './directives/reactive_directives/form_group_di export {FormArrayName, FormGroupName} from './directives/reactive_directives/form_group_name'; export {NgSelectOption, SelectControlValueAccessor} from './directives/select_control_value_accessor'; export {SelectMultipleControlValueAccessor, ɵNgSelectMultipleOption} from './directives/select_multiple_control_value_accessor'; +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'; diff --git a/packages/forms/test/value_accessor_integration_spec.ts b/packages/forms/test/value_accessor_integration_spec.ts index a3acdae37d825..243572b07b523 100644 --- a/packages/forms/test/value_accessor_integration_spec.ts +++ b/packages/forms/test/value_accessor_integration_spec.ts @@ -1066,6 +1066,40 @@ import {dispatchEvent} from '@angular/platform-browser/testing/src/browser_util' expect(fixture.componentInstance.control.status).toEqual('DISABLED'); }); + describe('should support custom accessors with setDisabledState - formControlName', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = initTest(CvaWithDisabledStateForm, CvaWithDisabledState); + }); + + it('sets the disabled state when the control is initally disabled', () => { + fixture.componentInstance.form = new FormGroup({ + 'login': new FormControl({value: 'aa', disabled: true}), + }); + fixture.detectChanges(); + + expect(fixture.componentInstance.form.status).toEqual('DISABLED'); + expect(fixture.componentInstance.form.get('login')!.status).toEqual('DISABLED'); + expect(fixture.debugElement.query(By.directive(CvaWithDisabledState)) + .nativeElement.textContent) + .toContain('DISABLED'); + }); + + it('sets the enabled state when the control is initally enabled', () => { + fixture.componentInstance.form = new FormGroup({ + 'login': new FormControl({value: 'aa', disabled: false}), + }); + fixture.detectChanges(); + + expect(fixture.componentInstance.form.status).toEqual('VALID'); + expect(fixture.componentInstance.form.get('login')!.status).toEqual('VALID'); + expect(fixture.debugElement.query(By.directive(CvaWithDisabledState)) + .nativeElement.textContent) + .toContain('ENABLED'); + }); + }); + it('should populate control in ngOnInit when injecting NgControl', () => { const fixture = initTest(MyInputForm, MyInput); fixture.componentInstance.form = new FormGroup({'login': new FormControl('aa')}); @@ -1163,6 +1197,38 @@ import {dispatchEvent} from '@angular/platform-browser/testing/src/browser_util' }); } + +describe('value accessors in reactive forms with custom options', () => { + function initTest(component: Type, ...directives: Type[]): ComponentFixture { + TestBed.configureTestingModule({ + declarations: [component, ...directives], + imports: [ReactiveFormsModule.withConfig({callSetDisabledState: 'whenDisabledForLegacyCode'})] + }); + return TestBed.createComponent(component); + } + + describe('should support custom accessors with setDisabledState', () => { + let fixture: ComponentFixture; + + beforeEach(() => { + fixture = initTest(CvaWithDisabledStateForm, CvaWithDisabledState); + }); + + it('does not set the enabled state when the control is initally enabled', () => { + fixture.componentInstance.form = new FormGroup({ + 'login': new FormControl({value: 'aa', disabled: false}), + }); + fixture.detectChanges(); + + expect(fixture.componentInstance.form.status).toEqual('VALID'); + expect(fixture.componentInstance.form.get('login')!.status).toEqual('VALID'); + expect( + fixture.debugElement.query(By.directive(CvaWithDisabledState)).nativeElement.textContent) + .toContain('UNSET'); + }); + }); +}); + @Component({selector: 'form-control-comp', template: ``}) export class FormControlComp { // TODO(issue/24571): remove '!'. @@ -1439,6 +1505,41 @@ class WrappedValue implements ControlValueAccessor { } } +@Component({ + selector: 'cva-with-disabled-state', + template: ` +
CALLED WITH {{disabled ? 'DISABLED' : 'ENABLED'}}
+
UNSET
+ `, + providers: [ + {provide: NG_VALUE_ACCESSOR, multi: true, useExisting: CvaWithDisabledState}, + ] +}) +class CvaWithDisabledState implements ControlValueAccessor { + disabled?: boolean; + onChange!: Function; + + writeValue(value: any) {} + + registerOnChange(fn: (value: any) => void) {} + registerOnTouched(fn: any) {} + + setDisabledState(disabled: boolean) { + this.disabled = disabled; + } +} + +@Component({ + selector: 'wrapped-value-form', + template: ` +
+ +
` +}) +class CvaWithDisabledStateForm { + form!: FormGroup; +} + @Component({selector: 'my-input', template: ''}) export class MyInput implements ControlValueAccessor { @Output('input') onInput = new EventEmitter();