Динамические формы в Angular или автоматизируем создание Angular форм

Aleksandr Serenko
F.A.F.N.U.R
Published in
10 min readJan 23, 2020

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

Для полного понимания, необходимо знакомство со стандартными средствами работы с формами — Angular Forms и Angular Reactive Forms. Также необходимы базовые представления по работе с DOM’ом и понимания работы Structural Directives.

В результате должны получить:

Введение

Работать с формами в Angular достаточно легко, где в вашем распоряжении две мощнейшие библиотеки — forms и reactive forms, которые можно расширить cdk и material.

Для небольших форм, которые содержат не более 5–10 HTMLElement’ов, все отлично работает, но когда логика с формами начинает разрастаться, поддерживать формы становиться достаточно тяжело.

Список того, что существенно может осложнить поддержку форм:

  • Валидаторы
  • Обработка ошибок
  • Асинхронные валидаторы и проверки
  • Общие обертки компонентов
  • и т.д.

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

Все это вынуждает разработчика автоматизировать процесс — создавать конфигурации форм (form config), и динамически создавать модели (form model) и верстку для них.

Есть несколько решений, одно из которых — Ngx-formly, которые несомненно упростят вашу жизнь.

Данное решение можно использовать если вам не нужен полноценный контроль за данными.

Обычно для работы с формами нужен следующий функционал:

  • Валидация
  • Персонализация/кастомизация
  • Показ/скрытие полей и обновление форм модели

Принцип работы динамический форм заключается в создании компонента (form component), который будет получать конфигурацию формы (form config) и на её основании создавать HTML элементы.

Обычно HTML элемент формы, отвечающий за ввод, сопровождается вместе с элементом HTML элементом Label. Все это подталкивает нас, выделять в формах две основных секции обёртку поля (field wrapper) и вывод самого поля (field component).

Компонент формы (form component) — это основной компонент, отвечающий за создание всех полей формы. Компонент формы принимает конфигурацию формы и создаёт поля. При изменении конфигурации или изменения модели формы, компонент формы сообщает каждому полю о изменении формы и обновляет компонент.

Конфигурация формы (form config) содержит настройки формы и список полей. Обычно в конфигурацию включаются следующие параметры:

  • Идентификатор формы
  • Классы для компонента формы (form component)
  • Опции формы (AbstractControlOptions)
  • Общая обёртка поля для всех полей
  • Массив конфигураций полей для отрисовки (Array<field config>)

Конфигурация поля (field config) содержит настройки конкретного поля, которое включает в себя:

  • Уникальный ключ
  • Тип поля (input, select или custom field)
  • Заголовок поля (label)
  • Значение по-умолчанию
  • Персональная (custom) дата
  • Признак поля, которое не является input’ом
  • Признак отображения ошибок
  • Функция, которая возвращает true, если компонент необходимо отобразить
  • Набор аттрибутов поля

Аттрибуты поля (field attributes) содержат HTML аттрибуты форм, такие как:

  • Идентификатор поля
  • Классы для компонента поля (field component)
  • Классы для обёртки поля (field wrapper)
  • Плейсхолдер
  • и другие

Обертка поля (field wrapper/wrapper component) отрисовывает Label, если тот указан, а также указывает где должно быть размещён вывод поля. Также field wrapper содержит примерный шаблон вывода ошибок. Обёртка поля следит за состоянием фокуса/блюра и обновляет макет, если это необходимо.

Обёртка поля содержит аттрибуты такие же как и компонент поля.

Компонент поля (field component) реализует всю логику связанную с отрисовкой и обработкой вводимых или изменяемых данных. Компонент следит за изменением поля (value changes), и обновляет данные (change detection) если это необходимо.

Компонент поля содержит следующие аттрибуты:

  • Конфигурация поля
  • Контрол (form control)
  • Конфигурация формы
  • Форма (form group)
  • Персональная (custom, кастомная) дата

