Angular IDPStrong Junior
Модулі / Форми / FormArray
🔥 0

Модуль 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>
track ctrl, а не track $index

Якщо відстежувати за індексом, після видалення середнього елемента 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.