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

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

О практической работе

subjectПоследнее обновление: мая 11, 2022
account_circleАвторы: Minko Gechev

1. Введение

Последнее обновление: 11 сентября 2020 г.

Что вы будете строить

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

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

b23bd3732d0206b.png

Что вы узнаете

  • Как использовать материал Angular и CDK.
  • Как добавить интеграцию с Firebase в ваше приложение Angular.
  • Как сохранить ваши постоянные данные в Firestore.
  • Как развернуть приложение на хостинге Firebase с помощью 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 поставляется с реализацией компонентов пользовательского интерфейса, совместимых с дизайном материалов, которые являются частью пакета @angular/material . Одной из зависимостей @angular/material является Component Development Kit или CDK. CDK предоставляет примитивы, такие как утилиты a11y, перетаскивание и наложение. Мы распространяем CDK в пакете @angular/cdk .

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

ng add @angular/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>

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

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

a39cf8f8428a03bc.png

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

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

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

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

ng generate component task

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

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="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>

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

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- 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 . Установив этот ввод, мы указываем, в какие другие списки мы можем добавлять элементы.

Кроме того, мы хотим обработать событие перетаскивания с помощью вывода 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[]|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
   
);
 
}
}

Обратите внимание, что в методе 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. Так как эта кнопка использует компонент материальной кнопки, нам нужно импортировать соответствующий модуль в AppModule :

src/app/app.module.ts

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

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

Теперь давайте реализуем функционал добавления задач в AppComponent . Мы будем использовать диалог материалов. В диалоге у нас появится форма с двумя полями: заголовок и описание. Когда пользователь нажимает кнопку «Добавить задачу», мы открываем диалоговое окно, и когда пользователь отправляет форму, мы добавляем вновь созданную задачу в список 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 .

Когда пользователь нажимает кнопку OK, мы автоматически возвращаем результат { 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 для документов в этой коллекции должно называться 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);
 
}
});
...

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

Наконец, нам нужно обновить метод создания новых задач. Замените 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 transferArrayItem, которая работает с локальными экземплярами массивов, представляющих задачи в каждой дорожке. Firebase SDK рассматривает эти массивы как неизменяемые, а это означает, что при следующем запуске Angular обнаружения изменений мы получим их новые экземпляры, которые отобразят предыдущее состояние до того, как мы передали задачу.

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

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.
  3. Выведите URL-адрес, чтобы вы могли просмотреть результат.

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

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

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

Что дальше?

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

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

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