В этом уроке вы узнаете, как создавать и использовать группы форм. И мы также узнаем, как добиться полной многоразовости.


Введение

В веб-приложениях у нас могут быть большие формы, такие как форма регистрации учетной записи, форма создания профиля, форма кредитной карты, форма адреса и т. д. Эти большие формы обычно имеют набор полей, которые в них повторяются.

Например, поля адреса обычно доступны во всех регистрационных формах. В этом уроке мы узнаем, как обернуть такие поля в повторно используемую форму, чтобы ее можно было использовать как вложенную форму любой другой формы.

В этом уроке мы обсудим 4 подхода к созданию формы, а также рассмотрим плюсы и минусы каждого из них:

  1. Как создать и использовать группу форм
  2. Как создавать вложенные группы форм
  3. Как использовать дочерний компонент с вложенной группой форм
  4. Как создать многоразовую форму с Composite ControlValueAccessor


Как создать и использовать FormGroup

Формы обычно содержат несколько связанных элементов управления. Реактивные формы предоставить два способа группировки нескольких связанных элементов управления в единую форму ввода: FormGroup а также FormArray. В этом уроке мы сосредоточимся на FormGroup.

Точно так же, как экземпляр элемента управления формой дает вам контроль над одним полем ввода, экземпляр группы форм отслеживает состояние формы группы экземпляров элемента управления формы (например, формы). Каждый элемент управления в экземпляре группы форм отслеживается по имени при создании группы форм.

Мы возьмем пример формы профиля, которая будет иметь поля ввода для имени, электронной почты, адресных строк, города, штата, почтового индекса и страны.

анкета

Группа форм для того же выглядит следующим образом:

// src/app/simple-form-group/simple-form-group.component.ts

@Component({
  selector: "app-simple-form-group",
  templateUrl: "simple-form-group.component.html",
})
export class SimpleFormGroupComponent {
  profileFormGroup = new FormGroup<SimpleProfileForm>({
    name: new FormControl("", {
      validators: [Validators.required],
      nonNullable: true,
    }),
    email: new FormControl("", {
      validators: [Validators.email, Validators.required],
      nonNullable: true,
    }),
    line1: new FormControl("", {
      validators: [Validators.required],
      nonNullable: true,
    }),
    // other address form controls
  });

  readonly COUNTRIES = COUNTRIES;
  constructor() {}

  get nameFC() {
    return this.profileFormGroup.get("name");
  }
  get emailFC() {
    return this.profileFormGroup.get("email");
  }
  get line1FC() {
    return this.profileFormGroup.get("line1");
  }
  // other form-control getters
}

export type SimpleProfileForm = {
  name: FormControl<string>;
  email: FormControl<string>;
  line1: FormControl<string>;
  line2?: FormControl<string | null>;
  zipCode: FormControl<string>;
  city: FormControl<string>;
  state: FormControl<string>;
  country: FormControl<string>;
};
Войти в полноэкранный режим

Выйти из полноэкранного режима

Обратите внимание, что мы также используем функцию строго типизированных форм, который представлен в Angular 14.

В приведенном выше коде мы просто создаем FormGroup со всеми полями как FormControl. Код шаблона HTML может выглядеть следующим образом:

<!-- src/app/simple-form-group/simple-form-group.component.html -->

<form [formGroup]="profileFormGroup">
  <h2>Basic Information</h2>
  <div>
    <label for="name">Name*</label>
    <input
      type="name"
      formControlName="name"
      id="name"
      required
      [ngClass]="{
        'is-invalid': nameFC?.invalid && (nameFC?.touched || nameFC?.dirty)
      }"
    />
    <div class="invalid-feedback">Name is required</div>
  </div>
  <!-- other basic information fields -->
  <div>
    <h2>Address</h2>
    <div>
      <label for="line1">Line 1*</label>
      <input
        [id]="'line1'"
        [name]="'line1'"
        autocomplete="address-line1"
        formControlName="line1"
        required
        type="text"
        [ngClass]="{
          'is-invalid': line1FC?.invalid && (line1FC?.touched || line1FC?.dirty)
        }"
      />
      <div class="invalid-feedback">Address line 1 is required</div>
    </div>
    <!-- other address fields -->
  </div>
  <button type="submit" [disabled]="profileFormGroup.invalid">
    Submit form
  </button>
</form>
Войти в полноэкранный режим

Выйти из полноэкранного режима

