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

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

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

Мы используем 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<T> extends FormFieldBase {
  private readonly itemsHandlers: TuiItemsHandlers<T> = inject(TUI_ITEMS_HANDLERS);

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

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

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

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

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

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

  @Output()
  search$ = new ReplaySubject<string | null>();
}

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

<tui-combo-box
  [formControl]="control"
  [identityMatcher]="identityMatcher"
  [valueContent]="valueContent"
  [stringify]="stringify"
  (searchChange)="search$.next($event)"
>
  <ng-content></ng-content>
  <input
    tuiTextfield
    [placeholder]="placeholder"
  />
  <tui-data-list-wrapper
    *tuiDataList
    [items]="items"
    [itemContent]="itemContent"
  ></tui-data-list-wrapper>
</tui-combo-box>

<tui-error
  [formControl]="control"
  [error]="[] | tuiFieldError | async"
></tui-error>

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

<div [formGroup]="formGroup">
  <div class="tui-form__row tui-form__row_multi-fields">
    <div class="tui-form__multi-field">
      <aff-combobox
        formControlName="address"
        [affComboboxDataProvider]="comboboxDataProvider"
        [stringify]="comboboxStringify"
      >
        address
      </aff-combobox>
    </div>
		...
</div>

Пример использования обертки 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<any> = (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> = 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<any>, active: boolean }
  ) {
  }
}

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

<div><b>{{label}}</b></div>
<div>{{hint}}</div>

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

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

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

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

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

<div [formGroup]="formGroup">
  <div class="tui-form__row tui-form__row_multi-fields">
    <div class="tui-form__multi-field">
      <aff-combobox
        formControlName="address"
        withHint
        [affComboboxDataProvider]="comboboxDataProvider"
        [stringify]="comboboxStringify"
      >
        address
      </aff-combobox>
    </div>
		...
</div>

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

<div [formGroup]="formGroup">
  <div class="tui-form__row tui-form__row_multi-fields">
    <div class="tui-form__multi-field">
      <aff-combobox
        formControlName="address"
        [affComboboxDataProvider]="comboboxDataProvider"
        [stringify]="comboboxStringify"
				[valueContent]="content"
				[itemContent]="content"
      >
        address
      </aff-combobox>
      <ng-template #content let-data>
        <aff-with-hint-option-template [label]="data.label" [hint]="data.hint"></aff-with-hint-option-template>
      </ng-template>
    </div>
		...
</div>

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

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

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

@Directive({
  selector: '[affComboboxDataProvider]',
  standalone: true
})
export class ComboboxDataProviderDirective<T> implements OnInit, OnDestroy {
  @Input('affComboboxDataProvider') dataFetchFn!: ComboboxDataProvider<T>;
  comboboxComponenRef = inject(ComboboxComponent<T>);
	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:

<div class="tui-form__row tui-form__row_multi-fields" [formGroup]="formGroup">
  <div class="tui-form__multi-field">
    <aff-input formControlName="name">Name</aff-input>
  </div>
  <div class="tui-form__multi-field">
    <aff-phone formControlName="phone">Phone</aff-phone>
  </div>
</div>

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

<div class="tui-container tui-container_adaptive tui-space_top-8">
  <h1>Pizza order form</h1>
  <form [formGroup]="formGroup">
    ...
    <h2 class="tui-space_top-8">Contacts</h2>
    <aff-contacts-short-form formGroupName="contacts"></aff-contacts-short-form>
		...
	</form>
</div>

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

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

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

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

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

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

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

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

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

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

Комментарии (2)


  1. astec
    10.06.2023 06:39

    Ничего непонятно, но очень интересно!

    Можете написать статью в чём схожесть и отличие TUI от Ionic Framework?


    1. alex_mayak Автор
      10.06.2023 06:39

      Спасибо за комментарий.

      Постараюсь в следующих публикациях понятней расписывать материал.

      Вы можете ознакомится с демо на stackblitz

      Если кратко то мы делаем удобней работу с внешней библиотекой компонентов