Angular 實戰教程 - Today (Part 3)
來自專欄 NG-ZORRO16 人贊了文章
Angular 實戰教程 - 手把手教你構建待辦事項應用 Today (Part 3)
在上一篇文章中我們完成了主界面的左邊部分,在這篇文章中我們會將右邊部分也完成。
right-control
從 Demo 中可以看到,右邊主要分為三個部分,一是上方的 header 區域,展示當前列表名稱,建議按鈕和排序按鈕,二是當前列表下待辦事項的顯示區域,三是快速添加待辦事項的輸入框。我們先來生成這些組件:
ng g c pages/main/right-controlng g c pages/main/right-control/headerng g c pages/main/right-control/quick-addng g c pages/main/right-control/todo
在 right-control.component.html 中先規劃好頁面結構:
<nz-layout class="right-control"> <nz-header class="header-wrapper"> </nz-header> <nz-content class="list-wrapper"> </nz-content> <div class="quick-add-wrapper"> </div></nz-layout>
接下來我們先寫 Todo 組件。
Todo
在 todo.component.html 中輸入如下內容:
<nz-list *ngIf="todos.length > 0" [nzDataSource]="todos" [nzRenderItem]="item" [nzItemLayout]="horizontal"> <ng-template #item let-item> <nz-list-item class="todo-item" (click)="click(item._id)"> <nz-list-item-meta [nzTitle]="nzTitle"> <ng-template #nzTitle> <label nz-checkbox (click)="$event.stopPropagation()" [(ngModel)]="item.completedFlag" (ngModelChange)="toggle(item._id)"></label> <span [class.strikethrough]="item.completedFlag">{{ item.title }}</span> </ng-template> </nz-list-item-meta> </nz-list-item> </ng-template></nz-list>
在 todo.component.ts 輸入如下內容:
import { Component, OnInit, OnDestroy } from @angular/core;import { Router } from @angular/router;import { NzDropdownService } from ng-zorro-antd;import { combineLatest, Subject } from rxjs;import { takeUntil } from rxjs/operators;import { Todo, List } from ../../../../../domain/entities;import { TodoService } from ../../../../services/todo/todo.service;import { ListService } from ../../../../services/list/list.service;import { floorToDate, getTodayTime } from ../../../../../utils/time;@Component({ selector: app-todo, templateUrl: ./todo.component.html, styleUrls: [ ./todo.component.less ]})export class TodoComponent implements OnInit, OnDestroy { private destory$ = new Subject(); todos: Todo[] = []; lists: List[] = []; currentContextTodo: Todo; constructor( private listService: ListService, private todoService: TodoService ) { } ngOnInit() { this.listService.lists$ .pipe(takeUntil(this.destory$)) .subscribe(lists => { this.lists = lists; }); combineLatest(this.listService.currentUuid$, this.todoService.todo$) .pipe(takeUntil(this.destory$)) .subscribe(sources => { this.processTodos(sources[ 0 ], sources[ 1 ]); }); this.todoService.getAll(); this.listService.getAll(); } ngOnDestroy() { this.destory$.next(); } private processTodos(listUUID: string, todos: Todo[]): void { const filteredTodos = todos .filter(todo => { return ((listUUID === today && todo.planAt && floorToDate(todo.planAt) <= getTodayTime()) || (listUUID === todo && (!todo.listUUID || todo.listUUID === todo)) || (listUUID === todo.listUUID)); }) .map(todo => Object.assign({}, todo) as Todo); this.todos = [].concat(filteredTodos); } add(title: string): void { this.todoService.add(title); }}
我們在之前一篇文章中曾經討論過為什麼不直接訪問 service 的屬性,而用一種看起來比較麻煩的訂閱機制,這裡就展現出原因了:藉助 rxjs 的強大威力,我們在當前列表改變的時候,不需要命令式地去修改列表,我們之後會做的排序,同樣通過這種機制監聽排序依據改變的事件。如果你想要增加更多的功能,良好的可拓展性是很有必要的。
Quick Add
現在有了 Todo 組件,但還無法顯示待辦事項清單——因為我們還辦法添加待辦事項呢!
我們需要 Quick Add 組件,它是個浮動的輸入框,用戶輸入待辦事項標題後按下回車,就可以添加一條待辦事項。
在 quick-add.component.html 中輸入:
<input nz-input #addInput placeholder="想要做什麼?" (keydown.enter)="addTodo(addInput.value); addInput.value = ">
在 quick-add.component.ts 中輸入:
import { Component, EventEmitter, OnInit, Output } from @angular/core;@Component({ selector: app-quick-add, templateUrl: ./quick-add.component.html, styleUrls: [ ./quick-add.component.less ]})export class QuickAddComponent implements OnInit { @Output() add = new EventEmitter<string>(); constructor() { } ngOnInit() { } addTodo(title: string) { if (title) { this.add.next(title); } }}
然後在 right-control.component.html 中引入這兩個組件:
<nz-layout class="right-control"> <nz-header class="header-wrapper"> </nz-header> <nz-content class="list-wrapper"> <app-todo></app-todo> </nz-content> <div class="quick-add-wrapper"> <app-quick-add></app-quick-add> </div></nz-layout>
預覽,效果如下:
可以看到,輸入之後回車,並沒有實現我們設計的效果,這是因為我們沒有觸發 Todo 組件中的方法。在 right-control.component.html 中作出如下修改:
<app-quick-add (add)="add($event)"></app-quick-add>
然後在 right-control.component.ts 中添加方法:
import { Component, OnInit, ViewChild } from @angular/core;import { TodoComponent } from ./todo/todo.component;@Component({ selector: app-right-control, templateUrl: ./right-control.component.html, styleUrls: [ ./right-control.component.less ]})export class RightControlComponent implements OnInit { ... add(title: string) { this.todoList.add(title); }}
這時候應用就按照我們的設計工作了!
體驗提升
但是現在的易用性顯然無法令我們滿意,讓我們來增加功能。
首先,我們要在待辦事項設定了截止日期、計劃日期和詳情的時候,在主界面進行顯示。然後,我們要為待辦事項添加右鍵菜單,讓用戶能夠方便地使用移動到別的列表、設置計劃日期和刪除等功能。
修改 todo.component.html 文件:
<nz-list *ngIf="todos.length > 0" [nzDataSource]="todos" [nzRenderItem]="item" [nzItemLayout]="horizontal"> <ng-template #item let-item> <nz-list-item class="todo-item" (click)="click(item._id)" (contextmenu)="contextMenu($event, todoContextRef, item._id)"> <nz-list-item-meta [nzTitle]="nzTitle" [nzDescription]="nzDescription"> <ng-template #nzTitle> <label nz-checkbox (click)="$event.stopPropagation()" [(ngModel)]="item.completedFlag" (ngModelChange)="toggle(item._id)"></label> <span [class.strikethrough]="item.completedFlag">{{ item.title }}</span> </ng-template> <ng-template #nzDescription> <span *ngIf="item.dueAt" class="todo-desc"> <i class="anticon anticon-calendar"></i> {{ item.dueAt | date }}</span> <span *ngIf="item.planAt" class="todo-desc"> <i class="anticon anticon-clock-circle-o"></i> {{ item.planAt | date }}</span> <span *ngIf="item.notifyMe" class="todo-desc"> <i class="anticon anticon-bell"></i> </span> <span *ngIf="item.desc"> <i class="anticon anticon-edit"></i> </span> </ng-template> </nz-list-item-meta> </nz-list-item> </ng-template></nz-list><ng-template #todoContextRef> <ul nz-menu nzInDropDown (nzClick)="close()"> <li nz-menu-item (click)="setToday()"> <i class="anticon anticon-home anticon-right-margin"></i> <span>設為今日</span> </li> <li nz-submenu> <span title> <i class="anticon anticon-bars anticon-right-margin"></i>移動到...</span> <ul> <li nz-menu-item *ngIf="currentContextTodo?.listUUID !== todo" (click)="moveToList(todo)"> 默認列表 </li> <li nz-menu-item *ngFor="let list of listsExcept(currentContextTodo.listUUID)" (click)="moveToList(list._id)"> {{ list.title }} </li> </ul> </li> <li nz-menu-divider></li> <li nz-menu-item (click)="delete()"> <i class="anticon anticon-delete anticon-right-margin danger"></i> <span class="danger">刪除</span> </li> </ul></ng-template>
以及 todo.component.ts 文件:
import { Component, OnInit, OnDestroy, TemplateRef } from @angular/core;import { NzDropdownService, NzDropdownContextComponent } from ng-zorro-antd;import { combineLatest, Subject } from rxjs;import { takeUntil } from rxjs/operators;import { Todo, List } from ../../../../../domain/entities;import { TodoService } from ../../../../services/todo/todo.service;import { ListService } from ../../../../services/list/list.service;import { floorToDate, getTodayTime } from ../../../../../utils/time;@Component({ selector: app-todo, templateUrl: ./todo.component.html, styleUrls: [ ./todo.component.less ]})export class TodoComponent implements OnInit, OnDestroy { private dropdown: NzDropdownContextComponent; private destory$ = new Subject(); todos: Todo[] = []; lists: List[] = []; currentContextTodo: Todo; constructor( private listService: ListService, private todoService: TodoService, private dropdownService: NzDropdownService ) { } ngOnInit() { this.listService.lists$ .pipe(takeUntil(this.destory$)) .subscribe(lists => { this.lists = lists; }); combineLatest(this.listService.currentUuid$, this.todoService.todo$) .pipe(takeUntil(this.destory$)) .subscribe(sources => { this.processTodos(sources[ 0 ], sources[ 1 ]); }); this.todoService.getAll(); this.listService.getAll(); } ngOnDestroy() { this.destory$.next(); } private processTodos(listUUID: string, todos: Todo[]): void { const filteredTodos = todos .filter(todo => { return ((listUUID === today && todo.planAt && floorToDate(todo.planAt) <= getTodayTime()) || (listUUID === todo && (!todo.listUUID || todo.listUUID === todo)) || (listUUID === todo.listUUID)); }) .map(todo => Object.assign({}, todo) as Todo); this.todos = [].concat(filteredTodos); } add(title: string): void { this.todoService.add(title); } contextMenu( $event: MouseEvent, template: TemplateRef<void>, uuid: string ): void { this.dropdown = this.dropdownService.create($event, template); this.currentContextTodo = this.todos.find(t => t._id === uuid); } listsExcept(listUUID: string): List[] { return this.lists.filter(l => l._id !== listUUID); } toggle(uuid: string): void { this.todoService.toggleTodoComplete(uuid); } delete(): void { this.todoService.delete(this.currentContextTodo._id); } setToday(): void { this.todoService.setTodoToday(this.currentContextTodo._id); } moveToList(listUuid: string): void { this.todoService.moveToList(this.currentContextTodo._id, listUuid); } close(): void { this.dropdown.close(); }}
header
我們現在來做 header 部分,可以看到它主要提供了兩個功能:一是點擊「建議」按鈕後,就可以彈出一個 dropdown 來讓用戶選擇適合今天完成的任務;二是點擊「排序」按鈕後,就會彈出一個選擇框讓用戶選擇待辦事項的排序方式。
我們前面已經提到,在實現排序的時候,我們要用訂閱發布模式,接下來我們就來實現它。
在 todo.service.ts 中為 TodoService 類增加如下屬性和方法:
@Injectable()export class TodoService { todo$ = new Subject<Todo[]>(); rank$ = new Subject<RankBy>(); private todos: Todo[] = []; private rank: RankBy = title; // 還要修改這個方法 private broadCast(): void { this.todo$.next(this.todos); this.rank$.next(this.rank); } toggleRank(r: RankBy): void { this.rank = r; this.rank$.next(r); }}
然後,我們來編寫 header.component.html 文件:
<div class="header-container"> <img class="background-img" src="./assets/img/logo.png" alt=""> <div class="list-title-wrapper"> {{ listTitle }} </div> <div class="suggest-btn-wrapper"> <nz-dropdown [nzTrigger]="click" [nzClickHide]="false" [nzPlacement]="bottomRight"> <button nz-dropdown nz-button [nzType]="primary"> <i class="anticon anticon-bulb"></i>建議 </button> <!-- hack nz-zorro! --> <div nz-menu class="dropdown-content-wrapper"> </div> </nz-dropdown> </div> <div class="sort-btn-wrapper"> <nz-dropdown [nzPlacement]="bottomRight"> <a nz-dropdown> 排序 <i class="anticon anticon-down"></i> </a> <ul nz-menu nzSelectable> <li nz-menu-item (click)="switchRankType(title)"> 名稱 </li> <li nz-menu-item (click)="switchRankType(planAt)"> 計劃時間 </li> <li nz-menu-item (click)="switchRankType(dueAt)"> 截止時間 </li> <li nz-menu-item (click)="switchRankType(completeFlag)"> 完成狀態 </li> </ul> </nz-dropdown> </div></div>
你可以嘗試去掉 <div nz-menu class="dropdown-content-wrapper">
中的 nz-menu
,會發現下拉動畫沒有了。官方文檔並不會告訴你類似這樣的奇技淫巧,而自己去實現這個動畫則破費周折,所以了解一下你所用的庫的工作細節還是很有意義的。
header.component.ts 文件:
import { Component, OnInit } from @angular/core;import { Subscription } from rxjs;import { RankBy } from ../../../../../domain/type;import { ListService } from ../../../../services/list/list.service;import { TodoService } from ../../../../services/todo/todo.service;@Component({ selector: app-header, templateUrl: ./header.component.html, styleUrls: [ ./header.component.less ]})export class HeaderComponent implements OnInit { private listTitle$: Subscription; listTitle = ; constructor( private listService: ListService, private todoService: TodoService ) { } ngOnInit() { this.listTitle$ = this.listService.current$.subscribe(list => { this.listTitle = list ? list.title : ; }); } switchRankType(e: RankBy): void { this.todoService.toggleRank(e); }}
不要忘記在 right-control.component.html 中引入這個組件。
嘗試在應用里多創建幾個待辦事項,然後用名稱進行排序,會發現沒有變化,這是因為我們沒有相應地修改 Todo 組件裡面的發布訂閱機制。不過這個改動也非常簡單:
const rankerGenerator = (type: RankBy = title): any => { if (type === completeFlag) { return (t1: Todo, t2: Todo) => t1.completedFlag && !t2.completedFlag; } return (t1: Todo, t2: Todo) => t1[ type ] > t2[ type ];};export class TodoComponent implements OnInit, OnDestroy { // ... ngOnInit() { this.listService.lists$ .pipe(takeUntil(this.destory$)) .subscribe(lists => { this.lists = lists; }); combineLatest(this.listService.currentUuid$, this.todoService.todo$, this.todoService.rank$) .pipe(takeUntil(this.destory$)) .subscribe(sources => { this.processTodos(sources[ 0 ], sources[ 1 ], sources[ 2 ]); }); this.todoService.getAll(); this.listService.getAll(); } ngOnDestroy() { this.destory$.next(); } private processTodos(listUUID: string, todos: Todo[], rank: RankBy): void { const filteredTodos = todos .filter(todo => { return ((listUUID === today && todo.planAt && floorToDate(todo.planAt) <= getTodayTime()) || (listUUID === todo && (!todo.listUUID || todo.listUUID === todo)) || (listUUID === todo.listUUID)); }) .map(todo => Object.assign({}, todo) as Todo) .sort(rankerGenerator(rank)); this.todos = [].concat(filteredTodos); } // ...}
建議
Today 的一個亮點功能,就是能夠根據待辦事項的計劃日期和截止日期推薦適合今日完成任務,讓我們來實現這個組件。
ng g c pages/main/right-control/header/suggest
然後編寫如下代碼:
<div class="suggest-container container"> <span style="font-weight: 700;"> 接下來打算做什麼?請考慮我們的建議! </span> <nz-list class="suggestion-list" [nzDataSource]="suggestedTodo" [nzRenderItem]="item" [nzItemLayout]="horizontal"> <ng-template #item let-item> <nz-list-item class="suggestion-item" [nzActions]="[setTodayAction]"> <nz-list-item-meta [nzTitle]="item.title" [nzDescription]="item.desc"> <ng-template #nzTitle> <a href="https://ng.ant.design">{{item.name.last}}</a> </ng-template> </nz-list-item-meta> <ng-template #setTodayAction> <a (click)="setTodoToday(item)">設為今日</a> </ng-template> </nz-list-item> </ng-template> </nz-list> <div class="no-suggestion" *ngIf="suggestedTodo.length === 0"> 暫無建議 </div></div>
import { Component, OnDestroy, OnInit } from @angular/core;import { Subscription } from rxjs;import { Todo } from ../../../../../../domain/entities;import { TodoService } from ../../../../../services/todo/todo.service;import { floorToDate, getTodayTime, ONE_DAY } from ../../../../../../utils/time;@Component({ selector: app-suggest, templateUrl: ./suggest.component.html, styleUrls: [ ./suggest.component.less ]})export class SuggestComponent implements OnInit, OnDestroy { suggestedTodo: Todo[] = []; private todo$: Subscription; constructor( private todoService: TodoService ) { } ngOnInit() { this.todo$ = this.todoService.todo$.subscribe(todos => { const filtered = todos.filter(t => { if (t.planAt && floorToDate(t.planAt) <= getTodayTime()) { return false; } if (t.dueAt && t.dueAt - getTodayTime() <= ONE_DAY * 2) { return true; } return false; }); this.suggestedTodo = [].concat(filtered); }); this.todoService.getAll(); } ngOnDestroy() { this.todo$.unsubscribe(); } setTodoToday(todo: Todo): void { this.todoService.setTodoToday(todo._id); }}
在 header.component.html 中引入這個組件,我們的主界面就大功告成了(暫時如此)!
等等,好像有個很嚴重的問題!我們在判斷哪些待辦事項能進入推薦的時候,用了兩條邏輯,一是事件的計劃日期在今天之前,二是事項的截止日期在兩天之內,而現在我們因為沒有辦法設置待辦事項詳情,所以似乎沒有辦法能夠手工測試它(真的沒有辦法嗎,其實你可以去直接改 local storage)。我們會在下一篇文章中解決這個問題。
第三篇教程就到這裡,簡單回顧一下我們學到的知識點:
- NzList 組件的使用,用 nz-menu「愚弄」ng-zorro
- 進一步了解訂閱模式的好處
下一篇文章,我們將會編寫 Detail 詳情組件。
推薦閱讀: