За последние пару лет я создал несколько приложений CRM и бэк-офиса с использованием angular. Единственное, что вы можете сказать о приложениях такого типа, так это то, что они в значительной степени полагаются на формы. Хотя по этой теме доступно довольно много руководств по angular 2+, было довольно сложно найти хороший реальный пример использования реактивных форм angular с вложенными компонентами, массивами, настраиваемыми входами и т. Д.

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

Подход реактивной формы

Angular предоставляет нам два способа обработки форм:

Мы не будем углубляться в разницу между обоими подходами, но я просто скажу, что реактивная форма angular - мощный инструмент для управления состояниями сложной формы. Он в значительной степени основан на RxJ, что позволяет легко реагировать и наблюдать за изменениями формы.

Начиная

Я не буду рассматривать каждую часть конечного продукта в этом руководстве, но вы можете найти весь исходный код в моем репозитории GitHub: https://github.com/scopsy/angular-forms-example

Вот темы, которые мы собираемся обсудить:

Делегирование бизнес-логики службам с областью действия компонентов

Чтобы управлять логикой формы пиццы, мы будем использовать несколько служб и инициализировать их в компоненте app-pizza-form-container, чтобы гарантировать, что новый экземпляр служб будет инициализироваться каждый раз при создании корневого компонента. Я видел много молодых разработчиков, использующих сервисы только для глобальных сервисов. Перенос нашей бизнес-логики в службу приведет к уменьшению размера файла компонента и упрощению тестирования компонентов.

Вот пример того, как мы определили наши сервисы на уровне компонентов:

@Component({
  selector: 'app-pizza-form-container',
  templateUrl: './pizza-form-container.component.html',
  styleUrls: ['./pizza-form-container.component.scss'],
  providers: [
    PizzaFormService,
    PizzaFormValidatorsService,
    PizzaLoaderService
  ]
})
export class PizzaFormContainerComponent implements OnInit {
    // ... code
}

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

Вот как выглядит наша PizzaFormService:

@Injectable()
export class PizzaFormService {
  public availableToppings = [...Object.values(PizzaToppingsEnum)];
  public form: FormGroup;

  constructor(
    private pizzaValidatorsService: PizzaFormValidatorsService,
    private fb: FormBuilder
  ) {
    this.form = this.fb.group({
      selectedPizza: null,
      pizzas: this.fb.array([]),
      customerDetails: this.fb.group({
        firstName: [null, Validators.required],
        lastName: [null, Validators.required],
        phoneNumber: [null, Validators.required],
        address: this.fb.group({
          street: [null, Validators.required],
          houseNum: [null, Validators.required],
          city: [null, Validators.required],
          floor: [null, Validators.required],
        })
      })
    }, {
      validator: this.pizzaValidatorsService.formValidator()
    });
  }

  get pizzasArray(): FormArray {
    return this.form.get('pizzas') as FormArray;
  }

  get isValid(): boolean {
    if (!this.form.valid) {
      this.pizzaValidatorsService.validateAllFormFields(this.form);
      return false;
    }

    return true;
  }

  selectPizzaForEdit(index: number) {
    this.form.get('selectedPizza').setValue(index);
  }

  addPizza(): FormGroup {
    const pizzaGroup = this.getPizzaFormGroup();
    this.pizzasArray.push(this.getPizzaFormGroup());

    this.form.markAsDirty();

    return pizzaGroup;
  }

  deletePizza(index: number): void {
    this.pizzasArray.removeAt(index);
    this.form.markAsDirty();
  }

  getPizzaFormGroup(size: PizzaSizeEnum = PizzaSizeEnum.MEDIUM): FormGroup {
    return this.fb.group({
      size: [size],
      toppings: this.mapToCheckboxArrayGroup(this.availableToppings)
    }, {
      validator: this.pizzaValidatorsService.pizzaItemValidator()
    });
  }

  createPizzaOrderDto(data: IPizzaFormInterface): IPizzaFormInterface {
    const order = {
      customerDetails: data.customerDetails,
      pizzas: data.pizzas
    };

    for (const pizza of order.pizzas) {
      pizza.toppings = this.getSelectedToppings(pizza.toppings as IToppingItem[])
        .map((i) => {
          return i.name;
        });
    }

    return order;
  }

  getSelectedToppings(toppings: IToppingItem[]): IToppingItem[] {
    return toppings.filter(i => i.selected);
  }

  private mapToCheckboxArrayGroup(data: string[]): FormArray {
    return this.fb.array(data.map((i) => {
      return this.fb.group({
        name: i,
        selected: false
      });
    }));
  }
}

Служба отвечает за управление объектом form, созданным службой FormBuilder, и за обработку взаимодействия с формой, включая добавление и удаление пиццы. Мы более подробно рассмотрим этот класс в будущем.

Проверка на основе нескольких контрольных значений

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

this.form = this.fb.group({
  selectedPizza: null,
  pizzas: this.fb.array([]),
  customerDetails: this.fb.group({
    firstName: [null, Validators.required],
    lastName: [null, Validators.required],
    phoneNumber: [null, Validators.required],
    address: this.fb.group({
      street: [null, Validators.required],
      houseNum: [null, Validators.required],
      city: [null, Validators.required],
      floor: [null, Validators.required],
    })
  })
}, {
  validator: this.pizzaValidatorsService.formValidator()
});

Встроенные валидаторы отлично подходят для проверки отдельных элементов управления и включают в себя следующие валидаторы:

  • Необходимый
  • мин
  • Максимум
  • Эл. адрес
  • шаблон (для соответствия регулярному выражению)

Чтобы объединить несколько валидаторов, мы используем функцию Validators.compose, которая принимает массив встроенных и настраиваемых валидаторов.

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

this.form = this.fb.group({
 // formGroup definition code goes here...
}, {
  validator: this.pizzaValidatorsService.formValidator()
});

Давайте посмотрим на PizzaValidatorsService:

@Injectable()
export class PizzaFormValidatorsService {

  constructor() { }

  formValidator(): ValidatorFn {
    return (control: FormGroup): ValidationErrors | null => {
      const errors: ValidationErrors = {};

      if (!(control.get('pizzas') as FormArray).length) {
        errors.noPizzas = {
          message: 'You must select at least one pizza to order'
        };
      }

      return Object.keys(errors).length ? errors : null;
    };
  }

  pizzaItemValidator(): ValidatorFn {
    return (control: FormGroup): ValidationErrors | null => {
      const errors: ValidationErrors = {};

      const pizzaSize: PizzaSizeEnum = control.get('size').value;
      const pizzaToppings: IToppingItem[] = control.get('toppings').value.filter(i => i.selected);

      if (pizzaSize !== PizzaSizeEnum.LARGE && pizzaToppings.length > 4) {
        errors.toppingPizzaSize = {
          message: 'To use more then 4 toppings you must selected large pizza'
        };
      }

      return Object.keys(errors).length ? errors : null;
    };
  }
  
  validateAllFormFields(formGroup: FormGroup) {
    Object.keys(formGroup.controls).forEach(field => {
      const control = formGroup.get(field);

      if (control instanceof FormControl) {
        control.markAsTouched({ onlySelf: true });
      } else if (control instanceof FormGroup) {
        this.validateAllFormFields(control);
      }
    });
  }
}

Мы определили 2 валидатора, по одному для всей формы и по одному для каждого элемента pizza formGroup.

formValidator(): ValidatorFn {
    return (control: FormGroup): ValidationErrors | null => {
      const errors: ValidationErrors = {};

      if (!(control.get('pizzas') as FormArray).length) {
        errors.noPizzas = {
          message: 'You must select at least one pizza to order'
        };
      }

      return Object.keys(errors).length ? errors : null;
    };
}

Валидатор формы - это просто метод класса, который возвращает функцию ValidatorFn, эта функция получит объект управления, который будет нашим form определением. Затем мы можем выполнить проверку pizzas FormArray и, если он пуст, добавить noPizzas ошибку. Обратите внимание, как свойство noPizzas является объектом, мы определяем его таким образом, чтобы предоставить пользователю осмысленное сообщение об ошибке.

Вот как мы можем отобразить сообщение об ошибке в нашем HTML-файле:

<div *ngIf="form.errors?.noPizzas && form.dirty" class="ValidationErrorLabel mg-bottom-15">
   {{form.errors?.noPizzas.message}}
</div>

Сначала мы проверяем, существует ли ошибка noPizzas в объекте form.errors, если она есть, мы проверяем, является ли форма грязной, поскольку мы не хотим отображать ошибку до того, как пользователь коснется формы (это будет неудобно). Затем мы отображаем текст ошибки noPizzas.message, определенный в валидаторе. Тадда.

Компоненты формы многоразового использования

