iT邦幫忙

2019 iT 邦幫忙鐵人賽

DAY 17
5
Modern Web

Angular 深入淺出三十天系列 第 17

[Angular 深入淺出三十天] Day 16 - Angular小學堂(三之四)

Imgur

上圖是我們目前的進度,已完成了待辦事項的新增、刪除與狀態變更的功能。 CRUD Create、Read、Update、Delete) 目前只剩下 U 還沒完成,所以我們得想個方式來讓使用者能夠更改他原本輸入的待辦事項名稱。

怎麼做才好呢?其實如果有操作原本 TodoMVC 的系統的話會發現,它是讓使用者在待辦事項清單裡讓使用者點擊兩下滑鼠後,開啟編輯模式而達到修改的目的;接著在 Blur (意即失去 focus 的當下) 或是按下 Enter 的時候修改資料;按下 Esc 的時候則會取消編輯模式。

但我們目前 Todo 的資料物件模型裡,沒有記錄編輯模式這件事,所以我們先在 todo.model.ts 的類別裡新增一個名為 editMode 的屬性:

/**
 * 是否處於編輯模式
 *
 * @private
 * @memberof Todo
 */
private editMode = false;

由於此屬性我將其宣告為私有,所以要再另外增加 settergetter

/**
 * 取得此事項是否處於編輯模式
 *
 * @readonly
 * @type {boolean}
 * @memberof Todo
 */
get editing(): boolean {
  return this.editMode;
}

/**
 * 設定此事項是否可被編輯
 *
 * @memberof Todo
 */
set editable(bl: boolean) {
  this.editMode = bl;
}

之前有用過 get ,這次刻意多用了 set

接下來會需要更新待辦事項的名稱,所以我們需要再多一個 setTitle 的函式:

/**
* 設定事項名稱
*
* @param {string} title
* @memberof Todo
*/
setTitle(title: string): void {
  this.title = title;
}

到這邊算是已經完成了資料物件模型的調整,接下來換調整 todo-list.component.ts

/**
 * 開始編輯待辦事項
 *
 * @param {Todo} todo
 * @memberof TodoListComponent
 */
edit(todo: Todo): void {
  todo.editable = true;
}

/**
 * 更新待辦事項
 *
 * @param {Todo} todo - 原本的待辦事項
 * @param {string} newTitle - 新的事項名稱
 * @memberof TodoListComponent
 */
update(todo: Todo, newTitle: string): void {

  if (!todo.editing) {
    return;
  }

  const title = newTitle.trim();

  // 如果有輸入名稱則修改事項名稱
  if (title) {
    todo.setTitle(title);
    todo.editable = false;

  // 如果沒有名稱則刪除該項待辦事項
  } else {
    const index = this.getList().indexOf(todo);
    if (index !== -1) {
      this.remove(index);
    }
  }

}

/**
 * 取消編輯狀態
 *
 * @param {Todo} todo - 欲取消編輯狀態的待辦事項
 * @memberof TodoListComponent
 */
cancelEditing(todo: Todo): void {
  todo.editable = false;
}

最後則是要在 todo-list.component.html 裡加上新的 input 元素並綁定 blurkeyup.enterkeyup.escape 這三個事件的處理,以及 value 的屬綁定且加上 *ngIf 的 Directive ,令其在開啟編輯模式的時候才會出現,像是:

<input
  class="edit"
  #editedtodo
  *ngIf="todo.editing"
  [value]="todo.getTitle()"
  (blur)="update(todo, editedtodo.value)"
  (keyup.enter)="update(todo, editedtodo.value)"
  (keyup.escape)="cancelEditing(todo)"
>

有注意到 #editedtodo 這個奇怪的屬性嗎?這其實也是 Angular 的 Template 語法,意思有點等同我們在事件綁定時使用 $event.target ,用來取得該元素的實體。

接著在原本的 label 元素上,增加對 dblclick 事件的處理:

<label (dblclick)="edit(todo)">{{ todo.getTitle() }}</label>

最後要在編輯模式開啟時, li 的元素上要對應加上 editing 的類別名稱,所以這段的 HTML 大概會長的像這樣:

<li
  *ngFor="let todo of getList(); let i = index"
  [class.completed]="todo.done"
  [class.editing]="todo.editing"
