Переиспользование форм в Angular


Проект в Stackblitz со всеми примерами в конце поста.

Переиспользуемые элементы управления

Проблема

Однажды я писал модуль аутентификации для компании в сфере электронной коммерции. Это кажется просто, но позже я понял: в таком модуле 8 разных страниц:

  • Вход.
  • Регистрация.
  • Сброс пароля.
  • Вход через социальные сети. 
  • Слияние аккаунтов и ещё 3 страницы. 

На большинстве из них были одни и те же элементы, одинаковый интерфейс, проверки и сообщения об ошибках. 

Рассмотрим поле ввода электронной почты. Вначале оно пустое. Если ввод корректен, указываем на это галочкой, а если нет  —  показываем сообщение об ошибке. Я подсчитал: в таком простом проекте это встречалось 12 раз!

Решение

Итак, мы хотим создать переиспользуемый элемент управления. Первое, что приходит в голову  —  сделать его компонентом.

Такой компонент должен:

  • Иметь поля с типами text и password.
  • Проверять ввод регулярным выражением.
  • Показывать результат проверки.
  • Сообщать об ошибках.

Начнём с простого компонента с @Input():

export class FirstCustomInputComponent { @Input() type = 'text'; @Input() isRequired: boolean = false; @Input() pattern: string = null; @Input() label: string = null; @Input() placeholder: string; @Input() errorMsg: string; }

И шаблон:

<div class="form-label-group"> <input class="form-control" [type]="type" #input [placeholder]="placeholder" ngModel> <div class="d-flex"> <span class="v" *ngIf="input.valid"> <img src="./assets/images/v.svg"> </span> <label class="mr-auto">{{label}} <span class="required" *ngIf="isRequired">*</span> </label> <span class="error" *ngIf="!input.valid">{{errorMsg}}</span> </div> </div>

Выше мы видим 4 части шаблона:

  • Поле ввода. У него будет директива ngModel
  • Знак , если ввод корректен.
  • Знак *, если ввод обязателен. 
  • Сообщение об ошибке, если ввод некорректен.

Проверяем:

<form class="form-signin" (ngSubmit)="onSubmit(f.value)" #f="ngForm"> <div class="text-center mb-4"> <h1 class="h3 mb-3 font-weight-normal">First Try</h1> </div> <app-first-custom-input [placeholder]="'Email'" [isRequired]="true" [errorMsg]="'Please enter your name'" [label] = "'User Email'" [pattern]="'[A-Za-z0-9._%-][email protected][A-Za-z0-9._%-]+\\.[a-z]{2,3}'" ngModel name="email"></app-first-custom-input> <button class="btn btn-lg btn-primary btn-block" [disabled]="!f.valid" type="submit">Sign in</button> </form>

Результат, страница First Try в примерах:

В чём ошибка? Мы прикрепили директиву формы туда, где её не должно быть. Angular не знает, что наш компонент  —  элемент управления.

Решение  —  интерфейс ControlValueAccessor:

Нам нужен ControlValueAccessor, посредник между API форм и нативными элементами. Он сообщает Angular, что элементу доступны директивы форм. У этого интерфейса 4 метода, 3 из них обязательны:

export interface ControlValueAccessor { writeValue(obj: any): void; registerOnChange(fn: any): void; registerOnTouched(fn: any): void; setDisabledState?(isDisabled: boolean): void; }

Реализуем наш элемент с его помощью:

export class GenericInputComponent implements ControlValueAccessor { @ViewChild('input') input: ElementRef; disabled; @Input() type = 'text'; @Input() isRequired: boolean = false; @Input() pattern: string = null; @Input() label: string = null; @Input() placeholder: string; @Input() errorMsg: string; writeValue(obj: any): void { this.input.nativeElement.value = obj; } registerOnChange(fn: any): void { this.onChange = fn; } registerOnTouched(fn: any): void { this.onTouched = fn; } setDisabledState?(isDisabled: boolean): void { this.disabled = isDisabled; } onChange(event) { } onTouched() { } }

Шаблон generic-input.component:

<div class="form-label-group"> <input class="form-control" [type]="type" #input (input)="onChange($event.target.value)" (blur)="onTouched()" [disabled]="disabled" [placeholder]="placeholder"> <div class="d-flex"> <span class="v" *ngIf="isRequired && input.valid && input.touched"> <img src="./assets/images/v.svg"> </span> <label class="mr-auto">{{label}} <span class="required" *ngIf="isRequired">*</span> </label> <span class="error" *ngIf="input && !input.valid && input.touched">{{errorMsg}}</span> </div> </div>

