angulartypescriptangular-reactive-formsformarrayformgroups

Angular - Cannot get ErrorStateMatcher to work with FormGroup inside a FormArray


This works on a simple form, with a single start and end date, but now the form I have is dynamic which has multiple pairs of start and end dates, so I've had to use a FormArray.

Here is the structure, but now I can't get the error state matchers, or the validation on each FormGroup (inside the FormArray) to work

/* error state matchers */
readonly startDateAfterEndDateMatchers: SingleErrorStateMatcher[] = [];

/* lifecycle hooks */
protected ngOnInit(): void {
  // initialisation
  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')
    );
  }
}

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

Here is the Stackblitz too (it uses Angular Material components) Any help would be appreciated: https://stackblitz.com/edit/stackblitz-starters-ss9qeg?file=src%2Fmain.ts

Thanks in advance.

ST


Solution

  • When our code is confussed, we need take a breath and check if we can factorize. The code has too much variables:datesArray, datesInfo, formGetters, startDateAfterEndDateMatchers,... realationated

    And we only need one: datesInfo and, as always we use a FormArray a getter of the formArray

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

    We are going to loop over datesArray.controls and we are going to use datesInfo.get(path) and datesInfo.hasError('error',path) to reach the controls.

    The path, when we have an FormArray can be in the way datesArray.0.startDate for the startDate of the first FormGroup of the form, datesArray.1.startDate for the second one, etc...

     <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 defect, 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 of before 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>
    

    About matchError. I suggest another aproach: makes the error belong to the FormControl, not to the FormGroup of the formArray. The only problem with this aproach it's that we need validate also the formControl when another formControl Change: we need check endDate, not only when change the endDate else also when change the startDate.

    For this we are going to define a Validator that return always null, but check a formControl. It's looks like this SO

    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 create the formGroup as

      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')],
                    },
                  ],
                },
              )
            )
          ),
        });
      }
    

    stackblitz