Tworzenie aplikacji internetowej za pomocą Angular i Firebase

Zadbaj o dobrą organizację dzięki kolekcji Zapisuj i kategoryzuj treści zgodnie ze swoimi preferencjami.

1. Wstęp

Ostatnia aktualizacja: 11 września 2020 r.

Co stworzysz

W tym ćwiczeniu stworzymy tablicę internetową banban z Angular i Firebase. Ostateczna wersja aplikacji będzie zawierać 3 kategorie zadań: zaległości, trwające i ukończone. Będzie można tworzyć, usuwać zadania i przenosić je z jednej kategorii do drugiej.

Będziemy rozwijać interfejs użytkownika za pomocą Angular i korzystać z Firestore jako naszego stałego sklepu. Na koniec ćwiczeń z programowania wdrożymy aplikację w Hostingu Firebase przy użyciu interfejsu wiersza poleceń Angular.

b23bd3732d0206b.png

Czego się nauczysz:

  • Jak używać materiałów Angular i CDK.
  • Jak dodać integrację z Firebase do aplikacji Angular.
  • Jak zachować trwałe dane w Firestore.
  • Jak wdrożyć aplikację w Hostingu Firebase przy użyciu interfejsu wiersza poleceń Angular za pomocą jednego polecenia.

Czego potrzebujesz

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

Zaczynajmy!

2. Tworzenie nowego projektu

Najpierw utwórz 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ń Angular tworzy strukturę projektu i instaluje wszystkie zależności. Po zakończeniu procesu instalacji przejdź do katalogu kanban-fire i uruchom serwer programowania aplikacji Angular CLI&#39.

ng serve

Otwórz stronę http://localhost:4200. Dane wyjściowe powinny przypominać te:

5ede7bc5b1109bf3.png

W edytorze otwórz src/app/app.component.html i usuń całą jego zawartość. Po powrocie do adresu http://localhost:4200 powinna wyświetlić się pusta strona.

3. Dodawanie materiału i CDK

Angular jest częścią komponentów interfejsu użytkownika zgodnych ze stylem Material Design, które są częścią pakietu @angular/material. Jedna z zależności narzędzia @angular/material to Component Development Kit lub CDK. CDK udostępnia elementy podstawowe, np. narzędzia a11y, przeciąganie i upuszczanie oraz nakładki. Rozsyłamy CDK w pakiecie @angular/cdk.

Aby dodać materiał do uruchomienia aplikacji:

ng add @angular/material

To polecenie pozwala wybrać motyw, jeśli chcesz używać globalnych stylów typografii materiału i chcesz skonfigurować animacje przeglądarki dla materiału Angular. Wybierz „&diquo;Indigo/Pink&quot”, aby uzyskać taki sam wynik jak w tym ćwiczeniu z programowania, i odpowiedziaj „Tak&tak”, na ostatnie dwa pytania.

Polecenie ng add zainstaluje @angular/material, jego zależności i zaimportuje BrowserAnimationsModule w AppModule. W następnym kroku możemy zacząć używać komponentów dostępnych w tym module.

Najpierw dodaj pasek narzędzi i ikonę do AppComponent. Otwórz app.component.html i dodaj te znaczniki:

src/app/app.component.html

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

Dodajemy do niego pasek narzędzi w kolorze głównym w stylu Material Design, a w jego wnętrzu znajduje się ikona local_fire_depeartment obok etykiety „&Kanban Fire”." Jeśli teraz wyświetlisz konsolę, zobaczysz, że Angular zwraca kilka błędów. Aby je naprawić, dodaj te importy do usługi 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 { }

Używamy paska narzędzi i ikony w Angular, więc musimy zaimportować odpowiednie moduły w AppModule.

Na ekranie powinny pojawić się następujące elementy:

A39cf8f8428a03bc.png

Całkiem spoko

4. Wizualizacja zadań

W następnym kroku utwórzmy komponent, którego możemy użyć 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 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 zawierać opcjonalne pola id, title i description, które zawierają ciąg znaków:

src/app/task/task.ts

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

