Несогласованная проблема проверки в пользовательском компоненте Angular

Чтобы показать своего рода пример из реального мира, предположим, что мы хотим использовать datepicker @ angular / material в нашем приложении.

Мы хотим использовать его на многих страницах, поэтому мы хотим упростить добавление его в форму с одинаковой конфигурацией повсюду. Чтобы удовлетворить эту потребность, мы создаем пользовательский компонент angular вокруг <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 есть какие-либо ошибки (и она становится действительной, если datepicker действителен).

НО!

Если член myDate внешнего компонента формы (тот, который передается как ngModel) изменяется внешним компонентом (например: this.myDate= null), происходит следующее:

  1. writeValue() компонента CustomDatepickerComponent запускается и обновляет значение datepicker.
  2. validate() компонента CustomDatepickerComponent выполняется, но на этом этапе innerNgModel не обновляется, поэтому он возвращает подтверждение более раннего состояния.

Чтобы решить эту проблему, мы можем выдать изменение из компонента в setTimeout:

public writeValue(data) {
    this.modelValue = data ? moment(data) : null;
    setTimeout(() => { this.emitChange(); }, 0);
}

В этом случае emitChange (транслирует изменение настраиваемого компонента) запускает новую проверку. И из-за setTimeout он будет запущен в следующем цикле, когда innerNgModel уже обновлен.


У меня вопрос: есть ли лучший способ справиться с этой проблемой, чем использование setTimeout? И, если возможно, я бы придерживался реализации на основе шаблонов.

Заранее спасибо!


Полный исходный код примера:

custom-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>
    ...

person Burnee    schedule 13.09.2018    source источник
comment
Я не собираюсь ставить это в качестве ответа, потому что в настоящее время я работаю над решением этой проблемы с синхронизацией, но это еще не закончено. Что я делаю, так это создаю сеттер, привязанный к статусу используемого события, так что, когда событие происходит, сеттер запускается для очистки / проверки.   -  person    schedule 20.09.2018
comment
Я бы подумал об использовании formcontrol вместо модели ng. angular.io/api/forms/FormControl   -  person Sjoerd de Wit    schedule 20.09.2018
comment
Мне не кажется необходимым переносить datepicker в другой компонент. Вы экономите пару байтов, но теряете гибкость и усложняете. Вы можете обернуть свои сообщения об ошибках в какой-нибудь компонент, и этого будет достаточно ...   -  person smnbbrv    schedule 20.09.2018
comment
Я знаю, что это возможно с реактивной формой. Мой вопрос касается реализации формы на основе шаблонов. @smnbbrv: упаковка datepicker - это не экономия байтов. Помимо этого примера, существует более сложная реализация настраиваемого раздела формы datepicker. Это очень важное преимущество, заключающееся в том, что мы можем просто использовать одну и ту же реализацию во всех формах надежного приложения. Копировать и вставлять эту реализацию в каждую форму было бы очень плохой практикой.   -  person Burnee    schedule 26.09.2018
comment
@Burnee, вы не предоставили эту более сложную реализацию. Я вижу только немного HTML, добавленного поверх простейшей формы datepicker (в частности, я вижу только сообщения об ошибках как повторно используемые). Если ваша реализация достаточно сложна, вам следует создать настраиваемое поле формы, см. material.angular.io/guide/creating-a-custom-form-field-control   -  person smnbbrv    schedule 26.09.2018
comment
Вы правы, я этого не предоставлял. Но я специально пропустил это, чтобы упростить пример. Написание материального элемента управления полем настраиваемой формы также может быть хорошим ответом, но мой вопрос хотел быть более общим. Mat-datepicker может быть любым сторонним компонентом, реализующим ControlValueAccessor. Извините, если вопрос был непонятен!   -  person Burnee    schedule 26.09.2018
comment
Вы должны попробовать с помощью событий Input и Ouput из своего пользовательского элемента управления удалить вашу ngModel, например, ‹my-control [mydata] = myDate (change) = updateDate ($ event)› ‹/my-control›. Позвольте мне знать, если это помогает.   -  person Narendra Singh Rathore    schedule 13.11.2018
comment
Не могли бы вы создать минимальный воспроизводимый пример с увиденным поведением? Хотя я понимаю, что вы имеете в виду, было бы полезно попытаться определить местонахождение проблемы. Я сделал то же самое, но немного по-другому, и я хочу увидеть, страдает ли моя реализация той же проблемой, а если нет, предложить решение для вас.   -  person bracco23    schedule 19.03.2019


Ответы (1)


Я столкнулся с той же задачей, и я применил другой подход к обработке привязки и изменения локальной модели.

Вместо того, чтобы разделять и вручную устанавливать обратный вызов ngModelChange, я спрятал свою локальную переменную за парой геттер \ сеттеров, где вызывается мой обратный вызов.

В вашем случае код будет выглядеть так:

in 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.

Я не знаю, будет ли это иметь значение, но я не видел проблем с обработкой проверки, пока я тестировал приложение, и фактические пользователи не сообщили мне ни о чем.

person bracco23    schedule 05.04.2019