☆Yuus Memo☆
非エンジニアの方でも業務を効率化できるプログラムを紹介します!
Laravel

Laravel + Vue.jsでGoogleカレンダーのクローンを作ろう!!【Laravel8対応】フロントエンド編④

皆さんこんにちは!!
前回に引き続き、フロント(Vue.js)側の開発を始めていきます。
Vue.jsに皆さんも大分慣れてきたのではないでしょうか?

前回は、予定の詳細表示ダイアログを作成しました。

今回は、予定の作成・更新・削除といった残りのCRUD処理を全て実装したいと思います。
結構、コンポーネントの量が多くなり、コードも多くなってくるので分からない部分などがあれば、お気軽にコメント下さい。

予定を作成する機能を実装する

カレンダーをクリックするとその日の予定を作成するフォームをダイアログで表示し、予定の新規作成を行う機能を実装します。

予定の追加は、日付欄をクリックした際に新規作成用のダイアログを表示したいと思います。
しかし、現在の現在のカレンダーは「予定の入っている日付」をクリックすると予定の詳細ダイアログが開いてしまいます。

そこで、events.js(Vuexのストア)で「編集状態かそうでないか」を表す変数を用意して、その変数の状態に合わせてダイアログを出し分ける事にします。
resources/js/store/module/events.jsを開き、次の様に編集して下さい。

import axios from 'axios';

const state = {
    events: [],
    event: null,
    isEditMode: false, // 追加
};

const getters = {
    events: state => state.events.map(event => {
        return {
            ...event,
            start: new Date(event.start),
            end: new Date(event.end)
        };
    }),
    event: state => state.event ? {
        ...state.event,
        start: new Date(state.event.start),
        end: new Date(state.event.end),
    } : null,
    isEditMode: state => state.isEditMode,  // 追加
};

const mutations = {
    setEvents: (state, events) => (state.events = events),
    setEvent: (state, event) => (state.event = event),
    setEditMode: (state, bool) => (state.isEditMode = bool), // 追加
}

const actions = {
    async fetchEvents({ commit }) {
        const response = await axios.get('/api/events');
        commit('setEvents', response.data);
    },
    setEvent({ commit }, event) {
        commit('setEvent', event);
    },
    // 追加
    setEditMode({ commit }, bool) {
        commit('setEditMode', bool)
    },
};

export default {
    namespaced: true,
    state,
    getters,
    mutations,
    actions
};

コメントで「追加」と書いてある行や関数が今回追加したものです。
変更箇所だけ書いても良いのですが、初めてVue.jsを触る方でも追いかけやすい様に全文を記載して行きます。

編集モードとそうでないモードで切り替えるために、isEditMode変数を用意しました。
またisEditModeステートと、値を変更するためのsetEditModeアクションを追加しておきます。

予定作成フォームの作成

まず予定の作成に必要な項目を検討しましょう。
データベースのフィールドの定義を思い出して下さい。

  1. 予定のタイトル
  2. 開始日時
  3. 終了日時
  4. 予定詳細
  5. 予定の色
  6. 終日かどうか
  7. どのカレンダーに属する予定か

以上の項目が必要なことが分かります。
今回の開発では、まだカレンダー(予定の種類)を管理する機能を作成していないので、⑦については、後でコントローラーで、仮のデフォルト値を入れる様にします。

また、⑥の終日かどうかについても、一旦、予定の登録ができる事を確認してから追加する事にします。
app/Http/Controllers/EventController.phpを開き下記のメソッドを修正して下さい。

<?php

namespace App\Http\Controllers;

use App\Models\Event;
use Illuminate\Http\Request;
use Carbon\Carbon;

class EventController extends Controller
{
// 〜 省略 〜
    /**
     * @param Request $request
     * @param $event
     * @return \Illuminate\Http\JsonResponse
     */
    public function _saveEvent(Request $request, $event): \Illuminate\Http\JsonResponse
    {
        $event->name = $request->input('name');
        $event->start = new Carbon($request->input('start'));
        $event->end = new Carbon($request->input('end'));

        // @TODO カレンダー管理機能を作成後に下記の処理を編集します。
//        $event->timed = $request->input('timed');
        $event->timed = 1; // 取り敢えず、終日として扱わない (時間表示する)
//        $event->calendar_id = $request->input('calendar_id');
        $event->calendar_id = 1; // 取り敢えず、登録されているカレンダーをデフォルトとして使用
        // TODOここまで
        
        $event->description = $request->input('description');
        $event->color = $request->input('color');

        if ($event->save()) {
            return response()->json($event);
        } else {
            return response()->json(['error' => 'Save Error']);
        }
    }
}

準備が出来たので、早速始めます。
まず、必要なファイルを全て用意してしまいます。
ターミナルを開き次のコマンドを実行して下さい。

#サブ関数を定義するファイルを作成
my-calendar $ mkdir resources/js/functions
my-calendar $ touch resources/js/functions/serializers.js
my-calendar $ touch resources/js/functions/datetime.js

