forms

Karine Samyn, Benjamin Vertonghen, Thomas Aelbrecht, Pieter Van Der Helst

"Any fool can write code that a computer can understand. Good programmers write code that humans can understand." - Martin Fowler

recipe app startpoint

if you spent last class on facebook (or at the pub), branch the repo at the correct commit and follow along!

~$ git clone https://github.com/Pieter-hogent/recipeapp.git  (or git pull)
~$ cd recipeapp
~/recipeapp$ npm install
~/recipeapp$ git checkout -b mybranch 08654da
        

overview.

  1. template driven vs reactive
    two possible approaches to forms in angular
  2. reactive forms
    adapt the application so it can use reactive forms
  3. validators
    how to add validation to your forms
  4. formbuilder and formarray
    learn how to create forms which change dynamically
  5. custom validators
    create a custom validator

forms

  • forms have always been a big part of the web
  • good form support provides client side validation, ways to display errors to the user and keeps track of the global form state
  • Angular has two ways of creating forms: template driven and reactive

template driven

  • faster (easier) for simple scenario's
  • very similar to how it was done in Angular 1
  • uses two way data binding
  • mainly created in the 'html', very little in the 'ts'
  • a lot is done automatically, but this comes at the price of less flexibility
  • asynchronous
  • hard to unit test

reactive

  • bit harder, but more flexible
  • no data binding (immutable data model)
  • mainly created in the 'ts', less in the 'html'
  • reactive transformations are used easily (debounce, distinctUntilChanged, dynamically adding elements)
  • synchronous
  • unit testing is easier

reactive forms module

