1. مقدمة
تاريخ آخر تعديل: 11-09-2020
العناصر التي سيتم إنشاؤها
في هذا الدرس التطبيقي حول الترميز، سننشئ لوحة كانبان على الويب باستخدام Angular وFirebase. سيحتوي تطبيقنا النهائي على ثلاث فئات للمهام: المهام المتأخرة قيد التقدم والمكتملة. سنتمكن من إنشاء المهام وحذفها ونقلها من فئة إلى أخرى باستخدام السحب والإفلات.
سنطوّر واجهة المستخدم باستخدام Angular ونستخدم Firestore كمتجرنا المستمر. في نهاية الدرس التطبيقي حول الترميز، سننشر التطبيق على "استضافة Firebase" باستخدام واجهة سطر الأوامر (GLI) المحلية.
ما ستتعرّف عليه
- كيفية استخدام مواد Angular وCDK
- كيفية إضافة دمج Firebase إلى تطبيق Angular
- كيفية الاحتفاظ ببياناتك الدائمة في Firestore
- كيفية نشر تطبيقك على "استضافة Firebase" باستخدام واجهة سطر أوامر Angular باستخدام أمر واحد.
المتطلبات اللازمة
يفترض هذا الدرس التطبيقي حول الترميز أن لديك حسابًا على Google وفهمًا أساسيًا لـ Angular وCligular 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's:
ng serve
افتح http://localhost:4200 ومن المفترض أن تظهر لك نتيجة مثل:
في المحرِّر، افتح src/app/app.component.html
واحذف محتواه بالكامل. عند الانتقال مرة أخرى إلى http://localhost:4200، ستظهر لك صفحة فارغة.
3- إضافة مواد وCDK
يأتي Angular مع تنفيذ مكوّنات واجهة المستخدم المتوافقة مع التصميم المتعدد الأبعاد كجزء من حزمة @angular/material
. إحدى تبعيات @angular/material
هي Components Kit أو CDK. يوفر CDK الوحدات الأساسية، مثل أدوات مساعدة a11y، والسحب والإفلات والتراكب. نوزّع CDK في حزمة @angular/cdk
.
لإضافة مواد إلى تطبيقك، يُرجى اتّباع الخطوات التالية:
ng add @angular/material
يطلب منك هذا الأمر اختيار مظهر، إذا كنت تريد استخدام أنماط طباعة المواد العامة، وإذا كنت تريد إعداد الصور المتحركة للمتصفِّح على مادة Angular. اختَر "Indigo/Pink" للحصول على النتيجة نفسها الواردة في هذا الدرس التطبيقي حول الإجابات، وأجِب باستخدام &"نعم" لآخر سؤالين.
يؤدي الأمر 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
:
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. التمثيل المرئي للمهام
كخطوة تالية، لنبدأ في إنشاء مكوّن يمكننا استخدامه لعرض المهام في لوحة Kankan.
انتقِل إلى الدليل src/app
ونفِّذ أمر سطر الأوامر التالي:
ng generate component task
سيؤدي هذا الأمر إلى إنشاء TaskComponent
وإضافة بيانه إلى AppModule
. داخل الدليل task
، أنشِئ ملفًا باسم task.ts
. سنستخدم هذا الملف لتحديد واجهة المهام في لوحة Kankan. ستحتوي كل مهمة على حقول اختيارية من أنواع 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-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
" ومادة أساسية بجانب التصنيف "&&;إضافة مهمة&; نحتاج إلى برنامج الضم الإضافي من أجل وضع الزر في أعلى قائمة ممرات السباحة التي سنضعها في وقت لاحق بجانب بعضنا البعض باستخدام جهاز المرن. بما أنّ هذا الزر يستخدم مكوّن زر المادة، علينا استيراد الوحدة المقابلة في 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
.
عندما ينقر المستخدم على الزر"حسنًا"، يتم تلقائيًا عرض النتيجة { 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
. بعد ذلك، باستخدام وضع "التصميم المرن"، نضع خطوط السباحة بجانب بعضها البعض، وأخيرًا نجري بعض التعديلات على كيفية تمثيل المهام والقوائم الفارغة.
بعد إعادة تحميل التطبيق، من المفترض أن تظهر لك واجهة المستخدم التالية:
على الرغم من إجراء تحسينات كبيرة على أنماط تطبيقنا، لا تزال لدينا مشكلة مزعجة عندما ننقل المهام إلى مكان آخر:
عندما نبدأ في سحب المهمة &&،;شراء الحليب&;;; علينا أن نلاحظ بطاقتين للمهمة نفسها: الأولى التي نسحبها والأخرى في ممر السباحة. تقدم لنا 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's ويسقطه وينسخه إلى الموضع الذي نسقط فيه العنصر الأصلي. ولضمان عدم ظهور هذا العنصر، اضبط خاصية التعتيم على فئة 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 Console.
- انقر على رمز الترس بجوار "نظرة عامة على المشروع".
- اختَر Project Project (إعدادات المشروع).
ضمن &علامة التبويب "تطبيقاتك"؛ اختَر &
بعد ذلك، سجِّل طلبك واحرِص على تفعيل &عرض استضافة 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
);
}
وفي المقتطف أعلاه، يتم تمييز الرمز الجديد. لنقل مهمة من ممر السباحة الحالي إلى الهدف المستهدف، ستتم إزالة المهمة من المجموعة الأولى وإضافتها إلى المجموعة الثانية. ولأنّنا نُجري عمليتَين نريد أن نشبههما (بعبارة أخرى، نجعل عمليّة Atom كاملة)، نُجريهما في معاملة على 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,
الذي يعمل على المثيلات المحلية للمصفوفات التي تمثل المهام في كل ممر سباحة. تتعامل حزمة تطوير البرامج (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.
تم إنشاء واجهة مستخدم تحتوي على ثلاثة أعمدة تمثّل حالة المهام المختلفة. وباستخدام CDK Angular، نفّذت سحب المهام وإفلاتها في الأعمدة. وبعد ذلك، باستخدام مواد Angular، يمكنك إنشاء نموذج لإنشاء مهام جديدة وتعديل المهام الحالية. بعد ذلك، تعلّمت كيفية استخدام @angular/fire
ونقلت كل حالة التطبيق إلى Firestore. وأخيرًا، نشرت طلبك إلى "استضافة Firebase".
الخطوات التالية
تذكّر أنّنا نشرنا التطبيق باستخدام إعدادات الاختبار. قبل نشر تطبيقك في مرحلة الإنتاج، تأكَّد من إعداد الأذونات الصحيحة. يمكنك التعرّف على كيفية إجراء ذلك هنا.
في الوقت الحالي، لا نحتفظ بترتيب المهام الفردية في ممر سباحة معين. لتنفيذ ذلك، يمكنك استخدام حقل الطلب في مستند المهمة والترتيب وفقًا له.
بالإضافة إلى ذلك، لقد أنشأنا لوحة kanan لشخص واحد فقط، ما يعني أن لدينا لوحة kanan واحدة لأي شخص يفتح التطبيق. لتنفيذ لوحات منفصلة لمستخدمين مختلفين في تطبيقك، ستحتاج إلى تغيير بنية قاعدة بياناتك. تعرّف على أفضل ممارسات Firestore'، هنا.