DEV Community

loading...
Cover image for Reusable Angular forms in no time

Reusable Angular forms in no time

Julian Finkler
Programmierer aus Leidenschaft. Ich lege besonders Wert auf Clean Code und liebe es mich durch legacy Code zu wühlen... und den dann zu refaktorieren 😉.
・7 min read

Imagine this situation:

Your team lead assigns you the task to create a list view (e.g.
a list of orders) and a form view to edit the list entries. You find out, that the product area which is also part of the software provides the same logic. You start with copying the code of the list view (which is fortunately just an separated component which only needs a data source passed in). Then you start copying step by step the form template. A textfield here, a checkbox there. Oh, I need a select field and the other template doesn't contain it - no prob, HTML is nice AF and I can write the markup on my own. After a while you're done and the form works as expected.

Recognize yourself in that workflow? Me too.

What's wrong with it?

Let's check what are the negative aspects of this:

  1. If you copy code you're violating the D.R.Y. principle
  2. The style of fields may vary since you need to write some markup on your own.
  3. If you want to add a tooltip to all text fields you've to edit all files containing a text field. (You will forget one, believe me 😅)
  4. Your edit form is violating the Single-responsibility principle since it must validate all fields against general business logic (Not empty, min, max etc), contains listeners of field selection etc.

How to make it better?

First of all you can create simple components for each field. For example a TextFieldComponent or a CheckboxComponent. This would solve problem 1, 3 and partially 2. Why partially 2? Because it's still possible to modify the outer markup in the html where you use the component.

What else? Forms without writing HTML? No way!

You're right. Without writing any HTML it's not possible but you can reduce the HTML to a minimum.
I created a Angular library which is called @mintware-de/form-builder.
It provides all functionality you need to create reusable, maintainable and extensible forms in Angular.

Real world example

Create a new minimal Angular project using

ng new form-example --minimal --skip-tests --inline-style --inline-template  

Install the package

npm i -d @mintware-de/form-builder@^2.0.0

Create a form fields module

In a previous section I mentioned that's an good idea to separate fields into it's own components. First, create a new module called form-fields inside your src/app directory and cd into the module directory.

ng g m form-fields
cd src/app/form-fields

As described in the getting started import and export the FormBuilderModule and the ReactiveFormsModule in your FormFieldsModule.

// ...
  imports: [
    CommonModule,
    FormBuilderModule,   // New
    ReactiveFormsModule  // New
  ],
  exports: [
    FormBuilderModule,   // New
    ReactiveFormsModule  // New
  ]
// ...

Import the FormFieldsModule in your AppModule.

Creating form field components

Let's start with creating a text field component which has a inline template, inline styles and no tests.

ng g c text-field --inline-template --inline-style --skip-tests

Why using inline template?
The template of the form components are really small in the most cases.
Additionally to this, mostly you don't need to write TypeScript code in the component itself.
That's the reason why I prefer inline templates.

Create the options interface and the form type

A form component for the form builder consists of 3 parts:

  1. The Angular component
  2. An options interface which is used for configuring the component
  3. The form type which connects the component and the options. The form type also defines the validation rules.

Now create the options and the type next to the text-field.component.

|- src/app/form-fields
|  |- text-field
|  |  |- text-field.component.ts
|  |  |- text-field.options.ts    <-- New
|  |  |- text-field.type.ts       <-- New

Create an empty interface for the text field options. We will add needed properties later.

// text-field.options.ts
export interface TextFieldOptions {

}

The form type must extend the AbstractType<TOptions> class. The naming convention for the class name is the PascalCased file name without the suffix. In this case simply TextField.

As TOptions you need to pass the created TextFieldOptions type and implement the abstract member component. Set the value to TextFieldComponent.
The referenced component will be used to render the form field.

The complete file should look like this:

// text-field.type.ts
import {AbstractType, Constructor} from '@mintware-de/form-builder';
import {TextFieldOptions} from './text-field.options';
import {TextFieldComponent} from './text-field.component';

export class TextField extends AbstractType<TextFieldOptions> {
    public readonly component: Constructor = TextFieldComponent;
}

Write the component code

First of all, add the TextFieldComponent to the FormFieldsModule inside the entryComponents section.
This is necessary since the form builder render the components
with a ComponentFactory.

// form-fields.module.ts
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TextFieldComponent } from './text-field/text-field.component';

@NgModule({
  declarations: [
    TextFieldComponent,
  ],
  entryComponents: [
    TextFieldComponent, // New
  ],
  imports: [
    CommonModule
  ]
})
export class FormFieldsModule {
}

Open the TextFieldComponent and replace the implements OnInit with
extends AbstractFormFieldComponent<AbstractType<TextFieldOptions>>

// text-field.component.ts
import { Component } from '@angular/core'; // modified
import {
  AbstractFormFieldComponent,
  AbstractType
} from '@mintware-de/form-builder'; // new
import { TextFieldOptions } from './text-field.options'; // new

