Les templates sont des documents HTML spécifiant comment le rendu d'un composant doit être réalisé. Le contenu d'un template peut être directement explicité dans l'entrée template de l'annotation @Component d'un composant ou alors nous pouvons indiquer un lien vers un fichier externe dans l'entrée templateUrl.
Toute balise HTML peut être utilisée dans un template Angular à l'exception notable de <script> : la génération de code JavaScript est de la reponsabilité du framework Angular, ainsi nous n'avons aucun intérêt à écrire du code JavaScript à la main dans un template. De plus si ce code dépendait d'une entrée de l'utilisateur, il y aurait un risque potentiel d'injection de code malveillant.
Les balises HTML "de haut niveau" (<html>, <body>...) n'ont pas à être utilisées dans un template : elles sont employées une seule fois dans le fichier src/index.html racine. La philosophie d'Angular est de créer des applications web utilisant une unique page HTML dans laquelle l'arbre DOM est dynamiquement modifié en fonction des intéractions avec l'utilisateur ou des services web.
Nous supposons que le lecteur de cette page connaît déjà le langage HTML, ainsi nous nous intéresserons plus particulièrement aux directives de template introduites par Angular.
Notons qu'un template Angular doit être traduit en code JavaScript pour pouvoir être utilisé. La compilation d'un template peut se faire de deux manières :
- Compilation JIT (Just In Time) : le template est traduit à la volée lors de sa première utilisation. Le temps de construction de l'application est réduit puisque la compilation de template est reportée à l'exécution. Cette approche est privilégiée lors de la phase de développement où les fichiers sont amenés à être souvent modifiés. L'inconvénient de cette technique est que les erreurs de template ne seront détectées qu'à l'exécution. D'autre part le code JavaScript exécuté est plus volumineux puisqu'il faut embarquer le compilateur de template.
- Compilation AoT (Ahead of Time) : le template est traduit lors de la compilation de l'application et les erreurs sont donc détectées lors de cette phase. L'exécution est plus rapide. Cette approche est utilisée lorsque l'on construit l'application pour la production avec ng build --prod.
Pour activer la compilation AoT des templates pour le serveur de développement on pourra utiliser la commande ng serve --aot
Un aperçu des directives de template Angular
Directives d'expressions
Nous présentons ici un résumé des différentes manières d'incorporer une expression dans un template Angular :
- L'interpolation: {{ expression }}. Elle permet d'incorporer une expression depuis le contexte du composant courant. Ainsi <p>Your name is {{ userName }}</p> incorpore le champ this.userName du composant courant dans la vue HTML. A priori n'importe quelle expression JavaScript valide (sans effet de bord, ni utilisation de new ou d'opérateurs binaires & et |) peut être utilisée (avec appels de méthodes notamment).
- Expressions d'attribut de balise: <tag [attribute]="expression">. Nous pouvons spécifier une expression JavaScript pour une propriété d'une balise HTML. Par exemple si l'URL d'une image dépend d'un champ this.imageSource du composant courant, nous indiquons la balise <img [src]="imageSource"> (le nom de la propriété est entre crochets). Si nous n'utilisions pas les crochets, l'attribut serait une chaîne statique "imageSource".
- Liaison sur événements: <tag (event)="doAction()">. Pour une balise représentant un élément, nous pouvons associer une action à exécuter lorsqu'un événement survient. Par exemple si nous souhaitons exécuter la méthode compute du composant courant lorsque l'on clique sur un bouton, nous écrivons : <button (click)="compute()">. A priori, n'importe quelle instruction JavaScript pour réagir l'événement, cette instruction étant exécutée dans le contexte du composant courant : il est donc classique d'écrire des méthodes dans le composant et de les appeler depuis le template.
- Liaison bidirectionnelle: <tag [(attribute)]="propertyName"> (syntaxe dite "banane dans une boîte"). Cela nous permet de lier à un attribut d'un composant embarqué une propriété (champ) du composant courant. La différence avec la syntaxe [attribute]="propertyName" est que la liaison fonctionne non seulement dans le sens d'une injection d'une propriété du composant parent vers le composant enfant mais que le composant enfant peut modifier l'attribut, cette valeur étant répercutée au composant parent. L'arbre DOM sera mis à jour automatiquement pour refléter cette modification.
Directives structurelles
Les directives structurelles permettent de modifier l'arbre DOM en ajoutant ou supprimant des éléments. Elles permettent notamment d'introduire des structures de contrôle au sein des templates pour exécuter conditionnellement certaines parties ou alors pour réaliser des boucles permettant d'afficher plusieurs éléments.
Les directives structurelles sont des attributs de balise toujours préfixés par une asterisque.
Directives conditionnelles
La directive *ngIf permet d'afficher un composant enfant uniquement si une expression du composant courant est évaluée vraie. Dans l'exemple qui suit, la balise <div> ne sera affichée que si le champ this.isConnected du composant est vrai :
<div *ngIf="isConnected">You are connected and your login is {{ userName }}</div>
Les directives *ngSwitchCase, *ngSwitchDefault offrent la possibilité de proposer plusieurs alternatives en fonction de la valeur d'une expression :
<ng-container [ngSwitch]="order.status"> <div *ngSwitchCase="'pending'"> Vous n'avez pas encore validé votre commande </div> <div *ngSwitchCase="'received'"> Votre commande {{ order.id }} est reçue et en cours de traitement </div> <div *ngSwitchCase="'payed'"> Votre paiement a été validé, nous préparons votre commande </div> <div *ngSwitchCase="'shipped'"> <p>Votre commande a été expédiée avec le numéro de suivi {{ order.parcel.trackingNumber }}, voici son suivi : </p> <parcel-tracking [parcel]="order.parcel"></parcel-tracking> </div> <div *ngSwitchCase="'delivered'"> Votre commande a été livrée avec succès ! </div> <div *ngSwitchDefault> Désolé, un problème est survenu dans la gestion de votre commande, veuillez nous contacter </div> </ng-container>
Dans les exemples présentés, les directives ont été appliquées sur des balises <div> ; il est possible des les appliquer sur n'importe quelle balise ou composant Angular.
☞ La balise ng-container ne rajoute pas de nœud dans l'arbre DOM.
Directive de boucle
La directive *ngFor permet d'afficher plusieurs fois un composant pour différents éléments d'une variable itérable. Par exemple, une page peut afficher le panier d'achat d'un client d'une boutique en ligne :
<app-basket-item *ngFor="let item of items" [item]="item"></app-basket-item>
Il est possible aussi de récupérer l'indice de chaque item affiché ainsi :
<div *ngFor="let item of items; let i = index"> Voici l'item {{ i }} du panier : <app-basket-item [item]="item"></app-basket-item> </div>
Si la liste est modifiée (par exemple par un autre composant sur le template), la région de l'arbre DOM affichant le panier devra être complétement recalculée. Il est possible de limiter le recalcul en associant à chaque item un identifiant unique. Si Angular retrouve des items de même identifiants après modification, il réutilisera les sous-arbres DOM déjà générés.
<app-basket-item *ngFor="let item of items; trackBy: trackByItems"></app-basket-item>
Il est nécessaire d'implanter une méthode trackByItems dans le composant afin de retourner un identifiant unique pour un item, ici la référence produit de l'item :
trackByItems(index: number, item: Item): string { return item.reference; }
Expressions
-
Les expressions indiquées avec {{ expression }} et <tag attribute=[expression]> sont évaluées dans le contexte du composant courant :
- Seuls les éléments du composant this sont disponibles
- Il est inutile de spécifier this
- Les objets et fonctions globales de JavaScript ne sont pas disponibles (window, document, setTimeout...)
- Angular déspécialise les caractères qui pourraient être interprétés comme spécifiant des balises HTML (notamment < et >) : il n'est donc pas possible de produire du code HTML avec une expression
Il est préconisé de respecter les principes essentiels suivants :
- Pas d'effet de bord de l'expression
- Exécution rapide de l'expression (pas d'appel de méthodes longues à calculer)
- Simplicité de l'expression (pas d'expression trop longue ; si l'expression est complexe, on écrit une méthode dans le composant que l'on appelle depuis le template)
- Idempotence de l'expression (si l'expression est évaluée deux fois d'affilée, elle retourne la même valeur)
Nommage de composant
Il est possible de donner un nom à un composant d'un template afin d'y faire référence. Voici un exemple où nous disposons d'un champ de formulaire input et souhaitons afficher sa valeur dans le template :
<input #dataInput> Valeur du champ : {{ dataInput.value }}
dataInput peut aussi être accessible à l'intérieur du code du composant en ajoutant le champ suivant :
@Component( { ... }) export class CustomComponent { ... @ViewChild('dataInput') dataInputRef: ElementRef; doSomeAction() { ... let valueInDataInput = dataInputRef.nativeElement.value; ... } }
Pipes
Les expressions peuvent être transformées par des pipes. Il est possible d'utiliser des pipes fournis par l'API (lowercase, uppercase, currency, json, slice, percent, date...). Par exemple si nous souhaitons formater un pourcentage, nous pouvons employer le pipe percent :
/** * Affiche le nombre a en pourcentage en utilisant au moins 4 chiffres avant la virgule * et entre 3 et 5 chiffres après la virgule avec la locale française (utilisation de virgule comme séparateur décimal) * Avec l'exemple a = 1.458, l'affichage sera 0145,800 % */ @Component({ selector: 'percent-test', template: `<div> {{ a | percent:'4.3-5':'fr' }} </div>` }) export class PercentPipeComponent { a: number = 1.458; }
Il est possible aussi d'écrire ses propres pipes en implantant l'interface PipeTransform. Ici nous écrivons un pipe renversant une chaîne :
import { Pipe, PipeTransform } from '@angular/core'; /** Pipe transforming a string by reversing the order of characters */ @Pipe({name: 'reverse'}) export class ReversePipe implements PipeTransform { transform(value: string): string { return s.split("").reverse().join(""); } }
Expressions immuables et changeantes
Les expressions introduites dans {{ }} et [ ] peuvent être immuables ou peuvent avoir une valeur changeante. Si la valeur change, Angular doit être en mesure de le détecter.
Angular déclenche le détecteur de changement sur l'arbre des composants lorsqu'un événement a lieu (clic sur un composant par exemple, réception du résultat d'une requête HTTP, exécution d'une fonction suite à un setTimeout ou setInterval...). Chacune des expressions du template de chaque composant est réévaluée : si sa valeur est modifiée, l'arbre DOM représentant le composant sera reconstruit. Afin de détecter une modification d'expression, seul un test d'égalité superficiel est réalisé. Cela signifie qu'un tableau modifié ou un objet dont une propriété a été modifiée ne sera pas considéré comme différent et ne déclenchera pas une reconstruction de la vue.
Les expressions pouvant être réévaluées souvent, il est important que leur calcul utilise peu de ressources. Il est préférable de mentionner des champs du composant comme expressions plutôt que des méthodes (possiblement coûteuses).
Voici un exemple de composant affichant l'heure courante à travers un champ ou une méthode. Par ailleurs un tableau est mélangé (en place) toutes les secondes. On constatera que si l'on conserve le même tableau (modifié), l'affichage n'est pas mis à jour. Les méthodes ngOnInit et ngOnDestroy sont respectivement appelées à l'initialisation de la vue et lors de sa destruction : nous désactivons l'exécution périodique de la fonction avec clearInterval lors de la destruction.
import { Component, OnInit, OnDestroy } from '@angular/core'; import { shuffle } from '../utils/arrays'; @Component({ selector: 'app-mutability', template: `<div> <p>Calling method getCurrentDate(): {{ getCurrentDate() | date:'fullTime' }}</p> <p>Displaying now field: {{ now | date:'fullTime' }}</p> <p>Content of the array (shuffled each second): {{ shuffledArray }}</p> <p>Content of a copy of the array: {{ copiedShuffledArray }}</p> </div>`, styleUrls: ['./mutability.component.css'] }) export class MutabilityComponent implements OnInit, OnDestroy { now: Date; shuffledArray = [1, 2, 3, 4, 5, 6]; copiedShuffledArray: number[]; intervalHandle; constructor() { this.now = this.getCurrentDate(); } getCurrentDate(): Date { return new Date(); } ngOnInit() { this.intervalHandle = setInterval(() => { this.now = this.getCurrentDate(); shuffle(this.shuffledArray); this.copiedShuffledArray = this.shuffledArray.slice(0, this.shuffledArray.length); }, 1000); } ngOnDestroy() { if (this.intervalHandle) clearInterval(this.intervalHandle); } }
L'exécution de l'algorithme de détection de changement à chaque événement peut être coûteux. Si l'état d'un composant ne dépend que de la valeur de ses propriétés d'entrée (annotées avec @Input), alors il est recommandé d'utiliser la stratégie de détection de changement onPush. La détection de changement ne sera réalisée que si les propriétés d'entrée sont modifiées. Voici un exemple avec un composant affichant le nom et le prénom d'une personne :
import { Component, Input, ChangeDetectionStrategy } from '@angular/core'; import { Person } from "../model/person" @Component({ selector: 'app-person', template: `<div>Component ID: {{ id }}, FirstName: {{ person.firstName }}, LastName: {{ person.lastName }}</div>`, changeDetection: ChangeDetectionStrategy.OnPush }) export class PersonComponent { @Input() person: Person; /** Giving an unique incrementing id for each component */ static counter = 0; id = PersonComponent.counter++; constructor() { } }
Nous pouvons alors embarquer le composant Person dans un composant parent :
import { Component, Input, ChangeDetectionStrategy, OnInit, OnDestroy } from '@angular/core'; import { Person } from "../model/person" @Component({ selector: 'app-couple', template: `<div>Here is a couple: <app-person *ngFor="let p of [person1, person2]" [person]="p"></app-person> </div>` }) export class CoupleComponent implements OnInit, OnDestroy { person1 = new Person("Jane", "Doe"); person2 = new Person("John", "Doe"); timeoutHandle; constructor() { } ngOnInit() { this.timeoutHandle = setTimeout(() => { this.switchPeople(); }, 10000); } ngOnDestroy() { clearTimeout(this.timeoutHandle); } switchPeople() { let tmp = this.person1; this.person1 = this.person2; this.person2 = tmp; } }
S'il survient un événement au cours duquel nous appelons la méthode switchPeople, les attributs d'entrée des composants Person sont réévalués. Angular détecte que les références d'objet restent les mêmes mais dans un ordre différent dans le tableau [person1, person2] : les composants sont échangés dans l'arbre DOM (ce que nous vérifions avec les ids affichés). Il n'y a pas de tentative de reconstruction de l'arbre DOM de chaque composant car la référence d'objet du paramètre d'entrée person ne change pas et que nous utilisons la stratégie de détection onPush.
Modifions maintenant la méthode switchPeople2 :
switchPeople2() { let tmp = [this.person1.firstName, this.person1.lastName]; [this.person1.firstName, this.person1.lastName] = [this.person2.firstName, this.person2.lastName]; [this.person2.firstName, this.person2.lastName] = tmp; }
Les références d'objet ne sont pas modifiées : c'est le contenu des champs qui est échangé. Aucun changement sur les paramètres d'entrée n'est détecté par Angular lorsque survient l'événement aucours duquel la méthode switchPeople2 est appelée. La stratégie onPush étant utilisé sur le composant Person, aucune détection de changement n'est mené sur ces composants. Avec la stratégie par défaut, l'affichage aurait été rafraíchi.
Il en résulte que la stratégie de détection de changement onPush doit être réservée aux composants :
- dont l'état ne dépend que des paramètres @Input
- et dont ces paramètres sont immuables (le contenu des objets ne doit pas être modifiable)
Nullabilité des types
Une variable typée avec TypeScript ne peut prendre la valeur null ou undefined sauf si cela est explicitement déclaré. Ainsi une variable person: Person ne peut pas être nulle contrairement à person: Person|null. Ainsi la fonction suivante devrait poser un problème car person pourrait être nul :
function getPersonInitials(person: Person|null) { return person.firstName.charAt(0) + person.lastName.charAt(0); }
Par défaut TypeScript ne réalise pas de vérification stricte de nullité et autorise la compilation de cette méthode. Il est possible d'activer la vérification stricte de nullité en modifiant le fichier tsconfig.json à la racine du projet :
{ "compilerOptions": { ... "strictNullChecks": true }
Si cette vérification est activée globalement et qu'une variable peut être nulle, il est possible de désactiver la vérification dans une expression de template en la suffixant par un ! :
Name of the person: {{ person!.name }}
Si person a une valeur nulle à l'exécution cela provoquerait une exception ; il est donc plus sûr de tester la nullité ainsi que la valeur undefined :
<div *ngIf="person !== null && person !== undefined"> Name of the person: {{ person.name }} </div>
Angular propose aussi un opérateur d'accès ?. sécurisé qui permet d'accéder à un membre d'un objet possiblement nul. Si l'objet est nul, le rendu de l'expression sera une chaîne vide :
Name of the person: {{ person?.name }}
Gestion d'événements
Événements standards
L'exécution d'une instruction peut être demandée dans un template suite à la survenue d'un événement. Par exemple si l'on souhaite appeler la méthode doAction() (implantée dans le composant) suite au clic sur un bouton, on écrira :
<button (click)="doAction()">Do the action</button>
A priori tous les événements standards JavaScript peuvent être supportés. Voici l'exemple d'un composant affichant une zone div rouge exploitant l'événement mouseover pour afficher les coordonnées actuelles de la souris dans la zone :
import { Component } from '@angular/core'; @Component({ selector: 'app-divmove', template: `<div> <div class="divzone" (mousemove)="updateCoordinates($event)"></div> <ul *ngIf="x !== null && y !== null"> <li>x = {{ x }}</li> <li>y = {{ y }}</li> </ul> <div *ngIf="x === null"> Not already entered in the red div zone! </div> `, styles: [`.divzone { width: 300px; height: 300px; background-color: red }`] }) export class DivmoveComponent { x: number|null = null; y: number|null = null; updateCoordinates(event) { [this.x, this.y] = [event.offsetX, event.offsetY]; } }
Événements personnalisés
Un composant peut définir un événement personnalisé qu'il peut émettre dans certaines circonstances. Pour cela, nous définissons un champ EventEmitter<T> dans le composant que nous annotons avec @Output() :
@Component({ ... }) export class CustomComponent { @Output myEvent = new EventEmitter<number>(); // événement émis sous la forme d'un nombre someMethod() { ... // Nous émettons l'événement this.myEvent.emit(42); } }
L'événement peut être capturé dans le template d'un composant parent pour faire une action :
<app-custom (myEvent)="doSomeAction()"></app-custom>
À titre d'application, implantons une machine à sous simplifiée avec deux rouleaux de symboles de 1 à 7. Nous empochons 7 fois notre mise si nous obtenons 2 symboles identiques, sinon nous perdons notre mise.
import { Component, Input, Output, EventEmitter } from '@angular/core'; @Component({ selector: 'app-slot-machine', template: `<div *ngIf="draw !== null">Slot machine draw: {{ draw[0] }} - {{ draw[1] }}</div> <div *ngIf="draw === null">Slot machine not used yet</div>` }) export class SlotMachineComponent { readonly minSymbol = 1; readonly maxSymbol = 7; readonly betMultiplier = 7; // the bet is multiplied by this value when we win // money bet in the slot machine @Input() bet: number; draw: [number, number] | null = null; @Output() gain = new EventEmitter<number>(); randomInt() { return Math.floor(Math.random() * (this.maxSymbol - this.minSymbol) + 1) + this.minSymbol; } doNewDraw() { this.draw = [this.randomInt(), this.randomInt()]; if (this.draw[0] === this.draw[1]) this.gain.emit( this.bet * this.betMultiplier ); // we win and multiply the bet else this.gain.emit ( - this.bet ); // we lose the bet } }
Nous pouvons incorporer notre composant machine à sous dans un composant casino gérant le solde d'un compte de joueur de casino. Ce composant enregistre une méthode à appeler lorsque l'événement gain survient sur la machine à sous afin de mettre à jour le solde du compte du joueur :
import { Component, OnInit, Input } from '@angular/core'; @Component({ selector: 'app-casino', template: `<div> <p>Welcome in the casino!</p> <app-slot-machine #machine [bet]="bet" (gain)="updateBalance($event)"></app-slot-machine> <div *ngIf="lastGain > 0">Congratulations, you won {{ lastGain }}</div> <div *ngIf="lastGain < 0">Sorry, you lost {{ -lastGain }}</div> Place your bet: <input [value]="bet" (input)="setBet($event.target.value)"> <br> <button *ngIf="balance >= bet" (click)="machine.doNewDraw()">Draw for a bet of {{ bet }}</button> <br> Balance of your account: {{ balance }} </div> ` }) export class CasinoComponent implements OnInit { @Input() initialBalance: number = 100; balance: number; bet = 0; lastGain = 0; ngOnInit() { this.balance = Math.floor(this.initialBalance); } setBet(bet) { bet = Math.floor(bet); if (! isNaN(bet)) this.bet = bet; } updateBalance(amount) { this.lastGain = amount; this.balance += amount; } }
Crochets (hooks) du cycle de vie
Un composant peut implanter des méthodes qui seront appelées lors d'événements de son cycle de vie :
- ngOnChanges(changes: SimpleChanges) {} : méthode appelée lorsqu'une (ou plusieurs) propriété annotée @Input() est modifiée sur le composant. Le paramètre changes est un objet contenant pour chaque propriété changée l'ancienne et la nouvelle valeur. Par exemple pour une propriété nommée person, on pourra récupérer changes.person.previousValue et changes.person.currentValue. La détection de changement est superficielle sur les propriétés ; si une propriété a pour valeur un objet, le changement d'un de ses champs ne sera pas détecté ; seule l'affectation d'un nouvel objet sera détectée. Pour détecter n'importe quel changement, on pourra implanter la méthode ngDoCheck() dans laquelle on pourra vérifier des potentielles modifications. Cette pratique est néanmoins à proscrire car ngDoCheck() est appelée très souvent et dégrade les performances de notre application.
- ngOnInit() {} : méthode appelée lorsque le composant est initialisé (une seule fois) ; cette méthode est appelée après un appel initial à ngOnChanges.
- ngOnDestroy() {} : méthode appelée avant que le composant ne soit détruit ; utile pour libérer des ressources utilisées par le composant, supprimer des timeout (clearTimeout) ou annuler des abonnements à des Observables RxJS.
Liaisons bidirectionnelles
Une liaison bidirectionnelle exprimée avec la syntaxe "banane dans la boîte" [( )] permet de fournir une valeur pour l'attribut d'un composant mais également d'étre informé des modifications réalisées par le composant sur cet attribut.
Formulaire basé sur un template
L'établissement de liaisons bidirectionnelles est utile pour les champs de formulaire. Nous proposons ici un exemple implantant un convertisseur de devises muni d'une zone de texte afin de rentrer une valeur numérique ainsi qu'un élément select afin de choisir la devise de la valeur. Une liste de conversions est affichée pour la valeur entrée.
Tout d'abord notons que l'utilisation de formulaires nécessite l'import de FormModule dans app.module.ts :
import { FormsModule } from '@angular/forms'; ... @NgModule({ ... imports: [ BrowserModule, FormsModule ], ... })
Nous écrivons ensuite le composant CurrencyConverter :
import { Component, OnInit } from '@angular/core'; class Currency { // value is the number of units we can buy with one euro constructor(public name: string, public value: number) {} } class MoneyAmount { constructor (public quantity: number, public currency: Currency) {} convert(otherCurrency: Currency): MoneyAmount { let newQuantity = this.quantity * this.currency.value / otherCurrency.value; return new MoneyAmount(newQuantity, otherCurrency); } toString() { return `${this.quantity.toFixed(2)} ${this.currency.name}`; } } const CURRENCIES = [ new Currency("EUR", 1), new Currency("FRF", 1/6.55957), new Currency("USD", 1/1.14), new Currency("GBP", 1/0.9), new Currency("BTC", 1/0.0003) ]; @Component({ selector: 'app-currency-converter', template: `<div> <form #currencyForm="ngForm"> <input type="number" [(ngModel)]="moneyAmount.quantity" name="quantity" id="quantity"> <select name="currency" id="currency" [(ngModel)]="moneyAmount.currency"> <option *ngFor="let currency of currencies" [ngValue]="currency">{{ currency.name }}</option> </select> </form> <ul> <li *ngFor="let currency of currencies">{{ moneyAmount.convert(currency) }}</li> </ul> <button (click)="multiply(2)">Multiply by 2</button> </div>`, styleUrls: ['./currency-converter.component.css'] }) export class CurrencyConverterComponent { moneyAmount: MoneyAmount = new MoneyAmount(1, CURRENCIES[0]); currencies = CURRENCIES; multiply(factor) { this.moneyAmount.quantity *= 2; } }
La liaison bidrectionnelle est mise en œuvre avec l'attribut [(ngModel}]=".." qui permet d'indiquer à quelle valeur du modèle est liée la valeur du champ de formulaire. La liaison fonctionne dans les deux sens : une modification de modèle entraînera une modification de la valeur affichée dans le champ ([]) et une modification de la valeur du champ entraîne un événement mettant à jour la valeur du modèle (()).
Le module de formulaires d'Angular offre des fonctionnalités supplémentaires afin de valider les valeurs des champs et de vérifier ainsi que l'utilisateur a entré des données valides.
Ce chapitre de la documentation Angular traite plus en détails du fonctionnement des formulaires.
Création de liaisons bidirectionnelles
Nous pouvons créer des liaisons bidirectionnelles sur nos composants. Le principe consiste à créer deux propriétés dans la classe du composant l'une nommée x annotée avec @Input() pour recevoir une valeur et l'autre nommée xChange avec @Output() pour un émetteur d'événement prévenant d'un changement de valeur :
@Input() x: type; @Output() xChange = new EventEmitter<type>();
Nous appliquons ce principe afin de réaliser un composant permettant d'entrer une suite de chiffres à l'aide d'un pavé numérique (composant utilisé sur certains sites bancaires pour limiter les risques liés à l'espionnage des touches de clavier) :
import { Component, EventEmitter, Input, Output } from '@angular/core'; import { shuffle } from '../utils/arrays'; @Component({ selector: 'app-keypad', template: `<div> <table> <tr *ngFor="let i of [0, 3, 6]"> <td *ngFor="let j of [i, i+1, i+2]"> <button (click)="appendDigit(this.digits[j])">{{ this.digits[j] }}</button> </td> </tr> </table> {{ value }} </div> ` }) export class KeypadComponent { readonly digits = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; @Input() value: string = ""; @Output() valueChange = new EventEmitter<string>(); constructor() { shuffle(this.digits); } appendDigit(j) { this.value += "" + j; this.valueChange.emit(this.value); } }
Embarquons le composant Keypad dans un composant parent KeypadWrapper :
import { Component, OnInit, Input } from '@angular/core'; @Component({ selector: 'app-keypad-wrapper', template: `<app-keypad [(value)]="keypadValue"></app-keypad> <button (click)="keypadValue = ''">Reset</button>`, styleUrls: ['./keypad-wrapper.component.css'] }) export class KeypadWrapperComponent implements OnInit { keypadValue: string = ""; constructor() { } ngOnInit() { } }
Formulaires réactifs
Angular propose deux types de formulaires :
- Les formulaires basés sur des templates. Ils exploitent les liaisons bidirectionnelles déclarées dans le template.
- Les formulaires réactifs. Ils sont plus flexibles et modifiables dynamiquement. La structure du formulaire réactif est déclaré dans le composant ; le template permet d'organiser graphiquement l'affichage des champs
Nous nous intéressons ici aux formulaires réactifs. La première étape pour leur emploi consiste à déclarer ReactiveFormsModule dans app.module.ts :
import { ReactiveFormsModule } from '@angular/forms'; @NgModule({ imports: [ // other imports ... ReactiveFormsModule ], }) export class AppModule { }
Un composant nécessitant un formulaire peut utiliser un FormBuilder qui permet de déclarer la structure du formulaire composée de groupes, de tableaux (nœuds internes) ainsi que de contrôles (feuilles). On peut créer un composant avec un formulaire ainsi construit :
import { Component, OnInit } from '@angular/core'; import { FormBuilder } from '@angular/forms'; import { Validators } from '@angular/forms'; import { DirectoryService } from '../directory.service'; @Component({ selector: 'app-directory', templateUrl: './directory.component.html', styleUrls: ['./directory.component.css'], providers: [DirectoryService] }) export class DirectoryComponent implements OnInit { form = this.fb.array([]); lastSave: Date|null = null; createDirectoryEntryForm() { return this.fb.group({ name: ['', Validators.required], coordinates: this.fb.group({ phone: ['', Validators.pattern("[0-9 ]+")], email: ['', Validators.email] }) }); } addDirectoryEntry(value) { let f = this.createDirectoryEntryForm(); f.setValue(value); this.form.push(f); } removeDirectoryEntry(position) { this.form.removeAt(position); } save() { this.directoryService.saveEntries(this.form.value).subscribe( result => { if (result) this.lastSave = new Date() }); } constructor(private directoryService: DirectoryService, private fb: FormBuilder) { } ngOnInit() { this.directoryService.loadEntries().subscribe( value => this.addDirectoryEntry(value) ); } ngOnDestroy() { } }
Initialement la formulaire principal directoryContent est un tableau vide. Nous ajouterons ensuite de nouvelles entrées avec la méthode addDirectoryEntryForm(). L'objectif est de réaliser une application permettant de gérer un carnet d'adresses (avec téléphone et adresse email pour chaque personne).
Pour chaque champ de formulaire utilisé, nous pouvons ajouter un validateur (Validators.required pour name par exemple) vérifiant la validité de la valeur entrée. Il est possible de créer ses propres validateurs ou d'utiliser des validateurs existants. Il est possible de créer des validateurs synchrones (d'exécution rapide pour calculer immédiatement si la valeur est valide) ou asynchrones (qui peuvent par exemple nécessiter d'interroger un serveur web). Ainsi le validateur pattern utilisée pour l'adresse email est synchrone (validant avec une expression régulière). Nous aurions pu aussi écrire notre propre validateur asynchrone interrogeant un service web vérifiant si le nom de domaine de l'adresse email est bien valide (avec une requête DNS).
On peut ajouter globalement à un groupe de contrôles d'un formulaire un validateur vérifiant la cohérence de plusieurs champs entre-eux.
Pour afficher les champs avec leur contenu dans le template, nous accédons à form déclaré dans le composant :
<div> <form [formGroup]="form" (ngSubmit)="save()"> <div *ngFor="let dirEntry of form.controls; let i=index" style="border: dotted"> <div [formGroupName]="i"> <label>Name: <input type="text" formControlName="name"></label> <a (click)="removeDirectoryEntry(i)" style="background-color: red">Remove</a> <div formGroupName="coordinates"> <label>Phone: <input type="text" formControlName="phone"></label> <label>Email: <input type="text" formControlName="email"></label> </div> </div> </div> <button (click)="addDirectoryEntry({name: '', coordinates:{phone: '', email: ''} }); false">Add entry</button> <button type="submit" [disabled]="!form.valid">Save</button> <p *ngIf="lastSave">Last save: {{ lastSave| date:"full" }}</p> </form> </div>
On peut ensuite consulter form déclaré dans DirectoryComponent pour avoir des informations sur l'état du formulaire :
- form.valid : indique si le formulaire est valide (ayant passé avec succès les tests des validateurs)
- form.value : indique la valeur du formulaire (un objet JavaScript avec la valeur de tous les champs)
Internationalisation (i18n)
Internationaliser une application consiste à la rendre disponible dans différentes langues. Pour cela on réalise un template dans une langue par défaut et on marque les chaînes traduisibles en ajoutant un attribut i18n="@@identifierOfTheString". Chaque identifiant i18n doit être unique. Il est possible aussi de traduire des attributs de tag en ajoutant un attribut i18n-nameOfTheAttribute="@@identifierOfTheString". Voici un exemple simple :
<h1 i18n="@@pictureDisplayer">Picture displayer</h1> <img src="picture.jpg" alt="Displayed picture" i18n-alt="@@pictureCaption">
Il est conseillé aussi d'utiliser les pipes suivants qui facilitent l'affichage de données localisées :
- date pour afficher une date (fonctionne sur un objet JavaScript Date)
- currency pour afficher une valeur monétaire ; par exemple {{b | currency:'USD':'symbol':'1.2-2'}} affiche si b=3.10 $ 3.10 avec une locale US et 3,10 $ avec une locale FR.
- decimal ou percent pour afficher un flottant avec une certaine précision ; par exemple { b | decimal:'1.2-3' } affiche le flottant b avec au miminum 1 chiffre avant la virgule et deux à trois chiffres après la virgule.
On peut générer un fichier messages.xlf réunissant l'ensemble des chaînes i18n avec la commande suivante :
ng xi18n --output-path src/locale
Par défaut Angular utilise le format XML xliff pour l'internationalisation. Il existe des éditeurs spécialisés pour ce type de fichiers comme Poedit.
Si nous souhaitons créer une traduction française, nous copions le fichier messages.xlf en un fichier messages.fr.xlf que nous pouvons éditer pour fournir les traductions des chaînes. Pour tester la nouvelle locale, on lance la commande :
ng serve --aot --configuration=fr
Malheureusement à l'heure actuelle Angular ne gère pas la détection automatique de langue préférée du navigateur afin d'adapter dynamiquement la locale. Pour pallier ce manque, une approche envisageable consiste à compiler plusieurs versions de l'application pour chacune des langues et à configurer le serveur web utilisé pour qu'il redirige l'internaute vers la version la plus adaptée (exemple pour Nginx).