פיתוח אפליקציית אינטרנט באמצעות Angular ו-Firebase

1. מבוא

העדכון האחרון: 11 בספטמבר 2020

מה תפַתחו

ב-codelab הזה נבנה לוח קנבן באינטרנט באמצעות Angular ו-Firebase. באפליקציה הסופית יהיו שלוש קטגוריות של משימות: backlog,‏ in progress ו-completed. נוכל ליצור ולמחוק משימות, ולהעביר אותן מקטגוריה אחת לאחרת באמצעות גרירה ושחרור.

נפתח את ממשק המשתמש באמצעות Angular ונשתמש ב-Firestore כמאגר נתונים קבוע. בסוף ה-codelab נבצע פריסה של האפליקציה ל-Firebase Hosting באמצעות Angular CLI.

b23bd3732d0206b.png

מה תלמדו

  • איך משתמשים ב-Angular Material וב-CDK.
  • איך מוסיפים שילוב של Firebase לאפליקציית Angular.
  • איך שומרים את הנתונים הקבועים ב-Firestore.
  • איך פורסים את האפליקציה ב-Firebase Hosting באמצעות Angular CLI עם פקודה אחת.

מה צריך להכין

ב-codelab הזה אנחנו מניחים שיש לכם חשבון Google והבנה בסיסית של Angular ושל Angular CLI.

קדימה, מתחילים!

2. יצירת פרויקט חדש

קודם כל, ניצור סביבת עבודה חדשה של Angular:

ng new kanban-fire
? Would you like to add Angular routing? No
? Which stylesheet format would you like to use? CSS

השלב הזה עשוי להימשך כמה דקות. ‫Angular CLI יוצר את מבנה הפרויקט ומתקין את כל יחסי התלות. בסיום תהליך ההתקנה, עוברים לספרייה kanban-fire ומפעילים את שרת הפיתוח של Angular CLI:

ng serve

פותחים את הכתובת http://localhost:4200 ורואים פלט שדומה לזה:

5ede7bc5b1109bf3.png

בעורך, פותחים את src/app/app.component.html ומוחקים את כל התוכן שלו. כשחוזרים אל http://localhost:4200 אמור להופיע דף ריק.

3. הוספת Material ו-CDK

‫Angular כולל הטמעה של רכיבי ממשק משתמש שתואמים ל-Material Design כחלק מחבילת @angular/material. אחד מהגורמים שתלויים ב-@angular/material הוא ערכת פיתוח הרכיבים, או CDK. ה-CDK מספק פרימיטיבים, כמו כלי נגישות, גרירה ושחרור ושכבת-על. אנחנו מפיצים את ה-CDK בחבילה @angular/cdk.

כדי להוסיף חומרים להרצת האפליקציה:

ng add @angular/material

הפקודה הזו מבקשת מכם לבחור ערכת נושא, אם אתם רוצים להשתמש בסגנונות הטיפוגרפיה הגלובליים של Material, ואם אתם רוצים להגדיר את האנימציות של הדפדפן עבור Angular Material. בוחרים באפשרות 'אינדיגו/ורוד' כדי לקבל את אותה תוצאה כמו ב-codelab הזה, ועונים 'כן' על שתי השאלות האחרונות.

הפקודה ng add מתקינה את @angular/material, את יחסי התלות שלו ומייבאת את BrowserAnimationsModule ב-AppModule. בשלב הבא, אפשר להתחיל להשתמש ברכיבים שהמודול הזה מציע.

קודם כל, נוסיף סרגל כלים וסמל ל-AppComponent. פותחים את app.component.html ומוסיפים את תג העיצוב הבא:

src/app/app.component.html

<mat-toolbar color="primary">
  <mat-icon>local_fire_department</mat-icon>
  <span>Kanban Fire</span>
</mat-toolbar>

כאן אנחנו מוסיפים סרגל כלים באמצעות הצבע הראשי של עיצוב Material Design שלנו, ובתוכו אנחנו משתמשים בסמל local_fire_depeartment לצד התווית Kanban Fire. אם תבדקו את המסוף עכשיו, תראו ש-Angular מציג כמה שגיאות. כדי לפתור את הבעיות, צריך להוסיף את ההצהרות הבאות ל-AppModule:

src/app/app.module.ts

...
import { MatToolbarModule } from '@angular/material/toolbar';
import { MatIconModule } from '@angular/material/icon';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatToolbarModule,
    MatIconModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