Часто рекомендуется разделять наше приложение на небольшие и повторно используемые компоненты. Наша область сведений о клиентах отлично подходит для многоразового компонента для всего приложения:

Мы создадим компонент сведений о клиенте, который будет получать только formGroup, связанный с данными о клиенте, это позволит повторно использовать customerDetails с другими формами в нашем приложении, пока компонент сведений о клиенте остается «немым» и изолированным от родительской формы. состав. Вот как выглядит файл компонента:

@Component({
  selector: 'app-customer-details',
  templateUrl: './customer-details.component.html',
  styleUrls: ['./customer-details.component.scss']
})
export class CustomerDetailsComponent implements OnInit {
  @Input() group: FormGroup;
  constructor() { }

  ngOnInit() {
  }
}

Он имеет единственный @Input с именем group типа FormGroup. В HTML-файле компонента мы обернем родительский div атрибутом [formGroup]:

<div class="CustomerDetails" [formGroup]="group">
  <div class="row">
    <div class="col-md-12">
      <h3>
        Customer Details
      </h3>
    </div>
    <div class="col-md-3">
      <mat-form-field class="full-width-input">
        <input matInput placeholder="First Name" formControlName="firstName" />
      </mat-form-field>
    </div>
    <div class="col-md-3">
      <mat-form-field class="full-width-input">
        <input matInput placeholder="Last Name" formControlName="lastName" />
      </mat-form-field>
    </div>
    <div class="col-md-3">
      <mat-form-field class="full-width-input">
        <input matInput placeholder="Phone Number" formControlName="phoneNumber" />
      </mat-form-field>
    </div>
  </div>

  <div class="row" [formGroup]="group.get('address')">
    <div class="col-md-12">
      <h3>
        Delivery Address
      </h3>
    </div>
    <div class="col-md-3">
      <mat-form-field class="full-width-input">
        <input matInput placeholder="Street" formControlName="street" />
      </mat-form-field>
    </div>
    <div class="col-md-3">
      <mat-form-field class="full-width-input">
        <input matInput placeholder="Apt. Number" formControlName="houseNum" />
      </mat-form-field>
    </div>
    <div class="col-md-3">
      <mat-form-field class="full-width-input">
        <input matInput placeholder="City" formControlName="city" />
      </mat-form-field>
    </div>
    <div class="col-md-3">
      <mat-form-field class="full-width-input">
        <input matInput placeholder="Floor" formControlName="floor" />
      </mat-form-field>
    </div>
  </div>
</div>

Теперь внутри нашего app-form-container компонента мы используем новый компонент следующим образом:

<div class="row">
    <div class="col-md-12">
      <app-customer-details [group]="form.get('customerDetails')"></app-customer-details>
    </div>
</div>

Обратите внимание, как мы передаем только customerDetails группу формы нашего объекта формы. Это гарантирует, что наш компонент знает только о контексте customerDetails, а не о своем хосте. Затем мы можем легко повторно использовать app-customer-details с другими формами, имеющими аналогичную formGroup:

customerDetails: this.fb.group({
    firstName: [null, Validators.required],
    lastName: [null, Validators.required],
    phoneNumber: [null, Validators.required],
    address: this.fb.group({
      street: [null, Validators.required],
      houseNum: [null, Validators.required],
      city: [null, Validators.required],
      floor: [null, Validators.required],
    })
})

(Часто рекомендуется извлечь группу форм customerDetails во внешнюю службу, чтобы гарантировать, что структура formGroup будет одинаковой во всех формах и избежать повторения.)

Пользовательский контроль формы

Работать с formControl легко при работе с обычными входами: select, input, textarea и т. Д. Но часто это необходимо для создания настраиваемого компонента, который мы хотим интегрировать с нашей реактивной формой. Посмотрим на компонент PizzaSizeSelector:

Наш дизайнер придумал сложный виджет, и нам нужно его реализовать. Прежде всего, нам нужно создать новый компонент и объявить его внутри нашего основного компонента:

<div class="col-md-6 offset-md-3">
  <app-pizza-size-picker formControlName="size"></app-pizza-size-picker>
</div>

Обратите внимание на атрибут formControlName, при перезагрузке страницы мы получим следующую ошибку:

ERROR Error: No value accessor for form control with name: 'size'

Это все потому, что нам нужно внести несколько изменений в наш новый компонент, давайте посмотрим, что нам нужно сделать:

@Component({
  selector: 'app-pizza-size-picker',
  templateUrl: './pizza-size-picker.component.html',
  styleUrls: ['./pizza-size-picker.component.scss'],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => PizzaSizePickerComponent),
      multi: true
    }
  ]
})

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

export class PizzaSizePickerComponent implements ControlValueAccessor {
  pizzaSize: PizzaSizeEnum;
  PizzaSizeEnum = PizzaSizeEnum;

  constructor() { }

  changeSize(size: PizzaSizeEnum) {
    this.pizzaSize = size;
    this.propagateChange(size);
  }

  writeValue(value: PizzaSizeEnum) {
    this.pizzaSize = value;
  }

  registerOnChange(fn) {
    this.propagateChange = fn;
  }

  registerOnTouched() {}
  propagateChange = (value: PizzaSizeEnum) => {};
}

Нам нужно реализовать ControlValueAccessor интерфейс. Это гарантирует, что мы реализовали правильные хуки компонента formControl.

writeValue

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

this.form.get('size').setValue(1);

будет вызвана функция writeValue, которая позволит нам записать новое значение в локальное свойство pizzaSize.

registerOnChange

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

registerOnTouched

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

После того, как мы закончили подключать все в нашем файле компонента, давайте взглянем на реализацию HTML:

div class="row">
  <div class="col-md-12">
    <div class="PizzaSizePicker">
      <div class="PizzaSizePicker__item"
           (click)="changeSize(PizzaSizeEnum.SMALL)"
           [ngClass]="{'PizzaSizePicker__item--active': pizzaSize === PizzaSizeEnum.SMALL}">
        SMALL
      </div>
      <div class="PizzaSizePicker__item"
           (click)="changeSize(PizzaSizeEnum.MEDIUM)"
           [ngClass]="{'PizzaSizePicker__item--active': pizzaSize === PizzaSizeEnum.MEDIUM}">
        MEDIUM
      </div>
      <div class="PizzaSizePicker__item"
           (click)="changeSize(PizzaSizeEnum.LARGE)"
           [ngClass]="{'PizzaSizePicker__item--active': pizzaSize === PizzaSizeEnum.LARGE}">
        LARGE
      </div>
    </div>
  </div>
</div>

Мы определяем наши настраиваемые кнопки и прикрепляем click функцию для выполнения функции changeSize, объявленной ранее:

changeSize(size: PizzaSizeEnum) {
    this.pizzaSize = size;
    this.propagateChange(size);
}

Мы меняем состояние нашего локального компонента и вызываем propagateChange функцию, чтобы уведомить родителя formGroup о нашем изменении.

Работа с массивами флажков в ReactiveForms

Я обнаружил, что работа с массивами флажков в ReactiveForms может быть довольно сложной, некоторые решения предлагают создать логическое FormArray представление состояния флажка. В нашем примере у нас есть средство выбора начинки, представленное несколькими флажками:

Обычно предлагается преобразовать все начинки примерно так:

const TOPPINGS = ['Pepperoni', 'Olives', 'Pineapple']; // Yes it says pineapple.

this.form = this.fb.group({
    // ... other code
    toppings: this.fb.array(TOPPINGS.map(() => false))
})

Лично я считаю этот подход не очень удобным, так как окончательное значение формы будет:

toppings: [false, false, true, false]

Затем нам нужно будет сопоставить этот странный массив с более значимым значением на основе индекса массива начинок.

Итак, как мы можем добиться большего?

Мы можем сопоставить массив данных начинки со следующим интерфейсом

interface ICheckBoxItem {
    id?: string; 
    selected: boolean;
    name: string; 
}
  • id - - необязательный ключ, который можно использовать для внутреннего enum значения элемента начинки.
  • selected - будет удерживать флажок checked в состоянии
  • имя - понятный текст, который будет отображаться пользователю.

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

private mapToCheckboxArrayGroup(data: string[]): FormArray {
    return this.fb.array(data.map((i) => {
      return this.fb.group({
        name: i,
        selected: false
      });
    }));
}

Мы получаем массив параметров и сопоставляем его с новым FormArray. Создание группы массива начинки будет выглядеть так:

getPizzaFormGroup(size: PizzaSizeEnum = PizzaSizeEnum.MEDIUM): FormGroup {
    return this.fb.group({
      size: [size],
      toppings: this.mapToCheckboxArrayGroup(this.availableToppings)
    }, {
      validator: this.pizzaValidatorsService.pizzaItemValidator()
    });
  }

