1. Введение
Последнее обновление: 11 сентября 2020 г.
Что вы будете строить
В этой лаборатории кода мы создадим веб-доску канбан с помощью Angular и Firebase! Наше финальное приложение будет иметь три категории задач: незавершенные, выполняемые и завершенные. Мы сможем создавать, удалять задачи и переносить их из одной категории в другую с помощью перетаскивания.
Мы разработаем пользовательский интерфейс с помощью Angular и будем использовать Firestore в качестве нашего постоянного хранилища. В конце кода мы развернем приложение на хостинге Firebase с помощью Angular CLI.
Что вы узнаете
- Как использовать материал 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 , и вы должны увидеть вывод, похожий на:
В редакторе откройте 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
.
Теперь на экране вы должны увидеть следующее:
Неплохо, всего 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>
При открытии браузера вы должны увидеть следующее:
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
мы сначала проверяем, попадаем ли мы в тот же список, из которого исходит задача. Если это так, то мы немедленно возвращаемся. В противном случае мы переносим текущую задачу на дорожку назначения.
Результат должен быть:
К этому моменту вы уже должны уметь перемещать элементы между двумя списками!
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 { }
Когда вы сейчас нажмете кнопку «Добавить задачу», вы должны увидеть следующий пользовательский интерфейс:
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, мы размещаем дорожки рядом друг с другом и, наконец, вносим некоторые коррективы в то, как мы визуализируем задачи и пустые списки.
После перезагрузки приложения вы должны увидеть следующий пользовательский интерфейс:
Хотя мы значительно улучшили стили нашего приложения, у нас все еще есть раздражающая проблема, когда мы перемещаем задачи:
Когда мы начинаем перетаскивать задачу «Купить молоко», мы видим две карточки для одной и той же задачи — ту, которую мы перетаскиваем, и ту, что на дорожке. 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
и задает вам несколько вопросов. В вашем терминале вы должны увидеть что-то вроде:
Тем временем установка открывает окно браузера, чтобы вы могли пройти аутентификацию с помощью своей учетной записи Firebase. Наконец, он попросит вас выбрать проект Firebase и создаст несколько файлов на вашем диске.
Далее нам нужно создать базу данных Firestore! В разделе «Cloud Firestore» нажмите «Создать базу данных».
После этого создайте базу данных в тестовом режиме:
Наконец, выберите регион:
Осталось только добавить конфигурацию Firebase в вашу среду. Вы можете найти конфигурацию своего проекта в консоли Firebase.
- Щелкните значок шестеренки рядом с элементом «Обзор проекта».
- Выберите Настройки проекта.
В разделе "Ваши приложения" выберите "Веб-приложение":
Затем зарегистрируйте свое приложение и убедитесь, что вы включили «Firebase Hosting» :
После того, как вы нажмете «Зарегистрировать приложение», вы можете скопировать свою конфигурацию в src/environments/environment.ts
:
В итоге ваш конфигурационный файл должен выглядеть так:
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, вы должны увидеть что-то вроде этого:
12. Улучшение оптимистичных обновлений
В приложении мы в настоящее время выполняем оптимистичные обновления . У нас есть свой источник правды в Firestore, но в то же время у нас есть локальные копии задач; когда какие-либо наблюдаемые, связанные с коллекциями, испускаются, мы получаем массив задач. Когда действие пользователя изменяет состояние, мы сначала обновляем локальные значения, а затем распространяем изменение в Firestore.
Когда мы перемещаем задачу из одной дорожки в другую, мы вызываем функцию TransferArrayItem transferArrayItem,
которая работает с локальными экземплярами массивов, представляющих задачи в каждой дорожке. Firebase SDK рассматривает эти массивы как неизменяемые, а это означает, что при следующем запуске Angular обнаружения изменений мы получим их новые экземпляры, которые отобразят предыдущее состояние до того, как мы передали задачу.
В то же время мы запускаем обновление Firestore, а Firebase SDK запускает обновление с правильными значениями, поэтому через несколько миллисекунд пользовательский интерфейс вернется в правильное состояние. Это приводит к тому, что задача, которую мы только что передали, переходит из первого списка в следующий. Это хорошо видно на гифке ниже:
Правильный способ решения этой проблемы варьируется от приложения к приложению, но во всех случаях мы должны гарантировать, что мы поддерживаем согласованное состояние до тех пор, пока наши данные не обновятся.
Мы можем воспользоваться 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
Эта команда будет:
- Создайте свое приложение с его производственной конфигурацией, применяя оптимизацию времени компиляции.
- Разверните свое приложение на хостинге Firebase.
- Выведите URL-адрес, чтобы вы могли просмотреть результат.
14. Поздравления
Поздравляем, вы успешно создали канбан-доску с помощью Angular и Firebase!
Вы создали пользовательский интерфейс с тремя столбцами, представляющими состояние различных задач. Используя Angular CDK, вы реализовали перетаскивание задач по столбцам. Затем, используя материал Angular, вы построили форму для создания новых задач и редактирования существующих. Затем вы узнали, как использовать @angular/fire
и переместили все состояние приложения в Firestore. Наконец, вы развернули свое приложение на хостинге Firebase.
Что дальше?
Помните, что мы развернули приложение с помощью тестовых конфигураций. Перед развертыванием приложения в рабочей среде убедитесь, что вы настроили правильные разрешения. Вы можете узнать, как это сделать здесь .
В настоящее время мы не сохраняем порядок отдельных задач в определенной дорожке. Чтобы реализовать это, вы можете использовать поле заказа в документе задачи и сортировать на его основе.
Кроме того, мы создали канбан-доску только для одного пользователя, а это значит, что у нас есть единая канбан-доска для всех, кто открывает приложение. Чтобы реализовать отдельные доски для разных пользователей вашего приложения, вам потребуется изменить структуру базы данных. Узнайте о лучших практиках Firestore здесь .