Для простоты понимания, перейдём к реализации динамических форм.

Реализация динамических форм

Реализовывать динамические формы мы будем с помощью стандартных средств Angular.

Одной из первых статьёй, что мне попадались была — Configurable Reactive Forms in Angular with dynamic components.

Формально мы будем делать тоже самое, только с тем исключением, что мы будет иметь 2 компонента для поля — враппер (wrapper component) и само поле (field component). Также мы откажемся от создания и контроля вложенных друг в друга форм, для создания сложных объектов.

Базовые интерфейсы

Первым делом определим интерфейс для FormConfig:

Как можно заметить, единственным аттрибутом конфигурации поля, которое не было включено в список параметров это refs, которые хранят ссылки на текущие (отрисованные) HTML компоненты.

Объяснение необходимости будет в разделе конструктора форм.

Теперь добавим интерфейсы для конфигурации полей:

Были объявлены следующие интерфейсы:

FormField — базовый интерфейс, который наследуют все поля.

FormFieldWithOptions — интерфейс, который расширяет FormField, добавляя в него возможность работы с контролами, у которых есть выбор (select’ы, radio buttons)

FormFieldInputMask — интерфейс, который расширяет FormField, добавляя в него возможность работать с текстовыми масками (text-mask-core).

FormFieldInputRange — интерфейс, который расширяет FormField, добавляя в него возможность работать с input[type=”range”]

Единственный интерфейс, который не является полем, но который есть в списке поддерживаемых в конфигурации формы — FormGroupField.

FormGroupField — специальный вид поля, который формально группирует поля, для отображения. Это сдвоенные или строенные поля, наподобие — серия и номер паспорта, месяц и год выдачи карты, и т.д. Основной характеристикой таких полей можно вынести — общий враппер для всех полей, но у полей нет доступа к нему.

И перед тем, как будем создавать компоненты, добавим несколько абстрактных классов, для упрощения работы:

FieldComponent — основной класс field компонента. Из интерфейса понятны все типы. Единственное для простоты работы, были созданы геттеры для обращения к атрибутам поля:

get attrs(): Partial<FormFieldAttributes> {
return this.field.attrs || {};
}

get classes(): string {
return this.attrs.classes;
}

get wrapperClasses(): string {
return this.attrs.wrapperClasses;
}

get id(): string {
return this.attrs.id ? this.attrs.id : this.field.key;
}

get name(): string {
return this.attrs.name ? this.attrs.name : this.field.key;
}

Аттрибуты Id и Name являются одними из важнейших в работе формы. Но так как за частую id и name совпадают, то данные геттеры выводят данные значения, и если конфигурация для данных атрибутов отсутствует, то выводится по умолчанию значение key, которое является уникальным в рамках формы.

Интерфейсы для полей и оберток полей

Теперь перейдём к созданию field и wrapper компонентов. Первым создадим FieldInputComponent:

Если посмотреть реализацию, то можно увидеть стандартный input, в котором работают стандартные Angular value accessor’ы. Добавим несколько обёрток полей.

FieldEmptyWrapperComponent
Для пустой обёртки:

FieldWrapperComponent
Для обёртки по умолчанию:

Данные компоненты содержат ViewChild — FormHostDirective, которая отвечает за расположение, где будет отрисован field компонент.

Реализация FormHostDirective очень проста, которая формально получает view container:

Второй особенностью компонента обёртки является наблюдение за touch’ем:

extractTouchedChanges(this.formControl).subscribe(() => {
this.changeDetectorRef.markForCheck();
})

ExtractTouchedChanges — реактивная функция, которая следит за фокусировкой на form contol’ах:

Данное решение было взято с https://github.com/angular/angular/issues/10887#issuecomment-547392548

