An issue with ErrorStateMatcher and FormGroup within a FormArray in Angular

I initially set this up to work on a basic form with just one start and end date. But now, I'm dealing with a dynamic form that includes multiple pairs of start and end dates. To handle this, I've implemented a FormArray.

Although I have the structure in place, I am struggling to get the error state matchers and validation for each FormGroup (within the FormArray) to function correctly.

/* Error State Matchers */
readonly startDateAfterEndDateMatchers: SingleErrorStateMatcher[] = [];

/* Lifecycle Hooks */
protected ngOnInit(): void {
  // Initialization
  this.initFormGroup();
  this.formGetters = this.initFormGetters(this.datesInfo);

  // Create Error State Matchers
  for (let i = 0; i < this.datesArray.length; i++) {
    this.startDateAfterEndDateMatchers.push(
      new SingleErrorStateMatcher('startDateAfterEndDate')
    );
  }
}

// Initialize Form
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] }
        )
      )
    ),
  });
}

For reference, here is the Stackblitz link which utilizes Angular Material components: Any assistance would be highly valued: https://stackblitz.com/edit/stackblitz-starters-ss9qeg?file=src%2Fmain.ts

Thank you in advance.

ST

Answer №1

The issue with your ErrorStateMatcher not functioning correctly is caused by the following condition:

formGroup?.hasError(this.errorCode)

This condition returns false. The problem lies in the fact that the isErrorState method's formGroup parameter is fetching the root FormGroup associated with the overall <form> tag instead of the specific parent form group linked to the current form control.

As a result, the error for "startDateAfterEndDate" is present in the FormGroup instance of the datesArray FormArray, not in the root FormGroup.

isErrorState(
  control: FormControl | null,
  formGroup: FormGroupDirective | NgForm | null
)

To resolve this issue, you need to modify your ErrorStateMatcher to reference the parent FormGroup of the current FormControl.

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;

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

You also need to adjust how you add the SingleErrorStateMatcher to the startDateAfterEndDateMatchers array.

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

Additionally, it seems like you have created 3 separate instances of FormGroup based on your HTML structure.

Your HTML should be structured as follows:

<form [formGroup]="datesInfo" class="form-group">
  <!-- dates array -->
  <div formArrayName="datesArray">
    @for (date of datesArray; track $index) {

      <ng-container [formGroupName]="$index">
        <!-- start date -->
        <mat-form-field class="form-date">
          <!-- label -->
          <mat-label> Start Date </mat-label>

          ... <!-- Additional HTML code omitted for brevity -->

        </mat-form-field>

        ... <!-- Additional HTML code omitted for brevity -->

      </ng-container>
    }
  </div>
</form>

Check out the Demo on StackBlitz

Answer №2

When our code becomes tangled, it's time to take a step back and see if we can simplify. There are too many variables in the code: datesArray, datesInfo, formGetters, startDateAfterEndDateMatchers,... all interconnected

But really, we only need one: datesInfo, and as usual, we use a FormArray and a getter of the formArray

  protected datesInfo: FormGroup = this.formBuilder.group({});
  get datesArray()
  {
    return  this.datesInfo.get('datesArray') as FormArray
  }

We'll iterate through datesArray.controls and utilize datesInfo.get(path) and datesInfo.hasError('error',path) to access the controls.

