Работа с формами в Angular — модуль работы с формами и обертки полей

Всем привет! Я Александр Бухтатый, frontend-разработчик в Тинькофф, специализируюсь на Angular. Наша команда работает в монорепозитории с четырьмя проектами. В каждом проекте много форм, нужно сопровождать их и создавать новые.

В статье покажу один из способов работы с формами в Angular-проектах, который упрощает создание новых форм и изолирует зависимость от внешней UI-библиотеки. Будет мало текста и много кода, пристегните ремни, мы начинаем.

05d566b4f9fbe73cb4f88e59c07bb433.png

Определили проблемы

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

Зависимость. Большая зависимость от UI-библиотеки при обновлении мажорной версии приведет к куче рефакторинга во всех формах всех проектов монорепозитория. При создании поля в форме нужно применить множество разных компонентов, каждый из которых служит поводом вернутся и внести правки в код с полем формы.

Например, если изменятся контракты tui-error, придется вносить правки во все поля всех форм на проекте. Обертки делают инверсию зависимостей, и наши формы зависят от оберток, а те, в свою очередь, зависят от внешней UI-библиотеки.

Такой подход защищает от обратно несовместимых правок в UI-библиотеке и уменьшает количество работы по переходу на ее новую версию.

Схема зависимостей полей формы от компонентов UI-библиотеки до применения оберток

Схема зависимостей полей формы от компонентов UI-библиотеки до применения оберток

Схема зависимостей полей формы от компонентов UI-библиотеки после применения оберток

Схема зависимостей полей формы от компонентов UI-библиотеки после применения оберток

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

Код Combobox в форме до применения обертки

Код Combobox в форме до применения обертки

Код Combobox в форме после применения обертки

Код Combobox в форме после применения обертки

Дублирование кода. К каждому полю нужно дописывать tui-error и вспомогательные вещи типа шаблонов или компонентов для работы со списком. Видно в примерах предыдущей проблемы.

Сложно добавлять новое поле в форму —много усилий и постоянное обращение к справке UI-библиотеки.

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

Нашли решение

Думая над обозначенными проблемами, мы осознали, что решение все это время было рядом — и это инкапсуляция.

Мы составили список работ:

  • Завернуть все связанное с формами в один модуль.

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

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

Приступили к реализации

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

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

  • Core — все вспомогательные классы.

  • Controls — пользовательские компоненты с реализацией ControlValueAccessor.

  • Fields — обертки полей формы и их варианты.

  • Masks — каталог доступных масок для полей формы.

  • Validators — различные валидаторы для полей формы.

Структура модуля

Структура модуля

Обертка поля формы — это компонент с удобным интерфейсом, инкапсулирующий в себя весь бойлерплейт из UI-библиотек. С оберткой можно работать как с компонентом, реализующим ControlValueAccessor, то есть используя Angular-директивы ngModel, FormControl, FormControlName.

Преимущества использования обертки:

  • Улучшаем читаемость кода.

  • Снижаем количество бойлерплейта.

  • Ускоряем разработку за счет переиспользования готовых оберток и их вариантов.

  • Изолируем зависимость от внешней UI-библиотеки.

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

@Directive()
export class FormFieldBase implements OnInit, OnDestroy, ControlValueAccessor {
  control!: FormControl;
  private subscription!: Subscription;

  constructor(
    @Optional() @Self() public ngControl: NgControl
  ) {
    if (this.ngControl != null) {
      this.ngControl.valueAccessor = this;
    }
  }

  writeValue(obj: any): void {}

  registerOnChange(fn: (_: any) => void): void {}

  registerOnTouched(fn: any): void {}

