angular/packages/forms/src/model/form_array.ts
Dylan Hunn 038ba8adea revert "fix(forms): Value and RawValue should be part of the public API." (#46023)
As per discussion on #fw-forms, this reverts #45978 (although the more in-depth comments were kept).

PR Close #46023
2022-05-17 22:48:03 +00:00

533 lines
20 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {AsyncValidatorFn, ValidatorFn} from '../directives/validators';
import {AbstractControl, AbstractControlOptions, assertAllValuesPresent, assertControlPresent, pickAsyncValidators, pickValidators, ɵRawValue, ɵTypedOrUntyped, ɵValue} from './abstract_model';
/**
* FormArrayValue extracts the type of `.value` from a FormArray's element type, and wraps it in an
* array.
*
* Angular uses this type internally to support Typed Forms; do not use it directly. The untyped
* case falls back to any[].
*/
export type ɵFormArrayValue<T extends AbstractControl<any>> =
ɵTypedOrUntyped<T, Array<ɵValue<T>>, any[]>;
/**
* FormArrayRawValue extracts the type of `.getRawValue()` from a FormArray's element type, and
* wraps it in an array. The untyped case falls back to any[].
*
* Angular uses this type internally to support Typed Forms; do not use it directly.
*/
export type ɵFormArrayRawValue<T extends AbstractControl<any>> =
ɵTypedOrUntyped<T, Array<ɵRawValue<T>>, any[]>;
/**
* Tracks the value and validity state of an array of `FormControl`,
* `FormGroup` or `FormArray` instances.
*
* A `FormArray` aggregates the values of each child `FormControl` into an array.
* It calculates its status by reducing the status values of its children. For example, if one of
* the controls in a `FormArray` is invalid, the entire array becomes invalid.
*
* `FormArray` accepts one generic argument, which is the type of the controls inside.
* If you need a heterogenous array, use {@see UntypedFormArray}.
*
* `FormArray` is one of the four fundamental building blocks used to define forms in Angular,
* along with `FormControl`, `FormGroup`, and `FormRecord`.
*
* @usageNotes
*
* ### Create an array of form controls
*
* ```
* const arr = new FormArray([
* new FormControl('Nancy', Validators.minLength(2)),
* new FormControl('Drew'),
* ]);
*
* console.log(arr.value); // ['Nancy', 'Drew']
* console.log(arr.status); // 'VALID'
* ```
*
* ### Create a form array with array-level validators
*
* You include array-level validators and async validators. These come in handy
* when you want to perform validation that considers the value of more than one child
* control.
*
* The two types of validators are passed in separately as the second and third arg
* respectively, or together as part of an options object.
*
* ```
* const arr = new FormArray([
* new FormControl('Nancy'),
* new FormControl('Drew')
* ], {validators: myValidator, asyncValidators: myAsyncValidator});
* ```
*
* ### Set the updateOn property for all controls in a form array
*
* The options object is used to set a default value for each child
* control's `updateOn` property. If you set `updateOn` to `'blur'` at the
* array level, all child controls default to 'blur', unless the child
* has explicitly specified a different `updateOn` value.
*
* ```ts
* const arr = new FormArray([
* new FormControl()
* ], {updateOn: 'blur'});
* ```
*
* ### Adding or removing controls from a form array
*
* To change the controls in the array, use the `push`, `insert`, `removeAt` or `clear` methods
* in `FormArray` itself. These methods ensure the controls are properly tracked in the
* form's hierarchy. Do not modify the array of `AbstractControl`s used to instantiate
* the `FormArray` directly, as that result in strange and unexpected behavior such
* as broken change detection.
*
* @publicApi
*/
export class FormArray<TControl extends AbstractControl<any> = any> extends AbstractControl<
ɵTypedOrUntyped<TControl, ɵFormArrayValue<TControl>, any>,
ɵTypedOrUntyped<TControl, ɵFormArrayRawValue<TControl>, any>> {
/**
* Creates a new `FormArray` instance.
*
* @param controls An array of child controls. Each child control is given an index
* where it is registered.
*
* @param validatorOrOpts A synchronous validator function, or an array of
* such functions, or an `AbstractControlOptions` object that contains validation functions
* and a validation trigger.
*
* @param asyncValidator A single async validator or array of async validator functions
*
*/
constructor(
controls: Array<TControl>,
validatorOrOpts?: ValidatorFn|ValidatorFn[]|AbstractControlOptions|null,
asyncValidator?: AsyncValidatorFn|AsyncValidatorFn[]|null) {
super(pickValidators(validatorOrOpts), pickAsyncValidators(asyncValidator, validatorOrOpts));
this.controls = controls;
this._initObservables();
this._setUpdateStrategy(validatorOrOpts);
this._setUpControls();
this.updateValueAndValidity({
onlySelf: true,
// If `asyncValidator` is present, it will trigger control status change from `PENDING` to
// `VALID` or `INVALID`.
// The status should be broadcasted via the `statusChanges` observable, so we set `emitEvent`
// to `true` to allow that during the control creation process.
emitEvent: !!this.asyncValidator
});
}
public controls: ɵTypedOrUntyped<TControl, Array<TControl>, Array<AbstractControl<any>>>;
/**
* Get the `AbstractControl` at the given `index` in the array.
*
* @param index Index in the array to retrieve the control. If `index` is negative, it will wrap
* around from the back, and if index is greatly negative (less than `-length`), the result is
* undefined. This behavior is the same as `Array.at(index)`.
*/
at(index: number): ɵTypedOrUntyped<TControl, TControl, AbstractControl<any>> {
return (this.controls as any)[this._adjustIndex(index)];
}
/**
* Insert a new `AbstractControl` at the end of the array.
*
* @param control Form control to be inserted
* @param options Specifies whether this FormArray instance should emit events after a new
* control is added.
* * `emitEvent`: When true or not supplied (the default), both the `statusChanges` and
* `valueChanges` observables emit events with the latest status and value when the control is
* inserted. When false, no events are emitted.
*/
push(control: TControl, options: {emitEvent?: boolean} = {}): void {
this.controls.push(control);
this._registerControl(control);
this.updateValueAndValidity({emitEvent: options.emitEvent});
this._onCollectionChange();
}
/**
* Insert a new `AbstractControl` at the given `index` in the array.
*
* @param index Index in the array to insert the control. If `index` is negative, wraps around
* from the back. If `index` is greatly negative (less than `-length`), prepends to the array.
* This behavior is the same as `Array.splice(index, 0, control)`.
* @param control Form control to be inserted
* @param options Specifies whether this FormArray instance should emit events after a new
* control is inserted.
* * `emitEvent`: When true or not supplied (the default), both the `statusChanges` and
* `valueChanges` observables emit events with the latest status and value when the control is
* inserted. When false, no events are emitted.
*/
insert(index: number, control: TControl, options: {emitEvent?: boolean} = {}): void {
this.controls.splice(index, 0, control);
this._registerControl(control);
this.updateValueAndValidity({emitEvent: options.emitEvent});
}
/**
* Remove the control at the given `index` in the array.
*
* @param index Index in the array to remove the control. If `index` is negative, wraps around
* from the back. If `index` is greatly negative (less than `-length`), removes the first
* element. This behavior is the same as `Array.splice(index, 1)`.
* @param options Specifies whether this FormArray instance should emit events after a
* control is removed.
* * `emitEvent`: When true or not supplied (the default), both the `statusChanges` and
* `valueChanges` observables emit events with the latest status and value when the control is
* removed. When false, no events are emitted.
*/
removeAt(index: number, options: {emitEvent?: boolean} = {}): void {
// Adjust the index, then clamp it at no less than 0 to prevent undesired underflows.
let adjustedIndex = this._adjustIndex(index);
if (adjustedIndex < 0) adjustedIndex = 0;
if (this.controls[adjustedIndex])
this.controls[adjustedIndex]._registerOnCollectionChange(() => {});
this.controls.splice(adjustedIndex, 1);
this.updateValueAndValidity({emitEvent: options.emitEvent});
}
/**
* Replace an existing control.
*
* @param index Index in the array to replace the control. If `index` is negative, wraps around
* from the back. If `index` is greatly negative (less than `-length`), replaces the first
* element. This behavior is the same as `Array.splice(index, 1, control)`.
* @param control The `AbstractControl` control to replace the existing control
* @param options Specifies whether this FormArray instance should emit events after an
* existing control is replaced with a new one.
* * `emitEvent`: When true or not supplied (the default), both the `statusChanges` and
* `valueChanges` observables emit events with the latest status and value when the control is
* replaced with a new one. When false, no events are emitted.
*/
setControl(index: number, control: TControl, options: {emitEvent?: boolean} = {}): void {
// Adjust the index, then clamp it at no less than 0 to prevent undesired underflows.
let adjustedIndex = this._adjustIndex(index);
if (adjustedIndex < 0) adjustedIndex = 0;
if (this.controls[adjustedIndex])
this.controls[adjustedIndex]._registerOnCollectionChange(() => {});
this.controls.splice(adjustedIndex, 1);
if (control) {
this.controls.splice(adjustedIndex, 0, control);
this._registerControl(control);
}
this.updateValueAndValidity({emitEvent: options.emitEvent});
this._onCollectionChange();
}
/**
* Length of the control array.
*/
get length(): number {
return this.controls.length;
}
/**
* Sets the value of the `FormArray`. It accepts an array that matches
* the structure of the control.
*
* This method performs strict checks, and throws an error if you try
* to set the value of a control that doesn't exist or if you exclude the
* value of a control.
*
* @usageNotes
* ### Set the values for the controls in the form array
*
* ```
* const arr = new FormArray([
* new FormControl(),
* new FormControl()
* ]);
* console.log(arr.value); // [null, null]
*
* arr.setValue(['Nancy', 'Drew']);
* console.log(arr.value); // ['Nancy', 'Drew']
* ```
*
* @param value Array of values for the controls
* @param options Configure options that determine how the control propagates changes and
* emits events after the value changes
*
* * `onlySelf`: When true, each change only affects this control, and not its parent. Default
* is false.
* * `emitEvent`: When true or not supplied (the default), both the `statusChanges` and
* `valueChanges`
* observables emit events with the latest status and value when the control value is updated.
* When false, no events are emitted.
* The configuration options are passed to the {@link AbstractControl#updateValueAndValidity
* updateValueAndValidity} method.
*/
override setValue(value: ɵFormArrayRawValue<TControl>, options: {
onlySelf?: boolean,
emitEvent?: boolean
} = {}): void {
assertAllValuesPresent(this, false, value);
value.forEach((newValue: any, index: number) => {
assertControlPresent(this, false, index);
this.at(index).setValue(newValue, {onlySelf: true, emitEvent: options.emitEvent});
});
this.updateValueAndValidity(options);
}
/**
* Patches the value of the `FormArray`. It accepts an array that matches the
* structure of the control, and does its best to match the values to the correct
* controls in the group.
*
* It accepts both super-sets and sub-sets of the array without throwing an error.
*
* @usageNotes
* ### Patch the values for controls in a form array
*
* ```
* const arr = new FormArray([
* new FormControl(),
* new FormControl()
* ]);
* console.log(arr.value); // [null, null]
*
* arr.patchValue(['Nancy']);
* console.log(arr.value); // ['Nancy', null]
* ```
*
* @param value Array of latest values for the controls
* @param options Configure options that determine how the control propagates changes and
* emits events after the value changes
*
* * `onlySelf`: When true, each change only affects this control, and not its parent. Default
* is false.
* * `emitEvent`: When true or not supplied (the default), both the `statusChanges` and
* `valueChanges` observables emit events with the latest status and value when the control
* value is updated. When false, no events are emitted. The configuration options are passed to
* the {@link AbstractControl#updateValueAndValidity updateValueAndValidity} method.
*/
override patchValue(value: ɵFormArrayValue<TControl>, options: {
onlySelf?: boolean,
emitEvent?: boolean
} = {}): void {
// Even though the `value` argument type doesn't allow `null` and `undefined` values, the
// `patchValue` can be called recursively and inner data structures might have these values,
// so we just ignore such cases when a field containing FormArray instance receives `null` or
// `undefined` as a value.
if (value == null /* both `null` and `undefined` */) return;
value.forEach((newValue, index) => {
if (this.at(index)) {
this.at(index).patchValue(newValue, {onlySelf: true, emitEvent: options.emitEvent});
}
});
this.updateValueAndValidity(options);
}
/**
* Resets the `FormArray` and all descendants are marked `pristine` and `untouched`, and the
* value of all descendants to null or null maps.
*
* You reset to a specific form state by passing in an array of states
* that matches the structure of the control. The state is a standalone value
* or a form state object with both a value and a disabled status.
*
* @usageNotes
* ### Reset the values in a form array
*
* ```ts
* const arr = new FormArray([
* new FormControl(),
* new FormControl()
* ]);
* arr.reset(['name', 'last name']);
*
* console.log(arr.value); // ['name', 'last name']
* ```
*
* ### Reset the values in a form array and the disabled status for the first control
*
* ```
* arr.reset([
* {value: 'name', disabled: true},
* 'last'
* ]);
*
* console.log(arr.value); // ['last']
* console.log(arr.at(0).status); // 'DISABLED'
* ```
*
* @param value Array of values for the controls
* @param options Configure options that determine how the control propagates changes and
* emits events after the value changes
*
* * `onlySelf`: When true, each change only affects this control, and not its parent. Default
* is false.
* * `emitEvent`: When true or not supplied (the default), both the `statusChanges` and
* `valueChanges`
* observables emit events with the latest status and value when the control is reset.
* When false, no events are emitted.
* The configuration options are passed to the {@link AbstractControl#updateValueAndValidity
* updateValueAndValidity} method.
*/
override reset(value: ɵTypedOrUntyped<TControl, ɵFormArrayValue<TControl>, any> = [], options: {
onlySelf?: boolean,
emitEvent?: boolean
} = {}): void {
this._forEachChild((control: AbstractControl, index: number) => {
control.reset(value[index], {onlySelf: true, emitEvent: options.emitEvent});
});
this._updatePristine(options);
this._updateTouched(options);
this.updateValueAndValidity(options);
}
/**
* The aggregate value of the array, including any disabled controls.
*
* Reports all values regardless of disabled status.
*/
override getRawValue(): ɵFormArrayRawValue<TControl> {
return this.controls.map((control: AbstractControl) => control.getRawValue());
}
/**
* Remove all controls in the `FormArray`.
*
* @param options Specifies whether this FormArray instance should emit events after all
* controls are removed.
* * `emitEvent`: When true or not supplied (the default), both the `statusChanges` and
* `valueChanges` observables emit events with the latest status and value when all controls
* in this FormArray instance are removed. When false, no events are emitted.
*
* @usageNotes
* ### Remove all elements from a FormArray
*
* ```ts
* const arr = new FormArray([
* new FormControl(),
* new FormControl()
* ]);
* console.log(arr.length); // 2
*
* arr.clear();
* console.log(arr.length); // 0
* ```
*
* It's a simpler and more efficient alternative to removing all elements one by one:
*
* ```ts
* const arr = new FormArray([
* new FormControl(),
* new FormControl()
* ]);
*
* while (arr.length) {
* arr.removeAt(0);
* }
* ```
*/
clear(options: {emitEvent?: boolean} = {}): void {
if (this.controls.length < 1) return;
this._forEachChild((control) => control._registerOnCollectionChange(() => {}));
this.controls.splice(0);
this.updateValueAndValidity({emitEvent: options.emitEvent});
}
/**
* Adjusts a negative index by summing it with the length of the array. For very negative
* indices, the result may remain negative.
* @internal
*/
private _adjustIndex(index: number): number {
return index < 0 ? index + this.length : index;
}
/** @internal */
override _syncPendingControls(): boolean {
let subtreeUpdated = (this.controls as any).reduce((updated: any, child: any) => {
return child._syncPendingControls() ? true : updated;
}, false);
if (subtreeUpdated) this.updateValueAndValidity({onlySelf: true});
return subtreeUpdated;
}
/** @internal */
override _forEachChild(cb: (c: AbstractControl, index: number) => void): void {
this.controls.forEach((control: AbstractControl, index: number) => {
cb(control, index);
});
}
/** @internal */
override _updateValue(): void {
(this as {value: any}).value =
this.controls.filter((control) => control.enabled || this.disabled)
.map((control) => control.value);
}
/** @internal */
override _anyControls(condition: (c: AbstractControl) => boolean): boolean {
return this.controls.some((control) => control.enabled && condition(control));
}
/** @internal */
_setUpControls(): void {
this._forEachChild((control) => this._registerControl(control));
}
/** @internal */
override _allControlsDisabled(): boolean {
for (const control of this.controls) {
if (control.enabled) return false;
}
return this.controls.length > 0 || this.disabled;
}
private _registerControl(control: AbstractControl) {
control.setParent(this);
control._registerOnCollectionChange(this._onCollectionChange);
}
/** @internal */
override _find(name: string|number): AbstractControl|null {
return this.at(name as number) ?? null;
}
}
interface UntypedFormArrayCtor {
new(controls: AbstractControl[],
validatorOrOpts?: ValidatorFn|ValidatorFn[]|AbstractControlOptions|null,
asyncValidator?: AsyncValidatorFn|AsyncValidatorFn[]|null): UntypedFormArray;
/**
* The presence of an explicit `prototype` property provides backwards-compatibility for apps that
* manually inspect the prototype chain.
*/
prototype: FormArray<any>;
}
/**
* UntypedFormArray is a non-strongly-typed version of @see FormArray, which
* permits heterogenous controls.
*/
export type UntypedFormArray = FormArray<any>;
export const UntypedFormArray: UntypedFormArrayCtor = FormArray;
export const isFormArray = (control: unknown): control is FormArray => control instanceof FormArray;