Tworzenie aplikacji internetowej za pomocą Angulara i Firebase

1. Wprowadzenie

Ostatnia aktualizacja: 11.09.2020

Co utworzysz

W tym ćwiczeniu z programowania utworzymy internetową tablicę Kanban przy użyciu Angulara i Firebase. Nasza aplikacja będzie zawierać 3 kategorie zadań: backlog, w trakcie i ukończone. Będziemy mogli tworzyć i usuwać zadania oraz przenosić je z jednej kategorii do drugiej za pomocą przeciągania i upuszczania.

Interfejs użytkownika opracujemy za pomocą Angulara, a Firestore będzie naszym trwałym magazynem danych. Na koniec wdrożymy aplikację w Hostingu Firebase za pomocą interfejsu wiersza poleceń Angular.

b23bd3732d0206b.png

Czego się nauczysz

  • Jak korzystać z Angular Material i CDK.
  • Jak dodać integrację z Firebase do aplikacji Angular.
  • Jak przechowywać stałe dane w Firestore.
  • Jak wdrożyć aplikację w Hostingu Firebase za pomocą wiersza poleceń Angulara za pomocą jednego polecenia.

Czego potrzebujesz

W tym laboratorium zakładamy, że masz konto Google i znasz podstawy Angulara oraz interfejsu wiersza poleceń Angular CLI.

Zaczynajmy!

2. Tworzenie nowego projektu

Najpierw utwórzmy nowy obszar roboczy Angular:

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

Może to potrwać kilka minut. Interfejs wiersza poleceń Angulara tworzy strukturę projektu i instaluje wszystkie zależności. Po zakończeniu procesu instalacji przejdź do katalogu kanban-fire i uruchom serwer programistyczny Angular CLI:

ng serve

Otwórz http://localhost:4200. Powinny pojawić się dane wyjściowe podobne do tych:

5ede7bc5b1109bf3.png

W edytorze otwórz plik src/app/app.component.html i usuń całą jego zawartość. Gdy wrócisz na stronę http://localhost:4200, powinna wyświetlić się pusta strona.

3. Dodawanie Material i CDK

Angular zawiera implementację komponentów interfejsu zgodnych z Material Design w ramach pakietu @angular/material. Jedną z zależności @angular/material jest pakiet Component Development Kit, czyli CDK. CDK udostępnia elementy podstawowe, takie jak narzędzia ułatwień dostępu, przeciąganie i upuszczanie oraz nakładka. CDK rozpowszechniamy w pakiecie @angular/cdk.

Aby dodać materiały do uruchomienia aplikacji:

ng add @angular/material

To polecenie poprosi Cię o wybranie motywu, określenie, czy chcesz używać globalnych stylów typografii Material Design, oraz skonfigurowanie animacji przeglądarki dla Angular Material. Wybierz „Indigo/Pink”, aby uzyskać taki sam wynik jak w tym samouczku, a na 2 ostatnie pytania odpowiedz „Yes”.

Polecenie ng add instaluje @angular/material i jego zależności oraz importuje BrowserAnimationsModuleAppModule. W następnym kroku możemy zacząć korzystać z komponentów oferowanych przez ten moduł.

Najpierw dodajmy pasek narzędzi i ikonę do elementu AppComponent. Otwórz app.component.html i dodaj ten kod:

src/app/app.component.html

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

Dodajemy tu pasek narzędzi, używając koloru podstawowego motywu Material Design, a w nim umieszczamy ikonę local_fire_depeartment obok etykiety „Kanban Fire”. Jeśli teraz otworzysz konsolę, zobaczysz, że Angular zgłasza kilka błędów. Aby je naprawić, dodaj do pliku AppModule te instrukcje importu:

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

Ponieważ używamy paska narzędzi i ikony Angular Material, musimy zaimportować odpowiednie moduły w AppModule.

Na ekranie powinny być teraz widoczne te informacje:

a39cf8f8428a03bc.png

