Monday, June 22, 2020

Real world angular reactive form

For the last couple of years, I've created a few CRM and back-office applications using angular. The one thing you can say about those type of apps is they heavily rely on forms. While there are quite a few angular 2+ tutorials about this topic available, It was quite difficult to find a good real-world example for using angular reactive forms with nested component, arrays, custom inputs and etc...

This post is targeted for programmers with some basic angular concepts understanding. If you you're new to angular I highly suggest this Udemy course before moving to more advanced topics:


In this tutorial, we are going to build a simple pizza booking form using angular reactive forms. Here is our simple app layout:

Component layout of the app 

Reactive form approach

Angular provides us with two ways for handling forms:

We won't go deep into the difference between both approaches but I will just tell that angular's reactive form is a powerful tool for managing complex form states. It's heavily based on RxJs which makes it easy to react and observe form changes.

Getting Started

Final product design

I won't cover each part of the final product in this tutorial, but you can find the entire source code on my GitHub repo: https://github.com/scopsy/angular-forms-example

Here are the topics we going to discuss:

Delegating business logic to component scoped services

In order to manage the pizza form logic, we will use multiple services and initialize them in the app-pizza-form-container component, to ensure a new instance of the services will be initialized each time the root component will be created. I have seen a lot of junior developers use services only for global services. Moving away our business logic to the service will result in a smaller component file and easier to test components.

Here is an example of how we defined our services at the component level:

@Component({
  selector: 'app-pizza-form-container',
  templateUrl: './pizza-form-container.component.html',
  styleUrls: ['./pizza-form-container.component.scss'],
  providers: [
    PizzaFormService,
    PizzaFormValidatorsService,
    PizzaLoaderService
  ]
})
export class PizzaFormContainerComponent implements OnInit {
    // ... code
}

When providing a service in the component a new instance of the service will be created when the component bootstraps and the service will be destructed when the component destroyed. This is very helpful since we want that every time a customer will enter the order page, a new order will be created as opposed to providing the service in our AppModule where a single instance of the service will be created for the entire lifecycle of our app.

Here is how our PizzaFormService looks:


@Injectable()
export class PizzaFormService {
  public availableToppings = [...Object.values(PizzaToppingsEnum)];
  public form: FormGroup;

  constructor(
    private pizzaValidatorsService: PizzaFormValidatorsService,
    private fb: FormBuilder
  ) {
    this.form = this.fb.group({
      selectedPizza: null,
      pizzas: this.fb.array([]),
      customerDetails: this.fb.group({
        firstName: [null, Validators.required],
        lastName: [null, Validators.required],
        phoneNumber: [null, Validators.required],
        address: this.fb.group({
          street: [null, Validators.required],
          houseNum: [null, Validators.required],
          city: [null, Validators.required],
          floor: [null, Validators.required],
        })
      })
    }, {
      validator: this.pizzaValidatorsService.formValidator()
    });
  }

  get pizzasArray(): FormArray {
    return this.form.get('pizzas') as FormArray;
  }

  get isValid(): boolean {
    if (!this.form.valid) {
      this.pizzaValidatorsService.validateAllFormFields(this.form);
      return false;
    }

    return true;
  }

  selectPizzaForEdit(index: number) {
    this.form.get('selectedPizza').setValue(index);
  }

  addPizza(): FormGroup {
    const pizzaGroup = this.getPizzaFormGroup();
    this.pizzasArray.push(pizzaGroup);

    this.form.markAsDirty();

    return pizzaGroup;
  }

  deletePizza(index: number): void {
    this.pizzasArray.removeAt(index);
    this.form.markAsDirty();
  }

  getPizzaFormGroup(size: PizzaSizeEnum = PizzaSizeEnum.MEDIUM): FormGroup {
    return this.fb.group({
      size: [size],
      toppings: this.mapToCheckboxArrayGroup(this.availableToppings)
    }, {
      validator: this.pizzaValidatorsService.pizzaItemValidator()
    });
  }

  createPizzaOrderDto(data: IPizzaFormInterface): IPizzaFormInterface {
    const order = {
      customerDetails: data.customerDetails,
      pizzas: data.pizzas
    };

    for (const pizza of order.pizzas) {
      pizza.toppings = this.getSelectedToppings(pizza.toppings as IToppingItem[])
        .map((i) => {
          return i.name;
        });
    }

    return order;
  }

