Angular2 : les formulaires (suite) et les filtres

Article mis à jour pour la version rc1 !

J’ai continué à m’amuser avec ce nouvel Angular et je dois dire qu’il est vraiment plein de ressources ! Je vais donc encore consacrer cet article aux formulaires en voyant cette fois comment les créer à partir du modèle alors que dans le précédent article la création se faisait à partir de la vue.

Pour illustrer les possibilités j’ai construit un exemple de panier à partir de celui que j’avais fait pour vue.js ici. Il est toujours intéressant de comparer des librairies à partir d’un même projet. Il m’a fallu piocher un peu pour arriver à mes fins de façon élégante parce que Angular2 n’est pas encore stable et que les exemples proposés sur le web partent un peu dans tous les sens.

Pour vous faciliter la vie le code complet est téléchargeable ici. Tout ça est évidemment construit sur un Angular2 encore en évolution, prenez-le donc avec circonspection !

Contrôle (Control) et groupe de contrôles (ControlGroup)

Control

Un contrôle (Control) représente un contrôle de formulaire, donc relié à une balise <input>. C’est un objet qui est chargé de gérer l’état du contrôle (valeur, validation, erreurs, modification…).

Dans le précédent article on a créé les contrôles à partir du formulaire avec la directive ngControl :

ngControl="name"

Maintenant on va les créer dans le modèle. On peut par exemple écrire :

let name = new Control('Dupont');

Et on utilise encore la directive ngControl dans le formulaire pour l’utiliser. La différence c’est qu’on peut manipuler ce contrôle directement dans le composant, par exemple pour lire la valeur :

let valeur = name.value;

ControlGroup

Un groupe de contrôles (ControlGroup) représente un ensemble de contrôles. C’est donc un objet qui contient des objets Control.

Par exemple :

let person= new ControlGroup({
    name: new Control('Dupont'),
    role: new Control('Administrateur')
})

Maintenant on peut accéder aux contrôles à partir du groupe de contrôles.

Le créateur de formulaire (FormBuilder)

Angular nous offre un outil encore plus pratique que de créer des contrôles et un grope de contrôles à partir de leurs classes : le créateur de formulaire (FormBuilder).

Prenons un exemple :

constructor(fb: FormBuilder) {
  this.form = fb.group({
    name: ['Dupont'],
    role: ['Administrateur']
  });
}

Avec cette syntaxe on crée les contrôles et le groupe de contrôle directement ! On injecte un FormBuilder et on l’utilise.

Ensuite dans le formulaire on écrit :

<form [ngFormModel]="form" (ngSubmit)="onSubmit(form.value)"

Que donne la propriété value sur le groupe de contrôle ? Tout simplement un objet avec les valeurs des contrôles :

{ name: "Dupont", role: "Administrateur" }

Pratique non ?

La validation

Un aspect important quand on gère un formulaire est la validation des données entrées. Angular2 propose une approche simple. On peut agir à deux niveau, comme on peut créer un formulaire de deux façons différentes, comme on l’a vu. Dans cet article je vais m’intéresser uniquement au cas où on crée un formulaire à partir du modèle. Angular propose ces options pour la validation :

  • required : une valeur est requise
  • minLength : longueur minimale
  • maxLength : longueur maximale
  • pattern : format spécifique

Il suffit de le prévoir quand on crée un contrôle :

 let name = new Control('', Validators.required)

Ou en passant par le FormBuilder :

this.form = fb.group({
  name: ['Dupont', Validators.required],
  role: ['Administrateur']
});

C’est donc très simple.

Si notre cas sort des options par défaut il faut se créer une validation sur mesure, on va voir ça plus loin.

On va voir plus loin également comment gérer les erreurs.

Les filtres (Pipe)

Angular propose des filtres pour l’affichage des données. Par exemple on a uppercase, percent, date, number, decimal…

Voici un exemple :

<p>{{date | date }}</p>

La date sera affichée comme une date. Certains filtres disposent de paramètres :

<p>{{date | date:'fullDate' }}</p>

La date sera affichée de façon plus verbeuse.

Si on ne trouve pas son bonheur parmi les filtres proposés par défaut on peut en créer un sur mesure comme on va le voir plus loin.

Le panier

