Créer une application Web avec Angular et Firebase

1. Introduction

Dernière mise à jour : 11/09/2020

Ce que vous allez faire

Dans cet atelier de programmation, vous allez créer un tableau kanban Web avec Angular et Firebase. L'appli finale comportera trois catégories de tâches : en attente, en cours et terminées. Nous pourrons créer, supprimer et transférer des tâches d'une catégorie à une autre par glisser-déposer.

Nous allons développer l'interface utilisateur à l'aide d'Angular et utiliser Firestore comme stockage persistant. À la fin de l'atelier de programmation, nous déploierons l'appli sur Firebase Hosting à l'aide de la CLI Angular.

b23bd3732d0206b.png

Ce que vous allez apprendre

  • Utiliser Angular et le CDK
  • Ajouter l'intégration Firebase à votre appli Angular
  • Conserver vos données persistantes dans Firestore
  • Déployer votre application sur Firebase Hosting à l'aide de la CLI Angular en une seule commande

Prérequis

Dans cet atelier de programmation, nous partons du principe que vous possédez un compte Google et que vous maîtrisez les bases d'Angular et de la CLI Angular.

C'est parti !

2. Créer un projet

Commençons par créer un espace de travail Angular :

ng new kanban-fire
? Would you like to add Angular routing? No
? Which stylesheet format would you like to use? CSS

Cela peut prendre quelques minutes. La CLI Angular crée la structure de votre projet et installe toutes les dépendances. Une fois le processus d'installation terminé, accédez au répertoire kanban-fire et démarrez le serveur de développement de la CLI Angular :

ng serve

Ouvrez http://localhost:4200. Vous devriez obtenir un résultat semblable à celui-ci :

5ede7bc5b1109bf3.png

Dans votre éditeur, ouvrez src/app/app.component.html et supprimez l'intégralité de son contenu. Lorsque vous revenez dans http://localhost:4200, une page blanche doit s'afficher.

3. Ajouter Material et le CDK

Angular propose un ensemble de composants d'interface utilisateur conformes au Material Design dans le package @angular/material. L'une des dépendances de @angular/material est le Component Development Kit, ou CDK. Le CDK fournit des primitives, comme les utilitaires d'accessibilité, la fonctionnalité glisser-déposer et la superposition. Nous distribuons le CDK dans le package @angular/cdk.

Pour ajouter du contenu à votre application, saisissez la commande suivante :

ng add @angular/material

Cette commande vous invite à choisir un thème si vous souhaitez utiliser les styles de la typographie globale Material et configurer les animations du navigateur pour Angular Material. Sélectionnez "Indigo/Pink" (Indigo/Rose) pour obtenir le même résultat que dans cet atelier de programmation, puis répondez "Yes" (Oui) aux deux dernières questions.

La commande ng add installe @angular/material, ses dépendances et importe le BrowserAnimationsModule dans AppModule. À l'étape suivante, nous pourrons commencer à utiliser les composants de ce module.

Ajoutons d'abord une barre d'outils et une icône à AppComponent. Ouvrez app.component.html et ajoutez le balisage suivant :

src/app/app.component.html

<mat-toolbar color="primary">
  <mat-icon>local_fire_department</mat-icon>
  <span>Kanban Fire</span>
</mat-toolbar>

Ce balisage permet d'ajouter une barre d'outils avec la couleur principale du thème Material Design et d'y intégrer l'icône local_fire_depeartment à côté du libellé "Kanban Fire". Si vous observez la console à nouveau, vous constaterez qu'Angular génère des erreurs. Pour les résoudre, veillez à ajouter les importations suivantes à AppModule :

src/app/app.module.ts

...
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatIconModule } from '@angular/material/icon';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatToolbarModule,
    MatIconModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Étant donné que nous utilisons la barre d'outils et l'icône Angular Material, nous devons importer les modules correspondants dans AppModule.

L'écran suivant doit désormais s'afficher :

a39cf8f8428a03bc.png

Pas mal avec seulement quatre lignes de code HTML et deux importations !

4. Visualiser les tâches

Nous allons créer un composant permettant de visualiser les tâches dans le tableau kanban.

Accédez au répertoire src/app et exécutez la commande CLI suivante :

ng generate component task

Cette commande génère le TaskComponent et ajoute sa déclaration au AppModule. Dans le répertoire task, créez un fichier nommé task.ts. Ce fichier va nous permettre de définir l'interface des tâches dans le kanban. Chaque tâche est associée à des champs id, title et description facultatifs de type chaîne :

src/app/task/task.ts

export interface Task {
  id?: string;
  title: string;
  description: string;
}

À présent, nous allons mettre à jour task.component.ts. Nous voulons que TaskComponent accepte la saisie d'un objet de type Task pour qu'il puisse émettre les résultats "edit" :

src/app/task/task.component.ts

import { Component, Input, Output, EventEmitter } from '@angular/core';
import { Task } from './task';

