angularangular-reactive-formsangular-dynamic-forms

Angular - trying to make last form control not required when using Reactive forms and Arrays - with add and delete buttons


This doesn't quite work.

I'm have an array of date pairs (start) and (end), and load three initially, and have two buttons, add and delete. The add button adds a new pair, but only if the last form group (which the pair of dates are in) is valid.

This works, but I want to be able to:

Make last element (the 3rd element) when the component is loaded, editable and to remove the validator.required condition from that control

Then when I add a 4th pair, I want to make the 3rd end date, non-editable, and add the validator.required condition, bu the 4th end date should be editable with the required condition.

Basically, the last end-date should always be optional (not required) and editable.

I'm struggling to get Angular to do this, for some reason, the last end date element is always required and never editable.

Sample code

protected addDatePair() {
    const datesArray = this.datesInfo.get('datesArray') as FormArray;

    if (datesArray.length > 0) {
      const lastFormGroup = datesArray.at(datesArray.length - 1) as FormGroup;

      if (lastFormGroup && lastFormGroup.valid) {
        // add date to array in class
        this.datesArray.push(9);

        // create new end date control
        const newEndDateControl = new FormControl(null, {
          nonNullable: false,
          validators: [],
        });
        newEndDateControl.reset();
        const newFormGroup = this.datePairs();

        // swap new control into new form group
        newFormGroup.removeControl('endDate');
        newFormGroup.addControl('startDate', newEndDateControl);

        // add new form group to dates array
        datesArray.push(newFormGroup);

        // should probably only initialise new element, rather than all again
        this.formGetters = this.initFormGetters(this.datesInfo);

        // should probably initialise new error state matcher too

        // need to add delay since,
        // asynchronouse HTML may not have rendered the 4th element to DOM
        // (a bit annoying!)
        // I'd like to make the last End Date (i) modifiable and (ii) not required
        setTimeout(() => {
          const inputs = document.querySelectorAll(
            'div[formArrayName="datesArray"] ' + 'input[id^="endDate"]'
          );
          const lastInput = inputs[inputs.length - 1] as HTMLInputElement;
          if (lastInput) {
            lastInput.removeAttribute('readonly');
            lastInput.removeAttribute('required');
            lastInput.removeAttribute('aria-required');
          }
        }, 1000);
      } else {
        console.log('last formGroup is not valid');
      }
    } else {
      // TO IMPLEMENT
    }
  }

  protected removeLastDatePair() {
    // TO IMPLEMENT
  }

Here is my stackblitz: https://stackblitz.com/edit/stackblitz-starters-y2b7mc?file=src%2Fmain.ts

I want the delete button to do the same / similar thing.


