Проект в 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._%-]+@[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

Предыдущая статьяСумма экспоненциальных случайных величин
Следующая статьяЛучшие практики Python для специалистов по обработке данных