From 2dbdebc6467074c7005c09ef5c229029f8d3607c Mon Sep 17 00:00:00 2001 From: Dylan Hunn Date: Mon, 2 May 2022 20:18:31 -0700 Subject: [PATCH] feat(forms): Add `FormBuilder.nonNullable`. (#45852) With typed forms, all `FormControl`s are nullable by default, because they can be reset to `null`. This behavior is possible to change by passing the option `initialValueIsDefault: true`. However, in a large form, this is extremely cumbersome, as the option must be repeated over and over. Additionally, it is not possible to take full advantage of `FormBuilder`, since `FormBuilder.group` and `FormBuilder.array` will produce nullable controls. This PR introduces a new accessor `FormBuilder.nonNullable`, which produces *non-nullable* controls. Specifically, any call to `.control` will produce controls with `{initialValueIsDefault: true}`, and calls to `.array` or `.group` that implicitly build inner controls will have the same effect. ```ts let nfb = new FormBuilder().nonNullable; let name = nfb.group({who: 'Alex'}); // FormGroup<{who: FormControl}> name.reset(); console.log(name); // {who: 'Alex'} ``` PR Close #45852 --- goldens/public-api/forms/index.md | 17 +- .../forms_reactive/bundle.golden_symbols.json | 3 + packages/forms/src/form_builder.ts | 129 ++++++++++-- packages/forms/src/forms.ts | 2 +- packages/forms/test/typed_integration_spec.ts | 187 +++++++++++++++++- 5 files changed, 317 insertions(+), 21 deletions(-) diff --git a/goldens/public-api/forms/index.md b/goldens/public-api/forms/index.md index 3af1e78c2452c..2bbed766850f8 100644 --- a/goldens/public-api/forms/index.md +++ b/goldens/public-api/forms/index.md @@ -276,7 +276,7 @@ export class FormArrayName extends ControlContainer implements OnInit, OnDestroy // @public export class FormBuilder { - array(controls: Array, validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null): FormArray<ɵElement>; + array(controls: Array, validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null): FormArray<ɵElement>; // (undocumented) control(formState: T | FormControlState, opts: FormControlOptions & { initialValueIsDefault: true; @@ -284,7 +284,7 @@ export class FormBuilder { // (undocumented) control(formState: T | FormControlState, validatorOrOpts?: ValidatorFn | ValidatorFn[] | FormControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null): FormControl; group(controls: T, options?: AbstractControlOptions | null): FormGroup<{ - [K in keyof T]: ɵElement; + [K in keyof T]: ɵElement; }>; // @deprecated group(controls: { @@ -292,6 +292,7 @@ export class FormBuilder { }, options: { [key: string]: any; }): FormGroup; + get nonNullable(): NonNullableFormBuilder; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; // (undocumented) @@ -704,6 +705,15 @@ export class NgSelectOption implements OnDestroy { static ɵfac: i0.ɵɵFactoryDeclaration; } +// @public +export interface NonNullableFormBuilder { + array(controls: Array, validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null): FormArray<ɵElement>; + control(formState: T | FormControlState, validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null): FormControl; + group(controls: T, options?: AbstractControlOptions | null): FormGroup<{ + [K in keyof T]: ɵElement; + }>; +} + // @public export class NumberValueAccessor extends BuiltInControlValueAccessor implements ControlValueAccessor { registerOnChange(fn: (_: number | null) => void): void; @@ -810,11 +820,8 @@ export const UntypedFormArray: UntypedFormArrayCtor; // @public export class UntypedFormBuilder extends FormBuilder { - // (undocumented) array(controlsConfig: any[], validatorOrOpts?: ValidatorFn | ValidatorFn[] | AbstractControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null): UntypedFormArray; - // (undocumented) control(formState: any, validatorOrOpts?: ValidatorFn | ValidatorFn[] | FormControlOptions | null, asyncValidator?: AsyncValidatorFn | AsyncValidatorFn[] | null): UntypedFormControl; - // (undocumented) group(controlsConfig: { [key: string]: any; }, options?: AbstractControlOptions | null): UntypedFormGroup; 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 e7d6c7d9f20a4..bf3075fda7186 100644 --- a/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json +++ b/packages/core/test/bundling/forms_reactive/bundle.golden_symbols.json @@ -1076,6 +1076,9 @@ { "name": "invokeHostBindingsInCreationMode" }, + { + "name": "isAbstractControlOptions" + }, { "name": "isAnimationProp" }, diff --git a/packages/forms/src/form_builder.ts b/packages/forms/src/form_builder.ts index 6528179112f47..0e86caf9bb48b 100644 --- a/packages/forms/src/form_builder.ts +++ b/packages/forms/src/form_builder.ts @@ -38,26 +38,28 @@ function isFormControlOptions(options: FormControlOptions|{[key: string]: any}|n */ export type ControlConfig = [T|FormControlState, (ValidatorFn|(ValidatorFn[]))?, (AsyncValidatorFn|AsyncValidatorFn[])?]; -// Disable clang-format to produce clearer formatting for these multiline types. + +// Disable clang-format to produce clearer formatting for this multiline type. // clang-format off /** * FormBuilder accepts values in various container shapes, as well as raw values. - * Element returns the appropriate corresponding model class. + * Element returns the appropriate corresponding model class, given the container T. + * The flag N, if not never, makes the resulting `FormControl` have N in its type. */ -export type ɵElement = +export type ɵElement = T extends FormControl ? FormControl : T extends FormGroup ? FormGroup : T extends FormArray ? FormArray : T extends AbstractControl ? AbstractControl : - T extends FormControlState ? FormControl : - T extends ControlConfig ? FormControl : + T extends FormControlState ? FormControl : + T extends ControlConfig ? FormControl : // ControlConfig can be too much for the compiler to infer in the wrapped case. This is // not surprising, since it's practically death-by-polymorphism (e.g. the optional validators // members that might be arrays). Watch for ControlConfigs that might fall through. - T extends Array ? FormControl : - // Fallthough case: T is not a container type; use is directly as a value. - FormControl; + T extends Array ? FormControl : + // Fallthough case: T is not a container type; use it directly as a value. + FormControl; // clang-format on @@ -75,6 +77,56 @@ export type ɵElement = */ @Injectable({providedIn: ReactiveFormsModule}) export class FormBuilder { + private useNonNullable: boolean = false; + + /** + * @description + * Returns a FormBuilder in which automatically constructed @see FormControl} elements + * have `{initialValueIsDefault: true}` and are non-nullable. + * + * **Constructing non-nullable controls** + * + * When constructing a control, it will be non-nullable, and will reset to its initial value. + * + * ```ts + * let nnfb = new FormBuilder().nonNullable; + * let name = nnfb.control('Alex'); // FormControl + * name.reset(); + * console.log(name); // 'Alex' + * ``` + * + * **Constructing non-nullable groups or arrays** + * + * When constructing a group or array, all automatically created inner controls will be + * non-nullable, and will reset to their initial values. + * + * ```ts + * let nnfb = new FormBuilder().nonNullable; + * let name = nnfb.group({who: 'Alex'}); // FormGroup<{who: FormControl}> + * name.reset(); + * console.log(name); // {who: 'Alex'} + * ``` + * **Constructing *nullable* fields on groups or arrays** + * + * It is still possible to have a nullable field. In particular, any `FormControl` which is + * *already* constructed will not be altered. For example: + * + * ```ts + * let nnfb = new FormBuilder().nonNullable; + * // FormGroup<{who: FormControl}> + * let name = nnfb.group({who: new FormControl('Alex')}); + * name.reset(); console.log(name); // {who: null} + * ``` + * + * Because the inner control is constructed explicitly by the caller, the builder has + * no control over how it is created, and cannot exclude the `null`. + */ + get nonNullable(): NonNullableFormBuilder { + const nnfb = new FormBuilder(); + nnfb.useNonNullable = true; + return nnfb as NonNullableFormBuilder; + } + /** * @description * Construct a new `FormGroup` instance. Accepts a single generic argument, which is an object @@ -93,7 +145,7 @@ export class FormBuilder { group( controls: T, options?: AbstractControlOptions|null, - ): FormGroup<{[K in keyof T]: ɵElement}>; + ): FormGroup<{[K in keyof T]: ɵElement}>; /** * @description @@ -189,7 +241,19 @@ export class FormBuilder { formState: T|FormControlState, validatorOrOpts?: ValidatorFn|ValidatorFn[]|FormControlOptions|null, asyncValidator?: AsyncValidatorFn|AsyncValidatorFn[]|null): FormControl { - return new FormControl(formState, validatorOrOpts, asyncValidator); + let newOptions: FormControlOptions = {}; + if (!this.useNonNullable) { + return new FormControl(formState, validatorOrOpts, asyncValidator); + } + if (isAbstractControlOptions(validatorOrOpts)) { + // If the second argument is options, then they are copied. + newOptions = validatorOrOpts; + } else { + // If the other arguments are validators, they are copied into an options object. + newOptions.validators = validatorOrOpts; + newOptions.asyncValidators = asyncValidator; + } + return new FormControl(formState, {...newOptions, initialValueIsDefault: true}); } /** @@ -208,7 +272,7 @@ export class FormBuilder { */ array( controls: Array, validatorOrOpts?: ValidatorFn|ValidatorFn[]|AbstractControlOptions|null, - asyncValidator?: AsyncValidatorFn|AsyncValidatorFn[]|null): FormArray<ɵElement> { + asyncValidator?: AsyncValidatorFn|AsyncValidatorFn[]|null): FormArray<ɵElement> { const createdControls = controls.map(c => this._createControl(c)); // Cast to `any` because the inferred types are not as specific as Element. return new FormArray(createdControls, validatorOrOpts, asyncValidator) as any; @@ -244,13 +308,50 @@ export class FormBuilder { } } +/** + * @description + * `NonNullableFormBuilder` is similar to {@see FormBuilder}, but automatically constructed + * {@see FormControl} elements have `{initialValueIsDefault: true}` and are non-nullable. + * + * @publicApi + */ +export interface NonNullableFormBuilder { + /** + * Similar to {@see FormBuilder#group}, except any implicitly constructed `FormControl` + * will be non-nullable (i.e. it will have `initialValueIsDefault` set to true). Note + * that already-constructed controls will not be altered. + */ + group( + controls: T, + options?: AbstractControlOptions|null, + ): FormGroup<{[K in keyof T]: ɵElement}>; + + /** + * Similar to {@see FormBuilder#array}, except any implicitly constructed `FormControl` + * will be non-nullable (i.e. it will have `initialValueIsDefault` set to true). Note + * that already-constructed controls will not be altered. + */ + array( + controls: Array, validatorOrOpts?: ValidatorFn|ValidatorFn[]|AbstractControlOptions|null, + asyncValidator?: AsyncValidatorFn|AsyncValidatorFn[]|null): FormArray<ɵElement>; + + /** + * Similar to {@see FormBuilder#control}, except this overridden version of `control` forces + * `initialValueIsDefault` to be `true`, resulting in the control always being non-nullable. + */ + control( + formState: T|FormControlState, + validatorOrOpts?: ValidatorFn|ValidatorFn[]|AbstractControlOptions|null, + asyncValidator?: AsyncValidatorFn|AsyncValidatorFn[]|null): FormControl; +} + /** * UntypedFormBuilder is the same as @see FormBuilder, but it provides untyped controls. */ @Injectable({providedIn: ReactiveFormsModule}) export class UntypedFormBuilder extends FormBuilder { /** - * @see FormBuilder#group + * Like {@see FormBuilder#group}, except the resulting group is untyped. */ override group( controlsConfig: {[key: string]: any}, @@ -273,7 +374,7 @@ export class UntypedFormBuilder extends FormBuilder { } /** - * @see FormBuilder#control + * Like {@see FormBuilder#control}, except the resulting control is untyped. */ override control( formState: any, validatorOrOpts?: ValidatorFn|ValidatorFn[]|FormControlOptions|null, @@ -282,7 +383,7 @@ export class UntypedFormBuilder extends FormBuilder { } /** - * @see FormBuilder#array + * Like {@see FormBuilder#array}, except the resulting array is untyped. */ override array( controlsConfig: any[], diff --git a/packages/forms/src/forms.ts b/packages/forms/src/forms.ts index e13994e17a0b5..98ddd8d923f9f 100644 --- a/packages/forms/src/forms.ts +++ b/packages/forms/src/forms.ts @@ -41,7 +41,7 @@ export {FormArrayName, FormGroupName} from './directives/reactive_directives/for export {NgSelectOption, SelectControlValueAccessor} from './directives/select_control_value_accessor'; export {SelectMultipleControlValueAccessor, ɵNgSelectMultipleOption} from './directives/select_multiple_control_value_accessor'; export {AsyncValidator, AsyncValidatorFn, CheckboxRequiredValidator, EmailValidator, MaxLengthValidator, MaxValidator, MinLengthValidator, MinValidator, PatternValidator, RequiredValidator, ValidationErrors, Validator, ValidatorFn} from './directives/validators'; -export {FormBuilder, UntypedFormBuilder, ɵElement} from './form_builder'; +export {FormBuilder, NonNullableFormBuilder, UntypedFormBuilder, ɵElement} from './form_builder'; export {AbstractControl, AbstractControlOptions, FormControlStatus, ɵCoerceStrArrToNumArr, ɵGetProperty, ɵNavigate, ɵRawValue, ɵTokenize, ɵTypedOrUntyped, ɵValue, ɵWriteable} from './model/abstract_model'; export {FormArray, UntypedFormArray, ɵFormArrayRawValue, ɵFormArrayValue} from './model/form_array'; export {FormControl, FormControlOptions, FormControlState, UntypedFormControl, ɵFormControlCtor} from './model/form_control'; diff --git a/packages/forms/test/typed_integration_spec.ts b/packages/forms/test/typed_integration_spec.ts index 1b11402ae0c78..3fd881da82802 100644 --- a/packages/forms/test/typed_integration_spec.ts +++ b/packages/forms/test/typed_integration_spec.ts @@ -9,7 +9,7 @@ // These tests mainly check the types of strongly typed form controls, which is generally enforced // at compile time. -import {FormBuilder, UntypedFormBuilder} from '../src/form_builder'; +import {FormBuilder, NonNullableFormBuilder, UntypedFormBuilder} from '../src/form_builder'; import {AbstractControl, FormArray, FormControl, FormGroup, UntypedFormArray, UntypedFormControl, UntypedFormGroup, Validators} from '../src/forms'; import {FormRecord} from '../src/model/form_group'; @@ -1270,6 +1270,191 @@ describe('Typed Class', () => { } }); }); + + describe('NonNullFormBuilder', () => { + let fb: NonNullableFormBuilder; + + beforeEach(() => { + fb = new FormBuilder().nonNullable; + }); + + describe('should build FormControls', () => { + it('non-nullably from values', () => { + const c = fb.control('foo'); + { + type RawValueType = string; + let t: RawValueType = c.getRawValue(); + let t1 = c.getRawValue(); + t1 = null as unknown as RawValueType; + } + c.reset(); + expect(c.value).not.toBeNull; + }); + }); + + describe('should build FormGroups', () => { + it('from objects with plain values', () => { + const c = fb.group({foo: 'bar'}); + { + type ControlsType = {foo: FormControl}; + let t: ControlsType = c.controls; + let t1 = c.controls; + t1 = null as unknown as ControlsType; + } + c.reset(); + expect(c.value).toEqual({foo: 'bar'}); + }); + + it('from objects with FormControlState', () => { + const c = fb.group({foo: {value: 'bar', disabled: false}}); + { + type ControlsType = {foo: FormControl}; + let t: ControlsType = c.controls; + let t1 = c.controls; + t1 = null as unknown as ControlsType; + } + c.reset(); + expect(c.value).toEqual({foo: 'bar'}); + }); + + it('from objects with ControlConfigs', () => { + const c = fb.group({foo: ['bar']}); + { + type ControlsType = {foo: FormControl}; + let t: ControlsType = c.controls; + let t1 = c.controls; + t1 = null as unknown as ControlsType; + } + c.reset(); + expect(c.value).toEqual({foo: 'bar'}); + }); + + it('from objects with ControlConfigs and validators', () => { + const c = fb.group({foo: ['bar', Validators.required]}); + { + type ControlsType = {foo: FormControl}; + let t: ControlsType = c.controls; + let t1 = c.controls; + t1 = null as unknown as ControlsType; + } + c.reset(); + expect(c.value).toEqual({foo: 'bar'}); + }); + + it('from objects with ControlConfigs and validator lists', () => { + const c = fb.group({foo: ['bar', [Validators.required, Validators.email]]}); + { + type ControlsType = {foo: FormControl}; + let t: ControlsType = c.controls; + let t1 = c.controls; + t1 = null as unknown as ControlsType; + } + c.reset(); + expect(c.value).toEqual({foo: 'bar'}); + }); + + it('from objects with ControlConfigs and explicit types', () => { + const c: FormGroup<{foo: FormControl}> = + fb.group({foo: ['bar', [Validators.required, Validators.email]]}); + { + type ControlsType = {foo: FormControl}; + let t: ControlsType = c.controls; + let t1 = c.controls; + t1 = null as unknown as ControlsType; + } + c.reset(); + expect(c.value).toEqual({foo: 'bar'}); + }); + + describe('from objects with FormControls', () => { + it('from objects with builder FormGroups', () => { + const c = fb.group({foo: fb.group({baz: 'bar'})}); + { + type ControlsType = {foo: FormGroup<{baz: FormControl}>}; + let t: ControlsType = c.controls; + let t1 = c.controls; + t1 = null as unknown as ControlsType; + } + c.reset(); + expect(c.value).toEqual({foo: {baz: 'bar'}}); + }); + + it('from objects with builder FormArrays', () => { + const c = fb.group({foo: fb.array(['bar'])}); + { + type ControlsType = {foo: FormArray>}; + let t: ControlsType = c.controls; + let t1 = c.controls; + t1 = null as unknown as ControlsType; + } + c.reset(); + expect(c.value).toEqual({foo: ['bar']}); + }); + }); + }); + + describe('should build FormArrays', () => { + it('from arrays with plain values', () => { + const c = fb.array(['foo']); + { + type ControlsType = Array>; + let t: ControlsType = c.controls; + let t1 = c.controls; + t1 = null as unknown as ControlsType; + } + c.reset(); + expect(c.value).toEqual(['foo']); + }); + + it('from arrays with FormControlStates', () => { + const c = fb.array([{value: 'foo', disabled: false}]); + { + type ControlsType = Array>; + let t: ControlsType = c.controls; + let t1 = c.controls; + t1 = null as unknown as ControlsType; + } + c.reset(); + expect(c.value).toEqual(['foo']); + }); + + it('from arrays with ControlConfigs', () => { + const c = fb.array([['foo']]); + { + type ControlsType = Array>; + let t: ControlsType = c.controls; + let t1 = c.controls; + t1 = null as unknown as ControlsType; + } + c.reset(); + expect(c.value).toEqual(['foo']); + }); + + it('from arrays with builder FormArrays', () => { + const c = fb.array([fb.array(['foo'])]); + { + type ControlsType = Array>>; + let t: ControlsType = c.controls; + let t1 = c.controls; + t1 = null as unknown as ControlsType; + } + c.reset(); + expect(c.value).toEqual([['foo']]); + }); + + it('from arrays with builder FormGroups', () => { + const c = fb.array([fb.group({bar: 'foo'})]); + { + type ControlsType = Array}>>; + let t: ControlsType = c.controls; + let t1 = c.controls; + t1 = null as unknown as ControlsType; + } + c.reset(); + expect(c.value).toEqual([{bar: 'foo'}]); + }); + }); + }); }); describe('Untyped Class', () => {