  ngOnInit() {
		if (!this.ngControl) throw new Error('ngControl is undefined');

    if (this.ngControl instanceof FormControlName) {
      this.control = this.ngControl.control;
    } else if (this.ngControl instanceof FormControlDirective) {
      this.control = this.ngControl.control;
    } else if (this.ngControl instanceof NgModel) {
      this.control = this.ngControl.control;
      this.subscription = this.control.valueChanges.subscribe((x) =>
        this.ngControl.viewToModelUpdate(this.control.value)
      );
    } else if (!this.ngControl) {
      this.control = new FormControl();
    }
  }

  ngOnDestroy() {
    this.subscription?.unsubscribe();
  }
}

После реализации базового класса можно создавать обертки. Пример обертки combobox.component.ts:

@Component({
  selector: 'ngnx-form-field-combobox',
  templateUrl: './form-field-combobox.component.html',
  styleUrls: ['./form-field-combobox.component.scss'],
  standalone: true,
  imports: [
    TuiComboBoxModule,
    ReactiveFormsModule,
    TuiDataListWrapperModule,
    TuiErrorModule,
    TuiFieldErrorPipeModule,
    AsyncPipe,
    JsonPipe
  ]
})
export class FormFieldComboboxComponent extends FormFieldBase {
  private readonly itemsHandlers: TuiItemsHandlers = inject(TUI_ITEMS_HANDLERS);

  @Input()
  items: any[] | null = null;

  @Input()
  identityMatcher: TuiItemsHandlers['identityMatcher'] = this.itemsHandlers.identityMatcher;

  @Input()
  stringify: TuiItemsHandlers['stringify'] = this.itemsHandlers.stringify;

  @Input()
  placeholder?: string = '';

  @Input()
  valueContent: PolymorpheusContent> = new PolymorpheusComponent(DefaultOptionTemplateComponent);

  @Input()
  itemContent: PolymorpheusContent> = new PolymorpheusComponent(DefaultOptionTemplateComponent);

  @Output()
  search$ = new ReplaySubject();
}

Пример обертки combobox.component.html:


  
  
  




Пример использования обертки Combobox — delivery-form.component.html:

address
...

Пример использования обертки Combobox — delivery-form.component.ts:

@Component({
  selector: 'aff-delivery-form',
  templateUrl: './delivery-form.component.html',
  styleUrls: ['./delivery-form.component.less'],
})
export class DeliveryFormComponent extends FormGroupBase {
  selectItemsWithHints = [
    {id: '1', label: 'Label 1'},
    {id: '2', label: 'Label 2'},
    {id: '3', label: 'Label 3'},
    {id: '4', label: 'Label 4'},
  ];

  comboboxStringify(item: {label: string}): string {
    return item.label;
  }

  comboboxDataProvider: ComboboxDataProvider = (term: string) => {
    const foundedItems = this.selectItemsWithHints.filter((item) => term == '' || item.label.toLowerCase() == term.toLowerCase() || item.label.toLowerCase().includes(term.toLowerCase()));
    return foundedItems && foundedItems.length ? of(foundedItems) : of(null);
  }
}

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

Пример варианта по умолчанию для select

Пример варианта по умолчанию для select

Пример варианта с подсказкой для select

Пример варианта с подсказкой для select

Шаблон — компоненты, которые отображаются в качестве частей оборачиваемого компонента. Реализация шаблона option-with-hint-content-template.component.ts:

export type OptionWithHint = T & {
  label: string;
  hint: string;
};

@Component({
  selector: 'aff-option-with-hint-content-template',
  standalone: true,
  imports: [CommonModule],
  templateUrl: './option-with-hint-content-template.component.html',
  styleUrls: ['./option-with-hint-content-template.component.scss'],
})
export class OptionWithHintContentTemplateComponent {
  @Input('label') inputLabel?: string;
  @Input('hint') inputHint?: string;

  get label(): string {
    return this.optionWithHintMapperDirectiveRef?.mapper?.label(this.context?.$implicit) || this.context?.$implicit?.label || this.inputLabel || '-';
  }

  get hint(): string {
    return this.optionWithHintMapperDirectiveRef?.mapper?.hint(this.context?.$implicit) || this.context?.$implicit?.hint || this.inputHint || '-';
  }

