angularangular-material2angular-material-stepper

Angular Material Stepper with reusable form component


I'm trying to implement an Angular Material Stepper by creating a form component that can be inserted as a step for multiple, larger forms.

For example, say I have N number of forms, and the first step will always be the same (name and address information), how can I insert the component in the parent component?

I have seen other questions, however, they all seem to make Angular complain in one way or another, i.e. when strict: true.

I've tried various approaches, and some I can get to work by adding a null-assertion operator, but that makes me feel like I'm not typing things properly or doing it the right way.

Here is an example I've tried.

// insured-form.component.ts

import { Component, OnInit } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms';
import { MatInputModule } from '@angular/material/input';
import { MatFormFieldModule } from '@angular/material/form-field';
import { MatButtonModule } from '@angular/material/button';
import { MatIconModule } from '@angular/material/icon';
import { MatSelectModule } from '@angular/material/select';

interface AddressForm {
  line1: FormControl<string>;
  line2: FormControl<string | null>;
  city: FormControl<string>;
  postalCode: FormControl<string>;
  stateId: FormControl<string>;
}

export interface InsuredForm {
  name: FormControl<string>;
  dba: FormControl<string | null>;
  mailingAddress: FormGroup<AddressForm>;
}

@Component({
  selector: 'grid-insured-form',
  standalone: true,
  imports: [
    ReactiveFormsModule,
    MatInputModule,
    MatFormFieldModule,
    MatButtonModule,
    MatIconModule,
    MatSelectModule,
  ],
  template: `<form [formGroup]="insuredForm" class="insured-form">
  <mat-form-field class="insured-form__input">
    <mat-label>Name</mat-label>
    <input type="text" matInput formControlName="name" />
  </mat-form-field>

  <mat-form-field class="insured-form__input">
    <mat-label>DBA</mat-label>
    <input type="text" matInput formControlName="dba" />
  </mat-form-field>

  <div formGroupName="mailingAddress" class="address-form">
    <h2>Mailing Address</h2>

    <mat-form-field class="address-form__input">
      <mat-label>Line 1</mat-label>
      <input type="text" matInput formControlName="line1" />
    </mat-form-field>

    <mat-form-field class="address-form__input">
      <mat-label>Line 2</mat-label>
      <input type="text" matInput formControlName="line2" />
    </mat-form-field>

    <mat-form-field class="address-form__input">
      <mat-label>Postal Code</mat-label>
      <input type="text" matInput formControlName="postalCode" />
    </mat-form-field>

    <mat-form-field class="address-form__input">
      <mat-label>City</mat-label>
      <input type="text" matInput formControlName="city" />
    </mat-form-field>

    <mat-form-field class="address-form__input">
      <mat-label>State</mat-label>
      <mat-select formControlName="stateId">
        <mat-option value="1">FL</mat-option>
      </mat-select>
    </mat-form-field>
  </div>
</form>
`,
  styleUrls: ['./insured-form.component.scss'],
})
export class InsuredFormComponent implements OnInit {
  insuredForm!: FormGroup;

  ngOnInit(): void {
    this.insuredForm = new FormGroup<InsuredForm>({
      name: new FormControl('', { nonNullable: true }),
      dba: new FormControl('', { nonNullable: true }),
      mailingAddress: new FormGroup<AddressForm>({
        line1: new FormControl('', { nonNullable: true }),
        line2: new FormControl(''),
        city: new FormControl('', { nonNullable: true }),
        postalCode: new FormControl('', { nonNullable: true }),
        stateId: new FormControl('', { nonNullable: true }),
      }),
    });
  }
}

// End of insured-form.component.ts
// feature-form.component.ts

import { AfterViewInit, Component, ViewChild } from '@angular/core';
import { MatStepperModule } from '@angular/material/stepper';
import { InsuredFormComponent } from '../shared/insured-form/insured-form.component';
import { FormGroup } from '@angular/forms';

@Component({
  selector: 'grid-feature-form',
  standalone: true,
  imports: [MatStepperModule, InsuredFormComponent],
  template: `<mat-stepper orientation="horizontal" #stepper>
  <mat-step [stepControl]="insuredForm">
    <ng-template matStepLabel>Step 1</ng-template>
    <grid-insured-form></grid-insured-form>
  </mat-step>
</mat-stepper>
`,
  styleUrls: ['./feature-form.component.scss'],
})
export class FeatureFormComponent implements AfterViewInit {
  @ViewChild(InsuredFormComponent) insuredFormComponent!: InsuredFormComponent;
  insuredForm!: FormGroup;

  ngAfterViewInit(): void {
    this.insuredForm = this.insuredFormComponent.insuredForm;
  }
}

// End of feature-form.component

The following works, but I'm presented with the following error in the console:

Error: NG0100: ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked. Previous value: 'undefined'. Current value: '[object Object]'

Alternative solution: I have got it to work by moving the insuredForm creation into a service, adding an Input() to the insured-form component and creating the form in the feature-form component. However, I would really like to encapsulate the insuredForm within the insured-form component. I believe this works because the insuredForm (form group) would be instantiated in the feature-form component when it is being created, in NgOnInit.

I'm also aware that I can manually run change detection, yet that still doesn't seem like the correct way to do it.

This is not going to be for a small side project, so I'd like to ensure I'm doing things the best way possible.

Note: I'm using the Angular 16, so I've thought about using Signals, but haven't worked with them much yet.

How can I create a reusable form component that can be inserted within a mat-step and avoid any complaining by the compiler in the console when strict is set to true?

StackBlitz


Solution

  • You can make sure that query result is available within ngOnInit by using static: true option.

    It will allow you using ngOnInit instead of ngAfterViewInit. But also you need to make sure that form is initialized within child component constructor.

    @ViewChild(InsuredForm, { static: true }) insuredFormComponent!: InsuredForm;
    insuredForm!: FormGroup;
    
    ngOnInit(): void {
      this.insuredForm = this.insuredFormComponent.insuredForm;
    }
    
    ...
    
    export class InsuredForm {
      insuredForm = new FormGroup<InsuredFormModel>({
        ...
      });
    }
    

    Forked Stackblitz