@Component({
  selector: 'app-task',
  templateUrl: './task.component.html',
  styleUrls: ['./task.component.css']
})
export class TaskComponent {
  @Input() task: Task | null = null;
  @Output() edit = new EventEmitter<Task>();
}

Modifiez le modèle de TaskComponent. Pour ce faire, ouvrez task.component.html et remplacez son contenu par le code HTML suivant :

src/app/task/task.component.html

<mat-card class="item" *ngIf="task" (dblclick)="edit.emit(task)">
  <h2>{{ task.title }}</h2>
  <p>
    {{ task.description }}
  </p>
</mat-card>

Nous constatons que la console affiche désormais des erreurs :

'mat-card' is not a known element:
1. If 'mat-card' is an Angular component, then verify that it is part of this module.
2. If 'mat-card' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@NgModule.schemas' of this component to suppress this message.ng

Dans le modèle ci-dessus, nous utilisons le composant mat-card de @angular/material, mais nous n'avons pas importé le module correspondant dans l'appli. Pour corriger cette erreur, nous devons importer le MatCardModule dans AppModule :

src/app/app.module.ts

...
import { MatCardModule } from '@angular/material/card';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatCardModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Nous allons ensuite créer quelques tâches dans AppComponent et les visualiser à l'aide de TaskComponent.

Dans AppComponent, définissez un tableau appelé todo et ajoutez-y deux tâches :

src/app/app.component.ts

...
import { Task } from './task/task';

@Component(...)
export class AppComponent {
  todo: Task[] = [
    {
      title: 'Buy milk',
      description: 'Go to the store and buy milk'
    },
    {
      title: 'Create a Kanban app',
      description: 'Using Firebase and Angular create a Kanban app!'
    }
  ];
}

Au bas de app.component.html, ajoutez la directive *ngFor suivante :

src/app/app.component.html

<app-task *ngFor="let task of todo" [task]="task"></app-task>

Lorsque vous ouvrez le navigateur, les éléments suivants doivent s'afficher :

d96fccd13c63ceb1.png

5. Implémenter la fonctionnalité glisser-déposer pour les tâches

Passons maintenant à la partie la plus intéressante. Nous allons créer trois zones pour les trois états possibles des tâches. En outre, à l'aide du CDK Angular, nous allons implémenter une fonctionnalité de glisser-déposer.

Dans app.component.html, supprimez le composant app-task avec la directive *ngFor au-dessus et remplacez-le par le code suivant :

src/app/app.component.html

<div class="container-wrapper">
  <div class="container">
    <h2>Backlog</h2>

    <mat-card
      cdkDropList
      id="todo"
      #todoList="cdkDropList"
      [cdkDropListData]="todo"
      [cdkDropListConnectedTo]="[doneList, inProgressList]"
      (cdkDropListDropped)="drop($event)"
      class="list">
      <p class="empty-label" *ngIf="todo.length === 0">Empty list</p>
      <app-task (edit)="editTask('todo', $event)" *ngFor="let task of todo" cdkDrag [task]="task"></app-task>
    </mat-card>
  </div>

  <div class="container">
    <h2>In progress</h2>

    <mat-card
      cdkDropList
      id="inProgress"
      #inProgressList="cdkDropList"
      [cdkDropListData]="inProgress"
      [cdkDropListConnectedTo]="[todoList, doneList]"
      (cdkDropListDropped)="drop($event)"
      class="list">
      <p class="empty-label" *ngIf="inProgress.length === 0">Empty list</p>
      <app-task (edit)="editTask('inProgress', $event)" *ngFor="let task of inProgress" cdkDrag [task]="task"></app-task>
    </mat-card>
  </div>

  <div class="container">
    <h2>Done</h2>

    <mat-card
      cdkDropList
      id="done"
      #doneList="cdkDropList"
      [cdkDropListData]="done"
      [cdkDropListConnectedTo]="[todoList, inProgressList]"
      (cdkDropListDropped)="drop($event)"
      class="list">
      <p class="empty-label" *ngIf="done.length === 0">Empty list</p>
      <app-task (edit)="editTask('done', $event)" *ngFor="let task of done" cdkDrag [task]="task"></app-task>
    </mat-card>
  </div>
</div>

Cet extrait contient beaucoup d'informations. Examinons chacune des parties qu'il contient. Voici la structure de premier niveau du modèle :

src/app/app.component.html

...
<div class="container-wrapper">
  <div class="container">
    <h2>Backlog</h2>
    ...
  </div>

  <div class="container">
    <h2>In progress</h2>
    ...
  </div>

  <div class="container">
    <h2>Done</h2>
    ...
  </div>
</div>

Dans cet exemple, nous créons un div qui encapsule les trois zones avec le nom de classe "container-wrapper". Chaque zone comporte un nom de classe "container" et un titre dans une balise h2.

À présent, examinons la structure de la première zone :

src/app/app.component.html

