VuetifyでTodoアプリを作成

# 概要

[フロントエンド勉強会](https://qiita.com/masayasviel/private/0e5a403388509e98313d)
ToDoアプリを作成して、[Vue.js](https://ja.vuejs.org/)での実践的な開発をつかむ。
また、[Vuetify](https://vuetifyjs.com/en/)を用いてUIフレームワークへの頼り方をつかむ。

基本的には[vanillaのTypeScriptでToDoアプリを作成](https://qiita.com/masayasviel/items/577020381900c529a1cc)をVue.jsでなぞる。

本記事では前回([フロントエンド勉強会 Vue.jsハンズオン](https://qiita.com/masayasviel/private/c0b8f1bff98aba2dd91e))同様にVue3を用いる。ネットで調べるとVue2の書き方をしている記事もかなりヒットするので注意。

# 環境構築

[Get started with Vuetify 3](https://vuetifyjs.com/en/getting-started/installation/)に従う。

```sh
npm create vuetify
```

色々訊かれるので以下のように回答。
プロジェクト名は好きな名前をつければよい。
%project_name% を置き換える。
何使えばいいかは私が[npm](https://www.npmjs.com/)使っているのでnpmを選択。

- [yarn](https://yarnpkg.com/)
- [pnpm](https://pnpm.io/)
- [bun](https://bun.sh/)

```sh
✔ Project name: … %project_name%
✔ Which preset would you like to install? › Default (Vuetify)
✔ Use TypeScript? … Yes
✔ Would you like to install dependencies with yarn, npm, pnpm, or bun? › npm
```

前回同様に要らないものはクリアする。

```App.vue
<script setup lang="ts">
</script>

<template>
  <main>
  </main>
</template>

<style scoped>
</style>
```

components/ 配下のコンポーネントが要らなくなったので削除。

```sh
rm -rf src/components/*
```

消していいか確認されたら y でOK。

```sh
sure you want to delete the only file in /path/to/%project_name%/src/components [yn]? y
```

今回はアイコンも使用する。
用いるのは[Material Design Icons](https://pictogrammers.com/library/mdi/)で、[Icon Fonts](https://vuetifyjs.com/en/features/icon-fonts/#mdi-css)に従いフォントの設定を行う。

```sh
npm install @mdi/js
```

`src/plugins/vuetify.ts` も下記のように修正。

```vuetify.ts
// Styles
import '@mdi/font/css/materialdesignicons.css';
import { aliases, mdi } from 'vuetify/iconsets/mdi';
import 'vuetify/styles';

// Composables
import { createVuetify } from 'vuetify';

export default createVuetify({
  theme: {
    themes: {
      light: {
        colors: {
          primary: '#1867C0',
          secondary: '#5CBBF6',
          error: '#B00020',
        },
      },
    },
  },
  icons: {
    defaultSet: 'mdi',
    aliases,
    sets: {
      mdi,
    },
  },
});
```

## Title

headの[title](https://developer.mozilla.org/ja/docs/Web/HTML/Element/title)はデフォルトでは `Vuetify 3` になっている。
これを変更するにはルート配下の `index.html` で変更できる。

# mockAPI

今回はフロントで完結せず、APIと合わせた本格的なアプリケーションにしていく。
とはいえ、APIまで本格的に行うつもりはないのでモックAPIを作成する。

APIもTypeScriptで書けるので下記コマンドで追加のパッケージをインストールする。
APIサイドの解説は行わない。

```sh
npm install express @types/express ts-node uuid @types/uuid cors @types/cors
```

API側のコードを先んじて作成する。

```sh
touch tsconfig.mockapi.json
mkdir mock-api
touch mock-api/index.ts mock-api/interfaces.ts
```

APIは[ts-node](https://typestrong.org/ts-node/)で動かすのだが、Vue側のtsconfigもあるため、API用のtsconfigを別途作成する。

```tsconfig.mockapi.json
{
  "compilerOptions": {
    "target": "es2016",
    "module": "commonjs",
    "esModuleInterop": true,
    "forceConsistentCasingInFileNames": true,
    "strict": true
  }
}
```

続いてAPI側のコードを作成していく。
APIは[expressjs](https://expressjs.com/ja/)で書いていく。
DBは用いないがリクエスト内容は揮発性でも残っていて欲しいので、オブジェクトで管理する。
モックAPIなので[cors](https://developer.mozilla.org/ja/docs/Web/HTTP/CORS)は気にしない。

型付けや条件分岐が中途半端だが、その辺は無視いただきたい。

## interfaces.ts

```interfaces.ts
const ITEM_STATE = ['未着手', '進行', '完了'] as const;
export type ItemStateLiteral = typeof ITEM_STATE[number];

export interface Tasks {
  title: string;
  status: ItemStateLiteral;
}

export interface TaskListResponse {
  title: string;
  uuid: string;
  status: ItemStateLiteral;
}
```

## index.ts

```index.ts
import express from 'express';
import cors from 'cors';
import { v4 as uuidv4 } from 'uuid';
import { Tasks, TaskListResponse, ItemStateLiteral } from './interfaces';

const app = express();
app.use(express.json());
app.use(cors());

const data: {
  [key in string]: Tasks;
} = {
  default1: {
    title: 'タイトル1',
    status: '未着手',
  },
  default2: {
    title: 'タイトル2',
    status: '進行',
  },
  default3: {
    title: 'タイトル3',
    status: '完了',
  },
  default4: {
    title: 'タイトル4',
    status: '未着手',
  },
};

app.get('/', (_req, res) => {
  const responseData = {
    notStartedYetCategory: new Array<TaskListResponse>(),
    wipCategory: new Array<TaskListResponse>(),
    finishCategory: new Array<TaskListResponse>(),
  };
  Object.entries(data).forEach(([key, task]) => {
    switch (task.status) {
      case '未着手':
        responseData.notStartedYetCategory.push({
          uuid: key,
          ...task,
        });
        break;
      case '進行':
        responseData.wipCategory.push({
          uuid: key,
          ...task,
        });
        break;
      case '完了':
        responseData.finishCategory.push({
          uuid: key,
          ...task,
        });
        break;
    }
  });
  res.status(200).send(responseData);
});

app.post('/', (req, res) => {
  data[uuidv4()] = req.body as Tasks;
  res.status(201).send('Created');
});

app.patch('/:uuid/status', (req, res) => {
  const uuid = req.params.uuid;
  if (!(uuid in data)) {
    res.status(404).send('Not Found');
    return;
  }
  data[uuid].status = req.body['status'] as ItemStateLiteral;
  res.status(204).send('No Content');
});

app.delete('/:uuid', (req, res) => {
  const uuid = req.params.uuid;
  if (!(uuid in data)) {
    res.status(404).send('Not Found');
    return;
  }
  delete data[uuid];
  res.status(204).send('No Content');
});

app.listen(4200, () => {
  console.log('http://localhost:4200');
});
```

APIは `http://localhost:4200` に立ち上げている。
2023/12現在、Vueで新しくプロジェクトを立ち上げるとルート直下に　`vite.config.ts` が存在する。
このファイルの下記部分を見て、

```ts
{
  server: {
    port: 3000,
  }
}
```

`4200` 以外であればそれでいい。
もしこの部分が4200ならばポートがバッティングしてしまうので3000に書き換えるなどしていただきたい。

# 実装

```sh
mkdir src/interfaces
touch src/interfaces/task.interface.ts
touch src/components/task-form.vue
touch src/components/category.vue
touch functions.ts
```

## src/interfaces/task.interface.ts

```task.interface.ts
/** タスクの状態 */
export const ITEM_STATE = ['未着手', '進行', '完了'] as const;
/** タスクの状態リテラル型 */
export type ItemStateLiteral = typeof ITEM_STATE[number];

/** タスク型 */
export interface TaskInterface {
  title: string;
  uuid: string;
  status: ItemStateLiteral;
}
```

- [constアサーション](https://typescriptbook.jp/reference/values-types-variables/const-assertion)
- [配列から型を生成する](https://typescriptbook.jp/tips/generates-type-from-array)
- [interfaceとtypeの違い](https://typescriptbook.jp/reference/object-oriented/interface/interface-vs-type-alias)

## src/components/task-form.vue

```task-form.vue
<script setup lang="ts">
import { computed, ref } from 'vue';

/** 入力値 */
const taskInput = ref('');
/** フォームルール */
const rules = {
  maxlength: (value: string) => value.length < 30 || 'cannot input string length over 30',
}

/** emit */
const emit = defineEmits<{
  (e: 'addTask', taskInput: string): void;
  (e: 'inputValueEmpty'): void;
}>();

/** ボタンの活性制御 */
const buttonDisabled = computed(() => taskInput.value.length > 30);

/** 新しいタスクの追加 */
function addNewTask() {
  const inputValue = taskInput.value;
  if (!inputValue) {
    emit('inputValueEmpty');
    return;
  }
  emit('addTask', inputValue);
  taskInput.value = '';
}
</script>

<template>
  <div class="d-flex align-center ga-4 ma-5 form-width">
    <v-text-field
      v-model.trim="taskInput"
      :rules="[rules.maxlength]"
      label="タスク"
      type="input"
      variant="underlined"
    ></v-text-field>

    <v-btn @click="addNewTask" :disabled="buttonDisabled" prepend-icon="mdi-plus">
      追加
    </v-btn>
  </div>
</template>

<style scoped>
.form-width {
  width: 34%;
}
</style>
```

TypeScript

- [論理和](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Logical_OR)

Vue

- [リアクティビティーの基礎](https://ja.vuejs.org/guide/essentials/reactivity-fundamentals)
- [フォーム入力バインディング](https://ja.vuejs.org/guide/essentials/forms#text)
- [v-modelの双方向バインディング](https://ja.vuejs.org/guide/components/v-model)
- [イベントハンドリング](https://ja.vuejs.org/guide/essentials/event-handling)
- [コンポーネントの基礎](https://ja.vuejs.org/guide/essentials/component-basics)
- [emit](https://vuejs.org/guide/components/events.html)
- [computed](https://ja.vuejs.org/guide/essentials/computed)

Vuetify

- [v-text-field](https://vuetifyjs.com/en/components/text-fields/)
- [validation-rules](https://vuetifyjs.com/en/components/text-fields/#validation-26-rules)
- [v-button](https://vuetifyjs.com/en/components/buttons)
- [flex](https://vuetifyjs.com/en/styles/flex)
- [spacing](https://vuetifyjs.com/en/styles/spacing)

## src/components/category.vue

```category.vue
<script setup lang="ts">
import { computed } from 'vue';
import { TaskInterface, ItemStateLiteral } from '@/interfaces/task.interface';

/** props */
const props = defineProps<{
  title: ItemStateLiteral;
  tasks: TaskInterface[]
}>();

/** emit */
const emit = defineEmits<{
  (e: 'onClickTask', uuid: string, status: ItemStateLiteral): void;
}>();

/** 所属タスク */
const tasks = computed(() => [...props.tasks]);
</script>

<template>
  <v-card
    class="mx-auto category-box"
  >
    <v-list>
      <v-list-subheader>{{ props.title }}</v-list-subheader>
      <v-list-item
        v-for="task in tasks"
        :key="task.uuid"
        class="task-item"
        @click="emit('onClickTask', task.uuid, props.title)"
      >
        <v-list-item-title v-text="task.title"></v-list-item-title>
      </v-list-item>
    </v-list>
  </v-card>
</template>

<style scoped>
.category-box {
  width: 25%;
}
.task-item:hover {
  background-color: #eeeeee;
}
</style>
```

TypeScript

- [スプレッド演算子](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/Spread_syntax)
- [ジェネリクス](https://typescriptbook.jp/reference/generics)

CSS

- [擬似クラス](https://developer.mozilla.org/ja/docs/Web/CSS/Pseudo-classes)
  - [:hover](https://developer.mozilla.org/ja/docs/Web/CSS/:hover)

Vue

- [props](https://ja.vuejs.org/guide/components/props)
- [v-for](https://ja.vuejs.org/guide/essentials/list)
    - [key](https://ja.vuejs.org/guide/essentials/list#maintaining-state-with-key)

Vuetify

- [v-card](https://vuetifyjs.com/en/components/cards)
- [v-list](https://vuetifyjs.com/en/components/lists)

## src/functions.ts

現在は[useFetch](https://ja.vuejs.org/guide/reusability/composables#async-state-example)を用いるが、今回はfetch処理が初出のため標準のを用いる。

```functions.ts
import { ItemStateLiteral, TaskInterface } from '@/interfaces/task.interface';

const ENDPOINT = 'http://localhost:4200';

/** タスク一覧レスポンス */
interface TaskListResponse {
  notStartedYetCategory: TaskInterface[];
  wipCtegory: TaskInterface[];
  finishCategory: TaskInterface[];
}

/** タスク一覧取得 */
export async function getTaskList(): Promise<TaskListResponse> {
  const data = await fetch(ENDPOINT);
  return data.json();
}

/**
 * タスク作成
 * @param title タスクタイトル
 */
export async function createNewTask(title: string): Promise<void> {
  const body = {
    title: title,
    status: '未着手',
  };
  await fetch(ENDPOINT, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify(body),
  });
}

/**
 * タスク状態更新
 * @param uuid タスクUUID
 * @param status タスク新状態
 */
export async function updateStatus(uuid: string, status: ItemStateLiteral): Promise<void> {
  await fetch(`${ENDPOINT}/${uuid}/status`, {
    method: 'PATCH',
    headers: {
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({
      status,
    }),
  });
}

/**
 * タスク削除
 * @param uuid タスクUUID
 */
export async function deleteTask(uuid: string): Promise<void> {
  await fetch(`${ENDPOINT}/${uuid}`, {
    method: 'DELETE',
  });
}
```

- [戻り値がない関数とvoid型](https://typescriptbook.jp/reference/functions/void-type)
- [APIとは](https://developer.mozilla.org/ja/docs/Learn/JavaScript/Client-side_web_APIs/Introduction)
- [HTTP](https://developer.mozilla.org/ja/docs/Web/HTTP)
- [HTTPリクエストメソッド](https://developer.mozilla.org/ja/docs/Web/HTTP/Methods)
- [フェッチAPIの使用](https://developer.mozilla.org/ja/docs/Web/API/Fetch_API/Using_Fetch)
- [フェッチAPI](https://developer.mozilla.org/ja/docs/Web/API/Fetch_API)
- [プロミスの使用](https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Using_promises)
- [Promise](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Promise)
- [async function](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/async_function)
- [await](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Operators/await)
- [Content-Type](https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Content-Type)
- [JSON](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/JSON)
  - [JSON.stringify](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify)
  - [JSON.parse](https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse)

## src/App.vue

```App.vue
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue';
import TaskForm from '@/components/task-form.vue';
import Category from '@/components/category.vue';
import { ITEM_STATE, ItemStateLiteral, TaskInterface } from '@/interfaces/task.interface';
import { getTaskList, createNewTask, updateStatus, deleteTask } from '@/functions';

/** --- 管理 --- */

/** スナックバー管理 */
const snackbar = reactive({
  isShow: false,
  text: '',
  timeout: 1000,
});
/** ダイアログ管理 */
const dialog = reactive({
  isShow: false,
});

/** --- public reactive var --- */

/** 状態選択モデル */
const stateModel = ref<ItemStateLiteral>('未着手');
/** 未着手タスク */
const notStartedYetCategory = ref<TaskInterface[]>([]);
/** 進行中タスク */
const wipCategory = ref<TaskInterface[]>([]);
/** 完了タスク */
const finishCategory = ref<TaskInterface[]>([]);

/** --- private var  --- */

/**
 * 現在選択されているUUID
 * @private
 */
let _currentSelectUuid = '';

/** --- life cycle --- */

onMounted(async () => {
  await _getTaskList();
});

/** --- public method --- */

/** スナックバー表示 */
function showSnackbar(): void {
  snackbar.isShow = true;
}

/** スナックバー非表示 */
function closeSnackbar(): void {
  snackbar.isShow = false;
}

/** タスクの追加 */
async function addNewTask(input: string): Promise<void> {
  await createNewTask(input);
  await _getTaskList();
  snackbar.text = 'タスクを追加しました';
  showSnackbar();
}

/** 空文字でタスクを追加しようとしたとき */
function inputValueEmpty(): void {
  snackbar.text = '空文字だけの追加はできません';
  showSnackbar();
}

/** タスククリックでダイアログを表示 */
function onClickTask(uuid: string, status: ItemStateLiteral): void {
  _currentSelectUuid = uuid;
  stateModel.value = status;
  dialog.isShow = true;
}

/** タスクの進行状態変更 */
async function onClickCategoryChangeButton(): Promise<void> {
  await updateStatus(_currentSelectUuid, stateModel.value);
  await _getTaskList();
  dialog.isShow = false;
  _currentSelectUuid = '';
  snackbar.text = `${stateModel.value}に変更しました`;
  showSnackbar();
}

/** タスク削除 */
async function onClickDeleteTask(): Promise<void> {
  await deleteTask(_currentSelectUuid);
  await _getTaskList();
  dialog.isShow = false;
  _currentSelectUuid = '';
  snackbar.text = `削除しました`;
  showSnackbar();
}

/** --- private method --- */
async function _getTaskList(): Promise<void> {
  const taskList = await getTaskList();
  notStartedYetCategory.value = taskList.notStartedYetCategory;
  wipCategory.value = taskList.wipCategory;
  finishCategory.value = taskList.finishCategory;
}
</script>

<template>
  <main>
    <TaskForm @addTask="addNewTask" @inputValueEmpty="inputValueEmpty"></TaskForm>
    <div class="d-flex flex-row justify-space-around categories">
      <Category title="未着手" :tasks="notStartedYetCategory" @onClickTask="onClickTask"></Category>
      <Category title="進行" :tasks="wipCategory" @onClickTask="onClickTask"></Category>
      <Category title="完了" :tasks="finishCategory" @onClickTask="onClickTask"></Category>
    </div>

    <v-dialog
      v-model="dialog.isShow"
      :persistent="true"
      width="300"
    >
      <v-card>
        <v-card-title>どうする？</v-card-title>
        <v-card-item>
          <v-select
            label="状態"
            v-model="stateModel"
            :items="ITEM_STATE"
          ></v-select>
        </v-card-item>
        <v-card-actions class="d-flex justify-space-around">
          <v-btn @click="dialog.isShow = false">キャンセル</v-btn>
          <v-btn color="primary" @click="onClickCategoryChangeButton">変更</v-btn>
          <v-btn color="error" @click="onClickDeleteTask">削除</v-btn>
        </v-card-actions>
      </v-card>
    </v-dialog>

    <v-snackbar
      v-model="snackbar.isShow"
      :timeout="snackbar.timeout"
    >
      {{ snackbar.text }}

      <template v-slot:actions>
        <v-btn
          color="blue"
          variant="text"
          @click="closeSnackbar"
        >
          Close
        </v-btn>
      </template>
    </v-snackbar>
  </main>
</template>

<style scoped>
.categories {
  height: 600px;
}
</style>
```

Vue

- [ライフサイクルフック](https://ja.vuejs.org/guide/essentials/lifecycle)
- [オプション: ライフサイクル](https://ja.vuejs.org/api/options-lifecycle)
- [slot](https://ja.vuejs.org/guide/components/slots)
- [ビルトインの特別な要素](https://ja.vuejs.org/api/built-in-special-elements)

Vuetify

- [v-snackbar](https://vuetifyjs.com/en/components/snackbars)
- [v-dialog](https://vuetifyjs.com/en/components/dialogs)
- [v-select](https://vuetifyjs.com/en/components/selects)
- [Theme configuration](https://vuetifyjs.com/en/features/theme/)

# 実行

フロント

```sh
npm run dev
```

API

```sh
npx ts-node -P tsconfig.mockapi.json mock-api/index.ts
```