Laravel

Laravel + Vueでtrello風タスク管理アプリを作ろう!![実装編②]

皆さんこんにちは!
この記事は、「Laravel + Vueでtrello風タスク管理アプリを作ろう!!」の3回目になります。
前回までの、記事を見ていない方は、先にそちらを確認して下さい。

前回までの記事で、ここまでできていると思います。

  1. Laravelのインストールと必要なパッケージの用意
  2. 認証関連の機能
  3. ボードタスクの土台を作成しました
  4. ユーザーのタスクをビューに戻す
  5. ユーザーの作成時にいくつかのデフォルトのステータスを作成する

それでは、早速続きを始めましょう!

ボードを作成する

まずVueをセットアップします。

ターミナルを開き、プロジェクトディレクトリーで次のコマンドを実行して下さい。

npm install vue

Vueをインストールしたら、resources/js/app.js ファイルで初期化します。

resources/js/app.js を下記の様に編集して下さい。

require("./bootstrap");

window.Vue = require("vue");

const app = new Vue({
    el: "#app"
});

これだけで、LaravelからVueを使用する準備が整いました。

カンバンコンポーネント作成

resources/js/components/へ、KanbanBoard.vueを作成し、次の様に記述して下さい。

<template>
  <div class="relative p-2 flex overflow-x-auto h-full">
    <div
      v-for="status in statuses"
      :key="status.slug"
      class="mr-6 w-4/5 max-w-xs flex-1 flex-shrink-0"
    >
      <div class="rounded-md shadow-md overflow-hidden">
        <div class="p-3 flex justify-between items-baseline bg-blue-800 ">
          <h4 class="font-medium text-white">
            {{ status.title }}
          </h4>
          <button class="py-1 px-2 text-sm text-orange-500 hover:underline">
            タスク追加
          </button>
        </div>
        <div class="p-2 flex-1 flex flex-col h-full overflow-x-hidden overflow-y-auto bg-blue-100">
          <div
            v-for="task in status.tasks"
            :key="task.id"
            class="mb-3 p-3 h-24 flex flex-col bg-white rounded-md shadow transform hover:shadow-md cursor-pointer"
          >
            <span class="block mb-2 text-xl text-gray-900">
              {{ task.title }}
            </span>
            <p class="text-gray-700 truncate">
              {{ task.description }}
            </p>
          </div>

      <!-- あとで、ここにタスク追加フォームを入れます-->

          <div
            v-show="!status.tasks.length"
            class="flex-1 p-4 flex flex-col items-center justify-center"
          >
            <span class="text-gray-600">No tasks yet</span>
            <button
              class="mt-1 text-sm text-orange-600 hover:underline"
            >
              追加
            </button>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>

<script>
export default {
  props: {
    initialData: Array
  },
  data() {
    return {
      statuses: []
    };
  },
  mounted() {
    this.statuses = JSON.parse(JSON.stringify(this.initialData));
  }
};
</script>

テンプレートではv-for="status in statuses"で、ボードの配列を反復処理して列を表示しています。「key」を追加することを忘れないでください。
今回のアプリでは、要素の順序が大切になるので、とても重要です。

同様に、各列の内部でv-forは、現在のボードのカードのリストを表示しています。

プロップデータを配列に「クローン」しています。実際にはプロップから渡されるデータは変更されず、コピーだけが変更されます。

Vueについての詳しい説明は今回行いませんが、渡されたデータを直接変更するのは望ましい処理では無いということを覚えておいて下さい。

そして、resources/js/app.jsへ、作成したコンポーネントの登録を行います。

Vue.component("kanban-board", require("./components/KanbanBoard.vue").default);

そして、前回作成した、resources/views/tasks/index.blade.phpの<main></main>部分へVueのコンポーネントを組み込みます。

<main class="h-full flex flex-col overflow-auto">
    <kanban-board :initial-data="{{ $tasks }}"></kanban-board>
</main>

ターミナルから、次のコマンドを実行してビルドを行って下さい。

npm run watch

ここまできたら、画面の確認を行いましょう。

ここで新規ユーザー登録を行うと、デフォルトでボードが作成されます。

ボードを作成する処理はまだ書いていないので、ユーザーを新規作成し、デフォルトで作成されるボードを確認して下さい。

新しいタスクを追加する

/resources/js/componentsへ、AddTaskForm.vueを追加して次の様に記述してください。