...
    <div class="container">
      <h2>Backlog</h2>

      <mat-card
        cdkDropList
        id="todo"
        #todoList="cdkDropList"
        [cdkDropListData]="todo"
        [cdkDropListConnectedTo]="[doneList, inProgressList]"
        (cdkDropListDropped)="drop($event)"
        class="list"
      >
        <p class="empty-label" *ngIf="todo.length === 0">Empty list</p>
        <app-task (edit)="editTask('todo', $event)" *ngFor="let task of todo" cdkDrag [task]="task"></app-task>
      </mat-card>
    </div>
...

Nous définissons d'abord la zone en tant que mat-card qui utilise la directive cdkDropList. Nous utilisons un mat-card en raison des styles fournis par ce composant. Le cdkDropList permet de supprimer des tâches dans l'élément à une étape ultérieure. Nous avons également défini les deux entrées suivantes :

  • cdkDropListData : saisie de la liste déroulante qui permet de spécifier le tableau de données.
  • cdkDropListConnectedTo : références aux autres cdkDropList auxquelles la cdkDropList actuelle est connectée. Ce paramètre permet de spécifier les autres listes dans lesquelles des éléments peuvent être déposés.

En outre, nous voulons gérer l'événement "déposer" à l'aide de la sortie cdkDropListDropped. Une fois que cdkDropList a émis ce résultat, nous allons appeler la méthode drop déclarée dans AppComponent et transmettre l'événement actuel en tant qu'argument.

Notez que nous spécifions aussi un id en guise d'identifiant pour ce conteneur, ainsi qu'un nom class auquel appliquer un style. Examinons maintenant le contenu enfant de mat-card. Nous observons les deux éléments suivants :

  • Un paragraphe que nous utilisons pour afficher le texte "Empty list" (Liste vide) lorsque la liste todo ne contient aucun élément.
  • Le composant app-task. Notez que nous traitons ici la sortie edit déclarée à l'origine en appelant la méthode editTask avec le nom de la liste et l'objet $event. Nous pouvons ainsi remplacer la tâche modifiée dans la bonne liste. Ensuite, nous parcourons la liste todo comme nous l'avons fait ci-dessus et transmettons l'entrée task. Cette fois, nous allons aussi ajouter l'instruction cdkDrag afin que les tâches individuelles soient déplaçables.

Pour que tout cela fonctionne, nous devons mettre à jour app.module.ts et inclure une importation dans le DragDropModule :

src/app/app.module.ts

...
import { DragDropModule } from '@angular/cdk/drag-drop';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    DragDropModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Nous devons aussi déclarer les tableaux inProgress et done, en même temps que les méthodes editTask et drop :

src/app/app.component.ts

...
import { CdkDragDrop, transferArrayItem } from '@angular/cdk/drag-drop';

@Component(...)
export class AppComponent {
  todo: Task[] = [...];
  inProgress: Task[] = [];
  done: Task[] = [];

  editTask(list: string, task: Task): void {}

  drop(event: CdkDragDrop<Task[]|null>): void {
    if (event.previousContainer === event.container) {
      return;
    }
    if (!event.container.data || !event.previousContainer.data) {
      return;
    }
    transferArrayItem(
      event.previousContainer.data,
      event.container.data,
      event.previousIndex,
      event.currentIndex
    );
  }
}

Notez que, dans la méthode drop, nous vérifions d'abord si nous effectuons le dépôt dans la même liste que celle d'où vient la tâche. Si c'est le cas, nous annulons immédiatement l'opération. Sinon, nous transférons la tâche actuelle vers la zone de destination.

Vous devez obtenir le résultat suivant :

460f86bcd10454cf.png

À ce stade, vous devriez déjà pouvoir transférer des éléments entre les deux listes.

6. Créer des tâches

À présent, implémentons une fonctionnalité permettant de créer des tâches. Pour ce faire, nous allons mettre à jour le modèle de AppComponent :

src/app/app.component.html

<mat-toolbar color="primary">
...
</mat-toolbar>

<div class="content-wrapper">
  <button (click)="newTask()" mat-button>
    <mat-icon>add</mat-icon> Add Task
  </button>

  <div class="container-wrapper">
    <div class="container">
      ...
    </div>
</div>

Nous créons un élément div de premier niveau autour du container-wrapper et ajoutons un bouton avec une icône Material "add" à côté du libellé "Add Task" (Ajouter une tâche). Nous avons besoin d'un wrapper supplémentaire pour placer le bouton en haut de la liste des zones. Nous les placerons ensuite l'un à côté de l'autre à l'aide de Flexbox. Étant donné que ce bouton utilise le composant "bouton Material", nous devons importer le module correspondant dans AppModule :

src/app/app.module.ts

...
import { MatButtonModule } from '@angular/material/button';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatButtonModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

À présent, implémentons la fonctionnalité permettant d'ajouter des tâches dans AppComponent. Nous allons utiliser une boîte de dialogue Material. Un formulaire comportant deux champs (titre et description) s'affiche dans la boîte de dialogue. Lorsque l'utilisateur clique sur le bouton "Add Task" (Ajouter une tâche), la boîte de dialogue s'ouvre. Une fois le formulaire envoyé, l'utilisateur ajoute la tâche créée à la liste todo.

Examinons l'implémentation générale de cette fonctionnalité dans AppComponent :

src/app/app.component.ts

...
import { MatDialog } from '@angular/material/dialog';

@Component(...)
export class AppComponent {
  ...

  constructor(private dialog: MatDialog) {}

  newTask(): void {
    const dialogRef = this.dialog.open(TaskDialogComponent, {
      width: '270px',
      data: {
        task: {},
      },
    });
    dialogRef
      .afterClosed()
      .subscribe((result: TaskDialogResult|undefined) => {
        if (!result) {
          return;
        }
        this.todo.push(result.task);
      });
  }
}

Nous déclarons un constructeur dans lequel nous injectons la classe MatDialog. Dans newTask, nous pouvons effectuer les opérations suivantes :

  • Ouvrir une nouvelle boîte de dialogue à l'aide de TaskDialogComponent (que nous allons définir prochainement)
  • Indiquer que la largeur de la boîte de dialogue doit être égale à 270px.
  • Transmettre une tâche vide à la boîte de dialogue en tant que données (dans TaskDialogComponent, nous pouvons obtenir une référence à cet objet de données)
  • S'abonner à l'événement de fermeture et ajouter la tâche de l'objet result au tableau todo

Pour nous assurer que tout fonctionne correctement, nous devons d'abord importer MatDialogModule dans AppModule :

src/app/app.module.ts

...
import { MatDialogModule } from '@angular/material/dialog';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatDialogModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

À présent, créons le TaskDialogComponent. Accédez au répertoire src/app et exécutez la commande suivante :

ng generate component task-dialog

Pour implémenter sa fonctionnalité, commencez par ouvrir src/app/task-dialog/task-dialog.component.html et remplacez son contenu par le code suivant :

src/app/task-dialog/task-dialog.component.html

<mat-form-field>
  <mat-label>Title</mat-label>
  <input matInput cdkFocusInitial [(ngModel)]="data.task.title" />
</mat-form-field>

<mat-form-field>
  <mat-label>Description</mat-label>
  <textarea matInput [(ngModel)]="data.task.description"></textarea>
</mat-form-field>

<div mat-dialog-actions>
  <button mat-button [mat-dialog-close]="{ task: data.task }">OK</button>
  <button mat-button (click)="cancel()">Cancel</button>
</div>

Dans le modèle ci-dessus, nous créons un formulaire avec deux champs pour title et description. Nous utilisons l'instruction cdkFocusInput pour sélectionner automatiquement l'entrée title lorsque l'utilisateur ouvre la boîte de dialogue.

Notez que le modèle fait référence à la propriété data du composant. Il s'agit des mêmes data que nous transmettons à la méthode open de dialog dans AppComponent. Pour mettre à jour le titre et la description de la tâche lorsque l'utilisateur modifie le contenu des champs correspondants, nous utilisons une liaison de données bidirectionnelle avec ngModel.

Lorsque l'utilisateur clique sur le bouton "OK", nous affichons automatiquement le résultat { task: data.task }, qui correspond à la tâche que nous avons transformée à l'aide des champs de formulaire du modèle ci-dessus.

À présent, implémentons le contrôleur du composant :

src/app/task-dialog/task-dialog.component.ts

import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Task } from '../task/task';

@Component({
  selector: 'app-task-dialog',
  templateUrl: './task-dialog.component.html',
  styleUrls: ['./task-dialog.component.css'],
})
export class TaskDialogComponent {
  private backupTask: Partial<Task> = { ...this.data.task };

  constructor(
    public dialogRef: MatDialogRef<TaskDialogComponent>,
    @Inject(MAT_DIALOG_DATA) public data: TaskDialogData
  ) {}

  cancel(): void {
    this.data.task.title = this.backupTask.title;
    this.data.task.description = this.backupTask.description;
    this.dialogRef.close(this.data);
  }
}

Dans TaskDialogComponent, nous injectons une référence à la boîte de dialogue afin de pouvoir la fermer. Nous pouvons également injecter la valeur du fournisseur associé au jeton MAT_DIALOG_DATA. Il s'agit de l'objet de données que nous avons transmis à la méthode ouverte dans la AppComponent ci-dessus. Nous déclarons aussi la propriété privée backupTask, qui est une copie de la tâche que nous avons transmise avec l'objet de données.

Lorsque l'utilisateur appuie sur le bouton d'annulation, nous restaurons les propriétés éventuellement modifiées de this.data.task et nous fermons la boîte de dialogue, ce qui entraîne la transmission de this.data.

Deux types ont été mentionnés, mais n'ont pas encore été déclarés : TaskDialogData et TaskDialogResult. Dans src/app/task-dialog/task-dialog.component.ts, ajoutez les déclarations suivantes en bas du fichier :

src/app/task-dialog/task-dialog.component.ts

...
export interface TaskDialogData {
  task: Partial<Task>;
  enableDelete: boolean;
}