formControlName вклад, предоставленный FormControlName директива связывает каждый отдельный ввод с элементом управления формы, определенным в FormGroup. Элементы управления формой взаимодействуют со своими соответствующими элементами. Они также сообщают об изменениях экземпляру группы форм, который является источником достоверного значения модели.

Плюсы

  1. Простой и понятный шаблон и группа форм

Минусы

  1. Сложно создавать и поддерживать модели сложной формы.
  2. Отсутствует логическое разделение между набором полей


Как создавать вложенные группы форм

При создании сложных форм управлять различными областями информации проще в небольших разделах. Использование экземпляра вложенной группы форм позволяет разбивать большие группы форм на более мелкие и более управляемые.

Сгруппируем поля, связанные с адресом, во вложенную группу:

// src/app/nested-from-group/nested-from-group.component.ts

@Component({
  selector: "app-nested-form-group",
  templateUrl: "nested-form-group.component.html",
})
export class NestedFormGroupComponent {
  profileFormGroup = new FormGroup<ProfileForm>({
    name: new FormControl("", {
      validators: [Validators.required],
      nonNullable: true,
    }),
    email: new FormControl("", {
      validators: [Validators.email, Validators.required],
      nonNullable: true,
    }),
    address: new FormGroup<AddressForm>({
      line1: new FormControl("", {
        validators: [Validators.required],
        nonNullable: true,
      }),
      // other address fields...
    }),
  });
}

type ProfileForm = {
  name: FormControl<string>;
  email: FormControl<string>;
  address: FormGroup<AddressForm>;
};

type AddressForm = {
  line1: FormControl<string>;
  line2?: FormControl<string | null>;
  zipCode: FormControl<string>;
  city: FormControl<string>;
  state: FormControl<string>;
  country: FormControl<string>;
};
Войти в полноэкранный режим

Выйти из полноэкранного режима

Обратите внимание, что сейчас в profileForm поля, связанные с адресом, сгруппированы в address элемент. Мы также создали AddressForm type, чтобы повысить безопасность типов и улучшить автозаполнение в IDE. Сгруппируем вложенную форму в шаблон:

<!-- src/app/nested-from-group/nested-form-group.component.html -->

<form [formGroup]="profileFormGroup">
  <h2>Basic Information</h2>
  <!-- basic information fields -->
  <!-- notice the usage of formGroupName below -->
  <div formGroupName="address">
    <h2>Address</h2>
    <div>
      <label for="line1">Line 1*</label>
      <input
        [id]="'line1'"
        [name]="'line1'"
        autocomplete="address-line1"
        formControlName="line1"
        required
        type="text"
        [ngClass]="{
          'is-invalid': line1FC?.invalid && (line1FC?.touched || line1FC?.dirty)
        }"
      />
      <div class="invalid-feedback">Address line 1 is required</div>
    </div>
    <!-- other address related fields -->
  </div>
  <button type="submit" [disabled]="profileFormGroup.invalid">
    Submit form
  </button>
</form>
Войти в полноэкранный режим

Выйти из полноэкранного режима

Мы добавили formGroupName директива, указывающая, что элементы управления дочерней формой являются частью address форма-группа.

Плюсы

  1. Легко создавать и поддерживать модели сложной формы
  2. Логическое разделение присутствует между набором полей

Минусы

  1. Шаблон большего размера из-за большего количества полей и сложной формы


Как использовать дочерний компонент с вложенной группой форм

В предыдущем разделе мы добились логического разделения в классе, но наш шаблон по-прежнему сложен. Мы создадим еще один компонент для обработки сложности шаблона.

// src/app/nested-form-group-child/address-form/address-form.component.ts

@Component({
  selector: "app-address-form",
  templateUrl: "address-form.component.html",
})
export class AddressFormComponent {
  @Input("formGroup") addressFormGroup!: FormGroup;

  get line1FC() {
    return this.addressFormGroup.get("line1");
  }
  // other form-control getters...
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Мы создали AddressFormComponent. Он имеет входное свойство, называемое formGroup, через которые родительские компоненты могут передавать форму-группу. Давайте посмотрим на шаблон:

<!-- src/app/nested-form-group-child/address-form/address-form.component.html -->

<div [formGroup]="addressFormGroup">
  <h2>Address</h2>
  <div>
    <label for="line1">Line 1*</label>
    <input
      [id]="'line1'"
      [name]="'line1'"
      autocomplete="address-line1"
      formControlName="line1"
      required
      type="text"
      [ngClass]="{
        'is-invalid': line1FC?.invalid && (line1FC?.touched || line1FC?.dirty)
      }"
    />
    <div class="invalid-feedback">Address line 1 is required</div>
  </div>
  <!-- other address fields -->
