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

от автора

Всем привет! Я Александр Бухтатый, 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-библиотеке, мы будем править только обертки, а не все поля у форм во всех проектах монорепозитория.

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

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


ссылка на оригинал статьи https://habr.com/ru/companies/tinkoff/articles/740706/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *