Creazione di un'applicazione web con Angular e Firebase

1. Introduzione

Ultimo aggiornamento: 11/09/2020

Cosa creerai

In questo codelab, creeremo una bacheca kanban web con Angular e Firebase. La nostra app finale avrà tre categorie di attività: backlog, in corso e completate. Potremo creare, eliminare attività e trasferirle da una categoria all'altra utilizzando il trascinamento.

Svilupperemo l'interfaccia utente utilizzando Angular e Firestore come archivio persistente. Alla fine del codelab eseguiremo il deployment dell'app su Firebase Hosting utilizzando la CLI di Angular.

b23bd3732d0206b.png

Cosa imparerai a fare

  • Come utilizzare Angular Material e il CDK.
  • Come aggiungere l'integrazione di Firebase alla tua app Angular.
  • Come conservare i dati persistenti in Firestore.
  • Come eseguire il deployment dell'app su Firebase Hosting utilizzando l'interfaccia a riga di comando di Angular con un unico comando.

Che cosa ti serve

Questo codelab presuppone che tu disponga di un Account Google e di una conoscenza di base di Angular e della CLI Angular.

Iniziamo.

2. Creare un nuovo progetto

Innanzitutto, creiamo un nuovo spazio di lavoro Angular:

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

Questo passaggio potrebbe richiedere alcuni minuti. Angular CLI crea la struttura del progetto e installa tutte le dipendenze. Al termine della procedura di installazione, vai alla directory kanban-fire e avvia il server di sviluppo di Angular CLI:

ng serve

Apri http://localhost:4200 e dovresti vedere un output simile a questo:

5ede7bc5b1109bf3.png

Nell'editor, apri src/app/app.component.html ed elimina tutti i contenuti. Quando torni alla pagina http://localhost:4200, dovresti vedere una pagina vuota.

3. Aggiunta di Material e del CDK

Angular include l'implementazione di componenti dell'interfaccia utente conformi a Material Design come parte del pacchetto @angular/material. Una delle dipendenze di @angular/material è il Component Development Kit o CDK. Il CDK fornisce primitive, come utilità di accessibilità, trascinamento e sovrapposizione. Distribuiamo il CDK nel pacchetto @angular/cdk.

Per aggiungere materiale alla tua corsa:

ng add @angular/material

Questo comando ti chiede di scegliere un tema, se vuoi utilizzare gli stili tipografici globali di Material e se vuoi configurare le animazioni del browser per Angular Material. Scegli "Indaco/Rosa" per ottenere lo stesso risultato di questo codelab e rispondi "Sì" alle ultime due domande.

Il comando ng add installa @angular/material, le relative dipendenze e importa BrowserAnimationsModule in AppModule. Nel passaggio successivo, possiamo iniziare a utilizzare i componenti offerti da questo modulo.

Innanzitutto, aggiungiamo una barra degli strumenti e un'icona a AppComponent. Apri app.component.html e aggiungi il seguente markup:

src/app/app.component.html

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

Qui aggiungiamo una barra degli strumenti utilizzando il colore principale del nostro tema Material Design e al suo interno utilizziamo l'icona local_fire_depeartment accanto all'etichetta "Kanban Fire". Se ora guardi la console, vedrai che Angular genera alcuni errori. Per correggerli, assicurati di aggiungere le seguenti importazioni a 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 { }

Poiché utilizziamo la barra degli strumenti e l'icona di Angular Material, dobbiamo importare i moduli corrispondenti in AppModule.

Ora sullo schermo dovresti vedere quanto segue:

a39cf8f8428a03bc.png

Non male con sole 4 righe di HTML e due importazioni.

4. Visualizzare le attività

Come passaggio successivo, creiamo un componente che possiamo utilizzare per visualizzare le attività nella bacheca Kanban.

Vai alla directory src/app ed esegui questo comando della CLI:

ng generate component task

Questo comando genera TaskComponent e aggiunge la relativa dichiarazione a AppModule. All'interno della directory task, crea un file denominato task.ts. Utilizzeremo questo file per definire l'interfaccia delle attività nel tabellone Kanban. Ogni attività avrà campi facoltativi id, title e description di tipo stringa:

src/app/task/task.ts

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

Ora aggiorniamo task.component.ts. Vogliamo che TaskComponent accetti come input un oggetto di tipo Task e che sia in grado di emettere gli output "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>();
}

Modifica il modello di TaskComponent. Apri task.component.html e sostituisci i contenuti con il seguente codice HTML:

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>

Nota che ora vengono visualizzati errori nella console:

'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

Nel modello precedente utilizziamo il componente mat-card di @angular/material, ma non abbiamo importato il modulo corrispondente nell'app. Per correggere l'errore riportato sopra, dobbiamo importare MatCardModule in AppModule:

src/app/app.module.ts

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

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

Successivamente creeremo alcune attività in AppComponent e le visualizzeremo utilizzando TaskComponent.

In AppComponent definisci un array chiamato todo e al suo interno aggiungi due attività:

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!'
    }
  ];
}

Ora, in fondo a app.component.html aggiungi la seguente direttiva *ngFor:

src/app/app.component.html

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

Quando apri il browser, dovresti visualizzare quanto segue:

d96fccd13c63ceb1.png

5. Implementazione del trascinamento per le attività

Ora siamo pronti per la parte divertente. Creiamo tre corsie per i tre diversi stati in cui possono trovarsi le attività e, utilizzando Angular CDK, implementiamo una funzionalità di trascinamento.

In app.component.html, rimuovi il componente app-task con la direttiva *ngFor in alto e sostituiscilo con:

src/app/app.component.html

<div class="content-wrapper">
  <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>
</div>

Qui succedono molte cose. Esaminiamo le singole parti di questo snippet passo dopo passo. Questa è la struttura di primo livello del modello:

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>

Qui creiamo un div che racchiude tutte e tre le corsie, con il nome della classe "container-wrapper". Ogni corsia ha un nome della classe "container" e un titolo all'interno di un tag h2.

Ora esaminiamo la struttura della prima corsia:

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>
...

Innanzitutto, definiamo la corsia come mat-card, che utilizza la direttiva cdkDropList. Utilizziamo un mat-card a causa degli stili forniti da questo componente. cdkDropList ci consentirà in seguito di rilasciare le attività all'interno dell'elemento. Abbiamo anche impostato i seguenti due input:

  • cdkDropListData: input dell'elenco a discesa che consente di specificare l'array di dati
  • cdkDropListConnectedTo: riferimenti agli altri cdkDropList a cui è connesso l'cdkDropList corrente. Impostando questo input, specifichiamo in quali altre liste possiamo inserire gli elementi

Inoltre, vogliamo gestire l'evento di rilascio utilizzando l'output cdkDropListDropped. Una volta che cdkDropList emette questo output, invocheremo il metodo drop dichiarato all'interno di AppComponent e passeremo l'evento corrente come argomento.

Tieni presente che specifichiamo anche un id da utilizzare come identificatore per questo contenitore e un nome class per poterlo stilizzare. Ora esaminiamo i nodi secondari di mat-card. I due elementi che abbiamo sono:

  • Un paragrafo, che utilizziamo per mostrare il testo "Elenco vuoto" quando non sono presenti elementi nell'elenco todo
  • Il componente app-task. Tieni presente che qui gestiamo l'output edit che abbiamo dichiarato originariamente chiamando il metodo editTask con il nome dell'elenco e l'oggetto $event. In questo modo, potremo sostituire l'attività modificata con quella dell'elenco corretto. Successivamente, iteriamo l'elenco todo come abbiamo fatto sopra e passiamo l'input task. Questa volta, però, aggiungiamo anche la direttiva cdkDrag. Rende trascinabili le singole attività.

Per far funzionare tutto questo, dobbiamo aggiornare app.module.ts e includere un'importazione in DragDropModule:

src/app/app.module.ts

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

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

Dobbiamo anche dichiarare gli array inProgress e done, insieme ai metodi editTask e 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[]>): 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
    );
  }
}

Tieni presente che nel metodo drop controlliamo innanzitutto di rilasciare l'attività nella stessa lista da cui proviene. In questo caso, il pagamento viene restituito immediatamente. In caso contrario, trasferiamo l'attività corrente nella corsia di destinazione.

Il risultato dovrebbe essere:

460f86bcd10454cf.png

A questo punto dovresti già essere in grado di trasferire elementi tra i due elenchi.

6. Creare nuove attività

Ora implementiamo una funzionalità per creare nuove attività. A questo scopo, aggiorniamo il modello di 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>

