angularangular-materialangular-reactive-formsangular-formscontrolvalueaccessor

How to merge validation error of matDatepicker and parent form


I'm currently building custom month picker component using the Datepicker from angular material library.The month picker should be able to use with reactive form like this.

app.component.ts

  form = this.fb.group({
    startMonth: [<Moment | null>null, [Validators.required]],
  });

app.component.html

<form [formGroup]="form" novalidate (ngSubmit)="onSubmit()">
  <app-month-picker-control 
     cssClass="w-100" 
     formControlName="startMonth"
     appearance="outline"
     label="start month" placeholder="please select month">
  </app-month-picker-control>
</form>

The following is the html of the month picker.

month-picker.component.ts

<mat-form-field [appearance]="appearance" [class]="cssClass">
  <mat-label>{{ label }}</mat-label>
  <input
    matInput
    [matDatepicker]="dp"
    [formControl]="date"
    [placeholder]="placeholder"
    (dateChange)="dateChangeHandler($event)"
  />
  <mat-hint>YYYY/MM</mat-hint>
  <mat-datepicker-toggle matIconSuffix [for]="dp"></mat-datepicker-toggle>
  <mat-datepicker
    #dp
    startView="multi-year"
    (monthSelected)="setMonthAndYear($event, dp)"
    panelClass="month-picker"
  >
  </mat-datepicker>
  @if(date.hasError('matDatepickerMax')) {
  <mat-error>Please enter the right format</mat-error>
  }

  <mat-error>the start month is required</mat-error>
</mat-form-field>

since the matDatepicker has it own built in validation (like "matDatepickerParse" error whenever the input format is wrong) I need to show error message for that.

the component should be in error state and show the correspond error message whenever the build in validation error happen or the validation set on the parent form.

here is the stackblitz example

How do show the "the start month is required" message only when the binding control has the required error and set the error state of the input so?

or maybe should propagate the matDatepickerParse error to parent form?