@Component({
  selector: 'app-text-field',
  template: `
    <p>
      text-field works!
    </p>
  `,
  // modified
})
export class TextFieldComponent
  extends AbstractFormFieldComponent<AbstractType<TextFieldOptions>> { // modified

  // modified

}

Why not using extends TextField directly?
Since the TextField imports the TextFieldComponent a direct use of TextField inside the TextFieldComponent would cause a circular reference.

Add the input field

Now we need to add some HTML code which contains the input element. We use the [formControl] attribute on the input to link the input element with the FormControl in the Angular FormGroup.
The AbstractFormGroupComponent a property mwElement which contains the form control.

Update the template of the TextFieldComponent:

<input type="text" [formControl]="mwElement">

Congrats, you just created your first form field 👏. Let's create a form to use and reuse the the form field.

Create a form and use the form field

Open the AppComponent and replace the content with this:

import {Component} from '@angular/core';
import {FormModel} from '@mintware-de/form-builder';
import {TextField} from './form-fields/text-field/text-field.type';

@Component({
  selector: 'app-root',
  // Display the form by using the MwFormBuilder Component
  // Pass the formModel and formData and set a submit action
  // The action is only called if the form is valid
  template: `
    <mw-form-builder #myForm
                     [mwFormModel]="formModel"
                     [mwFormData]="formData"
                     (mwFormSubmit)="submit($event)">
    </mw-form-builder>

    <button type="button" (click)="myForm.submit()">Submit</button>
  `
})
export class AppComponent {
  // Create a form model. 
  // The naming and nesting is equal to the formData
  public formModel: FormModel = {
    firstName: new TextField({}),
    lastName: new TextField({})
  };

  // Set the initial form data
  public formData: { firstName: string, lastName: string } = {
    firstName: 'John',
    lastName: 'Doe',
  };

  // Create a submit handler
  public submit(data: { firstName: string, lastName: string }): void {
    console.log("Form was submitted: %o", data);
  }
}

Run ng serve to start the app.
Alt Text

Press the button and something like
Form was submitted: {firstName: "John", lastName: "Doe"}
is written to the console.

Adding options to the text field

Cool, text fields without labels. That's what I call usability 😬
Ok, let's add a few options to our text field:

  • Label: The string which is used as the label
  • Required: A boolean which defaults to true and marks the field as required or not.

Edit the TextFieldOptions interface and add the fields:

// text-field.options.ts
export interface TextFieldOptions {
  label: string;      // new
  required?: boolean; // new
}

Now update the HTML code of the TextFieldComponent and use the properties. You can access the options object in the mwFieldType property, which comes from AbstractFormFieldComponent.

<div>
  <label [for]="mwPath">{{ mwFieldType.options.label }}</label>
  <input type="text"
         [formControl]="mwElement"
         [id]="mwPath"
         [required]="mwFieldType.options.required">
   <div *ngIf="mwElement.errors && mwElement.errors.required">
       {{mwFieldType.options.label}} is required.
   </div>
</div>

Since the label property is not nullable, you've to set it in the form model in the AppComponent.

public formModel: FormModel = {
  firstName: new TextField({
    label: 'First name', // new
  }),
  lastName: new TextField({
    label: 'Last name',  // new
  })
};

Reload the page and voilà, the form fields has labels:
Form with labels

Almost done. We just need to add the required validation and set the default state to true.

Option defaults & validation

To set default values for optional options, you need to add a constructor to the TextField type. In the constructor you can use Object.assign({}, ...); to set the defaults in the options object. Validators can be added by overriding the validators getter.

import {AbstractType, Constructor} from '@mintware-de/form-builder';
import {TextFieldOptions} from './text-field.options';
import {TextFieldComponent} from './text-field.component';
import {ValidatorFn, Validators} from '@angular/forms';

export class TextField extends AbstractType<TextFieldOptions> {
  public readonly component: Constructor = TextFieldComponent;

  constructor(opts: TextFieldOptions) {
    // Set the field defaults
    super(Object.assign({
      required: true,
    }, opts));
  }

  public get validators(): ValidatorFn[] {
    const validators: ValidatorFn[] = [];

    // Only add the required validator if the field is required
    if (this.options.required) {
      validators.push(Validators.required);
    }

    return validators;
  }
}

Reload the page and clear the inputs. You should see the error message and if you try to submit the form, the submit method is not called since both fields are required by default.

FAQ

Is the form builder compatible with Angular Material?

Yep

Can I create collection fields and nested forms?

Collections or Array fields and nested forms are both supported.
Guides:

...Complex layouts?

Yes, there is a special FormType called AbstractLayoutType. Guide: Layout types

Example: https://gist.github.com/devtronic/807e8bfc712330ef13a5c9b8bf5a71cf


I hope everything was clear and you enjoyed reading my post.

Questions, Suggestions etc. ➡️ Comments

Discussion (0)