اذهب إلى المحتوى

سنبني في هذا المقال لوحة كانبان kanban على الإنترنت باستخدام Angular ومنصة Firebase، حيث سيحتوي تطبيقنا النهائي على ثلاثة فئات من المهام وهي الأعمال المتأخرة والأعمال قيد الإنجاز والأعمال المنجزة، كما سنكون قادرين على إنشاء مهام وحذفها ونقلها من فئة إلى أخرى باستخدام عمليتي السحب والإفلات، وسنطوِّر واجهة المستخدِم باستخدام Angular وسنستخدِم قاعدة بيانات Firestore على أساس مخزن دائم لبيانات التطبيق، كما سننشر التطبيق في نهاية المقال على استضافة Firebase باستخدام واجهة سطر أوامر Angular CLI.

001.png

ستتعلم في هذا المقال ما يلي:

  • كيفية استخدام 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 لكي تشاهد صفحةً مشابهةً لهذه الصورة:

002.png

افتح ملف 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، فيجب عليك رؤية النتيجة التالية على الشاشة:

003.png

ليست نتيجةً سيئةً بما أنك لم تكتب سوى أربعة سطور من 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>

ستشاهد ما يلي عندما تفتح نافذة المتصفح:

004.png

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.

يجب أن تكون النتيجة كما يلي:

005.png

ستكون قادرًا على نقل العناصر بين قائمتين عندما تصل لهذه النقطة.

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":

006.png

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، وأخيرًا أجرينا بعض التعديلات في كيفية تصوّر المهام والقوائم الفارغة، ويجب عليك مشاهدة واجهة المستخدِم التالية عند إعادة تحميل تطبيقك:

007.png

لا يزال لدينا مشكلةً مزعجةً تحدث عندما ننقل المهام بين الحاويات على الرغم من تحسين تنسيقات تطبيقنا بصورة كبيرة:

008.png

نرى بطاقتين للمهمة نفسها -أي واحدة للمهمة التي نسحبها وأخرى للمهمة الموجودة في الحاوية نفسها- عندما نبدأ في سحب مهمة "شراء الحليب 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 ويسألك بعض الأسئلة، ويجب عليك رؤية مثل هذه الصورة في الطرفية الخاصة بك:

009.png

تفتح عملية التثبيت في غضون ذلك نافذة المتصفح حتى تتمكن من المصادقة باستخدام حساب Firebase الخاص بك، ويطلب منك أخيرًا اختيار مشروع Firebase وينشئ بعض الملفات على القرص الخاص بك، وبعد ذلك نحتاج إلى إنشاء قاعدة بيانات Firestore، إذ يمكنك فعل ذلك من خلال التوجه إلى "Cloud Firestore" ثم النقر فوق "Create Database".

010.png

أنشئ بعد ذلك قاعدة بيانات في وضع الاختبار:

011.png

أخيرًا، حدِّد المنطقة:

012.png

الشيء الأخير الذي يجب عليك فعله هو إضافة تهيئة Firebase إلى بيئتك، حيث يمكنك العثور على تهيئة المشروع الخاص بك في Firebase Console.

  • انقر على أيقونة الإعدادات بجوار نظرة عامة عن المشروع Project Overview.
  • اختر إعدادات المشروع Project Settings.

013.png

حدِّد "تطبيق ويب Web app" ضمن "تطبيقاتك Your apps":

014.png

سجّل تطبيقك بعد ذلك وتأكد من نفعيل "Firebase Hosting":

015.png

يمكنك نسخ التهيئة الخاصة بك إلى ملف src/environments/environment.ts بعد أن تنقر على "تسجيل التطبيق Register app":

016.png

في النهاية، يجب أن يبدو ملف التكوين الخاص بك كما يلي:

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:

017.png

10. تحسين عمليات التحديث بشكل أفضل

سنؤدي حاليًا عمليات التحديث في التطبيق الخاص بك بصورة أفضل، حيث يوجد لدينا مصدر خدمة في Firestore، ولكن في الوقت نفسه لدينا نسخًا محليةً من المهام، وبالتالي عندما تُصدر أي مرصودات مرتبطة بالتجميعات أيَّ تغيير، فسنحصل على مصفوفة من المهام، وعندما يُغير إجراء المستخدِم من الحالة، فسنحدِّث القيم المحلية أولًا ثم نرسِل عملية التغيير إلى Firestore؛ أما عندما ننقل مهمةً من حاوية إلى أخرى، فسنستدعي transferArrayItem التي تعمل على النسخ المحلية من المصفوفات والتي تمثِّل المهام الموجودة في كل حاوية.

يعامل Firebase SDK هذه المصفوفات على أنها غير قابلة للتغيير، مما يعني أنه سنحصل على نسخ جديدة منها أيضًا والتي ستعيد الحالة السابقة قبل نقل المهمة في المرة القادمة التي تُشغّل فيها Angular الكاشف عن حدوث تغييرات، والوقت نفسه نشغِّل التحديث في Firestore ويشغِّل Firebase SDK عملية التحديث بالقيم الصحيحة، وبالتالي ستُعرَض واجهة المستخدِم بالصورة الصحيحة، وبهذه الطريقة تُنقَل المهمة من القائمة الأولى إلى القائمة الثانية كما توضحه الصورة التالية:

018.gif

تختلف الطريقة الصحيحة لحل هذه المشكلة من تطبيق لآخر، ولكن نحتاج في جميع الحالات إلى الحفاظ على حالة ثابتة ريثما تُحدَّث البيانات، كما يمكننا الاستفادة من المزايا التي يقدمها 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 الرسمي.

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...