</div>
Войти в полноэкранный режим

Выйти из полноэкранного режима

Шаблон довольно прост, в нем есть только поля, связанные с адресом. И ниже показано, как мы будем использовать его в родительском компоненте:

<!-- src/app/nested-form-group-child/nested-form-group-child.component.html -->

<form [formGroup]="profileFormGroup">
  <!-- basic fields →

  <!-- notice the usage of child component -->
  <app-address-form [formGroup]="addressFormGroup"></app-address-form>

  <button type="submit" [disabled]="profileFormGroup.invalid">
    Submit form
  </button>
</form>
Войти в полноэкранный режим

Выйти из полноэкранного режима

И в классе мы получим группу форм адреса, как показано ниже:

// src/app/nested-from-group/nested-from-group.component.ts

@Component({
  // ...
})
export class NestedFormGroupChildComponent {
  profileFormGroup = new FormGroup<ProfileForm>({ /*...*/ });

  get addressFormGroup() {
    return this.profileFormGroup.get("address") as FormGroup;
  }
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Плюсы

  1. Простые шаблоны а. С таким подходом теперь мы можем обрабатывать все, что связано с адресом в AddressFormComponent так что наш основной компонент намного проще в обращении.

Минусы

  1. Не полностью повторно используемая a. Если вы хотите использовать этот компонент как отдельный компонент формы, это пока невозможно.


Как создать многоразовую форму с Composite ControlValueAccessor

В предыдущем разделе мы создали компонент и переместили в него шаблон адресной формы. Но, это все еще не полностью re-suable. Под полностью пригодным для повторного использования я подразумеваю:

  1. Логика, обработка ошибок и создание адресной формы должны быть частью компонента адреса, а не родительского компонента.
  2. Компонент должен использоваться внутри любой формы.
  3. Компонент должен использоваться как с управляемыми шаблонами, так и с реактивными формами.
  4. Компонент должен поддерживать встроенные функции управления формой, чтобы родительский или потребительский компонент мог воспользоваться этим, например, показывать ошибки, если общая адресная форма недействительна.

В этом разделе мы узнаем, как создать полностью повторно используемую форму, которую можно использовать как внутри других форм, так и как автономную форму. И метод, который мы собираемся использовать, называется «Composite ControlValueAccessor». Кара Эриксон в AngularConnect 2017.

Наша цель — использовать адресную форму, как показано ниже:

<!-- src/app/reusable-form/reusable-form-wrapper/reusable-form-wrapper.component.html -->

<form [formGroup]="profileFormGroup">
  <!-- other basic information fields -->
  <app-reusable-form formControlName="address"></app-reusable-form>
</form>
Войти в полноэкранный режим

Выйти из полноэкранного режима


Аксессор Контролвалуе

А ControlValueAccessor действует как мост между API форм Angular и собственным элементом в DOM. Любой компонент или директиву можно превратить в элемент управления формой, внедрив ControlValueAccessor интерфейс и зарегистрировать себя как NG_VALUE_ACCESSOR провайдер.

Среди прочего интерфейс определяет два важных метода — writeValue а также registerOnChange.

Метод writeValue используется formControl для установки значения в собственный элемент управления формы. Метод registerOnChange используется formControl для регистрации обратного вызова, который, как ожидается, будет запускаться при каждом обновлении собственного элемента управления формы. Вы несете ответственность за передачу обновленного значения этому обратному вызову, чтобы обновить значение соответствующего элемента управления формы Angular. Метод registerOnTouched используется для указания того, что пользователь взаимодействовал с элементом управления.

Вот диаграмма, демонстрирующая взаимодействие:

Как работает ControlValueAccessor

Из Никогда больше не путайтесь при реализации ControlValueAccessor в формах Angular — Angular inDepth

Чтобы узнать больше о ControlValueAccessorвы можете прочитать это подробное руководство. Если вы ищете практический пример, вы можете обратиться к моему предыдущему статья о вводе даты а также туториал по управлению объектом в form-control.


Составные ControlValueAccessors

В более ранних статьях и руководствах мы видели использование ControlValueAccessor с одним &lt;input>. Но преимущество использования ControlValueAccessor вы можете иметь больше входных данных с ним, если хотите.

Нам просто нужно обработать все 4 необходимых метода этого интерфейса, и он просто будет работать, независимо от того, как мы обработаем его внутри.


Многоразовая форма

Одна вещь из предыдущего раздела ясна, мы хотим использовать тот же шаблон, и внутри мы будем управлять состояниями полей с помощью группы форм, поэтому наш шаблон будет выглядеть следующим образом:

<!-- src/app/reusable-form/reusable-form.component.html -->

<div role="group" [formGroup]="form">
  <div>
    <label for="line1">Line 1*</label>
    <input
      id="line1"
      name="line1"
      autocomplete="address-line1"
      formControlName="line1"
      required
      type="text"
      [ngClass]="{
        'is-invalid': line1FC?.invalid && (line1FC?.touched || line1FC?.dirty)
      }"
    />
    <div class="invalid-feedback">Address line 1 is required</div>
  </div>
  <!-- other address fields -->
