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?
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>({
...
});
}