Comme rien ne vaut un exemple je vous propose d’illustrer tout ça avec une application de panier qui n’a rien de vraiment réaliste mais qui permet de voir tous les cas de figure. Il va se présenter ainsi :

img037

On peut :

  • modifier un article

img038

En cliquant sur le bouton de modification (le bleu clair) les valeurs passent dans le formulaire du bas et l’article disparaît de la liste. Après modification des valeurs on clique sur le bouton Ajouter pour le remettre dans la liste.

  • supprimer un article

img039

Il suffit de cliquer sur le bouton rouge. Je n’ai pas prévu de dialogue de confirmation.

  • ajouter un article : Il faut entrer des valeurs dans le formulaire et cliquer sur le bouton Ajouter.

index, main et app

Je reprends le même code que celui vu dans les précédents articles pour ces fichiers.

index

Pour vous éviter de chercher voici index.html :

<html>
  <head>
    <title>Formulaire</title>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.6/css/bootstrap.min.css">
    <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/font-awesome/4.3.0/css/font-awesome.min.css">

    <script src="node_modules/core-js/client/shim.min.js"></script>
    <script src="node_modules/zone.js/dist/zone.js"></script>
    <script src="node_modules/reflect-metadata/Reflect.js"></script>
    <script src="node_modules/systemjs/dist/system.src.js"></script>

    <script src="systemjs.config.js"></script>
    <script>
      System.import('app').catch(function(err){ console.error(err); });
    </script>
  </head>

  <body>
    <basket-test>Chargement...</basket-test>
  </body>
</html>

main

Et voici main.ts :

import { bootstrap }    from '@angular/platform-browser-dynamic';
import { AppComponent } from './app.component';

bootstrap(AppComponent);

app

Et voici app.component.ts :

import { Component } from '@angular/core';
import {BasketComponent} from './basket.component';

@Component({
    selector: 'basket-test',
    template: '<basket></basket>',
    directives: [BasketComponent]
})
export class AppComponent { }

Ici on charge un composant basket qui va gérer le panier.

Composant du panier

Le panier est géré avec le composant BasketComponent dans le fichier basket.component.ts. En voici le code complet :

import {Component}   from '@angular/core';
import {Article}     from './article'; 
import {EuroPipe}    from './pipes/euro.pipe';
import {NumberValidator}   from './validators/number.validator';
import {ValidationMessages}   from './validators/validation-messages.component';
import {
  FormBuilder,
  ControlGroup,
  Control,
  Validators
} from '@angular/common';

@Component({
  selector: 'basket',
  templateUrl: 'app/basket.html',
  pipes: [EuroPipe],
  directives: [ValidationMessages]
})
export class BasketComponent {

  private panier: Array<Article> = [
    new Article('cahier', 2, 5.3),
    new Article('crayon', 4, 1.1),
    new Article('gomme', 1, 3.25)
  ];
  private total: number;
  private form: ControlGroup;

  constructor(private builder: FormBuilder) {}

  ngOnInit() { this.createForm(); }

  createForm() {
    this.form = this.builder.group({
      name: ['', Validators.required],
      quantity: ['', Validators.compose([Validators.required, NumberValidator.isInteger])],
      price: ['', Validators.compose([Validators.required, NumberValidator.isNumber])],
    });
    this.updateTotal();    
  }

  editArticle(index: number) { 
    (<Control>this.form.find('name')).updateValue(this.panier[index].name);
    (<Control>this.form.find('quantity')).updateValue(this.panier[index].quantity);
    (<Control>this.form.find('price')).updateValue(this.panier[index].price);
    this.panier.splice(index, 1);
    this.updateTotal();
  }

  deleteArticle(index: number) {
    this.panier.splice(index, 1);
    this.updateTotal();
  }

  addArticle() {
    this.panier.push(new Article(
      this.form.value.name, 
      Number(this.form.value.quantity), 
      Number(this.form.value.price)
    ));
    for(let key in this.form.controls) {
      let control = <Control>this.form.find(key);
      control.updateValue('');
      control.updateValueAndValidity();
    }
    this.updateTotal();
  }

  updateTotal() {
    this.total = 0;
    for(let element of this.panier) {
      this.total += element.price * element.quantity;
    }
  }

}

Avec son template basket.html :