  constructor(
    @Optional() private optionWithHintMapperDirectiveRef: OptionWithHintMapperDirective,
    @Optional() @Inject(POLYMORPHEUS_CONTEXT) readonly context: { $implicit: OptionWithHint, active: boolean }
  ) {
  }
}

Реализация шаблона option-with-hint-content-template.component.html:

{{label}}
{{hint}}

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

Реализация варианта combobox-with-hint-variant.directive.ts:

@Directive({
  selector: 'aff-combobox[withHint]',
  standalone: true,
})
export class ComboboxWithHintVariantDirective {
  comboboxComponenRef = inject(ComboboxComponent);

  constructor() {
    this.comboboxComponenRef.itemContent = new PolymorpheusComponent(
      WithHintOptionTemplateComponent
    );
    this.comboboxComponenRef.valueContent = new PolymorpheusComponent(
      WithHintValueTemplateComponent
    );
  }
}

Пример использования варианта для Combobox delivery-form.component.html:

address
...

Пример использования шаблона без директивы-варианта:

address
...

Для удобной работы с разными обертками можно создавать директивы-помощники. Например, сделать директиву, в которую через Input передадим метод, возвращающий список доступных значений каждый раз при обновлении поисковой строки Combobox. Это нужно, чтобы не писать каждый раз логику с подпиской и прокидыванием результата в компонент через шаблон.

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

export type ComboboxDataProvider = (term: string) => Observable | null>;

@Directive({
  selector: '[affComboboxDataProvider]',
  standalone: true
})
export class ComboboxDataProviderDirective implements OnInit, OnDestroy {
  @Input('affComboboxDataProvider') dataFetchFn!: ComboboxDataProvider;
  comboboxComponenRef = inject(ComboboxComponent);
	private subscription!: Subscription;

  ngOnInit() {
    this.comboboxComponenRef.search$.pipe(
      startWith(''),
      filter((term: string | null) => term !== null),
      switchMap((term: string | null) => this.dataFetchFn(term))
    ).subscribe({
      next: (response) => this.comboboxComponenRef.items = response,
      error: (error) => this.comboboxComponenRef.items = [],
    })
  }

	ngOnDestroy() {
    this.subscription?.unsubscribe();
  }
}

Пример использования мы уже видели ранее в delivery-form.component.html и delivery-form.component.ts.

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

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

Реализация вспомогательного класса form-group-base.class.ts:

@Directive()
export class FormGroupBase {
  get formGroup(): FormGroup {
    return this.controlContainer.control as FormGroup;
  }

  constructor(private controlContainer: ControlContainer) {}
}

Пример реализации переиспользуемой формы contacts-short-form.component.ts:

@Component({
  selector: 'aff-contacts-short-form',
  templateUrl: './contacts-short-form.component.html',
  styleUrls: ['./contacts-short-form.component.less'],
})
export class ContactsShortFormComponent extends FormGroupBase {}

Пример реализации переиспользуемой формы contacts-short-form.component.html:

Name
Phone

Пример использования order-form.component.html:

Pizza order form

...

Contacts

...

Результаты и полезные ссылки

Создание оберток для всех компонентов Taiga UI заняло один рабочий день, потом пару недель мы обкатывали обертки на формах, с которыми работаем. После успешной обкатки решили целиком перейти на обертки — и не жалеем об этом.

У нас получилось:

  • Сократить время разработки.

  • Улучшить читаемость кода.

  • Сократить количество бойлерплейта — в среднем html-код сократился на 50%.

  • Создать единое место для всего связанного с формами, что снижает вероятность создания дублей.

  • Изолировать зависимость от внешней UI-библиотеки. Если произойдут критичные изменения в UI-библиотеке, мы будем править только обертки, а не все поля у форм во всех проектах монорепозитория.

Полезные ссылки:

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

© Habrahabr.ru