Dynamic forms with angular

In this post I am going to explain my approach to generate dynamic forms with angular. Therefore I set up a small ng-cli based project called formdef that shows a working sample.

See the sample application hosted by StackBlitz in action here.

My requirement

If your frontend runs on top of some restful backend you usually end up consuming some json based objects in the client - your viewmodels. Such a viewmodel then somehow gets presented to the user within a form. The form itself allows to create, modify or delete that data by applying the changes to the viewmodel and sending it back to the appropriate api endpoint.

If you have a small application with just a couple of forms it is totally fine to create the required form templates for each specific purpose. But my daily business requires me to write quite a lot of forms. That’s where my approach to dynamic forms with angular can help.

The goal: Generate a form based on a viewmodel and a form definition.

The idea

The basic workflow of what I came up looks like this:

  1. take a viewmodel
  2. take the appropriate form definition for that viewmodel
  3. create a FormGroup by merging viewmodel with form definition
  4. render it with a Component

The contracts

Before I start to explain the concept let’s briefly have a look at our contracts. An Editor is the interface to any kind of input element, i.e. input, checkbox, select, etc. The Slot on the other hand defines the structure of our form and should reflect the viewmodel object graph. Additionally the Slot carries the information about the different editors.

export interface Editor {
  type: string;
  name: string;
  label: string;
  value?: any;
  options?: any;
  required?: boolean;
  size?: number;
  valueMin?: number;
  valueMax?: number;
}

export interface Slot {
  key: string;
  type: string;
  title: string;
  editors: Array<Editor>;
  children: Array<Slot>;
}

Register form definitions

The FormdefRegistry acts as the application wide registry for form definitions. Simple as that…!

@Injectable()
export class FormdefRegistry {
  private _registry: Map<string, Slot>;

  public constructor() {
    this._registry = new Map<string, Slot>();
  }

  public register(slot: Slot) {
    this._registry.set(slot.key, slot);
  }

  public get(key: string): Slot {
    return this._registry.get(key);
  }
}

The heart - the FormdefService

The FormdefService has all it needs to merge our viewmodel together with the form definition. The result is a FormGroup that can be used like described in the Reactive Forms section in the fabulous angular tutorials.

@Injectable()
export class FormdefService {
  public constructor(
    private _slotRegistry: FormdefRegistry,
    private _fb: FormBuilder
  ) { }

  public toGroup(key: string, viewModel: any): FormGroup {
    const slot = this.getSlot(key);

    const fg = this.toGroupRecursive(slot, viewModel);

    return <FormGroup>fg;
  }

  public getSlot(key: string): Slot {
    return this._slotRegistry.get(key);
  }

  public createRow(arraySlot: Slot, template: FormGroup): FormGroup {
    const row = this._fb.group({});

    arraySlot.editors.forEach((e: Editor) => {
      row.addControl(e.name, new FormControl(undefined, this.getValidators(e)));
    });

    return row;
  }

  private toGroupRecursive(slot: Slot, viewModel: any): FormGroup | FormArray {
    const fg = this._fb.group({});
    let fa: FormArray;

    if (slot.type === SINGLE_SLOT) {
      slot.editors.forEach((e: Editor) => {
        e.value = viewModel[e.name];
        fg.addControl(e.name, new FormControl(e.value, this.getValidators(e)));
      });
    }

    if (slot.type === ARRAY_SLOT
      && Array.isArray(viewModel)) {

      fa = this._fb.array([]);

      for (let i = 0; i < viewModel.length; i++) {
        const vm = viewModel[i];
        const row = this._fb.group({});

        slot.editors.forEach((e: Editor) => {
          const value = vm[e.name];
          row.addControl(e.name, new FormControl(value, this.getValidators(e)));
        });

        fa.push(row);
      }

      return fa;
    }

    if (slot.children && slot.children.length > 0) {
      slot.children.forEach((child: Slot) => {
        fg.addControl(child.key, this.toGroupRecursive(child, viewModel[child.key]));
      });
    }

    return fg;
  }

  private getValidators(editor: Editor): ValidatorFn {
    const validators: Array<ValidatorFn> = new Array<ValidatorFn>();

    if (editor.required) {
      validators.push(Validators.required);
    }
    if (editor.size) {
      validators.push(Validators.maxLength(editor.size));
    }
    if (editor.valueMin) {
      validators.push(Validators.min(editor.valueMin));
    }
    if (editor.valueMax) {
      validators.push(Validators.max(editor.valueMax));
    }

    return Validators.compose(validators);
  }
}

Our example viewmodel represents a contact and looks like this:

public viewModel = {
  surname: 'Thomas',
  lastname: 'Duft',
  isDefault: true,
  gender: 'm',
  year: 1980,
  address: {
    street: 'Some street',
    zip: '12345'
  },
  phones: [
    { type: 'p', number: '0123456' },
    { type: 'o', number: '987654' }
  ]
};

The form definition object to our contact viewmodel looks like this:

export class ContactSlot implements Slot {
  public static KEY = 'contactslot';

  public key = ContactSlot.KEY;
  public type = SINGLE_SLOT;
  public title = 'Contact';
  public editors: Editor[];
  public children: Slot[];

  public constructor() {
    this.editors = [
      {
        type: TEXT_EDITOR,
        name: 'surname',
        label: 'Surname',
        required: true
      },
      {
        type: TEXT_EDITOR,
        name: 'lastname',
        label: 'Last name',
        size: 20
      },
      {
        type: CHECKBOX_EDITOR,
        name: 'isDefault',
        label: 'Use as default'
      },
      {
        type: SELECT_EDITOR,
        name: 'gender',
        label: 'Gender',
        required: true,
        options: [
          { key: 'm', value: 'male' },
          { key: 'f', value: 'female' }
        ]
      },
      {
        type: NUMBER_EDITOR,
        name: 'year',
        label: 'Year',
        required: true,
        valueMin: 1900,
        valueMax: 2100
      },
    ];
    this.children = [
      {
        key: 'address',
        type: SINGLE_SLOT,
        title: 'Address',
        editors: [
          {
            type: TEXT_EDITOR,
            name: 'street',
            label: 'Street'
          },
          {
            type: TEXT_EDITOR,
            name: 'zip',
            label: 'Zip'
          }
        ],
        children: []
      },
      {
        key: 'phones',
        type: ARRAY_SLOT,
        title: 'Phones',
        editors: [
          {
            type: SELECT_EDITOR,
            name: 'type',
            label: 'Type',
            required: true,
            options: [
              { key: 'p', value: 'private' },
              { key: 'o', value: 'office' }
            ]
          },
          {
            type: TEXT_EDITOR,
            name: 'number',
            label: 'Number',
            required: true,
            size: 10
          }
        ],
        children: []
      }
    ];
  }
}

The result of the merging operation will be a FormGroup.

Rendering the form - the FormdefComponent

In order to display the dynamic form to the user I came up with the SlotComponent, a component that has the ability to render the tree of a particular Slot recursively. It is hosted within the FormdefComponent.

If you want to play around then it is now time to go here!

Benefits

  • The submitted FormGroup value is equal to our viewmodel and can easily be used in a put or post request dependent of what you want to achieve.
  • Within the application I have a common forms/ux experience.
  • The form definitions could also be part of an api instead of hard coding it in the client.

Summary

The formdef module shown in the sample app contains the required logic to satisfy my requirements. For sure there is lot’s of room to improve but so far I am happy.

What are your experiences with dynamic forms in angular?

Let me know… Thomas

Privacy & Cookies: This site uses cookies. By continuing to use this website, you agree to their use. To find out more, see here.