<div class="container">
  <form (ngSubmit)="addArticle()" [ngFormModel]="form">
    <div class="panel panel-primary">
      <div class="panel-heading">Panier</div>        
      <table class="table table-bordered table-striped">
        <thead>
          <tr>
           <th class="col-sm-4">Article</th>
           <th class="col-sm-2">Quantité</th>
           <th class="col-sm-2">Prix</th>
           <th class="col-sm-2">Total</th>
           <th class="col-sm-1"></th>
           <th class="col-sm-1"></th>
          </tr>
        </thead>
        <tbody>
          <tr *ngFor="let element of panier; let i=index">
            <td>{{ element.name | uppercase }}</td>
            <td>{{ element.quantity }}</td> 
            <td>{{ element.price | number:'1.0-2' | euros }}</td>
            <td>{{ element.quantity * element.price | number:'1.0-2' | euros }}</td>
            <td><button class="btn btn-info btn-block" (click)=editArticle(i)><span class="fa fa-edit fa-lg"></span></button></td>
            <td><button class="btn btn-danger btn-block" (click)=deleteArticle(i)><span class="fa fa-trash-o fa-lg"></span></button></td>
          </tr> 
          <tr>
            <td colspan="3"></td>
            <td><strong>{{ total | number:'1.0-2' | euros }}</strong></td>
            <td colspan="2"></td>
          </tr> 
          <tr>
            <td>
              <input type="text" class="form-control" ngControl="name" placeholder="Nom">
              <validation-messages [control]="form.controls.name"></validation-messages>
            </td>
            <td>
              <input type="text" class="form-control" ngControl="quantity">
              <validation-messages [control]="form.controls.quantity"></validation-messages>
            </td>
            <td>
              <input type="text" class="form-control" ngControl="price">
              <validation-messages [control]="form.controls.price"></validation-messages>
            </td>
            <td colspan="3"><button type="submit" [disabled]="!form.valid" class="btn btn-primary btn-block">Ajouter</button></td>
          </tr>
        </tbody>       
      </table>
    </div> 
  </form>
</div>

On va détailler tout ça…

On voit dans la classe que les éléments du panier sont mémorisés dans une propriété panier :

private panier: Array<Article> = [
  new Article('cahier', 2, 5.3),
  new Article('crayon', 4, 1.1),
  new Article('gomme', 1, 3.25)
];

C’est un tableau (Array) d’objets Article. On voit que la classe Article n’est pas définie dans le composant mais est importée :

import {Article}     from './article';

La classe Article

On trouve cette classe dans le fichier article.ts :

export class Article {

	private _name: string;
	private _quantity: number;
	private _price: number;

	constructor(name: string = '', quantity: number = 0, price: number = 0) {
		this._name = name;
		this._quantity = quantity;
		this._price = price;
	}

	get name(): string { return this._name; }
	set name(name: string) { this._name = name; }
	get quantity(): number { return this._quantity; }
	set quantity(quantity: number) { this._quantity = quantity; }
	get price(): number { return this._price; }
	set price(price: number) { this._price = price; }
}

Une simple classe avec trois propriétés :

  • name de type string,
  • quantity de type number,
  • price de type number.

Pour travailler proprement j’ai prévu des accesseurs, même si ce n’est pas vraiment utile dans ce cas. D’autre part on a aussi une initialisation par défaut des valeurs des propriétés dans le constructeur.

J’ai prévu déjà 3 articles dans le panier pour que quelque chose s’affiche au lancement.

L’affichage du panier

Dans le template l’affichage du panier est assuré par une directive ngFor que nous avons déjà vu dans un précédent article :

<tr *ngFor="let element of panier; let i=index">
  <td>{{ element.name | uppercase }}</td>
  <td>{{ element.quantity }}</td> 
  <td>{{ element.price | number:'1.0-2' | euros }}</td>
  <td>{{ element.quantity * element.price | number:'1.0-2' | euros }}</td>
  <td><button class="btn btn-info btn-block" (click)=editArticle(i)><span class="fa fa-edit fa-lg"></span></button></td>
  <td><button class="btn btn-danger btn-block" (click)=deleteArticle(i)><span class="fa fa-trash-o fa-lg"></span></button></td>
</tr>

Notez qu’on a prévu un index pour identifier les articles.