A teraz zaktualizujmy task.component.ts. Chcemy, aby obiekt TaskComponent akceptował jako obiekt wejściowy typu Task i chceł w ten sposób emitować 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&#39! 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>

Uwaga: w konsoli wyświetlają 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 powyższym szablonie używamy komponentu mat-card z elementu @angular/material, ale nie zaimportujemy tego modułu w aplikacji. Aby naprawić błąd powyżej, zaimportujemy MatCardModule w narzędziu AppModule:

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 zwizualizujemy je za pomocą TaskComponent.

W AppComponent zdefiniuj tablicę o nazwie todo i dodaj do niej dwa 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!'
    }
  ];
}

Następnie na dole strony 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 wyświetlić się ta informacja:

d96fccd13c63ceb1.png

5. Wdrażanie funkcji przeciągania i upuszczania w zadaniach

Pora na zabawę! Utwórzmy 3 ścieżki dla 3 różnych zadań, które można wykonać w danym stanie, a dzięki Angular CDK zaimplementujemy funkcję przeciągania i upuszczania.

W systemie app.component.html usuń komponent app-task z dyrektywą *ngFor na górze i zastąp go fragmentem:

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>

Wiele się tu dzieje. Przyjrzyjmy się poszczególnym częściom tego fragmentu kodu. Oto struktura najwyższego poziomu szablonu:

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 div, który opakowuje wszystkie 3 ścieżki, nadając im nazwę klasy „container-wrapper”.&quot”. 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 ścieżkę jako mat-card, która korzysta z dyrektywy cdkDropList. Używamy mat-card ze względu na style obsługiwane przez ten komponent. cdkDropList umożliwi nam późniejsze pominięcie zadań w elemencie. Ustawiamy też 2 takie dane wejściowe:

  • cdkDropListData – dane wejściowe listy rozwijanej, która umożliwia nam określenie tablicy danych
  • cdkDropListConnectedTo – odwołania do pozostałych elementów cdkDropList, z którymi jest obecnie połączony cdkDropList. To ustawienie określa, na których innych listach można upuścić elementy.

Dodatkowo chcemy obsługiwać zdarzenie spadku, używając danych wyjściowych cdkDropListDropped. Gdy cdkDropList wywoła dane wyjściowe, wywołamy metodę drop zadeklarowaną w elemencie AppComponent i przekażemy bieżące zdarzenie jako argument.

Zauważ, że określamy również właściwość id, która ma być używana jako identyfikator tego kontenera, oraz nazwę class, abyśmy mogli nadać jej styl. Przyjrzyjmy się treściom elementu mat-card. Oto dwa elementy:

  • Akapit, który służy do wyświetlania tekstu „Pusta lista”, gdy na liście todo nie ma żadnych elementów
  • Komponent app-task. Zwróć uwagę, że tutaj przetwarzamy dane wyjściowe edit, które zadeklarowano pierwotnie przez wywołanie metody editTask z nazwą listy i obiektem $event. Pomoże nam to zastąpić edytowane zadanie z odpowiedniej listy. Następnie, podobnie jak powyżej, powtarzamy listę todo i przekazujemy dane wejściowe task. Jednak tym razem dodajemy również dyrektywę cdkDrag. Umożliwia przeciąganie pojedynczych zadań.

Aby wszystko działało, musisz zaktualizować plik app.module.ts i zaimportować go do usługi DragDropModule:

src/app/app.module.ts

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

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

Musisz też zadeklarować tablicę inProgress i done, a także metody editTask i 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
    );
  }
}

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

Wynik powinien wyglądać tak:

460f86bcd10454cf.png

Na tym etapie możesz już przenieść elementy między tymi listami.

6. Tworzenie nowych zadań

Teraz zaimplementujemy funkcję tworzenia nowych zadań. W tym celu zaktualizujmy szablon strony 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 div najwyższego poziomu wokół elementu container-wrapper i dodajemy przycisk z &&t;add " ikoną materiału obok etykiety "Dodaj zadanie. Potrzebujemy dodatkowego kodu, aby umieścić przycisk nad listą ścieżek, a następnie umieścić je obok siebie za pomocą flexbox. Ponieważ ten przycisk używa komponentu przycisku Materiał, musimy zaimportować odpowiedni moduł w elemencie AppModule:

src/app/app.module.ts

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

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

A teraz zaimplementuj funkcję dodawania zadań w AppComponent. Użyjemy okna Material Design. W oknie dialogowym pojawi się formularz z dwoma polami: tytuł i opis. Gdy użytkownik kliknie przycisk „Dodaj zadanie”, otworzymy to okno. Gdy użytkownik prześle formularz, nowo utworzone zadanie zostanie dodane do listy todo.

Przyjrzyjmy się ogólnej implementacji 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 wstawiamy klasę MatDialog. newTask:

  • Otwórz nowe okno dialogowe za pomocą TaskDialogComponent, które zdefiniujemy trochę.
  • Określ, że okno ma mieć szerokość 270px.
  • Przekaż puste zadanie w oknie jako dane. W TaskDialogComponent znajdziemy odniesienie do tego obiektu danych.
  • Zasubskrybujemy zdarzenie zamknięcia i dodajemy zadanie z obiektu result do tablicy todo.

Aby upewnić się, że wszystko działa, musisz najpierw zaimportować MatDialogModule w AppModule:

src/app/app.module.ts

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

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

A teraz utwórzmy TaskDialogComponent. Przejdź do katalogu src/app i uruchom polecenie:

ng generate component task-dialog

Aby wdrożyć tę funkcję, otwórz ją i zastąp jej treść: src/app/task-dialog/task-dialog.component.html

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 dwoma polami danych title i description. Dyrektywa cdkFocusInput automatycznie ustawia pole wyboru title, gdy użytkownik otworzy okno.

Zwróć uwagę, jak w szablonie odwołujemy się do właściwości data komponentu. To będzie ta sama właściwość data, którą przekazujemy do metody open dialog w AppComponent. Aby zaktualizować tytuł i opis zadania, gdy użytkownik zmieni zawartość odpowiednich pól, stosujemy dwukierunkowe wiązanie danych z ngModel.

Gdy użytkownik kliknie przycisk OK, automatycznie zwrócimy wynik { task: data.task }, czyli zadanie, które mutowaliśmy za pomocą pól formularza w powyższym szablonie.

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

W elemencie TaskDialogComponent wstawiamy odwołanie do okna dialogowego, abyśmy mogli je zamknąć, a także określamy wartość dostawcy powiązaną z tokenem MAT_DIALOG_DATA. To jest obiekt danych, który przekazaliśmy do metody otwartej w AppComponent powyżej. Deklarujemy również właściwość prywatną backupTask, która jest kopią zadania, którą przekazaliśmy razem z obiektem danych.

Gdy użytkownik naciśnie przycisk Anuluj, przywracamy potencjalną zmianę właściwości this.data.task i zamkniemy to okno, przesyłając wynik this.data.

Mamy tu 2 typy, które nie zostały jeszcze zadeklarowane: TaskDialogData i TaskDialogResult. W dolnej części pliku src/app/task-dialog/task-dialog.component.ts umieść 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 wszystko będzie gotowe, 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 { }

Kliknięcie przycisku „Dodaj zadanie” powinno spowodować wyświetlenie następującego interfejsu:

33bcb987fade2a87.png

7. Ulepszanie stylów aplikacji

Aby aplikacja była bardziej atrakcyjna wizualnie, możemy nieco ulepszyć jej układ. Chcemy, aby pasy płynące znajdowały się obok siebie. Chcemy też wprowadzić drobne zmiany w przycisku „Dodaj zadanie” i pustą etykietę listy.

Otwórz src/app/app.component.css i dodaj te style na dole:

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 etykiety. Dopilnujemy też, aby treść była wyrównana w poziomie, ustawiając szerokość na 1400px, a jej margines na auto. Następnie korzystamy z flexbox, aby umieścić obok siebie linie ścienne i na tej podstawie wprowadzić pewne zmiany w sposobie wizualizacji zadań i pustych list.

