This article showcases the automation of crafting Angular Reactive Forms for complex business applications, utilizing the Angular FormGroup Plugin of WebApiClientGen to generate typed FormGroups with validations, streamlining development and reducing manual adjustments during ASP.NET Core Web API changes, while also addressing complexities in object structures and arrays, and extending the approach to OpenAPI definitions through OpenApiClientGen.
Introduction
You are constructing both ASP.NET Core Web API and Angular apps.
When developing fat Web clients (SPA, PWA) of a complex business application using Angular 2+, you prefer to use typed Reactive Forms over Template-Driven Forms for data entries. This article introduces how to automate the crafting of codes of Reactive Forms in real world projects.
Background
Rather than writing your own AJAX calls using HttpClient
, likely you have been using a client library generated by NSwag or WebApiClientGen or alike. So for consuming API responses or constructing API payloads, you use the client data models in the client library. So far so good.
When using Reactive Forms, you have to manually craft FormGroups
, FormControls
and FormArray
etc., with logical structures reassembling the client data models. And every time the Web API is upgraded with changes in data models, you will have to adjust respective codes of Reactive Forms.
Would it be nice to automate the construction of FormGroups
?
Short Answer
Using the Code
If you have never used WebApiClientGen
for your Angular app along with ASP.NET (Core) Web API, please walk through the following first:
Prerequisites
In addition to what is described in Generate TypeScript Client API for ASP.NET Core Web API, you need to adjust the import and the code gen payload:
- Import Fonlow.WebApiClientGenCore.NG2FormGroup v1.2 or above instead of
Fonlow.WebapiclientGenCore.NG2
. - Define the following in CodeGen.json
{
"AssemblyName": "Fonlow.WebApiClientGenCore.NG2FormGroup",
"TargetDir": "..\\..\\..\\..\\..\\HeroesDemo\\src\\ClientApi",
"TSFile": "WebApiCoreNG2FormGroupClientAuto.ts",
"AsModule": true,
"ContentType": "application/json;charset=UTF-8",
"ClientNamespaceSuffix": ".Client",
"ContainerNameSuffix": "",
"DataAnnotationsToComments": true,
"HelpStrictMode": true
},
Backend Data Types
[DataContract(Namespace = DemoWebApi.DemoData.Constants.DataNamespace)]
public class Hero
{
public Hero(long id, string name)
{
Id = id;
Name = name;
PhoneNumbers = new List<DemoWebApi.DemoData.PhoneNumber>();
}
[DataMember]
public long Id { get; set; }
[DataMember]
[Required]
[StringLength(120, MinimumLength = 2)]
public string Name { get; set; }
[DataMember]
public DateOnly DOB { get; set; }
[DataMember]
public DateOnly? Death { get; set; }
[DataMember]
[EmailAddress]
public string EmailAddress { get; set; }
[DataMember]
public DemoWebApi.DemoData.Address Address { get; set; }
[DataMember]
[MinLength(6)]
[RegularExpression(@"https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]
{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()@:%_\\+.~#?&//=]*)")]
public string WebAddress { get; set; }
[DataMember]
public virtual IList<DemoWebApi.DemoData.PhoneNumber> PhoneNumbers { get; set; }
}
Comparing with the Hero type in Tour of Heroes, this hero includes a property of complex type and a property of complex type array. This is to demonstrate how to use the generated form groups in a real world project.
Generated Client Data Types in TypeScript
The full source codes of the modified Tour of Heroes is HeroesDemo with extra data properties and Angular Material Components.
export interface Hero {
address?: DemoWebApi_DemoData_Client.Address;
death?: Date | null;
dob?: Date | null;
id?: number | null;
name?: string | null;
phoneNumbers?: Array<DemoWebApi_DemoData_Client.PhoneNumber>;
}
export namespace DemoWebApi_DemoData_Client {
export interface Address {
city?: string | null;
country?: string | null;
id?: string | null;
postalCode?: string | null;
state?: string | null;
street1?: string | null;
street2?: string | null;
type?: DemoWebApi_DemoData_Client.AddressType | null;
location?: DemoWebApi_DemoData_Another_Client.MyPoint;
}
export interface PhoneNumber {
fullNumber?: string | null;
phoneType?: DemoWebApi_DemoData_Client.PhoneType | null;
}
Generated FormGroup Codes for Application Programming
export interface HeroFormProperties {
death: FormControl<Date | null | undefined>,
dob: FormControl<Date | null | undefined>,
emailAddress: FormControl<string | null | undefined>,
id: FormControl<number | null | undefined>,
name: FormControl<string | null | undefined>,
webAddress: FormControl<string | null | undefined>,
}
export function CreateHeroFormGroup() {
return new FormGroup<HeroFormProperties>({
death: new FormControl<Date | null | undefined>(undefined),
dob: new FormControl<Date | null | undefined>(undefined),
emailAddress: new FormControl<string | null | undefined>
(undefined, [Validators.email]),
id: new FormControl<number | null | undefined>(undefined),
name: new FormControl<string | null | undefined>(undefined,
[Validators.required, Validators.maxLength(120), Validators.minLength(2)]),
webAddress: new FormControl<string | null | undefined>(undefined,
[Validators.minLength(6), Validators.pattern('https?:\\/\\/(www\\.)?
[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b
([-a-zA-Z0-9()@:%_\\+.~#?&//=]*)')]),
});
}
Noted that the properties of complex types like "address
" and "phoneNumbers
" are skipped in the generated codes, and I will explain next.
Nevertheless, you see that the data constraints declared in the service data types are mapped to validators.
export interface AddressFormProperties {
city: FormControl<string | null | undefined>,
country: FormControl<string | null | undefined>,
id: FormControl<string | null | undefined>,
postalCode: FormControl<string | null | undefined>,
state: FormControl<string | null | undefined>,
street1: FormControl<string | null | undefined>,
street2: FormControl<string | null | undefined>,
type: FormControl<DemoWebApi_DemoData_Client.AddressType | null | undefined>,
}
export function CreateAddressFormGroup() {
return new FormGroup<AddressFormProperties>({
city: new FormControl<string | null | undefined>
(undefined, [Validators.maxLength(50), Validators.minLength(2)]),
country: new FormControl<string | null | undefined>
(undefined, [Validators.maxLength(30), Validators.minLength(2)]),
id: new FormControl<string | null | undefined>(undefined),
postalCode: new FormControl<string | null | undefined>
(undefined, [Validators.maxLength(10), Validators.minLength(2)]),
state: new FormControl<string | null | undefined>
(undefined, [Validators.maxLength(30), Validators.minLength(2)]),
street1: new FormControl<string | null | undefined>
(undefined, [Validators.maxLength(100), Validators.minLength(2)]),
street2: new FormControl<string | null | undefined>
(undefined, [Validators.maxLength(100), Validators.minLength(2)]),
type: new FormControl<DemoWebApi_DemoData_Client.AddressType |
null | undefined>(undefined),
});
}
export interface PhoneNumberFormProperties {
fullNumber: FormControl<string | null | undefined>,
phoneType: FormControl<DemoWebApi_DemoData_Client.PhoneType | null | undefined>,
}
export function CreatePhoneNumberFormGroup() {
return new FormGroup<PhoneNumberFormProperties>({
fullNumber: new FormControl<string | null | undefined>
(undefined, [Validators.maxLength(16)]),
phoneType: new FormControl<DemoWebApi_DemoData_Client.PhoneType |
null | undefined>(undefined),
});
}
Therefore, through the generated FormGroups
codes, you introduce client side validations. This reduces the round trips of backend validations, and improve overall UX, especially with Angular Material UI Component Library.
Application Programming
Commonly in business applications, relational databases are in use. A normalized database schema commonly splits the nested properties of an object data model into multiple tables, linked through foreign keys to form one to many or many to many relationships. If you are using an ORM like Entity Framework, EF can generate multiple insert statements upon multiple tables to persist a new object with nested properties. However, when updating, generally you have to update each nested structure accordingly.
Depending on the overall UX design, business constraints and technical constraints, you make respective design decisions during application programming when constructing Angular reactive forms. This is why NG2FormGroup
skips properties of complex types and array.
However, if your design decision is to add and update a complex object with nested structures always in one go, it is still easy to utilize the generated codes, as demonstrated below:
Through inheritance and composition, in the application codes, you create a FormGroup
including all properties of the Hero
type.
import { Location } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { FormArray, FormGroup } from '@angular/forms';
import { ActivatedRoute, Params } from '@angular/router';
import { DemoWebApi_Controllers_Client, DemoWebApi_DemoData_Client }
from '../../clientapi/WebApiCoreNG2FormGroupClientAuto';
export interface HeroWithNestedFormProperties
extends DemoWebApi_Controllers_Client.HeroFormProperties {
address?: FormGroup<DemoWebApi_DemoData_Client.AddressFormProperties>,
phoneNumbers?: FormArray<FormGroup
<DemoWebApi_DemoData_Client.PhoneNumberFormProperties>>,
}
export function CreateHeroWithNestedFormGroup() {
const fg: FormGroup<HeroWithNestedFormProperties> =
DemoWebApi_Controllers_Client.CreateHeroFormGroup();
fg.controls.address = DemoWebApi_DemoData_Client.CreateAddressFormGroup();
fg.controls.phoneNumbers = new FormArray<FormGroup
<DemoWebApi_DemoData_Client.PhoneNumberFormProperties>>([]);
return fg;
}
@Component({
selector: 'app-hero-detail',
templateUrl: './hero-detail.component.html'
})
export class HeroDetailComponent implements OnInit {
hero?: DemoWebApi_Controllers_Client.Hero;
heroForm: FormGroup<HeroWithNestedFormProperties>;
constructor(
private heroService: DemoWebApi_Controllers_Client.Heroes,
private route: ActivatedRoute,
private location: Location
) {
this.heroForm = CreateHeroWithNestedFormGroup();
}
ngOnInit(): void {
this.route.params.forEach((params: Params) => {
const id = +params['id'];
this.heroService.getHero(id).subscribe({
next: hero => {
if (hero) {
this.hero = hero;
this.heroForm.patchValue(hero);
if (this.hero.phoneNumbers) {
this.hero.phoneNumbers.forEach(d => {
const g = DemoWebApi_DemoData_Client.
CreatePhoneNumberFormGroup();
g.patchValue(d);
this.heroForm.controls.phoneNumbers?.push(g);
});
}
}
},
error: error => alert(error)
});
});
}
...
}
<div *ngIf="hero">
<h2>{{hero.name | uppercase}} Details</h2>
<div><span>id: </span>{{hero.id}}</div>
<div [formGroup]="heroForm">
<label for="hero-name">Hero name: </label>
<mat-form-field>
<mat-label>Name</mat-label>
<input matInput id="hero-name" formControlName="name" />
<mat-error *ngIf="heroForm.controls.name.hasError">
{{getErrorsText(heroForm.controls.name.errors)}}</mat-error>
</mat-form-field>
<input matInput id="hero-dob" type="date"
formControlName="dob" placeholder="DOB" />
<input matInput id="hero-death" type="date" formControlName="death"
placeholder="Death" />
<div>
<mat-form-field>
<mat-label>Email</mat-label>
<input matInput formControlName="emailAddress"
placeholder="name@domain" />
<mat-error *ngIf="heroForm.controls.emailAddress.hasError">
{{getErrorsText(heroForm.controls.emailAddress.errors)}}</mat-error>
</mat-form-field>
</div>
<div>
<mat-form-field>
<mat-label>Web</mat-label>
<input matInput formControlName="webAddress" />
<mat-error *ngIf="heroForm.controls.webAddress.hasError">
{{getErrorsText(heroForm.controls.webAddress.errors)}}</mat-error>
</mat-form-field>
</div>
<div formGroupName="address">
<mat-form-field>
<mat-label>Street</mat-label>
<input matInput formControlName="street1" />
<mat-error *ngIf="heroForm.controls.address?.controls?.street1?.hasError">
{{getErrorsText(heroForm.controls.address?.controls?.street1?.errors)}}
</mat-error>
</mat-form-field>
<mat-form-field>
<mat-label>City</mat-label>
<input matInput formControlName="city" />
<mat-error *ngIf="heroForm.controls.address?.controls?.city?.hasError">
{{getErrorsText(heroForm.controls.address?.controls?.city?.errors)}}
</mat-error>
</mat-form-field>
<mat-form-field>
<mat-label>State</mat-label>
<input matInput formControlName="state" />
<mat-error *ngIf="heroForm.controls.address?.controls?.state?.hasError">
{{getErrorsText(heroForm.controls.address?.controls?.state?.errors)}}
</mat-error>
</mat-form-field>
<mat-form-field>
<mat-label>Country</mat-label>
<input matInput formControlName="country" />
<mat-error *ngIf="heroForm.controls.address?.controls?.country?.hasError">
{{getErrorsText(heroForm.controls.address?.controls?.country?.errors)}}
</mat-error>
</mat-form-field>
</div>
<div *ngFor="let pg of heroForm.controls.phoneNumbers!.controls" [formGroup]="pg">
<mat-form-field>
<mat-label>Number</mat-label>
<input matInput formControlName="fullNumber" />
<button mat-mini-fab color="any"
matSuffix (click)="removePhoneNumber(pg)">X</button>
<mat-error *ngIf="pg.hasError">
{{getErrorsText(pg.controls.fullNumber.errors)}}</mat-error>
</mat-form-field>
</div>
<div>
<button mat-raised-button (click)="addPhoneNumber()">Add Phone Number</button>
</div>
</div>
<button mat-raised-button type="button" (click)="goBack()">go back</button>
<button mat-raised-button type="button" (click)="save()"
[disabled]="!allNestedValid(heroForm)">save</button>
</div>
Points of Interest
FormGroup.patchValue and .getRawValue as of Angular 17
FormGroup.patchValue
will populate all Form Controls and nested Form Groups except nested Form Arrays. Not too bad, since such codes could compensate:
if (this.hero.phoneNumbers) {
this.hero.phoneNumbers.forEach(d => {
const g = DemoWebApi_DemoData_Client.CreatePhoneNumberFormGroup();
g.patchValue(d);
this.heroForm.controls.phoneNumbers?.push(g);
});
}
FormGroup.getRawValue
will read all Form Controls, nested Form Groups and nested Form Arrays.
This looks a bit inconsistent. However, I am not sure if being consistent is really significant for application programming with Reactive Forms. Or is it better to let programmers decide whether to populate Form Arrays during application programming? Please leave your comment.
Null or undefined as Default Value of FormControl
Probably you have noticed, the generated form controls are always with:
aField: new FormControl<PrimitiveType | null | undefined>(undefined)
However, even if the field is untouched, formControl.defaultValue is null even if the generated codes provide undefined,
and the behaviour of FormGroup.getRawValue() is always outputing:
{...... "aField": null ......}
You may not desire this because of the application protocol between the frontend and the backend, and you might prefer that an undefined and untouched field won't appear in the request payload, especially when by default ASP.NET (Core) Web API won't return those properties with null value in the response payload.
When I designed both the frontend and the backend, I applied a helper function to clean up the null value of fields of each request payload of JSON data.
static removeNullOrEmptyFields(obj: any) {
for (const f in obj) {
let p = obj[f];
if (p === null || p === '') {
delete obj[f];
} else if (typeof p === 'object' && p !== null) {
this.removeNullOrEmptyFields(p);
}
}
}
Nevertheless, this can't be a universal solution / workaround. Keeping such defect of FormGroup.getRawValue() in mind, you should come up with your own solution based on your application protocols.
Hints:
- You may ask why not having
FormControl<PrimitiveType | null>
instead. This is because often in the application codes, FormControl.setValue(v)
may receive a value which is potentially undefined
. And such approach will trigger less tlint or compiler warnings/errors.
Remarks:
- I had reported this defect to the Angular team on #40608, and the other guy had raised the same issue again on #47027. Hopefully you can upvote the ticket.
Client Only Data Models
As of Angular 17, for client only data models, you need to craft typed Form Groups manually. And I have made a proposal to the Angular team: "Generate typed FormGroup including validations from interface/model through declarative info of validations". If you like the idea that may be beneficial to your application programming with Reactive Forms, please upvote the issue to make this happen sooner.
How About OpenAPI?
I had copied the same feature for Angular Reactive Forms to OpenApiClientGen which shares TypeCodeCodeDom
and Poco2Ts of the WebApiClientGen
repository. If you have a Swagger/OpenAPI definition file and would like to generate codes of Angular Reactive Forms, you may try Fonlow.OpenApiClientGen.exe v2.7 or above along with its plugin OpenApiClientGenCore.NG2FormGroup
v1.6. If your dev machine is Mac, you may use BuildForMac.bat to make a release of OpenApiClientGen
for MacOS.
For example, upon definition pet.yaml, OpenApiClientGen
generates Angular Reactive Forms codes:
export namespace MyNS {
export interface Pet {
id?: number | null;
category?: Category;
name: string;
photoUrls: Array<string>;
friend?: Pet;
tags?: Array<Tag>;
status?: PetStatus | null;
petType?: string | null;
}
export interface PetFormProperties {
id: FormControl<number | null | undefined>,
name: FormControl<string | null | undefined>,
status: FormControl<PetStatus | null | undefined>,
petType: FormControl<string | null | undefined>,
}
export function CreatePetFormGroup() {
return new FormGroup<PetFormProperties>({
id: new FormControl<number | null | undefined>(undefined),
name: new FormControl<string | null | undefined>
(undefined, [Validators.required]),
status: new FormControl<PetStatus | null | undefined>(undefined),
petType: new FormControl<string | null | undefined>(undefined),
});
}
export interface Cat extends Pet {
huntingSkill: CatHuntingSkill;
}
export interface CatFormProperties extends PetFormProperties {
huntingSkill: FormControl<CatHuntingSkill | null | undefined>,
}
export function CreateCatFormGroup() {
return new FormGroup<CatFormProperties>({
id: new FormControl<number | null | undefined>(undefined),
name: new FormControl<string | null | undefined>
(undefined, [Validators.required]),
status: new FormControl<PetStatus | null | undefined>(undefined),
petType: new FormControl<string | null | undefined>(undefined),
huntingSkill: new FormControl<CatHuntingSkill | null | undefined>
(undefined, [Validators.required]),
});
}
export enum CatHuntingSkill
{ clueless = 0, lazy = 1, adventurous = 2, aggressive = 3 }
export interface Category {
id?: number | null;
name?: string | null;
sub?: CategorySub;
}
export interface CategoryFormProperties {
id: FormControl<number | null | undefined>,
name: FormControl<string | null | undefined>,
}
export function CreateCategoryFormGroup() {
return new FormGroup<CategoryFormProperties>({
id: new FormControl<number | null | undefined>(undefined),
name: new FormControl<string | null | undefined>
(undefined, [Validators.minLength(1)]),
});
}
export interface CategorySub {
prop1?: string | null;
}
export interface CategorySubFormProperties {
prop1: FormControl<string | null | undefined>,
}
export function CreateCategorySubFormGroup() {
return new FormGroup<CategorySubFormProperties>({
prop1: new FormControl<string | null | undefined>(undefined),
});
}
export interface Dog extends Pet {
packSize: number;
}
export interface DogFormProperties extends PetFormProperties {
packSize: FormControl<number | null | undefined>,
}
export function CreateDogFormGroup() {
return new FormGroup<DogFormProperties>({
id: new FormControl<number | null | undefined>(undefined),
name: new FormControl<string | null | undefined>
(undefined, [Validators.required]),
status: new FormControl<PetStatus | null | undefined>(undefined),
petType: new FormControl<string | null | undefined>(undefined),
packSize: new FormControl<number | null | undefined>
(undefined, [Validators.required, Validators.min(1)]),
});
}
Please also refer to the mappings between Swagger / OpenApi data constraints and Angular Validators, and article "Generate Typed Forms of Angular Reactive Forms from Swagger / OpenAPI definitions".
History
- 9th January, 2024: Initial version