Это нужно для того, чтобы генерировать события. Например, есть форма из 10 полей. Пользователь заполнил 3 из 10 и нажал сохранить. Первые 5 полей из 10 обязательные, поэтому в форме нужно подсветить нужные обязательные поля. И для этого нужна глобальная система оповещения между компонентами. В качестве данной системы и выступает touch. Можно пометить все поля как нажатые, и соответственно, мы запустим валидаторы, которые подсветят все необходимые обязательные для заполнения поля.

FieldRadioComponent
Добавим компонент по сложнее. Предоставим реализацию radio button’ов.

Как видим из кода, используется FieldWithOptionsComponent. Также можно увидеть 2 watch’ера: touchedChanged$ и valueChanges$.

Хотя в данном случае достаточно и одного touchedChanged$ в силу специфики реализации radio button’ов. Но например, для select’а уже нужны оба наблюдателя.

Свойство isArray возвращает true, если field options являются простым массивом [“male”, “female”]. Если isArray ложно, тогда field option это объект ({label: “gender.female”, value: “female” }) и для вывода option value и option label нужно брать значения свойств объекта.

Приведём пример с компонентом для компонента выпадающего списка (select’а):

FieldInputUcfirstComponent
Добавим реализацию сложного компонента, например Input upper case first — особый вид input’а, в котором первая буква всегда должна быть заглавной.

Как можно убедиться, компонент полностью совпадает с FieldInputComponent лишь за тем исключением, что в FieldInputUcfirstComponent ипользуется директива — msInputUcfirst, в которую и вынесена вся логика.

Директива InputUcfirstDirective представляет собой реализацию ControlValueAccessor.

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

Это вынесено в директиву не случайно. Если это делалось с помощью valueChanges$ control’а формы, то тогда в список действий попадало бы 2 события:

  • Ввод маленькой буквы
  • Изменение маленькой буквы на большую

В случае использовании директивы, form group получит лишь одно изменение — Введение большой буквы, вне зависимости вводили ли большую или маленькую букву.

FieldInputMaskComponent
Теперь добавим компонент, который работает с масками:

Как и в случае с FieldInputUcfirstComponent, вся логика вынесена в директиву — InputMaskDirective.

InputMaskDirective является адаптацией решения text-mask-angular2, в котором были исправлены некоторые баги, поправлена концептуальность чистоты angular и добавлены новые фичи.

Основной особенностью является проверка и авто подстановка русского номера телефона, который может начинаться с 8, +7, 7, которые явно не соответствует паттерну.

Добавим просто списком остальные реализации checkbox, textarea, input range.

FieldCheckboxComponent

FieldDateComponent

FieldInputRangeComponent

FieldTextareaComponent

Компонент формы

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

Компонент формы небольшой из-за того, что вся логика за построение и обновления полей вынесена в отдельный сервис — FormConstructor.

Прежде чем перейти к реализации FormConstructor’а, отметим, что в форме есть всего два входящих параметра: form group и form config.
В качестве генерируемых событий тоже два события: created и changed. Первое событие генерируется при создании формы, второе при каждом изменении модели формы(form group).

Реализация конструктора форм

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

Принцип работы конструктора форм:

  1. Получить конфигурацию формы и создать модель формы (form group)
  2. Для созданной модели для каждого поля создать соответствующие компоненты обёртки и поля
  3. При изменении любого поля проверять нужно ли показывать поле, и в случае отображения либо создавать новое поле либо удалять если поле было скрыто.

Объявим интерфейс для конструктора форм:

Интерфейс содержит 3 метода:

  • registerControls — метод, который создаёт form model
  • renderControls — метод, который отрисовывает поля form model
  • updateControls — метод, который обновляет form model и перерисовывает поля

Добавим имплементацию данного интерфейса:

Приведём общий пример конфигурации формы:

Особенности данной формы:

  • при выборе опций radio срываются/показываются новые поля,
  • есть скрытое поле, которое не отрисовывается
  • есть групповые поля

Опишем методы в порядке инициации.

RegisterControls

Данный метод перебирает каждое поле, и если поле групповое (FormGroupField), то перебираем все вложенные поля и добавляем их в модель (form group).