#フォームのパーツを作成
my-calendar $ touch resources/js/components/form/DateForm.vue
my-calendar $ touch resources/js/components/form/TimeForm.vue
my-calendar $ touch resources/js/components/form/TextForm.vue
my-calendar $ touch resources/js/components/form/ColorForm.vue

# 予定作成フォームを作成
my-calendar $ touch resources/js/components/form/EventFormDialog.vue

一気に沢山ファイルを作りましたが、一つ一つのファイルは短く、これまでに解説してきた内容で理解できることばかりなので、一気に作りましょう!!

補助関数の作成

まずは、「functions」ディレクトリへ作成した2つのファイルを編集します。
resources/js/functions/serializers.jsを開き次の様に編集して下さい。

import { format } from 'date-fns';

export const serializeEvent = event => {
    if (event === null) {
        return null;
    }
    const start = new Date(event.start);
    const end = new Date(event.end);
    return {
        ...event,
        start,
        end,
        startDate: format(start, 'yyyy/MM/dd'),
        startTime: format(start, 'HH:mm'),
        endDate: format(end, 'yyyy/MM/dd'),
        endTime: format(end, 'HH:mm'),
        color: event.color || '#216a1a',
    };
};

このファイルはVuexのストアで行っていたイベントをJSのオブジェクトにする処理を切り分けたものです。
Vuexは状態の管理やAPIとの連携のみを行う様にしておく事で見通しが良くなります。

続いて、resources/js/functions/datetime.jsを編集して下さい。

export const getTimeIntervalList = () => {
    const hours = [...Array(24)].map((_, i) => ('0' + i).slice(-2));
    const minutes = ['00', '15', '30', '45'];
    return hours.map(hour => minutes.map(minute => hour + ':' + minute)).flat();
}

このファイルは先ほど作成した、TimeForm.vueで選択できる様にする時間の間隔をオブジェクトとして渡す関数です。

今回は、15分単位で時間指定を行える様にします。

正直分ける程では無かったのですが、今後の拡張性を考えた場合、分けておくと良いかと思います。

予定の登録処理の追加

イベントストアへ、先ほど作成したserializers.jsのserializeEventメソッドを組み込みつつ、APIのポスト処理も書いておきましょう。

resources/js/store/module/events.jsを開き次の様に編集して下さい。

import axios from 'axios';
import { serializeEvent } from '../../functions/serializers'; // 追加

const state = {
    events: [],
    event: null,
    isEditMode: false,
};

// 編集 serializeEventメソッドでeventを整形
const getters = {
    events: state => state.events.map(event => serializeEvent(event)),
    event: state => serializeEvent(state.event),
    isEditMode: state => state.isEditMode,
};

const mutations = {
    setEvents: (state, events) => (state.events = events),
    appendEvent: (state, event) => (state.events = [...state.events, event]), // 追加
    setEvent: (state, event) => (state.event = event),
    setEditMode: (state, bool) => (state.isEditMode = bool),
}

const actions = {
    async fetchEvents({ commit }) {
        const response = await axios.get('/api/events');
        commit('setEvents', response.data);
    },
    // メソッドを追加
    async createEvent({ commit }, event) {
        const response = await axios.post('/api/events', event);
        commit('appendEvent', response.data);
    },
    // ここまで
    setEvent({ commit }, event) {
        commit('setEvent', event);
    },
    setEditMode({ commit }, bool) {
        commit('setEditMode', bool)
    },
};

export default {
    namespaced: true,
    state,
    getters,
    mutations,
    actions
};

createEventアクションを追加しました。
第2引数にはこのアクションを呼び出す時の引数が代入されます。
axios.postでeventデータをパラメータとしてPOSTリクエストを送ります。
responseにはデータベースに登録されたeventデータが代入されます。
このデータをappendEventミューテーションに渡し、[...state.events, event]で元々のstate.events配列の末尾にeventデータを追加させます。

eventステートを更新することでカレンダーの予定表示が変更されます。

日付選択コンポーネントの作成

resources/js/components/form/DateForm.vueを開き次の様に編集して下さい。

<template>
    <v-menu offset-y>
        <template v-slot:activator="{ on }">
            <v-btn text v-on="on">{{ value || '日付を選択' }}</v-btn>
        </template>
        <v-date-picker
            :value="value.replace(/\//g, '-')"
            @input="$emit('input', $event.replace(/-/g, '/'))"
            no-title
            locale="ja-ja"
            :day-format="value => new Date(value).getDate()"
        ></v-date-picker>
    </v-menu>
</template>

<script>
export default {
    name: 'DateForm',
    props: ['value'],
}
</script>

<v-date-picker>はVuetifyが提供する日付選択カレンダーです。
propsで受け取るのはvalue変数なので、v-model="value"と指定すれば良いと思われるかもしれませんが、propsで受け取る値は変更不可のため、v-modelに指定することができません
:valueにpropsから受け取る値を保持する変数、@inputにその変数の値が更新された時に呼び出す処理を指定しました。
$eventにはDatePickerで選択した日付が入ります。
$emit('input', $event)を実行すると親コンポーネントの値を更新する事が出来ます。

