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



Книга Переиспользование форм в 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 и создать подформу.

610   0  

Comments

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