On trouve aussi des filtres (pipes) pour l’affichage des valeurs :

  • uppercase : nom de l’article en majuscules,
  • number : format du prix et du calcul prix * quantité avec au minimum un chiffre et deux décimales.

Pour ce qui concerne le filtre euro ce n’est pas quelque chose par défaut dans Angular2, c’est un filtre sur mesure dont je vais parler plus loin.

On a aussi la propriété total chargée de contenir le montant total du panier :

private total: number;

Et qui est affichée dans le template de la même manière :

<td><strong>{{ total | number:'1.0-2' | euros }}</strong></td>

Le filtre (pipe) euro

Vous remarquez qu’on importe le fichier de ce filtre :

import {EuroPipe} from './pipes/euro.pipe';

Et qu’on le déclare en tant que tel :

@Component({
  ...
  pipes: [EuroPipe],
  ...
})

Voici le code du filtre dans le fichier pipes/euro.pipe :

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({name: 'euros'})
export class EuroPipe implements PipeTransform {
  transform(value: number): string {
    return value + ' €';
  }
}

La syntaxe est simple :

  • on importe les éléments d’Angular2 nécessaires (la classe Pipe et son interface PipeTransform),
  • on utilise une annotation (@pipe) pour définir le nom du filtre,
  • on crée une méthode transform qui accepte une valeur en entrée et qui renvoie la valeur transformée, ici on se contente d’ajouter un espace suivi du caractère .

On pourrait prévoir des paramètres pour le filtre, il suffit d’ajouter un paramètre à la méthode transform.

Le formulaire

Venons en maintenant au formulaire. On le crée dans le composant :

private form: ControlGroup;

createForm() {
  this.form = this.builder.group({
    name: ['', Validators.required],
    quantity: ['', Validators.compose([Validators.required, NumberValidator.isInteger])],
    price: ['', Validators.compose([Validators.required, NumberValidator.isNumber])],
  });
  this.updateTotal();    
}...
}

On utilise le FormBuilder pour définir 3 contrôles :

  • name,
  • quantity,
  • price.

Pour chacun on a prévu la validation required (valeur requise). Pour la quantité et le prix on a ajouté (à l’aide de la méthode compose) des validations sur mesure dont je vais parler plus loin.

On peut donc facilement créer le formulaire dans le template :

<form (ngSubmit)="addArticle()" [ngFormModel]="form">
...
<tr>
  <td>
    <input type="text" class="form-control" ngControl="name" placeholder="Nom">
    ...
  </td>
  <td>
    <input type="text" class="form-control" ngControl="quantity">
    ...
  </td>
  <td>
    <input type="text" class="form-control" ngControl="price">
    ...
  </td>
  <td colspan="3"><button type="submit" [disabled]="!form.valid" class="btn btn-primary btn-block">Ajouter</button></td>
</tr>

Il faut utiliser la directive ngFormModel pour préciser le nom de groupe de contrôle (ControlGroup) et ensuite d’utiliser la directive ngControl avec le nom des contrôles correspondants et la liaison est établie !

A la soumission la directive ngSubmit va appeler la méthode addArticle du composant.

Le groupe de contrôle possède la propriété valid qui permet de savoir si tous les contrôles sont valides vis-à-vis de la validation voulue. On rend actif le bouton de soumission que si tout est valide.

Les validateurs sur mesure

On a vu qu’il y a deux validateurs sur mesure. J’ai créé la classe NumberValidator pour les regrouper. Il faut la charger :

import {NumberValidator}   from './validators/number.validator';

En voici le code :

import { Control } from '@angular/common';

interface ValidationResult { [key: string]: boolean; }

export class NumberValidator {
 
	static isInteger(control: Control): ValidationResult { 

		if (Number.isInteger(Number(control.value))) {
			return null;
		}

		return { "isInteger": true };

	}

	static isNumber(control: Control): ValidationResult { 

		if (Number.isNaN(Number(control.value))) {
			return { "isNumber": true };
		}

		return null;

	}

}

On crée des méthodes statiques avec un contrôle en entrée. Ensuite on renvoie null si tout va bien (oui ce n’est pas très intuitif…) et un message JSON dans le cas contraire. On a ainsi deux validateurs :

  • isInteger : c’est un nombre entier,
  • isNumber : c’est un nombre.