export interface TaskDialogResult {
  task: Task;
  delete?: boolean;
}

La dernière étape à effectuer avant d'accéder à la fonctionnalité consiste à importer quelques modules dans AppModule.

src/app/app.module.ts

...
import { MatInputModule } from '@angular/material/input';
import { FormsModule } from '@angular/forms';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatInputModule,
    FormsModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

Lorsque vous cliquez sur le bouton "Add Task" (Ajouter une tâche), l'interface utilisateur suivante devrait s'afficher :

33bcb987fade2a87.png

7. Améliorer les styles de l'appli

Afin de rendre l'application plus attrayante, nous allons améliorer sa mise en page en modifiant légèrement ses styles. Nous devons positionner les zones côte à côte et modifier légèrement le bouton "Add Task" (Ajouter une tâche) ainsi que le libellé de liste vide.

Ouvrez src/app/app.component.css et ajoutez les styles suivants en bas du fichier :

src/app/app.component.css

mat-toolbar {
  margin-bottom: 20px;
}

mat-toolbar > span {
  margin-left: 10px;
}

.content-wrapper {
  max-width: 1400px;
  margin: auto;
}

.container-wrapper {
  display: flex;
  justify-content: space-around;
}

.container {
  width: 400px;
  margin: 0 25px 25px 0;
}

.list {
  border: solid 1px #ccc;
  min-height: 60px;
  border-radius: 4px;
}

app-new-task {
  margin-bottom: 30px;
}

.empty-label {
  font-size: 2em;
  padding-top: 10px;
  text-align: center;
  opacity: 0.2;
}

Dans l'extrait de code ci-dessus, nous modifions la mise en page et le libellé de la barre d'outils. Nous veillons aussi à ce que le contenu soit aligné horizontalement en définissant sa largeur sur 1400px et sa marge sur auto. Ensuite, à l'aide de Flexbox, nous plaçons les zones côte à côte, puis nous modifions la façon dont nous visualisons les tâches et les listes vides.

Une fois votre appli actualisée, l'interface utilisateur suivante doit s'afficher :

69225f0b1aa5cb50.png

Bien que nous ayons amélioré de manière significative les styles de notre appli, un problème gênant se produit toujours lorsque nous déplaçons des tâches :

f9aae712027624af.png

Lorsque nous commençons à faire glisser la tâche "Buy milk" (Acheter du lait), deux fiches s'affichent pour la même tâche : celle que nous faisons glisser et celle de la zone. La CDK Angular fournit des noms de classes CSS que nous pouvons utiliser pour résoudre ce problème.

Ajoutez les remplacements de style suivants en bas du fichier src/app/app.component.css :

src/app/app.component.css

.cdk-drag-animating {
  transition: transform 250ms;
}

.cdk-drag-placeholder {
  opacity: 0;
}

Lorsque vous faites glisser un élément, la fonction de glisser-déposer du CDK Angular le clone et l'insère à l'emplacement où nous allons déposer l'élément d'origine. Pour veiller à ce que cet élément ne soit pas visible, nous allons définir la propriété "opacity" dans la classe cdk-drag-placeholder, que le CDK ajoutera à l'espace réservé.

De plus, lorsque nous supprimons un élément, le CDK ajoute la classe cdk-drag-animating. Pour afficher une animation fluide, nous définissons une transition avec une durée de 250ms au lieu d'ancrer l'élément.

En outre, nous devons apporter quelques modifications mineures aux styles de nos tâches. Dans task.component.css, nous allons définir l'affichage de l'élément hôte sur block et définir des marges :

src/app/task/task.component.css

:host {
  display: block;
}

.item {
  margin-bottom: 10px;
  cursor: pointer;
}

8. Modifier et supprimer des tâches existantes

Pour modifier et supprimer des tâches existantes, nous allons réutiliser la plupart des fonctionnalités que nous avons déjà implémentées. Lorsque l'utilisateur double-clique sur une tâche, nous ouvrons TaskDialogComponent et renseignons les deux champs du formulaire avec les title et description de la tâche.

Nous allons aussi ajouter un bouton de suppression à TaskDialogComponent. Lorsque l'utilisateur clique dessus, nous transmettons une instruction de suppression qui sera placée dans AppComponent.

Dans TaskDialogComponent, nous devons uniquement modifier le modèle :

src/app/task-dialog/task-dialog.component.html

<mat-form-field>
 ...
</mat-form-field>

<div mat-dialog-actions>
  ...
  <button
    *ngIf="data.enableDelete"
    mat-fab
    color="primary"
    aria-label="Delete"
    [mat-dialog-close]="{ task: data.task, delete: true }">
    <mat-icon>delete</mat-icon>
  </button>
</div>

Ce bouton affiche l'icône Material de suppression. Lorsque l'utilisateur clique dessus, nous fermons la boîte de dialogue et transmettons le littéral d'objet { task: data.task, delete: true }. En outre, notez que nous avons modifié le bouton à l'aide de mat-fab, que nous avons défini sa couleur comme couleur principale et qu'il n'est affiché que lorsque la suppression des données de la boîte de dialogue est activée.

