Vue3 × Vuetify3 × Laravelでタスク管理アプリ作ってみた

Laravel

こんにちは!

Webデザインが苦手な筆者はどうにかしてデザイン工程をショートカットできないかと思い、手軽にいい感じのUIにしてくれそうなVuetifyを使ってタスク管理アプリを作ってみました。

結果、Vuetifyは素晴らしいCSSフレームワークでした!

ということで、今回はVuetifyにフォーカスして、その使い方やどのようなUIを作ってくれるのかについて見ていこうと思います。

また、VuetifyだけではなくVue3の導入方法やVuetify3の導入方法、LaravelとVue3の繋ぎ込み方法なども解説していきます。

「プログラミングはできるけど、デザインは苦手だな…」

「CSS書くの面倒だな…」

「スッキリしていて綺麗なUIを手軽に作りたい…」

などお考えの方はVuetifyはかなり良い選択になるかと思います。

今回の記事では、以下のようなことがわかります。

  • Vue3の導入方法
  • Vuetify3の導入方法
  • Vue3とLaravel APIの繋ぎ込み方
  • Vuetify3の概要
  • Vuetify3によるUI構築

それでは、早速みていきましょう!

Vuetifyとは

そもそもVuetifyというのは何かというと、CSSフレームワークのことです。

簡単にいうと、少ないコードでいい感じのUIを作ってくれるものです。

VuetifyはVue.jsを使用する場合に使用することができ、コンポーネント志向のフレームワークのため、事前に用意されたコンポーネントを使い回すことができます。

また、学習コストも低く、少し慣れてしまえば使いこなすのは容易でしょう。

公式サイトを載せておきます。

Vuetify — A Vue Component Framework
Vuetify is a no design skills required Open Source UI Component Framework for Vue. It provides you with all of the t...

目標成果物

以下のようなタスク管理アプリを作っていきます。

技術構成

  • API
    • Laravel9
  • フロント
    • Vue3
    • Vuetify3
    • VueRouter4

Vue.jsのバージョン3へのバージョンアップに合わせて、今回はフロントにVue3とVuetify3を使用します。

説明しないこと

以下は本記事の主題ではないので割愛させていただきます。

  • Laravelの環境構築方法
  • HTMLとCSS

Laravel環境はすでに構築済みのものとして説明していきます。

セットアップ

さて、ここから実装していきます。

テーブル作成

今回はtasksテーブルを作成し、具体的なタスクデータを格納していきます。

php artisan make:migrations create_tasks_table

マイグレーションファイルが作成されるので、tasksテーブルを定義していきます。

/**
* Run the migrations.
*
* @return void
*/
public function up()
{
  Schema::create('tasks', function (Blueprint $table) {
      $table->id();
      $table->string('title')->comment('タスク名');
      $table->text('description')->comment('タスクの説明')->nullable();
      $table->timestamps();
  });
}

/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
  Schema::dropIfExists('table_tasks');
}

タスクデータはめちゃくちゃシンプルにタイトルと説明だけにしておきます。

それでは、データベースに反映します。

php artisan migrate

モデル作成

タスクモデルも作成しておきましょう。

php artisan make:model Task

Vue3導入

このセクションではLaravelにVue3を導入してセットアップをしていきます。

Laravelに組み込む際は@vitejs/plugin-vueを使用します。

npm install @vitejs/plugin-vue

筆者の環境では最新バージョンが動きませんでした。

バージョンによる問題の場合は以下のようにバージョンを落としましょう。

npm install @vitejs/plugin-vue@4.0.0

vite.config.jsを開き、Vueの設定をします。

import { defineConfig } from 'vite';
import laravel from 'laravel-vite-plugin';
import vue from '@vitejs/plugin-vue'; // 追加

export default defineConfig({
    plugins: [
        vue(), // 追加
        laravel({
            input: ['resources/css/app.css', 'resources/js/app.js'],
            refresh: true,
        }),
    ],
});

resources/js/App.vueを作成します。

<script setup>
</script>

<template>
    <h1>トップページ</h1>
</template>

次に、resources/js/bootstrap.jsを編集していきます。

import { createApp } from 'vue';
import App from './App.vue';

createApp(App).mount('#app');

bootstrap.jsは、ビルド時に最初に読み込まれるjsファイルです。

ここでVueインスタンスをマウントすることで、Vueを反映させることができます。

ただし、Vueインスタンスは「app」という名前のIDがないとマウントできません。

そのた、これを設定していきます。

resources/views/welcome.blade.phpを編集します。