Как можно увидеть из реализации, модель формы — плоская, то есть не содержит вложенных сущностей. Все групповые поля добавляются на root уровень. Это сделано специально, так как API будет принимать набор полей, а не вложенные в друг друга сущности.

Возможно это должно быть опционально, но так как нет задачи написать универсальную библиотеку, читатель сможет сам внести соответствующие правки.

Метод createControl добавляет в formGroup если поле должно быть отображено.

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

RenderControls

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

Формально мы перебираем все поля, проверяем их на доступность и отрисовываем их.

Как и при создании formControl’ов, для групповых полей, для каждого вложенного поля вызывается собственная функция создания компонента для поля.

Метод создания компонента получает для поля компонент обёртки (getWrapperComponent) и комопонент самого поля (getComponent).

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

Методы getFieldComponentFactory и getWrapperComponentFactory формально по типу, либо создают ранее предопределённый компонент, или же если передан пользовательский тип компонента, то создаётся переданный тип вместо типа по умолчанию.

UpdateControls

Последний метод интерфейса конструктора форм это обновление полей

Как можно увидеть из реализации, он почти идентичен методу -RenderControls. Ключевым отличием метода является наблюдение за порядком полей. Если при создании полей, мы точно знаем, что каждое следующее поле будет отображено ниже текущего, то при обновлении полей, мы не знаем порядок, который будет. Поэтому приходиться контролировать вложенность полей, и вставлять в форму новые поля.

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

UpdateControls при обновлении полей пропускает все поля с аттрибутом static = true или все поля, которые являются скрытыми. Статик поля это просто обычные Angular компоненты, не являющиеся контролами.

Например, между двух полей нужно вывести изображение и какой-то текст, который не связан с формой. Для этого используется field с параметром static.

Dymanic forms module

Добавим модуль и подключим все компоненты:

Демо

Для приведённого выше конфига, снова покажем демонстрацию работы формы:

Резюме

В ходе статьи были разобраны и получены следующие результаты:

  • Рассмотрена проблема с созданием и управлением форм
  • Рассмотрен и описан механизм создания динамических форм
  • Приведена реализация динамических форм для Angular
  • Проведён детальный разбор компонентов и сервисов, отвечающих за постарение и отрисовку.

Основные особенности, которых удалось достичь:

  • Данное решение не зависит от внешних или сторонних библиотек и может быть использовано в любом Angular приложении
  • Данное решение использует change detection strategy — OnPush, что существенно повышает производительность данных форм.

Исходники

Все исходники находятся на github, в репозитории https://github.com/Fafnur/medium-stories

Для того, чтобы посмотреть состояние проекта на момент написания статьи, нужно выбрать соответвующий tag — forms.

git checkout forms

Код dinamic forms можно посмотреть в разделе libs/dynamic-forms.

Дополнение

Так как статья в ходит в цикл статей про разработку Angular приложения с использованием mono-repo, то интегрируем все описанное выше в моно репозиторий.

Создадим клон последнего проекта, назвав его forms (просто скопируем содержимое проекта и исправим конфига в angular.json, nx.json, package.json).

Добавим в модуль events новый компонент event-new:

Создадим форму и выведем её на странице:

В итоге получим следующее:

В следующих статьях интегрируем наши формы с Grapgql и добавим серверную валидацию форм.

Спасибо за внимание!
Подписывайтесь на канал, чтобы не пропустить новые статьи про Angular и мира фронтенд разработки.

Предыдущие статьи:

  1. Статья про Redux в Angular с помощью Ngrx. Создание Store в Angular
  2. Организация Stat’ов в Angular c Ngrx и Nx
  3. GraphQL API для Angular с помощью NX и Nest.
  4. Интеграция GraphQL API в Angular приложение

--

--

Aleksandr Serenko
F.A.F.N.U.R

Senior Front-end Developer, Angular evangelist, Nx apologist, NodeJS warlock