Creiamo un elemento div di primo livello intorno a container-wrapper e aggiungiamo un pulsante con un'icona Material "add" accanto all'etichetta "Aggiungi attività". Abbiamo bisogno del wrapper aggiuntivo per posizionare il pulsante sopra l'elenco delle corsie, che in seguito posizioneremo una accanto all'altra utilizzando Flexbox. Poiché questo pulsante utilizza il componente pulsante Material, dobbiamo importare il modulo corrispondente in AppModule:

src/app/app.module.ts

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

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

Ora implementiamo la funzionalità per aggiungere attività in AppComponent. Utilizzeremo una finestra di dialogo Material. Nella finestra di dialogo verrà visualizzato un modulo con due campi: titolo e descrizione. Quando l'utente fa clic sul pulsante "Aggiungi attività", si apre la finestra di dialogo e quando l'utente invia il modulo, l'attività appena creata viene aggiunta all'elenco todo.

Diamo un'occhiata all'implementazione di alto livello di questa funzionalità in 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);
      });
  }
}

Dichiariamo un costruttore in cui inseriamo la classe MatDialog. All'interno del newTask:

  • Apri una nuova finestra di dialogo utilizzando TaskDialogComponent, che definiremo tra poco.
  • Specifica che la finestra di dialogo deve avere una larghezza di 270px.
  • Passa un'attività vuota alla finestra di dialogo come dati. In TaskDialogComponent potremo ottenere un riferimento a questo oggetto dati.
  • Ci abboniamo all'evento di chiusura e aggiungiamo l'attività dall'oggetto result all'array todo.

Per assicurarci che funzioni, dobbiamo prima importare MatDialogModule in AppModule:

src/app/app.module.ts

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

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

Ora creiamo il TaskDialogComponent. Vai alla directory src/app ed esegui:

ng generate component task-dialog

Per implementarne la funzionalità, apri prima src/app/task-dialog/task-dialog.component.html e sostituisci il relativo contenuto con:

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>

Nel modello precedente creiamo un modulo con due campi per title e description. Utilizziamo la direttiva cdkFocusInput per mettere automaticamente il focus sull'input title quando l'utente apre la finestra di dialogo.

Nota come all'interno del modello facciamo riferimento alla proprietà data del componente. Sarà lo stesso data che passiamo al metodo open di dialog in AppComponent. Per aggiornare il titolo e la descrizione dell'attività quando l'utente modifica il contenuto dei campi corrispondenti, utilizziamo il data binding bidirezionale con ngModel.

Quando l'utente fa clic sul pulsante OK, restituiamo automaticamente il risultato { task: data.task }, ovvero l'attività che abbiamo modificato utilizzando i campi del modulo nel modello precedente.

Ora implementiamo il controller del componente:

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);
  }
}

In TaskDialogComponent inseriamo un riferimento alla finestra di dialogo, in modo da poterla chiudere, e inseriamo anche il valore del fornitore associato al token MAT_DIALOG_DATA. Questo è l'oggetto dati che abbiamo passato al metodo open in AppComponent sopra. Dichiariamo anche la proprietà privata backupTask, che è una copia dell'attività che abbiamo superato insieme all'oggetto dati.

Quando l'utente preme il pulsante Annulla, ripristiniamo le proprietà eventualmente modificate di this.data.task e chiudiamo la finestra di dialogo, passando this.data come risultato.

Abbiamo fatto riferimento a due tipi che non abbiamo ancora dichiarato: TaskDialogData e TaskDialogResult. All'interno di src/app/task-dialog/task-dialog.component.ts, aggiungi le seguenti dichiarazioni alla fine del file:

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

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

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

L'ultima cosa da fare prima di avere la funzionalità pronta è importare alcuni moduli in 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 { }

Quando fai clic sul pulsante "Aggiungi attività", dovresti visualizzare la seguente interfaccia utente:

33bcb987fade2a87.png

7. Miglioramento degli stili dell'app

Per rendere l'applicazione più accattivante dal punto di vista visivo, ne miglioreremo il layout modificando leggermente gli stili. Vogliamo posizionare le corsie una accanto all'altra. Vogliamo anche apportare alcune piccole modifiche al pulsante "Aggiungi attività" e all'etichetta dell'elenco vuoto.

Apri src/app/app.component.css e aggiungi gli stili seguenti in fondo:

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;
}

Nello snippet precedente, modifichiamo il layout della barra degli strumenti e della relativa etichetta. Inoltre, ci assicuriamo che i contenuti siano allineati orizzontalmente impostando la larghezza su 1400px e il margine su auto. Successivamente, utilizzando Flexbox, abbiamo posizionato le corsie una accanto all'altra e infine abbiamo apportato alcune modifiche al modo in cui visualizziamo le attività e gli elenchi vuoti.

Una volta ricaricata l'app, dovresti visualizzare la seguente interfaccia utente:

69225f0b1aa5cb50.png

Sebbene abbiamo migliorato notevolmente gli stili della nostra app, abbiamo ancora un problema fastidioso quando spostiamo le attività:

f9aae712027624af.png

Quando iniziamo a trascinare l'attività"Compra latte", vediamo due schede per la stessa attività: quella che stiamo trascinando e quella nella corsia. L'Angular CDK ci fornisce nomi di classi CSS che possiamo utilizzare per risolvere questo problema.

Aggiungi i seguenti override di stile alla fine di src/app/app.component.css:

src/app/app.component.css

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

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

Mentre trasciniamo un elemento, la funzionalità di trascinamento della CDK di Angular lo clona e lo inserisce nella posizione in cui rilasceremo l'originale. Per assicurarci che questo elemento non sia visibile, impostiamo la proprietà di opacità nella classe cdk-drag-placeholder, che il CDK aggiungerà al segnaposto.

Inoltre, quando rilasciamo un elemento, il CDK aggiunge la classe cdk-drag-animating. Per mostrare un'animazione fluida anziché spostare direttamente l'elemento, definiamo una transizione con durata 250ms.

Vogliamo anche apportare alcune piccole modifiche agli stili delle nostre attività. In task.component.css impostiamo la visualizzazione dell'elemento host su block e impostiamo alcuni margini:

src/app/task/task.component.css

:host {
  display: block;
}

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

8. Modificare ed eliminare le attività esistenti

Per modificare e rimuovere le attività esistenti, riutilizzeremo la maggior parte delle funzionalità che abbiamo già implementato. Quando l'utente fa doppio clic su un'attività, si apre TaskDialogComponent e i due campi del modulo vengono compilati con title e description dell'attività.

Al TaskDialogComponent aggiungeremo anche un pulsante di eliminazione. Quando l'utente fa clic, trasmettiamo un'istruzione di eliminazione, che finirà in AppComponent.

L'unica modifica da apportare in TaskDialogComponent riguarda il modello:

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>

Questo pulsante mostra l'icona Elimina materiale. Quando l'utente fa clic, chiudiamo la finestra di dialogo e passiamo il valore letterale dell'oggetto { task: data.task, delete: true } come risultato. Inoltre, nota che rendiamo il pulsante circolare utilizzando mat-fab, impostiamo il colore su primario e lo mostriamo solo quando l'eliminazione dei dati della finestra di dialogo è abilitata.

Il resto dell'implementazione delle funzionalità di modifica ed eliminazione si trova in AppComponent. Sostituisci il metodo editTask con il seguente:

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;
      }
    });
  }
  ...
}

Esaminiamo gli argomenti del metodo editTask:

  • Un elenco di tipo 'done' | 'todo' | 'inProgress',, che è un tipo di unione letterale di stringhe con valori corrispondenti alle proprietà associate alle singole corsie.
  • L'attività corrente che vogliamo modificare.

Nel corpo del metodo, apriamo innanzitutto un'istanza di TaskDialogComponent. Come data passiamo un oggetto letterale, che specifica l'attività che vogliamo modificare e attiva anche il pulsante di modifica nel modulo impostando la proprietà enableDelete su true.