<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
    <head>
        <meta charset="utf-8">
        <meta name="viewport" content="width=device-width, initial-scale=1">

        <title>Laravel</title>
        @vite(['resources/css/app.css', 'resources/js/app.js'])
    </head>
    <body>
        <div id="app"></div>
    </body>
</html>

Viteでは、@vite(['resources/css/app.css', 'resources/js/app.js'])でcssとjsを反映させることができます。

最後に、動作確認しておきましょう。

npm run dev

Docker等を使っていない場合は、別タブにて以下コマンドでサーバー側も起動しておきましょう。

php artisan serve

「トップページ」の文字が表示されていたら成功

VueRouter導入

今回はVueRouterも使用する予定なので、こちらも導入していきます。

npm install vue-router

resources/js/router.jsを作成します。

今回はタスク一覧画面とタスク詳細画面の2つのみなので、以下のようになります。

import { createRouter, createWebHistory } from 'vue-router'
import Task from './components/Task.vue'
import Show from './components/Show.vue'

const routes = [
    {
        path: '/tasks',
        name: 'tasks',
        component: Task
    },
    {
        path: '/tasks/:id',
        name: 'show',
        component: Show
    }
]

const router = createRouter({
    history: createWebHistory(),
    routes
})

export default router

resources/js/components/Task.vueとresources/js/components/Show.vueは空かサンプルテキストのみで作成しておきます。

bootstrap.jsのvueインスタンスにuseします。

// VueRouter
import router from './router'

...

createApp(App).use(router).mount('#app');

仮でコンポーネントを作成しておきます。

resources/js/components/Task.vueを作成します。

<script setup>
</script>

<template>
    <h1>タスク一覧</h1>
</template>

resources/js/components/Show.vue

<script setup>
</script>

<template>
    <h1>タスク詳細</h1>
</template>

resources/js/App.vueを編集します。

<script setup>
import { RouterView } from 'vue-router'
</script>

<template>
   <RouterView />
</template>

また、Laravel側もどのようなルートでアクセスされても、welcome.blade.phpのテンプレートを返すようにしておきます。

routes/web.php

Route::get('/{any}', function () {
    return view('welcome');
})->where('any', '.*');

最後に動作確認しておきます。

それでは、ビルドします。

npm run dev

/tasksにアクセスすると、「タスク一覧」が。

/tasks/1にアクセスすると、「タスク詳細」が表示されていれば成功です。

Vuetify導入

長かったですが、ここからが本題です。

いよいよVuetifyを導入していきます。

なお、Vuetifyのインストール方法や設定などは公式サイトに則って行っていこうと思います。

Vuetify公式サイト

まずはVuetifyをインストールします。

npm install vuetify

resources/js/bootstrap.jsを編集します。

import { createApp } from 'vue'
import App from './App.vue'

// Vuetify
import 'vuetify/styles'
import { createVuetify } from 'vuetify'
import * as components from 'vuetify/components'
import * as directives from 'vuetify/directives'

// VueRouter
import router from './router'

const vuetify = createVuetify({
  components,
  directives,
})

createApp(App).use(vuetify).use(router).mount('#app');

これでひとまずvuetifyのインストールは完了なのですが、実はこのままだとアイコンが使えません。

ということで、アイコンを使えるようにしていきます。

npm install @mdi/font -D

「mdi」というのは、マテリアルデザインアイコンの略称だと思います。

bootstrap.jsに追記します。

import '@mdi/font/css/materialdesignicons.css' // 追加

...

const vuetify = createVuetify({
  components,
  directives,
  icons: {
    defaultSet: 'mdi', // This is already the default value - only for display purposes
  }, // 追加
})

createApp(App).use(vuetify).use(router).mount('#app');

これで導入完了です。簡単ですね😊

バックエンドAPI

ここまでで、セットアップ編が完了しました。

ここからは、Laravelを使ったAPIの作成を行なっていきます。

先にコントローラを作成しておきます。

php artisan make:controller TaskController

エンドポイント

routes/api.phpにエンドポイントを書いていきます。

Route::post('/tasks', 'App\\Http\\Controllers\\TaskController@store')->name('tasks.store');
Route::get('/tasks', 'App\\Http\\Controllers\\TaskController@index')->name('tasks.index');
Route::get('/tasks/{id}', 'App\\Http\\Controllers\\TaskController@show')->name('tasks.show');
Route::put('/tasks/{id}', 'App\\Http\\Controllers\\TaskController@update')->name('tasks.update');
Route::delete('/tasks/{id}', 'App\\Http\\Controllers\\TaskController@delete')->name('tasks.delete');