With a FormArray, the path could look like datesArray.0.startDate for the startDate of the first FormGroup, datesArray.1.startDate for the second one, and so on...

 <form *ngIf="datesInfo.get('datesArray')" [formGroup]="datesInfo" class="form-group">
   <div formArrayName="datesArray">
     @for(group of datesArray.controls;track $index)
     {
       <!--we indicate the formGroup-->
       <div [formGroupName]="$index">

       <mat-form-field class="form-date">
         <mat-label>
           Start Date
         </mat-label>
         <!--we use formControlName, not FormControl-->
         <input
           matInput id="startDate-{{$index}}"
           [matDatepicker]="startDatePicker"
           formControlName="startDate"
           autocomplete="off"
           required/>
         <mat-hint>DD/MM/YYYY</mat-hint>
         <mat-datepicker-toggle matIconSuffix [for]="startDatePicker" [disabled]="false">
         </mat-datepicker-toggle>

         <!--see the use of get('datesArray.'+($index-1)+'.endDate')-->
         <mat-datepicker #startDatePicker 
   [startAt]="$index?datesInfo.get('datesArray.'+($index-1)+'.endDate')?.value:null">
         </mat-datepicker>

         <!-- a mat-error, by default, only show if touched, so
              we only check the "type of error"
          -->
         <mat-error 
 *ngIf="datesInfo.hasError('required','datesArray.'+$index+'.startDate')">
             Start Date is required.
          </mat-error>
          <mat-error 
 *ngIf="datesInfo.hasError('lessDate','datesArray.'+$index+'.startDate')">
            Cannot be before the end Date or before previous row
          </mat-error>

       </mat-form-field>

       <mat-form-field class="form-date">
         <mat-label>
           End Date
         </mat-label>
         <input
           (keydown)="endDatePicker.open()"
           (click)="endDatePicker.open()"
           matInput id="endDate-{{$index}}"
           [matDatepicker]="endDatePicker"
           formControlName="endDate"
           autocomplete="off"/>
          <mat-hint>DD/MM/YYYY</mat-hint>
          <mat-datepicker-toggle matIconSuffix [for]="endDatePicker" [disabled]="false">
          </mat-datepicker-toggle>
          <mat-datepicker #endDatePicker
  [startAt]="datesInfo.get('datesArray.'+$index+'.startDate')?.value">
          </mat-datepicker>

          <mat-error 
  *ngIf="datesInfo.hasError('required','datesArray.'+$index+'.endDate')">
                End Date is required.
          </mat-error>
          <mat-error 
  *ngIf="datesInfo.hasError('lessDate','datesArray.'+$index+'.endDate')">
              Cannot be before Start Date
          </mat-error>

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

Regarding matchError, I propose a different approach: assigning the error to the FormControl instead of the FormGroup of the formArray. The only challenge with this approach is that we also need to validate the formControl when another formControl changes: we must check endDate not just when it changes but also when startDate changes.

To achieve this, we define two functions like:

 greaterThan(dateCompare:string)
 {
   return (control:AbstractControl)=>{
     if (!control.value)
      return null;
     const group=control.parent as FormGroup;
     const formArray=group?group.parent as FormArray:null;
     if (group && formArray)
     {
       const index=dateCompare=='startDate'? formArray.controls.findIndex(x=>x==group):formArray.controls.findIndex(x=>x==group)-1;
       if (index>=0)
       {
         const date=formArray.at(index).get(dateCompare)?.value
         if (date && control.value && control.value.getTime()<date.getTime())
          return {lessDate:true}
       }
     }
     return null
   }
 }
 checkAlso(dateCheck:string){
  return (control:AbstractControl)=>{
    const group=control.parent as FormGroup;
    const formArray=group?group.parent as FormArray:null;
    if (group && formArray)
    {
      const index=dateCheck=='endDate'? formArray.controls.findIndex(x=>x==group):formArray.controls.findIndex(x=>x==group)+1;
      if (index>=0 && index<formArray.controls.length)
      {
        const control=formArray.at(index).get(dateCheck)
        control && control.updateValueAndValidity()
      }
    }
    return null
 }

And we initialize the formGroup like this:

  private initFormGroup() {
    this.datesInfo = this.formBuilder.group({
      datesArray: this.formBuilder.array(
        ([1,2,3]).map((_) =>
          this.formBuilder.group(
            {
              startDate: [
                '',
                {
                  nonNullable: true,
                  validators: [Validators.required,this.greaterThan("endDate"),this.checkAlso('endDate')],
                },
              ],
              endDate: [
                '',
                {
                  validators: [this.greaterThan("startDate"),this.checkAlso('startDate')],
                },
              ],
            },
          )
        )
      ),
    });
  }

Check out stackblitz

Similar questions

If you have not found the answer to your question or you are interested in this topic, then look at other similar questions below or use the search

Validation of identification numbers in South Africa

Despite doing thorough research, I am unable to make the code work. South African ID numbers contain information about the individual's date of birth and gender. My goal is to extract this information and validate it when a user enters their ID number ...

Developing a loader feature in React

I've been working on incorporating a loader that displays when the component has not yet received data from an API. Here's my code: import React, {Component} from 'react' import './news-cards-pool.css' import NewsService fro ...

Creating a JSON file from a custom key-value class in Typescript: A comprehensive guide

I am struggling to find an npm package or create my own function that can generate a JSON file from elements within this specific class: export class TranslatedFileElement { private key: string private hasChild: boolean priva ...

When conducting Angular testing using Karma and Jasmine, a method that makes use of HttpClient.get may result in undefined values in the service spec. However, it successfully

Issue with Angular service method retrieving data from database during testing In myservice.ts, I have a method named getEvents() that creates an array, fetches data from the database using http.get, fills the array, and returns it. However, when I try to ...

What sets Babel and TypeScript apart from each other in terms of functionality and features?

While TypeScript was utilized to create Angular2, Babel also appears quite similar. Many established companies prefer Babel over TypeScript. Here are some questions to consider: What specific advantages does each one offer? Which is a better or worse ch ...

default folder location for core modules adjustment

While experimenting with module imports in TypeScript, I encountered an issue when trying to import a module using import { Component, OnInit } from '@angular/core';. The compiler was successfully finding the module in the node_modules folder. I ...

When using a try-catch block to validate an object, why does the Liskov Substitution Principle (LSP) fail to correctly

function parseAndValidate(obj: unknown): ParsedObj | void { try { // conducting various validations return parsedObj } catch { throw new Error('obj is invalid') } } const parsedObj = parseAndValidate(obj) I ...

Styling array of arrays

When retrieving data from an API, the structure looks like this: "key1" : { "subkey1" : value "subkey2" : value "subkey3" : value } "key2" : { &q ...

Uninitialized variables in Angular 4.0 causing Rxjs issues

Since the update to angular 4.0, I've been encountering errors with RxJs variables. While everything builds without any issues, when I try to load the page, I receive this error message: ERROR Error: Uncaught (in promise): TypeError: Cannot read pr ...

The Rx subject has not been initialized

Within this particular Namespace, I am exporting a Rx subject of the string data type: namespace something.TaskHandling { export const selectedTask$ = new Rx.Subject<String>(); Inside my TaskListComponent class within the something.TaskHandling. ...

The contrast between 'this' reference in asynchronous calls of Angular compared to those of React and Node

Can someone explain why this in the body of this.http.subscribe in Angular refers to the current component, while in nested asynchronous callbacks in React or Node it references the http request itself? How can I replicate the Angular pattern in my code? ...

The Ionic2 slider appears to be experiencing difficulties when used in conjunction with *ngFor in Angular2

Issue with Screen Prints!! After clicking the Skip button, unexpectedly the Login page appears! https://i.sstatic.net/2Enng.png Encountering the Login Page! Furthermore, if you click on the Home icon, the Slider page will be displayed!! https://i.ssta ...

Sending parameters to a function within the ngAfterViewInit() lifecycle method in Angular version 6

As a beginner in coding, I have created a canvas in Angular and am attempting to pass data received from my server to a function within ngAfterViewInit(). When I hard code the values I want to pass, everything works perfectly. However, if I try to pass da ...

Adding supplementary text to a form input label is simple with this guide

I am new to Angular 2 and I am looking for a way to add text to form inputs that are optional. Something similar to using a directive called app-optional: <div class="form-group"> <label app-optional> Name ... input here ... </ ...

Type Conversion in Typescript

After receiving JSON data that can be in the form of a TextField object or a DateField object, both of which inherit from the Field superclass, I am faced with the task of converting this JSON into a Field object. To further complicate matters, I need to ...

What is the process of switching the dark theme on and off in an Angular application using

Looking to incorporate a bootstrap dark theme into my Angular app, but unsure of how to add the data-bs-theme="dark" attribute in the index.html file. Any suggestions? Struggling with dynamically changing the data-bs-theme attribute using Angular. Any tip ...

Issue encountered: Upon executing 'ng add @angular/fire', an error was detected in the node_modules/firebase/compat/index.d.ts file while attempting to launch the Angular application

Recently, I decided to integrate Firebase into my project, but encountered a persistent error after installing the firebase module 'ng add @angular/fire' and running it with 'ng serve': Error: node_modules/firebase/compat/index.d.ts:770 ...

Creating a Search Bar with Ionic 3, PHP, and SQL Server for Efficient Data Retrieval

I have a project in progress that involves using Ionic 3, PHP, and a SQL Server database for the backend. One of the features I'm trying to implement is a search bar. I've looked at various examples online, but none of them have worked for me. Co ...

Encountering 'propertyof undefined' error while attempting to import a local JSON file in Angular/Ionic

I am looking to display data in an HTML file that is sourced from a local JSON file. The structure of my JSON file is as follows: { "ACCESSIBILITY_EXPANDED": "Expanded", "ACCESSIBILITY_BACK": "Back", "ACCESSIBILITY_COLLAPSED": "Collapsed", ...

Can someone point me in the direction of the AND and BUT keywords within the cucumber library

After adding the following libraries to my Angular project: "@cucumber/cucumber": "^7.2.1", "@types/chai": "^4.2.16", "protractor-cucumber-framework": "^8.0.2", I noticed that when I open the cuc ...