מכיוון שאנחנו משתמשים בסרגל הכלים ובסמל של Angular Material, אנחנו צריכים לייבא את המודולים המתאימים ב-AppModule.

במסך אמורים להופיע עכשיו הנתונים הבאים:

a39cf8f8428a03bc.png

לא רע בשביל 4 שורות של HTML ושני ייבואים!

4. הדמיה של משימות

בשלב הבא, ניצור רכיב שבו נוכל להשתמש כדי להציג את המשימות בלוח קנבן.

עוברים לספרייה src/app ומריצים את פקודת ה-CLI הבאה:

ng generate component task

הפקודה הזו יוצרת את TaskComponent ומוסיפה את ההצהרה שלו אל AppModule. בתוך הספרייה task, יוצרים קובץ בשם task.ts. נשתמש בקובץ הזה כדי להגדיר את הממשק של המשימות בלוח קנבן. לכל משימה יהיו שדות אופציונליים id, title ו-description, כולם מסוג מחרוזת:

src/app/task/task.ts

export interface Task {
  id?: string;
  title: string;
  description: string;
}

עכשיו נעדכן את task.component.ts. אנחנו רוצים ש-TaskComponent יקבל כקלט אובייקט מסוג Task, ושהוא יוכל להפיק את הפלטים edit:

src/app/task/task.component.ts

import { Component, Input, Output, EventEmitter } from '@angular/core';
import { Task } from './task';

@Component({
  selector: 'app-task',
  templateUrl: './task.component.html',
  styleUrls: ['./task.component.css']
})
export class TaskComponent {
  @Input() task: Task | null = null;
  @Output() edit = new EventEmitter<Task>();
}

עריכת התבנית של TaskComponent פותחים את task.component.html ומחליפים את התוכן שלו ב-HTML הבא:

src/app/task/task.component.html

<mat-card class="item" *ngIf="task" (dblclick)="edit.emit(task)">
  <h2>{{ task.title }}</h2>
  <p>
    {{ task.description }}
  </p>
</mat-card>

שימו לב שעכשיו אנחנו מקבלים שגיאות במסוף:

'mat-card' is not a known element:
1. If 'mat-card' is an Angular component, then verify that it is part of this module.
2. If 'mat-card' is a Web Component then add 'CUSTOM_ELEMENTS_SCHEMA' to the '@NgModule.schemas' of this component to suppress this message.ng

בתבנית שלמעלה אנחנו משתמשים ברכיב mat-card מ-@angular/material, אבל לא ייבאנו את המודול התואם שלו באפליקציה. כדי לתקן את השגיאה שמופיעה למעלה, אנחנו צריכים לייבא את MatCardModule ב-AppModule:

src/app/app.module.ts

...
import { MatCardModule } from '@angular/material/card';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatCardModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

בשלב הבא ניצור כמה משימות ב-AppComponent ונמחיש אותן באמצעות TaskComponent.

ב-AppComponent מגדירים מערך בשם todo ובתוכו מוסיפים שתי משימות:

src/app/app.component.ts

...
import { Task } from './task/task';

@Component(...)
export class AppComponent {
  todo: Task[] = [
    {
      title: 'Buy milk',
      description: 'Go to the store and buy milk'
    },
    {
      title: 'Create a Kanban app',
      description: 'Using Firebase and Angular create a Kanban app!'
    }
  ];
}

עכשיו, בתחתית של 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="content-wrapper">
  <div class="container-wrapper">
    <div class="container">
      <h2>Backlog</h2>

      <mat-card
        cdkDropList
        id="todo"
        #todoList="cdkDropList"
        [cdkDropListData]="todo"
        [cdkDropListConnectedTo]="[doneList, inProgressList]"
        (cdkDropListDropped)="drop($event)"
        class="list">
        <p class="empty-label" *ngIf="todo.length === 0">Empty list</p>
        <app-task (edit)="editTask('todo', $event)" *ngFor="let task of todo" cdkDrag [task]="task"></app-task>
      </mat-card>
    </div>

    <div class="container">
      <h2>In progress</h2>

      <mat-card
        cdkDropList
        id="inProgress"
        #inProgressList="cdkDropList"
        [cdkDropListData]="inProgress"
        [cdkDropListConnectedTo]="[todoList, doneList]"
        (cdkDropListDropped)="drop($event)"
        class="list">
        <p class="empty-label" *ngIf="inProgress.length === 0">Empty list</p>
        <app-task (edit)="editTask('inProgress', $event)" *ngFor="let task of inProgress" cdkDrag [task]="task"></app-task>
      </mat-card>
    </div>

    <div class="container">
      <h2>Done</h2>

      <mat-card
        cdkDropList
        id="done"
        #doneList="cdkDropList"
        [cdkDropListData]="done"
        [cdkDropListConnectedTo]="[todoList, inProgressList]"
        (cdkDropListDropped)="drop($event)"
        class="list">
        <p class="empty-label" *ngIf="done.length === 0">Empty list</p>
        <app-task (edit)="editTask('done', $event)" *ngFor="let task of done" cdkDrag [task]="task"></app-task>
      </mat-card>
    </div>
  </div>
