angulartypescriptangular-reactive-formsformarrayformgroups

Angular 17 - How to add errorStateMatcher to Form Array, but one that traverses formGroups at consecutve indices


the below code works,

https://stackblitz.com/edit/stackblitz-starters-wnququ?file=src%2Fmain.html

But I need to take it a step further and ensure that the end date of the FormGroup at index (x) is not greater than or equal to the start date of FormGroup at index (x + 1) - (all inside the main form array).

Do you know how I do that?

This is what I have so far (refer to Stackblitz demo too)

Validators

Currently, my date validator looks like:

// VALIDATORS
public startDateAfterEndDateMatcher: ValidatorFn =
  this.dateComparisonValidator(
    'startDate',
    'endDate',
    'startDateAfterEndDate',
    (date1: Date, date2: Date) => date1 && date2 && date1 > date2
  );

private dateComparisonValidator(
  fieldName1: string,
  fieldName2: string,
  errorName: string,
  condition: (value1: any, value2: any) => boolean
): ValidatorFn {
  return (control: AbstractControl): ValidationErrors | null => {
    const field1Value = control.get(fieldName1)?.value;
    const field2Value = control.get(fieldName2)?.value;
    console.log('condition', condition(field1Value, field2Value));
    if (condition(field1Value, field2Value)) {
      const errors: ValidationErrors = {};
      errors[errorName] = true;
      return errors;
    }
    return null;
  };
}

Form Structure

The form structure currently looks like this. The form validator gets added on to each formGroup object, (but I'd like to try and validate across formGroups now - which I'm not sure how to do)

private initFormGroup() {
  this.datesInfo = this.formBuilder.group({
    datesArray: this.formBuilder.array(
      (this.datesArray || []).map((_) =>
        this.formBuilder.group(
          {
            startDate: [
              '',
              {
                nonNullable: true,
                validators: [Validators.required],
              },
            ],
            endDate: [
              '',
              {
                validators: [],
              },
            ],
          },
          { validators: [this.startDateAfterEndDateMatcher] }
        )
      )
    ),
  });
}

Error State Matcher

My error state matcher (that attaches to each form group in the form array) looks like:

// ERROR MATCHER
export class SingleErrorStateMatcher implements ErrorStateMatcher {
  private errorCode: string;
  public constructor(errorCode: string, private formGroup?: FormGroup) {
    this.errorCode = errorCode;
  }

  isErrorState(
    control: FormControl | null,
    formGroup: FormGroupDirective | NgForm | null
  ): boolean {
    let parentFormGroup = this.formGroup ?? formGroup;
    console.log('parentFormGroup', parentFormGroup);

    return (
      !!(parentFormGroup?.dirty || parentFormGroup?.touched) &&
      !!(parentFormGroup?.invalid && parentFormGroup?.hasError(this.errorCode))
    );
  }
}

Initialisation

These get pushed inside ngOnInit only (so it's not fully dynamic in the sense, I haven't yet thought about what happens, if I want to add another pair of dates- or if I delete/roll back a pair of dates... - but that's ok for now)

// create error state matchers
for (let i = 0; i < this.datesArray.length; i++) {
  this.startDateAfterEndDateMatchers.push(
    new SingleErrorStateMatcher(
      'startDateAfterEndDate',
      this.datesInfo.controls['datesArray'].get(`${i}`) as FormGroup
    )
  );
}

Solution

  • First, would like to clarify that ErrorStateMatcher is used on how/when the <mat-error> is displayed. So you shouldn't mix it with the validation logic.

    Adjust the SingleErrorStateMatcher to display the error when the FormGroup is invalid instead of specified error (code).

    export class SingleErrorStateMatcher implements ErrorStateMatcher {
      private errorCode: string;
      public constructor(errorCode: string, private formGroup?: FormGroup) {
        this.errorCode = errorCode;
      }
    
      isErrorState(
        control: FormControl | null,
        formGroup: FormGroupDirective | NgForm | null
      ): boolean {
        let parentFormGroup = this.formGroup ?? formGroup;
        //console.log('parentFormGroup', parentFormGroup);
    
        return (
          !!(parentFormGroup?.dirty || parentFormGroup?.touched) &&
          !!parentFormGroup?.invalid
        );
      }
    }
    

    For comparing the field with the consecutive (next) field, thinking that by subscribing to the dateArray FormArray's valuesChanges observable and adding the validation will be easier.

    subscription!: Subscription;
    
    this.subscription = (
      this.datesInfo.controls['datesArray'] as FormArray
    ).valueChanges.subscribe((dates) => {
      dates.forEach((x: any, i: number) => {
        const endDateExceedsStartDate = dates.some(
          (y: any, j: number) =>
          j == i + 1 && x.endDate && y.startDate && x.endDate >= y.startDate
        );
    
        const endDate = (
          this.datesInfo.controls['datesArray']?.get(`${i}`) as FormGroup
        )?.get('endDate')!;
    
        if (endDateExceedsStartDate) {
          endDate.setErrors(
            { endDateExceedsStartDate: true },
            { emitEvent: false }
          );
        } else {
          if (endDate.hasError('endDateExceedsStartDate')) {
            delete endDate.errors?.['endDateExceedsStartDate'];
            endDate.updateValueAndValidity({ emitEvent: false });
          }
        }
      });
    });
    

    And don't forget to unsubscribe the Subscription for the performance optimization.

    ngOnDestroy() {
      this.subscription.unsubscribe();
    }
    

    Showing the "endDateExceedsStartDate" error.

    <mat-error
      *ngIf="datesInfo.get('datesArray')!.get([$index])?.get('endDate')?.hasError('endDateExceedsStartDate')"
    >
      End Date cannot exceed (next) Start Date
    </mat-error>
    

    In case the <mat-error> is not shown, it could be due to not enough space. Hence you may need to adjust the <mat-form-field>/container to display the error message completely.

    Demo @ StackBlitz

    .container {
      display: block;
      height: 150px;
      overflow: visible;
    }