AppComponent contient le reste de l'implémentation de la fonctionnalité de modification et de suppression. Remplacez sa méthode editTask par le code suivant :

src/app/app.component.ts

@Component({ ... })
export class AppComponent {
  ...
  editTask(list: 'done' | 'todo' | 'inProgress', task: Task): void {
    const dialogRef = this.dialog.open(TaskDialogComponent, {
      width: '270px',
      data: {
        task,
        enableDelete: true,
      },
    });
    dialogRef.afterClosed().subscribe((result: TaskDialogResult|undefined) => {
      if (!result) {
        return;
      }
      const dataList = this[list];
      const taskIndex = dataList.indexOf(task);
      if (result.delete) {
        dataList.splice(taskIndex, 1);
      } else {
        dataList[taskIndex] = task;
      }
    });
  }
  ...
}

Examinons les arguments de la méthode editTask :

  • Liste de type 'done' | 'todo' | 'inProgress', qui est un type d'union de littéral de chaîne dont les valeurs correspondent aux propriétés associées aux zones individuelles.
  • Tâche actuelle que nous voulons modifier.

Dans le corps de la méthode, nous commençons par ouvrir une instance de TaskDialogComponent. Pour ses data, nous transmettons un littéral d'objet, qui spécifie la tâche à modifier, et active le bouton "Edit" (Modifier) du formulaire en définissant la propriété enableDelete sur true.

Lorsque nous obtenons le résultat de la boîte de dialogue, nous pouvons gérer deux scénarios :

  • Lorsque l'indicateur delete est défini sur true (c'est-à-dire, lorsque l'utilisateur a appuyé sur le bouton "Delete" (Supprimer)), nous supprimons la tâche de la liste correspondante.
  • Nous pouvons aussi remplacer la tâche dans l'index donné par la tâche obtenue à partir du résultat de la boîte de dialogue.

9. Créer un projet Firebase

À présent, voyons comment créer un projet Firebase.

10. Ajouter Firebase au projet

Dans cette section, nous allons intégrer notre projet à Firebase. L'équipe Firebase propose le package @angular/fire, qui permet l'intégration entre les deux technologies. Pour ajouter la compatibilité Firebase à votre appli, ouvrez le répertoire racine de votre espace de travail et exécutez la commande suivante :

ng add @angular/fire

Cette commande installe le package @angular/fire et vous pose quelques questions. Dans votre terminal, vous devriez obtenir le code suivant :

9ba88c0d52d18d0.png

Pendant ce temps, l'installation ouvre une fenêtre de navigateur vous permettant de vous authentifier avec votre compte Firebase. Enfin, vous êtes invité à choisir un projet Firebase et à créer des fichiers sur votre disque.

Nous devons ensuite créer une base de données Firestore. Sous "Cloud Firestore", cliquez sur "Create Database" (Créer une base de données).

1e4a08b5a2462956.png

Ensuite, créez une base de données en mode test :

ac1181b2c32049f9.png

Enfin, sélectionnez une région :

34bb94cc542a0597.png

Il vous suffit maintenant d'ajouter la configuration Firebase à votre environnement. La configuration de votre projet est indiquée dans la console Firebase.

  • Cliquez sur l'icône en forme de roue dentée à côté de "Project Overview" (Vue d'ensemble du projet).
  • Sélectionnez "Project Settings" (Paramètres du projet).

c8253a20031de8a9.png

Sous "Your apps" (Vos applis), sélectionnez une appli Web :

428a1abcd0f90b23.png

Enregistrez ensuite votre application et assurez-vous d'activer Firebase Hosting :

586e44cb27dd8f39.png