src/app/recipe/recipe.module.ts  
                  import { ReactiveFormsModule } from '@angular/forms';
                  
                  @NgModule({
                  imports: [
                    CommonModule,
                    HttpClientModule,
                    MaterialModule,
                    ReactiveFormsModule
                  ], ...  
              
just like with Http, we first need to include the proper module to our app.module

reactive forms

  • reactive forms allow for a reactive programming style, favoring the explicit management of the data flow between a non-UI data model and a UI-oriented form model
  • you create a tree of Angular FormControl objects in the component class, and bind them to native form elements in the template
  • a FormControl is a class that tracks the value and the validation status of an individual control of a form
  • let's look at an example

add recipe

src/app/recipe/add-recipe/add-recipe.component.ts  
                  export class AddRecipeComponent implements OnInit {
                    public recipe: FormGroup;
                  
                    ngOnInit() {
                      this.recipe = new FormGroup({
                        name: new FormControl('risotto')
                      })
                    }
                  }; 
              
lets create an add recipe component, using a reactive form we build our own FormGroup, with a FormControl for each control we wish to map we start with only an input field for the name of our recipe, so just one FormControl a form is always associated with a FormGroup, which contains FormControl's, FormGroup's and/or FormArray's

add recipe

src/app/add-recipe/add-recipe.component.html
              <mat-card>
                <mat-card-title>add recipe</mat-card-title>
                <mat-card-content>
                  <form [formGroup]="recipe" (ngSubmit)='onSubmit()'>
                    <mat-form-field>
                      <input
                        matInput
                        aria-label="name"
                        placeholder="name"
                        type="text"
                        formControlName="name"
                      />
                    </mat-form-field>
                    <button type='submit' mat-raised-button>
                      add recipe
                    </button>
                  </form>
                </mat-card-content>
              </mat-card>
              REMOVE {{ recipe.value | json }}
              
now we need to link this to the html form the formGroup is a binding on the form and formControls are linked using the formControlName attribute directive in material design we generally do not have a label associated with input elements (the placeholder is shown as the label) screenreaders expect this though, so add an aria-label attribute instead accessibility is often overlooked, but it's little effort and can make a world of change for people who rely on screenreaders, so no excuses! (and sometimes even required by law, government websites) for debugging purposes it's convenient to dump the value in the form itself let's try this out if we want the add button to work, we need to respond to the (ngSubmit) event

reactive forms

 src/app/recipe/add-recipe/add-recipe.component.ts 
                export class AddRecipeComponent implements OnInit {
                  @Output() public newRecipe = new EventEmitter<Recipe>();
                  private recipe: FormGroup;

                  // [ ... ]

                  onSubmit() {
                    this.newRecipe.emit(new Recipe(this.recipe.value.name));
                  }
                };
            7bec462
and then we need to add this onSubmit method we'll have to convert the formGroup.value to a recipe object before we can emit it let's try this out

directives

  • when I said Angular is all about components that was actually a lie, Angular is all about directives, and components are one kind of directive
  • there are also structural directives, which start with a * (*ngFor, *ngIf, ...)
  • and then there are attribute directives, which work on an attribute of a tag (and not the tag itself, like components)

directives

  • so Angular builds a page by applying functions to tags / attributes and by expanding the structural directives
  • that's how this [formGroup] is applied, a standard html form obviously has no @Input attributes or something similar
  • but by including the ReactiveFormsModule, standard forms got this functionality added

reactive form validators

 src/app/recipe/add-recipe/add-recipe.component.ts 
                export class AddRecipeComponent implements OnInit {
                  ngOnInit() {
                    this.recipe = new FormGroup({
                      name: new FormControl('risotto', Validators.required, 
                        [Validators.required, Validators.minLength(2)], [ ... ])
                    })
                  }
                }
            
if you want to add validation, you have to explicitly include and add Validators functions Validators has many static functions defined which perform the validation, they correspond to the standard HTML5 validators if you need multiple, simply pass an array of them you can also add async Validators (server side validation), I'll show an example when we add user authentication

validators

 src/app/recipe/add-recipe/add-recipe.component.html 
                <mat-card>
                <mat-card-title>add recipe</mat-card-title>
                <mat-card-content>
                  <form [formGroup]="recipe" (ngSubmit)="onSubmit()">
                    <mat-form-field>
                      <input
                        matInput
                        aria-label="name"
                        placeholder="name"
                        type="text"
                        formControlName="name"
                        required
                        #spy
                      />
                    </mat-form-field>
                    <button type="submit" mat-raised-button [disabled]='!recipe.valid'>
                      add
                    </button>
                  </form>
                </mat-card-content>
              </mat-card>
              REMOVE {{ recipe.value | json }} 
              {{ spy.className }}
              
            af010eb
if there are validators the FormControl will automatically check if it's valid on every change and being valid will propagate to the ancestors, if any child is invalid, the parent control is as well so if the input field is invalid, the recipe FormGroup becomes invalid as well you can use this to disable the add button, by setting the disabled property based on the validness of the root formgroup when a control is invalid, it doesn't just set the .valid to false, but also adds css classes to the control to signal this, which you can use to give feedback let's add a variable and see what this does you could add custom css based on these classes, but angular material already has some basic css for required checks it's often better to add the html5 required attribute as well, this way the UI will show a * next to the input field (just as users expect)

ngControlStatus

ng-valid ng-invalid does the content conform to the validators?
ng-pristine ng-dirty have the contents changed?
ng-untouched ng-touched has the user touched this?

validators

src/app/recipe/add-recipe/add-recipe.component.html  
              <mat-card>
                <mat-card-title>add recipe</mat-card-title>
                <mat-card-content>
                  <form [formGroup]="recipe" (ngSubmit)="onSubmit()">
                    <mat-form-field>
                      <input matInput
                        aria-label="name" placeholder="name"
                        type="text" formControlName="name"
                      />
                      <mat-error
                        *ngIf="recipe.get('name')['errors']?.required && 
                          recipe.get('name').touched"
                      >
                        is required and needs at least 2 characters
                        {{ getErrorMessage(recipe.get('name')['errors']) }}
                      </mat-error>
                      <mat-error
                        *ngIf="recipe.get('name')['errors']?.minlength && recipe.get('name').touched"
                      >
                        needs at least {{ recipe.get('name')['errors'].minlength.requiredLength }} characters
                      </mat-error>
                    </mat-form-field>
                    <button type="submit" mat-raised-button [disabled]='!recipe.valid'>add</button>
                  </form>
                </mat-card-content>
              </mat-card>
              REMOVE {{ recipe.get('name')['errors'] | json }}
            
often you also need to show a proper error message whenever a validation error occurs, the FormControl.errors object contains those errors let's check this out so you can add mat-error's which are shown based on these properties, using a *ngIf construct it's better to take touched state into account, you don't want to bombard your user with errors when she opens a page let's try this out this works, but if you want to cover multiple errors, you either end up with generic error messages or multiple mat-error tags you can imagine this quickly starts to 'overtake' your forms for more complicated forms it's often cleaner to construct the error message in the class and provide more generic html to show this message so whenever any error occurs (and control is touched), pass the errors object to a function of our class

validators

 src/app/recipe/add-recipe/add-recipe.component.ts 
                export class AddRecipeComponent implements OnInit {
                  // [ ... ]
                
                  getErrorMessage(errors: any): string {
                    if (errors.required) {
                      return 'is required';
                    } else if (errors.minlength) {
                      return `needs at least ${errors.minlength.requiredLength} 
                        characters (got ${errors.minlength.actualLength})`;
                    }
                  }
            8c8b4e9
then inside the class contruct different error messages based on which error occurred let's try this out P.S. whoever decided minlength (with lowercase 'l') should have properties requiredLength and actualLength with uppercase 'L' 🙄🙄🙄

formbuilder.

src/app/recipe/add-recipe/add-recipe.component.ts  
                import { FormGroup, FormControlFormBuilder } from '@angular/forms'; 
    
                export class AddRecipeComponent implements OnInit {
                  constructor(private fb: FormBuilder) { }
                  
                  ngOnInit() {
                    this.recipe = new FormGroupthis.fb.group({
                      name: new FormControl(this.fb.control(['risotto')]
                    })
                  }
                }
            
there's an easier way to construct these FormGroups and controls, using a FormBuilder inject the FormBuilder in the constructor and replace 'new FormGroup' with fb.group and 'new FormControl' with fb.control you can even remove the fb.control completely, and replace it with an array which holds all the arguments (default value, validators, ...)

formarray

  • until now reactive forms don't offer much template driven forms can't easily do too
  • they really start to shine when you do more dynamic (complicated) stuff
  • to illustrate this, lets expand our form so we can also fill in one or more ingredients
  • as ingredients are filled, we'll dynamically create additional input fields using a FormArray
  • when you want to dynamically create a set of input elements you typically create a function which creates a formgroup with those inputs
  • and then push such a formgroup into the formarray whenever you want new input controls

add ingredient form.

 src/app/recipe/add-recipe/add-recipe.component.ts 
                public readonly unitTypes = ['Liter', 'Gram', 'Tbsp', 'Pcs'];
                
                ngOnInit() {
                  this.recipe = this.fb.group({
                    name: ['', [Validators.required, Validators.minLength(2)]],
                    ingredients: this.fb.array([ this.createIngredients() ])
                  });
                }
                
                createIngredients(): FormGroup {
                  return this.fb.group({
                    amount: [''],
                    unit: [''],
                    name: ['', [Validators.required, 
                      Validators.minLength(3)]]
                  });
                }  
            
so we have a function that can create a formgroup and we call this function to initialize our array with one element we'll also add a member variable for our option list

add ingredient form.

 src/app/recipe/add-recipe/add-recipe.component.html 
              <div formArrayName="ingredients"
                *ngFor="let item of recipe.get('ingredients')['controls']; let i = index">
                <div [formGroupName]="...i">
                  <mat-form-field>
                    <input matInput type="text" aria-label="ingredient amount"
                      placeholder="amount" formControlName="amount"/>
                  </mat-form-field>
                  <mat-form-field>
                    <mat-select placeholder="unit" aria-label="ingredient unit"
                      formControlName="unit">
                      <mat-option *ngFor="let type of unitTypes" [value]="type">
                        {{ type }}
                      </mat-option>
                    </mat-select>
                  </mat-form-field>
                  <mat-form-field>
                    <input matInput placeholder="name" aria-label="ingredient name"
                      type="text" formControlName="name"/>
                  </mat-form-field>
                </div>
              </div>
            
the first part of the form stays the same, then we create a new div where we will display the form array per ingredient we have three controls, and input for the amount, a dropdown for the unit, and an input for the name of the ingredient just like we referred to a control using the formControlName attribute, we refer to the array using the formArrayName attribute inside the array we'll display all the groups that were added, the groups themselves are nothing special, simply hook up the formControlName's of the three controls in each group each group is in its own div, which needs a formGroupName, but now we can't just set a fixed name, we're an index in an array so we'll add a loop over all 'ingredients' controls in the recipe formgroup, and use the loop index as the group name note how we also simply loop over a property variable to create an option input field

add ingredient form.

src/app/recipe/add-recipe/add-recipe.component.ts  
                onSubmit() {
                  let ingredients = this.recipe.value.ingredients.map(Ingredient.fromJSON);
                  ingredients = ingredients.filter(ing => ing.name.length > 2);
                  this.newRecipe.emit(new Recipe(this.recipe.value.name, ingredients));

                  this.recipe = this.fb.group({
                    name: ['', [Validators.required, Validators.minLength(2)]],
                    ingredients: this.fb.array([this.createIngredients()])
                  });
                }
            43acb73
back in our typescript file we need to adapt our onSubmit method too I explicitly check for a valid ingredientname, I don't want to include empty input fields it's probably a good idea to rebind to a new group here after a submit (so the form resets) we can't add more than one ingredient yet, but let's try this out

multiple add ingredient.

 src/app/recipe/add-recipe/add-recipe.component.ts 
                export class AddRecipeComponent implements OnInit {
                  get ingredients(): FormArray {
                    return <FormArray>this.recipe.get('ingredients');
                  }
                
                  ngOnInit() {
                    this.ingredients.valueChanges
                      .pipe(debounceTime(400), distinctUntilChanged())
                      .subscribe(ingList => {
                        const lastElement = ingList[ingList.length - 1];
                        if ( lastElement.name && lastElement.name.length > 2 ) { 
                          this.ingredients.push(this.createIngredients()); 
                        }
                      });
                  }
            
we could just add a button in the form, and call createIngredients and add a FormGroup to the array as it's clicked but let's try something else, there is also change tracking available, we'll add a new line of input fields as the previous one is filled first, add a convenience method to access the ingredients array inside the formgroup then subscribe to the valueChanges, with a debounceTime of 400ms and add a new set of input fields if the last ingredient is entered (has >2 chars) you probably want to add an 'else' and remove them again as they are cleared, see the recipe app github for an example let's try this out

custom validators

  • so this works (as in: new rows to add ingredients appear), but it also breaks everything (as in: we can never push the add anymore), what's going on?
  • whenever we make an ingredient entry valid (by typing 3+ letters in the ingredient name), a new (empty, so invalid) ingredient entry appears in the form
  • ingredient name is invalid > ingredient group is invalid > formarray is invalid > recipe group is invalid (invalidness 'bubbles up')
  • what we want is a custom validator
    • if nothing is filled inside an ingredient 'row', then everything's fine
    • if amount is filled however, the name should be as well (ingredient '1 liter' makes no sense)

custom validator

 src/app/add-recipe/add-recipe.component.ts  
function validateIngredientName(control: FormGroup)
  : { [key: string]: any } {
  if (
    control.get('amount').value.length >= 1 &&
    control.get('name').value.length < 2
  ) {
    return { amountNoName: true };
  }
  return null;
}

export class AddRecipeComponent implements OnInit { 
  createIngredients(): FormGroup {
    return this.fb.group({
        amount: [''],
        unit: [''],
        name: ['', [Validators.required, Validators.minLength(3)]]
      },
      { validator: validateIngredientName }
    );
  }

  getErrorMessage(errors: any): string {
    // [ ... ]
    if (errors.required) {
      return 'is required';
    } else if (errors.minlength) {
      return `needs at least ${
        errors.minlength.requiredLength
      } characters (got ${errors.minlength.actualLength})`;
    } else if (errors.amountNoName) {
      return `if amount is set you must set a name`;
    }
  } 
            
we can't use the built in validators to do this, so these need to go we'll apply a validator on the whole group this is done by passing an object with a validator key and a function to the group we'll define our validator function as a free function this function expects an AbstractControl as a parameter (the control being validated) and returns an object of type { [key: string]: any } if everything's fine you return null, otherwhise you return an object with the error as key, and extra info as the value e.g. { required: true }, this object is set as the .errors on your control in our case we'll add a new error 'amountNoName', if amount is set but name is empty next we'll add a proper error message for this error to our getErrorMessage function (scroll down in the code window)

custom validator

 src/app/add-recipe/add-recipe.component.html  
      <div
        formArrayName="ingredients"
        *ngFor="let item of ingredients.controls; let i = index"
      >
        <div [formGroupName]="i">
          <mat-form-field>
            <input [...]  formControlName="amount"
          /></mat-form-field>
          [ ... ]
        </div>
        <mat-error *ngIf="item.errors && item.get('name').touched">
          {{ getErrorMessage(item.errors) }}
        </mat-error>
      </div>
            
we'll show the error using a mat-error, just like before when using mat-error, make sure you add it inside the correct tag, the ingredient group is the one being checked (and begin invalidated) mat-error tags are only shown if their encompassing tag is invalid (don't waste time wondering why the error isn't shown simply because you put it inside the wrong div, like I did when making these slides) let's try this out

summary

  • we've seen how to build reactive forms (template driven forms are still used and a good way to build forms too, if you're intrigued, do check them out)
  • we've done validation and display error messages based on the state of our forms
  • we've seen how to dynamically create forms, and respond to changes in the form, and we added a custom validator
  • there's still more, you can do server side validation too (we'll do that when we register new users)