On a vu ci-dessus qu’il suffit ensuite de préciser le nom du validateur pour un contrôle :

quantity: ['', Validators.compose([Validators.required, NumberValidator.isInteger])],

Les messages d’erreur

Puisqu’on a prévu une validation il faut afficher des messages en cas d’erreur pour renseigner l’utilisateur. Pour ça j’ai créé une directive ValidationMessages qu’il faut donc charger :

import {ValidationMessages} from './validators/validation-messages.component';

Qu’il faut déclarer :

@Component({
  ...
  directives: [ValidationMessages]
})

En voici le code :

import { Component, Input }  from '@angular/core';
import { Control }           from '@angular/common';

@Component({
  selector: 'validation-messages',
  template: `
    <div *ngIf="control.dirty && !control.valid">
      <small class="text-danger" *ngIf="control.hasError('required')">La valeur est obligatoire</small>
      <small class="text-danger" *ngIf="control.hasError('isNumber')">La valeur doit être un nombre</small>
      <small class="text-danger" *ngIf="control.hasError('isInteger')">La valeur doit être un nombre entier</small>
    </div>  
  `
})
export class ValidationMessages {
  @Input() control: Control;
}

Un composant classique qui prend en entrée (@input) un contrôle. Au niveau du template du panier c’est facile à intégrer :

<validation-messages [control]="form.controls.name"></validation-messages>

Ici on envoie le contrôle name dans le composant des messages.

Au niveau du template des messages on utilise la directive ngIf pour afficher que si nécessaire :

  • control.dirty : le contrôle a été modifié,
  • !control.valid : le contrôle n’est pas valide.

Ensuite on utilise la méthode hasError pour sélectionner les erreurs et afficher le bon texte. Ce qui donne ce genre de résultat :

img041

Gestion des articles

Avec toute l’architecture en place il devient facile de gérer les articles.

Suppression

La suppression est le plus simple à réaliser, il suffit de retirer un élément du tableau du panier. On a un bouton dans le template :

<td><button class="btn btn-danger btn-block" (click)=deleteArticle(i)><span class="fa fa-trash-o fa-lg"></span></button>

Et la méthode appelée dans le composant :

deleteArticle(index: number) {
  this.panier.splice(index, 1);
  this.updateTotal();
}

On enlève l’élément du tableau et on recalcule le total :

updateTotal() {
  this.total = 0;
  for(let element of this.panier) {
    this.total += element.price * element.quantity;
  }
}

Et c’est réglé !

Modification et ajout

Pour la modification on a aussi un bouton dans le template :

<td><button class="btn btn-info btn-block" (click)=editArticle(i)><span class="fa fa-edit fa-lg"></span></button></td>

Et une méthode dans le composant :

editArticle(index: number) { 
  (<Control>this.form.find('name')).updateValue(this.panier[index].name);
  (<Control>this.form.find('quantity')).updateValue(this.panier[index].quantity);
  (<Control>this.form.find('price')).updateValue(this.panier[index].price);
  this.panier.splice(index, 1);
  this.updateTotal();
}

Trois actions :

  • on recopie les valeurs dans les contrôles du formulaire,
  • on retire l’élément du tableau,
  • on met à jour le total.

Ce qui donne ce genre d’aspect :

img038

Après modification des valeurs on clique sur le bouton Ajouter et on a la soumission du formulaire avec appel de la méthode addArticle :

addArticle() {
  this.panier.push(new Article(
    this.form.value.name, 
    Number(this.form.value.quantity), 
    Number(this.form.value.price)
  ));
  for(let key in this.form.controls) {
    let control = <Control>this.form.find(key);
    control.updateValue('');
    control.updateValueAndValidity();
  }
  this.updateTotal();
}

Plusieurs actions ici aussi :

  • on ajoute l’article dans le tableau,
  • on efface des valeurs dans les contrôles,
  • on fait un reset des contrôles (je n’ai pas réussi à faire un reset complet, le dirty reste en place, apparemment ce n’est pas encore au point pour cet aspect, du coup après la soumission on se retrouve avec les messages d’erreurs…),
  • on met à jour le total.

Pour un ajout d’article on utilise directement le formulaire !

Laisser un commentaire