0

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

2 Answers 2

2

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

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

3 Comments

That solved it. I haven't seen the static property before. Off to do some research, thanks!
Follow-up question if you don't mind, your change resolves the error, but introduces a new issue where the insuredForm in the parent component doesn't update when the form values change. I.e. <pre>{{ insuredForm.value | json }}</pre> always shows null.
Please take a look at updated Stackblitz. You need to make sure form is initialized in child component within constructor
0

From the error, I believe the issue is that form values are getting changed after the change detection ran. So you would have to explicitly run the change detection cycle in 'ngAfterViewInit'.

Screen shot:

enter image description here

Let me know if this is what you are looking for by responding to my answer. Thank you.

2 Comments

Thank you for your answer, however, I'd like to stay away from explicitly running change detection.
sounds good! But I wonder if you can actually stay away from it. Best of luck.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.