Quando riceviamo il risultato dalla finestra di dialogo, gestiamo due scenari:

  • Quando il flag delete è impostato su true (ovvero quando l'utente ha premuto il pulsante di eliminazione), rimuoviamo l'attività dall'elenco corrispondente.
  • In alternativa, sostituiamo l'attività nell'indice specificato con l'attività ottenuta dal risultato della finestra di dialogo.

9. Creare un nuovo progetto Firebase

Ora creiamo un nuovo progetto Firebase.

10. Aggiunta di Firebase al progetto

In questa sezione integreremo il nostro progetto con Firebase. Il team di Firebase offre il pacchetto @angular/fire, che fornisce l'integrazione tra le due tecnologie. Per aggiungere il supporto di Firebase alla tua app, apri la directory principale del tuo workspace ed esegui:

ng add @angular/fire

Questo comando installa il pacchetto @angular/fire e ti pone alcune domande. Nel terminale dovresti vedere qualcosa di simile a questo:

9ba88c0d52d18d0.png

Nel frattempo, l'installazione apre una finestra del browser in modo che tu possa eseguire l'autenticazione con il tuo account Firebase. Infine, ti chiede di scegliere un progetto Firebase e crea alcuni file sul disco.

Successivamente, dobbiamo creare un database Firestore. Nella sezione "Cloud Firestore", fai clic su "Crea database".

1e4a08b5a2462956.png

Dopodiché, crea un database in modalità test:

ac1181b2c32049f9.png

Infine, seleziona una regione:

34bb94cc542a0597.png

L'unica cosa che resta da fare è aggiungere la configurazione Firebase al tuo ambiente. Puoi trovare la configurazione del progetto nella console Firebase.

  • Fai clic sull'icona a forma di ingranaggio accanto a Panoramica del progetto.
  • Scegli Impostazioni progetto.

c8253a20031de8a9.png

Nella sezione "Le tue app", seleziona un'app web:

428a1abcd0f90b23.png

Successivamente, registra la tua applicazione e assicurati di attivare "Firebase Hosting":

586e44cb27dd8f39.png

Dopo aver fatto clic su "Registra app", puoi copiare la configurazione in src/environments/environment.ts:

e30f142d79cecf8f.png

Alla fine, il file di configurazione dovrebbe avere il seguente aspetto:

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. Spostamento dei dati in Firestore

Ora che abbiamo configurato l'SDK Firebase, utilizziamo @angular/fire per spostare i dati in Firestore. Innanzitutto, importiamo i moduli di cui abbiamo bisogno in 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 {}

Poiché utilizzeremo Firestore, dobbiamo inserire AngularFirestore nel costruttore di AppComponent:

src/app/app.component.ts

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

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

Successivamente, aggiorniamo il modo in cui inizializziamo gli array delle corsie:

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[]>;
  ...
}

Qui utilizziamo AngularFirestore per ottenere i contenuti della raccolta direttamente dal database. Tieni presente che valueChanges restituisce un observable anziché un array e che specifichiamo che il campo ID per i documenti in questa raccolta deve essere chiamato id in modo che corrisponda al nome che utilizziamo nell'interfaccia Task. L'observable restituito da valueChanges emette una raccolta di attività ogni volta che cambia.

Poiché lavoriamo con gli osservabili anziché con gli array, dobbiamo aggiornare il modo in cui aggiungiamo, rimuoviamo e modifichiamo le attività, nonché la funzionalità per spostare le attività tra le corsie. Anziché modificare gli array in memoria, utilizzeremo l'SDK Firebase per aggiornare i dati nel database.

Per prima cosa, vediamo come apparirebbe il riordino. Sostituisci il metodo drop in src/app/app.component.ts con:

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
  );
}

Nello snippet riportato sopra, il nuovo codice è evidenziato. Per spostare un'attività dalla corsia attuale a quella di destinazione, la rimuoveremo dalla prima raccolta e la aggiungeremo alla seconda. Poiché eseguiamo due operazioni che vogliamo che sembrino una sola (ovvero rendiamo l'operazione atomica), le eseguiamo in una transazione Firestore.

Dopodiché, aggiorniamo il metodo editTask per utilizzare Firestore. All'interno del gestore della finestra di dialogo di chiusura, dobbiamo modificare le seguenti righe di codice:

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);
  }
});
...

Accediamo al documento di destinazione corrispondente all'attività che manipoliamo utilizzando l'SDK Firestore ed eliminiamo o aggiorniamo il documento.

Infine, dobbiamo aggiornare il metodo per creare nuove attività. Sostituisci this.todo.push('task') con: this.store.collection('todo').add(result.task).

Tieni presente che ora le nostre raccolte non sono array, ma osservabili. Per poterli visualizzare, dobbiamo aggiornare il modello di AppComponent. Basta sostituire ogni accesso alle proprietà todo, inProgress e done con todo | async, inProgress | async e done | async rispettivamente.

La pipe asincrona esegue automaticamente la sottoscrizione agli osservabili associati alle raccolte. Quando gli osservabili emettono un nuovo valore, Angular esegue automaticamente il rilevamento delle modifiche ed elabora l'array emesso.

Ad esempio, esaminiamo le modifiche che dobbiamo apportare nella corsia 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>

