1. مقدمة
تاريخ آخر تعديل: 2020-09-11
ما ستنشئه
في هذا الدرس التطبيقي حول الترميز، سننشئ لوحة كانبان على الويب باستخدام Angular وFirebase. سيتضمّن تطبيقنا النهائي ثلاث فئات من المهام: المهام المتراكمة والمهام قيد التنفيذ والمهام المكتملة. سنتمكّن من إنشاء المهام وحذفها ونقلها من فئة إلى أخرى باستخدام ميزة السحب والإفلات.
سنطوّر واجهة المستخدم باستخدام Angular وسنستخدم Firestore كمخزن دائم. في نهاية هذا الدرس العملي، سننشر التطبيق على "استضافة Firebase" باستخدام واجهة سطر الأوامر Angular.
ما ستتعرّف عليه
- كيفية استخدام Angular Material و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:
ng serve
افتح http://localhost:4200 ومن المفترض أن تظهر لك نتيجة مشابهة لما يلي:
في المحرِّر، افتح src/app/app.component.html
واحذف كل محتواه. عند الرجوع إلى http://localhost:4200، من المفترض أن تظهر لك صفحة فارغة.
3- إضافة Material وCDK
يتضمّن Angular تنفيذ مكونات واجهة المستخدم المتوافقة مع Material Design كجزء من حزمة @angular/material
. أحد العناصر التابعة لـ @angular/material
هو حزمة تطوير المكوّنات، أو CDK. توفر حزمة تطوير البرامج (CDK) عناصر أساسية، مثل أدوات تسهيل الاستخدام، والسحب والإفلات، والتراكب. نوزّع CDK في حزمة @angular/cdk
.
لإضافة مواد إلى تشغيل تطبيقك:
ng add @angular/material
يطلب منك هذا الأمر اختيار مظهر، إذا كنت تريد استخدام أنماط الكتابة العالمية في Material، وإذا كنت تريد إعداد رسوم متحركة في المتصفّح لـ Angular Material. اختَر "أزرق نيلي/وردي" للحصول على النتيجة نفسها كما في هذا الدرس العملي، وأجب بـ "نعم" عن السؤالَين الأخيرَين.
يُثبِّت الأمر ng add
الحزمة @angular/material
ومواردها التابعة، ويستورد BrowserAnimationsModule
في AppModule
. في الخطوة التالية، يمكننا البدء في استخدام المكوّنات التي توفّرها هذه الوحدة.
أولاً، لنضِف شريط أدوات ورمزًا إلى AppComponent
. افتح app.component.html
وأضِف الترميز التالي:
src/app/app.component.html
<mat-toolbar color="primary">
<mat-icon>local_fire_department</mat-icon>
<span>Kanban Fire</span>
</mat-toolbar>
هنا، نضيف شريط أدوات باستخدام اللون الأساسي لمظهر Material Design، وداخله نستخدم الرمز local_fire_depeartment
بجانب التصنيف "Kanban Fire". إذا نظرت إلى وحدة التحكّم الآن، ستلاحظ أنّ Angular يعرض بعض الأخطاء. لإصلاحها، تأكَّد من إضافة عمليات الاستيراد التالية إلى AppModule
:
src/app/app.module.ts
...
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatIconModule } from '@angular/material/icon';
@NgModule({
declarations: [
AppComponent
],
imports: [
...
MatToolbarModule,
MatIconModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
بما أنّنا نستخدم شريط أدوات ورمز Angular Material، علينا استيراد الوحدات النمطية المقابلة في AppModule
.
من المفترَض أن يظهر لك الآن ما يلي على الشاشة:
هذا ليس سيئًا مع 4 أسطر فقط من HTML وعمليتَي استيراد.
4. تصوُّر المهام
في الخطوة التالية، لننشئ مكوّنًا يمكننا استخدامه لتصوّر المهام في لوحة كانبان.
انتقِل إلى دليل src/app
ونفِّذ أمر واجهة سطر الأوامر التالي:
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!'
}
];
}
الآن، أضِف توجيه *ngFor
التالي إلى أسفل app.component.html
:
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="content-wrapper">
<div class="container-wrapper">
<div class="container">
<h2>Backlog</h2>
<mat-card
cdkDropList
id="todo"
#todoList="cdkDropList"
[cdkDropListData]="todo"
[cdkDropListConnectedTo]="[doneList, inProgressList]"
(cdkDropListDropped)="drop($event)"
class="list">
<p class="empty-label" *ngIf="todo.length === 0">Empty list</p>
<app-task (edit)="editTask('todo', $event)" *ngFor="let task of todo" cdkDrag [task]="task"></app-task>
</mat-card>
</div>
<div class="container">
<h2>In progress</h2>
<mat-card
cdkDropList
id="inProgress"
#inProgressList="cdkDropList"
[cdkDropListData]="inProgress"
[cdkDropListConnectedTo]="[todoList, doneList]"
(cdkDropListDropped)="drop($event)"
class="list">
<p class="empty-label" *ngIf="inProgress.length === 0">Empty list</p>
<app-task (edit)="editTask('inProgress', $event)" *ngFor="let task of inProgress" cdkDrag [task]="task"></app-task>
</mat-card>
</div>
<div class="container">
<h2>Done</h2>
<mat-card
cdkDropList
id="done"
#doneList="cdkDropList"
[cdkDropListData]="done"
[cdkDropListConnectedTo]="[todoList, inProgressList]"
(cdkDropListDropped)="drop($event)"
class="list">
<p class="empty-label" *ngIf="done.length === 0">Empty list</p>
<app-task (edit)="editTask('done', $event)" *ngFor="let task of done" cdkDrag [task]="task"></app-task>
</mat-card>
</div>
</div>
</div>
هناك الكثير من التفاصيل هنا. لنلقِ نظرة على الأجزاء الفردية من هذا المقتطف خطوة بخطوة. في ما يلي البنية ذات المستوى الأعلى للنموذج:
src/app/app.component.html
...
<div class="container-wrapper">
<div class="container">
<h2>Backlog</h2>
...
</div>
<div class="container">
<h2>In progress</h2>
...
</div>
<div class="container">
<h2>Done</h2>
...
</div>
</div>
في هذا المثال، ننشئ div
يضمّ جميع مسارات السباحة الثلاثة، مع اسم الفئة "container-wrapper
". يحتوي كل مسار سباحة على اسم الفئة "container
" وعنوان داخل علامة h2
.
لنلقِ الآن نظرة على بنية المسار الأول:
src/app/app.component.html
...
<div class="container">
<h2>Backlog</h2>
<mat-card
cdkDropList
id="todo"
#todoList="cdkDropList"
[cdkDropListData]="todo"
[cdkDropListConnectedTo]="[doneList, inProgressList]"
(cdkDropListDropped)="drop($event)"
class="list"
>
<p class="empty-label" *ngIf="todo.length === 0">Empty list</p>
<app-task (edit)="editTask('todo', $event)" *ngFor="let task of todo" cdkDrag [task]="task"></app-task>
</mat-card>
</div>
...
أولاً، نعرّف مسار العمل على أنّه mat-card
، والذي يستخدم توجيه cdkDropList
. نستخدم mat-card
بسبب الأنماط التي يوفّرها هذا المكوّن. ستتيح لنا السمة cdkDropList
لاحقًا إدراج مهام داخل العنصر. نضبط أيضًا الإدخالَين التاليَين:
-
cdkDropListData
- إدخال قائمة منسدلة تتيح لنا تحديد مصفوفة البيانات -
cdkDropListConnectedTo
: مراجع إلىcdkDropList
الأخرى التي يرتبط بهاcdkDropList
الحالي من خلال ضبط هذا الإدخال، نحدّد القوائم الأخرى التي يمكننا نقل العناصر إليها.
بالإضافة إلى ذلك، نريد التعامل مع حدث الإفلات باستخدام الناتج cdkDropListDropped
. بعد أن يُصدر cdkDropList
هذا الناتج، سنستدعي الطريقة drop
المُعرَّفة داخل AppComponent
ونمرّر الحدث الحالي كوسيطة.
لاحظ أنّنا نحدّد أيضًا id
لاستخدامه كمعرّف لهذا الحاوية، واسم class
حتى نتمكّن من تنسيقه. لنلقِ نظرة الآن على العناصر الفرعية الخاصة بالعنصر mat-card
. العنصران اللذان لدينا هما:
- فقرة، نستخدمها لعرض النص "قائمة فارغة" عندما لا تتضمّن القائمة
todo
أي عناصر - المكوّن
app-task
لاحظ أنّنا هنا نتعامل مع ناتجedit
الذي حدّدناه في الأصل من خلال استدعاء طريقةeditTask
مع اسم القائمة وعنصر$event
. سيساعدنا ذلك في استبدال المهمة المعدَّلة من القائمة الصحيحة. بعد ذلك، نكرّر قائمةtodo
كما فعلنا أعلاه ونمرّر الإدخالtask
. في هذه المرة، سنضيف أيضًا التوجيهcdkDrag
. ويتيح سحب المهام الفردية.
لكي تعمل كل هذه العناصر، علينا تعديل app.module.ts
وتضمين عملية استيراد إلى DragDropModule
:
src/app/app.module.ts
...
import { DragDropModule } from '@angular/cdk/drag-drop';
@NgModule({
declarations: [
AppComponent
],
imports: [
...
DragDropModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
علينا أيضًا تعريف مصفوفتَي inProgress
وdone
، بالإضافة إلى طريقتَي editTask
وdrop
:
src/app/app.component.ts
...
import { CdkDragDrop, transferArrayItem } from '@angular/cdk/drag-drop';
@Component(...)
export class AppComponent {
todo: Task[] = [...];
inProgress: Task[] = [];
done: Task[] = [];
editTask(list: string, task: Task): void {}
drop(event: CdkDragDrop<Task[]>): void {
if (event.previousContainer === event.container) {
return;
}
if (!event.container.data || !event.previousContainer.data) {
return;
}
transferArrayItem(
event.previousContainer.data,
event.container.data,
event.previousIndex,
event.currentIndex
);
}
}
لاحظ أنّه في طريقة drop
، نتحقّق أولاً من أنّنا نسحب المهمة إلى القائمة نفسها التي تأتي منها. في هذه الحالة، نعود فورًا. بخلاف ذلك، ننقل المهمة الحالية إلى مسار التنفيذ الوجهة.
يجب أن تكون النتيجة:
في هذه المرحلة، يجب أن تتمكّن من نقل العناصر بين القائمتين.
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
من رموز Material بجانب التصنيف "إضافة مهمة". نحتاج إلى الغلاف الإضافي لوضع الزر أعلى قائمة مسارات السباحة، والتي سنضعها لاحقًا بجانب بعضها البعض باستخدام flexbox. بما أنّ هذا الزر يستخدم مكوّن زر Material، علينا استيراد الوحدة النمطية المقابلة في AppModule
:
src/app/app.module.ts
...
import { MatButtonModule } from '@angular/material/button';
@NgModule({
declarations: [
AppComponent
],
imports: [
...
MatButtonModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
لننفّذ الآن وظيفة إضافة المهام في AppComponent
. سنستخدم مربّع حوار Material Design. في مربّع الحوار، سيكون لدينا نموذج يتضمّن حقلَين: العنوان والوصف. عندما ينقر المستخدم على الزر "إضافة مهمة"، سيتم فتح مربّع الحوار، وعندما يرسل المستخدم النموذج، ستتم إضافة المهمة التي تم إنشاؤها حديثًا إلى القائمة todo
.
لنلقِ نظرة على التنفيذ العام لهذه الوظيفة في AppComponent
:
src/app/app.component.ts
...
import { MatDialog } from '@angular/material/dialog';
@Component(...)
export class AppComponent {
...
constructor(private dialog: MatDialog) {}
newTask(): void {
const dialogRef = this.dialog.open(TaskDialogComponent, {
width: '270px',
data: {
task: {},
},
});
dialogRef
.afterClosed()
.subscribe((result: TaskDialogResult|undefined) => {
if (!result) {
return;
}
this.todo.push(result.task);
});
}
}
نعلن عن دالة إنشائية نحقن فيها الفئة MatDialog
. داخل newTask
، سنقوم بما يلي:
- افتح مربّع حوار جديدًا باستخدام
TaskDialogComponent
الذي سنحدّده بعد قليل. - حدِّد أنّنا نريد أن يكون عرض مربّع الحوار
270px.
- مرِّر مهمة فارغة إلى مربّع الحوار كبيانات. في
TaskDialogComponent
، سنتمكّن من الحصول على مرجع لكائن البيانات هذا. - نُفعّل الاشتراك في حدث الإغلاق ونضيف المهمة من الكائن
result
إلى المصفوفةtodo
.
للتأكّد من أنّ هذا الإجراء يعمل، علينا أولاً استيراد MatDialogModule
في AppModule
:
src/app/app.module.ts
...
import { MatDialogModule } from '@angular/material/dialog';
@NgModule({
declarations: [
AppComponent
],
imports: [
...
MatDialogModule
],
providers: [],
bootstrap: [AppComponent]
})
export class AppModule { }
لننشئ الآن TaskDialogComponent
. انتقِل إلى الدليل src/app
ونفِّذ الأمر التالي:
ng generate component task-dialog
لتنفيذ وظائفه، افتح أولاً: src/app/task-dialog/task-dialog.component.html
واستبدِل محتواه بما يلي:
src/app/task-dialog/task-dialog.component.html
<mat-form-field>
<mat-label>Title</mat-label>
<input matInput cdkFocusInitial [(ngModel)]="data.task.title" />
</mat-form-field>
<mat-form-field>
<mat-label>Description</mat-label>
<textarea matInput [(ngModel)]="data.task.description"></textarea>
</mat-form-field>
<div mat-dialog-actions>
<button mat-button [mat-dialog-close]="{ task: data.task }">OK</button>
<button mat-button (click)="cancel()">Cancel</button>
</div>
في النموذج أعلاه، ننشئ نموذجًا يتضمّن حقلَين لـ title
وdescription
. نستخدم التوجيه cdkFocusInput
لتركيز الإدخال title
تلقائيًا عندما يفتح المستخدم مربّع الحوار.
لاحظ كيف أنّنا نشير داخل النموذج إلى السمة data
الخاصة بالمكوّن. سيكون هذا هو data
نفسه الذي نمرّره إلى طريقة open
في dialog
في AppComponent
. لتعديل عنوان المهمة ووصفها عندما يغيّر المستخدم محتوى الحقول المقابلة، نستخدم ربط البيانات في اتجاهين مع ngModel
.
عندما ينقر المستخدم على الزر "حسنًا"، نعرض تلقائيًا النتيجة { task: data.task }
، وهي المهمة التي عدّلناها باستخدام حقول النموذج في النموذج أعلاه.
لننفّذ الآن وحدة التحكّم في المكوّن:
src/app/task-dialog/task-dialog.component.ts
import { Component, Inject } from '@angular/core';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { Task } from '../task/task';
@Component({
selector: 'app-task-dialog',
templateUrl: './task-dialog.component.html',
styleUrls: ['./task-dialog.component.css'],
})
export class TaskDialogComponent {
private backupTask: Partial<Task> = { ...this.data.task };
constructor(
public dialogRef: MatDialogRef<TaskDialogComponent>,
@Inject(MAT_DIALOG_DATA) public data: TaskDialogData
) {}
cancel(): void {
this.data.task.title = this.backupTask.title;
this.data.task.description = this.backupTask.description;
this.dialogRef.close(this.data);
}
}
في TaskDialogComponent
، نضيف مرجعًا إلى مربّع الحوار، حتى نتمكّن من إغلاقه، كما نضيف قيمة مقدّم الخدمة المرتبط بالرمز المميز MAT_DIALOG_DATA
. هذا هو عنصر البيانات الذي مرّرناه إلى طريقة الفتح في 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، نضع مسارات السباحة بجانب بعضها البعض، وأخيرًا نجري بعض التعديلات على طريقة عرض المهام والقوائم الفارغة.
بعد إعادة تحميل تطبيقك، من المفترض أن تظهر لك واجهة المستخدم التالية:
على الرغم من أنّنا حسّنّا بشكل كبير أنماط تطبيقنا، لا تزال لدينا مشكلة مزعجة عند نقل المهام:
عندما نبدأ بسحب المهمة "شراء الحليب"، نرى بطاقتَين للمهمة نفسها، البطاقة التي نسحبها والبطاقة الموجودة في مسار السباحة. توفّر لنا حزمة تطوير البرامج (CDK) في Angular أسماء فئات 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":
بعد النقر على "تسجيل التطبيق"، يمكنك نسخ الإعدادات إلى 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
بعد إعداد حزمة تطوير البرامج (SDK) لمنصة Firebase، لنستخدِم @angular/fire
لنقل بياناتنا إلى Firestore. أولاً، لنستورد الوحدات التي نحتاجها في AppModule
:
src/app/app.module.ts
...
import { environment } from 'src/environments/environment';
import { AngularFireModule } from '@angular/fire';
import { AngularFirestoreModule } from '@angular/fire/firestore';
@NgModule({
declarations: [AppComponent, TaskDialogComponent, TaskComponent],
imports: [
...
AngularFireModule.initializeApp(environment.firebase),
AngularFirestoreModule
],
providers: [],
bootstrap: [AppComponent],
})
export class AppModule {}
بما أنّنا سنستخدم Firestore، علينا إدخال AngularFirestore
في أداة إنشاء AppComponent
:
src/app/app.component.ts
...
import { AngularFirestore } from '@angular/fire/firestore';
@Component({...})
export class AppComponent {
...
constructor(private dialog: MatDialog, private store: AngularFirestore) {}
...
}
بعد ذلك، نعدّل طريقة بدء استخدام مصفوفات لوحات العرض:
src/app/app.component.ts
...
@Component({...})
export class AppComponent {
todo = this.store.collection('todo').valueChanges({ idField: 'id' }) as Observable<Task[]>;
inProgress = this.store.collection('inProgress').valueChanges({ idField: 'id' }) as Observable<Task[]>;
done = this.store.collection('done').valueChanges({ idField: 'id' }) as Observable<Task[]>;
...
}
نستخدم هنا AngularFirestore
للحصول على محتوى المجموعة مباشرةً من قاعدة البيانات. لاحظ أنّ الدالة valueChanges
تعرض كائنًا قابلاً للمراقبة بدلاً من مصفوفة، وأنّنا نحدّد أيضًا أنّه يجب تسمية حقل المعرّف للمستندات في هذه المجموعة id
ليتطابق مع الاسم الذي نستخدمه في الواجهة Task
. تعرض السلسلة القابلة للمراقبة التي يعرضها valueChanges
مجموعة من المهام في أي وقت تتغير فيه.
بما أنّنا نعمل مع عناصر قابلة للمراقبة بدلاً من المصفوفات، علينا تعديل طريقة إضافة المهام وإزالتها وتعديلها، بالإضافة إلى وظيفة نقل المهام بين مسارات العمل. بدلاً من تعديل مصفوفاتنا في الذاكرة، سنستخدم حزمة تطوير البرامج (SDK) من Firebase لتعديل البيانات في قاعدة البيانات.
لنلقِ نظرة أولاً على الشكل الذي سيبدو عليه إعادة الترتيب. استبدِل طريقة 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
على التوالي.
تُجري قناة 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
، نطبّق قناة async. ويكون الأمر نفسه داخل التوجيه *ngIf
، ولكن تجدر الإشارة إلى أنّنا نستخدم أيضًا التسلسل الاختياري (المعروف أيضًا باسم عامل التشغيل الآمن في Angular) عند الوصول إلى السمة length
لضمان عدم حدوث خطأ في وقت التشغيل إذا لم يكن todo | async
هو null
أو undefined
.
الآن، عند إنشاء مهمة جديدة في واجهة المستخدم وفتح Firestore، من المفترض أن يظهر لك ما يلي:
12. تحسين التعديلات المتفائلة
في التطبيق، نُجري حاليًا تعديلات متفائلة. لدينا مصدر بيانات أساسي في Firestore، ولكن في الوقت نفسه لدينا نُسخ محلية من المهام. وعندما يتم إصدار أي من العناصر القابلة للمراقبة المرتبطة بالمجموعات، نحصل على مصفوفة من المهام. عندما يؤدي إجراء المستخدم إلى تغيير الحالة، نعمل أولاً على تعديل القيم المحلية ثم ننقل التغيير إلى Firestore.
عند نقل مهمة من مسار إلى آخر، نستدعي transferArrayItem,
التي تعمل على النُسخ المحلية من المصفوفات التي تمثّل المهام في كل مسار. تعامِل حزمة تطوير البرامج (SDK) من Firebase مع هذه المصفوفات على أنّها غير قابلة للتغيير، ما يعني أنّه في المرة التالية التي يشغّل فيها Angular عملية رصد التغيير، سنحصل على مثيلات جديدة منها، ما سيؤدي إلى عرض الحالة السابقة قبل نقل المهمة.
في الوقت نفسه، نفعِّل تعديلاً على Firestore، وتفعِّل حزمة تطوير البرامج (SDK) من Firebase تعديلاً بالقيم الصحيحة، وبالتالي ستصل واجهة المستخدِم إلى حالتها الصحيحة في غضون بضع أجزاء من الثانية. سيؤدي ذلك إلى نقل المهمة التي نقلناها للتو من القائمة الأولى إلى القائمة التالية. يمكنك ملاحظة ذلك بوضوح في صورة 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
سيؤدي هذا الأمر إلى ما يلي:
- أنشئ تطبيقك باستخدام إعدادات الإصدار العلني، وطبِّق تحسينات وقت الترجمة البرمجية.
- انشر تطبيقك على "استضافة Firebase".
- إخراج عنوان URL حتى تتمكّن من معاينة النتيجة
14. تهانينا
تهانينا، لقد أنشأت لوحة كانبان بنجاح باستخدام Angular وFirebase.
أنشأت واجهة مستخدم تتضمّن ثلاثة أعمدة تمثّل حالة المهام المختلفة. باستخدام Angular CDK، نفّذت عملية سحب وإفلات المهام في الأعمدة. بعد ذلك، باستخدام Angular Material، أنشأت نموذجًا لإنشاء مهام جديدة وتعديل المهام الحالية. بعد ذلك، تعلّمت كيفية استخدام @angular/fire
ونقلت جميع حالات التطبيق إلى Firestore. أخيرًا، نشرت تطبيقك على Firebase Hosting.
ما هي الخطوات التالية؟
تذكَّر أنّنا نشرنا التطبيق باستخدام إعدادات الاختبار. قبل نشر تطبيقك في مرحلة الإنتاج، تأكَّد من إعداد الأذونات الصحيحة. يمكنك الاطّلاع على كيفية إجراء ذلك هنا.
في الوقت الحالي، لا نحافظ على ترتيب المهام الفردية في مسار سباحة معيّن. لتنفيذ ذلك، يمكنك استخدام حقل ترتيب في مستند المهمة والترتيب استنادًا إليه.
بالإضافة إلى ذلك، أنشأنا لوحة كانبان لمستخدم واحد فقط، ما يعني أنّ لدينا لوحة كانبان واحدة لأي شخص يفتح التطبيق. لتنفيذ لوحات منفصلة لمستخدمين مختلفين لتطبيقك، عليك تغيير بنية قاعدة البيانات. يمكنك الاطّلاع على أفضل الممارسات المتعلّقة بخدمة Firestore هنا.