</div>
Войти в полноэкранный режим

Выйти из полноэкранного режима

Для управления адресным объектом мы создадим класс. Это будет использоваться на более позднем этапе в классе вышеуказанного шаблона:

// src/app/reusable-form/address.ts

export class Address {
  constructor(
    public line1: string = "",
    public zipCode: string = "",
    public city: string = "",
    public state: string = "",
    public country: string = "",
    public line2?: string | null
  ) {}

  isValid() {
    return (
      this.line1 && this.city && this.country && this.state && this.zipCode
    );
  }
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

У нас будет вышеперечисленное Address class как значение для формы. Как string, number а также boolean, мы также можем хранить объект в form-control. Вы можете прочитать больше об этом в учебнике об управлении объектом в form-control.

А для проверки у вас может быть собственная логика для проверки адреса, но пока мы не будем усложнять ее. Мы будем использовать это при реализации проверки.

Далее, на основе предыдущих статей о ControlValueAccessorмы начнем с реализации ControlValueAccessor интерфейс и NG_VALUE_ACCESSOR как провайдер:

// src/app/reusable-form/reusable-form.component.ts

@Component({
  selector: "app-reusable-form",
  templateUrl: "reusable-form.component.html",
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: ReusableFormComponent,
      multi: true,
    },
  ],
})
export class ReusableFormComponent implements ControlValueAccessor {
  form = new FormGroup<AddressForm>({
    line1: new FormControl("", {
      validators: Validators.required,
      nonNullable: true,
    }),
    // other address fields' form-controls
  });

  writeValue(value: Address | null): void {}

  registerOnChange(fn: (val: Partial<Address> | null) => void): void {}

  registerOnTouched(fn: () => void): void {}

  setDisabledState(disabled: boolean) {}
}
Войти в полноэкранный режим

Выйти из полноэкранного режима


writeValue

// src/app/reusable-form/reusable-form.component.ts

  writeValue(value: Address | null): void {
    const address = this.createAddress(value);
    this.form.patchValue(address);
  }

  private createAddress(value: Partial<Address> | null) {
    return new Address(
      value?.line1 || "",
      value?.zipCode || "",
      value?.city || "",
      value?.state || "",
      value?.country || "",
      value?.line2
    );
  }
Войти в полноэкранный режим

Выйти из полноэкранного режима

В этом мы сначала преобразуем значение, поступающее в экземпляр Address а потом позвонит form.patchValue и он обновит все поля, значения которых обновлены.


регистрация при изменении

// src/app/reusable-form/reusable-form.component.ts

registerOnChange(fn: (val: Partial<Address> | null) => void): void {
    this.form.valueChanges.subscribe((value) => {
      const address = this.createAddress(value);
      fn(address);
    });
  }
Войти в полноэкранный режим

Выйти из полноэкранного режима

мы используем valueChanges наблюдаемый для обработки registerOnChange. valueChanges observable испускает для всех дочерних полей формы, поэтому он идеально подходит для registerOnChange.

Теперь, пока мы слушаем valueChangesнам придется пересмотреть и исправить writeValueх patchValueпотому что каждый раз patchValue называется, valueChanges сработает, мы этого не хотим.

// src/app/reusable-form/reusable-form.component.ts

  writeValue(value: Address | null): void {
    const address = this.createAddress(value);
    this.form.patchValue(address, { emitEvent: false });
  }
