Создание веб-приложения с помощью Angular и Firebase

1. Введение

Последнее обновление: 11.09.2020

Что вы построите

В этой лабораторной работе мы создадим веб-доску Kanban с помощью Angular и Firebase! В нашем готовом приложении задачи будут разделены на три категории: «в отложенном режиме», «в процессе» и «завершённые». Мы сможем создавать, удалять задачи и переносить их из одной категории в другую с помощью перетаскивания.

Мы разработаем пользовательский интерфейс с помощью Angular и будем использовать Firestore в качестве постоянного хранилища. В конце практического занятия мы развернем приложение на Firebase Hosting с помощью Angular CLI.

b23bd3732d0206b.png

Чему вы научитесь

  • Как использовать материал Angular и CDK.
  • Как добавить интеграцию Firebase в ваше приложение Angular.
  • Как сохранить постоянные данные в Firestore.
  • Как развернуть приложение на Firebase Hosting с помощью Angular CLI с помощью одной команды.

Что вам понадобится

В этой лабораторной работе предполагается, что у вас есть учетная запись Google и базовые знания Angular и Angular CLI.

Давайте начнем!

2. Создание нового проекта

Для начала давайте создадим новое рабочее пространство Angular:

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

Этот шаг может занять несколько минут. Angular CLI создаст структуру вашего проекта и установит все зависимости. После завершения установки перейдите в каталог kanban-fire и запустите сервер разработки Angular CLI:

ng serve

Откройте http://localhost:4200 , и вы должны увидеть вывод, похожий на:

5ede7bc5b1109bf3.png

В редакторе откройте src/app/app.component.html и удалите всё его содержимое. Когда вы вернётесь на http://localhost:4200, вы увидите пустую страницу.

3. Добавление материала и CDK

Angular поставляется с реализацией компонентов пользовательского интерфейса, совместимых с Material Design, в составе пакета @angular/material . Одной из зависимостей @angular/material является Component Development Kit (CDK). CDK предоставляет примитивы, такие как утилиты a11y, функции перетаскивания и наложения. Мы распространяем CDK в составе пакета @angular/cdk .

Чтобы добавить материал в ваше приложение, выполните:

ng add @angular/material

Эта команда предлагает вам выбрать тему, использовать глобальные стили типографики Material и настроить анимацию браузера для Angular Material. Выберите «Индиго/Розовый», чтобы получить тот же результат, что и в этой практической работе, и ответьте «Да» на последние два вопроса.

Команда ng add устанавливает @angular/material и его зависимости, а также импортирует BrowserAnimationsModule в AppModule . На следующем этапе мы можем начать использовать компоненты, предлагаемые этим модулем!

Сначала добавим панель инструментов и значок в AppComponent . Откройте app.component.html и добавьте следующую разметку:

src/app/app.component.html

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

Здесь мы добавляем панель инструментов, используя основной цвет нашей темы Material Design, а внутри неё — значок local_fire_depeartment рядом с надписью «Kanban Fire». Если вы сейчас посмотрите в консоль, то увидите, что Angular выдаёт несколько ошибок. Чтобы исправить их, добавьте следующие импорты в 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 { }

Поскольку мы используем панель инструментов и значок Angular Material, нам необходимо импортировать соответствующие модули в AppModule .

На экране вы теперь должны увидеть следующее:

a39cf8f8428a03bc.png

Неплохо, всего 4 строки HTML и два импорта!

4. Визуализация задач

На следующем этапе давайте создадим компонент, который можно использовать для визуализации задач на доске канбан.

Перейдите в каталог src/app и выполните следующую команду CLI:

ng generate component task

Эта команда генерирует TaskComponent и добавляет его объявление в AppModule . В каталоге task создайте файл task.ts Мы будем использовать этот файл для определения интерфейса задач на доске kanban. Каждая задача будет иметь необязательные поля id , title и description , все типа string:

src/app/task/task.ts

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

Теперь обновим task.component.ts . Мы хотим, чтобы TaskComponent принимал в качестве входных данных объект типа Task и мог выводить выходные данные « 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>();
}

Отредактируйте шаблон TaskComponent ! Откройте task.component.html и замените его содержимое следующим 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>

Обратите внимание, что теперь в консоли появляются ошибки:

'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

В шаблоне выше мы используем компонент mat-card из @angular/material , но не импортировали соответствующий модуль в приложение. Чтобы исправить ошибку, указанную выше, нам нужно импортировать MatCardModule в AppModule :

src/app/app.module.ts

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

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

Далее мы создадим несколько задач в AppComponent и визуализируем их с помощью TaskComponent !

В AppComponent определите массив с именем todo и внутрь него добавьте две задачи:

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

Теперь в конец app.component.html добавьте следующую директиву *ngFor :

src/app/app.component.html

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

При открытии браузера вы увидите следующее:

d96fccd13c63ceb1.png

5. Реализация перетаскивания задач

Теперь мы готовы к самому интересному! Давайте создадим три зоны для трёх различных состояний задач и, используя Angular CDK, реализуем функцию перетаскивания.

В app.component.html удалите компонент app-task с директивой *ngFor вверху и замените его на:

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>

Здесь происходит много всего. Давайте рассмотрим отдельные части этого фрагмента пошагово. Вот структура шаблона верхнего уровня:

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>

Здесь мы создаём div , охватывающий все три дорожки, с именем класса « container-wrapper ». Каждая дорожка имеет имя класса « container » и заголовок внутри тега h2 .

Теперь давайте рассмотрим структуру первой дорожки:

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

Сначала мы определяем дорожку как mat-card , которая использует директиву cdkDropList . Мы используем mat-card из-за стилей, предоставляемых этим компонентом. В дальнейшем cdkDropList позволит нам перетаскивать задачи внутри элемента. Мы также задаём следующие два входных параметра:

  • cdkDropListData — вход раскрывающегося списка, позволяющий указать массив данных
  • cdkDropListConnectedTo — ссылки на другие списки cdkDropList , к которым подключен текущий cdkDropList . Устанавливая этот параметр, мы указываем, в какие другие списки можно перетаскивать элементы.

Кроме того, мы хотим обрабатывать событие drop с помощью выходного сигнала cdkDropListDropped . Как только cdkDropList выдаст этот выходной сигнал, мы вызовем метод drop , объявленный внутри AppComponent , и передадим текущее событие в качестве аргумента.

Обратите внимание, что мы также указываем id , который будет использоваться в качестве идентификатора для этого контейнера, и имя class , чтобы мы могли его стилизовать. Теперь давайте рассмотрим содержимое дочерних элементов mat-card . Здесь есть два элемента:

  • Абзац, который мы используем для отображения текста «Пустой список», когда в списке todo нет пунктов.
  • Компонент app-task . Обратите внимание, что здесь мы обрабатываем выходные данные edit , объявленные изначально, вызывая метод editTask с именем списка и объектом $event . Это поможет нам заменить отредактированную задачу из правильного списка. Далее мы перебираем список todo , как и раньше, и передаем входные данные task . Однако на этот раз мы также добавляем директиву cdkDrag . Она позволяет перетаскивать отдельные задачи.

Чтобы все это заработало, нам нужно обновить app.module.ts и включить импорт в DragDropModule :

src/app/app.module.ts

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

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

Нам также необходимо объявить массивы inProgress и done , а также методы editTask и 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
    );
  }
}

Обратите внимание, что в методе drop мы сначала проверяем, что перемещаем задачу в тот же список, из которого она была взята. Если это так, то мы немедленно возвращаем управление. В противном случае мы переносим текущую задачу в целевую зону.

Результат должен быть таким:

460f86bcd10454cf.png

На этом этапе вы уже сможете переносить элементы между двумя списками!

6. Создание новых задач

Теперь реализуем функционал создания новых задач. Для этого обновим шаблон 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>

Мы создаём элемент div верхнего уровня вокруг container-wrapper и добавляем кнопку со значком « add » рядом с надписью «Добавить задачу». Эта дополнительная обёртка нужна нам для размещения кнопки над списком дорожек, которые мы позже разместим рядом друг с другом с помощью Flexbox. Поскольку эта кнопка использует компонент Material Button, нам нужно импортировать соответствующий модуль в AppModule :

src/app/app.module.ts

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

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

Теперь реализуем функционал добавления задач в AppComponent . Мы будем использовать диалоговое окно Material. В нём будет форма с двумя полями: заголовок и описание. При нажатии пользователем кнопки «Добавить задачу» откроется диалоговое окно, а при отправке формы — созданная задача будет добавлена в список todo .

Давайте рассмотрим высокоуровневую реализацию этой функциональности в 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);
      });
  }
}

Мы объявляем конструктор, в который внедряем класс MatDialog . Внутри newTask мы:

  • Откройте новый диалог, используя TaskDialogComponent , который мы определим чуть позже.
  • Укажите, что мы хотим, чтобы ширина диалогового окна составляла 270px.
  • Передайте пустую задачу в диалог в качестве данных. В TaskDialogComponent мы сможем получить ссылку на этот объект данных.
  • Мы подписываемся на событие закрытия и добавляем задачу из объекта result в массив todo .

Чтобы убедиться, что это работает, нам сначала нужно импортировать MatDialogModule в AppModule :

src/app/app.module.ts

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

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

Теперь создадим TaskDialogComponent . Перейдите в каталог src/app и выполните:

ng generate component task-dialog

Чтобы реализовать его функциональность, сначала откройте: 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>

В приведённом выше шаблоне мы создаём форму с двумя полями: для title и description . Мы используем директиву cdkFocusInput для автоматического фокусирования на поле ввода title при открытии диалогового окна пользователем.

Обратите внимание, как внутри шаблона мы ссылаемся на свойство data компонента. Это будут те же data , которые мы передаем методу open dialog в AppComponent . Для обновления заголовка и описания задачи при изменении пользователем содержимого соответствующих полей мы используем двустороннюю привязку данных с помощью ngModel .

Когда пользователь нажимает кнопку «ОК», мы автоматически возвращаем результат { task: data.task } , который представляет собой задачу, которую мы мутировали с использованием полей формы в шаблоне выше.

Теперь реализуем контроллер компонента:

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 мы внедряем ссылку на диалоговое окно, чтобы иметь возможность закрыть его, а также значение поставщика, связанного с токеном MAT_DIALOG_DATA . Это объект данных, который мы передали методу open в AppComponent выше. Мы также объявляем закрытое свойство backupTask , которое представляет собой копию задачи, переданной вместе с объектом данных.

Когда пользователь нажимает кнопку отмены, мы восстанавливаем возможно измененные свойства this.data.task и закрываем диалог, передавая this.data в качестве результата.

Есть два типа, на которые мы ссылались, но пока не объявили: TaskDialogData и TaskDialogResult . В файле src/app/task-dialog/task-dialog.component.ts добавьте следующие объявления в конец файла:

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

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

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

Последнее, что нам нужно сделать перед тем, как функционал будет готов, — это импортировать несколько модулей в 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 { }

Если вы сейчас нажмете кнопку «Добавить задачу», вы увидите следующий пользовательский интерфейс:

33bcb987fade2a87.png

7. Улучшение стилей приложения

Чтобы сделать приложение более привлекательным, мы улучшим его внешний вид, немного изменив стили. Мы хотим расположить зоны ответственности рядом друг с другом. Также хотим немного доработать кнопку «Добавить задачу» и метку пустого списка.

Откройте src/app/app.component.css и добавьте следующие стили внизу:

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

В приведённом выше фрагменте кода мы настраиваем макет панели инструментов и её метки. Мы также обеспечиваем горизонтальное выравнивание содержимого, устанавливая её ширину 1400px и отступы на auto . Затем, используя Flexbox, мы размещаем дорожки рядом друг с другом и, наконец, вносим некоторые изменения в визуализацию задач и пустых списков.

После перезагрузки приложения вы увидите следующий пользовательский интерфейс:

69225f0b1aa5cb50.png

Несмотря на то, что мы значительно улучшили стили нашего приложения, у нас все еще возникает раздражающая проблема при перемещении задач:

f9aae712027624af.png

Когда мы начинаем перетаскивать задачу «Купить молоко», мы видим две карточки для одной и той же задачи: ту, которую мы перетаскиваем, и ту, что находится в зоне ответственности. Angular CDK предоставляет нам имена CSS-классов, которые можно использовать для решения этой проблемы.

Добавьте следующие переопределения стилей в конец src/app/app.component.css :

src/app/app.component.css

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

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

При перетаскивании элемента функция перетаскивания Angular CDK клонирует его и вставляет в то место, куда мы собираемся поместить оригинал. Чтобы этот элемент не был виден, мы задаём свойство opacity в классе cdk-drag-placeholder , которое CDK добавит к заполнителю.

Кроме того, при перетаскивании элемента CDK добавляет класс cdk-drag-animating . Чтобы показать плавную анимацию вместо прямой привязки элемента, мы определяем переход длительностью 250ms .

Мы также хотим внести небольшие изменения в стили наших задач. В task.component.css установим block отображения хост-элемента и зададим отступы:

src/app/task/task.component.css

:host {
  display: block;
}

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

8. Редактирование и удаление существующих задач

Для редактирования и удаления существующих задач мы повторно используем большую часть уже реализованных функций! Когда пользователь дважды щёлкает по задаче, мы открываем TaskDialogComponent и заполняем два поля формы title и description задачи.

В компонент TaskDialogComponent мы также добавим кнопку удаления. Когда пользователь нажмёт на неё, мы передадим команду удаления, которая попадёт в AppComponent .

Единственное изменение, которое нам нужно внести в TaskDialogComponent , — это его шаблон:

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>

Эта кнопка отображает значок удаления материала. Когда пользователь нажимает на неё, мы закрываем диалоговое окно и передаем в качестве результата литерал объекта { task: data.task, delete: true } . Также обратите внимание, что мы делаем кнопку круглой с помощью mat-fab , устанавливаем её основной цвет и отображаем её только тогда, когда в диалоговом окне разрешено удаление данных.

Остальная часть реализации функций редактирования и удаления находится в AppComponent . Замените его метод editTask следующим:

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

Давайте рассмотрим аргументы метода editTask :

  • Список типа 'done' | 'todo' | 'inProgress', представляющий собой тип объединения строковых литералов со значениями, соответствующими свойствам, связанным с отдельными дорожками.
  • Текущая задача, которую мы хотим редактировать.

В теле метода мы сначала открываем экземпляр TaskDialogComponent . В качестве его data мы передаём литерал объекта, который определяет задачу, которую мы хотим редактировать, а также включает кнопку редактирования на форме, устанавливая свойство enableDelete в true .

Когда мы получаем результат диалога, мы обрабатываем два сценария:

  • Когда флаг delete установлен в true (т. е. когда пользователь нажал кнопку удаления), мы удаляем задачу из соответствующего списка.
  • В качестве альтернативы мы просто заменяем задачу по указанному индексу на задачу, полученную из результата диалога.

9. Создание нового проекта Firebase

Теперь давайте создадим новый проект Firebase!

  • Перейдите в консоль Firebase .
  • Создайте новый проект с названием «KanbanFire».

10. Добавление Firebase в проект

В этом разделе мы интегрируем наш проект с Firebase! Команда Firebase предлагает пакет @angular/fire , который обеспечивает интеграцию между двумя технологиями. Чтобы добавить поддержку Firebase в ваше приложение, откройте корневой каталог рабочей области и выполните:

ng add @angular/fire

Эта команда устанавливает пакет @angular/fire и задаёт несколько вопросов. В терминале вы увидите что-то вроде:

9ba88c0d52d18d0.png

Тем временем программа установки открывает окно браузера для аутентификации с помощью учётной записи Firebase. В заключение предлагается выбрать проект Firebase, после чего на диске создаются файлы.

Далее нам нужно создать базу данных Firestore! В разделе «Cloud Firestore» нажмите «Создать базу данных».

1e4a08b5a2462956.png

После этого создадим базу данных в тестовом режиме:

ac1181b2c32049f9.png

Наконец, выберите регион:

34bb94cc542a0597.png

Осталось только добавить конфигурацию Firebase в вашу среду. Конфигурацию проекта можно найти в консоли Firebase.

  • Нажмите на значок шестеренки рядом с обзором проекта.
  • Выберите «Настройки проекта».

c8253a20031de8a9.png

В разделе «Ваши приложения» выберите «Веб-приложение»:

428a1abcd0f90b23.png

Затем зарегистрируйте свое приложение и убедитесь, что вы включили «Firebase Hosting» :

586e44cb27dd8f39.png

После нажатия кнопки «Зарегистрировать приложение» вы можете скопировать свою конфигурацию в src/environments/environment.ts :

e30f142d79cecf8f.png

В итоге ваш файл конфигурации должен выглядеть так:

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. Перенос данных в Firestore

Теперь, когда мы настроили Firebase SDK, давайте используем @angular/fire для переноса данных в Firestore! Для начала импортируем необходимые модули в 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 {}

Поскольку мы будем использовать Firestore, нам необходимо внедрить AngularFirestore в конструктор AppComponent :

src/app/app.component.ts

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

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

Далее мы обновляем способ инициализации массивов дорожек:

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

Здесь мы используем AngularFirestore для получения содержимого коллекции непосредственно из базы данных. Обратите внимание, что valueChanges возвращает наблюдаемый объект , а не массив, а также мы указываем, что поле идентификатора документов в этой коллекции должно называться id , чтобы соответствовать имени, используемому в интерфейсе Task . Наблюдаемый объект, возвращаемый valueChanges , создаёт коллекцию задач при каждом изменении.

Поскольку мы работаем с наблюдаемыми объектами, а не с массивами, нам необходимо обновить способ добавления, удаления и редактирования задач, а также функционал перемещения задач между дорожками. Вместо мутации массивов в памяти мы будем использовать Firebase SDK для обновления данных в базе данных.

Для начала посмотрим, как будет выглядеть переупорядочивание. Замените метод drop в src/app/app.component.ts на:

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

В приведённом выше фрагменте кода новый код выделен. Чтобы переместить задачу из текущей дорожки в целевую, мы удалим её из первой коллекции и добавим во вторую. Поскольку мы выполняем две операции, которые должны выглядеть как одна (т.е. сделать операцию атомарной), мы запускаем их в транзакции Firestore.

Теперь давайте обновим метод editTask для использования Firestore! Внутри обработчика закрытия диалогового окна нам нужно изменить следующие строки кода:

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

Мы получаем доступ к целевому документу, соответствующему задаче, которой мы манипулируем, используя Firestore SDK, и удаляем или обновляем его.

Наконец, нам нужно обновить метод создания новых задач. Замените this.todo.push('task') на this.store.collection('todo').add(result.task) .

Обратите внимание, что теперь наши коллекции — это не массивы, а наблюдаемые объекты. Чтобы визуализировать их, нам нужно обновить шаблон AppComponent . Просто замените все обращения к свойствам todo , inProgress и done на todo | async , inProgress | async и done | async соответственно.

Асинхронный канал автоматически подписывается на наблюдаемые объекты, связанные с коллекциями. Когда наблюдаемые объекты выдают новое значение, Angular автоматически запускает обнаружение изменений и обрабатывает переданный массив.

Например, давайте рассмотрим изменения, которые нам нужно внести в 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>

При передаче данных директиве cdkDropList мы применяем асинхронный конвейер. То же самое происходит и внутри директивы *ngIf , но обратите внимание, что там мы также используем опциональную цепочку (также известную как оператор безопасной навигации в Angular) при доступе к свойству length , чтобы гарантировать отсутствие ошибки времени выполнения, если todo | async не равен null или undefined .

Теперь, когда вы создадите новую задачу в пользовательском интерфейсе и откроете Firestore, вы должны увидеть что-то вроде этого:

dd7ee20c0a10ebe2.png

12. Улучшение оптимистичных обновлений

В приложении мы сейчас выполняем оптимистичные обновления . Источником данных является Firestore, но в то же время у нас есть локальные копии задач; когда любой из наблюдаемых объектов, связанных с коллекциями, генерирует данные, мы получаем массив задач. Когда действие пользователя изменяет состояние, мы сначала обновляем локальные значения, а затем передаем изменения в Firestore.

При перемещении задачи с одной дорожки на другую мы вызываем transferArrayItem, который работает с локальными экземплярами массивов, представляющих задачи в каждой дорожке. Firebase SDK рассматривает эти массивы как неизменяемые, а это значит, что при следующем запуске Angular для обнаружения изменений мы получим новые экземпляры, которые отобразят предыдущее состояние до переноса задачи.

В то же время мы запускаем обновление Firestore, и Firebase SDK запускает обновление с правильными значениями, так что через несколько миллисекунд пользовательский интерфейс вернется в правильное состояние. Это позволяет только что переданной задаче перейти из первого списка в следующий. Это хорошо видно на GIF-изображении ниже:

70b946eebfa6f316.gif

Правильный способ решения этой проблемы различается от приложения к приложению, но во всех случаях нам необходимо обеспечить сохранение согласованного состояния до тех пор, пока наши данные не обновятся.

Мы можем воспользоваться BehaviorSubject , который оборачивает исходный наблюдатель, полученный от valueChanges . Под капотом BehaviorSubject хранит изменяемый массив, сохраняющий обновление из transferArrayItem .

Чтобы исправить ситуацию, нам нужно всего лишь обновить 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[]>;
...
}

Все, что мы делаем в приведенном выше фрагменте, — это создаем BehaviorSubject , который выдает значение каждый раз, когда изменяется наблюдаемый объект, связанный с коллекцией.

Все работает так, как и ожидалось, потому что BehaviorSubject повторно использует массив при вызовах обнаружения изменений и обновляется только тогда, когда мы получаем новое значение от Firestore.

13. Развертывание приложения

Все, что нам нужно сделать для развертывания нашего приложения, это запустить:

ng deploy

Эта команда выполнит:

  1. Создайте свое приложение с его производственной конфигурацией, применяя оптимизации времени компиляции.
  2. Разверните свое приложение на Firebase Hosting.
  3. Выведите URL-адрес, чтобы вы могли предварительно просмотреть результат.

14. Поздравления

Поздравляем, вы успешно создали kanban-доску с помощью Angular и Firebase!

Вы создали пользовательский интерфейс с тремя столбцами, отображающими состояние различных задач. Используя Angular CDK, вы реализовали функцию перетаскивания задач между столбцами. Затем, используя Angular Material, вы создали форму для создания новых задач и редактирования существующих. Далее вы изучили, как использовать @angular/fire , и перенесли всё состояние приложения в Firestore. Наконец, вы развернули приложение на Firebase Hosting.

Что дальше?

Помните, что мы развернули приложение с использованием тестовых конфигураций. Перед запуском приложения в эксплуатацию убедитесь, что вы настроили правильные разрешения. Узнать, как это сделать, можно здесь .

В настоящее время порядок отдельных задач в конкретной зоне ответственности не сохраняется. Для этого можно использовать поле порядка в документе задачи и сортировать задачи по нему.

Кроме того, мы создали канбан-доску только для одного пользователя, то есть у нас будет одна канбан-доска для каждого, кто откроет приложение. Чтобы реализовать отдельные доски для разных пользователей приложения, вам потребуется изменить структуру базы данных. Узнайте о передовом опыте Firestore здесь .