>
  <div class="view">
    <input
      class="toggle"
      type="checkbox"
      (click)="todo.toggleCompletion()"
      [checked]="todo.done"
    >
    <label (dblclick)="edit(todo)">{{ todo.getTitle() }}</label>
    <button
      class="destroy"
      (click)="remove(i)"
    ></button>
  </div>
  <input
    class="edit"
    #editedtodo
    *ngIf="todo.editing"
    [value]="todo.getTitle()"
    (blur)="update(todo, editedtodo.value)"
    (keyup.enter)="update(todo, editedtodo.value)"
    (keyup.escape)="cancelEditing(todo)"
  >
</li>

做完了就來看看效果吧:

Imgur

很好!所以我們目前已經把基本的 CRUD 都處理好了,接下來我們來處理這塊:

Imgur

這塊的功能大致如下:

  1. 顯示目前尚未完成的待辦事項數量
  2. 當有已完成的待辦事項時,會出現一個按鈕用以清除已完成的待辦事項
  3. 有個 Filter 可以切換顯示 所有/未完成/已完成 之待辦事項

我們先從最簡單的 1. 開始做好了。

打開 todo-list.service.ts ,我們要先能夠把尚未完成的待辦事項從清單裡面過濾出來:

/**
 * 取得已完成/未完成的清單
 *
 * @param {boolean} completed - 要取得已完成還是未完成的清單
 * @returns {Todo[]}
 * @memberof TodoListService
 */
getWithCompleted(completed: boolean): Todo[] {
  return this.list.filter(todo => todo.done === completed);
}

接著再到 todo-list.component.ts 來使用剛剛在 Service 裡新增的函式:

/**
 * 取得未完成的待辦事項清單
 *
 * @returns {Todo[]}
 * @memberof TodoListComponent
 */
getRemainingList(): Todo[] {
  return this.todoListService.getWithCompleted(false);
}

然後再開啟 todo-list.component.html 加上以下的程式碼:

<section class="todoapp">

  <header class="header">
    <!-- 省略 -->
  </header>
  
  <section 
    class="main" 
    *ngIf="getList().length"
  >
    <!-- 省略 -->
  </section>
  
  <footer 
    class="footer" 
    *ngIf="getList().length"
  >
      <span class="todo-count">
        <strong>{{ getRemainingList().length }}</strong> 
        {{ getRemainingList().length > 1 ? 'items' : 'item'}} left
      </span>
  </footer>
  
</section>

加完之後我們來看看效果:

Imgur

再來是 3. ,因為之後要記錄使用者現在看的清單是哪一種,所以讓 CLI 幫我們建立一個列舉用以列舉出所有的狀態:

ng generate enum todo-list/todo-status-type

接著打開 todo-status-type.enum.ts 檔,我們來列舉一下之後會有的狀態類型:

/**
 * 待辦事項的狀態類型列舉
 *
 * @export
 * @enum {number}
 */
export enum TodoStatusType {

  /**
   * 所有
   */
  All,

  /**
   * 正在進行
   */
  Active,

  /**
   * 已完成
   */
  Completed

}

然後到 todo-list.component.ts 裡引入此列舉:

import { TodoStatusType } from './todo-status-type.enum';

再新增兩個屬性:

/**
 * 待辦事項狀態的列舉
 *
 * @memberof TodoListComponent
 */
todoStatusType = TodoStatusType;

/**
 * 目前狀態
 *
 * @private
 * @memberof TodoListComponent
 */
private status = TodoStatusType.All;

加上取得已完成清單、設定狀態、檢查狀態的函式:

/**
 * 取得已完成的待辦事項
 *
 * @returns {Todo[]}
 * @memberof TodoListComponent
 */
getCompletedList(): Todo[] {
  return this.todoListService.getWithCompleted(true);
}

/**
 * 設定狀態
 *
 * @param {number} status - 欲設定的狀態
 * @memberof TodoListComponent
 */
setStatus(status: number): void {
  this.status = status;
}

/**
 * 檢查目前狀態
 *
 * @param {number} status - 欲檢查的狀態
 * @returns {boolean}
 * @memberof TodoListComponent
 */
checkStatus(status: number): boolean {
  return this.status === status;
}

然後修改原本的 getList 函式:

/**
 * 取得待辦事項清單
 *
 * @returns {Todo[]}
 * @memberof TodoListComponent
 */