Но этого недостаточно. Мы должны указать токен NG_VALUE_ACCESSOR в метаданных компонента:

@Component({ selector: 'app-generic-input', templateUrl: './generic-input.component.html', styleUrls: ['./generic-input.component.scss'], providers: [{ provide: NG_VALUE_ACCESSOR, multi: true, useExisting: GenericInputComponent }] }) export class GenericInputComponent implements ControlValueAccessor { //... } Валидаторы

Мы создали пользовательский элемент управления, а теперь реализуем Validator для проверки ввода:

export interface Validator { validate(c: AbstractControl): ValidationErrors | null; registerOnValidatorChange?(fn: () => void): void; }

И код в GenericComponent:

export class GenericInputComponent implements ControlValueAccessor, Validator { //... validate(c: AbstractControl): ValidationErrors { const validators: ValidatorFn[] = []; if (this.isRequired) { validators.push(Validators.required); } if (this.pattern) { validators.push(Validators.pattern(this.pattern)); } return validators; } }

Не забудьте о токене NG_VALIDATORS:

@Component({ selector: 'app-generic-input', templateUrl: './generic-input.component.html', styleUrls: ['./generic-input.component.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: GenericInputComponent, multi: true }, { provide: NG_VALIDATORS, useExisting: GenericInputComponent, multi: true } ] })

Попробуем ещё раз:

Пока не видно, корректен ввод в элементе или нет.

Получение ссылки на элемент

Мы хотим показать, верен ли ввод. Но у нас нет экземпляра элемента управления. Может быть, мы можем внедрить зависимость, чтобы получить его? Да, это возможно!

constructor(@Self() public controlDir: NgControl) { this.control.valueAccessor = this; }

Важные замечания:

  • Мы внедряем NgControl, родительский для formControlName и ngModel, не связывая его с каким-либо шаблоном или реактивным модулем.
  • Декорируем его с помощью @Self(). Это гарантирует, что он не будет перезаписан деревом инжекторов.
  • Устанавливаем его valueAccessor. Он должен указывать на GenericComponent.

Обновим шаблон и используем ссылку:

<div class="form-label-group"> <input class="form-control" [type]="type" #input (input)="onChange($event.target.value)" (blur)="onTouched()" [disabled]="disabled" [placeholder]="placeholder"> <div class="d-flex"> <span class="v" *ngIf="(isRequired && controlDir && controlDir.control.valid && controlDir.control.touched)"> <img src="./assets/images/v.svg"> </span> <label class="mr-auto">{{label}} <span class="required" *ngIf="isRequired">*</span> </label> <span class="error" *ngIf="controlDir && !controlDir.control.valid && controlDir.control.touched">{{errorMsg}}</span> </div> </div>

NgControl уже предоставляет NG_VALUE_ACCESSOR и NG_VALIDATOR. Удаляем их, чтобы не возникла циклическая зависимость:

@Component({ selector: 'app-generic-input', templateUrl: './generic-input.component.html', styleUrls: ['./generic-input.component.scss'], providers: [ ] })

Также элементу управления нужны валидаторы:

export class GenericInputComponent implements ControlValueAccessor, Validator, OnInit { constructor(@Self() public controlDir: NgControl) { this.controlDir.valueAccessor = this; } ngOnInit(): void { const control = this.controlDir.control; const validators: ValidatorFn[] = control.validator ? [control.validator] : []; if (this.isRequired) { validators.push(Validators.required); } if (this.pattern) { validators.push(Validators.pattern(this.pattern)); } control.setValidators(validators); control.updateValueAndValidity(); } //...

Страница Login & Register:

Формы  —  компоненты

Проблема

Представьте безумное: вы хотите использовать одну и ту же форму в нескольких местах. Помните, как дважды приходилось заполнять форму адреса, когда платёжный адрес не совпадал с адресом доставки?

market.co.uk checkout proccess, using the same form twice

Не хочется снова и снова делать одно и то же. Мы хотим написать только одну форму и переиспользовать её. Переиспользовать? Значит, это компонент!

Снова ControlValueAccessor

Создаём AddressFormComponent:

export class AddressFormComponent implements OnInit { form: FormGroup; constructor(private formBuilder: FormBuilder) { } ngOnInit() { this.form = this.formBuilder.group({ 'firstName': [null, [Validators.required]], 'lastName': [null, [Validators.required]], 'phone': [null, null], 'street': [null, [Validators.required]], 'city': [null, [Validators.required]], 'state': [null], 'zip': [null, [Validators.required]], }); } }

Шаблон:

<form [formGroup]="form"> <div class="form-label-group"> <label>First Name*</label> <input type="text" class="form-control" name="firstName" formControlName="firstName" /> </div> <div class="form-label-group"> <label>Last Name*</label> <input type="text" class="form-control" name="lastName" formControlName="lastName" /> </div> <div class="form-label-group"> <label>Phone</label> <input type="text" class="form-control" name="phone" formControlName="phone" /> </div> <div class="form-label-group"> <label>Street*</label> <input type="text" class="form-control" name="street" formControlName="street" /> </div> <div class="form-label-group"> <label>City*</label> <input type="text" class="form-control" name="city" formControlName="city" /> </div> <div class="form-label-group"> <label>State*</label> <input type="text" class="form-control" name="state" formControlName="state" /> </div> <div class="form-label-group"> <label>Zip*</label> <input type="text" class="form-control" name="zip" formControlName="zip" /> </div> </form>

И опять ControlValueAccessor, но теперь чтобы обернуть всю форму:

export class AddressFormComponent implements OnInit, ControlValueAccessor { form: FormGroup; constructor(private formBuilder: FormBuilder) { } ngOnInit() { this.form = this.formBuilder.group({ 'firstName': [null, [Validators.required]], 'lastName': [null, [Validators.required]], 'phone': [null, null], 'street': [null, [Validators.required]], 'city': [null, [Validators.required]], 'state': [null], 'zip': [null, [Validators.required]], }); } onTouch() { } writeValue(obj: any): void { obj && this.form.setValue(obj, { emitEvent: false }); } registerOnChange(fn: any): void { this.form.valueChanges.subscribe(fn); } registerOnTouched(fn: any): void { this.onTouch = fn; } setDisabledState?(isDisabled: boolean): void { isDisabled ? this.form.disabled : this.form.enabled; } }

Не забудьте о NG_VALUE_ACCESSOR:

@Component({ selector: 'app-address-form', templateUrl: './address-form.component.html', styleUrls: ['./address-form.component.scss'], providers: [ { provide: NG_VALUE_ACCESSOR, useExisting: AddressFormComponent, multi: true } ] })

Reusable forms  —  ControlValueAccessor:

Альтернатива

Утомил ControlValueAccessor? Меня тоже. При переиспользовании всей формы можно внедрить ControlContainer:

@Component({ selector: 'app-address-form', templateUrl: './address-form.component.html', styleUrls: ['./address-form.component.scss'], viewProviders: [{ provide: ControlContainer, useExisting: FormGroupDirective }] })

Запомните: в коде viewProviders, а не providers. Причина в декораторе @Host(). Он используется при внедрении ControlContainer в FormControlName и NgModel. Проверьте директивы FormControlName и NgModel в исходниках.

После предоставления ControlContainer мы можем внедрить его в AddressFormComponent и установить форму адреса равной форме в ControlContainer:

export class AddressFormComponent implements OnInit { @Input() address: Address; form: FormGroup; constructor( private ctrlContainer: FormGroupDirective, private formBuilder: FormBuilder) { } ngOnInit() { this.form = this.ctrlContainer.form; this.form.addControl('addressForm', this.formBuilder.group({ //... })); console.log(this.form); } //... }

Reusable forms  —  SubForms:

Просто и коротко, но ограниченно

Как только мы предоставили FormGroupDirective или ngModelGroup, то создали связь только с одной реализацией форм (шаблонной или реактивной).

Демо

<figure><iframe width="700" height="376" src="/media/4ce26a2b4ab6df0109c47281ea6e86fc" allowfullscreen=""></iframe></figure>

Итоги

Вот, что мы узнали:

  • ControlValueAccessor  —  мост между нашими компонентами и API формами. Он позволяет создавать настраиваемые, переиспользуемые элементы управления и формы.
  • Внедрение зависимости поможет использовать NgControl и его valueAccessor для простого доступа к элементу в шаблоне.
  • Можно снова внедрить зависимости, чтобы добавить FormGroupDirective или ngModelGroup и создать подформу.

Перевод статьи Eliran Eliassy: Reducing the forms boilerplate  —  make your Angular forms reusable


Поделиться статьей:


Вернуться к статьям

Комментарии

    Ничего не найдено.