  getSelectedToppings(toppings: IToppingItem[]): IToppingItem[] {
    return toppings.filter(i => i.selected);
  }

  private mapToCheckboxArrayGroup(data: string[]): FormArray {
    return this.fb.array(data.map((i) => {
      return this.fb.group({
        name: i,
        selected: false
      });
    }));
  }
}

The service is responsible and manage the form object built by the FormBuilder service and for handling the interaction with the form including adding and removing pizzas. We will take a closer look at this class down the road.

Validation based on multiple control values

Notice how we delegated the form validators to a different service in order keep our form service file in a reasonable size. In our pizza app we created some basic validations for the form fields with the built-in validators:

this.form = this.fb.group({
  selectedPizza: null,
  pizzas: this.fb.array([]),
  customerDetails: this.fb.group({
    firstName: [null, Validators.required],
    lastName: [null, Validators.required],
    phoneNumber: [null, Validators.required],
    address: this.fb.group({
      street: [null, Validators.required],
      houseNum: [null, Validators.required],
      city: [null, Validators.required],
      floor: [null, Validators.required],
    })
  })
}, {
  validator: this.pizzaValidatorsService.formValidator()
});

The built-in validators are great for single control validations and they include the following validators:

  • Required
  • min
  • max
  • email
  • pattern (for a regex match)

To combine multiple validators we use the Validators.compose function that accepts an array of built-in and custom validators.

Single control validations are great, but usually, we need to create more complex validations based on multiple values in our forms. We can provide a validator function to each formGroup in our form, simply by passing the options parameter after the group content object  

this.form = this.fb.group({
 // formGroup definition code goes here...
}, {
  validator: this.pizzaValidatorsService.formValidator()
});

Let's take a look at the PizzaValidatorsService:

@Injectable()
export class PizzaFormValidatorsService {

  constructor() { }

  formValidator(): ValidatorFn {
    return (control: FormGroup): ValidationErrors | null => {
      const errors: ValidationErrors = {};

      if (!(control.get('pizzas') as FormArray).length) {
        errors.noPizzas = {
          message: 'You must select at least one pizza to order'
        };
      }

      return Object.keys(errors).length ? errors : null;
    };
  }

  pizzaItemValidator(): ValidatorFn {
    return (control: FormGroup): ValidationErrors | null => {
      const errors: ValidationErrors = {};

      const pizzaSize: PizzaSizeEnum = control.get('size').value;
      const pizzaToppings: IToppingItem[] = control.get('toppings').value.filter(i => i.selected);

      if (pizzaSize !== PizzaSizeEnum.LARGE && pizzaToppings.length > 4) {
        errors.toppingPizzaSize = {
          message: 'To use more then 4 toppings you must selected large pizza'
        };
      }

      return Object.keys(errors).length ? errors : null;
    };
  }
  
  validateAllFormFields(formGroup: FormGroup) {
    Object.keys(formGroup.controls).forEach(field => {
      const control = formGroup.get(field);

      if (control instanceof FormControl) {
        control.markAsTouched({ onlySelf: true });
      } else if (control instanceof FormGroup) {
        this.validateAllFormFields(control);
      }
    });
  }
}

We defined 2 validators, one for the entire form and one for each pizza formGroup item.

formValidator(): ValidatorFn {
    return (control: FormGroup): ValidationErrors | null => {
      const errors: ValidationErrors = {};

      if (!(control.get('pizzas') as FormArray).length) {
        errors.noPizzas = {
          message: 'You must select at least one pizza to order'
        };
      }

      return Object.keys(errors).length ? errors : null;
    };
}

The form validator is just a class method that returns a ValidatorFn function, this function will receive the control object that will be our form definition. We can then perform a check of the pizzas FormArray and if its empty add a noPizzas error. Notice how the noPizzas property is an object, we define it like this to provide a meaningful error message to the user.

This is how we can then display the error message in our HTML file:

<div *ngIf="form.errors?.noPizzas && form.dirty" class="ValidationErrorLabel mg-bottom-15">
   {{form.errors?.noPizzas.message}}
</div>

First, we check if the noPizzas error exist on the form.errors object, if it does we check if the form is dirty, since we don't want to show the error before the user has touched the form (it will be awkward). We then display the noPizzas.message error text defined in the validator. Tadda.

Reusable form components

It is often a good practice to separate our application into small and reusable components. Our customer details area is a great fit for an app-wide reusable component:

We will create a customer details component that will receive only the formGroup related to the customer details, this will allow as to reuse the customerDetails with other forms in our application as long as the customer details component stays "dumb" and isolated from the parent form structure. Here is how the component file looks:


@Component({
  selector: 'app-customer-details',
  templateUrl: './customer-details.component.html',
  styleUrls: ['./customer-details.component.scss']
})
export class CustomerDetailsComponent implements OnInit {
  @Input() group: FormGroup;
  constructor() { }

  ngOnInit() {
  }
}

It has a single @Input named group of type FormGroup. In the component HTML file we will wrap the parent div with the [formGroup] attribute:

<div class="CustomerDetails" [formGroup]="group">
  <div class="row">
    <div class="col-md-12">
      <h3>
        Customer Details
      </h3>
    </div>
    <div class="col-md-3">
      <mat-form-field class="full-width-input">
        <input matInput placeholder="First Name" formControlName="firstName" />
      </mat-form-field>
    </div>
    <div class="col-md-3">
      <mat-form-field class="full-width-input">
        <input matInput placeholder="Last Name" formControlName="lastName" />
      </mat-form-field>
    </div>
    <div class="col-md-3">
      <mat-form-field class="full-width-input">
        <input matInput placeholder="Phone Number" formControlName="phoneNumber" />
      </mat-form-field>
    </div>
  </div>

  <div class="row" [formGroup]="group.get('address')">
    <div class="col-md-12">
      <h3>
        Delivery Address
      </h3>
    </div>
    <div class="col-md-3">
      <mat-form-field class="full-width-input">
        <input matInput placeholder="Street" formControlName="street" />
      </mat-form-field>
    </div>
    <div class="col-md-3">
      <mat-form-field class="full-width-input">
        <input matInput placeholder="Apt. Number" formControlName="houseNum" />
      </mat-form-field>
    </div>
    <div class="col-md-3">
      <mat-form-field class="full-width-input">
        <input matInput placeholder="City" formControlName="city" />
      </mat-form-field>
    </div>
    <div class="col-md-3">
      <mat-form-field class="full-width-input">
        <input matInput placeholder="Floor" formControlName="floor" />
      </mat-form-field>
    </div>
  </div>
</div>

Now, inside our app-form-container component we use the new component like this:

<div class="row">
    <div class="col-md-12">
      <app-customer-details [group]="form.get('customerDetails')"></app-customer-details>
    </div>
</div>

Notice how we only pass the customerDetails form group of our form object. This ensures that our component is only aware of the customerDetails context and not about its host. We then can easily reuse the app-customer-details with other forms that have a similar formGroup:

customerDetails: this.fb.group({
    firstName: [null, Validators.required],
    lastName: [null, Validators.required],
    phoneNumber: [null, Validators.required],
    address: this.fb.group({
      street: [null, Validators.required],
      houseNum: [null, Validators.required],
      city: [null, Validators.required],
      floor: [null, Validators.required],
    })
})

(It is often recommended to extract the customerDetails form group to external service in order to ensure the formGroup structure will be the same across forms and to avoid repetition.)

Custom form control

Working with formControl is easy when working with the regular inputs: selectinputtextarea and etc... But often its needed to create a custom component we want to integrate with our reactive form. Let's look at the PizzaSizeSelector component:

Our designer came up with a sophisticated widget and we need to implement it. First of all, we need to create a new component and declare it inside our main component:

<div class="col-md-6 offset-md-3">
  <app-pizza-size-picker formControlName="size"></app-pizza-size-picker>
</div>

Notice the formControlName attribute, when the page reloaded we will get the following error:

ERROR Error: No value accessor for form control with name: 'size'

This is all because we have to make a few modifications to our new component, let's take a look at what we need to do:

@Component({
  selector: 'app-pizza-size-picker',
  templateUrl: './pizza-size-picker.component.html',
  styleUrls: ['./pizza-size-picker.component.scss'],
  providers: [
    {
      provide: NG_VALUE_ACCESSOR,
      useExisting: forwardRef(() => PizzaSizePickerComponent),
      multi: true
    }
  ]
})