</div>

יש פה הרבה דברים. בואו נבחן את החלקים השונים של קטע הקוד הזה, שלב אחר שלב. זהו המבנה ברמה העליונה של התבנית:

src/app/app.component.html

...
<div class="container-wrapper">
  <div class="container">
    <h2>Backlog</h2>
    ...
  </div>

  <div class="container">
    <h2>In progress</h2>
    ...
  </div>

  <div class="container">
    <h2>Done</h2>
    ...
  </div>
</div>

בדוגמה הזו יצרנו תג div שעוטף את כל שלושת נתיבי הפעילות, עם שם המחלקה container-wrapper. לכל נתיב פעילות יש שם מחלקה container וכותרת בתוך תג h2.

עכשיו נבחן את המבנה של נתיב התקדמות העבודה הראשון:

src/app/app.component.html

...
    <div class="container">
      <h2>Backlog</h2>

      <mat-card
        cdkDropList
        id="todo"
        #todoList="cdkDropList"
        [cdkDropListData]="todo"
        [cdkDropListConnectedTo]="[doneList, inProgressList]"
        (cdkDropListDropped)="drop($event)"
        class="list"
      >
        <p class="empty-label" *ngIf="todo.length === 0">Empty list</p>
        <app-task (edit)="editTask('todo', $event)" *ngFor="let task of todo" cdkDrag [task]="task"></app-task>
      </mat-card>
    </div>
...

קודם מגדירים את ה-swimlane כ-mat-card באמצעות ההנחיה cdkDropList. אנחנו משתמשים ב-mat-card בגלל הסגנונות שהרכיב הזה מספק. בהמשך נוכל להשתמש ב-cdkDropList כדי להוסיף משימות לאלמנט. הגדרנו גם את שני הקלטים הבאים:

  • cdkDropListData – קלט של הרשימה הנפתחת שמאפשר לנו לציין את מערך הנתונים
  • cdkDropListConnectedTo – הפניות אל cdkDropList האחרים שאליהם מחובר cdkDropList הנוכחי. הגדרת הקלט הזה מאפשרת לנו לציין לאילו רשימות אחרות אפשר להעביר פריטים

בנוסף, אנחנו רוצים לטפל באירוע השחרור באמצעות הפלט cdkDropListDropped. אחרי ש-cdkDropList יפיק את הפלט הזה, נפעיל את השיטה drop שהוגדרה בתוך AppComponent ונעביר את האירוע הנוכחי כארגומנט.

שימו לב שציינו גם id לשימוש כמזהה של הקונטיינר הזה, ושם class כדי שנוכל להגדיר לו סגנון. עכשיו נבדוק את התוכן של צאצאי mat-card. שני הרכיבים שמופיעים שם הם:

  • פסקה שבה אנחנו משתמשים כדי להציג את הטקסט 'רשימה ריקה' כשאין פריטים ברשימה todo
  • הרכיב app-task. שימו לב שכאן אנחנו מטפלים בפלט edit שהצהרנו עליו במקור על ידי קריאה לשיטה editTask עם שם הרשימה והאובייקט $event. כך נוכל להחליף את המשימה הערוכה ברשימה הנכונה. לאחר מכן, חוזרים על הפעולה ברשימה todo כמו בדוגמה שלמעלה ומעבירים את הקלט task. אבל הפעם אנחנו מוסיפים גם את ההוראה cdkDrag. הוא מאפשר לגרור את המשימות הבודדות.

כדי שכל זה יפעל, אנחנו צריכים לעדכן את app.module.ts ולכלול ייבוא אל DragDropModule:

src/app/app.module.ts

...
import { DragDropModule } from '@angular/cdk/drag-drop';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    DragDropModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

צריך גם להצהיר על המערכים inProgress ו-done, יחד עם השיטות editTask ו-drop:

src/app/app.component.ts

...
import { CdkDragDrop, transferArrayItem } from '@angular/cdk/drag-drop';

@Component(...)
export class AppComponent {
  todo: Task[] = [...];
  inProgress: Task[] = [];
  done: Task[] = [];

  editTask(list: string, task: Task): void {}

  drop(event: CdkDragDrop<Task[]>): void {
    if (event.previousContainer === event.container) {
      return;
    }
    if (!event.container.data || !event.previousContainer.data) {
      return;
    }
    transferArrayItem(
      event.previousContainer.data,
      event.container.data,
      event.previousIndex,
      event.currentIndex
    );
  }
}

שימו לב שבשיטה drop אנחנו קודם בודקים שאנחנו משחררים את המשימה באותה רשימה שממנה היא מגיעה. במקרה כזה, אנחנו חוזרים מיד. אחרת, אנחנו מעבירים את המשימה הנוכחית אל ה-swimlane של היעד.

התוצאה צריכה להיות:

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 ומוסיפים לחצן עם סמל Material ‏add לצד התווית 'הוספת משימה'. אנחנו צריכים את ה-wrapper הנוסף כדי למקם את הלחצן מעל רשימת ה-swimlanes, שאותם נמקם בהמשך זה לצד זה באמצעות flexbox. מכיוון שהלחצן הזה משתמש ברכיב הלחצן של Material, צריך לייבא את המודול המתאים ב-AppModule:

src/app/app.module.ts

...
import { MatButtonModule } from '@angular/material/button';

@NgModule({
  declarations: [
    AppComponent
  ],
  imports: [
    ...
    MatButtonModule
  ],
  providers: [],
  bootstrap: [AppComponent]
})
export class AppModule { }

עכשיו נטמיע את הפונקציונליות להוספת משימות ב-AppComponent. נשתמש בתיבת דו-שיח של Material. בתיבת הדו-שיח יופיע טופס עם שני שדות: שם ותיאור. כשהמשתמש לוחץ על הלחצן 'הוספת משימה', תיבת הדו-שיח נפתחת, וכשהמשתמש שולח את הטופס, המשימה החדשה שנוצרה מתווספת לרשימה 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. זהו אובייקט הנתונים שהעברנו לשיטת open ב-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. לאחר מכן, באמצעות flexbox, אנחנו ממקמים את נתיבי השחייה זה לצד זה, ולבסוף מבצעים כמה שינויים באופן שבו אנחנו מדמיינים משימות ורשימות ריקות.

אחרי שהאפליקציה נטענת מחדש, אמור להופיע ממשק המשתמש הבא:

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 משכפלת אותו ומכניסה אותו למיקום שבו ישוחרר הרכיב המקורי. כדי לוודא שהרכיב הזה לא גלוי, אנחנו מגדירים את מאפיין השקיפות במחלקה cdk-drag-placeholder, שה-CDK יוסיף ל-placeholder.

בנוסף, כשאנחנו משחררים רכיב, ה-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>

הלחצן הזה מציג את סמל המחיקה של 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|undefined) => {
      if (!result) {
        return;
      }
      const dataList = this[list];
      const taskIndex = dataList.indexOf(task);
      if (result.delete) {
        dataList.splice(taskIndex, 1);
      } else {
        dataList[taskIndex] = task;
      }
    });
  }
  ...
}

נבחן את הארגומנטים של השיטה editTask:

  • רשימה מסוג 'done' | 'todo' | 'inProgress',, שהוא סוג איחוד של מחרוזות מילוליות עם ערכים שתואמים למאפיינים שמשויכים לכל אחד מהאזורים.
  • המשימה הנוכחית שרוצים לערוך.

בגוף השיטה, אנחנו קודם פותחים מופע של TaskDialogComponent. כערך של data מעבירים אובייקט ליטרלי שמציין את המשימה שרוצים לערוך, וגם מאפשר את לחצן העריכה בטופס על ידי הגדרת המאפיין enableDelete לערך true.

כשאנחנו מקבלים את התוצאה מתיבת הדו-שיח, אנחנו מטפלים בשני תרחישים:

  • כשהדגל delete מוגדר לערך true (כלומר, כשהמשתמש לחץ על לחצן המחיקה), אנחנו מסירים את המשימה מהרשימה המתאימה.
  • לחלופין, פשוט מחליפים את המשימה באינדקס הנתון במשימה שקיבלנו מתוצאת הדו-שיח.