getList(): Todo[] {

  let list: Todo[] = [];

  switch (this.status) {

    case TodoStatusType.Active:
      list = this.getRemainingList();
      break;

    case TodoStatusType.Completed:
      list = this.getCompletedList();
      break;

    default:
      list = this.todoListService.getList();
      break;

  }

  return list;

}

最後再到 todo-list.component.html 加上:

<footer
  class="footer"
  *ngIf="getList().length"
>

  <span class="todo-count">
    <!-- 省略 -->
  </span>

  <ul class="filters">

    <li>
      <a
       href="javascript:;"
       [class.selected]="checkStatus(todoStatusType.All)"
       (click)="setStatus(todoStatusType.All)"
      >
        All
      </a>
    </li>

    <li>
      <a
        href="javascript:;"
        [class.selected]="checkStatus(todoStatusType.Active)"
        (click)="setStatus(todoStatusType.Active)"
      >
        Active
      </a>
    </li>

    <li>
      <a
        href="javascript:;"
        [class.selected]="checkStatus(todoStatusType.Completed)"
        (click)="setStatus(todoStatusType.Completed)"
      >
        Completed
      </a>
    </li>

  </ul>

</footer>

再來看看效果:

Imgur

快完成了!!剩下最後一項,從清單中移除所有已完成的待辦事項。

打開 todo-list.service.ts 加上:

/**
 * 從清單中移除所有已完成之待辦事項
 *
 * @memberof TodoListService
 */
removeCompleted(): void {
  this.list = this.getWithCompleted(false);
}

然後再到 todo-list.component.ts 加上:

/**
 * 從清單中移除所有已完成之待辦事項
 *
 * @memberof TodoListComponent
 */
removeCompleted(): void {
  this.todoListService.removeCompleted();
}

最後則是到 todo-list.component.html 加上:

<footer
  class="footer"
  *ngIf="getList().length"
>

  <span class="todo-count">
    <!-- 省略 -->
  </span>

  <ul class="filters">
    <!-- 省略 -->
  </ul>
  
  <button
    class="clear-completed"
    *ngIf="getCompletedList().length"
    (click)="removeCompleted()"
  >
    Clear completed
  </button>

</footer>

好的!完成了!趕快來看一下效果:

Imgur

最後最後,不曉得你們有沒有注意到在輸入框旁邊有一個小小的下箭頭,點擊那個箭頭可以一次將所有的待辦事項勾選為未完成或是已完成。

我們來完成這最後一個小功能吧!

首先先到 todo.model.ts 新增一個可以設定待辦事項完成與否的函式:

/**
 * 設定是否完成
 *
 * @param {boolean} completed
 * @memberof Todo
 */
setCompleted(completed: boolean): void {
  this.completed = completed;
}

然後再到 todo-list.component.ts 裡新增以下三個函式:

/**
 * 取得所有的待辦事項清單(不受狀態影響)
 *
 * @returns {Todo[]}
 * @memberof TodoListComponent
 */
getAllList(): Todo[] {
  return this.todoListService.getList();
}

/**
 * 所有的代辦事項是否都已完成
 *
 * @returns {boolean}
 * @memberof TodoListComponent
 */
allCompleted(): boolean {
  return this.getAllList().length === this.getCompletedList().length;
}

/**
 * 設定所有的待辦事項已完成/未完成
 *
 * @param {boolean} completed - 已完成/未完成
 * @memberof TodoListComponent
 */
setAllTo(completed: boolean): void {

  this.getAllList().forEach((todo) => {
    todo.setCompleted(completed);
  });

}

最後則是到 todo-list.component.html 稍微調整一下:

<section class="todoapp">

  <header class="header">
    <!-- 省略 -->
  </header>
  
  <section 
    class="main" 
    *ngIf="getAllList().length"
  >
    
    <input
      id="toggle-all"
      class="toggle-all"
      type="checkbox"
      *ngIf="getAllList().length"
      #toggleall
      [checked]="allCompleted()"
      (click)="setAllTo(toggleall.checked)"
    >
    <label for="toggle-all"></label>
    
    <!-- 省略 -->
  </section>
  
  <footer 
    class="footer" 
    *ngIf="getAllList().length"
  >
    <!-- 省略 -->
  </footer>
  
</section>

大功告成!!來看一下效果:

Imgur

這次的小學堂總算做了一個相對來說較為完整的範例,各位都完成了嗎?

