Создание форм собственных полей в стиле Angular с помощью средств доступа к контрольным значениям
Формы Angular являются мощными и, как известно, могут использоваться таким образом, чтобы значительно упростить ваши собственные формы, а также повысить возможность повторного использования ваших компонентов.
Мы увидим, как реализовать нашу собственную форму с нашим собственным настраиваемым полем и посмотрим, как мы можем получить его значение.
В данной статье мы рассмотрим сценарий, в которой мы будем создавать форму рекламации.
На нашей странице клиент сможет указать свой идентификатор счета, который имеет некоторую проверку и логику, например:
- prefix - поскольку все наши идентификаторы счетов начинаются с
INV-
- length - наш идентификатор счет-фактуры имеет длину не более 8 символов
Создание нашей начальной формы
Давайте напишем нашу первую версию только с реактивными формами.
Вам понадобится FormGroup
, которая оборачивает наше поле invoiceId
:
@Component({
selector: 'app-root',
templateUrl: './app.component.html'
})
export class AppComponent {
form: FormGroup<{ invoiceId: FormControl<string | null> }>;
constructor(fb: FormBuilder) {
this.form = fb.group({
invoiceId: ['', [Validators.pattern(/^INV-.*/), Validators.maxLength(8)]],
});
}
}
и наш связанный
<h1>Invoice reclamation</h1>
<form [formGroup]="form">
<label for="invoice-id">Invoice ID: </label>
<input
type="text"
formControlName="invoiceId"
id="invoice-id"
placeholder="INV-xxxx"
/>
<button type="submit" [disabled]="!form.valid">Send</button>
</form>
Если вы хотите увидеть, что происходит внутри, вы можете добавить этот фрагмент кода в конец вашего шаблона:
<hr />
<p>Value:</p>
<pre>{{ form.value | json }}</pre>
<p>Errors:</p>
<pre>{{ form.controls.invoiceId.errors | json }}</pre>
Если все идет нормально, то
Расширение возможностей на полях
Возможно, мы захотим помочь пользователю, заполнившему эту форму.
Например, если бы мы сами могли указать prefix, это было бы прекрасно. Лучшее, что мы могли бы сделать, это показать пользователю тему его же счета, когда он будет вводить его, предлагая ему некоторый контекст.
Давайте немного изменим наш код, чтобы добиться этого:
@Component({
selector: 'app-root',
templateUrl: './app.component.html'
})
export class AppComponent {
form: FormGroup<{ invoiceId: FormControl<string | null> }>;
constructor(fb: FormBuilder) {
this.form = fb.group({
invoiceId: ['', Validators.maxLength(4)],
});
}
}
<h1>Invoice reclamation</h1>
<form [formGroup]="form">
<label for="invoice-id">Invoice ID: </label>
<span style="font-family: 'Courier New', Courier, monospace">INV-</span>
<input
type="text"
formControlName="invoiceId"
id="invoice-id"
placeholder="xxxx"
/>
<p *ngIf="form.controls.invoiceId.value && form.controls.invoiceId.valid">INVOICE n°...</p>
<button type="submit" [disabled]="!form.valid">Send</button>
</form>
Это немного лучше для пользователя:
Однако для нас это не очень.
Делая это:
- Внесла больше сложностей в нашу форму, даже если это поведение связано только с определенным полем
- Мы изменили ожидаемое значение нашего поля
invoiceId
: если кто-то, кто имеет дело с ним в другом месте, привык видеть, что оно начинается сINV-
, и он, вероятно, с первого взгляда не поймет, почему мы не применяем его в наших валидаторах.
К счастью, Angular предлагает разработчикам возможность писать свои собственные поля формы и инкапсулировать их, давайте именно это мы и сделаем.
Средства доступа к управляющему значению
Интерфейс ControlValueAccessor
— очень удобный интерфейс, который помогает нам писать поля формы.
Он предоставляет 4 метода (в том числе с дополнительным):
writeValue(obj: any): void
Вызывается всякий раз, когда новое значение предоставляется вашему полю из API форм;registerOnChange(fn: any): void
Предоставляет функцию, которую нам нужно будет вызывать, когда мы будем вносить какие-либо изменения в наше поле;registerOnTouched(fn: any): void
То же, что и предыдущий, но для поведения сенсорной (touched
) панели;setDisabledState(isDisabled: boolean)?: void
Вызывается всякий раз, когда форма API сообщает нашему полю, что отключенное состояние изменилось и мы могли применить его к нашему полю.
Создание нового компонента
В новый компонент InvoiceIdFormField
, который будет содержать наше поле, переместим нашу логику.
Мы просто заменим всю форму простым FormControl
, поскольку здесь будем манипулировать только идентификатором счета:
@Component({
selector: 'app-invoice-id-form-field',
templateUrl: './invoice-id-form-field.component.html',
})
export class InvoiceIdFormFieldComponent {
readonly invoiceIdControl: FormControl<string | null>;
constructor() {
this.invoiceIdControl = new FormControl<string | null>(
null,
Validators.maxLength(4)
);
}
}
<label for="invoice-id">Invoice ID: </label>
<span style="font-family: 'Courier New', Courier, monospace">INV-</span>
<input
type="text"
[formControl]="invoiceIdControl"
id="invoice-id"
placeholder="xxxx"
/>
<p *ngIf="invoiceIdControl.value && invoiceIdControl.valid">INVOICE n°...</p>
Реализация ControlValueAccessor
Теперь, когда наш компонент создан, мы можем реализовать наш интерфейс:
@Component({
selector: 'app-invoice-id-form-field',
templateUrl: './invoice-id-form-field.component.html',
})
export class InvoiceIdFormFieldComponent implements ControlValueAccessor {
+ private _onChange: (invoiceId: string | null) => any = noop;
+ private _onTouched: () => any = noop;
readonly invoiceIdControl: FormControl<string | null>;
constructor() {
this.invoiceIdControl = new FormControl<string | null>(
null,
Validators.maxLength(4)
);
}
+ writeValue(invoiceId: string | null): void {
+ this.invoiceIdControl.setValue(invoiceId);
+ }
+ registerOnChange(fn: any): void {
+ this._onChange = fn;
+ }
+ registerOnTouched(fn: any): void {
+ this._onTouched = fn;
+ }
+ setDisabledState?(isDisabled: boolean): void {
+ isDisabled
+ ? this.invoiceIdControl.disable()
+ : this.invoiceIdControl.enable();
+ }
}
Поскольку все готово, для того, чтобы Angular принял наш компонент в качестве поля, нам нужно распространить значение, когда оно установлено:
export class InvoiceIdFormFieldComponent implements OnDestroy, ControlValueAccessor {
// ...
private readonly _componentDestroyed$ = new Subject();
constructor() {
this.invoiceIdControl = new FormControl<string | null>(
null,
Validators.maxLength(4)
);
this.invoiceIdControl.valueChanges.pipe(
map(invoiceId => (invoiceId ? 'INV-' : '') + invoiceId),
takeUntil(this._componentDestroyed$),
).subscribe(invoiceId => {
this._onChange(invoiceId);
this._onTouched();
});
}
ngOnDestroy(): void {
this._componentDestroyed$.next('');
this._componentDestroyed$.complete();
}
// ...
Поскольку мы манипулируем нашим значением внутренне теперь мы можем сами добавлять prefix INV-
И последнее, но не менее важное: теперь мы должны указать API формы Angular, как использовать наш компонент в качестве поля:
@Component({
selector: 'app-invoice-id-form-field',
templateUrl: './invoice-id-form-field.component.html',
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => InvoiceIdFormFieldComponent),
+ multi: true
+ }
+ ]
})
Использование нашего поля формы
Когда поле и его логика инкапсулированы, мы можем использовать его из нашей основной формы.
Начнем с упрощения валидаторов:
@Component({
selector: 'app-root',
templateUrl: './app.component.html',
})
export class AppComponent {
form: FormGroup<{ invoiceId: FormControl<string | null> }>;
constructor(fb: FormBuilder) {
this.form = fb.group({ invoiceId: '' });
}
}
И тогда мы можем легко заменить наш предыдущий шаблон просто нашим полем:
<h1>Invoice reclamation</h1>
<form [formGroup]="form">
<app-invoice-id-form-field
formControlName="invoiceId"
></app-invoice-id-form-field>
<button type="submit" [disabled]="!form.valid">Send</button>
</form>
Давайте проверим:
Мы видим, что наш компонент распознает Angular как поле формы и может быть привязан к нашему значению формы.
Даже если мы используемFormGroup
, вы также можете использовать[(ngModel)]
, если у вас есть только одно значение, ничего менять не нужно.
Распостранение ошибки
Это здорово, однако вы, возможно, заметили, что сбой валидатора нашего invoiceIdControl
не распостраняет ошибку на энглобационную форму.
Это на удивление сложнее, чем то, чего мы достигли до сих пор, и нам нужно будет изменить способ, которым определяется наш метод доступа к управляющему значению, чтобы сделать это.
Чтобы это сработало, нам нужно будет получить NgControl
Angular, который использует для этого компонент, перехватив его с помощью внедрения зависимости и перенаправив его метод доступа к самому компоненту:
constructor(
+ @Self()
+ private readonly control: NgControl
) {
+ this.control.valueAccessor = this;
this.invoiceIdControl = new FormControl<string | null>(
null,
- Validators.maxLength(4)
+ Validators.maxLength(8)
);
this.invoiceIdControl.valueChanges
.pipe(
map((invoiceId) => (invoiceId ? 'INV-' : '') + invoiceId),
takeUntil(this._componentDestroyed$)
)
.subscribe((invoiceId) => {
this._onChange(invoiceId);
this._onTouched();
});
}
Однако при этом компонент будет внедрять себя в свой конструктор, что приведет к циклической зависимости. Чтобы сломать его, нам придется удалить его из провайдера NG_VALUE_ACCESSOR
:
@Component({
selector: 'app-invoice-id-form-field',
templateUrl: './invoice-id-form-field.component.html',
- providers: [
- {
- provide: NG_VALUE_ACCESSOR,
- useExisting: forwardRef(() => InvoiceIdFormFieldComponent),
- multi: true,
- },
- ],
})
Наконец, нам нужно будет назначить валидаторы захваченного элемента управления тем, которые используются компонентом внутри:
// ...
ngOnInit(): void { this.control.control?.setValidators([this.invoiceIdControl.validator!]);
this.control.control?.updateValueAndValidity();
}
// ...,
Теперь Angular может взаимодействовать с элементом управления, чтобы получить его значения и ошибки:
Заключение
В этой статье мы изучили:
- Как извлечь из формы логику и шаблон, привязанный к определенному полю;
- Как заставить компонент действовать как поле формы для API Angular form;
- Как перенести ошибки из нашего пользовательского поля формы в основную форму.
Извлечение настраиваемых полей формы действительно может помочь вам сохранить чистоту и простоту кодовой базы. Таким образом, вы получите более четкие формы, которые не связаны с дополнительной сложностью каждого поля, и компоненты, которые четко имеют дело только с тем, что они должны делать.
Наш пример довольно прост, но это также может улучшить взаимодействие с пользователем, если вы правильно спроектируете свое поле. Например, для этого счета мы могли бы добавить ссылку на внешний сервис при идентификации счета, его предварительный просмотр в формате PDF или дополнительную проверку, чтобы помочь пользователю использовать это поле: использование специального компонента поможет вам раскрыть всю мощь Angular внутри него.
Однако имейте в виду, что это все еще поле формы, и вы должны быть в состоянии установить границы: сделать все полем формы может быть так же болезненно, как и не иметь его, поскольку вы будете иметь дело с множеством новых слоев.