25

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>
    ...
Burnee
  • 2,453
  • 1
  • 24
  • 28
  • I'm not going to put this as an answer, because I'm currently working on a solution for this timing problem, but it's not finished. What I'm doing is creating a setter hooked into the status of the event that is being used, so that when the event occurs the setter is triggered to do the clean up/validation. –  Sep 20 '18 at 14:57
  • 6
    I would consider using formcontrol instead of ng model. https://angular.io/api/forms/FormControl – Sjoerd de Wit Sep 20 '18 at 15:00
  • 1
    It does not seem to me it is necessary to wrap datepicker into another component. You save couple of bytes but you lose flexibility and add complexity. You can wrap your error messages into some component and this would be just enough... – smnbbrv Sep 20 '18 at 15:41
  • 2
    I know that it's possible w/ reactive form. My question is about template driven form implementation. @smnbbrv: wrapping the datepicker isn't about saving bytes. Beyond this example there is a more complex implementation of custom datepicker form section. It's a very important advantage of it that we can simply use the same implementation in all the forms of a robust application. Copy-paste-ing this implementation into every form would be a very bad practice. – Burnee Sep 26 '18 at 17:51
  • @Burnee you did not provide this more complex implementation. What I see is only a bit of HTML added on top of the simplest shape of datepicker (I only see error messages as a reusable in particular). If your implementation is complex enough you should create a custom form field, see https://material.angular.io/guide/creating-a-custom-form-field-control – smnbbrv Sep 26 '18 at 18:49
  • 1
    You're right, I didn't provide it. But I skipped it on purpose to make the example more simple. To write a material custom form field control could be also a good answer, but my question wanted to be more general. The mat-datepicker could be any kind of 3rd party component that implements ControlValueAccessor. Sorry if it wasn't clear in the question! – Burnee Sep 26 '18 at 18:56
  • You should try with Input and Ouput event from your custom control remove your ngModel for e.g . Let me know if this helps. – Narendra Singh Rathore Nov 13 '18 at 12:37
  • Could you please create a [MCVE] with the seen behavior? While I can see what you mean, it would help to try to locate the problem. I did a similar thing in a slightly different way and I want to see if my implementation suffer from the same problem, and if not, suggest a solution for you. – bracco23 Mar 19 '19 at 11:38

1 Answers1

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.

bracco23
  • 2,181
  • 10
  • 28