詳しくは公式ガイドをご覧ください。

時間選択コンポーネントを作成する

resources/js/components/form/TimeForm.vueを開き次のように編集して下さい。

<template>
    <v-menu offset-y>
        <template v-slot:activator="{ on }">
            <v-btn text v-on="on">{{ value || '時間を選択' }}</v-btn>
        </template>
        <v-list height="300px" class="overflow-y-auto">
            <v-list-item v-for="(time, i) in times" :key="i" @click="$emit('input', time)">
                {{ time }}
            </v-list-item>
        </v-list>
    </v-menu>
</template>

<script>
import { getTimeIntervalList } from '../../functions/datetime';

export default {
    name: 'TimeForm',
    props: ['value'],
    computed: {
        times() {
            return getTimeIntervalList();
        },
    },
}
</script>

ここで、先ほどdatetime.jsで作成したgetTimeIntervalListメソッドを使用します。
コンポーネントから処理を分離していることで、コンポーネント自体は、とてもシンプルになっているかと思います。

@click=$emit('input', time)でクリックした時にその時間の文字列を親コンポーネントに渡します。

予定詳細入力コンポーネントの作成

resources/js/components/form/TextForm.vueを次の様に編集して下さい。

<template>
    <v-textarea
        filled
        rounded
        auto-grow
        :value="value"
        @input="$emit('input', $event)"
        placeholder="詳細"
        rows="4">
    </v-textarea>
</template>

<script>
export default {
    name: 'TextForm',
    props: ['value'],
}
</script>

DateFormコンポーネントやTimeFormコンポーネントと同様に、propsでvalueを受け取り、フォームに何か入力したら$emit('input', $event)で親コンポーネントにその値を返します。

カラー選択コンポーネントの作成

resources/js/components/form/ColorForm.vueを次の様に編集して下さい。

<template>
    <div class="d-flex align-center">
        <v-menu offset-y>
            <template v-slot:activator="{ on }">
                <v-btn text v-on="on">
                    <v-icon :color="value" size="20px">mdi-circle</v-icon>
                    <v-icon color="rgba(0, 0, 0, 0.6)" size="24px">mdi-menu-down</v-icon>
                </v-btn>
            </template>
            <v-color-picker
                hide-canvas
                hide-inputs
                show-swatches
                :value="value"
                @input="$emit('input', $event)"
            >
            </v-color-picker>
        </v-menu>
    </div>
</template>

<script>
export default {
    name: 'ColorForm',
    props: ['value'],
}
</script>

Vuetifyが提供している<v-color-picker>を使用します。
このコンポーネントを使用することでカラーを選択するためのパレットを簡単に表示することができます。

データの受け渡しはこれまでと同様に、propsでvalueを受け取り、$emit('input', $event)で選択したカラーを親コンポーネントに返しています。

予定作成ダイアログコンポーネントの作成

パーツの作成が終わったので、フォーム本体を作成して行きます。
resources/js/components/form/EventFormDialog.vueを次の様に編集して下さい。

<template>
    <v-card class="pb-12">
        <v-card-actions class="d-flex justify-end pa-2">
            <v-btn icon @click="closeDialog">
                <v-icon size="20px">mdi-close</v-icon>
            </v-btn>
        </v-card-actions>
        <v-card-text>
            <DialogSection icon="mdi-square" :color="color">
                <v-text-field v-model="name" label="タイトル"></v-text-field>
            </DialogSection>
            <DialogSection icon="mdi-clock-outline">
                <DateForm v-model="startDate" />
                <TimeForm v-model="startTime" />
                <DateForm v-model="endDate" />
                <TimeForm v-model="endTime" />
            </DialogSection>
            <DialogSection icon="mdi-card-text-outline">
                <TextForm v-model="description" />
            </DialogSection>
            <DialogSection icon="mdi-palette">
                <ColorForm v-model="color" />
            </DialogSection>
        </v-card-text>
        <v-card-actions class="d-flex justify-end">
            <v-btn @click="submit">保存</v-btn>
        </v-card-actions>
    </v-card>
</template>

<script>
import { mapGetters, mapActions } from 'vuex';
import DialogSection from '../pageParts/DialogSection';
import DateForm from './DateForm';
import TimeForm from './TimeForm';
import TextForm from './TextForm';
import ColorForm from './ColorForm';

export default {
    name: 'EventFormDialog',
    components: {
        DialogSection,
        DateForm,
        TimeForm,
        TextForm,
        ColorForm,
    },
    data: () => ({
        name: '',
        startDate: null,
        startTime: null,
        endDate: null,
        endTime: null,
        description: '',
        color: '',
    }),
    computed: {
        ...mapGetters('events', ['event']),
    },
    created() {
        this.startDate = this.event.startDate;
        this.startTime = this.event.startTime;
        this.endDate = this.event.endDate;
        this.endTime = this.event.endTime;
        this.color = this.event.color;
    },
    methods: {
        ...mapActions('events', ['setEvent', 'setEditMode', 'createEvent']),
        closeDialog() {
            this.setEditMode(false);
            this.setEvent(null);
        },
        submit() {
            const params = {
                name: this.name,
                start: `${this.startDate} ${this.startTime || ''}`,
                end: `${this.endDate} ${this.endTime || ''}`,
                description: this.description,
                color: this.color,
            };
            this.createEvent(params);
            this.closeDialog();
        },
    },
};
</script>

