إنشاء تطبيق ويب باستخدام Angular وFirebase

1. مقدمة

تاريخ آخر تعديل: 11-09-2020

العناصر التي سيتم إنشاؤها

في هذا الدرس التطبيقي حول الترميز، سننشئ لوحة كانبان على الويب باستخدام Angular وFirebase. سيحتوي تطبيقنا النهائي على ثلاث فئات للمهام: المهام المتأخرة قيد التقدم والمكتملة. سنتمكن من إنشاء المهام وحذفها ونقلها من فئة إلى أخرى باستخدام السحب والإفلات.

سنطوّر واجهة المستخدم باستخدام Angular ونستخدم Firestore كمتجرنا المستمر. في نهاية الدرس التطبيقي حول الترميز، سننشر التطبيق على "استضافة Firebase" باستخدام واجهة سطر الأوامر (GLI) المحلية.

b23bd3732d0206b.png

ما ستتعرّف عليه

  • كيفية استخدام مواد 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 ومن المفترض أن تظهر لك نتيجة مثل:

5ede7bc5b1109bf3.png

في المحرِّر، افتح 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.

من المفترض أن يظهر لك ما يلي على الشاشة الآن:

a39cf8f8428a03bc.png

لا بأس باستخدام 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، ونرغب في أن يتمكن من إخراج مخرجات &quot؛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&#39؛ افتح 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>

عند فتح المتصفِّح، من المفترَض أن يظهر لك ما يلي:

d96fccd13c63ceb1.png

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&&quot؛ ويكون لكل ممر سباحة اسم فئة &;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، نتحقّق أولاً من أننا نستبعد في القائمة نفسها التي تأتي منها المهمة. وإذا كان الأمر كذلك، سنعود على الفور. بخلاف ذلك، يتم نقل المهمة الحالية إلى ممر السباحة الوجهة.

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

460f86bcd10454cf.png

في هذه المرحلة، من المفترض أن يكون بإمكانك نقل العناصر بين القائمتين.

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 { }

عند النقر على الزر "إضافة مهمة&;;الآن"، من المفترض أن تظهر لك واجهة المستخدم التالية:

33bcb987fade2a87.png

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

بعد إعادة تحميل التطبيق، من المفترض أن تظهر لك واجهة المستخدم التالية:

69225f0b1aa5cb50.png

على الرغم من إجراء تحسينات كبيرة على أنماط تطبيقنا، لا تزال لدينا مشكلة مزعجة عندما ننقل المهام إلى مكان آخر:

f9aae712027624af.png

عندما نبدأ في سحب المهمة &&،;شراء الحليب&;;; علينا أن نلاحظ بطاقتين للمهمة نفسها: الأولى التي نسحبها والأخرى في ممر السباحة. تقدم لنا 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.

10- إضافة Firebase إلى المشروع

في هذا القسم، سندمج مشروعنا مع Firebase. ويوفّر فريق Firebase الحزمة @angular/fire، التي توفّر إمكانية الدمج بين التقنيتين. لإضافة دعم Firebase إلى تطبيقك، افتح الدليل الجذري لمساحة العمل وشغِّل:

ng add @angular/fire

يؤدي هذا الأمر إلى تثبيت حزمة @angular/fire وطرح بعض الأسئلة عليك. في المحطّة الطرفية، من المفترض أن تظهر لك عناصر مثل:

9ba88c0d52d18d0.png

وفي هذه الأثناء، تفتح عملية التثبيت نافذة متصفح بحيث يمكنك المصادقة باستخدام حسابك على Firebase. وأخيرًا، سيُطلب منك اختيار مشروع Firebase وإنشاء بعض الملفات على القرص.

بعد ذلك، نحتاج إلى إنشاء قاعدة بيانات Firestore. ضمن "Cloud Firestore" انقر على &إنشاء قاعدة بيانات&&

1e4a08b5a2462956.png

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

ac1181b2c32049f9.png

أخيرًا، اختَر منطقة:

34bb94cc542a0597.png