To całkiem niezły wynik, biorąc pod uwagę, że wystarczyły 4 wiersze kodu HTML i 2 importy.

4. Wizualizacja zadań

W kolejnym kroku utwórzmy komponent, którego będziemy używać do wizualizacji zadań na tablicy Kanban.

Przejdź do katalogu src/app i uruchom to polecenie interfejsu wiersza poleceń:

ng generate component task

To polecenie generuje TaskComponent i dodaje jego deklarację do AppModule. W katalogu task utwórz plik o nazwie task.ts. Użyjemy tego pliku do zdefiniowania interfejsu zadań na tablicy Kanban. Każde zadanie będzie miało opcjonalne pola id, titledescription, które będą ciągami znaków:

src/app/task/task.ts

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

Teraz zaktualizujmy task.component.ts. Chcemy, aby funkcja TaskComponent przyjmowała jako dane wejściowe obiekt typu Task i mogła generować dane wyjściowe „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>();
}

Edytuj szablon TaskComponent. Otwórz plik task.component.html i zastąp jego zawartość tym kodem 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>

Zwróć uwagę, że w konsoli pojawiają się teraz błędy:

'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

W szablonie powyżej używamy komponentu mat-card@angular/material, ale nie zaimportowaliśmy w aplikacji odpowiedniego modułu. Aby naprawić błąd, musimy zaimportować MatCardModuleAppModule:

src/app/app.module.ts

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

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

Następnie utworzymy kilka zadań w AppComponent i wizualizujemy je za pomocą TaskComponent.

W AppComponent zdefiniuj tablicę o nazwie todo i dodaj do niej 2 zadania:

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

Teraz na końcu sekcji app.component.html dodaj tę dyrektywę *ngFor:

src/app/app.component.html

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

Po otwarciu przeglądarki powinna pojawić się ta treść:

d96fccd13c63ceb1.png

5. Wdrażanie przeciągania i upuszczania zadań

Teraz czas na zabawę! Utwórzmy 3 ścieżki dla 3 różnych stanów, w których mogą znajdować się zadania, i za pomocą Angular CDK zaimplementujmy funkcję przeciągania i upuszczania.

app.component.html usuń komponent app-task z dyrektywą *ngFor u góry i zastąp go tym kodem:

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>

Dużo się tu dzieje. Przyjrzyjmy się poszczególnym częściom tego fragmentu kodu krok po kroku. Oto struktura szablonu najwyższego poziomu:

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>

Tworzymy tutaj element div, który obejmuje wszystkie 3 ścieżki z nazwą klasy „container-wrapper”. Każda ścieżka ma nazwę klasy „container” i tytuł w tagu h2.

Przyjrzyjmy się teraz strukturze pierwszej ścieżki:

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

Najpierw definiujemy pulę jako mat-card, która korzysta z dyrektywy cdkDropList. Używamy komponentu mat-card ze względu na style, które zapewnia. Element cdkDropList pozwoli nam później umieszczać w nim zadania. Ustawiamy też te 2 dane wejściowe:

  • cdkDropListData – pole wprowadzania listy rozwijanej, które umożliwia określenie tablicy danych.
  • cdkDropListConnectedTo – odwołania do innych urządzeń cdkDropList, z którymi połączone jest bieżące urządzenie cdkDropList. Ustawiając to pole wejściowe, określamy, do których innych list możemy przenosić elementy.

Chcemy też obsługiwać zdarzenie upuszczania za pomocą danych wyjściowych cdkDropListDropped. Gdy cdkDropList wyemituje ten wynik, wywołamy metodę drop zadeklarowaną w AppComponent i przekażemy bieżące zdarzenie jako argument.

Zauważ, że określamy też id, który będzie identyfikatorem tego kontenera, oraz nazwę class, abyśmy mogli go ostylować. Przyjrzyjmy się teraz elementom podrzędnym treści elementu mat-card. Są to:

  • Akapit, którego używamy do wyświetlania tekstu „Pusta lista”, gdy na liście todo nie ma żadnych elementów.
  • Komponent app-task. Zwróć uwagę, że w tym miejscu obsługujemy zadeklarowane wcześniej dane wyjściowe edit, wywołując metodę editTask z nazwą listy i obiektem $event. Pomoże nam to zastąpić edytowane zadanie na odpowiedniej liście. Następnie iterujemy po liście todo, tak jak powyżej, i przekazujemy dane wejściowe task. Tym razem dodajemy jednak też dyrektywę cdkDrag. Umożliwia przeciąganie poszczególnych zadań.

Aby to wszystko działało, musimy zaktualizować plik app.module.ts i dodać import do pliku DragDropModule:

src/app/app.module.ts

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

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

Musimy też zadeklarować tablice inProgressdone oraz metody editTaskdrop:

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

Zwróć uwagę, że w metodzie drop najpierw sprawdzamy, czy upuszczamy element na tę samą listę, z której pochodzi zadanie. W takim przypadku od razu wracamy. W przeciwnym razie przeniesiemy bieżące zadanie do docelowej ścieżki.

Wynik powinien wyglądać tak:

460f86bcd10454cf.png

W tym momencie powinna być już możliwość przenoszenia elementów między listami.

6. Tworzenie nowych zadań

Teraz zaimplementujmy funkcję tworzenia nowych zadań. W tym celu zaktualizujmy szablon 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>

Tworzymy element najwyższego poziomu div wokół elementu container-wrapper i dodajemy przycisk z ikoną materiału „add” obok etykiety „Dodaj zadanie”. Potrzebujemy dodatkowego elementu opakowującego, aby umieścić przycisk nad listą ścieżek, które później umieścimy obok siebie za pomocą flexboxa. Ten przycisk korzysta z komponentu przycisku Material, więc musimy zaimportować odpowiedni moduł w pliku AppModule:

src/app/app.module.ts

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

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

Teraz zaimplementujmy funkcję dodawania zadań w AppComponent. Użyjemy okna Material Design. W oknie dialogowym pojawi się formularz z 2 polami: tytuł i opis. Gdy użytkownik kliknie przycisk „Dodaj zadanie”, otworzymy okno, a gdy prześle formularz, dodamy nowo utworzone zadanie do listy todo.

Przyjrzyjmy się ogólnemu wdrożeniu tej funkcji w 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);
      });
  }
}

Deklarujemy konstruktor, w którym wstrzykujemy klasę MatDialog. W ramach newTask:

  • Otwórz nowe okno za pomocą elementu TaskDialogComponent, który zdefiniujemy za chwilę.
  • Określ, że okno ma mieć szerokość 270px.
  • Przekaż do okna puste zadanie jako dane. W TaskDialogComponent będziemy mogli uzyskać odwołanie do tego obiektu danych.
  • Subskrybujemy zdarzenie zamknięcia i dodajemy zadanie z obiektu result do tablicy todo.

Aby to zadziałało, musimy najpierw zaimportować MatDialogModuleAppModule:

src/app/app.module.ts

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

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

Teraz utwórzmy TaskDialogComponent. Przejdź do katalogu src/app i uruchom:

ng generate component task-dialog

Aby wdrożyć jego funkcjonalność, najpierw otwórz: src/app/task-dialog/task-dialog.component.html i zastąp jego zawartość tym kodem:

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>

W szablonie powyżej tworzymy formularz z 2 polami dla titledescription. Używamy dyrektywy cdkFocusInput, aby automatycznie ustawiać fokus na polu title, gdy użytkownik otworzy okno.

Zwróć uwagę, że w szablonie odwołujemy się do właściwości data komponentu. Będzie to ten sam data, który przekazujemy do metody open obiektu dialogAppComponent. Aby zaktualizować tytuł i opis zadania, gdy użytkownik zmieni zawartość odpowiednich pól, używamy dwukierunkowego wiązania danych z ngModel.

Gdy użytkownik kliknie przycisk OK, automatycznie zwrócimy wynik { task: data.task }, czyli zadanie, które zostało zmodyfikowane za pomocą pól formularza w szablonie powyżej.

Teraz zaimplementujmy kontroler komponentu:

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

TaskDialogComponent wstawiamy odwołanie do okna, aby można było je zamknąć, a także wstawiamy wartość dostawcy powiązanego z tokenem MAT_DIALOG_DATA. Jest to obiekt danych, który przekazaliśmy do metody open w AppComponent powyżej. Deklarujemy też właściwość prywatną backupTask, która jest kopią zadania przekazanego wraz z obiektem danych.

Gdy użytkownik naciśnie przycisk anulowania, przywracamy ewentualnie zmienione właściwości elementu this.data.task i zamykamy okno, przekazując jako wynik wartość this.data.

Odwołaliśmy się do 2 typów, ale nie zadeklarowaliśmy ich jeszcze – TaskDialogDataTaskDialogResult. W pliku src/app/task-dialog/task-dialog.component.ts dodaj na jego końcu te deklaracje:

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

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

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

Ostatnią rzeczą, którą musimy zrobić, zanim funkcja będzie gotowa, jest zaimportowanie kilku modułów w 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 { }

Gdy teraz klikniesz przycisk „Dodaj zadanie”, powinien pojawić się ten interfejs:

33bcb987fade2a87.png

7. Ulepszanie stylów aplikacji

Aby aplikacja była bardziej atrakcyjna wizualnie, ulepszymy jej układ, wprowadzając niewielkie zmiany w stylach. Chcemy umieścić ścieżki obok siebie. Chcemy też wprowadzić drobne zmiany w przycisku „Dodaj zadanie” i etykiecie pustej listy.

Otwórz src/app/app.component.css i dodaj na końcu te style:

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

W powyższym fragmencie kodu dostosowujemy układ paska narzędzi i jego etykietę. Dbamy też o to, aby treść była wyrównana w poziomie, ustawiając jej szerokość na 1400px i margines na auto. Następnie za pomocą flexboxa umieszczamy ścieżki obok siebie i wprowadzamy zmiany w sposobie wizualizacji zadań i pustych list.

Po ponownym załadowaniu aplikacji powinien pojawić się ten interfejs:

69225f0b1aa5cb50.png

Chociaż znacznie ulepszyliśmy style naszej aplikacji, nadal występuje irytujący problem podczas przenoszenia zadań:

f9aae712027624af.png

Gdy zaczniemy przeciągać zadanie „Kupić mleko”, zobaczymy 2 karty tego samego zadania – tę, którą przeciągamy, i tę na ścieżce. Angular CDK udostępnia nazwy klas CSS, których możemy użyć, aby rozwiązać ten problem.

Dodaj te zastąpienia stylów na końcu pliku src/app/app.component.css:

src/app/app.component.css

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

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

Podczas przeciągania elementu funkcja przeciągania i upuszczania Angular CDK klonuje go i wstawia w miejsce, w którym upuścimy oryginał. Aby ten element nie był widoczny, ustawiamy właściwość opacity w klasie cdk-drag-placeholder, którą CDK doda do elementu zastępczego.

Dodatkowo, gdy upuścimy element, CDK doda klasę cdk-drag-animating. Aby wyświetlić płynną animację zamiast bezpośrednio przyciągać element, definiujemy przejście o czasie trwania 250ms.

Chcemy też wprowadzić drobne zmiany w stylach naszych zadań. W task.component.css ustawmy wyświetlanie elementu hosta na block i ustawmy marginesy:

src/app/task/task.component.css

:host {
  display: block;
}

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

8. Edytowanie i usuwanie dotychczasowych zadań

Aby edytować i usuwać istniejące zadania, wykorzystamy większość funkcji, które już wdrożyliśmy. Gdy użytkownik kliknie dwukrotnie zadanie, otworzymy TaskDialogComponent i wypełnimy 2 pola w formularzu titledescription zadania.

Do TaskDialogComponent dodamy też przycisk usuwania. Gdy użytkownik kliknie ten przycisk, przekażemy instrukcję usunięcia, która trafi do AppComponent.

Jedyna zmiana, jaką musimy wprowadzić w TaskDialogComponent, dotyczy jego szablonu:

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>

Ten przycisk zawiera ikonę usuwania materiałów. Gdy użytkownik kliknie ten przycisk, zamkniemy okno i przekażemy literał obiektu { task: data.task, delete: true } jako wynik. Zwróć też uwagę, że przycisk jest okrągły dzięki mat-fab, ma kolor podstawowy i wyświetla się tylko wtedy, gdy w danych okna jest włączone usuwanie.

Pozostała część implementacji funkcji edytowania i usuwania znajduje się w pliku AppComponent. Zastąp metodę editTask tym kodem:

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

Przyjrzyjmy się argumentom metody editTask:

  • Lista typu 'done' | 'todo' | 'inProgress',, czyli typ unii literałów ciągu znaków z wartościami odpowiadającymi właściwościom powiązanym z poszczególnymi ścieżkami.
  • Bieżące zadanie, które chcemy edytować.

W treści metody najpierw otwieramy instancję TaskDialogComponent. Jako data przekazujemy literał obiektu, który określa zadanie, które chcemy edytować, a także włącza przycisk edycji w formularzu, ustawiając właściwość enableDelete na true.

Gdy otrzymamy wynik z okna, rozpatrujemy 2 scenariusze:

  • Gdy flaga delete ma wartość true (czyli gdy użytkownik naciśnie przycisk usuwania), usuwamy zadanie z odpowiedniej listy.
  • Możemy też po prostu zastąpić zadanie na danym indeksie zadaniem uzyskanym z wyniku okna.

9. Tworzenie nowego projektu Firebase

Teraz utwórzmy nowy projekt Firebase.

10. Dodawanie Firebase do projektu

W tej sekcji zintegrujemy nasz projekt z Firebase. Zespół Firebase oferuje pakiet @angular/fire, który zapewnia integrację tych dwóch technologii. Aby dodać obsługę Firebase do aplikacji, otwórz katalog główny obszaru roboczego i uruchom:

ng add @angular/fire

To polecenie instaluje pakiet @angular/fire i zadaje kilka pytań. W terminalu powinien pojawić się tekst podobny do tego:

9ba88c0d52d18d0.png

W tym czasie instalacja otworzy okno przeglądarki, w którym możesz uwierzytelnić się na koncie Firebase. Na koniec poprosi Cię o wybranie projektu Firebase i utworzy na dysku kilka plików.

Następnie musimy utworzyć bazę danych Firestore. W sekcji „Cloud Firestore” kliknij „Utwórz bazę danych”.

1e4a08b5a2462956.png

Następnie utwórz bazę danych w trybie testowym:

ac1181b2c32049f9.png

Na koniec wybierz region:

34bb94cc542a0597.png

Pozostało tylko dodanie konfiguracji Firebase do środowiska. Konfigurację projektu znajdziesz w konsoli Firebase.

  • Obok opcji Przegląd projektu kliknij ikonę koła zębatego.
  • Wybierz Ustawienia projektu.

c8253a20031de8a9.png

W sekcji „Twoje aplikacje” wybierz „Aplikacja internetowa”:

428a1abcd0f90b23.png

Następnie zarejestruj aplikację i włącz „Hosting Firebase”:

586e44cb27dd8f39.png

Po kliknięciu „Zarejestruj aplikację” możesz skopiować konfigurację do src/environments/environment.ts:

e30f142d79cecf8f.png

Na koniec plik konfiguracyjny powinien wyglądać tak:

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. Przenoszenie danych do Firestore

Po skonfigurowaniu pakietu SDK Firebase użyjemy @angular/fire, aby przenieść dane do Firestore. Najpierw zaimportujmy potrzebne moduły w 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 {}

Ponieważ będziemy korzystać z Firestore, musimy wstrzyknąć AngularFirestore do konstruktora AppComponent:

src/app/app.component.ts

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

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

Następnie aktualizujemy sposób inicjowania tablic ścieżek:

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

Używamy tutaj znaku AngularFirestore, aby pobrać zawartość kolekcji bezpośrednio z bazy danych. Zwróć uwagę, że valueChanges zwraca obiekt observable zamiast tablicy. Zwróć też uwagę, że określamy, że pole identyfikatora dokumentów w tej kolekcji powinno mieć nazwę id, aby pasowało do nazwy używanej w interfejsie Task. Obserwowalny obiekt zwracany przez funkcję valueChanges emituje kolekcję zadań za każdym razem, gdy się zmienia.

Ponieważ pracujemy z obiektami obserwowanymi zamiast z tablicami, musimy zaktualizować sposób dodawania, usuwania i edytowania zadań oraz funkcję przenoszenia zadań między ścieżkami. Zamiast zmieniać tablice w pamięci, będziemy używać pakietu SDK Firebase do aktualizowania danych w bazie danych.

Najpierw zobaczmy, jak wyglądałoby ponowne zamówienie. Zastąp metodę drop w pliku src/app/app.component.ts tym kodem:

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

W powyższym fragmencie kodu nowy kod jest wyróżniony. Aby przenieść zadanie z bieżącej ścieżki do docelowej, usuniemy je z pierwszej kolekcji i dodamy do drugiej. Ponieważ wykonujemy 2 operacje, które mają wyglądać jak jedna (czyli sprawić, aby operacja była niepodzielna), uruchamiamy je w transakcji Firestore.

Następnie zaktualizujmy metodę editTask, aby korzystać z Firestore. W obsłudze zamykania okna musimy zmienić te wiersze kodu:

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

Uzyskujemy dostęp do dokumentu docelowego odpowiadającego zadaniu, które modyfikujemy, za pomocą pakietu Firestore SDK i usuwamy go lub aktualizujemy.

Na koniec musimy zaktualizować metodę tworzenia nowych zadań. Zastąp this.todo.push('task') tym tekstem: this.store.collection('todo').add(result.task).

Zwróć uwagę, że nasze kolekcje nie są już tablicami, ale obiektami obserwowanymi. Aby je wizualizować, musimy zaktualizować szablon AppComponent. Wystarczy zastąpić każdy dostęp do właściwości todo, inProgressdone odpowiednio właściwościami todo | async, inProgress | asyncdone | async.

Potok asynchroniczny automatycznie subskrybuje obiekty obserwowane powiązane z kolekcjami. Gdy obiekty Observable wyemitują nową wartość, Angular automatycznie uruchomi wykrywanie zmian i przetworzy wyemitowaną tablicę.

Przyjrzyjmy się na przykład zmianom, które musimy wprowadzić w 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>

Gdy przekazujemy dane do dyrektywy cdkDropList, stosujemy potok asynchroniczny. W dyrektywie *ngIf jest tak samo, ale pamiętaj, że podczas uzyskiwania dostępu do właściwości length używamy też opcjonalnego łańcucha (w Angularze znanego też jako operator bezpiecznej nawigacji), aby uniknąć błędu w czasie działania, jeśli todo | async nie jest null ani undefined.

Gdy utworzysz nowe zadanie w interfejsie i otworzysz Firestore, zobaczysz coś takiego:

dd7ee20c0a10ebe2.png

12. Ulepszanie optymistycznych aktualizacji

W aplikacji obecnie przeprowadzamy optymistyczne aktualizacje. Źródłem danych jest Firestore, ale jednocześnie mamy lokalne kopie zadań. Gdy którykolwiek z obiektów obserwowanych powiązanych z kolekcjami wyemituje wartość, otrzymujemy tablicę zadań. Gdy działanie użytkownika zmienia stan, najpierw aktualizujemy wartości lokalne, a potem propagujemy zmianę do Firestore.

Gdy przenosimy zadanie z jednej ścieżki do drugiej, wywołujemy funkcję transferArrayItem,, która działa na lokalnych instancjach tablic reprezentujących zadania na każdej ścieżce. Pakiet SDK Firebase traktuje te tablice jako niezmienne, co oznacza, że przy następnym uruchomieniu przez Angular wykrywania zmian otrzymamy ich nowe instancje, które przed przeniesieniem zadania wyrenderują poprzedni stan.

Jednocześnie uruchamiamy aktualizację Firestore, a pakiet SDK Firebase uruchamia aktualizację z prawidłowymi wartościami, więc po kilku milisekundach interfejs użytkownika osiągnie prawidłowy stan. Spowoduje to przeniesienie zadania z pierwszej listy na następną. Dobrze widać to na poniższym GIF-ie:

70b946eebfa6f316.gif

Prawidłowy sposób rozwiązania tego problemu różni się w zależności od aplikacji, ale we wszystkich przypadkach musimy zadbać o to, aby do momentu aktualizacji danych zachować spójny stan.

Możemy skorzystać z funkcji BehaviorSubject, która opakowuje pierwotny obiekt obserwatora otrzymany z funkcji valueChanges. W tle BehaviorSubject przechowuje modyfikowalną tablicę, która utrwala aktualizację z transferArrayItem.

Aby wprowadzić poprawkę, wystarczy zaktualizować 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[]>;
...
}

W powyższym fragmencie kodu tworzymy tylko BehaviorSubject, który emituje wartość za każdym razem, gdy zmieni się obiekt obserwowany powiązany z kolekcją.

Wszystko działa zgodnie z oczekiwaniami, ponieważ BehaviorSubject ponownie wykorzystuje tablicę podczas wywołań wykrywania zmian i aktualizuje ją tylko wtedy, gdy otrzymujemy nową wartość z Firestore.

13. Wdrożenie aplikacji

Aby wdrożyć aplikację, wystarczy uruchomić to polecenie:

ng deploy

To polecenie:

  1. Skompiluj aplikację z konfiguracją produkcyjną, stosując optymalizacje czasu kompilacji.
  2. Wdróż aplikację w Hostingu Firebase.
  3. Wygeneruj adres URL, aby wyświetlić podgląd wyniku.

14. Gratulacje

Gratulacje, udało Ci się utworzyć tablicę Kanban przy użyciu Angulara i Firebase.

Masz interfejs użytkownika z 3 kolumnami, które przedstawiają stan różnych zadań. Za pomocą Angular CDK zaimplementowano przeciąganie zadań między kolumnami. Następnie za pomocą Angular Material utworzono formularz do tworzenia nowych zadań i edytowania istniejących. Następnie dowiedzieliśmy się, jak używać @angular/fire i przenieśliśmy cały stan aplikacji do Firestore. Na koniec wdrożysz aplikację w Hostingu Firebase.

Co dalej?

Pamiętaj, że aplikację wdrożyliśmy przy użyciu konfiguracji testowych. Zanim wdrożysz aplikację w środowisku produkcyjnym, skonfiguruj odpowiednie uprawnienia. Instrukcje znajdziesz tutaj.

Obecnie nie zachowujemy kolejności poszczególnych zadań w konkretnej ścieżce. Aby to zrobić, możesz użyć pola kolejności w dokumencie zadania i sortować na jego podstawie.

Tablicę Kanban stworzyliśmy z myślą o jednym użytkowniku, co oznacza, że każdy, kto otworzy aplikację, będzie miał dostęp do tej samej tablicy. Aby wdrożyć osobne tablice dla różnych użytkowników aplikacji, musisz zmienić strukturę bazy danych. Więcej informacji o sprawdzonych metodach korzystania z Firestore znajdziesz tutaj.