0

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

2 Answers 2

1

The reason why your ErrorStateMatcher is not working due to this condition:

formGroup?.hasError(this.errorCode)

returns false. Believe that the formGroup from the isErrorState method will get the root FormGroup which is with the <form> tag instead of the parent form group of the current form control.

Thus, the root FormGroup instance will not have the "startDateAfterEndDate" error but the error is in the FormGroup instance of the datesArray FormArray.

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

Hence, you need to customize your ErrorStateMatcher to supply 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))
    );
  }
}

And the current way to push the SingleErrorStateMatcher into 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
    )
  );
}

Besides, based on your HTML, you create 3 separate FormGroup instances.

Your HTML structure should be as below:

<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>

          <!-- input -->
          <input
            matInput
            id="startDate-{{$index}}"
            [matDatepicker]="startDatePicker"
            [errorStateMatcher]="startDateAfterEndDateMatchers[$index]"
            [formControl]="formGetters[$index]!.startDate"
            autocomplete="off"
            readonly
            required
          />

          <!-- hint -->
          <mat-hint>DD/MM/YYYY</mat-hint>

          <!-- picker -->
          <mat-datepicker-toggle
            matIconSuffix
            [for]="startDatePicker"
            [disabled]="false"
          ></mat-datepicker-toggle>
          <mat-datepicker
            #startDatePicker
            [startAt]="(formGetters[$index]?.startDate?.value ?? null)"
          ></mat-datepicker>

          <!-- errors -->
          <mat-error
            *ngIf="formGetters[$index]?.startDate?.invalid
                  && (formGetters[$index]?.startDate?.dirty || formGetters[$index]?.startDate?.touched)"
          >
            @if(formGetters[$index]?.startDate?.errors?.['required']) { 
              Start Date is required. 
            }
          </mat-error>
          <mat-error
            *ngIf="datesInfo.get('datesArray')!.get([$index])?.hasError('startDateAfterEndDate')"
          >
            Cannot be after End Date
          </mat-error>
        </mat-form-field>

        <!-- end date -->
        <mat-form-field class="form-date">
          <!-- label -->
          <mat-label> End Date </mat-label>

          <!-- input -->
          <input
            (keydown)="endDatePicker.open()"
            (click)="endDatePicker.open()"
            matInput
            id="endDate-{{$index}}"
            [matDatepicker]="endDatePicker"
            [errorStateMatcher]="startDateAfterEndDateMatchers[$index]"
            [formControl]="formGetters[$index]!.endDate"
            autocomplete="off"
          />

          <!-- hint -->
          <mat-hint>DD/MM/YYYY</mat-hint>

          <!-- picker -->
          <mat-datepicker-toggle
            matIconSuffix
            [for]="endDatePicker"
            [disabled]="false"
          ></mat-datepicker-toggle>
          <mat-datepicker
            #endDatePicker
            [startAt]="(formGetters[$index]?.endDate?.value ?? null)"
          ></mat-datepicker>

          <!-- errors -->
          <mat-error
            *ngIf="formGetters[$index]?.endDate?.invalid
                  && (formGetters[$index]?.endDate?.dirty || formGetters[$index]?.endDate?.touched)"
          >
            @if(formGetters[$index]?.endDate?.errors?.['required']) { End Date is required. }
          </mat-error>
          <mat-error
            *ngIf="datesInfo.get('datesArray')!.get([$index])?.hasError('startDateAfterEndDate')"
          >
            Cannot be before Start Date
          </mat-error>
        </mat-form-field>

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

Demo @ StackBlitz

Sign up to request clarification or add additional context in comments.

2 Comments

Thank you so much! I didn't change the html, but you're eagle eye on the ErrorMatcher, and pushing the right formGroup to it, definitely worked!
I also need to take this one step further and ensure the end date of index x is not more than or equal to the start date of index x + 1. Do you know how I do that?
0

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

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.