The first thing to notice is that we added a new provider named NG_VALUE_ACCESSOR. Angular at startup uses this token to register the element with the respected model. After registering our component like a real pro the error goes away. However we have not finished our job here.

export class PizzaSizePickerComponent implements ControlValueAccessor {
  pizzaSize: PizzaSizeEnum;
  PizzaSizeEnum = PizzaSizeEnum;

  constructor() { }

  changeSize(size: PizzaSizeEnum) {
    this.pizzaSize = size;
    this.propagateChange(size);
  }

  writeValue(value: PizzaSizeEnum) {
    this.pizzaSize = value;
  }

  registerOnChange(fn) {
    this.propagateChange = fn;
  }

  registerOnTouched() {}
  propagateChange = (value: PizzaSizeEnum) => {};
}

We need to implement the ControlValueAccessor interface. This will ensure we implemented the correct hooks of the formControl component.

writeValue

Is used to write a new value to the controller, for example when we will use our form to set a value:

this.form.get('size').setValue(1);

the writeValue function will get called and will allow us to write the new value the local pizzaSize property.

registerOnChange

Is called when the component is bootstrapped in the form context, it will pass a function we will use later to notify the host form about changes inside our custom component.

registerOnTouched

Behaves like registerOnChange but is used to pass the touched event to the form, we won't implement it in our simple example.

After we finished hooking up everything in our component file let's take a look at the HTML implementation:


<div class="row">
  <div class="col-md-12">
    <div class="PizzaSizePicker">
      <div class="PizzaSizePicker__item"
           (click)="changeSize(PizzaSizeEnum.SMALL)"
           [ngClass]="{'PizzaSizePicker__item--active': pizzaSize === PizzaSizeEnum.SMALL}">
        SMALL
      </div>
      <div class="PizzaSizePicker__item"
           (click)="changeSize(PizzaSizeEnum.MEDIUM)"
           [ngClass]="{'PizzaSizePicker__item--active': pizzaSize === PizzaSizeEnum.MEDIUM}">
        MEDIUM
      </div>
      <div class="PizzaSizePicker__item"
           (click)="changeSize(PizzaSizeEnum.LARGE)"
           [ngClass]="{'PizzaSizePicker__item--active': pizzaSize === PizzaSizeEnum.LARGE}">
        LARGE
      </div>
    </div>
  </div>
</div>

We define our custom buttons and attached a click function to execute the changeSize function declared before:

changeSize(size: PizzaSizeEnum) {
    this.pizzaSize = size;
    this.propagateChange(size);
}

We change our local component state and call the propagateChange function to notify the parent formGroup of our change.

Working with checkbox arrays in ReactiveForms

I found that working with checkbox arrays in ReactiveForms can get quite tricky, some of the solutions suggest creating a boolean FormArray representation of the checkbox state. In our example we have the Toppings picker represented with multiple checkboxes:

The usual suggestion would be to convert all the toppings with something like this:

const TOPPINGS = ['Pepperoni', 'Olives', 'Pineapple']; // Yes it says pineapple.

this.form = this.fb.group({
    // ... other code
    toppings: this.fb.array(TOPPINGS.map(() => false))
})

Personally, I find this approach not very convenient since the final form value will be:

toppings: [false, false, true, false]

We will then have to map this weird array to more meaningful value based on the index of the toppings array.

So how can we do better?

We can map the toppings data array to the following interface

interface ICheckBoxItem {
    id?: string; 
    selected: boolean;
    name: string; 
}
  • id - is an optional key that can be used for the internal enum value of the topping item.
  • selected - will hold the checkbox checked state
  • name - is the friendly text will be displayed to the user.

In our example, we will use the same value for the display and for the internal ID. We can create a small helper function to map our data to the ICheckBoxItem interface:

private mapToCheckboxArrayGroup(data: string[]): FormArray {
    return this.fb.array(data.map((i) => {
      return this.fb.group({
        name: i,
        selected: false
      });
    }));
}

We receive the options array and map it to a new FormArray. The toppings array formGroup creation will look like this:

  getPizzaFormGroup(size: PizzaSizeEnum = PizzaSizeEnum.MEDIUM): FormGroup {
    return this.fb.group({
      size: [size],
      toppings: this.mapToCheckboxArrayGroup(this.availableToppings)
    }, {
      validator: this.pizzaValidatorsService.pizzaItemValidator()
    });
  }

