🕌
TypeScript & GraphQL でToDoアプリを開発する #5
⬅️前回の記事はこちら
画面開発 & 動作確認
⭐️画面開発
frontend/にVuetifyやアイコンフォントを導入
% npm install vuetify@^3.0 sass sass-loader @mdi/font -D
index.vueを本格的な画面に修正
index.vue
<template>
<!-- メインの表示領域 -->
<v-container class="py-10 todo-container">
<!-- タスク追加フォーム -->
<v-card elevation="4" class="pa-4 mb-4">
<v-card-title class="text-h6">新しいタスク</v-card-title>
<v-form @submit.prevent="createTodo" class="d-flex gap-2 mt-2">
<v-text-field
v-model="newTitle"
label="新しいタスクを入力"
dense
hide-details
variant="outlined"
/>
<v-btn color="primary" type="submit">追加</v-btn>
</v-form>
</v-card>
<!-- 読み込み中 / エラー表示 -->
<v-alert v-if="pending" type="info" variant="text" class="mb-4">読み込み中...</v-alert>
<v-alert v-else-if="error" type="error" class="mb-4">エラー: {{ error.message }}</v-alert>
<!-- タスクリストの表示 -->
<v-card
v-for="todo in todos"
:key="todo.id"
elevation="2"
class="pa-3 mb-3"
>
<div class="d-flex align-center justify-space-between w-100">
<!-- 編集モードではないときの表示 -->
<template v-if="editingId !== todo.id">
<div class="d-flex align-center gap-3">
<!-- 完了状態のチェックボックス -->
<v-checkbox
:model-value="todo.completed"
@update:model-value="() => toggleCompleted(todo)"
density="compact"
hide-details
/>
<!-- タイトル(完了時は取り消し線) -->
<span :class="{ 'text-decoration-line-through': todo.completed }">
{{ todo.title }}
</span>
</div>
<!-- 編集 / 削除 メニュー -->
<v-menu
:opened="menuOpenId === todo.id"
@update:opened="(val: boolean) => (menuOpenId = val ? todo.id : null)"
location="bottom"
>
<template #activator="{ props }">
<v-btn icon v-bind="props"><v-icon>mdi-dots-horizontal</v-icon></v-btn>
</template>
<v-list>
<v-list-item @click="startEdit(todo)">
<v-list-item-title>編集</v-list-item-title>
</v-list-item>
<v-list-item @click="deleteTodo(todo.id)">
<v-list-item-title>削除</v-list-item-title>
</v-list-item>
</v-list>
</v-menu>
</template>
<!-- 編集モードのときの表示 -->
<template v-else>
<v-text-field
v-model="editedTitle"
density="compact"
hide-details
class="flex-grow-1"
/>
<div class="d-flex gap-2">
<v-btn size="small" color="primary" @click="saveEdit(todo.id)">保存</v-btn>
<v-btn size="small" color="grey" @click="cancelEdit">キャンセル</v-btn>
</div>
</template>
</div>
</v-card>
</v-container>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useTodos } from '@/composables/useTodos'
import { CREATE_TODO, UPDATE_TODO, DELETE_TODO } from '@/graphql/mutations'
import { useNuxtApp } from '#app'
/**
* Todoの型定義
*/
interface Todo {
id: number
title: string
completed: boolean
}
/**
* Composableから状態取得
*/
const { todos, pending, error, refresh } = useTodos()
const { $apollo } = useNuxtApp()
/**
* 入力および編集中の状態管理
*/
const newTitle = ref('')
const editedTitle = ref('')
const editingId = ref<number | null>(null)
const menuOpenId = ref<number | null>(null)
/**
* タスクを新規作成する処理
*/
const createTodo = async () => {
if (!newTitle.value.trim()) return
await $apollo.mutate({
mutation: CREATE_TODO,
variables: { title: newTitle.value }
})
newTitle.value = ''
refresh()
}
/**
* 完了状態を切り替える処理
*/
const toggleCompleted = async (todo: Todo) => {
await $apollo.mutate({
mutation: UPDATE_TODO,
variables: { id: todo.id, completed: !todo.completed }
})
refresh()
}
/**
* タスクを削除する処理
*/
const deleteTodo = async (id: number) => {
await $apollo.mutate({
mutation: DELETE_TODO,
variables: { id }
})
refresh()
closeMenu()
}
/**
* 編集モードに切り替える処理
*/
const startEdit = (todo: Todo) => {
editingId.value = todo.id
editedTitle.value = todo.title
closeMenu()
}
/**
* 編集をキャンセルする処理
*/
const cancelEdit = () => {
editingId.value = null
editedTitle.value = ''
}
/**
* 編集内容を保存する処理
*/
const saveEdit = async (id: number) => {
if (!editedTitle.value.trim()) return
await $apollo.mutate({
mutation: UPDATE_TODO,
variables: { id, title: editedTitle.value }
})
editingId.value = null
editedTitle.value = ''
refresh()
}
/**
* メニューを閉じる処理
*/
const closeMenu = () => {
menuOpenId.value = null
}
</script>
<style scoped>
/* 完了タスクの取り消し線 */
.text-decoration-line-through {
text-decoration: line-through;
}
/* コンポーネント間の余白調整 */
.gap-2 {
gap: 8px;
}
.gap-3 {
gap: 12px;
}
/* 中央寄せ・幅制限 */
.todo-container {
max-width: 600px;
margin: 0 auto;
}
</style>
frontend/にplugins/vuetify.tsを作成
vuetify.ts
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'
// Nuxtプラグインとして Vuetify を定義
export default defineNuxtPlugin((nuxtApp) => {
// Vuetify インスタンスの作成
const vuetify = createVuetify({
components, // 全てのVuetifyコンポーネントを登録
directives, // 全てのVuetifyディレクティブを登録
ssr: true, // SSR(サーバーサイドレンダリング)対応を有効にする
})
// 作成したVuetifyインスタンスを Nuxt アプリに組み込む
nuxtApp.vueApp.use(vuetify)
})
frontend/のnuxt.config.tsを修正
nuxt.config.ts
export default defineNuxtConfig({
compatibilityDate: '2025-05-15',
devtools: { enabled: true },
css: [ // 追加
'vuetify/styles', // 追加
'@mdi/font/css/materialdesignicons.css', // 追加
], // 追加
build: { // 追加
transpile: ['vuetify'], // 追加
}, // 追加
vite: { // 追加
define: { // 追加
'process.env.DEBUG': false, // 追加
}, // 追加
}, // 追加
})
frontend/で npm run dev を実行
% npm run dev
http://localhost:3001 にアクセスしてindex.vueの内容が表示されたら成功
🚀画面開発が完了!!!
⭐️動作確認
✏️タスクの作成ができることを確認
✅タスクの完了ができることを確認
📑タスクの編集ができることを確認
🗑️タスクの削除ができることを確認
🚀動作確認が完了!!!
#5のおわりに
画面開発 & 動作確認 お疲れさまでした。
次回はダークモード/ライトモードの切り替え機能を実装します。
〜Vuetifyの紹介〜
VuetifyはVueに対応したUIコンポーネントライブラリです。
ここでは、Vuetifyの特徴を3つに絞って紹介します。
◼️Material Design に準拠した美しいデザイン
- Googleが提唱するMaterial Designに沿った洗練されたUIを、
複雑なCSSやJavaScriptを書かずに実装できます。
◼️コンポーネントが豊富
- ボタンやテーブル、ダイアログ、ナビゲーションなど、
多数のUIコンポーネントが用意されており、幅広い画面構成に対応できます。
◼️カスタマイズ性が高い
- テーマカラーの設定やレイアウトの調整などが柔軟に行えるため、
開発するサービスの要件やガイドラインに合わせたカスタマイズが可能です。
Discussion