Agent Grow Tech Notes
🕌

TypeScript & GraphQL でToDoアプリを開発する #5

に公開

⬅️前回の記事はこちら

https://zenn.dev/agent_grow/articles/c5a622f1a98522

画面開発 & 動作確認

⭐️画面開発

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のおわりに

画面開発 & 動作確認 お疲れさまでした。
次回はダークモード/ライトモードの切り替え機能を実装します。
https://zenn.dev/agent_grow/articles/939d9197dd87c5

〜Vuetifyの紹介〜

VuetifyはVueに対応したUIコンポーネントライブラリです。
ここでは、Vuetifyの特徴を3つに絞って紹介します。

◼️Material Design に準拠した美しいデザイン
 - Googleが提唱するMaterial Designに沿った洗練されたUIを、
  複雑なCSSやJavaScriptを書かずに実装できます。

◼️コンポーネントが豊富
 - ボタンやテーブル、ダイアログ、ナビゲーションなど、
  多数のUIコンポーネントが用意されており、幅広い画面構成に対応できます。

◼️カスタマイズ性が高い
 - テーマカラーの設定やレイアウトの調整などが柔軟に行えるため、
  開発するサービスの要件やガイドラインに合わせたカスタマイズが可能です。

Agent Grow Tech Notes
Agent Grow Tech Notes

Discussion