Solution

  • There has been a lot of changes made, but the gist is the below points.

    When running the for loops, just use the controls array and not the data array.

    We can simplify most of the complex code into smaller functionality by just running for loops.

    We should not use required in HTML because these are reserved for template driven validations, but we are using reactive forms here.

    Please go through the code and let me know if doubts.

    Please adapt the code based on your use case, I'm sure I did not exactly achieve what you wanted!

    TS

    import { Component, DestroyRef, inject, Signal } from '@angular/core';
    import { CommonModule, DatePipe, JsonPipe } from '@angular/common';
    import {
      AbstractControl,
      FormArray,
      FormBuilder,
      FormControl,
      FormGroupDirective,
      FormGroup,
      NgForm,
      FormsModule,
      ReactiveFormsModule,
      ValidationErrors,
      ValidatorFn,
      Validators,
    } from '@angular/forms';
    import {
      MatError,
      MatFormField,
      MatHint,
      MatLabel,
      MatSuffix,
    } from '@angular/material/form-field';
    import {
      MatDatepicker,
      MatDatepickerInput,
      MatDatepickerToggle,
    } from '@angular/material/datepicker';
    import { MatInput } from '@angular/material/input';
    import { MatOption } from '@angular/material/autocomplete';
    import { MatSelect } from '@angular/material/select';
    import {
      DateAdapter,
      ErrorStateMatcher,
      MAT_DATE_FORMATS,
      MAT_DATE_LOCALE,
    } from '@angular/material/core';
    import { bootstrapApplication } from '@angular/platform-browser';
    import { provideAnimations } from '@angular/platform-browser/animations';
    import {
      MomentDateAdapter,
      MAT_MOMENT_DATE_ADAPTER_OPTIONS,
    } from '@angular/material-moment-adapter';
    import 'zone.js';
    import { Subscription } from 'rxjs';
    
    export const MY_FORMATS = {
      parse: {
        dateInput: 'MM/YYYY',
      },
      display: {
        dateInput: 'MM/YYYY',
        monthYearLabel: 'MMM YYYY',
        dateA11yLabel: 'LL',
        monthYearA11yLabel: 'MMMM YYYY',
      },
    };
    
    type FormGetters = {
      startDate: FormControl<string>;
      endDate: FormControl<string>;
    };
    
    @Component({
      selector: 'app-root',
      standalone: true,
      imports: [
        CommonModule,
        FormsModule,
        ReactiveFormsModule,
        MatFormField,
        MatLabel,
        MatDatepickerInput,
        MatInput,
        MatError,
        MatDatepicker,
        MatDatepickerToggle,
        MatHint,
        MatSuffix,
        MatOption,
        MatSelect,
        JsonPipe,
      ],
      providers: [
        {
          provide: DateAdapter,
          useClass: MomentDateAdapter,
          deps: [MAT_DATE_LOCALE, MAT_MOMENT_DATE_ADAPTER_OPTIONS],
        },
        { provide: MAT_DATE_FORMATS, useValue: MY_FORMATS },
      ],
      templateUrl: `main.html`,
    })
    export class App {
      private formBuilder = inject(FormBuilder);
    
      name = 'Angular';
    
      /* data properties */
      datesArray = [1, 2, 3];
    
      /* form properties */
      protected datesInfo: FormGroup = this.formBuilder.group({});
      protected formGetters: FormGetters[] = [];
      errorMatcher = new SingleErrorStateMatcher('startDateAfterEndDate');
    
      /* error state matchers */
      readonly startDateAfterEndDateMatchers: SingleErrorStateMatcher[] = [];
    
      /* lifecycle hooks */
      protected ngOnInit(): void {
        // initialisation
        this.initFormGroup();
        this.formGetters = this.initFormGetters(this.datesInfo);
      }
    
      // INITIALISE FORM
      private initFormGroup() {
        this.datesInfo = this.formBuilder.group({
          datesArray: this.formBuilder.array(
            (this.datesArray || []).map((_, i) =>
              this.datePairs(this.datesArray.length - 1 === i)
            )
          ),
        });
      }
    
      get datesArrayControls() {
        return (<FormArray>this.datesInfo.get('datesArray'))
          .controls as FormGroup[];
      }
    
      private datePairs(isLast: boolean): FormGroup {
        return this.formBuilder.group({
          startDate: [
            null,
            {
              nonNullable: true,
              validators: !isLast
                ? [Validators.required, this.startDateAfterEndDateMatcher]
                : [],
            },
          ],
          endDate: [
            null,
            {
              nonNullable: true,
              validators: !isLast
                ? [Validators.required, this.startDateAfterEndDateMatcher]
                : [],
            },
          ],
        });
      }
    
      protected addDatePair() {
        const datesArray = this.datesInfo.get('datesArray') as FormArray;
        (<FormGroup[]>datesArray.controls).forEach((formGroup: FormGroup) => {
          const startDateCtrl = formGroup.get('startDate') as FormControl;
          const endDateCtrl = formGroup.get('endDate') as FormControl;
          startDateCtrl.clearValidators();
          startDateCtrl.updateValueAndValidity();
          endDateCtrl.clearValidators();
          endDateCtrl.updateValueAndValidity();
          endDateCtrl.disable();
          startDateCtrl.disable();
        });
        datesArray.push(this.datePairs(true));
      }
    
      protected removeLastDatePair() {
        const datesArray = this.datesInfo.get('datesArray') as FormArray;
        datesArray.controls.splice(datesArray.controls.length - 1, 1);
        (<FormGroup[]>datesArray.controls).forEach(
          (formGroup: FormGroup, i: number) => {
            const startDateCtrl = formGroup.get('startDate') as FormControl;
            const endDateCtrl = formGroup.get('endDate') as FormControl;
            if (datesArray.controls.length - 1 !== i) {
              startDateCtrl.clearValidators();
              startDateCtrl.updateValueAndValidity();
              endDateCtrl.clearValidators();
              endDateCtrl.updateValueAndValidity();
              endDateCtrl.disable();
              startDateCtrl.disable();
            }
          }
        );
      }
    
      // FORM GETTER
      private initFormGetters(form: FormGroup) {
        const datesArray = this.datesInfo.get('datesArray') as FormArray;
        const formGetters: FormGetters[] = [];
    
        datesArray.controls.forEach((control: AbstractControl) => {
          if (control instanceof FormGroup) {
            const formGetter: FormGetters = {
              startDate: control.get('startDate') as FormControl<string>,
              endDate: control.get('endDate') as FormControl<string>,
            };
    
            formGetters.push(formGetter);
          }
        });
    
        return formGetters;
      }
    
      // 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 field1: FormControl = control.parent?.get(
            fieldName1
          ) as FormControl;
          const field2: FormControl = control.parent?.get(
            fieldName2
          ) as FormControl;
          const field1Value = field1?.value;
          const field2Value = field2?.value;
          console.log('condition', condition(field1Value, field2Value));
          if (condition(field1Value, field2Value)) {
            const errors: ValidationErrors = {};
            errors[errorName] = true;
            return errors;
          }
          return null;
        };
      }
    }
    
    // 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 !!(control?.invalid && control?.touched);
      }
    }
    
    bootstrapApplication(App, {
      providers: [provideAnimations()],
    });
    

    HTML

    <!-- Add Button -->
    <button (click)="addDatePair()" [disabled]="datesInfo.invalid">
      Add Date Pair
    </button>
    <!-- Delete Button -->
    <button (click)="removeLastDatePair()" [disabled]="datesInfo.invalid">
      Delete Date Pair
    </button>
    
    <!-- dates info -->
    <form [formGroup]="datesInfo" class="form-group">
      <!-- dates array -->
      <div formArrayName="datesArray">
        @for (date of datesArrayControls; track $index) {
        <ng-container [formGroupName]="$index">
          <!-- start date -->
          <mat-form-field class="form-date">
            <!-- label -->
            <mat-label> Start Date </mat-label>
    
            <!-- input -->
            <input
              matInput
              [matDatepicker]="startDatePicker"
              [errorStateMatcher]="errorMatcher"
              formControlName="startDate"
              autocomplete="off"
            />
    
            <!-- hint -->
            <mat-hint>DD/MM/YYYY</mat-hint>
    
            <!-- picker -->
            <mat-datepicker-toggle
              matIconSuffix
              [for]="startDatePicker"
              [disabled]="false"
            ></mat-datepicker-toggle>
            <mat-datepicker
              #startDatePicker
              [startAt]="date?.get('startDate')?.value"
            ></mat-datepicker>
    
            <!-- errors -->
            <mat-error
              *ngIf="date?.get('startDate')?.invalid
                      && (date?.get('startDate')?.dirty || date?.get('startDate')?.touched)"
            >
              @if(date?.get('startDate')?.errors?.['required']) { Start Date is
              required. }
            </mat-error>
            <mat-error
              *ngIf="date?.get('startDate')?.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
              matInput
              [matDatepicker]="endDatePicker"
              [errorStateMatcher]="errorMatcher"
              formControlName="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]="date?.get('startDate')?.value"
            ></mat-datepicker>
    
            <!-- errors -->
            <mat-error
              *ngIf="date?.get('endDate')?.invalid && (date?.get('endDate')?.dirty || date?.get('endDate')?.touched)"
            >
              @if (date?.get('endDate')?.errors?.['required']) { End Date is
              required. }
            </mat-error>
            <mat-error
              *ngIf="date?.get('endDate')?.hasError('startDateAfterEndDate')"
            >
              Cannot be before Start Date
            </mat-error>
            <mat-error
              *ngIf="date?.get('endDate')?.hasError('endDateExceedsStartDate')"
            >
              End Date cannot exceeds any Start Date
            </mat-error>
          </mat-form-field>
        </ng-container>
        }
      </div>
    </form>
    

    Stackblitz Demo