Войти в полноэкранный режим

Выйти из полноэкранного режима

Как вы заметили выше, мы устанавливаем emitEvent к falseчтобы он не срабатывал valueChanges.


РегистрацияOnTouched

// src/app/reusable-form/reusable-form.component.ts

onTouched: any;

registerOnTouched(fn: () => void): void {
    this.onTouched = fn;
  }
Войти в полноэкранный режим

Выйти из полноэкранного режима

Мы хотим, чтобы к форме прикасались каждый раз, когда прикасаются к любому дочернему элементу управления формой. Итак, мы позвоним onTouched в шаблоне также со всеми дочерними элементами управления формой:

<!-- src/app/reusable-form/reusable-form.component.html -->

<div role="group" [formGroup]="form">
  <div>
    <label for="line1">Line 1*</label>
    <!-- removed other attributes/properties for brevity -->
    <input
      (blur)="onTouched()"
    />
  </div>
  <!-- other address fields -->
</div>
Войти в полноэкранный режим

Выйти из полноэкранного режима


сетдисабледстате

// src/app/reusable-form/reusable-form.component.ts

setDisabledState(disabled: boolean) {
    disabled ? this.form.disable() : this.form.enable();
  }
Войти в полноэкранный режим

Выйти из полноэкранного режима


Проверка

Для обработки проверки мы сначала добавим провайдера и реализуем Validator интерфейс:

// src/app/reusable-form/reusable-form.component.ts

@Component({
  // ...
  providers: [
    // ...
    {
      provide: NG_VALIDATORS,
      useExisting: ReusableFormComponent,
      multi: true,
    },
  ],
})
export class ReusableFormComponent implements ControlValueAccessor, Validator {

  // ...
  validate(control: AbstractControl<Address>): ValidationErrors | null {}
}
Войти в полноэкранный режим

Выйти из полноэкранного режима


подтверждать

// src/app/reusable-form/reusable-form.component.ts

validate(control: AbstractControl<Address>): ValidationErrors | null {
    const value = control.value;
    return value && value.isValid() ? null : { address: true };
  }
Войти в полноэкранный режим

Выйти из полноэкранного режима

Здесь мы собираемся использовать Address класс isValid метод и на основании этого мы вернем ошибку.


Несколько экземпляров

Хотя наша форма уже готова к использованию, с ней все еще есть одна проблема. Если этот компонент используется в одном и том же пользовательском интерфейсе, элементы управления формы могут работать неправильно, поскольку все они будут иметь одинаковые name а также id атрибуты. И сам этот компонент не имеет idчто может быть полезно для тестирования.

Чтобы исправить это, сначала давайте представим host собственность под названием id:

// src/app/reusable-form/reusable-form.component.ts

@Component({
  // ...
  host: {
    "[id]": "id",
  },
})
export class ReusableFormComponent {
  id = `address-input`;
}
Войти в полноэкранный режим

Выйти из полноэкранного режима

Далее мы добавим статический счетчик, который будем увеличивать с каждым экземпляром этого компонента:

// src/app/reusable-form/reusable-form.component.ts

@Component({
  // ...
})
export class ReusableFormComponent {

  static nextId = 0;
  id = `address-input-${ReusableFormComponent.nextId++}`;

}
Войти в полноэкранный режим

Выйти из полноэкранного режима

И, наконец, мы будем использовать это id со всеми inputс’ id а также name атрибуты. А также в их &lt;label>с’ for атрибут:

<!-- src/app/reusable-form/reusable-form.component.html -->

<div role="group" [formGroup]="form">
  <div>
    <label [htmlFor]="id + '-line1'">Line 1*</label>
    <!-- other attributes removed for brevity -->
    <input
      [id]="id + '-line1'"
      [name]="id + '-line1'"
    />
  </div>
  <!-- other address fields -->
</div>
Войти в полноэкранный режим

Выйти из полноэкранного режима

На этом наша полностью повторно используемая адресная форма готова.


Вывод

В этом уроке мы узнали о том, как создавать и использовать группы форм. Затем мы узнали о том, как создавать вложенные группы форм и как использовать их с дочерними компонентами. И с каждым из этих подходов мы также узнали их плюсы и минусы.

В последнем, но очень важном разделе мы узнали о том, как создать полностью повторно используемый компонент формы с составным ControlValueAccessor.

Я загрузил код на Гитхабвы также можете взглянуть на него на стекблиц.