Vue3 - 老樣子做個 TodoList - 代辦事項功能

tags: Vue
category: Front-End
description: Vue3 - 老樣子做個 TodoList - 代辦事項功能
created_at: 2022/07/27 22:00:00

cover image


回到 手把手開始寫 Vue3


前言

上一篇講完了關於使用者的部分,這一篇把剩餘的功能補齊。

還是放一下成品的長相,還是很陽春DEMO

cover image


剩餘的功能

  • 共通:
    • 查看代辦事項清單
      • 搜尋
      • 從狀態過濾
      • 切換狀態
      • 刪除代辦事項
  • 需登入:
    • 新增代辦事項

雖然看起來上一篇消化了很少,但實際上是做得有點細緻(其他沒列出來XD),所以剩下這些應該反而比上一篇快(?)


老樣子 開始

現在先來建一個 store 來放 todos 相關的資料:(順便寫好新增的 action)

import { defineStore } from "pinia";
import { useUserStore } from "./user";

export const useTodoStore = defineStore({
  id: "todo",
  state: () => ({
    todos: [],
  }),
  getters: {},
  actions: {
    initTodos() {
      localStorage.todos = localStorage.todos || "[]";
      this.todos = JSON.parse(localStorage.todos);
    },

    addTodo(text) {
      // 假設你想要存這個 todo 是誰建的可以拿
      // 我這邊雖然拿了但我其實後續沒有任何處理XD
      const userStore = useUserStore();

      const todo = {
        id: Math.random().toString(36).substr(2),
        username: userStore.username,
        text,
        done: false,
      };

      this.todos.push(todo);
      localStorage.todos = JSON.stringify(this.todos);
    },
  },
});

比較特別的地方只有希望重新整理之後資料還在,所以一樣把代辦事項存在 localStorage

再來要修改 Home 的頁面了

<script setup>
import { useUserStore } from "../stores/user";
import TheButton from "../components/TheButton.vue";

const userStore = useUserStore();
</script>

<template>
  <main>
    <h1 class="text-4xl text-center">
      Todos
      <TheButton
        class="bg-amber-300"
        @click="$router.push({ name: 'create-todo' })"
        v-if="userStore.isLogin"
        >Add</TheButton
      >
    </h1>
  </main>
</template>

這邊先做一個按鈕給他,可以連結到新增代辦事項的頁面,然後老樣子他會爆炸,因為根本還沒有那個路由,所以要去加路由跟頁面:

頁面的部分: AddTodoView.vue

<script setup>
import { ref } from "vue";
import { useRouter } from "vue-router";
import { useTodoStore } from "../stores/todo";
import TheInput from "../components/TheInput.vue";
import TheButton from "../components/TheButton.vue";

const todo = ref("");
const todoStore = useTodoStore();
const router = useRouter();

const addTodo = () => {
  todoStore.addTodo(todo);
  router.push({ name: "home" });
};
</script>

<template>
  <div>
    <h1 class="text-3xl ml-2">Add Todo</h1>

    <form @submit.prevent="addTodo">
      <div class="grid grid-cols-[100px_1fr] gap-2 items-center m-4">
        <TheInput label="todo-input" text="Todo: " v-model="todo" />
      </div>

      <div class="text-center mt-2 mx-4">
        <TheButton class="bg-green-500 text-white">Add</TheButton>
      </div>
    </form>
  </div>
</template>

路由的部分:

{
  path: "/create",
  name: "create-todo",
  component: () => import("../views/AddTodoView.vue"),
  meta: {
    requireAuth: true,
  },
},

都好之後基本上就可以正常新增 todo 了,但是目前畫面上還沒有把資料印出來。

所以再來就需要一個 TodoList 的組件。

<script setup>
import { useTodoStore } from "../stores/todo";
import TheTodo from "./TheTodo.vue";

const todoStore = useTodoStore();
todoStore.initTodos();
</script>

<template>
  <div class="max-w-[640px] mx-auto my-2">
    <div class="grid grid-cols-[100px_1fr_max-content] gap-2">
      <div class="text-center font-black">Id</div>
      <div class="text-center font-black">Text</div>
      <div class="text-center font-black">Operation</div>

      <TheTodo
        v-for="todo in todoStore.todos"
        :key="todo.id"
        :id="todo.id"
        :text="todo.text"
        :done="todo.done"
      />
    </div>
  </div>
</template>

然後每一個 todo 又是一個組件,這邊叫他 TheTodo.vue

<script setup>
import TheButton from "./TheButton.vue";

const props = defineProps({
  id: String,
  text: String,
  done: Boolean,
});
</script>

<template>
  <div>
    {{ props.id }}
  </div>

  <div class="flex-grow px-4">
    {{ props.text }}
  </div>

  <div class="text-center">
    <TheButton
      class="text-white"
      :class="{
        'bg-yellow-500': props.done,
        'bg-green-500': !props.done,
      }"
    >
      {{ props.done ? "UnDone" : "Done" }}
    </TheButton>
    <TheButton class="bg-red-500 text-white"> Delete </TheButton>
  </div>
</template>

畫面好了之後記得到 Home 要去使用他:

<script setup>
import TodoList from "../components/TodoList.vue";

</script>

<template>
  <main>
    <!-- ... -->
    <TodoList />
  </main>
</template>

到這邊回到首頁應該就會列出代辦事項清單了,但是按鈕都還沒有功能,所以要加兩個 action:

{
  // ...
  deleteTodo(id) {
    this.todos = this.todos.filter((todo) => todo.id !== id);
    localStorage.todos = JSON.stringify(this.todos);
  },

  switchStatus(id) {
    const todo = this.todos.find((todo) => todo.id === id);
    todo.done = !todo.done;
    localStorage.todos = JSON.stringify(this.todos);
  },
}

然後記得要在畫面上使用這兩個方法:

<TheButton
  class="text-white"
  :class="{
    'bg-yellow-500': props.done,
    'bg-green-500': !props.done,
  }"
  @click="todoStore.switchStatus(props.id)"
>
  {{ props.done ? "UnDone" : "Done" }}
</TheButton>
<TheButton
  class="bg-red-500 text-white"
  @click="todoStore.deleteTodo(props.id)"
>
  Delete
</TheButton>

上面記得也要 importuse:

import { useTodoStore } from "../stores/todo";

const todoStore = useTodoStore();

這樣應該就可以切換狀態跟刪除了。


搜尋與過濾

這是剩下的兩個功能

要做這些功能,這邊的做法是也存一下搜尋的狀態,在透過 getters 回傳 todos,所以也改一下 store,另外因為中間步驟有點繁雜,所以直接貼最終的 code

import { defineStore } from "pinia";
import { useUserStore } from "./user";

export const useTodoStore = defineStore({
  id: "todo",
  state: () => ({
    search: {
      text: "",
      done: undefined,
    },
    rawTodos: [],
  }),
  getters: {
    todos: (state) =>
      state.rawTodos
        .filter(
          (todo) =>
            state.search.done === undefined ||
            todo.done.toString() === state.search.done
        )
        .filter((todo) => todo.text.includes(state.search.text)),
  },
  actions: {
    initTodos() {
      localStorage.todos = localStorage.todos || "[]";
      this.rawTodos = JSON.parse(localStorage.todos);
    },

    addTodo(text) {
      const userStore = useUserStore();

      const todo = {
        id: Math.random().toString(36).substr(2),
        username: userStore.username,
        text,
        done: false,
      };

      this.rawTodos.push(todo);
      localStorage.todos = JSON.stringify(this.rawTodos);
    },

    deleteTodo(id) {
      this.rawTodos = this.rawTodos.filter((todo) => todo.id !== id);
      localStorage.todos = JSON.stringify(this.rawTodos);
    },

    switchStatus(id) {
      const todo = this.rawTodos.find((todo) => todo.id === id);
      todo.done = !todo.done;
      localStorage.todos = JSON.stringify(this.rawTodos);
    },

    setSearch({ text, done }) {
      if (text !== undefined) {
        this.search.text = text;
      }

      this.search.done = done;
    },
  },
});

上面會發現原本宣告叫做 todos ,後來一時想不到其他名字就改成了 rawTodos(原始的 todos),而 todos 這個名稱移到了 getters ,他負責跟 search 相關的東西做一些運算產出結果,所以前面畫面上使用到的地方一個字都不用改。

然後 done 的部分也是用一個布林代表三種狀態,分別是

  • undefined = 沒設定過濾 = 全部顯示
  • true = 只顯示完成的
  • false = 只顯示還沒完成的

上面主要的重點大概是這樣 (偷懶)

再來 畫面的部分也要修改,首先先處理過濾好了:

這邊過濾特別練一下使用 GET 參數(也可以自己採用別的方式做),所以先在做一個組件出來,這邊叫他 TodoFilterLink.vue

<script setup>
import { RouterLink } from "vue-router";

const props = defineProps({
  done: String,
});
</script>

<template>
  <RouterLink
    class="hover:brightness-105 mx-1"
    :class="{ 'text-blue-500': $route.query.done === props.done }"
    :to="{
      name: 'home',
      query: { done: props.done ? props.done === 'true' : undefined },
    }"
  >
    <slot></slot>
  </RouterLink>
</template>

主要就是希望可以吃一個 done 的字串,然後幫我帶入 query 裡面跟一些樣式的修改。

TodoList.vue 畫面的部分要加上:

<div class="max-w-[640px] mx-auto my-2">
  <div>
    過濾:
    <TodoFilterLink done="true">Done</TodoFilterLink>
    <TodoFilterLink done="false">UnDone</TodoFilterLink>
    <TodoFilterLink>None</TodoFilterLink>
  </div>

  <!-- .... -->
</div>

javascript 的部分則要記得 import

import TodoFilterLink from "./TodoFilterLink.vue";

到這邊至少可以點,有帶入網址了,但是他沒有起作用,所以要去監聽他改變就要設定進 search 的物件。

import { onMounted } from "vue";
import { onBeforeRouteUpdate, useRoute } from "vue-router";

// ...
const route = useRoute();
onBeforeRouteUpdate((to) => {
  const { done } = to.query;
  todoStore.setSearch({ done });
});

onMounted(() => {
  const { done } = route.query;
  todoStore.setSearch({ done });
});

這樣子過濾的功能就完成了,最後剩下搜尋。

都寫到這邊了,搜尋就變得相對簡單,只要下面這一段就完成了。


<div class="max-w-[640px] mx-auto my-2">
  <!-- ... -->

  <TheInput
    type="text"
    placeholder="Search.."
    class="w-full my-4"
    @input="
      todoStore.setSearch({
        ...todoStore.search,
        text: $event.target.value,
      })
    "
  />

  <!-- ... -->
</div>

最後想幫這個搜尋框加一個功能,就是當使用者輸入完一段時間沒更新才執行搜尋,這個功能在現在其實沒什麼必要(?),因為也沒去打 API,不會造成什麼負擔,只是為了做而做,當作一個 bonus

這個功能叫做 debounce,這個語法印象中在 lodash 有提供,不過這邊自己做一個。

所以先建立一個 src/utils.js 專門放一些小功能。

export const useDebounce = (ms = 300) => {
  let timeout = null;
  return (fn) => {
    clearTimeout(timeout);
    timeout = setTimeout(() => fn(), ms);
  };
};

然後使用起來也很簡單,基本上就是建立一個 debounce 的函數,然後把要執行的函數包起來,像是下面這樣:

<script setup>
// ...
import { useDebounce } from "../utils";

// ...
const debounce = useDebounce(300);

// ...
</script>

<!-- ... -->
<TheInput
  type="text"
  placeholder="Search.."
  class="w-full my-4"
  @input="
    debounce(() =>
      todoStore.setSearch({
        ...todoStore.search,
        text: $event.target.value,
      })
    )
  "
/>

這時候你到前端去搜尋的時候,如果一直輸入就不會過濾,直到你停下 300ms 才會真的去搜尋。

這樣未來在打 API 搜尋才不會給伺服器造成太大負擔。


總結

到這邊終於大功告成了,雖然也沒有特別複雜的功能也不夠完整(?),而且還缺滿多可以做的,不過其他就當作自己的練習吧XD




最後更新時間: 2022年07月27日.