Quando passiamo i dati alla direttiva cdkDropList, applichiamo la pipe asincrona. È lo stesso all'interno della direttiva *ngIf, ma tieni presente che utilizziamo anche l'incatenamento opzionale (noto anche come operatore di navigazione sicura in Angular) quando accediamo alla proprietà length per assicurarci di non ricevere un errore di runtime se todo | async non è null o undefined.

Ora, quando crei una nuova attività nell'interfaccia utente e apri Firestore, dovresti vedere qualcosa di simile a questo:

dd7ee20c0a10ebe2.png

12. Miglioramento degli aggiornamenti ottimistici

Nell'applicazione stiamo attualmente eseguendo aggiornamenti ottimistici. Abbiamo la nostra fonte di verità in Firestore, ma allo stesso tempo abbiamo copie locali delle attività; quando viene emesso uno degli osservabili associati alle raccolte, otteniamo un array di attività. Quando un'azione utente modifica lo stato, aggiorniamo prima i valori locali e poi propaghiamo la modifica a Firestore.

Quando spostiamo un'attività da una corsia all'altra, richiamiamo transferArrayItem,, che opera sulle istanze locali degli array che rappresentano le attività in ogni corsia. L'SDK Firebase considera questi array come immutabili, il che significa che la prossima volta che Angular esegue il rilevamento delle modifiche, otterremo nuove istanze, che eseguiranno il rendering dello stato precedente prima di trasferire l'attività.

Allo stesso tempo, attiviamo un aggiornamento di Firestore e l'SDK Firebase attiva un aggiornamento con i valori corretti, quindi in pochi millisecondi l'interfaccia utente raggiungerà il suo stato corretto. In questo modo, l'attività appena trasferita passa dal primo elenco a quello successivo. Puoi vedere bene questo aspetto nella GIF di seguito:

70b946eebfa6f316.gif

Il modo corretto per risolvere questo problema varia da applicazione ad applicazione, ma in tutti i casi dobbiamo assicurarci di mantenere uno stato coerente fino all'aggiornamento dei dati.

Possiamo sfruttare BehaviorSubject, che esegue il wrapping dell'observer originale che riceviamo da valueChanges. Dietro le quinte, BehaviorSubject mantiene un array modificabile che conserva l'aggiornamento da transferArrayItem.

Per implementare una correzione, è sufficiente aggiornare 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[]>;
...
}

Tutto ciò che facciamo nello snippet precedente è creare un BehaviorSubject, che emette un valore ogni volta che cambia l'observable associato alla raccolta.

Tutto funziona come previsto, perché BehaviorSubject riutilizza l'array nelle invocazioni di rilevamento delle modifiche e si aggiorna solo quando riceviamo un nuovo valore da Firestore.

13. Implementazione dell'applicazione

Per eseguire il deployment della nostra app, dobbiamo solo eseguire:

ng deploy

Questo comando:

  1. Crea la tua app con la configurazione di produzione, applicando le ottimizzazioni in fase di compilazione.
  2. Esegui il deployment della tua app in Firebase Hosting.
  3. Restituisci un URL in modo da poter visualizzare l'anteprima del risultato.

14. Complimenti

Congratulazioni, hai creato correttamente una bacheca Kanban con Angular e Firebase.

Hai creato un'interfaccia utente con tre colonne che rappresentano lo stato di diverse attività. Utilizzando Angular CDK, hai implementato il trascinamento delle attività tra le colonne. Poi, utilizzando Angular Material, hai creato un modulo per creare nuove attività e modificare quelle esistenti. Poi hai imparato a utilizzare @angular/fire e hai spostato tutto lo stato dell'applicazione su Firestore. Infine, hai eseguito il deployment dell'applicazione in Firebase Hosting.

Passaggi successivi

Ricorda che abbiamo eseguito il deployment dell'applicazione utilizzando configurazioni di test. Prima di eseguire il deployment della tua app in produzione, assicurati di configurare le autorizzazioni corrette. Puoi scoprire come farlo qui.

Al momento, non conserviamo l'ordine delle singole attività in una determinata corsia. Per implementare questa funzionalità, puoi utilizzare un campo di ordinamento nel documento delle attività e ordinare in base a questo campo.

Inoltre, abbiamo creato la bacheca Kanban per un solo utente, il che significa che abbiamo una sola bacheca Kanban per chiunque apra l'app. Per implementare bacheche separate per diversi utenti dell'app, devi modificare la struttura del database. Scopri di più sulle best practice di Firestore qui.