ولم يتبقَّ سوى إضافة إعدادات Firebase إلى بيئتك. يمكنك العثور على إعدادات مشروعك في Firebase Console.

  • انقر على رمز الترس بجوار "نظرة عامة على المشروع".
  • اختَر Project Project (إعدادات المشروع).

c8253a20031de8a9.png

ضمن &علامة التبويب "تطبيقاتك"؛ اختَر &

428a1abcd0f90b23.png

بعد ذلك، سجِّل طلبك واحرِص على تفعيل &عرض استضافة Firebase:

586e44cb27dd8f39.png

بعد النقر على &&تسجيل التطبيق، يمكنك نسخ الضبط إلى src/environments/environment.ts:

e30f142d79cecf8f.png

في نهاية الأمر، يجب أن يظهر ملف الإعداد على النحو التالي:

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، من المفترض أن يظهر لك ما يلي:

dd7ee20c0a10ebe2.png

12- تحسين التحديثات الإيجابية

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

عند نقل مهمة من ممر سباحة إلى آخر، نستدعي transferArrayItem, الذي يعمل على المثيلات المحلية للمصفوفات التي تمثل المهام في كل ممر سباحة. تتعامل حزمة تطوير البرامج (SDK) لمنصة Firebase مع هذه المصفوفات على أنها غير قابلة للتغيير، ما يعني أنه في المرة التالية التي تُجري فيها Angular عملية رصد للتغيير، سنحصل على مثيلات جديدة منها، والتي ستعرض الحالة السابقة قبل أن ننقل المهمة.

وفي الوقت نفسه، نحن نُشغّل تحديث Firestore ونشغّل حزمة تطوير البرامج (SDK) لمنصّة Firebase تحديثًا بالقيم الصحيحة، ومن ثمّ خلال بضعة ثوانٍ، ستنتقل واجهة المستخدم إلى حالتها الصحيحة. ويؤدي ذلك إلى نقل المهمة التي أرسلناها للتو من القائمة الأولى إلى القائمة التالية. يمكنك رؤية ذلك جيدًا على ملف GIF أدناه:

70b946eebfa6f316.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

سيؤدي هذا الأمر إلى:

  1. يمكنك إنشاء تطبيقك باستخدام إعدادات الإنتاج وتطبيق تحسينات وقت التجميع.
  2. انشر تطبيقك على "استضافة Firebase".
  3. يمكنك إخراج عنوان URL حتى تتمكن من معاينة النتيجة.

14- تهانينا

تهانينا، لقد نجحت في إنشاء لوحة كانبان باستخدام Angular وFirebase.

تم إنشاء واجهة مستخدم تحتوي على ثلاثة أعمدة تمثّل حالة المهام المختلفة. وباستخدام CDK Angular، نفّذت سحب المهام وإفلاتها في الأعمدة. وبعد ذلك، باستخدام مواد Angular، يمكنك إنشاء نموذج لإنشاء مهام جديدة وتعديل المهام الحالية. بعد ذلك، تعلّمت كيفية استخدام @angular/fire ونقلت كل حالة التطبيق إلى Firestore. وأخيرًا، نشرت طلبك إلى "استضافة Firebase".

الخطوات التالية

تذكّر أنّنا نشرنا التطبيق باستخدام إعدادات الاختبار. قبل نشر تطبيقك في مرحلة الإنتاج، تأكَّد من إعداد الأذونات الصحيحة. يمكنك التعرّف على كيفية إجراء ذلك هنا.

في الوقت الحالي، لا نحتفظ بترتيب المهام الفردية في ممر سباحة معين. لتنفيذ ذلك، يمكنك استخدام حقل الطلب في مستند المهمة والترتيب وفقًا له.

بالإضافة إلى ذلك، لقد أنشأنا لوحة kanan لشخص واحد فقط، ما يعني أن لدينا لوحة kanan واحدة لأي شخص يفتح التطبيق. لتنفيذ لوحات منفصلة لمستخدمين مختلفين في تطبيقك، ستحتاج إلى تغيير بنية قاعدة بياناتك. تعرّف على أفضل ممارسات Firestore&#39، هنا.