9. יצירת פרויקט חדש ב-Firebase

עכשיו ניצור פרויקט חדש ב-Firebase.

  • עוברים אל מסוף Firebase.
  • יוצרים פרויקט חדש בשם KanbanFire.

10. הוספת Firebase לפרויקט

בקטע הזה נשלב את הפרויקט שלנו עם Firebase. צוות Firebase מציע את החבילה @angular/fire, שמאפשרת שילוב בין שתי הטכנולוגיות. כדי להוסיף תמיכה ב-Firebase לאפליקציה, פותחים את ספריית הבסיס של סביבת העבודה ומריצים את הפקודה:

ng add @angular/fire

הפקודה הזו מתקינה את חבילת @angular/fire ושואלת אתכם כמה שאלות. במסוף, אמור להופיע משהו כזה:

9ba88c0d52d18d0.png

בזמן הזה, ההתקנה פותחת חלון דפדפן כדי שתוכלו לאמת את החשבון שלכם ב-Firebase. לבסוף, תתבקשו לבחור פרויקט Firebase וייווצרו כמה קבצים בדיסק.

השלב הבא הוא ליצור מסד נתונים של Firestore. בקטע Cloud Firestore, לוחצים על Create Database (יצירת מסד נתונים).

1e4a08b5a2462956.png

לאחר מכן, יוצרים מסד נתונים במצב בדיקה:

ac1181b2c32049f9.png

לבסוף, בוחרים אזור:

34bb94cc542a0597.png

הדבר היחיד שנותר לעשות הוא להוסיף את ההגדרה של Firebase לסביבה. אפשר למצוא את הגדרות הפרויקט במסוף Firebase.

  • לוחצים על סמל גלגל השיניים לצד 'סקירת הפרויקט'.
  • בוחרים באפשרות Project Settings (הגדרות הפרויקט).

c8253a20031de8a9.png

בקטע 'האפליקציות שלך', בוחרים באפשרות 'אפליקציית אינטרנט':

428a1abcd0f90b23.png

לאחר מכן, רושמים את האפליקציה ומוודאים שמפעילים את Firebase Hosting:

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

אחרי שהגדרנו את Firebase SDK, נשתמש ב-@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) {}
  ...
}

בשלב הבא, אנחנו מעדכנים את האופן שבו אנחנו מאתחלים את מערכי ה-swimlane:

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 מחזירה observable ולא מערך, וגם שאנחנו מציינים ששדה המזהה של המסמכים בקולקציה הזו צריך להיקרא id כדי להתאים לשם שבו אנחנו משתמשים בממשק Task. ה-observable שמוחזר על ידי valueChanges פולט אוסף של משימות בכל פעם שהוא משתנה.

מכיוון שאנחנו עובדים עם ערכים שניתנים לצפייה במקום עם מערכים, אנחנו צריכים לעדכן את הדרך שבה אנחנו מוסיפים, מסירים ועורכים משימות, ואת הפונקציונליות להעברת משימות בין נתיבי פעילות. במקום לשנות את המערכים בזיכרון, נשתמש ב-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|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);
  }
});
...

אנחנו ניגשים למסמך היעד שמתאים למשימה שאנחנו מבצעים באמצעות Firestore SDK ומוחקים או מעדכנים אותו.

לבסוף, צריך לעדכן את השיטה ליצירת משימות חדשות. החלפה של this.todo.push('task') ב: this.store.collection('todo').add(result.task).

שימו לב שעכשיו הקולקציות שלנו הן לא מערכים, אלא ערכים שניתן לצפות בהם. כדי שנוכל להציג אותם בצורה ויזואלית, אנחנו צריכים לעדכן את התבנית של AppComponent. פשוט מחליפים כל גישה למאפיינים todo, inProgress ו-done במאפיינים todo | async, inProgress | async ו-done | async בהתאמה.

ה-async pipe נרשם אוטומטית ל-observables שמשויכים לאוספים. כשערכי ה-Observable פולטים ערך חדש, Angular מפעיל באופן אוטומטי זיהוי שינויים ומעבד את המערך שנפלט.

לדוגמה, נבדוק את השינויים שצריך לבצע בtodo swimlane:

src/app/app.component.html

<mat-card
  cdkDropList
  id="todo"
  #todoList="cdkDropList"
  [cdkDropListData]="todo | async"
  [cdkDropListConnectedTo]="[doneList, inProgressList]"
  (cdkDropListDropped)="drop($event)"
  class="list">
  <p class="empty-label" *ngIf="(todo | async)?.length === 0">Empty list</p>
  <app-task (edit)="editTask('todo', $event)" *ngFor="let task of todo | async" cdkDrag [task]="task"></app-task>