Po ponownym załadowaniu aplikacji powinien wyświetlić się następujący interfejs:

69225f0b1aa5cb50.png

Chociaż znacznie ulepszyliśmy nasze aplikacje, nadal występuje irytujący problem z przenoszeniem zadań:

f9aae712027624af.png

Gdy przeciągniemy zadanie „Kup mleko”, zobaczysz 2 karty dla tego samego zadania – tę, którą przeciągamy i tę, która znajduje się na ścieżce. Angular CDK dostarcza nam nazw klas CSS, dzięki którym można rozwiązać ten problem.

Na dole elementu src/app/app.component.css dodaj te zastąpienia stylu:

src/app/app.component.css

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

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

Podczas przeciągania elementu Angular CDK&ponuje jego kopię, a następnie wstawia go w miejsce, w którym umieścimy oryginał. Aby mieć pewność, że ten element nie będzie widoczny, ustaw właściwość przezroczystości w klasie cdk-drag-placeholder, którą CDK doda do zmiennej.

Dodatkowo, gdy odrzucamy element, CDK dodaje 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 modyfikacje stylów naszych zadań. W zasadzie task.component.css zezwól elementowi wyświetlania na block i ustaw 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 usunąć istniejące zadania, wykorzystamy większość funkcji, które zostały już wdrożone. Gdy użytkownik kliknie dwukrotnie zadanie, otworzymy TaskDialogComponent i wypełnimy oba pola formularza wartościami title i description zadania.

Na karcie TaskDialogComponent znajdzie się też przycisk usuwania. Gdy użytkownik go kliknie, przekażemy instrukcję usuwania, która pojawi się w AppComponent.

Jedyną zmianą, jaką musimy wprowadzić w zasadzie TaskDialogComponent, jest szablon:

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łu. Gdy użytkownik go kliknie, zamkniemy to okno i przekażemy dosłowny obiekt { task: data.task, delete: true }. Zwróć uwagę, że przycisk jest ustawiony na okrągły za pomocą zasady mat-fab, ustawiamy jego kolor jako główny i pokazuje go tylko wtedy, gdy dane w oknie dialogowym mają włączone usuwanie.

Resztę funkcji edytowania i usuwania znajdziesz w AppComponent. Zastąp jego metodę editTask tymi fragmentami:

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

Przeanalizujmy argumenty metody editTask:

  • Lista typu 'done' | 'todo' | 'inProgress',, która jest typem ciągu literału z wartościami odpowiadającymi właściwościom poszczególnych ścieżek.
  • Bieżące zadanie, które chcesz edytować.

W treści metody najpierw otwieramy wystąpienie elementu TaskDialogComponent. Obiekt data przekazuje dosłowny obiekt, który wskazuje zadanie, które chcesz edytować, oraz włącza przycisk edycji w formularzu, ustawiając właściwość enableDelete na true.

Gdy pojawi się wynik, wyświetlimy 2 scenariusze:

  • Gdy flaga delete jest ustawiona na true (tzn. gdy użytkownik naciśnie przycisk usuwania), usuniemy zadanie z odpowiedniej listy.
  • Możesz też zastąpić zadanie w indeksie podanym zadaniem z okna.

9. Tworzenie nowego projektu Firebase

A teraz utwórz nowy projekt Firebase.

10. Dodaję Firebase do projektu

W tej sekcji zintegrujemy nasz projekt z Firebase. Zespół Firebase oferuje pakiet @angular/fire, który zapewnia integrację między 2 technologiami. Aby dodać obsługę Firebase do aplikacji, otwórz katalog główny Workspace i uruchom:

ng add @angular/fire

Polecenie to zainstaluje pakiet @angular/fire i zada Ci kilka pytań. W terminalu powinno pojawić się coś takiego:

9ba88c0d52d18d0.png

W międzyczasie instalacja otworzy okno przeglądarki, aby umożliwić uwierzytelnienie za pomocą konta Firebase. Na koniec trzeba wybrać projekt Firebase i utworzyć pliki na dysku.

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

Teraz wystarczy tylko dodać do środowiska konfigurację Firebase. Konfigurację projektu znajdziesz w konsoli Firebase.

  • Kliknij ikonę koła zębatego obok Przeglądu projektu.
  • Wybierz ustawienia projektu.

C8253a20031de8a9.png

W sekcji „Twoje aplikacje” wybierz &aplikację;

428a1abcd0f90b23.png

Następnie zarejestruj aplikację i upewnij się, że włączona jest usługa „Hosting Firebase”:

586e44cb27dd8f39.png

Gdy klikniesz „Zarejestruj aplikację”, możesz skopiować konfigurację do src/environments/environment.ts:

e30f142d79cecf8f.png

Na koniec plik konfiguracji 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 Firebase SDK za pomocą @angular/fire przenieśmy dane do Firestore. Najpierw zaimportujmy moduły, których potrzebujemy 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 używać 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 tablicowych:

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

Tutaj korzystamy z AngularFirestore, aby pobrać zawartość kolekcji bezpośrednio z bazy danych. Zauważ, że valueChanges zwraca obserwowalny obiekt zamiast tablicy, a ponadto określamy, że pole identyfikatora dokumentów w tej kolekcji powinno mieć wartość id, aby odpowiadać nazwie, której używamy w interfejsie Task. Za każdym razem, gdy następuje zmiana, zwraca ona valueChanges zadania.

Ze względu na to, że współpracujemy z obserwowalnymi obiektami zamiast tablic, musimy zaktualizować sposób dodawania, usuwania i edytowania zadań, a także funkcję przenoszenia zadań między ścieżkami. Zamiast mutować tablice w pamięci, do aktualizowania danych w bazie danych używamy pakietu SDK Firebase.

Najpierw zobaczmy, jak wygląda zmiana kolejności. Zamień metodę drop w src/app/app.component.ts na:

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

Nowy fragment kodu jest podświetlony w powyższym fragmencie kodu. Aby przenieść zadanie z bieżącej ścieżki do docelowej, usuniemy zadanie z pierwszej kolekcji i dodamy je do drugiej. Wykonujemy dwie operacje, które chcemy upodobnić do jednej (czyli to zrobisz ją atomowo), więc każdą z nich przeprowadzamy w ramach transakcji Firestore.

Teraz zaktualizujmy metodę editTask, by używała Firestore. W module obsługi okna dialogowego musisz 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);
  }
});
...

Korzystamy z dokumentu docelowego odpowiadającego zadaniu, które obrabiamy przy użyciu pakietu SDK Firestore, i usuwamy je lub aktualizujemy.

Na koniec musimy zaktualizować metodę tworzenia nowych zadań. Zamień this.todo.push('task') na: this.store.collection('todo').add(result.task).

Zwróć uwagę, że nasze kolekcje nie są tablicami, ale obserwowalnymi elementami. Aby móc je zwizualizować, musisz zaktualizować szablon szablonu AppComponent. Wystarczy, że zastąpisz każdy dostęp do właściwości todo, inProgress i done odpowiednimi wartościami todo | async, inProgress | async i done | async.

Próbka asynchroniczna automatycznie subskrybujesz subskrypcje, które są powiązane z kolekcjami. Gdy obserwowane wartości emitują nową wartość, Angular automatycznie uruchamia wykrywanie zmian i przetwarza emitowaną tablicę.

Przyjrzyjmy się na przykład zmianom, które musimy wprowadzić w ścieżce 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, stosuję asynchroniczny potok. To jest to samo w dyrektywie *ngIf, ale pamiętaj, że w przypadku uzyskiwania dostępu do właściwości length stosujemy też opcjonalny łańcuch (nazywany w Angular bezpiecznym narzędziem do nawigacji), aby mieć pewność, że nie wystąpi błąd w czasie działania, gdy todo | async nie jest null lub undefined.

Gdy utworzysz nowe zadanie w interfejsie użytkownika i otworzysz Firestore, powinno pojawić się coś takiego:

dd7ee20c0a10ebe2.png

12. Poprawa optymistycznych aktualizacji

W aplikacji wprowadzamy obecnie aktualizacje optymistyczne. W Firestore mamy źródło danych zgodnych z prawdą, ale mamy też lokalne kopie zadań. Gdy którekolwiek z obserwacji związanych z kolekcjami wyemitują, otrzymujemy tabelę zadań. Gdy działanie użytkownika powoduje zmianę stanu, najpierw aktualizujemy wartości lokalne, a następnie rozpowszechniamy zmianę w Firestore.

Przenosząc zadanie z jednej ścieżki do drugiej, wywołujemy funkcję transferArrayItem,, która działa w lokalnych instancjach tablic reprezentujących zadania w każdej ścieżce. Pakiet SDK Firebase traktuje te tablice jako niezmienne. Oznacza to, że gdy następnym razem Angular uruchomi wykrywanie zmian, uzyskamy nowe wystąpienia, które wyrenderują poprzedni stan, zanim przeniesiemy zadanie.

Jednocześnie uruchamiamy aktualizację Firestore i pakiet SDK Firebase uruchamia aktualizację z poprawnymi wartościami, więc w ciągu kilku milisekund interfejs uzyska odpowiedni stan. W ten sposób zadanie właśnie zostało przeniesione z pierwszej listy na następną. Dobrze widać to w pliku GIF poniżej:

70b946eebfa6f316.gif

Sposób rozwiązania tego problemu różni się w zależności od aplikacji, ale w każdym przypadku musimy dbać o utrzymanie spójnego stanu do czasu aktualizacji danych.

Możemy skorzystać z funkcji BehaviorSubject, która pakuje pierwotny obserwator z usługi valueChanges. Pod maską funkcji BehaviorSubject znajdziesz zmienną tablicę, która zachowuje aktualizację z przeglądarki transferArrayItem.

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

Tylko w powyższym fragmencie kodu tworzymy element BehaviorSubject, który emituje wartość za każdym razem, gdy możliwa do zarejestrowania jest powiązana z tą kolekcją.

Wszystko działa zgodnie z oczekiwaniami, ponieważ element BehaviorSubject ponownie używa tablicy w wywołaniach wykrywania zmian i jest aktualizowany tylko po otrzymaniu nowej wartości z Firestore.

13. Wdrożenie aplikacji

Potrzebujemy tylko wdrożenia aplikacji:

ng deploy

To polecenie spowoduje:

  1. Przygotuj aplikację z użyciem jej konfiguracji, optymalizując czas kompilacji.
  2. wdrożyć aplikację w Hostingu Firebase,
  3. Podaj adres URL, by zobaczyć podgląd wyniku.

14. Gratulacje

Gratulacje, udało Ci się utworzyć tablicę kanban za pomocą Angular i Firebase.

Utworzono interfejs z trzema kolumnami reprezentującymi stan różnych zadań. Za pomocą Angular CDK zaimplementowano przeciąganie i upuszczanie zadań w kolumnach. Następnie, korzystając z materiałów Angular, stworzysz formularz do tworzenia nowych zadań i edytowania istniejących. Następnie dowiesz się, jak używać @angular/fire, i przenieść cały stan aplikacji do Firestore. Na koniec wdrożyliśmy aplikację w Hostingu Firebase.

Co dalej?

Pamiętaj, że aplikacja została wdrożona za pomocą konfiguracji testowych. Przed wdrożeniem aplikacji w wersji produkcyjnej skonfiguruj odpowiednie uprawnienia. Tutaj dowiesz się, jak to zrobić.

Obecnie nie zachowujemy kolejności poszczególnych zadań na określonej ścieżce. Aby to zrobić, możesz użyć pola zamówienia w dokumencie zadania i posortować je.

Ponadto deskę kanban tworzyliśmy tylko dla jednego użytkownika, co oznacza, że dla każdej osoby, która otworzy aplikację, korzysta z jednej tablicy. Aby zaimplementować osobne tablice dla różnych użytkowników aplikacji, należy zmienić strukturę bazy danych. Tutaj znajdziesz więcej informacji o sprawdzonych metodach Firestore.