タスク一覧取得API

コントローラにindexアクションを作成します。

public function index(Request $request)
{
    if ($request->title) {
        $tasks = Task::where('title', 'like', '%' . $request->title . '%')->orderBy('created_at', 'desc')->get();
        return response()->json($tasks);
    }
    $tasks = Task::orderBy('created_at', 'desc')->get();
    return response()->json($tasks);
}

今回はタイトル検索を実装したいので、タイトルをリクエストで受け取ったらDBにLIKE検索で検索します。

本当はバリデーションなどが必要ですが、今回はあくまでデモなので割愛させていただきます。

また、タスク一覧はデータ作成日時の降順で取得します。

タスク詳細取得API

コントローラにshowアクションを追加します。

public function show($id)
{
    $task = Task::findOrFail($id);
    return response()->json($task);
}

コードはただIDに一致するタスクを取得して返しているだけですが、find()メソッドとfindOrFail()メソッドの違いは押さえておきましょう。

以下のような違いがあります。

  • find()メソッド・・・一致するIDがなかった場合にnullを返します。
  • findOrFail()メソッド・・・一致するIDがなかった場合にエラーを返します。

見つからなかった場合はnullとして返してほしい場合は、find()メソッドを使い、エラーになってほしい場合はfindOrFail()を使うと良いでしょう。

ただ、find()を使うとエラーになった場合にデバッグ画面が表示されてしまう場合があるので、経験的にはfindOrFail()を使った方が良い場合が多い印象があります。

タスク登録API

コントローラにstoreアクションを追加します。

public function store(Request $request)
{
    $task = new Task();
    $task->title = $request->title;
    $task->description = $request->description;
    $task->save();
    return response()->json($task, 201);
}

データ登録はinsert()メソッドとsave()メソッドがあると思いますが、これらの違いは押さえておきましょう。

insert()を使う場合は、created_atカラムとupdated_atカラムが自動で入らない点に注意しなければなりません。

save()を使う場合は、データの差分を見てcreated_atやupdated_atを更新するか決めます。また、データ登録の場合もデータ更新の場合も同様にsave()メソッドで対応することができます。

上記のような理由から筆者はsave()を好みます。また、実際の開発現場でもsave()メソッドが使われることが多かったです。

タスク更新API

コントローラにupdateアクションを追加します。

public function update(Request $request, $id)
{
    $task = Task::findOrFail($id);
    $task->title = $request->title;
    $task->description = $request->description;
    $task->save();
    return response()->json($task);
}

タスク削除

コントローラにdeleteアクションを追加します。

public function delete($id)
{
    $task = Task::findOrFail($id);
    $task->delete();
    return response()->json(null, 204);
}

これで一通りバックエンドAPIは完了です。

この辺りで一度APIの動作確認をしておくと良いでしょう。

フロント実装

ここからは、いよいよVuetifyを用いた画面作成を行なっていきます。

ヘッダーコンポーネント

まずは、非常に簡単にですが、ヘッダーのコンポーネントを作成していきます。

resources/js/components/Header.vueを作成します。

<template>
    <v-app-bar>
        <v-app-bar-title>ToDo</v-app-bar-title>
    </v-app-bar>
</template>

Vuetifyでアプリケーションバーを作成する場合は、上記のようにv-app-barを使います。

また、以下のように実装することで、影をつけたりアイコンをつけたりすることもできます。

<v-app-bar :elevation="2">
  <template v-slot:prepend>
    <v-app-bar-nav-icon></v-app-bar-nav-icon>
  </template>

  <v-app-bar-title>ToDo</v-app-bar-title>
</v-app-bar>

<template v-slot:prepend></template>のようにv-slotにprependをつけると、アプリケーションバーの左側に要素が配置され、appendをつけると右側に要素が配置されるようです。

それでは、ヘッダーコンポーネントを画面レイアウトに組み込んでみます。

resources/js/App.vueを編集します。

<script setup>
import Header from './components/Header.vue'
import { RouterView } from 'vue-router'
</script>

<template>
    <v-app>
        <Header />
        <RouterView />
    </v-app>
</template>

App.vueには<v-app></v-app>が必須になるようです。

タスク一覧

次にタスク一覧画面を作成していきます。

resources/js/components/Task.vueを編集します。

<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'