<template>
  <form
    class="relative mb-3 flex flex-col justify-between bg-white rounded-md shadow overflow-hidden"
    @submit.prevent="handleAddNewTask"
  >
    <div class="p-3 flex-1">
      <input
        class="block w-full px-2 py-1 text-lg border-b border-blue-800 rounded"
        type="text"
        placeholder="Enter a title"
        v-model.trim="newTask.title"
      />
      <textarea
        class="mt-3 p-2 block w-full p-1 border text-sm rounded"
        rows="2"
        placeholder="Add a description (optional)"
        v-model.trim="newTask.description"
      ></textarea>
      <div v-show="errorMessage">
        <span class="text-xs text-red-500">
          {{ errorMessage }}
        </span>
      </div>
    </div>
    <div class="p-3 flex justify-between items-end text-sm bg-gray-100">
      <button
        @click="$emit('task-canceled')"
        type="reset"
        class="py-1 leading-5 text-gray-600 hover:text-gray-700"
      >
        キャンセル
      </button>
      <button
        type="submit"
        class="px-3 py-1 leading-5 text-white bg-orange-600 hover:bg-orange-500 rounded"
      >
        追加
      </button>
    </div>
  </form>
</template>

<script>
export default {
  props: {
    statusId: Number
  },
  data() {
    return {
      newTask: {
        title: "",
        description: "",
        status_id: null
      },
      errorMessage: ""
    };
  },
  mounted() {
    this.newTask.status_id = this.statusId;
  },
  methods: {
    handleAddNewTask() {
      if (!this.newTask.title) {
        this.errorMessage = "The title field is required";
        return;
      }

      axios
        .post("/tasks", this.newTask)
        .then(res => {
          this.$emit("task-added", res.data);
        })
        .catch(err => {
          this.handleErrors(err);
        });
    },
    handleErrors(err) {
      if (err.response && err.response.status === 422) {
        const errorBag = err.response.data.errors;
        if (errorBag.title) {
          this.errorMessage = errorBag.title[0];
        } else if (errorBag.description) {
          this.errorMessage = errorBag.description[0];
        } else {
          this.errorMessage = err.response.message;
        }
      } else {
        console.log(err.response);
      }
    }
  }
};
</script>

このコンポーネントは、タイトルと説明を登録するためのフォームです。

axiosを使用して、フォームデータをLaravel側に送信します。

データベースへの登録はLaravelが行います。

登録ができれば、新しいカード(task)を返却します。

返却された、タスクをKanbanBoardコンポーネントへ渡し、追加します。
※まだ、作っていません。次に作ります。

新しいコンポーネントをKanbanBoardに追加する

それでは、resources/js/components/KanbanBoard.vueを編集します。

<template>
<!-- 省略 -->
 <!-- あとで、ここにタスク追加フォームを入れます--> ←このコメントの下を編集します。

<AddTaskForm
    v-if="newTaskForStatus === status.id"
    :status-id="status.id"
    v-on:task-added="handleTaskAdded"
    v-on:task-canceled="closeAddTaskForm"
  />
<div
    v-show="!status.tasks.length && newTaskForStatus !== status.id"
    class="flex-1 p-4 flex flex-col items-center justify-center"
  >
    <span class="text-gray-600">No tasks yet</span>
    <button
      class="mt-1 text-sm text-orange-600 hover:underline"
      @click="openAddTaskForm(status.id)"
    >
      Add one
    </button>
  </div>
  <!-- ./No Tasks -->
</template>
<script>
import AddTaskForm from "./AddTaskForm"; //コンポーネントをインポートする

export default {
  components: { AddTaskForm }, // 登録

  props: {
    initialData: Array
  },
  data() {
    return {
      statuses: [],

      newTaskForStatus: 0 // 追加するステータスのID
    };
  },
  mounted() {
    // ステータスを「クローン」して、変更時にプロップを変更しないように
    this.statuses = JSON.parse(JSON.stringify(this.initialData));
  },
  methods: {
    // statusIdを設定し、フォームを表示
    openAddTaskForm(statusId) {
      this.newTaskForStatus = statusId;
    },
    // statusIdをリセットしてフォームを閉じる
    closeAddTaskForm() {
      this.newTaskForStatus = 0;
    },
    // ボードの正しい列にカードを追加
    handleTaskAdded(newTask) {
      // Find the index of the status where we should add the task
      const statusIndex = this.statuses.findIndex(
        status => status.id === newTask.status_id
      );

      // 新しく作成しカードをボードに追加
      this.statuses[statusIndex].tasks.push(newTask);

      // AddTaskFormを閉じる
      this.closeAddTaskForm();
    },
  }
};
</script>

受け取ったリクエストを検証して、取得しているデータが期待どおりであることを確認します。それ以外の場合は、検証エラーを含む422応答を返します。

問題なければ、新しいカードを保存してユーザーに返します。

ここまでで、タスクの登録ができる様になりました。
次は、そのタスクカードをドラッグアンドドロップできる様にしていきたいと思います。

ドラッグアンドドロップ