いかがでしょうか?
パーツごとにコンポーネントを分けているので、フォーム本体のコードが非常にすっきりしているのがわかるかと思います。

では実際に「npm run watch」でビルドを行い、ローカルサーバーを立ち上げて登録処理を行ってみて下さい。

こんな感じに出来上がっていればOKです。

保存ボタンを押すと登録でき、即座にカレンダーへ反映されている事が確認できます。
「予定をクリック」すると「予定の詳細」ダイアログが開く事も併せてご確認下さい。

いかがでしょうか?
結構簡単だったのではないかと思います。

今回作成したコンポーネントは、後で作成する編集画面でも再利用します。
コンポーネント化することで、同じ様なコードを何度も書くことを防ぐことが出来、保守がしやすい見通しの良いプロジェクトを作成することが出来ます。

終日チェックボックスの追加

正直必須では無いのですが、終日かどうかを設定するチェックボックスを付けておきたいと思います。
ターミナルで次のコマンドを実行して下さい。

my-calendar $  touch resources/js/components/form/CheckBox.vue

resources/js/components/form/CheckBox.vueを開き次の様に編集して下さい。

<template>
    <v-checkbox
        v-model="value"
        @change="$emit('input', $event)"
        :label="label"
    ></v-checkbox>
</template>

<script>
export default {
    name: 'CheckBox',
    props: ['value', 'label'],
}
</script>

これまでと同じなので、特別解説は必要ないかと思います。
CheckBoxコンポーネントを組み込んでいきます。
resources/js/components/form/EventFormDialog.vueを次の様に編集して下さい。

<template>
    <v-card class="pb-12">
        <v-card-actions class="d-flex justify-end pa-2">
            <v-btn icon @click="closeDialog">
                <v-icon size="20px">mdi-close</v-icon>
            </v-btn>
        </v-card-actions>
        <v-card-text>
            <DialogSection icon="mdi-square" :color="color">
                <v-text-field v-model="name" label="タイトル"></v-text-field>
            </DialogSection>
            <DialogSection icon="mdi-clock-outline">
                <DateForm v-model="startDate" />
                <!--追加-->
                <div v-show="!allDay">
                    <TimeForm v-model="startTime" />
                </div>
                <!--ここまで-->
                <DateForm v-model="endDate" />
                <!--追加-->
                <div v-show="!allDay">
                    <TimeForm v-model="endTime" />
                </div>
                <CheckBox v-model="allDay" label="終日" />
                <!--ここまで-->
            </DialogSection>
            <DialogSection icon="mdi-card-text-outline">
                <TextForm v-model="description" />
            </DialogSection>
            <DialogSection icon="mdi-palette">
                <ColorForm v-model="color" />
            </DialogSection>
        </v-card-text>
        <v-card-actions class="d-flex justify-end">
            <v-btn @click="submit">保存</v-btn>
        </v-card-actions>
    </v-card>
</template>

<script>
import { mapGetters, mapActions } from 'vuex';
import DialogSection from '../pageParts/DialogSection';
import DateForm from './DateForm';
import TimeForm from './TimeForm';
import TextForm from './TextForm';
import ColorForm from './ColorForm';
import CheckBox from './CheckBox'; // 追加

export default {
    name: 'EventFormDialog',
    components: {
        DialogSection,
        DateForm,
        TimeForm,
        TextForm,
        ColorForm,
        CheckBox, // 追加
    },
    data: () => ({
        name: '',
        startDate: null,
        startTime: null,
        endDate: null,
        endTime: null,
        description: '',
        color: '',
        allDay: false, // 追加
    }),
    computed: {
        ...mapGetters('events', ['event']),
    },
    created() {
        this.startDate = this.event.startDate;
        this.startTime = this.event.startTime;
        this.endDate = this.event.endDate;
        this.endTime = this.event.endTime;
        this.color = this.event.color;
        this.allDay = !this.event.timed; // 追加
    },
    methods: {
        ...mapActions('events', ['setEvent', 'setEditMode', 'createEvent']),
        closeDialog() {
            this.setEditMode(false);
            this.setEvent(null);
        },
        submit() {
            const params = {
                name: this.name,
                start: `${this.startDate} ${this.startTime || ''}`,
                end: `${this.endDate} ${this.endTime || ''}`,
                description: this.description,
                color: this.color,
                timed: !this.allDay, // 追加
            };
            this.createEvent(params);
            this.closeDialog();
        },
    },
};
</script>

予定が終日ならallDayはtrue、時間指定があればfalseになります。
DBに終日かどうかの情報を保存するカラムはtimedカラムで、これは時間指定があればtrue、なければfalseの値が入るので、!this.allDayで反転させています。

API側で、timedは直指定していたので修正します。
app/Http/Controllers/EventController.phpを開き下記のメソッドを修正して下さい。

<?php

namespace App\Http\Controllers;

use App\Models\Event;
use Illuminate\Http\Request;
use Carbon\Carbon;

class EventController extends Controller
{
// 〜 省略 〜
    public function _saveEvent(Request $request, $event): \Illuminate\Http\JsonResponse
    {
        $event->name = $request->input('name');
        $event->start = new Carbon($request->input('start'));
        $event->end = new Carbon($request->input('end'));

        $event->timed = $request->input('timed'); // ここを編集

        // @TODO カレンダー管理機能を作成後に下記の処理を編集します。
//        $event->calendar_id = $request->input('calendar_id');
        $event->calendar_id = 1; // 取り敢えず、登録されているカレンダーをデフォルトとして使用
        // TODOここまで

        $event->description = $request->input('description');
        $event->color = $request->input('color');

        if ($event->save()) {
            return response()->json($event);
        } else {
            return response()->json(['error' => 'Save Error']);
        }
    }
}

実際に作成・保存してみて下さい。

終日チェックボックスをチェックにすると、時間選択コンポーネントが非表示になり、チェックを外すと時間選択コンポーネントが表示される事が確認できます。

予定詳細モーダルで終日の場合は時間を非表示に

予定が終日の場合は時間を非表示にします。
resources/js/components/pageParts/EventDetailDialog.vueを次の様に編集して下さい。

<template>
    <v-card class="pb-12">
        <v-card-actions class="d-flex justify-end pa-2">

            <v-btn icon @click="closeDialog">
                <v-icon size="20px">mdi-close</v-icon>
            </v-btn>
        </v-card-actions>
        <v-card-title>
            <DialogSection icon="mdi-square" :color="event.color">
                {{ event.name }}
            </DialogSection>
        </v-card-title>
        <v-card-text>
            <DialogSection icon="mdi-clock-time-three-outline">
                <!--編集-->
                {{ event.startDate }} {{ event.timed ? event.startTime : '' }} ~ {{ event.endDate }} {{ event.timed ? event.endTime : '' }}
                <!--ここまで-->
            </DialogSection>
        </v-card-text>
        <v-card-text>
            <DialogSection icon="mdi-card-text-outline">
                {{ event.description || 'no description' }}
            </DialogSection>
        </v-card-text>

    </v-card>
</template>

<script>
import { mapGetters, mapActions } from 'vuex';
import DialogSection from './DialogSection';

export default {
    name: 'EventDetailDialog',
    computed: {
        ...mapGetters('events', ['event']),
    },
    components: {
        DialogSection,
    },
    methods: {
        ...mapActions('events', ['setEvent']),
        closeDialog() {
            this.setEvent(null);
        },
    }
};
</script>

これで予定の作成機能は一旦、完了です。
カレンダーを選択する機能はカレンダーの管理機能を作った後に一緒に実装します。
※次回カレンダー管理機能を作ります。

また、現時点ではバリデーションもかけていませんが、カレンダー選択機能を作った後に併せて実装します。
バリデーションは、Vuelidateというライブラリーを使用します。

予定の削除機能を作る

次は削除機能を作って行きましょう!!
大分長い記事になっているので、お腹がいっぱいかと思いますが、削除・更新はあっさりと終わるので、頑張って行きましょう!!

Vuexストアに削除アクションを追加

resources/js/store/module/events.jsを開き次の様に編集して下さい。

import axios from 'axios';
import { serializeEvent } from '../../functions/serializers';

const state = {
    events: [],
    event: null,
    isEditMode: false,
};

const getters = {
    events: state => state.events.map(event => serializeEvent(event)),
    event: state => serializeEvent(state.event),
    isEditMode: state => state.isEditMode,
};

const mutations = {
    setEvents: (state, events) => (state.events = events),
    appendEvent: (state, event) => (state.events = [...state.events, event]), 
    setEvent: (state, event) => (state.event = event),
    // 追加
    removeEvent: (state, event) => (state.events = state.events.filter(e => e.id !== event.id)),
    resetEvent: state => (state.event = null),
    // ここまで
    setEditMode: (state, bool) => (state.isEditMode = bool),
}

const actions = {
    async fetchEvents({ commit }) {
        const response = await axios.get('/api/events');
        commit('setEvents', response.data);
    },
    async createEvent({ commit }, event) {
        const response = await axios.post('/api/events', event);
        commit('appendEvent', response.data);
    },
    // メソッドを追加
    async deleteEvent({ commit }, id) {
        const response = await axios.delete(`/api/events/${id}`);
        commit('removeEvent', response.data);
        commit('resetEvent');
    },
    // ここまで
    setEvent({ commit }, event) {
        commit('setEvent', event);
    },
    setEditMode({ commit }, bool) {
        commit('setEditMode', bool)
    },
};

