Модуль 6 · урок 7 з 12
FormArray
FormArray — це список контролів, який можна нарощувати й скорочувати під час роботи. Підзадачі, теги, рядки таблиці всередині форми, довільні пари «ключ-значення» — усе це він.
Масив простих контролів
protected readonly form = this.fb.nonNullable.group({
title: ['', Validators.required],
tags: this.fb.array<FormControl<string>>([]),
});
protected get tags(): FormArray<FormControl<string>> {
return this.form.controls.tags;
}
addTag(): void {
this.tags.push(this.fb.nonNullable.control('', Validators.required));
}
removeTag(index: number): void {
this.tags.removeAt(index);
}
<div formArrayName="tags">
@for (ctrl of tags.controls; track ctrl) {
<div class="row">
<input [formControlName]="$index">
<button type="button" (click)="removeTag($index)" aria-label="Видалити тег">×</button>
</div>
}
</div>
<button type="button" (click)="addTag()">Додати тег</button>
Якщо відстежувати за індексом, після видалення середнього елемента Angular вважатиме,
що змінились усі наступні рядки — і перемалює їх усі, з утратою фокуса. Сам обʼєкт контролу
унікальний і стабільний, тож track ctrl тут правильний вибір.
Це та сама історія з track із модуля 2.
Масив груп
type SubtaskForm = FormGroup<{
title: FormControl<string>;
hours: FormControl<number>;
}>;
protected readonly form = this.fb.nonNullable.group({
title: ['', Validators.required],
subtasks: this.fb.array<SubtaskForm>([]),
});
private makeSubtask(value?: Subtask): SubtaskForm {
return this.fb.nonNullable.group({
title: [value?.title ?? '', Validators.required],
hours: [value?.hours ?? 1, [Validators.min(0.5), Validators.max(40)]],
});
}
addSubtask(): void {
this.form.controls.subtasks.push(this.makeSubtask());
}
<div formArrayName="subtasks">
@for (group of form.controls.subtasks.controls; track group; let i = $index) {
<fieldset [formGroupName]="i">
<input formControlName="title" placeholder="Що зробити">
<input formControlName="hours" type="number">
<button type="button" (click)="form.controls.subtasks.removeAt(i)">Видалити</button>
</fieldset>
}
</div>
Заповнення з даних
fillFrom(task: Task): void {
this.form.patchValue({ title: task.title });
const arr = this.form.controls.subtasks;
arr.clear(); // прибрати старі контроли
task.subtasks.forEach(s => arr.push(this.makeSubtask(s)));
}
patchValue сам по собі не створює контролів у масиві: якщо в ньому нуль елементів,
а в даних три — три значення просто загубляться. Тому послідовність завжди така:
очистити, потім додати потрібну кількість контролів, і лише тоді заповнювати.
Валідація масиву цілком
subtasks: this.fb.array<SubtaskForm>([], {
validators: [
Validators.minLength(1), // для масиву це «мінімум елементів»
totalHoursWithin(40),
],
}),
function totalHoursWithin(limit: number): ValidatorFn {
return (arr: AbstractControl): ValidationErrors | null => {
const total = (arr as FormArray).controls
.reduce((sum, g) => sum + Number(g.get('hours')?.value ?? 0), 0);
return total > limit ? { totalHours: { limit, total } } : null;
};
}
Цікава деталь: Validators.minLength на FormArray означає
не довжину рядка, а кількість елементів. Це той рідкісний випадок, коли одна назва
означає різні речі залежно від типу контролу.
Порядок елементів
move(from: number, to: number): void {
const arr = this.form.controls.subtasks;
const ctrl = arr.at(from);
arr.removeAt(from);
arr.insert(to, ctrl); // той самий контрол, тому значення й статус збережуться
}
Саме тому переміщення роблять через removeAt плюс insert,
а не через створення нового контролу: так зберігаються і значення, і стани
touched/dirty.