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<string>}>
name.reset();
console.log(name); // {who: 'Alex'}
```

PR Close #45852
This commit is contained in:
Dylan Hunn
2022-05-02 20:18:31 -07:00
parent 41e2a68e30
commit 2dbdebc646
5 changed files with 317 additions and 21 deletions
@@ -1076,6 +1076,9 @@
{
"name": "invokeHostBindingsInCreationMode"
},
{
"name": "isAbstractControlOptions"
},
{
"name": "isAnimationProp"
},
+115 -14
View File
@@ -38,26 +38,28 @@ function isFormControlOptions(options: FormControlOptions|{[key: string]: any}|n
*/
export type ControlConfig<T> = [T|FormControlState<T>, (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<T> =
export type ɵElement<T, N extends null> =
T extends FormControl<infer U> ? FormControl<U> :
T extends FormGroup<infer U> ? FormGroup<U> :
T extends FormArray<infer U> ? FormArray<U> :
T extends AbstractControl<infer U> ? AbstractControl<U> :
T extends FormControlState<infer U> ? FormControl<U|null> :
T extends ControlConfig<infer U> ? FormControl<U|null> :
T extends FormControlState<infer U> ? FormControl<U|N> :
T extends ControlConfig<infer U> ? FormControl<U|N> :
// 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<infer U|ValidatorFn|ValidatorFn[]|AsyncValidatorFn|AsyncValidatorFn[]> ? FormControl<U|null> :
// Fallthough case: T is not a container type; use is directly as a value.
FormControl<T|null>;
T extends Array<infer U|ValidatorFn|ValidatorFn[]|AsyncValidatorFn|AsyncValidatorFn[]> ? FormControl<U|N> :
// Fallthough case: T is not a container type; use it directly as a value.
FormControl<T|N>;
// clang-format on
@@ -75,6 +77,56 @@ export type ɵElement<T> =
*/
@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<string>
* 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<string>}>
* 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<string|null>}>
* 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<T extends {}>(
controls: T,
options?: AbstractControlOptions|null,
): FormGroup<{[K in keyof T]: ɵElement<T[K]>}>;
): FormGroup<{[K in keyof T]: ɵElement<T[K], null>}>;
/**
* @description
@@ -189,7 +241,19 @@ export class FormBuilder {
formState: T|FormControlState<T>,
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<T>(formState, {...newOptions, initialValueIsDefault: true});
}
/**
@@ -208,7 +272,7 @@ export class FormBuilder {
*/
array<T>(
controls: Array<T>, validatorOrOpts?: ValidatorFn|ValidatorFn[]|AbstractControlOptions|null,
asyncValidator?: AsyncValidatorFn|AsyncValidatorFn[]|null): FormArray<ɵElement<T>> {
asyncValidator?: AsyncValidatorFn|AsyncValidatorFn[]|null): FormArray<ɵElement<T, null>> {
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<T extends {}>(
controls: T,
options?: AbstractControlOptions|null,
): FormGroup<{[K in keyof T]: ɵElement<T[K], never>}>;
/**
* 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<T>(
controls: Array<T>, validatorOrOpts?: ValidatorFn|ValidatorFn[]|AbstractControlOptions|null,
asyncValidator?: AsyncValidatorFn|AsyncValidatorFn[]|null): FormArray<ɵElement<T, never>>;
/**
* 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<T>(
formState: T|FormControlState<T>,
validatorOrOpts?: ValidatorFn|ValidatorFn[]|AbstractControlOptions|null,
asyncValidator?: AsyncValidatorFn|AsyncValidatorFn[]|null): FormControl<T>;
}
/**
* 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[],
+1 -1
View File
@@ -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';
+186 -1
View File
@@ -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<string>};
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<string>};
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<string>};
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<string>};
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<string>};
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<string>}> =
fb.group({foo: ['bar', [Validators.required, Validators.email]]});
{
type ControlsType = {foo: FormControl<string>};
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<string>}>};
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<FormControl<string>>};
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<FormControl<string>>;
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<FormControl<string>>;
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<FormControl<string>>;
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<FormArray<FormControl<string>>>;
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<FormGroup<{bar: FormControl<string>}>>;
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', () => {