Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / Languages / Typescript

Generate Typed FormGroup of Angular Reactive Forms with ASP.NET Core Web API

5.00/5 (2 votes)
9 Jan 2024CPOL6 min read 2.9K  
Construct Angular Reactive Forms of client data models through generated codes
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:

  1. Import Fonlow.WebApiClientGenCore.NG2FormGroup v1.2 or above instead of Fonlow.WebapiclientGenCore.NG2.
  2. Define the following in CodeGen.json
JavaScript
{
    "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

C#
[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)] //just for testing multiple validations
    [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.

JavaScript
export interface Hero {
    address?: DemoWebApi_DemoData_Client.Address;
    death?: Date | null;
    dob?: Date | null;
    id?: number | null;

    /**
     * Required
     * String length: inclusive between 2 and 120
     */
    name?: string | null;
    phoneNumbers?: Array<DemoWebApi_DemoData_Client.PhoneNumber>;
}

export namespace DemoWebApi_DemoData_Client {
    export interface Address {

        /** String length: inclusive between 2 and 50 */
        city?: string | null;

        /** String length: inclusive between 2 and 30 */
        country?: string | null;
        id?: string | null;

        /** String length: inclusive between 2 and 10 */
        postalCode?: string | null;

        /** String length: inclusive between 2 and 30 */
        state?: string | null;

        /** String length: inclusive between 2 and 100 */
        street1?: string | null;

        /** String length: inclusive between 2 and 100 */
        street2?: string | null;
        type?: DemoWebApi_DemoData_Client.AddressType | null;

        /**
         * It is a field
         */
        location?: DemoWebApi_DemoData_Another_Client.MyPoint;
    }

    export interface PhoneNumber {

        /** Max length: 120 */
        fullNumber?: string | null;
        phoneType?: DemoWebApi_DemoData_Client.PhoneType | null;
    } 

Generated FormGroup Codes for Application Programming

JavaScript
export interface HeroFormProperties {
    death: FormControl<Date | null | undefined>,
    dob: FormControl<Date | null | undefined>,
    emailAddress: FormControl<string | null | undefined>,
    id: FormControl<number | null | undefined>,

    /**
     * Required
     * String length: inclusive between 2 and 120
     */
    name: FormControl<string | null | undefined>,

    /** Min length: 6 */
    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.

JavaScript
export interface AddressFormProperties {

    /** String length: inclusive between 2 and 50 */
    city: FormControl<string | null | undefined>,

    /** String length: inclusive between 2 and 30 */
    country: FormControl<string | null | undefined>,
    id: FormControl<string | null | undefined>,

    /** String length: inclusive between 2 and 10 */
    postalCode: FormControl<string | null | undefined>,

    /** String length: inclusive between 2 and 30 */
    state: FormControl<string | null | undefined>,

    /** String length: inclusive between 2 and 100 */
    street1: FormControl<string | null | undefined>,

    /** String length: inclusive between 2 and 100 */
    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 {

    /** Max length: 120 */
    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.

JavaScript
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); // populate properties
                             // including composit ones except nested array.
                        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)
            });
        });
    }

   ...
}
HTML
<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:

JavaScript
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:

TypeScript
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:

TypeScript
{...... "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.

JavaScript
/**
 * Remove null or empty fields including those in nested objects.
 * This is useful for reducing payload of AJAX serialization.
 * @param obj
 */
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:

JavaScript
export namespace MyNS {

    export interface Pet {

        /** Pet ID */
        id?: number | null;

        /** Categories this pet belongs to */
        category?: Category;

        /**
         * The name given to a pet
         * Required
         */
        name: string;

        /**
         * The list of URL to a cute photos featuring pet
         * Required
         * Maximum items: 20
         */
        photoUrls: Array<string>;
        friend?: Pet;

        /**
         * Tags attached to the pet
         * Minimum items: 1
         */
        tags?: Array<Tag>;

        /** Pet status in the store */
        status?: PetStatus | null;

        /** Type of a pet */
        petType?: string | null;
    }
    export interface PetFormProperties {

        /** Pet ID */
        id: FormControl<number | null | undefined>,

        /**
         * The name given to a pet
         * Required
         */
        name: FormControl<string | null | undefined>,

        /** Pet status in the store */
        status: FormControl<PetStatus | null | undefined>,

        /** Type of a pet */
        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),
        });
    }

    /** A representation of a cat */
    export interface Cat extends Pet {

        /**
         * The measured skill for hunting
         * Required
         */
        huntingSkill: CatHuntingSkill;
    }

    /** A representation of a cat */
    export interface CatFormProperties extends PetFormProperties {

        /**
         * The measured skill for hunting
         * Required
         */
        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 {

        /** Category ID */
        id?: number | null;

        /**
         * Category name
         * Min length: 1
         */
        name?: string | null;

        /** Test Sub Category */
        sub?: CategorySub;
    }
    export interface CategoryFormProperties {

        /** Category ID */
        id: FormControl<number | null | undefined>,

        /**
         * Category name
         * Min length: 1
         */
        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 {

        /** Dumb Property */
        prop1?: string | null;
    }
    export interface CategorySubFormProperties {

        /** Dumb Property */
        prop1: FormControl<string | null | undefined>,
    }
    export function CreateCategorySubFormGroup() {
        return new FormGroup<CategorySubFormProperties>({
            prop1: new FormControl<string | null | undefined>(undefined),
        });
    }

    /** A representation of a dog */
    export interface Dog extends Pet {

        /**
         * The size of the pack the dog is from
         * Required
         * Minimum: 1
         */
        packSize: number;
    }

    /** A representation of a dog */
    export interface DogFormProperties extends PetFormProperties {

        /**
         * The size of the pack the dog is from
         * Required
         * Minimum: 1
         */
        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

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)