Несогласованная проблема валидации в пользовательском компоненте Angular
Чтобы показать пример реального мира, скажем, что мы хотим использовать @angular/material datepicker в нашем приложении.
Мы хотим использовать его на большом количестве страниц, поэтому мы хотим сделать его очень простым, чтобы добавить его в форму с одинаковой конфигурацией во всем мире. Чтобы удовлетворить эту потребность, мы создаем настраиваемый угловой компонент вокруг объекта <mat-datepicker>
с реализацией ControlValueAccessor, чтобы иметь возможность использовать [(ngModel)]
на нем.
Мы хотим обрабатывать типичные проверки в компоненте, но в то же время мы хотим сделать результат проверки доступным для внешнего компонента, который включает наш CustomDatepickerComponent
.
В качестве простого решения мы можем реализовать метод validate()
подобный этому (innerNgModel поступает из экспортированного ngModel: #innerNgModel="ngModel"
. См. Полный код в конце этого вопроса):
validate() {
return (this.innerNgModel && this.innerNgModel.errors) || null;
}
На этом этапе мы можем использовать datepicker в любом компоненте формы очень простым способом (как мы и хотели):
<custom-datepicker [(ngModel)]="myDate"></custom-datepicker>
Мы также можем расширить приведенную выше строку, чтобы иметь лучший опыт отладки (например, это):
<custom-datepicker [(ngModel)]="myDate" #date="ngModel"></custom-datepicker>
<pre>{{ date.errrors | json }}</pre>
Пока я изменяю значение в пользовательском компоненте datepicker, все работает нормально. Окружающая форма остается недействительной, если у datepicker есть какие-либо ошибки (и она становится действительной, если датапикер действителен).
НО!
Если член myDate
компонента внешней формы (тот, который передается как ngModel) изменяется внешним компонентом (например: this.myDate= null
), то происходит следующее:
-
writeValue()
для CustomDatepickerComponent и обновляет значение параметра datepicker. - Выполняется
validate()
для CustomDatepickerComponent, но в этот момент innerNgModel
не обновляется, поэтому он возвращает проверку более раннего состояния.
Чтобы решить эту проблему, мы можем исправить изменение из компонента в setTimeout:
public writeValue(data) {
this.modelValue = data ? moment(data) : null;
setTimeout(() => { this.emitChange(); }, 0);
}
В этом случае emitChange (трансляция изменения пользовательского comoponent) будет инициировать новую проверку. И из-за setTimeout он будет запущен в следующем цикле, когда будет обновлен innerNgModel.
Мой вопрос в том, что если есть лучший способ справиться с этой проблемой, чем с помощью setTimeout? И если это возможно, я буду придерживаться реализации, основанной на шаблонах.
Заранее спасибо!
Полный исходный код примера:
таможенно-datepicker.component.ts
import {Component, forwardRef, Input, ViewChild} from '@angular/core';
import {ControlValueAccessor, NG_VALIDATORS, NG_VALUE_ACCESSOR, NgModel} from '@angular/forms';
import * as moment from 'moment';
import {MatDatepicker, MatDatepickerInput, MatFormField} from '@angular/material';
import {Moment} from 'moment';
const AC_VA: any = {
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => CustomDatepickerComponent),
multi: true
};
const VALIDATORS: any = {
provide: NG_VALIDATORS,
useExisting: forwardRef(() => CustomDatepickerComponent),
multi: true,
};
const noop = (_: any) => {};
@Component({
selector: 'custom-datepicker',
templateUrl: './custom-datepicker.compnent.html',
providers: [AC_VA, VALIDATORS]
})
export class CustomDatepickerComponent implements ControlValueAccessor {
constructor() {}
@Input() required: boolean = false;
@Input() disabled: boolean = false;
@Input() min: Date = null;
@Input() max: Date = null;
@Input() label: string = null;
@Input() placeholder: string = 'Pick a date';
@ViewChild('innerNgModel') innerNgModel: NgModel;
private propagateChange = noop;
public modelChange(event) {
this.emitChange();
}
public writeValue(data) {
this.modelValue = data ? moment(data) : null;
setTimeout(() => { this.emitChange(); }, 0);
}
public emitChange() {
this.propagateChange(!this.modelValue ? null : this.modelValue.toDate());
}
public registerOnChange(fn: any) { this.propagateChange = fn; }
public registerOnTouched() {}
validate() {
return (this.innerNgModel && this.innerNgModel.errors) || null;
}
}
И шаблон (custom-datepicker.compnent.html):
<mat-form-field>
<mat-label *ngIf="label">{{ label }}</mat-label>
<input matInput
#innerNgModel="ngModel"
[matDatepicker]="#picker"
[(ngModel)]="modelValue"
(ngModelChange)="modelChange($event)"
[disabled]="disabled"
[required]="required"
[placeholder]="placeholder"
[min]="min"
[max]="max">
<mat-datepicker-toggle matSuffix [for]="picker"></mat-datepicker-toggle>
<mat-datepicker #picker></mat-datepicker>
<mat-error *ngIf="innerNgModel?.errors?.required">This field is required!</mat-error>
<mat-error *ngIf="innerNgModel?.errors?.matDatepickerMin">Date is too early!</mat-error>
<mat-error *ngIf="innerNgModel?.errors?.matDatepickerMax">Date is too late!</mat-error>
</mat-form-field>
Окружающий микромодуль (custom-datepicker.module.ts):
import {NgModule} from '@angular/core';
import {FormsModule} from '@angular/forms';
import {MatDatepickerModule, MatFormFieldModule, MatInputModule, MAT_DATE_LOCALE, MAT_DATE_FORMATS} from '@angular/material';
import {CustomDatepickerComponent} from './custom-datepicker.component';
import {MAT_MOMENT_DATE_ADAPTER_OPTIONS, MatMomentDateModule} from '@angular/material-moment-adapter';
import {CommonModule} from '@angular/common';
const DATE_FORMATS = {
parse: {dateInput: 'YYYY MM DD'},
display: {dateInput: 'YYYY.MM.DD', monthYearLabel: 'MMM YYYY', dateA11yLabel: 'LL', monthYearA11yLabel: 'MMMM YYYY'}
};
@NgModule({
imports: [
CommonModule,
FormsModule,
MatMomentDateModule,
MatFormFieldModule,
MatInputModule,
MatDatepickerModule
],
declarations: [
CustomDatepickerComponent
],
exports: [
CustomDatepickerComponent
],
providers: [
{provide: MAT_DATE_LOCALE, useValue: 'es-ES'},
{provide: MAT_DATE_FORMATS, useValue: DATE_FORMATS},
{provide: MAT_MOMENT_DATE_ADAPTER_OPTIONS, useValue: {useUtc: false}}
]
})
export class CustomDatepickerModule {}
И части внешнего компонента формы:
<form #outerForm="ngForm" (ngSubmit)="submitForm(outerForm)">
...
<custom-datepicker [(ngModel)]="myDate" #date="ngModel"></custom-datepicker>
<pre>{{ date.errors | json }}</pre>
<button (click)="myDate = null">set2null</button>
...
Ответы
Ответ 1
Я столкнулся с той же задачей, и я принял другой подход в обработке привязки и изменения локальной модели.
Вместо разделения и ручной установки обратного вызова ngModelChange
, я спрятал свою локальную переменную за парой getter\setters, где вызывается мой обратный вызов.
В вашем случае код будет выглядеть так:
в custom-datepicker.component.html
:
<input matInput
#innerNgModel="ngModel"
[matDatepicker]="#picker"
[(ngModel)]="modelValue"
[disabled]="disabled"
[required]="required"
[placeholder]="placeholder"
[min]="min"
[max]="max">
в то время как в custom-datepicker.component.ts
:
get modelValue(){
return this._modelValue;
}
set modelValue(newValue){
if(this._modelValue != newValue){
this._modelValue = newValue;
this.emitChange();
}
}
public writeValue(data) {
this.modelValue = data ? moment(data) : null;
}
Вы можете увидеть фактический компонент в https://github.com/cdigruttola/GestioneTessere/tree/master/Server/frontend/src/app/viewedit
Я не знаю, будет ли это иметь значение, но я не видел проблем с обработкой валидации во время тестирования приложения, и фактические пользователи не сообщили мне ни об одном.