(this.availableToppings представляет собой массив начинок, описанный выше).

Затем наш HTML-файл будет перебирать массив формы начинки и генерировать входные данные флажка:

<div class="col-md-12 mg-top-15">
  <h5>Toppings</h5>
  <div class="ToppingsSelector" formArrayName="toppings">
    <div class="ToppingsSelector__item" *ngFor="let topping of toppingsArray.controls" [formGroup]="topping" >
      <mat-checkbox [formControl]="topping.get('selected')">
        {{topping.get('name').value}}
      </mat-checkbox>
    </div>
  </div>
</div>

Позже, когда нам понадобится получить все выбранные начинки, мы можем просто отфильтровать все элементы formControls, где selected === true.

Восстановление состояния формы с сервера

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

К счастью, с нашей формой это очень просто. Во-первых, в нашем компоненте контейнера мы определим, находимся ли мы в режиме редактирования или в режиме создания. Я обычно передаю параметр id маршрутизатору, и если он существует, это означает, что мы редактируем существующую форму.

ngOnInit() {
    if (this.editMode) {
      // ...fetch data from server based on the id 
      this.pizzaLoaderService.loadPizzaForEdit(DEMO_PIZZA);
    }
}

Давайте посмотрим на PizzaLoaderService:

@Injectable()
export class PizzaLoaderService {
  constructor(
    private pizzaFormService: PizzaFormService
  ) {

  }

  loadPizzaForEdit(data: IPizzaFormInterface): void {
    this.pizzaFormService.form.patchValue({
      customerDetails: {
        ...data.customerDetails
      }
    });

    for (const pizza of data.pizzas) {
      const group = this.pizzaFormService.addPizza();
      group.patchValue({
        size: pizza.size,
        toppings: this.prefillToppingsSelection(group.get('toppings').value, pizza.toppings as PizzaToppingsEnum[])
      });
    }
  }

  prefillToppingsSelection(toppings: IToppingItem[], selectedToppings: PizzaToppingsEnum[]): IToppingItem[] {
    return toppings.map((i) => {
      if (selectedToppings.includes(i.name)) {
        i.selected = true;
      }

      return i;
    });
  }
}

Мы вводим PizzaFormService, чтобы получить доступ к объекту form, и предоставляем функцию loadPizzaForEdit. Он получит объект заказа в том виде, в каком он был получен от сервера (для простоты я использовал тот же интерфейс для формы, но обычно вам придется сопоставить модель сервера с интерфейсом формы. Эта логика будет происходить здесь).

Он исправляет значения группы формы customerDetails, а затем добавляет группу пиццы для каждого объекта пиццы, полученного с сервера. Только после добавления группы форм мы можем исправить ее значение данными с сервера.

Сопоставление значений флажков

Помните, что наши начинки представляют собой массив формы с интерфейсом ICheckBoxItem? Итак, сервер ответил ['Ham', 'Pineapple']. Где остальные начинки? И подождите, наш formArray выглядит не так! Чтобы сопоставить ответ сервера с правильной структурой, мы создали небольшую функцию с именем prefillToppingsSelection:

prefillToppingsSelection(toppings: IToppingItem[], selectedToppings: PizzaToppingsEnum[]): IToppingItem[] {
    return toppings.map((i) => {
      if (selectedToppings.includes(i.name)) {
        i.selected = true;
      }

      return i;
    });
}

Он получает все значения формы из начинки formGroup, созданной методом addPizza, и выполняет итерацию по ним, если отдельная начинка существует в массиве selectedToppings, она помечает ее как selected = true. Вуаля.

Сброс формы

Проходит неделя, и еще один запрос функции от нашего начальника Джона: «Клиент должен иметь возможность сбросить форму». Happy me отвечает: «Подержи мое пиво на секундочку».

Давайте создадим новый метод в нашем PizzaFormService:

resetForm() {
    while (this.pizzasArray.length) {
      this.pizzasArray.removeAt(0);
    }

    this.form.reset();
}

Реактивные формы имеют встроенную функцию reset. Однако это не удалит наши добавленные группы форм пиццы из FormArray. Нам нужно будет перебрать их и удалить вручную.

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

Подведение итогов

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

Есть какие-нибудь полезные советы? Делитесь ими в комментариях!

Первоначально опубликовано на blog.grossman.io 27 августа 2018 г.