相關的程式碼我會放在我的 Github 上,如果有什麼問題都可以在底下留言噢!

那我們明天見囉!

錯誤更新記錄

  • 2019/09/05 17:13 - 非常感謝邦友 jacky123200 的提醒,因按下 escape 會觸發 update 函式,補上判斷式以防止被更新。
  • 2019/09/05 18:28 - 非常感謝邦友 jacky123200 的提醒,將 {{ getRemainingList().length ? 'item' : 'items'}} 修正成 {{ getRemainingList().length > 1 ? 'items' : 'item'}}
  • 2019/10/15 19:41 - 非常感謝邦友 cuxy6705 的提醒,將 if (!this.editable) 修正為 if (!this.editing)

上一篇
[Angular 深入淺出三十天] Day 15 - Angular小學堂(三之三)
下一篇
[Angular 深入淺出三十天] Day 17 - 基礎結構說明(四)
系列文
Angular 深入淺出三十天33
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中
0
xa414141
iT邦新手 5 級 ‧ 2019-01-04 14:48:56

版主您好,我從第一篇看到現在有些疑問,
component、service和model都各自有自己的function,
舉例來說,您的CRUD的C就是先呼叫component再呼叫service,
而您的U是先呼叫component再直接用model的setter寫入而不透過service,
我的意思是說component、service和model的function各自有什麼差別?

Leo iT邦新手 3 級 ‧ 2019-01-04 15:04:03 檢舉

Hi xa414141,這個問題問得滿好的,我稍微描述一下我個人的看法:

無論是 Component、Service 還是 Model Class,其實本質上都是 Class ,差別只在於 Angular 的 Decorator 會賦予該 Class 不同的特性。

在 Angular 裡,Component 跟 View 有非常直接的關係,我個人會把畫面相關的處理邏輯寫在 Component 裡。

而 Service 會因其在不同的 providers 裡或者是不同的 provideIn 的宣告,有不同的生命週期,且其具有可被注入的特性。有鑑於這些特性,我個人會將商業邏輯或是資料處理邏輯都寫在 Service。

至於 Model Class 的話,我個人會把跟這個 Model 有相關的資料轉換/處理邏輯寫在裡面,方便外部使用。

總而言之,程式其實無論怎麼寫都可以,端看各自的設計,我的示範也只是一種展示,告訴大家可以怎麼玩 Angular 而已。

xa414141 iT邦新手 5 級 ‧ 2019-01-04 15:52:18 檢舉

我假設一下,
因為今天此範例是展示用,所以您的Update會直接使用Model的Setter。
假設今天CRUD是針對資料庫或API的,一般來說都會透過Service而不會直接使用Model的Setter,因為還要考慮到資料庫或API的連線變數,不知道這樣理解對不對?

Leo iT邦新手 3 級 ‧ 2019-01-04 18:07:30 檢舉

Hi xa414141,

沒錯!!正是如此! :)

1
kivini
iT邦新手 5 級 ‧ 2019-04-19 13:23:40

版主您好:我在練習這項練習的第一個小段落時(可展示成果時),發現如果缺少了class="edit"的input時,雙次點擊Label會造成整欄消失,但我卻始終找不出原因,不知道您是否能夠替我解惑一下呢?

Leo iT邦新手 3 級 ‧ 2019-04-19 13:36:55 檢舉

Hi kivini,

請參照以下這段 HTML:

<li
  *ngFor="let todo of getList(); let i = index"
  [class.completed]="todo.done"
  [class.editing]="todo.editing"
>
  <div class="view">
    <input
      class="toggle"
      type="checkbox"
      (click)="todo.toggleCompletion()"
      [checked]="todo.done"
    >
    <label (dblclick)="edit(todo)">{{ todo.getTitle() }}</label>
    <button
      class="destroy"
      (click)="remove(i)"
    ></button>
  </div>
  <input
    class="edit"
    #editedtodo
    *ngIf="todo.editing"
    [value]="todo.getTitle()"
    (blur)="update(todo, editedtodo.value)"
    (keyup.enter)="update(todo, editedtodo.value)"
    (keyup.escape)="cancelEditing(todo)"
  >
</li>

之所以如果缺少了 class="edit" 的 input 時,雙次點擊 Label 會造成整欄消失的原因正是因為,在「顯示」狀態時,我們其實是讓 div.view 這個區塊裡的 HTML 顯示在畫面上,而當我們雙次點擊 Label 時,我們是要讓該筆資料的狀態改為「編輯」狀態,所以就會讓 div.view 這個區塊裡的 HTML 隱藏,改為顯示 class="edit" 的 input 。