export default {
    namespaced: true,
    state,
    getters,
    mutations,
    actions
};

deleteEventは引数に予定のidを受け取り、削除APIを叩いて予定を削除します。
APIを叩いた後、removeEventミューテーションとresetEventミューテーションを実行します。

eventsステートから削除した予定を除くことで、カレンダー上からイベントが消えます。

予定詳細モーダルに削除ボタンを作成

resources/js/components/pageParts/EventDetailDialog.vueを次の様に編集して下さい。

<template>
    <v-card class="pb-12">
        <v-card-actions class="d-flex justify-end pa-2">
            <!--削除ボタン追加-->
            <v-btn icon @click="removeEvent">
                <v-icon size="20px">mdi-trash-can-outline</v-icon>
            </v-btn>
            <!--ここまで-->
            <v-btn icon @click="closeDialog" :color="event.color">
                <v-icon size="20px">mdi-close</v-icon>
            </v-btn>
        </v-card-actions>
        <v-card-title>
            <DialogSection icon="mdi-square">
                {{ event.name }}
            </DialogSection>
        </v-card-title>
        <v-card-text>
            <DialogSection icon="mdi-clock-time-three-outline">
                {{ event.startDate }} {{ event.timed ? event.startTime : '' }} ~ {{ event.endDate }} {{ event.timed ? event.endTime : '' }}
            </DialogSection>
        </v-card-text>
        <v-card-text>
            <DialogSection icon="mdi-card-text-outline">
                {{ event.description || 'no description' }}
            </DialogSection>
        </v-card-text>

    </v-card>
</template>

<script>
import { mapGetters, mapActions } from 'vuex';
import DialogSection from './DialogSection';

export default {
    name: 'EventDetailDialog',
    computed: {
        ...mapGetters('events', ['event']),
    },
    components: {
        DialogSection,
    },
    methods: {
        ...mapActions('events', ['setEvent', 'deleteEvent']), // deleteEventを追加
        closeDialog() {
            this.setEvent(null);
        },
        // 追加
        removeEvent() {
            const res = confirm(`「${this.event.name}」を削除してもよろしいですか?`);
            if(res) {
                this.deleteEvent(this.event.id);
            }
        },
    }
};
</script>

mapActionsにストアに作成したdeleteEventアクションを追加して呼び出せるようにします。
deleteEventを呼び出すためのremoveEventメソッドを用意し、クリックイベントで呼び出します。

ビルドして予定をクリックし、ゴミ箱アイコンをクリックすると削除確認メッセージが表示されるのでOKをクリックすると、予定が削除されます。

予定の編集・更新機能を作成

最後は予定の編集・更新を実装します。
皆さんも大分お疲れだと思いますが、最後までがんばりましょう!!

Vuexストアに予定の更新アクションを追加

だいたい流れは掴めてきているかと思います。
いつも通り、Vuexのストアから編集して行きます。
resources/js/store/module/events.jsを開き次の様に編集して下さい。

import axios from 'axios';
import { serializeEvent } from '../../functions/serializers';

const state = {
    events: [],
    event: null,
    isEditMode: false,
};

const getters = {
    events: state => state.events.map(event => serializeEvent(event)),
    event: state => serializeEvent(state.event),
    isEditMode: state => state.isEditMode,
};

const mutations = {
    setEvents: (state, events) => (state.events = events),
    appendEvent: (state, event) => (state.events = [...state.events, event]),
    setEvent: (state, event) => (state.event = event),
    removeEvent: (state, event) => (state.events = state.events.filter(e => e.id !== event.id)),
    resetEvent: state => (state.event = null),
    // 追加
    updateEvent: (state, event) => (state.events = state.events.map(e => (e.id === event.id ? event : e))),
    // ここまで
    setEditMode: (state, bool) => (state.isEditMode = bool),
}

const actions = {
    async fetchEvents({ commit }) {
        const response = await axios.get('/api/events');
        commit('setEvents', response.data);
    },
    async createEvent({ commit }, event) {
        const response = await axios.post('/api/events', event);
        commit('appendEvent', response.data);
    },
    async deleteEvent({ commit }, id) {
        const response = await axios.delete(`/api/events/${id}`);
        commit('removeEvent', response.data);
        commit('resetEvent');
    },
    // メソッドを追加
    async updateEvent({ commit }, event) {
        const response = await axios.put(`/api/events/${event.id}`, event);
        commit('updateEvent', response.data);
    },
    // ここまで
    setEvent({ commit }, event) {
        commit('setEvent', event);
    },
    setEditMode({ commit }, bool) {
        commit('setEditMode', bool)
    },
};

export default {
    namespaced: true,
    state,
    getters,
    mutations,
    actions
};

updateEventアクションを追加します。
予定データを受け取り、そのデータで値を更新するAPIを叩きます。
その後、更新された予定データを受け取り、eventsステートの中にある更新前のデータを更新後のデータに入れ替えます。

予定詳細モーダルに編集ボタンを作成

resources/js/components/pageParts/EventDetailDialog.vueを次の様に編集して下さい。

