Dynamic Forms in Angular: Simple Yet Powerful

Awab AbdounAwab Abdoun
4 min read

Introduction

Building dynamic forms can be complex. In this blog post, we'll cover a simple yet powerful way of creating dynamic forms in Angular. We'll walk through the process of creating a form schema, converting it to Angular's ReactiveForms controls, and finally building and using the form in our Angular component.

Creating the Dynamic Form

To begin, we need to create a schema object that defines the form controls, validators, and other metadata. We can model and save this schema to a backend of our choice and load it through an API call. Here's an example of a form schema:

formSchema: FormSchemaItem[] = [
    {
      key: 'name',
      name: 'Full Name',
      value: '',
      type: 'text',
      validators: [
        {
          type: FormSchemaValidatorTypes.REQUIRED,
        },
        {
          type: FormSchemaValidatorTypes.MAX_LENGTH,
          argument: 25,
        },
        {
          type: FormSchemaValidatorTypes.MIN_LENGTH,
          argument: 3,
        },
      ],
    },
    {
      key: 'age',
      name: 'Age',
      value: '',
      type: 'number',
      validators: [
        {
          type: FormSchemaValidatorTypes.REQUIRED,
        },
        {
          type: FormSchemaValidatorTypes.MAX,
          argument: 60,
        },
        {
          type: FormSchemaValidatorTypes.MIN,
          argument: 18,
        },
      ],
    },
    {
      key: 'email',
      name: 'Email',
      value: '',
      type: 'email',
      validators: [
        {
          type: FormSchemaValidatorTypes.REQUIRED,
        },
        {
          type: FormSchemaValidatorTypes.EMAIL,
        },
      ],
    },
    {
      key: 'phoneNumber',
      name: 'Phone Number',
      value: '',
      type: 'number',
      validators: [
        {
          type: FormSchemaValidatorTypes.REQUIRED,
        },
        {
          type: FormSchemaValidatorTypes.PATTERN,
          argument: /\d/,
        },
      ],
    },
    {
      key: 'address',
      name: 'Full Address',
      value: '',
      type: 'text',
      validators: [
        {
          type: FormSchemaValidatorTypes.PATTERN,
          argument: /[a-z]/,
        },
        {
          type: FormSchemaValidatorTypes.MAX_LENGTH,
          argument: 100,
        },
      ],
    },
    {
      key: 'date',
      name: 'Date',
      value: '',
      type: 'date',
      validators: [],
    },
  ];

Next, we define the types used in the schema, including form item definitions and validators. These types help in maintaining a structured and consistent form schema.

import { FormControl } from '@angular/forms';

export type FormSchemaItemAndControl = {
  item: FormSchemaItem;
  control: FormControl;
};

export type FormSchemaItem = {
  key: string;
  name: string;
  value: any;
  type: 'text' | 'number' | 'tel' | 'email' | 'date';
  validators: FormSchemaValidator[];
};

export type FormSchemaValidator = {
  type: FormSchemaValidatorTypes;
  argument?: any;
};

export enum FormSchemaValidatorTypes {
  REQUIRED = 'required',
  MAX = 'max',
  MIN = 'min',
  MAX_LENGTH = 'maxLength',
  MIN_LENGTH = 'minLength',
  EMAIL = 'email',
  PATTERN = 'pattern',
}

Converting Schema to Form Controls

We can now create a function that converts our custom schema to Angular's ReactiveForms controls. This function called createFormControls maps each item in the schema to a corresponding form control using Angular's FormBuilder. We also load the validators specified in the schema. Here's the code snippet:

createFormControls(schema: FormSchemaItem[]): FormSchemaItemAndControl[] {
    const formSchemaWithControls = schema.map((item) => {
      return {
        item: item,
        control: this.fb.control(
          item.value,
          this.loadValidators(item.validators)
        ),
      };
    });

    return formSchemaWithControls;
}

Additionally, we have a loadValidators function that maps the custom validators to Angular's built-in validators.

loadValidators(validators: FormSchemaValidator[]): any {
    const validations = validators.map((element) => {
      if (element.argument) {
        return Validators[element.type](element.argument);
      }

      return Validators[element.type];
    });

    return validations;
}

Finally, we define the createFormGroup function that creates a form group for all the controls. This function adds each control to the form group using the control's key from the schema.

createFormGroup(
    formSchemaWithControls: FormSchemaItemAndControl[]
): UntypedFormGroup {
    const form = this.fb.group({});

    formSchemaWithControls.forEach((item) => {
      form.addControl(item.item.key, item.control);
    });

    return form;
}

Using the form service

In our component.ts file, we can now build and use the dynamic form by injecting the form service and creating the form. Inside the createForm method, we call createFormControls to populate the formSchemaWithControls array with the converted form controls. Then, we use createFormGroup to create the form group for our dynamic form.

formSchemaWithControls: FormSchemaItemAndControl[] = [];
formGroup!: UntypedFormGroup;

constructor(private formService: FormService) {}

ngOnInit(): void {
    this.createForm();
}

createForm(): void {
    this.formSchemaWithControls = this.formService.createFormControls(
      this.formService.formSchema
    );

    this.formGroup = this.formService.createFormGroup(
      this.formSchemaWithControls
    );
}

submit() {
    console.log(this.formGroup.value);
}

In the HTML template, we can dynamically generate the form by looping through the controls and binding them to the respective input fields. We also display any validation errors associated with each control.

<div class="container">
  <form
    class="form"
    *ngIf="formSchemaWithControls"
    (ngSubmit)="submit()"
    [formGroup]="formGroup"
  >
    <div class="form-item" *ngFor="let element of formSchemaWithControls">
      <input
        [type]="element.item.type"
        [placeholder]="element.item.name"
        [formControl]="element.control"
      />
      <div
        class="form-error"
        *ngIf="
          element.control.errors &&
          (element.control.dirty || element.control.touched)
        "
      >
        <p *ngFor="let error of element.control.errors | keyvalue">
          {{ element.item.name + " " + error.key }}
        </p>
      </div>
    </div>

    <button [disabled]="formGroup.invalid" type="submit">Submit</button>
  </form>
</div>

Conclusion

With the implementation of dynamic forms in Angular using the provided techniques, we have successfully created a form that adapts to changing requirements. The power of Angular's ReactiveForms, combined with a well-defined form schema, allows us to build robust and user-friendly dynamic forms. By following the steps outlined in this blog post, you can enhance the form-building experience in your Angular applications and create dynamic forms with ease.

And that's it! We now have a fully functional dynamic form based on the schema, complete with validations. Feel free to experiment with different schema configurations and expand upon this foundation to suit your specific needs.

You can find the source code in this GitHub Repository.

0
Subscribe to my newsletter

Read articles from Awab Abdoun directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Awab Abdoun
Awab Abdoun