Solution

  • You need to make the following corrections to your code:

    To access the parent control, which contains the required validation, we can use this code block.

    We first access the ControlContainer which contains the formGroup inside the control property, then we get the attribute formcontrolname which is out form control name, using this we can get the parent form control.

    constructor(
      private controlContainer: ControlContainer,
      private element: ElementRef
    ) {}
    
    ngOnInit() {
      const formControlName =
        this.element.nativeElement.getAttribute('formcontrolname');
      console.log(this.controlContainer, formControlName);
      this.control = this.controlContainer?.control?.get(formControlName) || null;
      console.log(this.controlContainer.control, this.control);
      this.matcher = new MyErrorStateMatcher(this.control);
    }
    

    Instead of propagating the material validation matDatepickerParse to the root element, just display the error message as shown below. If there is a parse error, the control is automatically made empty so there is no need to propogate it.

    @if(date?.errors?.['matDatepickerParse']) {
      <mat-error>Please enter the right format</mat-error>
    } @if(control?.errors?.['required']) {
      <mat-error>the start month is required</mat-error>
    }
    

    The main fix, is the use of errorStateMatcher to display the red border when either your inner form control, or the outer form control (obtained using control container) have validation issues.

    export class MyErrorStateMatcher implements ErrorStateMatcher {
      constructor(private outerControl: AbstractControl<any, any> | null = null) {}
      isErrorState(
        control: FormControl | null,
        form: FormGroupDirective | NgForm | null
      ): boolean {
        console.log(control);
        const isSubmitted = form && form.submitted;
        return (
          !!(
            control &&
            control.invalid &&
            (control.dirty || control.touched || isSubmitted)
          ) ||
          !!(
            this.outerControl &&
            this.outerControl.invalid &&
            (this.outerControl.dirty || this.outerControl.touched || isSubmitted)
          )
        );
      }
    }
    

    Then we map this to a property on the component.

    export class MonthPickerControlComponent implements ControlValueAccessor {
      matcher = new MyErrorStateMatcher();
    

    Finally we use it on the HTML.

    Full Code:

    TS:

    import {
      ChangeDetectionStrategy,
      Component,
      forwardRef,
      Injectable,
      Input,
      Optional,
      ViewEncapsulation,
      Host,
      SkipSelf,
      ElementRef,
    } from '@angular/core';
    import {
      AbstractControl,
      ControlContainer,
      ControlValueAccessor,
      FormControl,
      FormGroupDirective,
      FormsModule,
      NG_VALIDATORS,
      NG_VALUE_ACCESSOR,
      NgControl,
      NgForm,
      ReactiveFormsModule,
      ValidationErrors,
      Validator,
    } from '@angular/forms';
    import {
      MatDatepicker,
      MatDatepickerInputEvent,
      MatDatepickerModule,
    } from '@angular/material/datepicker';
    
    // Depending on whether rollup is used, moment needs to be imported differently.
    // Since Moment.js doesn't have a default export, we normally need to import using the `* as`
    // syntax. However, rollup creates a synthetic default module and we thus need to import it using
    // the `default as` syntax.
    import * as _moment from 'moment';
    // tslint:disable-next-line:no-duplicate-imports
    import { default as _rollupMoment, Moment } from 'moment';
    import { MatInputModule } from '@angular/material/input';
    import { CommonModule } from '@angular/common';
    import {
      MatFormFieldAppearance,
      MatFormFieldModule,
    } from '@angular/material/form-field';
    import { provideMomentDateAdapter } from '@angular/material-moment-adapter';
    import { ErrorStateMatcher } from '@angular/material/core';
    
    const moment = _rollupMoment || _moment;
    
    export const MY_FORMATS = {
      parse: {
        dateInput: 'YYYY/MM',
      },
      display: {
        dateInput: 'YYYY/MM',
        monthYearLabel: 'YYYY MMM',
        dateA11yLabel: 'LL',
        monthYearA11yLabel: 'YYYY MMMM',
      },
    };
    /** Error when invalid control is dirty, touched, or submitted. */
    export class MyErrorStateMatcher implements ErrorStateMatcher {
      constructor(private outerControl: AbstractControl<any, any> | null = null) {}
      isErrorState(
        control: FormControl | null,
        form: FormGroupDirective | NgForm | null
      ): boolean {
        console.log(control);
        const isSubmitted = form && form.submitted;
        return (
          !!(
            control &&
            control.invalid &&
            (control.dirty || control.touched || isSubmitted)
          ) ||
          !!(
            this.outerControl &&
            this.outerControl.invalid &&
            (this.outerControl.dirty || this.outerControl.touched || isSubmitted)
          )
        );
      }
    }
    
    @Component({
      selector: 'app-month-picker-control',
      standalone: true,
      encapsulation: ViewEncapsulation.None,
      imports: [
        CommonModule,
        MatFormFieldModule,
        MatInputModule,
        MatDatepickerModule,
        FormsModule,
        ReactiveFormsModule,
      ],
      providers: [
        provideMomentDateAdapter(MY_FORMATS),
        {
          provide: NG_VALUE_ACCESSOR,
          useExisting: forwardRef(() => MonthPickerControlComponent),
          multi: true,
        },
      ],
      // changeDetection: ChangeDetectionStrategy.OnPush,
      templateUrl: './month-picker-control.component.html',
      styleUrl: './month-picker-control.component.scss',
    })
    export class MonthPickerControlComponent implements ControlValueAccessor {
      matcher = new MyErrorStateMatcher();
      @Input() appearance: MatFormFieldAppearance;
      @Input() label: string;
      @Input() placeholder: string;
      @Input('formcontrolname') formControlName: string;
    
      @Input() cssClass: string;
      control: AbstractControl<any, any> | null;
      readonly date = new FormControl<Moment | null>(null);
    
      private onChange: (value: Moment | null) => void = () => {};
      private onTouched: () => void = () => {};
    
      writeValue(value: Moment | null): void {
        if (value !== null) {
          this.date.setValue(moment(value), { emitEvent: false });
        } else {
          this.date.reset(null, { emitEvent: false }); // Safely reset the control for null values
        }
      }
    
      constructor(
        private controlContainer: ControlContainer,
        private element: ElementRef
      ) {}
    
      ngOnInit() {
        const formControlName =
          this.element.nativeElement.getAttribute('formcontrolname');
        console.log(this.controlContainer, formControlName);
        this.control = this.controlContainer?.control?.get(formControlName) || null;
        console.log(this.controlContainer.control, this.control);
        this.matcher = new MyErrorStateMatcher(this.control);
      }
    
      registerOnChange(fn: (value: Moment | null) => void): void {
        this.onChange = fn;
      }
    
      registerOnTouched(fn: () => void): void {
        this.onTouched = fn;
      }
    
      setDisabledState(isDisabled: boolean): void {
        if (isDisabled) {
          this.date.disable();
        } else {
          this.date.enable();
        }
      }
    
      dateChangeHandler(e: MatDatepickerInputEvent<Moment>) {
        // the value would be null if the input format is invalid
        const input = e.value;
        const parsedDate = moment(input, 'YYYY/MM', true);
    
        if (parsedDate.isValid()) {
          this.date.setValue(parsedDate);
          this.onChange(parsedDate); // Emit valid date to parent form
        } else {
          this.date.setValue(null);
          this.onChange(null); // Emit null to parent form
        }
    
        this.onTouched();
      }
    
      setMonthAndYear(
        normalizedMonthAndYear: Moment,
        datepicker: MatDatepicker<Moment>
      ) {
        // Check if the selected month/year combination is a valid Moment object
        const ctrlValue = this.date.value ?? moment();
        ctrlValue.month(normalizedMonthAndYear.month());
        ctrlValue.year(normalizedMonthAndYear.year());
    
        // Set the value to null if the resulting date is invalid
        if (!ctrlValue.isValid()) {
          this.date.setValue(null);
          this.onChange(null); // Emit null to parent form
        } else {
          this.date.setValue(ctrlValue);
          this.onChange(ctrlValue); // Emit valid date to parent form
        }
        this.onTouched();
        datepicker.close();
      }
    }
    

    HTML:

    <mat-form-field [appearance]="appearance" [class]="cssClass">
      <mat-label>{{ label }}</mat-label>
      <input
        matInput
        [matDatepicker]="dp"
        [formControl]="date"
        [placeholder]="placeholder"
        (dateChange)="dateChangeHandler($event)"
        [errorStateMatcher]="matcher"
      />
      <mat-hint>YYYY/MM</mat-hint>
      <mat-datepicker-toggle matIconSuffix [for]="dp"></mat-datepicker-toggle>
      <mat-datepicker
        #dp
        startView="multi-year"
        (monthSelected)="setMonthAndYear($event, dp)"
        panelClass="month-picker"
      >
      </mat-datepicker>
        @if(date?.errors?.['matDatepickerParse']) {
          <mat-error>Please enter the right format</mat-error>
        } @if(control?.errors?.['required']) {
          <mat-error>the start month is required</mat-error>
        }
    </mat-form-field>
    {{ date.errors | json }} | {{ control?.errors | json }} |
    {{ control?.value | json }}
    

    Stackblitz Demo