</mat-card>

כשמעבירים את הנתונים להוראה cdkDropList, משתמשים בצינור async. ההגדרה זהה בתוך ההוראה *ngIf, אבל שימו לב שגם שם אנחנו משתמשים בשרשור אופציונלי (שנקרא גם אופרטור ניווט בטוח ב-Angular) כשניגשים למאפיין length, כדי לוודא שלא תתקבל שגיאת זמן ריצה אם todo | async הוא לא null או undefined.

עכשיו, כשתיצרו משימה חדשה בממשק המשתמש ותפתחו את Firestore, אמור להופיע משהו כזה:

dd7ee20c0a10ebe2.png

12. שיפור העדכונים האופטימיים

באפליקציה אנחנו מבצעים כרגע עדכונים אופטימיים. מקור האמת שלנו הוא ב-Firestore, אבל יש לנו גם עותקים מקומיים של המשימות. כשמתבצעת פליטה של אחד מהערכים הניתנים לצפייה שמשויכים לאוספים, אנחנו מקבלים מערך של משימות. כשפעולת משתמש משנה את המצב, אנחנו קודם מעדכנים את הערכים המקומיים ואז מעבירים את השינוי ל-Firestore.

כשמעבירים משימה מנתיב אחד לנתיב אחר, מפעילים את transferArrayItem, שפועל על מופעים מקומיים של המערכים שמייצגים את המשימות בכל נתיב. ב-Firebase SDK המערכים האלה נחשבים כבלתי ניתנים לשינוי, כלומר בפעם הבאה ש-Angular יפעיל זיהוי שינויים, נקבל מופעים חדשים שלהם, שיציגו את המצב הקודם לפני העברת המשימה.

במקביל, אנחנו מפעילים עדכון של Firestore ו-Firebase SDK מפעיל עדכון עם הערכים הנכונים, כך שבתוך כמה אלפיות השנייה ממשק המשתמש יחזור למצב הנכון. כך המשימה שהעברנו קופצת מהרשימה הראשונה לרשימה הבאה. אפשר לראות את זה בבירור בקובץ ה-GIF שלמטה:

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

13. פריסת האפליקציה

כדי לפרוס את האפליקציה, כל מה שצריך לעשות זה להריץ את הפקודה:

ng deploy

הפקודה הזו:

  1. מבצעים build של האפליקציה עם הגדרת הייצור שלה, ומחילים אופטימיזציות בזמן הקומפילציה.
  2. פריסת האפליקציה ב-Firebase Hosting.
  3. הפלט הוא כתובת URL שמאפשרת לראות את התוצאה בתצוגה מקדימה.

14. מזל טוב

כל הכבוד, יצרתם בהצלחה לוח קנבן באמצעות Angular ו-Firebase!

יצרתם ממשק משתמש עם שלוש עמודות שמייצגות את הסטטוס של משימות שונות. באמצעות Angular CDK, הטמעתם גרירה ושחרור של משימות בין העמודות. לאחר מכן, באמצעות Angular material, יצרתם טופס ליצירת משימות חדשות ולעריכת משימות קיימות. לאחר מכן למדתם איך להשתמש ב-@angular/fire והעברתם את כל מצב האפליקציה ל-Firestore. לבסוף, פרסתם את האפליקציה ב-Firebase Hosting.

מה השלב הבא?

חשוב לזכור שהפריסה של האפליקציה בוצעה באמצעות הגדרות בדיקה. לפני שפורסים את האפליקציה בסביבת הייצור, חשוב לוודא שהגדרתם את ההרשאות הנכונות. כאן מוסבר איך עושים את זה.

בשלב הזה, אנחנו לא שומרים על הסדר של המשימות הנפרדות בנתיב מסוים. כדי להטמיע את זה, אפשר להשתמש בשדה הזמנה במסמך המשימות ולמיין על פיו.

בנוסף, יצרנו את לוח קנבן רק למשתמש אחד, כלומר יש לנו לוח קנבן אחד לכל מי שפותח את האפליקציה. כדי להטמיע לוחות נפרדים למשתמשים שונים באפליקציה, תצטרכו לשנות את מבנה מסד הנתונים. כאן אפשר לקרוא על השיטות המומלצות לשימוש ב-Firestore.