Search code examples
angularangular-materialangular-reactive-formscontrolvalueaccessormat-datepicker

Mat-Datepicker custom component ExpressionChangedAfterItHasBeenChecked Error


I am making a custom datepicker component so it can work with localization and moment.js, and also with reactive forms.

I have set up the component but after DateChange event, after it exits the zone, it throws an error:

ERROR Error: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'ng-valid: true'. Current value: 'ng-valid: false'.

At this moment I am out of the ideas how to fix it though I tried to fix it with ChangeDetectorRef which makes my validation untriggerable, and similar stuff happens with this.control.updateValueAndValidty().

After all, it happens a lot later than I can trigger anything. I suppose it's input element problem, so I'll try to catch its change event in the meantime.

So this is date-picker.component.ts:

import { MomentDateAdapter } from '@angular/material-moment-adapter';
import { Component, OnInit, forwardRef, Input, Self, Optional, ChangeDetectorRef } from '@angular/core';
import { ControlValueAccessor, NG_VALUE_ACCESSOR, Validator, NG_VALIDATORS, FormControl, NgControl, FormControlName } from '@angular/forms';
import * as _moment from 'moment';
import { MatDatepickerInputEvent, MAT_DATE_FORMATS, DateAdapter, MAT_DATE_LOCALE } from '@angular/material';
import { default as _rollupMoment } from 'moment';
const moment = _rollupMoment || _moment;

export const DATE_FORMATS = {
  parse: {
    dateInput: 'DD.MM.YYYY.',
  },
  display: {
    dateInput: 'DD.MM.YYYY.',
    monthYearLabel: 'MMM YYYY',
    dateA11yLabel: 'LL',
    monthYearA11yLabel: 'MMMM YYYY',
  },
};


@Component({
  selector: 'app-date-picker',
  templateUrl: './date-picker.component.html',
  styleUrls: ['./date-picker.component.scss'],
  providers: [
    { provide: MAT_DATE_LOCALE, useValue: 'sr' },
    { provide: DateAdapter, useClass: MomentDateAdapter, deps: [MAT_DATE_LOCALE] },
    { provide: MAT_DATE_FORMATS, useValue: DATE_FORMATS }
  ]
})
export class DatePickerComponent implements ControlValueAccessor {
  control: FormControl;
  private onChange = (value: any) => { };
  private onTouched = (value: any) => { };
  @Input() dateValue: string = null;
  @Input() errorMessage: string = "This is required.";
  @Input() public placeholder: string = null;
  @Input() public minDate: string = null;
  @Input() public maxDate: string = null;
  @Input() public startDate: string = null;
  @Input() public label: string = null;

  constructor(@Optional() @Self() private ngControl: NgControl, @Optional() private controlName: FormControlName, private cdr: ChangeDetectorRef) {
    if(this.ngControl != null) {
      this.ngControl.valueAccessor = this;
    }
  }

  ngOnInit() {
    moment.locale('sr');
    this.control = this.controlName.control;
  }

  onDateChange(event: MatDatepickerInputEvent<Date>) {
    this.dateValue = moment(event.value).format();
    this.onChange(event.value);
  }

  // get errorState() {
  //   debugger;

  //   if(!this.dateValue){
  //     return this.ngControl.errors !== null && !!this.ngControl.touched;
  //   }
  //   return false;
  // }

  // ControlValueAccessor methods
  writeValue(value: any): void {
    if(value !== undefined || value !== '') {
      this.dateValue = moment(value).format();
    }
  }

  registerOnChange(fn) {
    this.onChange = fn;
  }

  registerOnTouched() {}
  // registerOnTouched(fn) {
  //   this.onTouched = fn;
  // }


}

And this is date-picker.component.html:

<div class="date-picker">
    <mat-form-field class="full-width">

      <mat-label>{{ label }}</mat-label>
      <input #dpInput matInput
        [matDatepicker]="componentDatePicker"
        (dateChange)="onDateChange($event)"
        [formControl]="control"
        [value]="dateValue"
        [min]="minDate"
        [max]="maxDate"
        [placeholder]="placeholder"
        [formControl]='control'>

      <mat-datepicker-toggle matSuffix [for]="componentDatePicker"></mat-datepicker-toggle>
      <mat-datepicker #componentDatePicker></mat-datepicker>
      <mat-error [innerHTML]="errorMessage"></mat-error>

    </mat-form-field>
</div>

And I am changing the min and max values in the parent component by returning the new Date(year, month, day) object with values or null. There are multiple instances of the same picker so I'm sending the string value to differentiate between them.

<app-date-picker
   [minDate]="getMinDate('datepickerIdentifier')"
   [maxDate]="getMaxDate('datepickerIdentifier')"
   [placeholder]="'Placeholder string'"
   [label]="'Label string'"
   [errorMessage]="'Error string'"
   formControlName="applicationEnd"
   required>
 </app-date-picker>

I was trying to build reusable datepicker component which will have localization, specific date format, and that it can be easily incorporated in the reactive forms so i can validate it with the rest of the form data, and show Validation.required errors.

Unfortunately I have no idea where to begin to debug this.

EDIT: I tried to make a stackblitz but it kinda gives me lot of errors but nonetheless here it is: https://stackblitz.com/edit/angular-aylptw


Solution

  • I have achieved it

    This ExpressionChangedAfterItHasBeenChecked is thrown when an expression in your HTML has changed after Angular has already checked or compared oldValue !== newValue. Make your change detection strategy to onPush like the following way and trigger the manual change detection just like explained in the last code snippet.

    @Component({
                       ...                   
      changeDetection: ChangeDetectionStrategy.OnPush
    })
    

    You are following the Angular material documentation, it is good.

    import { MatDateFormats } from '@angular/material/core'; //import MatDateFormats 
    
    export const APP_DATE_FORMATS: MatDateFormats = {
    parse: {
      dateInput: 'MM/DD/YYYY', //whichever format you are tyring, define here
    },
    display: {
      dateInput: 'MM/DD/YYYY', //change accordingly, to display
      monthYearLabel: 'MMM YYYY',
      dateA11yLabel: 'LL',
      monthYearA11yLabel: 'MMMM YYYY',
      }
    };
        
    

    Please try to create the above code in a different file, it's easier to maintain.

    import { DateAdapter, MAT_DATE_FORMATS, MAT_DATE_LOCALE } from '@angular/material/core';
    import { APP_DATE_FORMATS } from '../../helpers/datepicker.format';
    import { MomentDateAdapter, MAT_MOMENT_DATE_ADAPTER_OPTIONS } from '@angular/material-moment-adapter'; 
    //register the imports respectively which are going to be addressed in providers
    
        
    providers: [
      { provide: DateAdapter, useClass: MomentDateAdapter },
      {
        provide: MAT_DATE_FORMATS, useValue: APP_DATE_FORMATS,
        deps: [
         MAT_DATE_LOCALE,
         MAT_MOMENT_DATE_ADAPTER_OPTIONS
        ]
      }
     ]
    

    Update your respective module providers as above and do your reactive form validations, If you encounter Error: ExpressionChangedAfterItHasBeenChecked then define the following ChangeDetectorRef from `angular/core to solve in the respective component. Thanks.

    constructor(private _cdr: ChangeDetectorRef) {}
            
    insideYourValidations() {
     setTimeout(() => {
      this._cdr.detectChanges() //call to update/detect your changes
     }, 1500);
    }
    

    Sometimes when we get the ExpressionchangeError the datepicker might not update the date value, with the ChangeDetectorRef the form input values are updated if user tries to input manually without using datepicker.

    This helped me, fix my problem. zmag Thanks