所以如果缺少了 class="edit" 的 input,當然就會整欄消失囉。

kivini iT邦新手 5 級 ‧ 2019-04-22 09:30:23 檢舉

感謝Leo版主的解釋。

所以說view切換成edit這個動作之間,您沒有使用任何程式去執行嗎?而是label或者div內建的程式碼執行的?
對不起,網頁語言我還不是很熟捻。這方面比較菜一點

.todo-list li.editing .view {
	display: none;
}

CSS 把它隱藏了

1
jacky123200
iT邦新手 5 級 ‧ 2019-09-05 17:03:21

hi Leo,

我發現escape有點問題
當我輸入123,然後把內容改成456,再按ecsape,發現內容變成456了(正確應該是123)
然後我console.log發現按escape也會進去update那個function
是不是因為escape導致focus lost 而觸發blur?

我暫時的解決方法是在update裡加上以下代碼來解決這個誤觸發就正常了
if (!todo.editing) {
return;
}

Leo iT邦新手 3 級 ‧ 2019-09-05 17:10:50 檢舉

Hi jacky123200,

看起來是這樣沒錯,感謝提醒!!

itsems iT邦新手 5 級 ‧ 2019-09-18 17:04:15 檢舉

Hi Leo 大大,

請問這一段是要加在 update 裡面嗎?
加上了這一段之後,原本的 enter 跟 blur 好像會壞掉?嗎?
因為在 return 之後,就不會執行下面的 if 動作了,
放在 if 下面的話,就失去 escape 不更動的效果了><

0
jacky123200
iT邦新手 5 級 ‧ 2019-09-05 18:23:50

hi Leo,

todo-list.component.html
原本{{ getRemainingList().length ? 'item' : 'items'}} left
修改後{{ getRemainingList().length > 1 ? 'items' : 'item' }} left
你應該想做到多於1個item就用items吧?

Leo iT邦新手 3 級 ‧ 2019-09-05 18:26:54 檢舉

Hi jacky123200,

沒錯,你好棒!!感謝提醒,我馬上修正!!

0
cuxy6705
iT邦新手 5 級 ‧ 2019-10-15 16:54:26

hi Leo, 感謝你的這篇
btw你的update
if (!todo.editable) {
return;
}
好像打錯了

Leo iT邦新手 3 級 ‧ 2019-10-15 16:56:03 檢舉

Hi cuxy6705,

非常高興有幫到你,不過請問是哪邊有錯呢?

cuxy6705 iT邦新手 5 級 ‧ 2019-10-15 17:40:14 檢舉

update 後面新增的if (!todo.editable) {
return;
}
我猜應該是想打if (!todo.editing)
不然就不會繼續執行下去了 只要input輸入東西editable就一直都是false
不知道這樣理解對不對XD

Leo iT邦新手 3 級 ‧ 2019-10-15 19:43:57 檢舉

Hi cuxy6705,

感謝你的提醒!錯誤已經修正囉!^^

0
alan8034
iT邦新手 5 級 ‧ 2023-09-18 22:31:16

Leo 大大您好,有試過clone您github上的程式,與文章中逐步教學跟著打比對,發現逐步教學中,如果當completed為空時,點選後會出現異常...footer那功能列會不見惹...請問應該如何修正呢?

Leo iT邦新手 3 級 ‧ 2023-09-19 09:59:40 檢舉

為「空」是指「空陣列」還是「null」?
抱歉,我可能要看一下你的程式碼或是更清楚地知道你指的是哪裡會更知道你遇到的問題是什麼情況@@

alan8034 iT邦新手 5 級 ‧ 2023-09-19 13:31:51 檢舉

我的意思是....在網頁畫面上,如果我現在的狀態沒有任何completed的物件,但我點選footer的completed,會導致我的畫面footer就不見惹....然後這時候也不能再新增東西。

我是依照您這四篇去做的,想了解是否有甚麼東西沒有加到?

abc3 iT邦新手 5 級 ‧ 2023-09-27 21:18:08 檢舉

是不是這邊沒有改到?

 <section 
   class="main" 
   *ngIf="getAllList().length"
 >

我要留言

立即登入留言