this.availableToppings represents the toppings array described above).

Then, our HTML file will iterate over the toppings form array and generate the checkbox inputs:

<div class="col-md-12 mg-top-15">
  <h5>Toppings</h5>
  <div class="ToppingsSelector" formArrayName="toppings">
    <div class="ToppingsSelector__item" *ngFor="let topping of toppingsArray.controls" [formGroup]="topping" >
      <mat-checkbox [formControl]="topping.get('selected')">
        {{topping.get('name').value}}
      </mat-checkbox>
    </div>
  </div>
</div>

Later when we need to get all the selected toppings we can simply filter all the formControls where selected === true.

Restoring the form state from the server

Personally, this is my favorite part of ReactiveForms. Where all the hard work we did before really pays off. So we finished our beautiful form and everyone is happy. But pretty soon our boss comes and introduce us to a new requirement: The user will be able to modify his order after it was created.

Luckily, it's super easy with our form. First, in our container component, we will detect if we are in edit mode or in creation mode. I usually pass the id param to the router and if it exists it means we are editing an existing form

ngOnInit() {
    if (this.editMode) {
      // ...fetch data from server based on the id 
      this.pizzaLoaderService.loadPizzaForEdit(DEMO_PIZZA);
    }
}

Let's take a look at the PizzaLoaderService:

@Injectable()
export class PizzaLoaderService {

  constructor(
    private pizzaFormService: PizzaFormService
  ) {

  }

  loadPizzaForEdit(data: IPizzaFormInterface): void {
    this.pizzaFormService.form.patchValue({
      customerDetails: {
        ...data.customerDetails
      }
    });

    for (const pizza of data.pizzas) {
      const group = this.pizzaFormService.addPizza();
      group.patchValue({
        size: pizza.size,
        toppings: this.prefillToppingsSelection(group.get('toppings').value, pizza.toppings as PizzaToppingsEnum[])
      });
    }
  }

  prefillToppingsSelection(toppings: IToppingItem[], selectedToppings: PizzaToppingsEnum[]): IToppingItem[] {
    return toppings.map((i) => {
      if (selectedToppings.includes(i.name)) {
        i.selected = true;
      }

      return i;
    });
  }
}

We inject the PizzaFormService to gain access to the form object and provide a loadPizzaForEdit function. It will get the order object as received from the server ( for simplicity sake I used the same interface for the form, but usually you will have to map the server model to the form interface. This logic will happen here.)

It patches the values of the customerDetails form group and then adds a pizza group for each pizza object received from the server. Only after the form group is added we can patch it's value with the data from the server.

Mapping the checkbox values

Remember that our toppings are a formArray with the ICheckBoxItem interface? Well, the server responded with ['Ham', 'Pineapple']. Where are the other toppings? And wait, that's not how our formArray looks like! To map the server response to the correct structure we created a small function named prefillToppingsSelection:

prefillToppingsSelection(toppings: IToppingItem[], selectedToppings: PizzaToppingsEnum[]): IToppingItem[] {
    return toppings.map((i) => {
      if (selectedToppings.includes(i.name)) {
        i.selected = true;
      }

      return i;
    });
}

It receives the entire form values from the toppings formGroup created from the addPizza method and iterates over them, if the individual topping exists in the selectedToppings array it marks it as selected = true. Voila.

Resetting a form

A week goes by and yet another feature request from our boss John: "A customer must be able to reset the form". Happy me responds with: "Hold my beer for a sec.".

Let's create a new method in our PizzaFormService:

resetForm() {
    while (this.pizzasArray.length) {
      this.pizzasArray.removeAt(0);
    }

    this.form.reset();
}

Reactive forms have a built-in  reset function. However, it won't remove our added pizza formGroups from the FormArray. We will need to iterate over them and remove them manually.

Now our form will completely reset when the customer hits that button. Great success.

Wrapping up

Handling complex forms is often pretty hard. Angular provides us with a powerful tool to manage the state of those forms easily. I highly recommend to take a look at the full project source code available here.

Have any tips worth knowing about? Share them in the comments!

No comments:

Post a Comment