Inconsistent validation issue in Angular custom co

2020-05-30 10:00发布

问题:

To show a kind of real world example, let's say that we want to use the @angular/material's datepicker in our application.

We want to use it on a lot of pages, so we want to make it very easy to add it to a form with the same configuration everywhere. To fulfill this need, we create a custom angular component around a <mat-datepicker> with ControlValueAccessor implementation to be able to use [(ngModel)] on it.

We want to handle the typical validations in the component, but in the same time, we want to make the result of the validation available for the outer component that includes our CustomDatepickerComponent.

As an easy solution, we can implement the validate() method like this (innerNgModel comes from exported ngModel: #innerNgModel="ngModel". See full code at the end of this question):

validate() {
    return (this.innerNgModel && this.innerNgModel.errors) || null;
}

At this point we can use a datepicker in any form component in a very simple way (as we wanted):

<custom-datepicker [(ngModel)]="myDate"></custom-datepicker>

We can also extend the above line to have a better debug experience (like this):

<custom-datepicker [(ngModel)]="myDate" #date="ngModel"></custom-datepicker>
<pre>{{ date.errrors | json }}</pre>

As long as I'm changing the value in the custom datepicker component, everything works fine. The surrounding form remains invalid if the datepicker has any errors (and it becomes valid if the datepicker is valid).

BUT!

If the myDate member of the outer form component (the one is passed as ngModel) is changed by the outer component (like: this.myDate= null), then the following happens:

  1. The writeValue() of the CustomDatepickerComponent runs, and it updates the value of the datepicker.
  2. The validate() of the CustomDatepickerComponent runs, but at this point the innerNgModel is not updated so it returns the validation of an earlier state.

To solve this issue, we can emit a change from the component in a setTimeout:

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

In this case, the emitChange (broadcasts change of the custom comoponent) is going to trigger a new validation. And because of the setTimeout, it is going to run in the next cycle when the innerNgModel is updated already.


My question is that if there is any better way to handle this issue than using setTimeout? And if possible, I would stick to template driven implementation.

Thanks in advance!


Full source code of the example:

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;
    }

}

And the template (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>

The surrounding micro-module (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 {}

And parts of the outer form component:

<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:

I have faced the same task and I have taken a different approach in handling binding and change of the local model.

Instead of separating and manually setting an ngModelChange callback, I have hidden my local variable behind a pair of getter\setters, where my callback is called.

In your case, the code would look like this:

in custom-datepicker.component.html:

<input matInput
        #innerNgModel="ngModel"
        [matDatepicker]="#picker"
        [(ngModel)]="modelValue"
        [disabled]="disabled"
        [required]="required"
        [placeholder]="placeholder"
        [min]="min"
        [max]="max">

while in 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;
  }

You can see the actual component in https://github.com/cdigruttola/GestioneTessere/tree/master/Server/frontend/src/app/viewedit

I don't know if it will make a difference, but I have seen no problem in validation handling while I was testing the application and none has been reported to me by the actual users.