Laravel

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

皆さんこんにちは!
この記事は、「Laravel + Vueでtrello風タスク管理アプリを作ろう!!」の3回目になります。
今回の記事で、このチュートリアルは終わりとなります。

駆け足で進めてきたので、わからない部分などがあると思いますが、お気軽にコメントいただければ、説明します。

また、間違えている部分や、コードの書き方が悪い部分などありましたら、ご指摘いただけると嬉しいです!


前回までの、記事を見ていない方は、先にそちらを確認して下さい。

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

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

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

カード(タスク)の削除機能の実装

前回までで、タスクを登録したり、ドラッグアンドドロップで移動した状態を保存したりすることができる様になっています。

ただ、現在のままでは、タスクを削除することがユーザー側ではできないので、アプリケーションとしては、使い物にならないかと思います。

まず、最低限の機能として削除機能を作成します。

それでは、「app/Http/Controllers/TaskController.php」の一番下に下記のメソッドを追加して下さい。

 /**
     * タスクを削除
     *
     * @param int $taskId
     * @return int
     */
    public function destroy(int $taskId)
    {
        $del_task = Task::find($taskId);
        $del_task->delete();

        return (200);
    }

ちなみにこのアクションのルーティングは、このチュートリアルの「実践編①」でまとめて作ってありますので、併せてご確認ください
削除のルーティングは下記のものです。

Route::group(['middleware' => 'auth'], function () {
    Route::get('tasks', 'TaskController@index')->name('tasks.index');
    Route::post('tasks', 'TaskController@store')->name('tasks.store');
    Route::put('tasks/sync', 'TaskController@sync')->name('tasks.sync');
    Route::put('tasks/{task}', 'TaskController@update')->name('tasks.update'); //should be implemented.
    Route::delete('tasks/{tasks}', 'TaskController@destroy')->name('tasks.destroy'); // ←これが削除
});

サーバーサイドの実装はこれで終わりです。

カード(タスク)削除、クライアント側の実装

それでは、「resources/js/components/KanbanBoard.vue」を下記の様に編集して下さい。

<!--省略--> 
<!-- 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>
                                    </transition-group>
                                </draggable>
   <!-- ./Tasks -->