const router = useRouter()
const task = ref('')
const items = ref([
  { id: 1, title: 'Pythonのお勉強', done: false },
  { id: 2, title: 'Vue.jsのお勉強', done: false },
  { id: 3, title: 'TypeScriptのお勉強', done: false },
  { id: 4, title: 'Djangoのお勉強', done: false }
])
const searchTitle = ref('')

const moveTask = (id) => {
  router.push({ name: 'show', params: { id: id } })
}
const doneEffect = (id) => {
  const checkbox = document.getElementById('checkbox-' + id)
  const label = document.getElementById('label-' + id)
  if (checkbox.checked) {
    label.parentElement.style.textDecoration = 'line-through'
  } else {
    label.parentElement.style.textDecoration = 'none'
  }
}
</script>
<template>
    <v-main>
      <v-card
        class="mx-auto mt-16"
        width="600"
      >
        <v-toolbar color="purple">
          <v-toolbar-title>Todo Card</v-toolbar-title>

          <v-spacer></v-spacer>

            <v-text-field
                label="タスク検索"
                variant="outlined"
                class="w-25"
                hide-details
                density="compact"
                clearable
                v-model="searchTitle"
                ></v-text-field>
            <v-btn icon="mdi-magnify"></v-btn>
            <v-btn icon="mdi-close"></v-btn>
        </v-toolbar>

        <v-form>
          <v-text-field
            label="タスク追加"
            clearable
            v-model="task"
            ></v-text-field>
        </v-form>

        <v-list lines="three" select-strategy="classic">
          <v-list-subheader>タスク一覧</v-list-subheader>
          <v-checkbox
            v-for="task in tasks"
            :key="task.id"
            :label="task.title"
            @change="doneEffect(task.id)"
            :id="'checkbox-' + task.id"
            >
            <template v-slot:label>
              <div :id="'label-' + task.id">{{ task.title }}</div>
            </template>
            <template v-slot:append>
              <v-icon class="mr-6 cursor-pointer" @click="moveTask(task.id)">mdi-file-document-edit</v-icon>
              <v-icon class="mr-6 cursor-pointer">mdi-delete</v-icon>
            </template>
          </v-checkbox>
        </v-list>
      </v-card>
    </v-main>
</template>

デザインは基本的に自分で一から考える必要はなく、以下の公式サイトのコンポーネントから拝借してカスタマイズするだけで十分モダンなUIっぽくなります。

Bottom sheet component — Vuetify
The bottom sheet component is used for elevating content above other elements in a dialog style fashion.

今回のタスク一覧のUIモデルとしては、以下から拝借させていただきました。

ここでは一旦見た目を作るだけにしておきます。

APIの繋ぎ込みはまた別途行なっていきます。

コンポーネントのカスタマイズは、各コンポーネントで使えるpropsなどをある程度知っておけばカスタマイズできます。

詳細な説明は以下のページを参考にしました。長いですが、一通り目を通しておくと良いと思います。

Vuetify3 の基本
Vuetify3 の画面のレイアウトの基本と、ボックス(領域)を確保する方法、位置合わせや寄せの方法、色やフォントの指定方法、スペーシング、各種のコンポーネントについて説明した記事です。Vue3 そのものについての説明はありません。 サンプ...

タスク詳細

次にタスク詳細画面です。

resources/js/components/Show.vueを編集します。

<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'

const router = useRouter()
const task = ref('')

const moveTasks = () => {
    router.push({ name: 'tasks' })
}
</script>

<template>
    <v-main>
        <v-container>
            <v-card
                class="mx-auto my-8 py-6"
                elevation="16"
                width="400"
                variant="outlined"
            >
            <v-form>
                <v-card-item>
                <v-card-title>
                    <v-text-field
                        label="タスク名"
                        clearable
                        v-model="task.title"
                        >
                    </v-text-field>
                </v-card-title>
                </v-card-item>

                <v-card-item>
                    <v-textarea
                        label="タスク説明"
                        clearable
                        v-model="task.description">
                    </v-textarea>
                </v-card-item>

                <v-card-actions>
                    <v-btn
                        color="purple-accent-4"
                        variant="text"
                        @click="moveTasks">戻る</v-btn>
                    <v-btn
                        color="purple-accent-4"
                        variant="outlined"
                        type="submit">更新</v-btn>
                </v-card-actions>
            </v-form>
            </v-card>
        </v-container>
    </v-main>
</template>

タスク詳細画面についても、以下のようなカードコンポーネントを拝借し、カスタマイズしています。

この辺りで一度ビルドしてUIを確認しておくと良いでしょう。

API繋ぎ込み

それでは、最後にAPIの繋ぎ込みを行っていきます。

タスク一覧画面

まずはタスク一覧画面から。

タスク一覧データを取得できるようにしていきます。

Laravelでは、デフォルトでaxiosが使えるようになっているため、axiosをインポートして使っていきます。

<script setup>
import axios from 'axios'
import { onMounted } from 'vue'

const items = ref([]) // 仮データを削除
...

onMounted(() => {
  getTasks()
})
const getTasks = async () => {
    const res = await axios.get('/api/tasks')
    tasks.value = res.data;
}
</script>

これでタスク一覧データを取得することができます。

UI確認用でitemsにテストデータを入れていたので、忘れずに消しておきましょう。

次に、タイトル検索のAPIと検索クリア機能を実装していきます。

<script setup>
const searchTask = async () => {
    const params = {
        title: searchTitle.value
    }
    const res = await axios.get('/api/tasks', { params })
    tasks.value = res.data;
}
const clearSearch = async () => {
    searchTitle.value = ''
    getTasks()
}
</script>

<template>
  ...
  
	<v-text-field
      label="タスク検索"
      variant="outlined"
      class="w-25"
      hide-details
      density="compact"
      clearable
      v-model="searchTitle"
      ></v-text-field>
  <v-btn icon="mdi-magnify" @click="searchTask"></v-btn>
  <v-btn icon="mdi-close" @click="clearSearch"></v-btn>
  
  ...
</template>

最後に、タスク追加と削除を入れます。

<script setup>
...

const addTask = async () => {
  const res = await axios.post('/api/tasks', { title: task.value, done: 0 })
  tasks.value.unshift(res.data)
  task.value = '';
}
const deleteTask = async (id) => {
    await axios.delete('/api/tasks/' + id)
    tasks.value = tasks.value.filter(task => task.id !== id)
}
</script>

<template>
...

<v-form @submit.prevent="addTask">
  <v-text-field
    label="タスク追加"
    clearable
    v-model="task"
    ></v-text-field>
</v-form>

...

<v-icon class="mr-6 cursor-pointer" @click="deleteTask(task.id)">mdi-delete</v-icon>

...
</template>

本来であれば、エラーハンドリングなどの処理をもっとしっかりと書かなければいけません。

ただ、今回はあくまでただのデモのため割愛します。

タスク詳細画面

次に、タスク詳細画面です。

まずは、タスク詳細データを取得するAPIを繋ぎ込みます。

<script setup>
import axios from 'axios'
import { onMounted } from 'vue'
...

onMounted(() => {
    getTask()
})
const getTask = async () => {
    const id = router.currentRoute.value.params.id
    const res = await axios.get('/api/tasks/' + id)
    task.value = res.data
}
</script>

対象のタスクIDはVueRouterから得られるため、これを引数にタスク詳細取得APIを実行するだけですね。

次に、タスク更新のAPIを繋ぎ込みます。

<script setup>
const updateTask = async (id) => {
    await axios.put('/api/tasks/' + id, task.value)
    moveTasks()
}
</script>

<template>
<v-form @submit.prevent="updateTask(task.id)">
	<v-card-item>
	<v-card-title>
	    <v-text-field
	        label="タスク名"
	        clearable
	        v-model="task.title"
	        >
	    </v-text-field>
	</v-card-title>
	</v-card-item>
	
	<v-card-item>
	    <v-textarea
	        label="タスク説明"
	        clearable
	        v-model="task.description">
	    </v-textarea>
	</v-card-item>
	
	<v-card-actions>
	    <v-btn
	        color="purple-accent-4"
	        variant="text"
	        @click="moveTasks">戻る</v-btn>
	    <v-btn
	        color="purple-accent-4"
	        variant="outlined"
	        type="submit">更新</v-btn>
	</v-card-actions>
</v-form>
</template>

はい、こちらで完成です。

あとは、一通り動作確認して問題なければ成功です!

まとめ

長い道のりでしたが、ここまでお疲れ様です。

今回はVuetifyを使って綺麗なUIのタスク管理アプリを作ってみるというテーマで書いてみました。

筆者はWebデザインが苦手で、デザインはできる限り省略したい派です。

そこで、Vuetifyを試しに使ってみましたが、非常に少ないコードでモダンなUIにできたので驚きました。

筆者と同じく、Webデザインが苦手なエンジニアのみなさんは是非ともVuetifyを学んでみてはいかがでしょうか。

それでは、良いコーディングライフを!😊

コメント

タイトルとURLをコピーしました