iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
Modern Web

從 UI/UX 設計到前端實作:30 天打造專屬的任務學習計時器系列 第 18 篇

DAY 18|任務管理(一):主任務與子任務的 State 管理與 CRUD 實作

  • 分享至 

  • xImage
  •  

在前幾天的實作中,已經完成了計時器核心運作與計時結束後的紀錄 Modal。
但在實際的應用場景中,「計時」並非單獨存在,它必須精確地隸屬於某個「主任務」下的「子任務」。

在正式對接 Local-first 本地資料庫(如 IndexedDB)之前,我們需要先在記憶體中建立一套清晰的 State(狀態)管理機制,並完成主任務與子任務的 CRUD互動邏輯。


主任務&子任務資料結構設計

為了方便處理「刪除主任務時連帶刪除子任務」以及「計算子任務進度」,採用樹狀陣列物件結構:

let tasks = [
  {
    id: "task-1",
    title: "iThome鐵人賽",
    subtasks: [
      { id: "sub-1", title: "主題發想" },
      { id: "sub-2", title: "撰寫每日文章" },
      { id: "sub-3", title: "mockup製作" },
    ],
  },
  {
    id: "task-2",
    title: "JavaScript線上課程",
    subtasks: [{ id: "sub-4", title: "觀看 DOM 章節影片" }],
  },
];

設計優點:

  1. 層級關係明確:主任務直接持有 subtasks 陣列,資料極具直覺性。

  2. 自動連動刪除:刪除主任務時,其下方的 subtasks 會隨之被刪除,不用再撰寫額外語法。


HTML 結構調整與元件標記 (Bootstrap Modal & Dropdown)

主畫面使用了兩個 Bootstrap Dropdown 作為任務選擇器,並搭配兩個 Modal 視窗來分別處理「管理主任務」與「新增子任務」。

為了讓 JavaScript 能綁定 DOM 元素,確認 HTML 元件已帶入以下關鍵 id。

  • dropdown-main-task-btn / dropdown-main-task-menu

  • dropdown-subtask-btn / dropdown-subtask-menu

  • input-new-main-task / btn-add-main-task

  • input-new-subtask-name / btn-confirm-add-subtask


JavaScript 實作:State 管理與 CRUD 防呆機制

步驟 1:全域 State 與選中狀態紀錄

除了儲存完整任務樹的 tasks 外,我們還需要額外維持「當前選中」的狀態指標:

// 全域資料來源 (Single Source of Truth)
let tasks = [ /* ...初始任務資料... */ ];

// 當前主畫面選中的主任務與子任務 ID
let currentMainTaskId = tasks[0]?.id || null;
let currentSubtaskId = tasks[0]?.subtasks[0]?.id || null;

// 在「新增子任務 Modal」中暫時選擇的歸屬主任務 ID
let modalSelectedParentId = tasks[0]?.id || null;

步驟 2:CRUD 邏輯與重複名稱防呆機制

在執行新增或修改時,使用者可能不知道或是不小心重複輸入相同名稱的任務。因此我們要利用 JavaScript 陣列的原生 .some() 方法進行重複檢測:

// 新增主任務
function addMainTask(title) {
  const cleanTitle = title.trim();
  if (!cleanTitle) return;

  // 1. 重複名稱檢查
  const isDuplicate = tasks.some((t) => t.title === cleanTitle);
  if (isDuplicate) {
    showToast(`新增失敗:已存在名為「${cleanTitle}」的主任務`, true);
    return;
  }

  // 2. 寫入 State
  const newId = `task-${Date.now()}`;
  tasks.push({ id: newId, title: cleanTitle, subtasks: [] });

  // 3. 自動選取防空機制
  if (!currentMainTaskId) {
    currentMainTaskId = newId;
    currentSubtaskId = null;
  }

  showToast(`已成功新增主任務「${cleanTitle}」`);
  renderAll(); // 觸發畫面更新
}