<!--省略-->
<script>
<!--省略-->
import { CreditCardIcon,Trash2Icon } from "vue-feather-icons";
<!--省略-->
export default {
    components: {
        draggable,
        CreditCardIcon,
        Trash2Icon, //追加
        AddTaskForm,
    },
 //省略
    methods: {
        // 追加
        onDelete (taskId, statusId) {
            const statusIndex = this.statuses.findIndex(
                status => status.id === statusId
            );
            const taskIndex = this.statuses[statusIndex].tasks.findIndex(
                id => taskId
            );
            if (confirm('タスクを削除しますか?')) {
                axios
                    .delete("/tasks/" + taskId, taskId)
                    .then(res => {
                        this.statuses[statusIndex].tasks.splice(taskIndex, 1);
                    })
                    .catch(err => {
                        console.log(err);
                    });
            }
        },
// 省略

本来なら、confirm用のモーダルを準備するのですが、長くなりそうなので、Jsのconfirmを使用して「OK」なら削除する様にしています。

皆さんが、実装するときはモーダルを作ってみて下さい。

これで、削除までできました。
あとは、ボード(ステータス)を作成・削除する処理を作ります。
ほとんどタスクの作成と変わりがないので、コード全文を載せていきます。

ボード(ステータス)の作成・削除(サーバーサイド)

まず、ルーティングに、下記の記述があることを確認して下さい。

routes/web.php

Route::group(['middleware' => 'auth'], function () {
    Route::post('statuses', 'StatusController@store')->name('statuses.store');
    Route::put('statuses/sync', 'StatusController@sync')->name('statuses.sync');
    Route::put('statuses/{status}', 'StatusController@update')->name('statuses.update'); //should be implemented.
    Route::delete('statuses/{status}', 'StatusController@destroy')->name('statuses.destroy');
});

上記のアクション達を実装します。

app/Http/Controllers/StatusController.php

<?php

namespace App\Http\Controllers;

use App\Status;
use Illuminate\Http\Request;

class StatusController extends Controller
{
    /**
     * ステータスを追加
     *
     * @param Request $request
     * @return mixed
     */
    public function store(Request $request)
    {
        return $request->user()
            ->statuses()
            ->create($request->only('title', 'slug', 'order'));
    }

    /**
     * @TODO ステータスを更新
     *
     * @param Request $request
     * @param Status $status
     */
    public function update(Request $request, Status $status)
    {
        //should be implemented.
    }

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

        foreach ($request->columns as $i => $status) {
            $order = $i + 1;
            request()->user()->statuses()
                ->find($status['id'])
                ->update(['order' => $order]);
        }

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

    /**
     * ステータスを削除
     *
     * @param int $statusId
     * @return int
     */
    public function destroy(int $statusId)
    {
        $del_status = Status::find($statusId);
        $del_status->delete();

        return (200);
    }
}

ステータス削除時に関連タスクも削除したいのでモデルも編集します。

app/Status.php(全文掲載します)

<?php

namespace App;

use Illuminate\Database\Eloquent\Model;

class Status extends Model
{
    
    protected $fillable = ['title', 'slug', 'order'];

    /**
     * @return \Illuminate\Database\Eloquent\Relations\HasMany
     */
    public function tasks ()
    {
        return $this->hasMany(Task::class)->orderBy('order');
    }

    /**
     * @return \Illuminate\Database\Eloquent\Relations\BelongsTo
     */
    public function user ()
    {
        return $this->belongsTo(User::class);
    }

    /**
     * ステータス削除時に、関連タスクを削除する
     */
    public static function boot ()
    {
        parent::boot ();

        static::deleting(function ($status) {
            $status->tasks()->delete();
        });
    }
}

これでサーバーサイドの実装は終了です。
ほとんどタスクの実装と変わりがないので、理解できるかと思います。

クライアント側の実装

ステータスの追加は、モーダルを使いたいので、モーダルコンポーネントを作成します。

次のファイルを新規に作成して下さい。

resources/js/components/AddStatusModal.vue

<template>
    <transition name="modal">
        <div class="overlay" @click="$emit('close')">
            <div class="panel" @click.stop>
                <form
                    class="relative mb-3 flex flex-col justify-between bg-white rounded-md shadow overflow-hidden"
                    @submit.prevent="handleAddNewStatus"
                >
                    <div class="text-center text-gray-500 mb-6">
                        <h3>Status Add</h3>
                    </div>
                    <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="タイトルを入力"
                            v-model.trim="newStatus.title"
                        />
                        <input
                            class="mt-3 p-2 block w-full px-2 py-1 text-lg border-b border-blue-800 rounded"
                            type="text"
                            placeholder="スラッグを入力"
                            v-model.trim="newStatus.slug"
                        />
                        <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('status-canceled')"
                            type="reset"
                            class="py-1 leading-5 text-gray-600 hover:text-gray-700"
                        >
                            cancel
                        </button>
                        <button
                            type="submit"
                            class="px-3 py-1 leading-5 text-white bg-orange-600 hover:bg-orange-500 rounded"
                        >
                            Add
                        </button>
                    </div>
                </form>
            </div>
        </div>
    </transition>
</template>

<script>
export default {
    props: {
        maxOrderNo: Number
    },
    data () {
        return {
            newStatus: {
                title: "",
                slug: "",
                order: null
            },
            errorMessage: "",
        };
    },
    mounted () {
        this.newStatus.order = this.maxOrderNo + 1;
    },
    methods: {
        handleAddNewStatus () {
            if (!this.newStatus.title) {
                this.errorMessage = "タイトルは必須です";
                return;
            }
            axios
                .post("/statuses", this.newStatus)
                .then(res => {
                    this.$emit("status-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>

<style>
.overlay {
    background: rgba(0, 0, 0, .8);
    position: fixed;
    width: 100%;
    height: 100%;
    left: 0;
    top: 0;
    right: 0;
    bottom: 0;
    z-index: 900;
    transition: all .5s ease;
}

.panel {
    width: 300px;
    height: 250px;
    background: #fff;
    padding: 20px;
    position: absolute;
    left: 50%;
    top: 50%;
    margin-left: -150px;
    margin-top: -100px;
    transition: all .3s ease;
}
.modal-enter, .modal-leave-active {
    opacity: 0;
}

.modal-enter .panel, .modal-leave-active .panel{
    top: -200px;
}
</style>

続いて、KanbanBoard.vueで、このコンポーネントを利用できる様にしていきます。

resources/js/components/KanbanBoard.vue(全文掲載します)

<template>
    <div>
        <!-- AddStatusModal -->
        <div>
            <div class="px-2 pb-4" style="background-color: aliceblue; border-bottom: solid 1px #ccc;">
                <button
                    class="bg-blue-500 border border-blue-500 px-6 py-2 text-white hover:bg-blue-400 rounded"
                    @click="showModal = true"
                >
                    Add Status +
                </button>

                <AddStatusModal
                    v-if="showModal"
                    @close="showModal = false"
                    :maxOrderNo="statuses.length"
                    v-on:status-added="handleStatusAdded"
                    v-on:status-canceled="closeStatusModal"
                />
            </div>
        </div>
        <!-- ./AddStatusModal -->

        <!-- Columns (Statuses) -->
        <div class="relative p-2 flex overflow-x-auto h-full">
            <draggable
                class="flex-1 overflow-hidden"
                v-model="statuses"
                v-bind="statusDragOptions"
                @end="handleStatusMoved"
            >
                <transition-group
                    class="flex-1 flex h-full overflow-x-hidden overflow-y-auto rounded shadow-xs"
                    tag="div"
                >
                    <div
                        v-for="status in statuses"
                        :key="status.slug"
                        class="mr-6 w-4/5 max-w-xs flex-shrink-0"
                    >
                        <div class="rounded-md shadow-md overflow-hidden status">
                            <div class="p-3 flex justify-between items-baseline bg-blue-800 ">
                                <h4 class="font-medium text-white">
                                    {{ status.title }}
                                </h4>
                                <button
                                    @click="openAddTaskForm(status.id)"
                                    class="pl-40 text-sm text-orange-500 hover:underline"
                                >
                                    <CreditCardIcon/>
                                </button>
                                <button
                                    @click="delStatus(status.id)"
                                    class="p-1 text-sm text-orange-500 hover:underline"
                                >
                                    <Trash2Icon/>
                                </button>
                            </div>

                            <div class="p-2 bg-blue-100">
                                <!-- AddTaskForm -->
                                <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>
                                    </transition-group>
                                </draggable>
                                <!-- ./Tasks -->

                                <!-- No Tasks -->
                                <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">タスクがありません</span>
                                    <br />
                                    <button
                                        class="mt-1 text-sm text-orange-600 hover:underline"
                                        @click="openAddTaskForm(status.id)"
                                    >
                                        Add Task +
                                    </button>
                                </div>
                                <!-- ./No Tasks -->

                            </div>
                        </div>
                    </div>
                </transition-group>
            </draggable>
        </div>
        <!-- ./Columns -->
    </div>
</template>

<script>
import AddTaskForm from "./AddTaskForm";
import AddStatusModal from "./AddStatusModal";
import draggable from "vuedraggable";
import { CreditCardIcon, Trash2Icon } from "vue-feather-icons";

export default {
    components: {
        draggable,
        CreditCardIcon,
        EditIcon,
        Trash2Icon,
        AddTaskForm,
        AddStatusModal,
    },
    props: {
        initialData: Array
    },
    data() {
        return {
            statuses: [],
            newTaskForStatus: 0,
            showModal: false,
            maxOrderNo: 0
        };
    },
    computed: {
        taskDragOptions () {
            return {
                animation: 200,
                group: "task-list",
                dragClass: "status-drag"
            };
        },
        statusDragOptions () {
            return {
                animation: 200,
                group: "status-list",
                dragClass: "status-drag"
            };
        }
    },
    mounted () {
        // ステータスを「複製」して、変更時にプロップを変更しないようにします
        this.statuses = JSON.parse(JSON.stringify(this.initialData));
    },
    methods: {
        openAddTaskForm(statusId) {
            this.newTaskForStatus = statusId;
        },
        closeAddTaskForm() {
            this.newTaskForStatus = 0;
        },
        openStatusModal() {
            this.showModal = true
        },
        closeStatusModal() {
            this.showModal = false;
        },
        onDelete (taskId, statusId) {
            const statusIndex = this.statuses.findIndex(
                status => status.id === statusId
            );
            const taskIndex = this.statuses[statusIndex].tasks.findIndex(
                id => taskId
            );
            if (confirm('タスクを削除しますか?')) {
                axios
                    .delete("/tasks/" + taskId, taskId)
                    .then(res => {
                        this.statuses[statusIndex].tasks.splice(taskIndex, 1);
                    })
                    .catch(err => {
                        console.log(err);
                    });
            }
        },
        handleStatusAdded (newStatus) {
            newStatus.tasks = []
            this.closeStatusModal();
            this.statuses.push(newStatus);
        },
        handleTaskAdded (newTask) {
            // タスクを追加する必要があるステータスのインデックスを見つけます
            const statusIndex = this.statuses.findIndex(
                status => status.id === newTask.status_id
            );
            // 新しく作成したタスクを列に追加します
            this.statuses[statusIndex].tasks.push(newTask);
            // AddTaskFormを閉じます
            this.closeAddTaskForm();
        },
        handleTaskMoved (evt) {
            axios.put("/tasks/sync", {columns: this.statuses})
                .then(res => {
                    console.log(res.data);
                })
                .catch(err => {
                console.log(err.response);
            });
        },
        handleStatusMoved (evt) {
            axios.put("/statuses/sync", {columns: this.statuses})
                .then(res => {
                    console.log(res.data);
                })
                .catch(err => {
                console.log(err.response);
            });
        },
        delStatus (statusId) {
            // タスクを削除する必要があるステータスのインデックスを見つけます
            const statusIndex = this.statuses.findIndex(
                status => status.id === statusId
            );

            if (confirm('ステータスを削除しますか?')) {
                axios
                    .delete("/statuses/" + statusId, statusId)
                    .then(res => {
                        // 削除が成功した場合、クライアント側も反映させる
                        this.statuses.splice(statusIndex, 1);
                    })
                    .catch(err => {
                        console.log(err);
                    });
            }
        },

    }
};
</script>

<style scoped>
.status-drag {
    transition: transform 0.5s;
    transition-property: all;
}

.flex {
    display: -webkit-flex;
    display: -moz-flex;
    display: -ms-flex;
    display: -o-flex;
    display: flex;
}
</style>

お疲れ様でした!!

これで、このチュートリアルは終了です。
サーバーを立ち上げて、実際に動かしてみて下さい。

下記の様なイメージで使えるかと思います。

まとめ

これで、このチュートリアルは終了です。
駆け足で、進めたので抜けている部分や間違えている部分などもあるかもしれませんので、お気軽にコメントしていただければ幸いです。

このチュートリアルは、わざと不完全な実装で終わらせています。
それは、チュートリアル完走後に自分で調べて、自分の好きな様にアレンジして欲しいからです。

例えば、次の様な機能を作成してみると良いかと思います。

  • Vuexを使用した状態管理の導入
  • Vuerouterを使用する
  • ステータス・タスクの編集機能追加(ルーティングは作ってあります。)
  • 各タスクカードへ、画像の添付(アップロードやダウンロード機能の実装)
  • チームのタスクを管理できる機能の作成(権限まわりなどの学習に良いです)
  • タスクカードのステータス間の移動履歴・ヒストリー的なものを見られると良いかと思います。
  • Herokuなどのサーバーへアップしてみる
  • UnitTestを書く

など、少し考えるだけで多くの機能を追加したくなってきます。
是非、皆さんだけの素晴らしいアプリケーションを作り出して下さい!

最後まで読んでいただき、ありがとうございました!!