SortableJS / Vue.Draggableを使用して、ドラッグアンドドロップを実装します。
ターミナルで次のコマンドを実行して下さい。

npm install vuedraggable

# 次のコマンドを再度実行して下さい。
npm run watch

KanbanBoardへドラッグアンドドロップの適用

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

<!-- 省略 -->
<AddTaskForm
                                    v-if="newTaskForStatus === status.id"
                                    :status-id="status.id"
                                    v-on:task-added="handleTaskAdded"
                                    v-on:task-canceled="closeAddTaskForm"
                                />
                                <!-- ./AddTaskForm -->

                                <!-- Tasks -->
                                <draggable
                                    class="flex-1 overflow-hidden"
                                    v-model="status.tasks"
                                    v-bind="taskDragOptions"
                                    @end="handleTaskMoved"
                                >
                                    <transition-group
                                        class="flex-1 flex flex-col h-full overflow-x-hidden overflow-y-auto rounded shadow-xs"
                                        tag="div"
                                    >
                                        <div
                                            v-for="task in status.tasks"
                                            :key="task.id"
                                            class="mb-3 p-4 flex flex-col bg-white rounded-md shadow transform hover:shadow-md cursor-pointer"
                                        >
                                            <div class="flex justify-between">
                                                <span class="block mb-2 text-xl text-gray-900">
                                                    {{ task.title }}
                                                </span>
                                                <div>
                                                    <button aria-label="Delete task"
                                                            class="p-1 focus:outline-none focus:shadow-outline text-red-500 hover:text-red-600"
                                                            @click="onDelete(task.id, status.id)"
                                                    >
                                                        <Trash2Icon/>
                                                    </button>
                                                </div>
                                            </div>
                                            <p class="text-gray-700">
                                                {{ task.description }}
                                            </p>

                                        </div>
                                        <!-- ./Tasks -->
                                    </transition-group>
                                </draggable>

                                <!-- No Tasks -->
<!-- 省略 -->

<script>
import AddTaskForm from "./AddTaskForm"; //コンポーネントをインポートする
import draggable from "vuedraggable";

export default {
  components: { 
AddTaskForm,
draggable
}, // 登録
<!-- 省略 -->

編集してビルドすると、ドラッグアンドドロップでカードの移動ができると思います。

データベースに保存

それでは、Laravel側のコードを編集して、データベースへ保存する処理を書いていきます。app/Http/Controllers/TaskControllerを次の様に編集して下さい。

/**
     * タスクカードを追加
     *
     * @param Request $request
     * @return mixed
     * @throws \Illuminate\Validation\ValidationException
     */
    public function store(Request $request)
    {
        $this->validate($request, [
            'title' => ['required', 'string', 'max:56'],
            'description' => ['required', 'string'],
            'status_id' => ['required', 'exists:statuses,id']
        ]);

        return $request->user()
            ->tasks()
            ->create($request->only('title', 'description', 'status_id'));
    }

$this->validateの部分は、バリデーションを行っています。

すべて問題なければ、新しいカードを保存して認証済みユーザーに添付し、それを返すことができます。

カードの順序の保存

現在のコードでは、カードを画面で動かした際に、順序を保存していないので、画面を読み込むたびに順序がリセットされます。

Route::put('tasks/sync', 'TaskController@sync')->name('tasks.sync');

実践編①で定義した、上のルートのアクションを書いて保存できる様にしていきます。

app/Http/Controllers/TaskControllerを次の様に編集して下さい。

 /**
     * タスクの並び順を更新
     *
     * @param Request $request
     * @return mixed
     * @throws \Illuminate\Validation\ValidationException
     */
    public function sync(Request $request)
    {
        $this->validate(request(), [
            'columns' => ['required', 'array']
        ]);

        foreach ($request->columns as $status) {
            foreach ($status['tasks'] as $i => $task) {
                $order = $i + 1;
                if ($task['status_id'] !== $status['id'] || $task['order'] !== $order) {
                    request()->user()->tasks()
                        ->find($task['id'])
                        ->update(['status_id' => $status['id'], 'order' => $order]);
                }
            }
        }

        return $request->user()->statuses()->with('tasks')->get();
    }

ここでは、すべての列をループして、カードの順序またはステータスが変更されたかどうかを確認しています。変更されている場合は、そのカードを更新します。

まとめ

お疲れ様です。
ここまでで、最低限の実装はできて、trelloの様な動きのタスク管理ができるかと思います。

次回は、「カードの削除」「ボードの作成・削除」を書いていきたいと思います。

一応、このチュートリアルは次回で終了です。
作ってもらうことを重要視して、説明などはかなり省略していますので、是非、公式ドキュメントや書籍で、知識の穴埋めを行って下さい。

最後まで、ありがとうございます!!
次回をお楽しみに!