1. Giới thiệu
Lần cập nhật gần đây nhất: ngày 11 tháng 9 năm 2020
Sản phẩm bạn sẽ tạo ra
Trong lớp học lập trình này, chúng ta sẽ xây dựng một bảng kanban trên web bằng Angular và Firebase! Ứng dụng cuối cùng của chúng ta sẽ có 3 danh mục nhiệm vụ: tồn đọng, đang thực hiện và đã hoàn thành. Chúng ta có thể tạo, xoá việc cần làm và chuyển việc cần làm từ danh mục này sang danh mục khác bằng cách kéo và thả.
Chúng ta sẽ phát triển giao diện người dùng bằng Angular và sử dụng Firestore làm bộ nhớ liên tục. Vào cuối lớp học lập trình, chúng ta sẽ triển khai ứng dụng này lên dịch vụ Lưu trữ Firebase bằng Giao diện dòng lệnh (CLI) của Angular.
Kiến thức bạn sẽ học được
- Cách sử dụng Material Angular và CDK.
- Cách thêm tính năng tích hợp Firebase vào ứng dụng Angular.
- Cách lưu trữ dữ liệu liên tục trong Firestore.
- Cách triển khai ứng dụng của bạn lên Firebase Hosting bằng Angular CLI chỉ bằng một lệnh.
Bạn cần có
Lớp học lập trình này giả định rằng bạn có Tài khoản Google và kiến thức cơ bản về Angular cũng như Angular CLI.
Hãy bắt đầu!
2. Tạo dự án mới
Trước tiên, hãy tạo một không gian làm việc Angular mới:
ng new kanban-fire
? Would you like to add Angular routing? No
? Which stylesheet format would you like to use? CSS
Bước này có thể mất vài phút. Angular CLI sẽ tạo cấu trúc dự án và cài đặt tất cả các phần phụ thuộc. Khi quá trình cài đặt hoàn tất, hãy chuyển đến thư mục kanban-fire
rồi khởi động máy chủ phát triển của Angular CLI:
ng serve
Mở http://localhost:4200 và bạn sẽ thấy kết quả tương tự như sau:
Trong trình chỉnh sửa, hãy mở src/app/app.component.html
rồi xoá toàn bộ nội dung của tệp này. Khi quay lại http://localhost:4200, bạn sẽ thấy một trang trống.
3. Thêm Material và CDK
Angular đi kèm với việc triển khai các thành phần giao diện người dùng tuân thủ Material Design trong gói @angular/material
. Một trong các phần phụ thuộc của @angular/material
là Component Development Kit (Bộ công cụ phát triển thành phần) hay CDK. CDK cung cấp các thành phần cơ bản, chẳng hạn như các tiện ích hỗ trợ tiếp cận, kéo và thả cũng như lớp phủ. Chúng tôi phân phối CDK trong gói @angular/cdk
.
Cách thêm tài liệu vào quá trình chạy ứng dụng:
ng add @angular/material
Lệnh này yêu cầu bạn chọn một giao diện, nếu bạn muốn sử dụng các kiểu chữ toàn cầu của Material và nếu bạn muốn thiết lập hiệu ứng động của trình duyệt cho Angular Material. Chọn "Chàm/Hồng" để nhận được kết quả tương tự như trong lớp học lập trình này và trả lời "Có" cho hai câu hỏi cuối cùng.
Lệnh ng add
cài đặt @angular/material
, các phần phụ thuộc của nó và nhập BrowserAnimationsModule
trong AppModule
. Trong bước tiếp theo, chúng ta có thể bắt đầu sử dụng các thành phần mà mô-đun này cung cấp!
Trước tiên, hãy thêm một thanh công cụ và biểu tượng vào AppComponent
. Mở app.component.html
rồi thêm mã đánh dấu sau:
src/app/app.component.html
<mat-toolbar color="primary">
<mat-icon>local_fire_department</mat-icon>
<span>Kanban Fire</span>
</mat-toolbar>
Ở đây, chúng ta thêm một thanh công cụ bằng cách sử dụng màu chính của giao diện Material Design và bên trong thanh công cụ đó, chúng ta sử dụng biểu tượng local_fire_depeartment
bên cạnh nhãn "Kanban Fire". Nếu xem bảng điều khiển ngay bây giờ, bạn sẽ thấy Angular đưa ra một số lỗi. Để khắc phục, hãy nhớ thêm các nội dung nhập sau vào 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 { }
Vì sử dụng thanh công cụ và biểu tượng Material của Angular, nên chúng ta cần nhập các mô-đun tương ứng trong AppModule
.
Lúc này, bạn sẽ thấy những thông tin sau trên màn hình:
Không tệ khi chỉ có 4 dòng HTML và 2 lần nhập!
4. Trực quan hoá việc cần làm
Ở bước tiếp theo, hãy tạo một thành phần mà chúng ta có thể dùng để trực quan hoá các việc cần làm trong bảng kanban.
Chuyển đến thư mục src/app
rồi chạy lệnh CLI sau:
ng generate component task
Lệnh này sẽ tạo TaskComponent
và thêm khai báo của TaskComponent
vào AppModule
. Trong thư mục task
, hãy tạo một tệp có tên là task.ts
. Chúng ta sẽ dùng tệp này để xác định giao diện của các việc cần làm trong bảng kanban. Mỗi tác vụ sẽ có các trường id
, title
và description
(không bắt buộc) đều thuộc loại chuỗi:
src/app/task/task.ts
export interface Task {
id?: string;
title: string;
description: string;
}
Bây giờ, hãy cập nhật task.component.ts
. Chúng ta muốn TaskComponent
chấp nhận một đối tượng thuộc loại Task
làm đầu vào và có thể phát ra đầu ra "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>();
}
Chỉnh sửa mẫu của TaskComponent
! Mở task.component.html
rồi thay thế nội dung của tệp này bằng đoạn mã HTML sau:
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>
Xin lưu ý rằng hiện tại chúng ta đang gặp lỗi trong bảng điều khiển:
'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
Trong mẫu ở trên, chúng ta đang sử dụng thành phần mat-card
từ @angular/material
, nhưng chúng ta chưa nhập mô-đun tương ứng của thành phần này vào ứng dụng. Để khắc phục lỗi ở trên, chúng ta cần nhập MatCardModule
vào AppModule
:
src/app/app.module.ts
...
import { MatCardModule } from '@angular/material/card';
@NgModule({
declarations: [
AppComponent
],
imports: [
...
MatCardModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
Tiếp theo, chúng ta sẽ tạo một vài việc cần làm trong AppComponent
và trực quan hoá các việc đó bằng TaskComponent
!
Trong AppComponent
, hãy xác định một mảng có tên là todo
và thêm 2 việc cần làm vào mảng này:
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!'
}
];
}
Bây giờ, ở cuối app.component.html
, hãy thêm chỉ thị *ngFor
sau:
src/app/app.component.html
<app-task *ngFor="let task of todo" [task]="task"></app-task>
Khi mở trình duyệt, bạn sẽ thấy nội dung sau:
5. Triển khai thao tác kéo và thả cho các việc cần làm
Giờ là phần thú vị nhất! Hãy tạo 3 làn đường cho 3 trạng thái khác nhau mà các tác vụ có thể ở trong đó, đồng thời triển khai chức năng kéo và thả bằng Angular CDK.
Trong app.component.html
, hãy xoá thành phần app-task
có chỉ thị *ngFor
ở trên cùng và thay thế bằng:
src/app/app.component.html
<div class="content-wrapper">
<div class="container-wrapper">
<div class="container">
<h2>Backlog</h2>
<mat-card
cdkDropList
id="todo"
#todoList="cdkDropList"
[cdkDropListData]="todo"
[cdkDropListConnectedTo]="[doneList, inProgressList]"
(cdkDropListDropped)="drop($event)"
class="list">
<p class="empty-label" *ngIf="todo.length === 0">Empty list</p>
<app-task (edit)="editTask('todo', $event)" *ngFor="let task of todo" cdkDrag [task]="task"></app-task>
</mat-card>
</div>
<div class="container">
<h2>In progress</h2>
<mat-card
cdkDropList
id="inProgress"
#inProgressList="cdkDropList"
[cdkDropListData]="inProgress"
[cdkDropListConnectedTo]="[todoList, doneList]"
(cdkDropListDropped)="drop($event)"
class="list">
<p class="empty-label" *ngIf="inProgress.length === 0">Empty list</p>
<app-task (edit)="editTask('inProgress', $event)" *ngFor="let task of inProgress" cdkDrag [task]="task"></app-task>
</mat-card>
</div>
<div class="container">
<h2>Done</h2>
<mat-card
cdkDropList
id="done"
#doneList="cdkDropList"
[cdkDropListData]="done"
[cdkDropListConnectedTo]="[todoList, inProgressList]"
(cdkDropListDropped)="drop($event)"
class="list">
<p class="empty-label" *ngIf="done.length === 0">Empty list</p>
<app-task (edit)="editTask('done', $event)" *ngFor="let task of done" cdkDrag [task]="task"></app-task>
</mat-card>
</div>
</div>
</div>
Có rất nhiều việc đang diễn ra ở đây. Hãy xem xét từng phần của đoạn mã này theo từng bước. Đây là cấu trúc cấp cao nhất của mẫu:
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>
Ở đây, chúng ta tạo một div
bao bọc cả 3 làn đường, với tên lớp là "container-wrapper
". Mỗi làn đường có tên lớp là "container
" và tiêu đề bên trong thẻ h2
.
Bây giờ, hãy xem cấu trúc của làn đường đầu tiên:
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>
...
Trước tiên, chúng ta xác định làn đường là một mat-card
, sử dụng chỉ thị cdkDropList
. Chúng ta sử dụng mat-card
vì các kiểu mà thành phần này cung cấp. Sau này, cdkDropList
sẽ cho phép chúng ta thả các tác vụ vào bên trong phần tử này. Chúng ta cũng đặt 2 đầu vào sau:
cdkDropListData
– đầu vào của danh sách thả xuống cho phép chúng ta chỉ định mảng dữ liệucdkDropListConnectedTo
– các mối tham chiếu đến nhữngcdkDropList
khác màcdkDropList
hiện tại được kết nối. Khi đặt đầu vào này, chúng ta sẽ chỉ định những danh sách khác mà chúng ta có thể thả các mục vào
Ngoài ra, chúng ta muốn xử lý sự kiện thả bằng đầu ra cdkDropListDropped
. Sau khi cdkDropList
phát ra đầu ra này, chúng ta sẽ gọi phương thức drop
được khai báo bên trong AppComponent
và truyền sự kiện hiện tại làm đối số.
Xin lưu ý rằng chúng ta cũng chỉ định một id
để dùng làm giá trị nhận dạng cho vùng chứa này và một tên class
để có thể tạo kiểu cho vùng chứa. Bây giờ, hãy xem xét các thành phần con nội dung của mat-card
. Hai phần tử mà chúng ta có ở đó là:
- Một đoạn văn mà chúng ta dùng để hiện văn bản "Danh sách trống" khi không có mục nào trong danh sách
todo
- Thành phần
app-task
. Lưu ý rằng ở đây, chúng ta đang xử lý đầu raedit
mà chúng ta đã khai báo ban đầu bằng cách gọi phương thứceditTask
bằng tên của danh sách và đối tượng$event
. Việc này sẽ giúp chúng tôi thay thế việc cần làm đã chỉnh sửa bằng việc cần làm trong danh sách chính xác. Tiếp theo, chúng ta lặp lại danh sáchtodo
như đã làm ở trên và truyền đầu vàotask
. Tuy nhiên, lần này, chúng ta cũng sẽ thêm chỉ thịcdkDrag
. Thao tác này giúp bạn kéo được các nhiệm vụ riêng lẻ.
Để mọi thứ hoạt động, chúng ta cần cập nhật app.module.ts
và thêm một lệnh nhập vào DragDropModule
:
src/app/app.module.ts
...
import { DragDropModule } from '@angular/cdk/drag-drop';
@NgModule({
declarations: [
AppComponent
],
imports: [
...
DragDropModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
Chúng ta cũng cần khai báo các mảng inProgress
và done
, cùng với các phương thức editTask
và drop
:
src/app/app.component.ts
...
import { CdkDragDrop, transferArrayItem } from '@angular/cdk/drag-drop';
@Component(...)
export class AppComponent {
todo: Task[] = [...];
inProgress: Task[] = [];
done: Task[] = [];
editTask(list: string, task: Task): void {}
drop(event: CdkDragDrop<Task[]>): void {
if (event.previousContainer === event.container) {
return;
}
if (!event.container.data || !event.previousContainer.data) {
return;
}
transferArrayItem(
event.previousContainer.data,
event.container.data,
event.previousIndex,
event.currentIndex
);
}
}
Xin lưu ý rằng trong phương thức drop
, trước tiên, chúng ta kiểm tra xem có đang thả vào cùng một danh sách mà tác vụ đến từ đó hay không. Nếu đúng như vậy, chúng tôi sẽ trả lại ngay. Nếu không, chúng tôi sẽ chuyển nhiệm vụ hiện tại sang làn đường đích.
Kết quả sẽ là:
Đến đây, bạn đã có thể chuyển các mục giữa hai danh sách!
6. Tạo việc cần làm mới
Bây giờ, hãy triển khai một chức năng để tạo các nhiệm vụ mới. Để làm việc này, hãy cập nhật mẫu của 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>
Chúng ta tạo một phần tử div
cấp cao nhất xung quanh container-wrapper
và thêm một nút có biểu tượng material "add
" bên cạnh nhãn "Add Task" ("Thêm việc cần làm"). Chúng ta cần trình bao bọc bổ sung này để đặt nút ở trên cùng của danh sách các làn đường, sau này chúng ta sẽ đặt các làn đường này cạnh nhau bằng cách sử dụng flexbox. Vì nút này sử dụng thành phần nút material, nên chúng ta cần nhập mô-đun tương ứng trong AppModule
:
src/app/app.module.ts
...
import { MatButtonModule } from '@angular/material/button';
@NgModule({
declarations: [
AppComponent
],
imports: [
...
MatButtonModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
Bây giờ, hãy triển khai chức năng thêm nhiệm vụ trong AppComponent
. Chúng ta sẽ sử dụng một hộp thoại Material. Trong hộp thoại, chúng ta sẽ có một biểu mẫu với hai trường: tiêu đề và nội dung mô tả. Khi người dùng nhấp vào nút "Thêm việc cần làm", chúng ta sẽ mở hộp thoại và khi người dùng gửi biểu mẫu, chúng ta sẽ thêm việc cần làm mới tạo vào danh sách todo
.
Hãy xem cách triển khai cấp cao của chức năng này trong 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);
});
}
}
Chúng ta khai báo một hàm khởi tạo mà trong đó chúng ta chèn lớp MatDialog
. Bên trong newTask
, chúng ta sẽ:
- Mở một hộp thoại mới bằng
TaskDialogComponent
mà chúng ta sẽ xác định sau. - Chỉ định rằng chúng ta muốn hộp thoại có chiều rộng là
270px.
- Truyền một việc cần làm trống vào hộp thoại dưới dạng dữ liệu. Trong
TaskDialogComponent
, chúng ta sẽ có thể lấy một tham chiếu đến đối tượng dữ liệu này. - Chúng ta đăng ký sự kiện đóng và thêm việc cần làm từ đối tượng
result
vào mảngtodo
.
Để đảm bảo điều này hoạt động, trước tiên chúng ta cần nhập MatDialogModule
vào AppModule
:
src/app/app.module.ts
...
import { MatDialogModule } from '@angular/material/dialog';
@NgModule({
declarations: [
AppComponent
],
imports: [
...
MatDialogModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
Bây giờ, hãy tạo TaskDialogComponent
. Chuyển đến thư mục src/app
rồi chạy:
ng generate component task-dialog
Để triển khai chức năng này, trước tiên, hãy mở src/app/task-dialog/task-dialog.component.html
rồi thay thế nội dung của chức năng này bằng:
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>
Trong mẫu ở trên, chúng ta tạo một biểu mẫu có 2 trường cho title
và description
. Chúng ta sử dụng chỉ thị cdkFocusInput
để tự động lấy tiêu điểm đầu vào title
khi người dùng mở hộp thoại.
Lưu ý cách chúng ta tham chiếu thuộc tính data
của thành phần bên trong mẫu. Đây sẽ là cùng một data
mà chúng ta truyền đến phương thức open
của dialog
trong AppComponent
. Để cập nhật tiêu đề và nội dung mô tả của nhiệm vụ khi người dùng thay đổi nội dung của các trường tương ứng, chúng ta sử dụng tính năng liên kết dữ liệu hai chiều với ngModel
.
Khi người dùng nhấp vào nút OK, chúng ta sẽ tự động trả về kết quả { task: data.task }
. Đây là nhiệm vụ mà chúng ta đã thay đổi bằng các trường biểu mẫu trong mẫu ở trên.
Bây giờ, hãy triển khai bộ điều khiển của thành phần này:
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);
}
}
Trong TaskDialogComponent
, chúng ta chèn một tham chiếu đến hộp thoại để có thể đóng hộp thoại đó, đồng thời chèn giá trị của trình cung cấp được liên kết với mã thông báo MAT_DIALOG_DATA
. Đây là đối tượng dữ liệu mà chúng ta đã truyền vào phương thức mở trong AppComponent
ở trên. Chúng ta cũng khai báo thuộc tính riêng tư backupTask
, đây là bản sao của tác vụ mà chúng ta đã truyền cùng với đối tượng dữ liệu.
Khi người dùng nhấn nút huỷ, chúng ta sẽ khôi phục các thuộc tính có thể đã thay đổi của this.data.task
và đóng hộp thoại, truyền this.data
làm kết quả.
Có hai loại mà chúng ta đã tham chiếu nhưng chưa khai báo – TaskDialogData
và TaskDialogResult
. Trong src/app/task-dialog/task-dialog.component.ts
, hãy thêm các nội dung khai báo sau vào cuối tệp:
src/app/task-dialog/task-dialog.component.ts
...
export interface TaskDialogData {
task: Partial<Task>;
enableDelete: boolean;
}
export interface TaskDialogResult {
task: Task;
delete?: boolean;
}
Việc cuối cùng chúng ta cần làm trước khi có chức năng này là nhập một số mô-đun trong 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 { }
Khi nhấp vào nút "Thêm việc cần làm" ngay bây giờ, bạn sẽ thấy giao diện người dùng sau:
7. Cải thiện kiểu dáng của ứng dụng
Để làm cho ứng dụng hấp dẫn hơn về mặt hình ảnh, chúng ta sẽ cải thiện bố cục bằng cách điều chỉnh một chút về kiểu dáng. Chúng ta muốn đặt các làn đường cạnh nhau. Chúng ta cũng muốn điều chỉnh một chút về nút "Thêm việc cần làm" và nhãn danh sách trống.
Mở src/app/app.component.css
rồi thêm các kiểu sau vào cuối:
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;
}
Trong đoạn mã trên, chúng ta điều chỉnh bố cục của thanh công cụ và nhãn của thanh công cụ. Chúng ta cũng đảm bảo nội dung được căn chỉnh theo chiều ngang bằng cách đặt chiều rộng thành 1400px
và lề thành auto
. Tiếp theo, bằng cách sử dụng flexbox, chúng ta đặt các làn bơi cạnh nhau và cuối cùng điều chỉnh cách chúng ta hình dung các nhiệm vụ và danh sách trống.
Sau khi ứng dụng tải lại, bạn sẽ thấy giao diện người dùng sau:
Mặc dù đã cải thiện đáng kể các kiểu của ứng dụng, nhưng chúng ta vẫn gặp phải một vấn đề gây phiền toái khi di chuyển các tác vụ:
Khi bắt đầu kéo nhiệm vụ "Mua sữa", chúng ta sẽ thấy hai thẻ cho cùng một nhiệm vụ – thẻ mà chúng ta đang kéo và thẻ trong làn đường. Angular CDK cung cấp cho chúng ta tên lớp CSS mà chúng ta có thể dùng để khắc phục vấn đề này.
Thêm các chế độ ghi đè kiểu sau vào cuối src/app/app.component.css
:
src/app/app.component.css
.cdk-drag-animating {
transition: transform 250ms;
}
.cdk-drag-placeholder {
opacity: 0;
}
Trong khi chúng ta kéo một phần tử, tính năng kéo và thả của Angular CDK sẽ sao chép phần tử đó rồi chèn vào vị trí mà chúng ta sẽ thả phần tử gốc. Để đảm bảo phần tử này không xuất hiện, chúng ta sẽ đặt thuộc tính độ mờ trong lớp cdk-drag-placeholder
. CDK sẽ thêm lớp này vào phần giữ chỗ.
Ngoài ra, khi chúng ta thả một phần tử, CDK sẽ thêm lớp cdk-drag-animating
. Để hiển thị một ảnh động mượt mà thay vì chụp nhanh phần tử, chúng ta sẽ xác định một hiệu ứng chuyển đổi có thời lượng là 250ms
.
Chúng tôi cũng muốn điều chỉnh một chút về kiểu dáng của các nhiệm vụ. Trong task.component.css
, hãy đặt màn hình của phần tử lưu trữ thành block
và đặt một số lề:
src/app/task/task.component.css
:host {
display: block;
}
.item {
margin-bottom: 10px;
cursor: pointer;
}
8. Chỉnh sửa và xoá các việc cần làm hiện có
Để chỉnh sửa và xoá các việc cần làm hiện có, chúng ta sẽ sử dụng lại hầu hết các chức năng mà chúng ta đã triển khai! Khi người dùng nhấp đúp vào một tác vụ, chúng ta sẽ mở TaskDialogComponent
và điền hai trường trong biểu mẫu bằng title
và description
của tác vụ.
Chúng ta cũng sẽ thêm một nút xoá vào TaskDialogComponent
. Khi người dùng nhấp vào nút này, chúng tôi sẽ truyền một chỉ dẫn xoá và chỉ dẫn này sẽ kết thúc trong AppComponent
.
Thay đổi duy nhất mà chúng ta cần thực hiện trong TaskDialogComponent
là trong mẫu của nó:
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>
Nút này cho thấy biểu tượng xoá tài liệu. Khi người dùng nhấp vào nút này, chúng ta sẽ đóng hộp thoại và truyền đối tượng chữ { task: data.task, delete: true }
dưới dạng kết quả. Cũng lưu ý rằng chúng ta tạo nút tròn bằng cách sử dụng mat-fab
, đặt màu của nút thành màu chính và chỉ hiện nút khi dữ liệu hộp thoại được bật tính năng xoá.
Phần còn lại của quá trình triển khai chức năng chỉnh sửa và xoá nằm trong AppComponent
. Thay thế phương thức editTask
của lớp này bằng phương thức sau:
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;
}
});
}
...
}
Hãy xem xét các đối số của phương thức editTask
:
- Một danh sách thuộc loại
'done' | 'todo' | 'inProgress',
, đây là một loại hợp nhất chuỗi ký tự có các giá trị tương ứng với những thuộc tính được liên kết với từng làn đường. - Việc cần làm hiện tại mà chúng ta muốn chỉnh sửa.
Trong phần nội dung của phương thức, trước tiên, chúng ta sẽ mở một phiên bản của TaskDialogComponent
. Khi data
, chúng ta truyền một đối tượng theo nghĩa đen, đối tượng này chỉ định việc cần làm mà chúng ta muốn chỉnh sửa, đồng thời cho phép nút chỉnh sửa trong biểu mẫu bằng cách đặt thuộc tính enableDelete
thành true
.
Khi nhận được kết quả từ hộp thoại, chúng ta sẽ xử lý 2 trường hợp:
- Khi cờ
delete
được đặt thànhtrue
(tức là khi người dùng nhấn nút xoá), chúng ta sẽ xoá việc cần làm khỏi danh sách tương ứng. - Ngoài ra, chúng ta chỉ cần thay thế tác vụ ở chỉ mục đã cho bằng tác vụ mà chúng ta nhận được từ kết quả của hộp thoại.
9. Tạo dự án Firebase mới
Bây giờ, hãy tạo một dự án Firebase mới!
- Truy cập vào Bảng điều khiển của Firebase.
- Tạo một dự án mới có tên là "KanbanFire".
10. Thêm Firebase vào dự án
Trong phần này, chúng ta sẽ tích hợp dự án của mình với Firebase! Nhóm Firebase cung cấp gói @angular/fire
, giúp tích hợp giữa hai công nghệ này. Để thêm tính năng hỗ trợ Firebase vào ứng dụng, hãy mở thư mục gốc của không gian làm việc rồi chạy:
ng add @angular/fire
Lệnh này sẽ cài đặt gói @angular/fire
và hỏi bạn một số câu hỏi. Trong thiết bị đầu cuối, bạn sẽ thấy nội dung tương tự như sau:
Trong thời gian chờ đợi, quá trình cài đặt sẽ mở một cửa sổ trình duyệt để bạn có thể xác thực bằng tài khoản Firebase của mình. Cuối cùng, công cụ này sẽ yêu cầu bạn chọn một dự án Firebase và tạo một số tệp trên đĩa của bạn.
Tiếp theo, chúng ta cần tạo một cơ sở dữ liệu Firestore! Trong "Cloud Firestore", hãy nhấp vào "Tạo cơ sở dữ liệu".
Sau đó, hãy tạo một cơ sở dữ liệu ở chế độ kiểm thử:
Cuối cùng, hãy chọn một khu vực:
Việc duy nhất bạn cần làm bây giờ là thêm cấu hình Firebase vào môi trường của mình. Bạn có thể tìm thấy cấu hình dự án trong Bảng điều khiển của Firebase.
- Nhấp vào biểu tượng Bánh răng bên cạnh phần Tổng quan về dự án.
- Chọn Cài đặt dự án.
Trong phần "Ứng dụng của bạn", hãy chọn một "Ứng dụng web":
Tiếp theo, hãy đăng ký ứng dụng của bạn và đảm bảo bạn bật "Lưu trữ Firebase":
Sau khi nhấp vào "Đăng ký ứng dụng", bạn có thể sao chép cấu hình vào src/environments/environment.ts
:
Cuối cùng, tệp cấu hình của bạn sẽ có dạng như sau:
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. Di chuyển dữ liệu sang Firestore
Bây giờ, sau khi thiết lập SDK Firebase, hãy sử dụng @angular/fire
để di chuyển dữ liệu của chúng ta sang Firestore! Trước tiên, hãy nhập các mô-đun cần thiết vào 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 {}
Vì sẽ sử dụng Firestore, nên chúng ta cần chèn AngularFirestore
vào hàm khởi tạo của AppComponent
:
src/app/app.component.ts
...
import { AngularFirestore } from '@angular/fire/firestore';
@Component({...})
export class AppComponent {
...
constructor(private dialog: MatDialog, private store: AngularFirestore) {}
...
}
Tiếp theo, chúng ta cập nhật cách khởi tạo các mảng làn đường:
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[]>;
...
}
Ở đây, chúng ta sử dụng AngularFirestore
để lấy nội dung của bộ sưu tập trực tiếp từ cơ sở dữ liệu. Xin lưu ý rằng valueChanges
trả về một observable thay vì một mảng, đồng thời chúng ta chỉ định rằng trường mã nhận dạng cho các tài liệu trong bộ sưu tập này phải được gọi là id
để khớp với tên mà chúng ta sử dụng trong giao diện Task
. Đối tượng có thể quan sát do valueChanges
trả về sẽ phát ra một tập hợp các tác vụ bất cứ khi nào tập hợp này thay đổi.
Vì đang làm việc với các đối tượng có thể quan sát thay vì mảng, nên chúng ta cần cập nhật cách thêm, xoá và chỉnh sửa các việc cần làm, cũng như chức năng di chuyển các việc cần làm giữa các làn đường. Thay vì thay đổi các mảng trong bộ nhớ, chúng ta sẽ sử dụng Firebase SDK để cập nhật dữ liệu trong cơ sở dữ liệu.
Trước tiên, hãy xem cách sắp xếp lại. Thay thế phương thức drop
trong src/app/app.component.ts
bằng:
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
);
}
Trong đoạn mã ở trên, mã mới được làm nổi bật. Để di chuyển một việc cần làm từ làn đường hiện tại sang làn đường mục tiêu, chúng ta sẽ xoá việc cần làm đó khỏi bộ sưu tập đầu tiên và thêm vào bộ sưu tập thứ hai. Vì chúng ta thực hiện 2 thao tác mà chúng ta muốn trông giống như một thao tác (tức là thực hiện thao tác một cách tỉ mỉ), nên chúng ta sẽ chạy các thao tác đó trong một giao dịch Firestore.
Tiếp theo, hãy cập nhật phương thức editTask
để sử dụng Firestore! Trong trình xử lý hộp thoại đóng, chúng ta cần thay đổi các dòng mã sau:
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);
}
});
...
Chúng ta truy cập vào tài liệu đích tương ứng với tác vụ mà chúng ta thao tác bằng Firestore SDK và xoá hoặc cập nhật tài liệu đó.
Cuối cùng, chúng ta cần cập nhật phương thức tạo việc cần làm mới. Thay thế this.todo.push('task')
bằng: this.store.collection('todo').add(result.task)
.
Xin lưu ý rằng giờ đây, các bộ sưu tập của chúng ta không phải là mảng mà là các đối tượng có thể quan sát. Để có thể hình dung được các thành phần này, chúng ta cần cập nhật mẫu của AppComponent
. Bạn chỉ cần thay thế mọi quyền truy cập vào các thuộc tính todo
, inProgress
và done
bằng todo | async
, inProgress | async
và done | async
tương ứng.
async pipe sẽ tự động đăng ký các đối tượng có thể quan sát được liên kết với các bộ sưu tập. Khi các đối tượng có thể quan sát phát ra một giá trị mới, Angular sẽ tự động chạy tính năng phát hiện thay đổi và xử lý mảng được phát ra.
Ví dụ: hãy xem xét những thay đổi cần thực hiện trong làn đường 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>
Khi truyền dữ liệu đến chỉ thị cdkDropList
, chúng ta sẽ áp dụng đường ống không đồng bộ. Điều này cũng tương tự trong chỉ thị *ngIf
, nhưng lưu ý rằng ở đó, chúng ta cũng sử dụng tính năng liên kết tuỳ chọn (còn gọi là toán tử điều hướng an toàn trong Angular) khi truy cập vào thuộc tính length
để đảm bảo chúng ta không gặp lỗi thời gian chạy nếu todo | async
không phải là null
hoặc undefined
.
Giờ đây, khi tạo một tác vụ mới trong giao diện người dùng và mở Firestore, bạn sẽ thấy nội dung như sau:
12. Cải thiện các bản cập nhật lạc quan
Trong ứng dụng, chúng ta hiện đang thực hiện các bản cập nhật lạc quan. Chúng tôi có nguồn dữ liệu đáng tin cậy trong Firestore, nhưng đồng thời chúng tôi có các bản sao cục bộ của các nhiệm vụ; khi bất kỳ đối tượng có thể quan sát nào được liên kết với các bộ sưu tập phát ra, chúng tôi sẽ nhận được một mảng các nhiệm vụ. Khi một thao tác của người dùng làm thay đổi trạng thái, trước tiên, chúng ta sẽ cập nhật các giá trị cục bộ rồi truyền thay đổi đó đến Firestore.
Khi di chuyển một việc cần làm từ một làn đường sang làn đường khác, chúng ta sẽ gọi transferArrayItem,
. Thao tác này sẽ hoạt động trên các phiên bản cục bộ của mảng đại diện cho các việc cần làm trong mỗi làn đường. Firebase SDK coi các mảng này là bất biến, nghĩa là lần tiếp theo Angular chạy tính năng phát hiện thay đổi, chúng ta sẽ nhận được các phiên bản mới của các mảng này. Điều này sẽ hiển thị trạng thái trước đó trước khi chúng ta chuyển nhiệm vụ.
Đồng thời, chúng tôi kích hoạt một bản cập nhật Firestore và SDK Firebase kích hoạt một bản cập nhật với các giá trị chính xác, vì vậy, trong vài mili giây, giao diện người dùng sẽ chuyển sang trạng thái chính xác. Thao tác này sẽ khiến việc cần làm mà chúng ta vừa chuyển nhảy từ danh sách đầu tiên sang danh sách tiếp theo. Bạn có thể thấy rõ điều này trong ảnh GIF bên dưới:
Cách giải quyết vấn đề này sẽ khác nhau tuỳ theo từng ứng dụng, nhưng trong mọi trường hợp, chúng ta cần đảm bảo duy trì trạng thái nhất quán cho đến khi dữ liệu được cập nhật.
Chúng ta có thể tận dụng BehaviorSubject
. Đây là đối tượng bao bọc đối tượng theo dõi ban đầu mà chúng ta nhận được từ valueChanges
. Trong nội bộ, BehaviorSubject
duy trì một mảng có thể thay đổi để duy trì nội dung cập nhật từ transferArrayItem
.
Để triển khai bản sửa lỗi, tất cả những gì chúng ta cần làm là cập nhật 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[]>;
...
}
Tất cả những gì chúng ta làm trong đoạn mã trên là tạo một BehaviorSubject
, phát ra một giá trị mỗi khi đối tượng có thể quan sát được liên kết với bộ sưu tập thay đổi.
Mọi thứ đều hoạt động như mong đợi, vì BehaviorSubject
sử dụng lại mảng trong các lệnh gọi phát hiện thay đổi và chỉ cập nhật khi chúng ta nhận được một giá trị mới từ Firestore.
13. Triển khai ứng dụng
Tất cả những gì chúng ta cần làm để triển khai ứng dụng là chạy:
ng deploy
Lệnh này sẽ:
- Tạo ứng dụng bằng cấu hình phát hành chính thức, áp dụng các phương pháp tối ưu hoá tại thời điểm biên dịch.
- Triển khai ứng dụng của bạn lên Firebase Hosting.
- Xuất một URL để bạn có thể xem trước kết quả.
14. Xin chúc mừng
Xin chúc mừng, bạn đã tạo thành công một bảng kanban bằng Angular và Firebase!
Bạn đã tạo một giao diện người dùng có 3 cột thể hiện trạng thái của các nhiệm vụ khác nhau. Khi sử dụng Angular CDK, bạn đã triển khai tính năng kéo và thả các nhiệm vụ giữa các cột. Sau đó, bằng cách sử dụng Angular Material, bạn đã tạo một biểu mẫu để tạo nhiệm vụ mới và chỉnh sửa nhiệm vụ hiện có. Tiếp theo, bạn đã tìm hiểu cách sử dụng @angular/fire
và chuyển tất cả trạng thái ứng dụng sang Firestore. Cuối cùng, bạn đã triển khai ứng dụng của mình lên Firebase Hosting.
Tiếp theo là gì?
Hãy nhớ rằng chúng ta đã triển khai ứng dụng bằng cách sử dụng cấu hình thử nghiệm. Trước khi triển khai ứng dụng cho người dùng thực tế, hãy đảm bảo bạn thiết lập đúng các quyền. Bạn có thể tìm hiểu cách thực hiện việc này tại đây.
Hiện tại, chúng tôi không giữ nguyên thứ tự của từng nhiệm vụ trong một làn đường cụ thể. Để triển khai việc này, bạn có thể sử dụng một trường thứ tự trong tài liệu nhiệm vụ và sắp xếp dựa trên trường đó.
Ngoài ra, chúng ta chỉ tạo bảng kanban cho một người dùng, tức là chúng ta có một bảng kanban duy nhất cho bất kỳ ai mở ứng dụng. Để triển khai các bảng riêng biệt cho những người dùng khác nhau của ứng dụng, bạn sẽ cần thay đổi cấu trúc cơ sở dữ liệu. Tìm hiểu về các phương pháp hay nhất của Firestore tại đây.