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

1. מבוא

עדכון אחרון: 2020-01-11

מה תיצור

במעבדה זו, נבנה לוח קאנבאן באינטרנט עם Angular and Firebase! לאפליקציה הסופית יש שלוש קטגוריות של משימות: יומן, התקדמות והשלמת הפעולה. נוכל ליצור, למחוק משימות ולהעביר אותן מקטגוריה אחת לקטגוריה אחרת באמצעות גרירה ושחרור.

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

b23bd3732d0206b.png

מה תלמדו

  • איך משתמשים בחומר זוויתי וב-CDC.
  • איך מוסיפים שילוב של Firebase לאפליקציה האנגלית.
  • איך לשמור על הנתונים הקבועים שלכם ב-Firestore.
  • איך לפרוס את האפליקציה שלכם ב-Firebase Hosting באמצעות פקודה יחידה (אנגלי) באמצעות פקודה אחת?

מה צריך?

בשיעור Lab זה מתבסס על ההנחה שיש לכם חשבון Google והבנה בסיסית של Angular and C Angular CLI.

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

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

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

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

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

ng serve

פותחים את http://localhost:4200 ומקבלים פלט דומה ל:

5ede7bc5b1109bf3.png

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

3. הוספת חומר ו-CDC

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

כדי להוסיף תוכן לאפליקציה:

ng add @angular/material

פקודה זו מבקשת ממך לבחור עיצוב, אם ברצונך להשתמש בסגנונות הטיפוסיים הכלליים של החומר, ואם ברצונך להגדיר את האנימציות של דפדפן לחומרים זוויתיים. בחרו ב-"Indigo/Pink" כדי לקבל את אותה תוצאה שמופיעה ב-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>

כאן אנחנו מוסיפים סרגל כלים באמצעות הצבע העיקרי של עיצוב העיצוב מהותית, ובתוכו אנחנו משתמשים בסמל local_fire_depeartment לצד התווית "Kanpan Fire." מעכשיו, אם תביטו במסוף תוכלו לראות שיש כמה שגיאות בזווית הזוויתית. כדי לפתור את הבעיות, צריך להוסיף את הייבוא הבא אל 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.

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

a39cf8f8428a03bc.png

לא רע! יש רק ארבע שורות 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. מיישמת גרירה ושחרור עבור משימות

אנחנו מוכנים עכשיו לחלק הכיף! ניצור שלושה בריכות שחייה בין שלוש המדינות השונות שבהן ניתן לבצע משימות. באמצעות CDK האנגלי, יש להשתמש בפונקציית הגרירה והשחרור.

ב-app.component.html, יש להסיר את הרכיב app-task בעזרת ההוראה *ngFor בחלק העליון של המסך, ולהחליף אותו ב:

src/app/app.component.html

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

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

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

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

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

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

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

src/app/app.component.html

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

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

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

כאן אנחנו יוצרים div שמכסה את כל שלושת בריכות השחייה, עם שם הכיתה "container-wrapper." לכל נתיב שחייה יש שם כיתה &מירכאות;container" וכותרת בתוך תג h2.

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

src/app/app.component.html

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

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

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

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

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

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

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

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

src/app/app.module.ts

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

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

כמו כן, עלינו להצהיר על מערכי inProgress ו-done, בשילוב עם השיטות editTask ו-drop:

src/app/app.component.ts

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

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

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

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

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

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

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" יחד עם תווית "הוספת משימה." אנחנו זקוקים לwrapper נוסף כדי למקם את הלחצן בחלק העליון של רשימת בריכות השחייה. אנחנו נוסיף את ה-Flexbox מאוחר יותר זה לצד זה. הלחצן הזה משתמש ברכיב הלחצן מהותי, ולכן אנחנו צריכים לייבא את המודול המתאים ב-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. בשלב הבא, השימוש ב-Flexbox מאפשר לנו למקם את נתיבי השחייה זה לצד זה, ולבסוף מבצעים התאמות מסוימות באופן שבו אנחנו מציגים באופן חזותי משימות ורשימות ריקות.

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

69225f0b1aa5cb50.png

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

f9aae712027624af.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;
}

בזמן שאנחנו גוררים רכיב, 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>

הלחצן הזה מציג את סמל המחיקה של החומר. כשהמשתמש לוחץ עליו, אנחנו סוגרים את תיבת הדו-שיח ומעבירים את המילה ליטרל של האובייקט { 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.
  • יוצרים פרויקט חדש בשם "KansanFire".

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.

  • לוחצים על סמל גלגל השיניים לצד 'סקירה כללית של הפרויקט'.
  • בוחרים הגדרות פרויקט.

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

עכשיו, לאחר שהגדרנו את ה-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&33:

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 אוסף של משימות בכל פעם שהוא משתנה.

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

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 מתייחס למערכים האלה ללא שינוי. כלומר, בפעם הבאה שאנג'ל יפעיל את השינויים, נקבל מופעים חדשים שלהם, מה שיגרום למצב הקודם לפני שנעביר את המשימה.

במקביל, אנחנו מפעילים עדכון ל-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 Hosting.
  3. יוצרים פלט של כתובת URL כדי להציג תצוגה מקדימה של התוצאה.

14. מזל טוב

מזל טוב, בנית בהצלחה לוח קנקן עם Angular ו-Firebase!

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

מה השלב הבא?

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

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

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