Après avoir cliqué sur "Register app" (Enregistrer l'appli), vous pouvez copier votre configuration dans src/environments/environment.ts :

e30f142d79cecf8f.png

Une fois que vous avez terminé, votre fichier de configuration devrait se présenter comme suit :

src/environments/environment.ts

export const environment = {
  production: false,
  firebase: {
    apiKey: '<your-key>',
    authDomain: '<your-project-authdomain>',
    databaseURL: '<your-database-URL>',
    projectId: '<your-project-id>',
    storageBucket: '<your-storage-bucket>',
    messagingSenderId: '<your-messaging-sender-id>'
  }
};

11. Déplacer les données vers Firestore

Maintenant que nous avons configuré le SDK Firebase, utilisons @angular/fire pour déplacer nos données vers Firestore. Commençons par importer les modules nécessaires dans AppModule :

src/app/app.module.ts

...
import { environment } from 'src/environments/environment';
import { AngularFireModule } from '@angular/fire';
import { AngularFirestoreModule } from '@angular/fire/firestore';

@NgModule({
  declarations: [AppComponent, TaskDialogComponent, TaskComponent],
  imports: [
    ...
    AngularFireModule.initializeApp(environment.firebase),
    AngularFirestoreModule
  ],
  providers: [],
  bootstrap: [AppComponent],
})
export class AppModule {}

Comme nous allons utiliser Firestore, nous devons injecter AngularFirestore dans le constructeur de AppComponent :

src/app/app.component.ts

...
import { AngularFirestore } from '@angular/fire/firestore';

@Component({...})
export class AppComponent {
  ...
  constructor(private dialog: MatDialog, private store: AngularFirestore) {}
  ...
}

Nous allons ensuite mettre à jour la façon dont nous initialisons les tableaux des zones :

src/app/app.component.ts

...

@Component({...})
export class AppComponent {
  todo = this.store.collection('todo').valueChanges({ idField: 'id' }) as Observable<Task[]>;
  inProgress = this.store.collection('inProgress').valueChanges({ idField: 'id' }) as Observable<Task[]>;
  done = this.store.collection('done').valueChanges({ idField: 'id' }) as Observable<Task[]>;
  ...
}

Ici, nous utilisons AngularFirestore pour obtenir le contenu de la collection directement à partir de la base de données. Notez que valueChanges affiche un objet observable au lieu d'un tableau, et que nous spécifions que le champ "id" des documents de cette collection doit être appelé id pour correspondre au nom que nous utilisons dans l'interface Task. L'objet observable renvoyé par valueChanges émet une collection de tâches à chaque modification.

Comme nous travaillons avec des objets observables au lieu de tableaux, nous devons mettre à jour notre méthode d'ajout, de suppression et de modification des tâches, ainsi que la fonctionnalité de déplacement des tâches entre les zones. Au lieu de modifier les tableaux en mémoire, nous utilisons le SDK Firebase pour mettre à jour les données dans la base de données.

Commençons par examiner l'apparence de la réorganisation. Remplacez la méthode drop dans src/app/app.component.ts par ce qui suit :

src/app/app.component.ts

drop(event: CdkDragDrop<Task[]>): void {
  if (event.previousContainer === event.container) {
    return;
  }
  const item = event.previousContainer.data[event.previousIndex];
  this.store.firestore.runTransaction(() => {
    const promise = Promise.all([
      this.store.collection(event.previousContainer.id).doc(item.id).delete(),
      this.store.collection(event.container.id).add(item),
    ]);
    return promise;
  });
  transferArrayItem(
    event.previousContainer.data,
    event.container.data,
    event.previousIndex,
    event.currentIndex
  );
}

Dans l'extrait de code ci-dessus, le nouveau code est mis en surbrillance. Pour déplacer une tâche de la zone actuelle vers la zone cible, nous allons la supprimer de la première collection et l'ajouter à la seconde. Étant donné que nous effectuons deux opérations, nous les exécutons dans une transaction Firestore, car nous voulons donner l'impression qu'une seule opération a eu lieu (autrement dit, rendre l'opération atomique).

Nous allons maintenant mettre à jour la méthode editTask pour utiliser Firestore. Dans le gestionnaire de dialogues de fermeture, nous devons modifier les lignes de code suivantes :

src/app/app.component.ts

...
dialogRef.afterClosed().subscribe((result: TaskDialogResult|undefined) => {
  if (!result) {
    return;
  }
  if (result.delete) {
    this.store.collection(list).doc(task.id).delete();
  } else {
    this.store.collection(list).doc(task.id).update(task);
  }
});
...

À l'aide du SDK Firestore, nous accédons au document cible correspondant à la tâche manipulée, puis nous le supprimons ou le mettons à jour.

Pour terminer, nous devons mettre à jour la méthode de création des tâches. Remplacez this.todo.push('task') par this.store.collection('todo').add(result.task).

Notez que nos collections ne sont plus des tableaux, mais des objets observables. Pour les visualiser, nous devons mettre à jour le modèle de AppComponent en remplaçant simplement chaque accès des propriétés todo, inProgress et done par todo | async, inProgress | async et done | async, respectivement.

Le pipeline asynchrone s'abonne automatiquement aux objets observables associés aux collections. Lorsque les objets observables émettent une nouvelle valeur, Angular exécute automatiquement la détection des modifications et traite le tableau émis.

Examinons par exemple les modifications à apporter à la zone todo :

src/app/app.component.html

<mat-card
  cdkDropList
  id="todo"
  #todoList="cdkDropList"
  [cdkDropListData]="todo | async"
  [cdkDropListConnectedTo]="[doneList, inProgressList]"
  (cdkDropListDropped)="drop($event)"
  class="list">
  <p class="empty-label" *ngIf="(todo | async)?.length === 0">Empty list</p>
  <app-task (edit)="editTask('todo', $event)" *ngFor="let task of todo | async" cdkDrag [task]="task"></app-task>
</mat-card>

Lorsque nous transmettons les données à la directive cdkDropList, nous appliquons le pipeline asynchrone. Le fonctionnement est le même pour *ngIf. Toutefois, notez que nous utilisons aussi un chaînage facultatif (également appelé opérateur de navigation sécurisée dans Angular) pour accéder à la propriété length, et ce, afin d'éviter les erreurs d'exécution si la valeur de todo | async n'est pas null ou undefined.

Lorsque vous créez une tâche dans l'interface utilisateur et que vous ouvrez Firestore, vous devriez obtenir un écran semblable à celui-ci :

dd7ee20c0a10ebe2.png

12. Améliorer les mises à jour optimistes

Dans l'application, nous effectuons actuellement des mises à jour optimistes. Nous disposons de sources d'informations fiables dans Firestore, mais aussi de copies locales des tâches, en parallèle. Lorsque l'un des objets observables associés aux collections émet des valeurs, nous obtenons un tableau de tâches. Lorsqu'une action utilisateur modifie l'état, nous mettons d'abord à jour les valeurs locales, puis propageons la modification dans Firestore.

Lorsque nous déplaçons une tâche d'une zone à une autre, nous appelons transferArrayItem,, qui fonctionne sur les instances locales des tableaux représentant les tâches dans chaque zone. Le SDK Firebase traite ces tableaux comme immuables. Cela signifie que la prochaine fois qu'Angular exécutera l'appel de détection des modifications, nous obtiendrons de nouvelles instances qui afficheront l'état précédent avant le transfert de la tâche.

En parallèle, nous déclenchons une mise à jour Firestore, et le SDK Firebase déclenche une mise à jour avec les valeurs appropriées. Ainsi, en quelques millisecondes, l'interface utilisateur retrouve son état correct. La tâche que nous venons de transférer passe ainsi de la première liste à la suivante. Le GIF ci-dessous montre clairement ce résultat :

70b946eebfa6f316.gif

La bonne façon de résoudre ce problème varie d'une application à l'autre. Cependant, dans tous les cas, nous devons veiller à conserver un état cohérent jusqu'à ce que nos données soient mises à jour.

Nous pouvons exploiter BehaviorSubject, qui encapsule l'observateur d'origine que nous recevons de valueChanges. En arrière-plan, BehaviorSubject conserve un tableau modifiable avec la mise à jour à partir de transferArrayItem.

Pour implémenter un correctif, il suffit de mettre à jour le AppComponent :

src/app/app.component.ts

...
import { AngularFirestore, AngularFirestoreCollection } from '@angular/fire/firestore';
import { BehaviorSubject } from 'rxjs';

const getObservable = (collection: AngularFirestoreCollection<Task>) => {
  const subject = new BehaviorSubject<Task[]>([]);
  collection.valueChanges({ idField: 'id' }).subscribe((val: Task[]) => {
    subject.next(val);
  });
  return subject;
};

@Component(...)
export class AppComponent {
  todo = getObservable(this.store.collection('todo')) as Observable<Task[]>;
  inProgress = getObservable(this.store.collection('inProgress')) as Observable<Task[]>;
  done = getObservable(this.store.collection('done')) as Observable<Task[]>;
...
}

Dans cet extrait, nous nous contentons de créer un BehaviorSubject, qui émet une valeur à chaque fois que l'objet observable associé à la collection change.

Tout fonctionne comme prévu, car le BehaviorSubject réutilise le tableau lors des appels de détection des changements et ne se met à jour que lorsqu'une nouvelle valeur est récupérée via Firestore.

13. Déploiement de l'application

Pour déployer l'appli, il nous suffit d'exécuter la commande suivante :

ng deploy

Cette commande :

  1. Créez votre appli avec sa configuration de production en appliquant des optimisations au temps de compilation.
  2. Déployez votre appli sur Firebase Hosting.
  3. Générez une URL pour prévisualiser le résultat.

14. Félicitations

Félicitations, vous avez créé un tableau kanban avec Angular et Firebase !

Vous avez créé une interface utilisateur avec trois colonnes représentant l'état de différentes tâches. À l'aide du CDK Angular, vous avez implémenté la fonctionnalité de glisser-déposer des tâches dans les colonnes. À l'aide d'Angular, vous avez ensuite créé un formulaire permettant de créer et de modifier des tâches, puis vous avez appris à utiliser @angular/fire et déplacé tout l'état de l'application vers Firestore. Enfin, vous avez déployé votre application sur Firebase Hosting.

Quelle est l'étape suivante ?

Gardez en tête que nous avons déployé l'application à l'aide de configurations de test. Avant de déployer votre appli en production, veillez à configurer les autorisations appropriées. Pour savoir comment procéder, cliquez ici.

Pour le moment, nous ne conservons pas l'ordre des tâches individuelles dans une zone particulière. Vous pouvez utiliser un champ de commande dans le document de tâche et procéder à un tri en fonction de ce champ.

Par ailleurs, nous avons conçu le tableau kanban pour un seul utilisateur, ce qui signifie qu'un seul tableau s'affiche pour tous les utilisateurs qui ouvrent l'appli. Pour implémenter des tableaux distincts pour différents utilisateurs de l'appli, vous devez modifier la structure de votre base de données. Pour découvrir les bonnes pratiques concernant Firestore, cliquez ici.