0
0

VuetifyでTodoアプリを作成

Last updated at Posted at 2024-03-05

概要

フロントエンド勉強会
ToDoアプリを作成して、Vue.jsでの実践的な開発をつかむ。
また、Vuetifyを用いてUIフレームワークへの頼り方をつかむ。

基本的にはvanillaのTypeScriptでToDoアプリを作成をVue.jsでなぞる。

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

環境構築

Get started with Vuetify 3に従う。

npm create vuetify

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

✔ 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/ 配下のコンポーネントが要らなくなったので削除。

rm -rf src/components/*

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

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

今回はアイコンも使用する。
用いるのはMaterial Design Iconsで、Icon Fontsに従いフォントの設定を行う。

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はデフォルトでは Vuetify 3 になっている。
これを変更するにはルート配下の index.html で変更できる。

mockAPI

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

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

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

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

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

APIはts-nodeで動かすのだが、Vue側のtsconfigもあるため、API用のtsconfigを別途作成する。

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

続いてAPI側のコードを作成していく。
APIはexpressjsで書いていく。
DBは用いないがリクエスト内容は揮発性でも残っていて欲しいので、オブジェクトで管理する。
モックAPIなので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 が存在する。
このファイルの下記部分を見て、

{
  server: {
    port: 3000,
  }
}

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

実装

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;
}

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

Vue

Vuetify

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

CSS

Vue

Vuetify

src/functions.ts

現在はuseFetchを用いるが、今回は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',
  });
}

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

Vuetify

実行

フロント

npm run dev

API

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

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0