補充
在子任務新增的處理上,若重複或輸入空白,函式會回傳 false;只有在回傳 true(成功)時,事件監聽器才會清空輸入框並關閉 Modal。這樣能避免使用者不小心打錯字時,彈窗直接關閉導致需要重新打開。


步驟 3:畫面統一渲染(State-Driven UI)

建立 renderAll() 作為單一渲染驅動點。每當 State(如 tasks、currentMainTaskId)改變時,只要呼叫 renderAll(),整個 UI 就會自動響應更新:

// 統一渲染入口
function renderAll() {
  renderManageMainTaskList();
  renderMainTaskDropdown();
  renderSubtaskDropdown();
  renderModalParentDropdown();
}

// 範例:動態渲染「管理主任務」列表
function renderManageMainTaskList() {
  if (!manageMainTaskList) return;

  if (tasks.length === 0) {
    manageMainTaskList.innerHTML = `<li class="p-3 text-center text-muted fs-sm">目前尚無主任務</li>`;
    return;
  }

  // 使用 Array.map 轉為 HTML 字串並渲染
  manageMainTaskList.innerHTML = tasks
    .map(
      (task) => `
    <li class="p-2 border-bottom">
      <div class="d-flex justify-content-between align-items-center">
        <p class="fw-bold mb-0">${task.title}</p>
        <div class="d-flex">
          <button type="button" class="btn btn-outline-secondary rounded-circle me-2 btn-sm" onclick="updateMainTask('${task.id}')">
            <i class="bi bi-pencil"></i>
          </button>
          <button type="button" class="btn btn-outline-danger rounded-circle btn-sm" onclick="deleteMainTask('${task.id}')">
            <i class="bi bi-trash"></i>
          </button>
        </div>
      </div>
    </li>
  `
    )
    .join("");
}

步驟 4:切換事件與 Modal 操作串接

當使用者點擊主介面下拉選單切換主任務時,程式碼會自動重新指派子任務的預設值,並呼叫 renderAll() 進行雙向連動:

// 切換選中的主任務
function selectMainTask(taskId) {
  currentMainTaskId = taskId;
  const task = tasks.find((t) => t.id === taskId);
  
  // 切換主任務時,預設自動選取該任務的第一個子任務
  currentSubtaskId = task?.subtasks[0]?.id || null;
  renderAll();
}

// 新增子任務 Modal 的確認點擊事件
btnConfirmAddSubtask.addEventListener("click", () => {
  const isSuccess = addSubtask(modalSelectedParentId, inputNewSubtaskName.value);

  // 只有新增成功時才清空並透過 Bootstrap API 手動關閉 Modal
  if (isSuccess) {
    inputNewSubtaskName.value = "";
    const modalEl = document.querySelector("#addSubTask");
    const modalObj = bootstrap.Modal.getInstance(modalEl);
    if (modalObj) modalObj.hide();
  }
});

Task CRUD


重點小結

  1. 單一事實來源(Single Source of Truth):
    無論是在 Modal 裡新增/修改/刪除主任務,全域的 tasks 陣列才是唯一標準。不直接在 DOM 上進行「刪除節點」或「修改文字」的手動操作,而是修改 State 數據後統一執行 renderAll() 重新渲染。

  2. 連動下拉選單(Dynamic Dependent Dropdowns):
    當切換「主任務」時,程式碼同步更新當前的 currentSubtaskId 並重新渲染子任務選單,有效避免了「主任務是 A,子任務選單卻顯示 B 的內容」這種狀態不一致的 Bug。

  3. Modal 自動關閉體驗:
    結合重複名稱的 showToast 警示,以及利用 bootstrap.Modal.getInstance(modalEl).hide() 的精準手動關閉,為使用者帶來更好的操作體驗。


個人筆記:
名詞介紹 | CRUD是什麼?4 個最基本的資料操作動作


上一篇
DAY 17|功能實作(五):計時結束跳出Modal 彈窗
下一篇
DAY 19|任務管理(二):自動狀態流轉邏輯
系列文
從 UI/UX 設計到前端實作:30 天打造專屬的任務學習計時器 共 20 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言