<template>
    <v-card class="pb-12">
        <v-card-actions class="d-flex justify-end pa-2">
            <!--編集ボタン追加-->
            <v-btn icon @click="editEvent">
                <v-icon size="20px">mdi-pencil-outline</v-icon>
            </v-btn>
            <!--ここまで-->
            <v-btn icon @click="removeEvent">
                <v-icon size="20px">mdi-trash-can-outline</v-icon>
            </v-btn>
            <v-btn icon @click="closeDialog">
                <v-icon size="20px">mdi-close</v-icon>
            </v-btn>
        </v-card-actions>
        <v-card-title>
            <DialogSection icon="mdi-square" :color="event.color">
                {{ event.name }}
            </DialogSection>
        </v-card-title>
        <v-card-text>
            <DialogSection icon="mdi-clock-time-three-outline">
                {{ event.startDate }} {{ event.timed ? event.startTime : '' }} ~ {{ event.endDate }} {{ event.timed ? event.endTime : '' }}
            </DialogSection>
        </v-card-text>
        <v-card-text>
            <DialogSection icon="mdi-card-text-outline">
                {{ event.description || 'no description' }}
            </DialogSection>
        </v-card-text>

    </v-card>
</template>

<script>
import { mapGetters, mapActions } from 'vuex';
import DialogSection from './DialogSection';

export default {
    name: 'EventDetailDialog',
    computed: {
        ...mapGetters('events', ['event']),
    },
    components: {
        DialogSection,
    },
    methods: {
        ...mapActions('events', ['setEvent', 'deleteEvent', 'setEditMode']), // setEditModeを追加
        closeDialog() {
            this.setEvent(null);
        },
        removeEvent() {
            const res = confirm(`「${this.event.name}」を削除してもよろしいですか?`);
            if(res) {
                this.deleteEvent(this.event.id);
            }
        },
        // 追加
        editEvent() {
            this.setEditMode(true);
        },
    }
};
</script>

editEventメソッドではsetEditModeアクションを実行し、isEditModeステートの値をtrueに変更します。
この処理を実行することで、これまでに作成した予定作成ダイアログが表示されます。

予定作成ダイアログを作成する時に、ちらっと書きましたが予定の更新も予定の作成と同じフォームを使います。

resources/js/components/form/EventFormDialog.vueを次の様に編集して下さい。

<template>
    <v-card class="pb-12">
        <v-card-actions class="d-flex justify-end pa-2">
            <v-btn icon @click="closeDialog">
                <v-icon size="20px">mdi-close</v-icon>
            </v-btn>
        </v-card-actions>
        <v-card-text>
            <DialogSection icon="mdi-square" :color="color">
                <v-text-field v-model="name" label="タイトル"></v-text-field>
            </DialogSection>
            <DialogSection icon="mdi-clock-outline">
                <DateForm v-model="startDate" />
                <div v-show="!allDay">
                    <TimeForm v-model="startTime" />
                </div>
                <DateForm v-model="endDate" />
                <div v-show="!allDay">
                    <TimeForm v-model="endTime" />
                </div>
                <CheckBox v-model="allDay" label="終日" />
            </DialogSection>
            <DialogSection icon="mdi-card-text-outline">
                <TextForm v-model="description" />
            </DialogSection>
            <DialogSection icon="mdi-palette">
                <ColorForm v-model="color" />
            </DialogSection>
        </v-card-text>
        <v-card-actions class="d-flex justify-end">
            <v-btn @click="cancel">キャンセル</v-btn> <!--追加-->
            <v-btn @click="submit">保存</v-btn>
        </v-card-actions>
    </v-card>
</template>

<script>
import { mapGetters, mapActions } from 'vuex';
import DialogSection from '../pageParts/DialogSection';
import DateForm from './DateForm';
import TimeForm from './TimeForm';
import TextForm from './TextForm';
import ColorForm from './ColorForm';
import CheckBox from './CheckBox';

export default {
    name: 'EventFormDialog',
    components: {
        DialogSection,
        DateForm,
        TimeForm,
        TextForm,
        ColorForm,
        CheckBox, 
    },
    data: () => ({
        name: '',
        startDate: null,
        startTime: null,
        endDate: null,
        endTime: null,
        description: '',
        color: '',
        allDay: false,
    }),
    computed: {
        ...mapGetters('events', ['event']),
    },
    created() {
        this.name = this.event.name; // 追加
        this.startDate = this.event.startDate;
        this.startTime = this.event.startTime;
        this.endDate = this.event.endDate;
        this.endTime = this.event.endTime;
        this.description = this.event.description; // 追加
        this.color = this.event.color;
        this.allDay = !this.event.timed;
    },
    methods: {
        ...mapActions('events', ['setEvent', 'setEditMode', 'createEvent', 'updateEvent']), // 追加
        closeDialog() {
            this.setEditMode(false);
            this.setEvent(null);
        },
        submit() {
            const params = {
                ...this.event, // 追加
                name: this.name,
                start: `${this.startDate} ${this.startTime || ''}`,
                end: `${this.endDate} ${this.endTime || ''}`,
                description: this.description,
                color: this.color,
                timed: !this.allDay,
            };
            // 追加
            if (params.id) {
                this.updateEvent(params);
            } else {
                this.createEvent(params);
            }
            // ここまで
            this.closeDialog();
        },
        // 追加
        cancel() {
            this.setEditMode(false);
            if (!this.event.id) {
                this.setEvent(null);
            }
        },
        // ここまで
    },
};
</script>

