سنبني في هذا المقال لوحة كانبان kanban على الإنترنت باستخدام Angular ومنصة Firebase، حيث سيحتوي تطبيقنا النهائي على ثلاثة فئات من المهام وهي الأعمال المتأخرة والأعمال قيد الإنجاز والأعمال المنجزة، كما سنكون قادرين على إنشاء مهام وحذفها ونقلها من فئة إلى أخرى باستخدام عمليتي السحب والإفلات، وسنطوِّر واجهة المستخدِم باستخدام Angular وسنستخدِم قاعدة بيانات Firestore على أساس مخزن دائم لبيانات التطبيق، كما سننشر التطبيق في نهاية المقال على استضافة Firebase باستخدام واجهة سطر أوامر Angular CLI.
ستتعلم في هذا المقال ما يلي:
- كيفية استخدام Angular material وعدة تطوير المكونات CDK.
- كيفية دمج Firebase مع تطبيق Angular الخاص بك.
- كيفية الحفاظ على بياناتك الدائمة في قاعدة بيانات Firestone.
- كيفية نشر تطبيقك إلى استضافة Firebase باستخدام Angular CLI وبأمر واحد فقط.
يفترض هذا المقال بأنه لديك حساب جوجل Google وأساسيات Angular، كما ننصحك بالرجوع إلى المقالين مقدمة في مفاهيم Angular وما هي Angular؟ للتعرُّف على مفاهيم Angular الأساسية.
1. إنشاء مشروع جديد
لننشئ مساحة عمل جديدة لإطار عمل Angular أولًا:
ng new kanban-fire ? Would you like to add Angular routing? No ? Which stylesheet format would you like to use? CSS
قد تستغرق هذه الخطوة بضع دقائق، حيث سينشئ Angular CLI بنية مشروعك ويُثبِّت جميع التبعيات المتعلقة به، كما عليك الانتقال إلى المجلد kanban-fire
وتشغيل خادم تطوير عبر واجهة سطر الأوامر Angular CLI عندما تكتمل عملية التثبيت بتنفيذ الأمر التالي:
ng serve
افتح العنوان http://localhost:4200 لكي تشاهد صفحةً مشابهةً لهذه الصورة:
افتح ملف src/app/app.component.html
في المحرر الخاص بك واحذف كامل محتواه، إذ ستشاهد صفحة فارغة فقط عندما تعود مرةً ثانيةً إلى العنوان http://localhost:4200.
اقتباستوضيح: يمكنك العثور على ملفات المشروع من مستودع codelab-kanban-fire على GitHub.
2. إضافة مكتبة Material وعدة CDK
تتضمن Angular افتراضيًا مكونات واجهة المستخدِم المتوافقة مع مكتبة Material Design على أساس جزء من حزمة @angular/material
، حيث تكون إحدى التبعيات الخاصة بحزمة @angular/material
هي مجموعة تطوير المكونات Component Development Kit -أو CDK اختصارًا-، إذ تُوفِّر CDK الأوليات مثل أدوات a11y المساعدة والسحب والإفلات والتراكب، كما يمكننا الوصول إلى CDK عن طريق حزمة @angular/cdk
.
نفِّذ الأمر التالي لإضافة مكتبة Material Design إلى التطبيق الخاص بك:
ng add @angular/material
يطلب منك الأمر السابق اختيار سمة لتطبيقك في حال كنت ترغب في استخدام تنسيقات الطباعة للمواد العالمية وإعداد تحريكات المتصفح للمواد Angular، وبعدها اختر "Indigo/Pink" للحصول على النتيجة نفسها الموجودة ضمن المقال، وأجب "نعم Yes" من أجل السؤالين الأخيرين، كما يثبِّت الأمر 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 { }
نحتاج إلى استيراد الوحدات المقابلة لها في ملف AppModule
نظرًا لأننا استخدمنا شريط أدوات وأيقونة عن طريق Angular material، فيجب عليك رؤية النتيجة التالية على الشاشة:
ليست نتيجةً سيئةً بما أنك لم تكتب سوى أربعة سطور من HTML واستوردت مرتين فقط.
3. عرض المهام
سننشئ الآن مكونًا نستطيع استخدامه لتمثيل المهام وعرضها في لوحة kanban، لذا انتقل إلى مجلد src/app
ونفِّذ أمر CLI التالي:
ng generate component task
ينشئ هذا الأمر TaskComponent
ويضيف التعريف الخاص به إلى ملف AppModule
، وبعدها أنشئ ملف يسمى task.ts
ضمن دليل task
، حيث سنستخدم هذا الملف لتعريف واجهة المهام في لوحة kanban، حيث سيكون لكل مهمة ثلاثة حقول اختيارية هي 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
عن طريق استبدال شيفرة HTML التالية بمحتوى الملف task.component.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
، لذا عرِّف مصفوفةً باسم todo
في AppComponent
وأضف مهمتين داخلها:
- الملف 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>
ستشاهد ما يلي عندما تفتح نافذة المتصفح:
4. تضمين السحب والإفلات للمهام
نحن جاهزون الآن لتنفيذ الجزء الممتع من التطبيق، لذا دعنا ننشئ ثلاث حاويات من أجل الحالات الثلاث المختلفة للمهام وضمّن وظيفة السحب والإفلات في التطبيق باستخدام Angular CDK.
احذف المكوِّن app-task
مع الموجِّه *ngFor
الخاص به في أعلى الملف app.component.html واستبدل الشيفرة التالية به:
-
الملف
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
: هو مرجع إلىcdkDropLists
الأخرى والتي يتصل بهاcdkDropList
الحالي، حيث سنحدد أي القوائم الأخرى التي يمكننا إسقاط العناصر ضمنها من خلال تعيين هذا الإدخال.
نريد بالإضافة إلى ذلك التعامل مع حدث الإفلات عن طريق استخدام خرج cdkDropListDropped
، حيث سنستدعي تابع drop
المعرَّف ضمن AppComponent
كما نمرر له الحدث الحالي على أساس وسيط بمجرد أن يُصدر cdkDropList
الخرج، ولاحظ أنه استخدمنا 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
من حالة إفلات المهمة في القائمة نفسها التي كانت موجودةً فيها أصلًا وفي هذه الحالة لا نفعل أي شيء؛ أما في حال كان الإفلات في قائمة جديدة، فسننقل المهمة الحالية إلى الحاوية الوجهة.
اقتباستنبيه: لا نعالج هنا حالة إعادة ترتيب المهام في الحاوية نفسها، حيث سنحذف هذه الوظيفة من المقال لتبسيط العملية، ولكن لا تتردد في تنفيذها بنفسك بالاستعانة بتوثيق CDK.
يجب أن تكون النتيجة كما يلي:
ستكون قادرًا على نقل العناصر بين قائمتين عندما تصل لهذه النقطة.
5. إنشاء مهام جديدة
يجب علينا تحديث قالب 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 بجانب عنوان "إضافة مهمة Add Task"، حيث نحتاج إلى مغلِّف إضافي لوضع الزر في أعلى القائمة الخاصة بكل حاوية والتي سنضعها لاحقًا بجانب بعضها بعضًا باستخدام flexbox، كما نحتاج إلى استيراد الوحدة المرتبطة به ضمن ملف AppModule
لأن هذا الزر يستخدم مكوِّن الزر من نوع Material:
- الملف src/app/app.module.ts:
... import { MatButtonModule } from '@angular/material/button'; @NgModule({ declarations: [ AppComponent ], imports: [ ... MatButtonModule ], providers: [], bootstrap: [AppComponent] }) export class AppModule { }
سنضمِّن الوظيفة المسؤولة عن إضافة المهام في AppComponent
عن طريق استخدام مربع حوار من نوع Material، حيث سيكون لدينا ضمن مربع الحوار استمارةً بحقلين هما العنوان والوصف، وبالتالي عندما ينقر المستخدِم على زر "إضافة مهمة Add Task"، فسنفتح له مربع الحوار، وعندما يرسل المستخدِم هذه الاستمارة، فسنضف مهمةً جديدةً إلى قائمة todo
، كما سنلقِ نظرةً على التضمين عالي المستوى لهذه الوظيفة في AppComponent
:
... 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) => this.todo.push(result.task)); } }
عرَّفنا بانيًا للمكوِّن في الشفرة السابقة وحقنّا صنف MatDialog
ضمنه، وسنفعل ضمن دالة newTask
ما يلي:
-
نفتح مربع حوار جديد باستخدام
TaskDialogComponent
الذي سنعرِّفه لاحقًا. -
نحدِّد عرض مربع الحوار ليكون
270px
. -
نمرِّر مهمةً فارغةً إلى مربع الحوار على أساس بيانات، حيث سنكون قادرين على الحصول على مرجع إلى الكائن الحاوي لهذه البيانات في
TaskDialogComponent
. -
نشترك بحدث الإغلاق
close
ونضيف المهمة من الكائن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
في 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:
... 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 { }
اقتباستنبيه: تأكد أيضًا من إضافة الاستيرادات الخاصة بكل من
TaskDialogComponent
وTaskDialogResult
في ملفapp.component.ts
.
ستشاهد الآن واجهة المستخدِم التالية عندما تنقر على زر "إضافة مهمة Add Task":
6. تحسين تنسيقات التطبيق
سنحسِّن التخطيط الخاص بالتطبيق بحيث يتحسّن مظهر التطبيق وذلك عن طريق تغيير وتبديل تنسيقاته بصورة بسيطة، حيث ستضع الحاويات بجانب بعضها البعض كما سنصيف بعض التعديلات الطفيفة على زر "إضافة مهمة Add Task" وعنوان القائمة فارغة، لذا افتح ملف 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، وأخيرًا أجرينا بعض التعديلات في كيفية تصوّر المهام والقوائم الفارغة، ويجب عليك مشاهدة واجهة المستخدِم التالية عند إعادة تحميل تطبيقك:
لا يزال لدينا مشكلةً مزعجةً تحدث عندما ننقل المهام بين الحاويات على الرغم من تحسين تنسيقات تطبيقنا بصورة كبيرة:
نرى بطاقتين للمهمة نفسها -أي واحدة للمهمة التي نسحبها وأخرى للمهمة الموجودة في الحاوية نفسها- عندما نبدأ في سحب مهمة "شراء الحليب Buy milk"، في حين توفِّر لنا عدة التطوير 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 ويدرجه في الموضع الذي سنُفلِت الأصل فيه، كما عيَّنا خاصية الشفافية في صنف 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; }
7. تحرير وحذف المهام الحالية
سنعيد استخدام معظم الوظائف التي ضمّنَّاها مسبقًا لتحرير المهام الحالية وإزالتها، فعندما ينقر المستخدِم نقرًا مزدوجًا على مهمة ما، فسنفتح 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>
يعرض هذا الزر أيقونة الحذف المنسقة بتنسيق مكتبة Material الافتراضي، فعندما ينقر المستخدِم عليه، فسنغلق مربع الحوار ونمرِّر الكائن الآتي:
{ 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) => { 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
-أي عندما يضغط المستخدِم على زر الحذف- فسنزيل المهمة من القائمة الموجودة ضمنها. - وإلا فسنستبدل فقط المهمة ذات الدليل المعطى بالمهمة التي حصلنا عليها من نتيجة مربع الحوار.
8. إنشاء مشروع Firebase وربطه بالمشروع
- لتنشأ مشروع Firebase جديد، انتقل إلى Firebase Console.
- أنشئ مشروعًا جديدًا باسم "KanbanFire".
سنربط الآن مشروعنا مع Firebase، حيث يقدِّم فريق Firebase حزمة @angular/fire
والتي توفر عملية التكامل بين التقنيتين، ولإضافة دعم Firebase إلى تطبيقك افتح الدليل الجذر لمساحة العمل الخاصة بتطبيقك ونفِّذ الأمر التالي:
ng add @angular/fire
يثبِّت هذا الأمر حزمة @angular/fire
ويسألك بعض الأسئلة، ويجب عليك رؤية مثل هذه الصورة في الطرفية الخاصة بك:
تفتح عملية التثبيت في غضون ذلك نافذة المتصفح حتى تتمكن من المصادقة باستخدام حساب Firebase الخاص بك، ويطلب منك أخيرًا اختيار مشروع Firebase وينشئ بعض الملفات على القرص الخاص بك، وبعد ذلك نحتاج إلى إنشاء قاعدة بيانات Firestore، إذ يمكنك فعل ذلك من خلال التوجه إلى "Cloud Firestore" ثم النقر فوق "Create Database".
أنشئ بعد ذلك قاعدة بيانات في وضع الاختبار:
أخيرًا، حدِّد المنطقة:
الشيء الأخير الذي يجب عليك فعله هو إضافة تهيئة Firebase إلى بيئتك، حيث يمكنك العثور على تهيئة المشروع الخاص بك في Firebase Console.
- انقر على أيقونة الإعدادات بجوار نظرة عامة عن المشروع Project Overview.
- اختر إعدادات المشروع Project Settings.
حدِّد "تطبيق ويب Web app" ضمن "تطبيقاتك Your apps":
سجّل تطبيقك بعد ذلك وتأكد من نفعيل "Firebase Hosting":
يمكنك نسخ التهيئة الخاصة بك إلى ملف src/environments/environment.ts
بعد أن تنقر على "تسجيل التطبيق Register app":
في النهاية، يجب أن يبدو ملف التكوين الخاص بك كما يلي:
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>' } };
9. نقل البيانات إلى Firestore
استخدم الآن @angular/fire
لنقل بياناتك إلى Firestore بعد أن أعددت Firebase SDK، لذا سنستورد أولًا الوحدات التي سنحتاجها في ملف AppModule
:
... 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 {}
يجب علينا حقن AngularFirestore
في باني AppComponent
بما أن التطبيق الخاص بنا يستخدِم Firestone:
- الملف 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
يُعيد observables
عوضًا عن مصفوفة، ولاحظ أيضًا أننا حدَّدنا أن حقل id
للمستندات في هذه التجميعة يجب أن يُسمى أيضًا id
ليطابق الاسم الذي استخدمناه في الواجهة Task
؛ أما observables
التي أُعيدت عن طريق valueChanges
فستُصدر تجميعةً من المهام في أي وقت تتغير فيه.
نظرًا لأننا نتعامل مع المرصودات observables بدلًا من المصفوفات فأنت بحاجة إلى تحديث طريقة إضافة المهام وإزالتها وتعديلها، وأيضًا تحديث الوظيفة المسؤولة عن نقل المهام بين الحاويات وعوضًا عن تغيير المصفوفات في الذاكرة ستستخدم Firebase SDK لتحديث البيانات في قاعدة البيانات، وسنرى كيف ستبدو الشيفرة بعد إعادة ترتيبها، لذا استبدل التابع drop
الموجود في ملف src/app/app.component.ts
بدايةً:
- الملف src/app/app.component.ts:
drop(event: CdkDragDrop<Task[]>): void { if (event.previousContainer === event.container) { return; } const item = event.previousContainer.data[event.previousIndex]; this.store.firestore.runTransaction(() => { const promise = Promise.all([ this.store.collection(event.previousContainer.id).doc(item.id).delete(), this.store.collection(event.container.id).add(item), ]); return promise; }); transferArrayItem( event.previousContainer.data, event.container.data, event.previousIndex, event.currentIndex ); }
سنزيل المهمة من التجميعة الأولى ونضيفها إلى التجميعة الثانية وذلك من أجل نقل مهمة من الحاوية الحالية إلى الحاوية الهدف، وبما أننا نؤدي هاتين العمليتين ونريد أن تظهرا على أساس عملية واحدة -أي جعل العملية ذَرية-، فسننفِّذها عن طريق معاملة Firestore، كما سنحدِّث تابع editTask
حتى نتمكن من استخدام Firestore
، لذا سنحتاج إلى تغيير الأسطر التالية من الشيفرة بداخل المعالج الخاص بمربع الإغلاق::
- الملف src/app/app.component.ts:
... dialogRef.afterClosed().subscribe((result: TaskDialogResult) => { if (result.delete) { this.store.collection(list).doc(task.id).delete(); } else { this.store.collection(list).doc(task.id).update(task); } }); ...
نستطيع الوصول إلى المستند المستهدَف والمقابل للمهمة التي نريد التعديل عليها باستخدام Firestore SDK ويمكنك حذفه أو تحديثه، ونحتاج أخيرًا إلى تحديث التابع المسؤول عن إنشاء مهام جديدة من خلال استبدال السطر this.todo.push('task')
بالسطر this.store.collection('todo').add(result.task)
.
لاحظ أن التجميعات الآن ليست مصفوفات وإنما مرصودات observables، ونحتاج إلى تحديث قالب AppComponent
فقط لكي نتمكَّن من تصوّرهم عن طريق استبدال كل وصول إلى خصائص todo
وinProgress
وdone
بالخصائص التالية todo | async
وinProgress | async
وdone | async
على التتالي، كما يشترك أنبوب async تلقائيًا في المرصودات observables المرتبطة بالتجميعات، وتُشغِّل 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>
نطبِّق أنبوب async عندما نمرِّر البيانات إلى الموجّه cdkDropList
، حيث تفعل الشيء ذاته أيضًا داخل الموجِّه *ngIf
ولكن لاحظ أننا نستخدِم أيضًا تسلسلًا اختياريًا -والذي يُعرّف أيضًا باسم معامِل التنقل الآمن في Angular- عندما نحاول الوصول إلى خاصية length
لضمان عدم حصولنا على خطأ وقت التشغيل runtime error في حال لم تكن قيمة todo | async
إما null
أو undefined
، والآن يجب أن تشاهد شيئًا يشبه الصورة التالية عندما تنشئ مهمةً جديدةً في واجهة المستخدِم وتفتح Firestore:
10. تحسين عمليات التحديث بشكل أفضل
سنؤدي حاليًا عمليات التحديث في التطبيق الخاص بك بصورة أفضل، حيث يوجد لدينا مصدر خدمة في Firestore، ولكن في الوقت نفسه لدينا نسخًا محليةً من المهام، وبالتالي عندما تُصدر أي مرصودات مرتبطة بالتجميعات أيَّ تغيير، فسنحصل على مصفوفة من المهام، وعندما يُغير إجراء المستخدِم من الحالة، فسنحدِّث القيم المحلية أولًا ثم نرسِل عملية التغيير إلى Firestore؛ أما عندما ننقل مهمةً من حاوية إلى أخرى، فسنستدعي transferArrayItem
التي تعمل على النسخ المحلية من المصفوفات والتي تمثِّل المهام الموجودة في كل حاوية.
يعامل Firebase SDK هذه المصفوفات على أنها غير قابلة للتغيير، مما يعني أنه سنحصل على نسخ جديدة منها أيضًا والتي ستعيد الحالة السابقة قبل نقل المهمة في المرة القادمة التي تُشغّل فيها Angular الكاشف عن حدوث تغييرات، والوقت نفسه نشغِّل التحديث في Firestore ويشغِّل Firebase SDK عملية التحديث بالقيم الصحيحة، وبالتالي ستُعرَض واجهة المستخدِم بالصورة الصحيحة، وبهذه الطريقة تُنقَل المهمة من القائمة الأولى إلى القائمة الثانية كما توضحه الصورة التالية:
تختلف الطريقة الصحيحة لحل هذه المشكلة من تطبيق لآخر، ولكن نحتاج في جميع الحالات إلى الحفاظ على حالة ثابتة ريثما تُحدَّث البيانات، كما يمكننا الاستفادة من المزايا التي يقدمها BehaviorSubject
والذي يغلِّف observer الأصلي الذي نتلقاه من 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
يُصدر قيمةً في كل مرة تتغير قيمة observable المرتبطة مع التجميعة، فالآن أصبح كل شيء يعمل كما يجب لأن BehaviorSubject
يعيد استخدام المصفوفة خلال استدعاءات الكشف عن حدوث تغييرات ويحدِّث فقط عندما نحصل على قيمة جديدة من Firestore.
11. نشر التطبيق
كل ما علينا فعله لنشر التطبيق الخاص بنا هو تنفيذ الأمر التالي:
ng deploy
اقتباسملاحظة: يجب عليك توفير تكوين Firebase في بيئة الإنتاج الخاصة بك وذلك ضمن ملف
src/environment/environment.prod.ts
.
سوف يفعل هذا الأمر ما يلي:
- يبني التطبيق الخاص بك باستخدام تكوين الإنتاج وبتطبيق التحسينات خلال عملية الترجمة.
- ينشر التطبيق الخاص بك إلى استضافة Firebase.
- يعطيك عنوان URL حتى تتمكن من معاينة النتيجة النهائية.
نهاية المشروع
تهانينا لقد نجحت في بناء لوحة kanban باستخدام Angular وFirebase.
لقد أنشأت واجهة مستخدِم بثلاثة أعمدة تمثِّل حالات المهام المختلفة، كما نفَّذت عمليتي السحب والإفلات للمهام بين الأعمدة باستخدام Angular CDK، ثمَّ بنيت استمارةً لإنشاء مهام جديدة وحرَّرت المهام الموجودة باستخدام Angular Material، وبعد ذلك تعلّمت كيفية استخدام @angular/fire
ونقلت حالة التطبيق الخاص بك جميعها إلى Firestore، وأخيرًا نشرت تطبيقك إلى استضافة Firebase.
تذكَّر أنك نشرت التطبيق باستخدام تكوينات الاختبار ولذلك تأكد من ضبط السماحيات الصحيحة قبل نشر التطبيق الخاص بك للإنتاج، حيث يمكنك معرفة كيفية القيام بذلك من هنا، كما أنك لم تأخذ بالحسبان ترتيب المهام الفردية في الحاوية نفسها في التطبيق الذي أنشأته، حيث يمكنك استخدام حقل مخصص للترتيب في المستند الخاص بالمهمة لإضافة ذلك وترتيب المهام بناءً عليه، وبالإضافة إلى ذلك بنيت لوحة kanban من أجل مستخدِم واحد فقط، مما يعني أنه لدينا لوحة kanban واحدةً لأي شخص يستخدِم التطبيق، كما ستحتاج إلى تغيير هيكل قاعدة البيانات الخاصة بك من أجل تضمين لوحات منفصلة لمستخدِمِين آخرِين.
ترجمة -وبتصرف- للمقال Building a web application with Angular and Firebase من موقع developers.google.com الرسمي.
أفضل التعليقات
لا توجد أية تعليقات بعد
انضم إلى النقاش
يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.