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

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 здесь .