createdにnameとdescriptionも追加し、eventステートの値を代入するようにしました。
これで、予定詳細の編集ボタンを押した時に、その予定の値が入力済みの状態でフォームが表示されます。

EventFormDialogコンポーネントとEventDetailDialogコンポーネントは、どちらも同じVuex Storeのevent ステートを参照しているので、データの受け渡しを行う必要がありません。

これがVuexを利用する1番のメリットだと思います。

予定の新規作成と更新処理を分けているのは、次の部分です。

            if (params.id) {
                this.updateEvent(params);
            } else {
                this.createEvent(params);
            }

APIに送信するparamsの値に、...this.eventを追加してid属性を取得できる様にしています。

        const params = {
                ...this.event, // 追加
                name: this.name,
                start: `${this.startDate} ${this.startTime || ''}`,
                end: `${this.endDate} ${this.endTime || ''}`,
                description: this.description,
                color: this.color,
                timed: !this.allDay, // 追加
            };


スプレッド構文で、全ての属性を展開します。
展開される属性にはnameやdescriptionが含まれるのですが、それ以降に直接指定することで上書きされます。
これを追加することで、eventステートにid属性があればid: 1のようにparamsのキーとして指定されます
eventステートにid属性がなければparamsにidのキーは指定されません。

簡単に言うと、paramsにidが含まれるかどうかで作成か更新かを分岐させるように処理しています。

実際に更新処理を実行して、予定が更新されることを確認してみて下さい。

ついでに追加したキャンセルボタンの動きも確認してみて下さい。

予定詳細 → 編集ボタンを押す → キャンセルボタンを押す → 予定詳細にもどる のように変化すればOKです。

ファイルの配置変更

ファイルが増えて来たのでカレンダー機能を追加する前に一部、ファイルを整理します。
ターミナルで次のコマンドを実行して下さい。

#eventsディレクトリを作成
my-calendar $ mkdir resources/js/components/events

#EventFormDialog.vueとEventDetailDialog.vueをeventsディレクトリへ移動
my-calendar $ mv resources/js/components/form/EventFormDialog.vue resources/js/components/events/EventFormDialog.vue
my-calendar $ mv resources/js/components/pageParts/EventDetailDialog.vue resources/js/components/events/EventDetailDialog.vue

#このディレクトリは次回からのカレンダー管理機能で使用します。
my-calendar $ mkdir resources/js/components/calendars

resources/js/components/pageParts/Calendar.vueのimport文を修正します。

 <!--省略-->
<script>
import { format } from 'date-fns';
import { mapGetters, mapActions } from 'vuex';
import EventDetailDialog from '../events/EventDetailDialog'; // 修正
import EventFormDialog from "../events/EventFormDialog"; // 修正

// 省略

これまでの動作が問題なく動くことを確認して下さい。

フロントエンド編④のまとめ

お疲れ様でした!!
今回は大分長いパートになりましたが、如何だったでしょうか?
長いチュートリアルも次回は折り返しを過ぎました。
最後まで、一緒にがんばりましょうね!!

予定の種類を分けないスケジューラーであれば、これでほぼ完成なのですが、今回は予定に種類を持たせられる様にしたい(仕事・プライベートなど)ので、次回は「カレンダー管理機能」を一気につくりたいと思います。

また、予定の種類をメイン画面に表示する(Googleカレンダーの左サイドバーのイメージ)も併せて実装します。

今回のチュートリアルでは、メイン機能の実装を優先的に進めているので、バリデーションやテストなどは最後にまとめて解説したいと思います。

最後までお読みいただきありがとうございます!!次回もお楽しみに!!


手っ取り早く稼げるエンジニアになりたい方は、スクールを利用した学習がオススメです。
TechAcademyは、選抜された現役エンジニアから学べるオンラインに特化したスクールです。
どこかに通う必要なく、自宅でもプログラミングやアプリ開発を学ぶことができます。


フリーランスとして活躍したい方や、副業で稼ぎたい方は初期投資の負担はありますが、スクールで学ぶ事で最短距離で目標を達成できます。

TechAcademyには次の様なメリットがあります。

  • 自宅にいながらオンライン完結で勉強できる
  • 受講生に1人ずつ現役のプロのパーソナルメンターがつく
  • チャットで質問すればすぐに回答が返ってくる
  • オリジナルサービスやオリジナルアプリなどの開発までサポート

コロナ禍で自宅時間が増え、自分のスキルアップや収入アップを目指している方は是非、スクールを検討してみて下さい。


コメントを残す