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

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

皆さんこんにちは!!
長く続いたこのチュートリアルも今回で終わりです。ありがとうございます。
今回は次の機能を実装します。

  • 予定のタイムライン表示
  • カレンダーの月表示・週表示・日表示
  • カレンダー横のチェックボックスのチェック状態でイベントのフィルタリング
  • ログインユーザーのカレンダーのみ表示するようにする

作る機能は多く感じますが、どれもそんなに難しくは無いのでここまで作成できている方なら問題ないかと思います。

では、張り切って行きましょう!!

バリデーションなどは、後日ステップアップ編として解説します。

予定のタイムライン表示を作成

Vuetifyのv-calendarコンポーネントを使ってカレンダーを表示していますが、予定が多くなってくると非常に見にくくなってしまいます

カレンダーの日付もしくはmoreをクリックしたらその日の予定一覧をダイアログでタイムライン表示するコンポーネントを作成します。

補助関数の追加

まずdatetime.jsへ関数を追加します。
resources/js/functions/datetime.jsを開き次の様に編集して下さい。

import {format, isWithinInterval} from 'date-fns'; // 追加
import { ja } from 'date-fns/locale'; // 追加

/**
 * 時刻選択用のオブジェクトを生成
 *
 * @returns {FlatArray<string[][], 1>[]}
 */
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();
}

// 追加

/**
 * startDateとendDateの間にdateが含まれるかどうか
 *
 * @param date
 * @param startDate
 * @param endDate
 */
export const isDateWithinInterval = (date, startDate, endDate) => {
    return isWithinInterval(new Date(date), { start: new Date(startDate), end: new Date(endDate) });
}

/**
 * 日付を日本語表記にする
 *
 * @param date
 * @returns {string}
 */
export const formatDateToJa = date => {
    return format(new Date(date), 'M月d日(E)', {locale: ja});
}

/**
 * 日付の比較を行う
 *
 * @param a
 * @param b
 * @returns {number}
 */
export const compareDates = (a, b) => {
    if (a.start < b.start) return -1;
    if (a.start > b.start) return 1;
    return 0;
}

// ここまで

1日の予定を表示するために、クリックされた日付に予定が入っているのかを判定する必要があります。
その判定を行うのにdate-fnsライブラリのisWithinIntervalメソッドを使い判定しています。

export const isDateWithinInterval = (date, startDate, endDate) => {
    return isWithinInterval(new Date(date), { start: new Date(startDate), end: new Date(endDate) });
}

また、1日の予定を表示した際に終日の予定を表示する為に、ソートを定義しています。

デフォルトでは作成順に並んでいます。

export const compareDates = (a, b) => {
    if (a.start < b.start) return -1;
    if (a.start > b.start) return 1;
    return 0;
}

続いてserializers.jsも編集します。
resources/js/functions/serializers.jsを開き次の様に編集して下さい。

import { format, set } from 'date-fns'; // setを追加

export const serializeEvent = event => {
    if (event === null) {
        return null;
    }
    // 編集
    let start = new Date(event.start);
    let end = new Date(event.end);
    if (!event.timed) {
        start = set(start, { hours: 0, minutes: 0, seconds: 0 });
        end = set(end, { hours: 23, minutes: 59, seconds: 59 });
    }
    // ここまで
    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',
    };
};

// カレンダー用の関数を追加
export const serializeCalendar = calendar => {
    if (calendar === null) {
        return null;
    }
    return {
        ...calendar,
        color: calendar.color || '#216a1a',
    };
};

終日の予定でも開始日時と終了日時が保存されており、順序を並べ替えた時ややこしくなってしまいます。
終日の予定の場合は開始日時の時刻を00:00:00に、終了日時の時刻を23:59:59に書き換えて扱うようにします。

Vuexストアの編集

準備ができたので、先ほど作成した関数を使用してevents.jsを編集して行きます。
resources/js/store/modules/events.jsを開き次の様に編集して下さい。

import axios from 'axios';
import { isDateWithinInterval, compareDates } from '../../functions/datetime'; // 追加
import { serializeEvent } from '../../functions/serializers';

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

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

    // 追加 クリックした日付の予定を取得する
    dayEvents: state =>
        state.events
            .map(event => serializeEvent(event))
            .filter(event => isDateWithinInterval(state.clickedDate, event.startDate, event.endDate))
            .sort(compareDates),
    // ここまで

    isEditMode: state => state.isEditMode,
    clickedDate: state => state.clickedDate, // 追加
};

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),
    setClickedDate: (state, date) => (state.clickedDate = date), // 追加
}

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)
    },
    // メソッドを追加
    setClickedDate({ commit }, date) {
        commit('setClickedDate', date);
    },
    // ここまで
};

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

特別解説する内容はありませんが、先ほど作成した補助関数のおかげで、ゲッターのdayEventsメソッドをとてもスッキリと実装できていることが分かるかと思います。

コンポーネントの作成

クリックされた時に表示されるダイアログを作成します。
ターミナルで次のコマンドを実行して下さい。

my-calendar $ touch resources/js/components/pageParts/DayEventList.vue

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

<template>
    <v-card class="pb-8">
        <v-card-actions class="d-flex justify-end">
            <v-btn icon @click="closeDialog">
                <v-icon>mdi-close</v-icon>
            </v-btn>
        </v-card-actions>
        <v-card-title class="d-flex justify-center">
            {{ formatDateToJa(clickedDate) }}
        </v-card-title>
        <v-card-text>
            <div v-for="event in dayEvents" :key="event.id">
                <v-timeline>
                    <v-timeline-item
                        :color="event.color"
                        small
                    >
                        <v-row justify="space-between">
                            <v-col
                                cols="7"
                                v-text="event.name"
                            ></v-col>
                            <v-col
                                class="text-right"
                                cols="5"
                                v-text="event.startTime"
                            ></v-col>
                        </v-row>
                    </v-timeline-item>
                </v-timeline>
            </div>


        </v-card-text>
    </v-card>
</template>

<script>
import { mapActions, mapGetters } from "vuex";
import { formatDateToJa } from "../../functions/datetime";

export default {
    name: 'DayEventList',
    computed: {
        ...mapGetters('events', ['dayEvents', 'clickedDate']),
    },
    methods: {
        ...mapActions('events', ['setClickedDate']),
        formatDateToJa,
        closeDialog () {
            this.setClickedDate(null);
        },
    },
};
</script>

ここでは、VuetifyのTimelineを使用しています。
今回はシンプルに実装しますが、オプションが多く素敵なタイムラインを簡単に作れるので、公式サイトを確認してみて下さい。

カレンダーコンポーネントへ組み込む

では、作成したダイアログコンポーネントをカレンダーへ組み込みます。
resources/js/components/pageParts/Calendar.vueを開き次の様に編集して下さい。

<template>
    <div>
        <v-sheet height="6vh" class="d-flex align-center" color="grey lighten-3">
            <v-btn outlined small class="ma-4" @click="setToday">TODAY</v-btn>
            <v-btn icon @click="$refs.calendar.prev()">
                <v-icon>mdi-chevron-left</v-icon>
            </v-btn>
            <v-btn icon @click="$refs.calendar.next()">
                <v-icon>mdi-chevron-right</v-icon>
            </v-btn>
            <v-toolbar-title>{{ title }}</v-toolbar-title>
        </v-sheet>
        <v-sheet height="94vh" class="d-flex">
            <v-sheet width="200px">
                <CalendarList />
            </v-sheet>
            <v-sheet class="flex">
                <!--クリックイベントを追加-->
                <v-calendar
                    ref="calendar"
                    v-model="value"
                    :events="events"
                    @change="fetchEvents"
                    locale="ja-jp"
                    :day-format="timestamp => new Date(timestamp.date).getDate()"
                    :month-format="timestamp => new Date(timestamp.date).getMonth() + 1 + ' /'"
                    @click:event="showEvent"
                    @click:day="initEvent"
                    @click:date="showDayEvents"
                    @click:more="showDayEvents"
                ></v-calendar>
                <!---->
            </v-sheet>
        </v-sheet>

        <v-dialog :value="event !== null" @click:outside="closeDialog" width="600">
            <EventDetailDialog v-if="event !== null && !isEditMode" />
            <EventFormDialog v-if="event !== null && isEditMode" />
        </v-dialog>

        <!--追加-->
        <v-dialog :value="clickedDate !== null" @click:outside="closeDialog" width="600">
            <DayEventList />
        </v-dialog>
        <!--ここまで-->
    </div>
</template>

<script>
import { format } from 'date-fns';
import { mapGetters, mapActions } from 'vuex';
import EventDetailDialog from '../events/EventDetailDialog';
import EventFormDialog from "../events/EventFormDialog";
import CalendarList from '../calendars/CalendarList';
import DayEventList from '../pageParts/DayEventList'; // 追加

export default {
    name: 'Calendar',
    data: () => ({
        value: format(new Date(), 'yyyy/MM/dd'),  // 初期値を今日の月にする
    }),
    components: {
        EventDetailDialog,
        EventFormDialog,
        CalendarList,
        DayEventList, // 追加
    },
    computed: {
        ...mapGetters('events', ['events', 'event', 'isEditMode', 'clickedDate']), // 'clickedDate'を追加
        title () {
            return format(new Date(this.value), 'yyyy年 M月');
        },
    },
    methods: {
        ...mapActions('events', ['fetchEvents', 'setEvent', 'setEditMode', 'setClickedDate']), // 'setClickedDate'を追加
        setToday() {
            this.value = format(new Date(), 'yyyy/MM/dd')
        },
        showEvent({ nativeEvent, event }) {
            this.setEvent(event);
            nativeEvent.stopPropagation();
        },
        closeDialog() {
            this.setEvent(null);
            this.setEditMode(false);
            this.setClickedDate(null); // 追加
        },
        initEvent({ date }) {
            // 追加
            if (this.clickedDate !== null) {
                return;
            }
            // ここまで
            date = date.replace(/-/g, '/');
            const start = format(new Date(date), 'yyyy/MM/dd 00:00:00')
            const end = format(new Date(date), 'yyyy/MM/dd 01:00:00')
            this.setEvent({ name: '', start, end, timed: true });
            this.setEditMode(true);
        },
        // 追加
        showDayEvents({ date }) {
            date = date.replace(/-/g, '/');
            this.setClickedDate(date);
        },
        // ここまで
    }
};
</script>

これで実装は終わりです。
ビルドして確認してみて下さい。

日付をクリックして、その日の予定がタイムライン表示されればOKです。

月表示・週表示・日表示を切り替えられる様にする

続いてはカレンダーの表示を切り替えられる様に実装します。
resources/js/components/pageParts/Calendar.vueを開き次の様に編集して下さい。

<template>
    <div>
        <v-sheet height="6vh" class="d-flex align-center" color="lighten-3"> <!--greyを抜いた--> 
            <v-btn outlined small class="ma-4" @click="setToday">TODAY</v-btn>
            <v-btn icon @click="$refs.calendar.prev()">
                <v-icon>mdi-chevron-left</v-icon>
            </v-btn>
            <v-btn icon @click="$refs.calendar.next()">
                <v-icon>mdi-chevron-right</v-icon>
            </v-btn>
            <v-toolbar-title>{{ title }}</v-toolbar-title>
            <!--追加-->
            <v-spacer></v-spacer>
            <v-sheet class="d-flex">
                <v-select
                    dense
                    v-model="viewSelect"
                    :items="items"
                    item-text="text"
                    item-value="value"
                    label="Select"
                    persistent-hint
                    return-object
                    single-line
                    prepend-icon="mdi-calendar"
                    @change="changeView(viewSelect.value, $event)"
                ></v-select>
            </v-sheet>
            <!--ここまで-->
        </v-sheet>
        <v-sheet height="94vh" class="d-flex">
            <v-sheet width="200px">
                <CalendarList />
            </v-sheet>
            <v-sheet class="flex">
                <!--:type="type || 'month'" を追加-->
                <v-calendar
                    ref="calendar"
                    :type="type || 'month'"
                    v-model="value"
                    :events="events"
                    @change="fetchEvents"
                    locale="ja-jp"
                    :day-format="timestamp => new Date(timestamp.date).getDate()"
                    :month-format="timestamp => new Date(timestamp.date).getMonth() + 1 + ' /'"
                    @click:event="showEvent"
                    @click:day="initEvent"
                    @click:date="showDayEvents"
                    @click:more="showDayEvents"
                ></v-calendar>
            </v-sheet>
        </v-sheet>

        <v-dialog :value="event !== null" @click:outside="closeDialog" width="600">
            <EventDetailDialog v-if="event !== null && !isEditMode" />
            <EventFormDialog v-if="event !== null && isEditMode" />
        </v-dialog>

        <v-dialog :value="clickedDate !== null" @click:outside="closeDialog" width="600">
            <DayEventList />
        </v-dialog>

    </div>
</template>

<script>
import { format } from 'date-fns';
import { mapGetters, mapActions } from 'vuex';
import EventDetailDialog from '../events/EventDetailDialog';
import EventFormDialog from "../events/EventFormDialog";
import CalendarList from '../calendars/CalendarList';
import DayEventList from '../pageParts/DayEventList';

export default {
    name: 'Calendar',
    data: () => ({
        value: format(new Date(), 'yyyy/MM/dd'),  // 初期値を今日の月にする
        // 追加
        type: 'month',
        viewSelect: { value: 'month', text: '月表示' },
        items: [
            { value: 'month', text: '月表示' },
            { value: 'week', text: '週表示' },
            { value: 'day', text: '日表示' },
        ],
        // ここまで
    }),
    components: {
        EventDetailDialog,
        EventFormDialog,
        CalendarList,
        DayEventList,
    },
    computed: {
        ...mapGetters('events', ['events', 'event', 'isEditMode', 'clickedDate']),
        title() {
            // 編集
            switch (this.type) {
                case "month":
                    return format(new Date(this.value), "yyyy年 M月");
                case "week":
                    return this.formedDateOfThisWeek(new Date(this.value));
                case "day":
                    return format(new Date(this.value), "yyyy年 M月 d日");
            }
            // ここまで
        },
    },
    methods: {
        ...mapActions('events', ['fetchEvents', 'setEvent', 'setEditMode', 'setClickedDate']),
        setToday() {
            this.value = format(new Date(), 'yyyy/MM/dd')
        },
        showEvent({ nativeEvent, event }) {
            this.setEvent(event);
            nativeEvent.stopPropagation();
        },
        closeDialog() {
            this.setEvent(null);
            this.setEditMode(false);
            this.setClickedDate(null);
        },
        initEvent({ date }) {
            if (this.clickedDate !== null) {
                return;
            }
            date = date.replace(/-/g, '/');
            const start = format(new Date(date), 'yyyy/MM/dd 00:00:00')
            const end = format(new Date(date), 'yyyy/MM/dd 01:00:00')
            this.setEvent({ name: '', start, end, timed: true });
            this.setEditMode(true);
        },
        showDayEvents({ date }) {
            date = date.replace(/-/g, '/');
            this.setClickedDate(date);
        },
        // 追加
        changeView (text, event) {
            this.type = event.value;
        },
        formedDateOfThisWeek(today) {
            const this_year = today.getFullYear();
            const this_month = today.getMonth();
            const date = today.getDate();
            const day_num = today.getDay();
            const this_sunday = date - day_num;
            const this_saturday = this_sunday + 6;
            const day = String("日月火水木金土");
            let start_date = new Date(this_year, this_month, this_sunday);
            start_date = start_date.getFullYear() + "年" + (start_date.getMonth() + 1) + "月" + start_date.getDate() + "日" + " (" + day.charAt(start_date.getDay()) + ")";
            let end_date = new Date(this_year, this_month, this_saturday);
            end_date = end_date.getFullYear() + "年" + (end_date.getMonth() + 1) + "月" + end_date.getDate() + "日" + " (" + day.charAt(end_date.getDay()) + ")";

            return start_date + " ~ " + end_date;
        },
        // ここまで
    },
};
</script>

右上にセレクトボックスが表示されて、切り替えるとそれぞれ表示が変わることが確認できます。

Vuetifyのカレンダーは日曜始まりなので、週表示した際の見出しを整形するformedDateOfThisWeekメソッドは、日曜から土曜の日付を返却するようにしてあります。

予定の種類でフィルタリング

予定の種類にはvisibilityカラムがあり、boolean値が入っています。
この値がtrueの時は、そのカレンダーに紐づいている予定をカレンダー画面で表示し、falseの時は表示しないようにします。

resources/js/store/modules/events.jsを開き次の様に編集して下さい。
※gettersのeventsのみ編集します。

const getters = {
    // 編集
    events: state => state.events.filter(event => event.calendar.visibility).map(event => serializeEvent(event)),

    event: state => serializeEvent(state.event),
    dayEvents: state =>
        state.events
            .map(event => serializeEvent(event))
            .filter(event => isDateWithinInterval(state.clickedDate, event.startDate, event.endDate))
            .sort(compareDates),
    isEditMode: state => state.isEditMode,
    clickedDate: state => state.clickedDate, 
};

カレンダー一覧のチェックボックスをクリックした時にvisibilityを更新

続いて、resources/js/components/calendars/CalendarList.vueを次の様に編集します。

<template>
    <v-list dense>
        <v-list-item>
            <v-list-item-content>
                <v-subheader>マイカレンダー</v-subheader>
            </v-list-item-content>
            <v-list-item-action>
                <v-btn icon @click="initCalendar">
                    <v-icon size="16px">mdi-plus</v-icon>
                </v-btn>
            </v-list-item-action>
        </v-list-item>
        <v-list-item-group :value="selectedItem">
            <v-list-item v-for="calendar in calendars" :key="calendar.id">
                <v-list-item-content class="pa-1">
 <!--クリックイベントを追加-->
                    <v-checkbox
                        dense
                        v-model="calendar.visibility"
                        :color="calendar.color"
                        :label="calendar.name"
                        @click="toggleVisibility(calendar)"
                        class="pb-2"
                        hide-details="true"
                    ></v-checkbox>
<!--ここまで-->
                </v-list-item-content>
                <v-list-item-action class="ma-0">
                    <v-menu transition="scale-transition" offset-y min-width="100px">
                        <template v-slot:activator="{ on }">
                            <v-btn icon v-on="on">
                                <v-icon size="12px">mdi-dots-vertical</v-icon>
                            </v-btn>
                        </template>
                        <v-list>
                            <v-list-item @click="editCalendar(calendar)">編集</v-list-item>
                            <v-list-item @click="delCalendar(calendar)">削除</v-list-item>
                        </v-list>
                    </v-menu>
                </v-list-item-action>
            </v-list-item>
        </v-list-item-group>
        <v-dialog :value="calendar !== null" @click:outside="closeDialog" width="600">
            <CalendarFormDialog v-if="calendar !== null" />
        </v-dialog>
    </v-list>
</template>

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

export default {
    name: 'CalendarList',
    data: () => ({
        selectedItem: null,
    }),
    components: { CalendarFormDialog },
    computed: {
        ...mapGetters('calendars', ['calendars', 'calendar']),
    },
    created() {
        this.fetchCalendars();
    },
    methods: {
        ...mapActions('calendars', ['fetchCalendars', 'updateCalendar', 'deleteCalendar', 'setCalendar']), // 'updateCalendar'追加
        initCalendar() {
            this.setCalendar({
                name: '',
                visibility: true,
            });
        },
        closeDialog() {
            this.setCalendar(null);
        },
        editCalendar(calendar) {
            this.setCalendar(calendar);
        },
        delCalendar(calendar) {
            const res = confirm(`「${calendar.name}」を削除してもよろしいですか?`);
            if(res) {
                this.deleteCalendar(calendar.id);
            }
        },
        // 追加
        toggleVisibility(calendar) {
            this.updateCalendar(calendar);
        },
        // ここまで
    },
};
</script>

v-checkboxに@click="toggleVisibility(calendar)"を設定します。
updateCalendarアクションでVisibilityの値を更新させます。

イベントの表示を更新

Visibilityを更新するタイミングでイベントデータを再取得します。
resources/js/store/modules/calendars.jsを開き次の様に編集して下さい。
※actionsのみ編集します。

const actions = {
    async fetchCalendars({ commit }) {
        const response = await axios.get('/api/calendars');
        commit('setCalendars', response.data);
    },
    async createCalendar({ commit }, calendar) {
        const response = await axios.post('/api/calendars', calendar);
        commit('appendCalendar', response.data);
    },
    // 編集
    async updateCalendar({ dispatch, commit }, calendar) {
        const response = await axios.put(`/api/calendars/${calendar.id}`, calendar);
        commit('updateCalendar', response.data);
        dispatch('events/fetchEvents', null, { root: true });
    },
    async deleteCalendar({ dispatch, commit }, id) {
        const response = await axios.delete(`/api/calendars/${id}`);
        commit('removeCalendar', response.data);
        dispatch('events/fetchEvents', null, { root: true });
    },
    // ここまで
    setCalendar({ commit }, calendar) {
        commit('setCalendar', calendar);
    },
};

新しいメソッドが出て来ました。
dispatchメソッドを使うことで直接アクションを実行することができます。

第3引数に{ root: true }を指定することで別のVuexストアのアクションを実行できるようになります。

詳しくはこちらをご覧ください。

ビルドして画面で確認してみて下さい。
チェックボックスのチェックを外すとイベントデータが表示されないのが確認できます。

また、データーベースのVisibilityがチェックを付け外しする事で変化していることも確認して下さい。

ログインユーザーの予定のみ表示する

さあ、いよいよ最後になりました。
ここまで本当にお疲れ様でした。

ログイン状態を管理しているのは、Laravel(API)側ですので、イベントデータを取得する際にログインユーザーに紐づくカレンダーを取得させれば良いことが分かります。

まずは、ルーティングを編集します。
routes/api.phpを開き次の様に編集して下さい。

//~ 省略 ~

Route::group(['prefix' => 'calendars', 'as' => 'calendars.'], function () {
//    Route::get('/', [CalendarController::class, 'index'])->name('index'); // コメントアウト
    Route::get("/{id}", [CalendarController::class, 'show'])->name('show');
    Route::post('/', [CalendarController::class, 'create'])->name('create');
    Route::put('/{id}', [CalendarController::class, 'save'])->name('update');
    Route::delete('/{id}', [CalendarController::class, 'destroy'])->name('delete');
});

Route::group(['prefix' => 'events', 'as' => 'events.'], function () {
//    Route::get('/', [EventController::class, 'index'])->name('index'); // コメントアウト
    Route::get("/{id}", [EventController::class, 'show'])->name('show');
    Route::post('/', [EventController::class, 'create'])->name('create');
    Route::put('/{id}', [EventController::class, 'save'])->name('update');
    Route::delete('/{id}', [EventController::class, 'destroy'])->name('delete');
});

ルーティングから、データ取得用の行を抜きます。
api.phpから抜いた行をweb.phpへ追記します。
routes/web.phpを開き次の様に編集して下さい。

<?php

use App\Http\Controllers\CalendarController; // 追加
use App\Http\Controllers\EventController; // 追加
use Illuminate\Support\Facades\Route;

Auth::routes();

Route::get('/', [App\Http\Controllers\HomeController::class, 'index'])->name('home');
// 追加
Route::get('/calendars', [CalendarController::class, 'index'])->name('calendars.index');
Route::get('/events', [EventController::class, 'index'])->name('events.index');

この変更は、以前にも書きましたが、 Auth::id()を使用する為に変更しているだけです。
api.phpでも使用できるように設定することも可能ですが、少々難しくなるので今回はこの様にして実装します。

routeを変更したので、フロント側のストアのアクションを編集します。
resources/js/store/modules/calendars.jsのアクションを次の様に編集して下さい。

// ~ 省略 ~
async fetchCalendars({ commit }) {
        const response = await axios.get('/calendars'); // 編集
        commit('setCalendars', response.data);
    },
// ~ 省略 ~

resources/js/store/modules/events.jsのアクションを次の様に編集して下さい。

// ~ 省略 ~
async fetchEvents({ commit }) {
        const response = await axios.get('/events'); // 編集
        commit('setEvents', response.data);
    },
// ~ 省略 ~

どちらもAPIリクエストの宛先から/apiを抜いているだけです。

続いてコントローラーを編集します。
app/Http/Controllers/CalendarController.phpを開き、indexメソッドを次の様に編集して下さい。

<?php

namespace App\Http\Controllers;

use App\Models\Calendar;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth; // 追加

class CalendarController extends Controller
{
    public function index()
    {
//        return response()->json(Calendar::all()); // 元の行を消して
        return response()->json(Calendar::query()->where('user_id', '=', Auth::id())->get()); // この様に変更
    }

where文でuser_idがログインユーザーのカレンダーデータのみを取得する様に変更しています。
use文でAuthを使用する事を宣言しているので、忘れず追加しましょう。

続いてイベントコントローラーも変更します。
app/Http/Controllers/ EventController.phpを開き、indexメソッドを次の様に編集して下さい。

<?php

namespace App\Http\Controllers;

use App\Models\Event;
use Illuminate\Http\Request;
use Carbon\Carbon;
use Illuminate\Support\Facades\Auth; // 追加

class EventController extends Controller
{
    /**
     * @return \Illuminate\Http\JsonResponse
     */
    public function index(): \Illuminate\Http\JsonResponse
    {
        //        return response()->json(Event::with('calendar')->get()); // 元の行を消して
        
         // この様に変更
        $events = Event::query()
            ->join('calendars', 'events.calendar_id', '=', 'calendars.id')
            ->where('calendars.user_id', '=',  Auth::id())
            ->with('calendar')
            ->get();

        return response()->json($events);
    }

基本的にこちらもログインユーザーのカレンダーに紐づくイベントを取得する条件に変更しています。
カレンダーテーブルにしか、user_idを保管していませんが、joinする事でイベントとユーザを紐づける事ができます。

LaravelのEloquentの詳細については、下記をご覧ください。


Laravel 8.x Eloquentの準備

これでユーザーごとのカレンダー管理が完成しました。
長いチュートリアルに最後まで付き合っていただき、ありがとうございました。

おまけ

皆さんお疲れだとは思いますが、もう少し付き合って下さい。
今回作成したスケジューラーは、カレンダーが無ければ予定を管理することが出来ません。

ユーザー登録を行ったら、デフォルトのカレンダーが何種類か登録される様になっていると親切ですよね。
なので、ユーザー登録時に、デフォルトで「仕事、プライベート、その他」をカレンダーテーブルに登録するようにします。

まず、app/Models/Calendar.phpを開き次の様に編集して下さい。

<?php

namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class Calendar extends Model
{
    use HasFactory;

    protected $table = 'calendars';

    // 追加
    protected $fillable = [
        'name',
        'color',
        'visibility',
        'user_id',
    ];
    // ここまで

    public function events()
    {
        return $this->hasMany(Event::class);
    }
}

今回、デフォルトで数件の予定を1度に登録したいので、モデルの$fillableプロパティを定義する必要があります。

ただし、createメソッドを使用する前に、モデルクラスでfillableまたはguardedプロパティを指定する必要があります。すべてのEloquentモデルはデフォルトで複数代入の脆弱性から保護されているため、こうしたプロパティが必須になります。

複数代入の脆弱性は、ユーザーから予期していないHTTPリクエストフィールドを渡され、そのフィールドがデータベース内の予想外のカラムを変更する場合に発生します。たとえば、悪意のあるユーザーがHTTPリクエストを介してis_adminパラメータを送信し、それがモデルのcreateメソッドに渡されて、ユーザーが自分自身を管理者に格上げする場合が考えられます。

したがって、Eloquentを使い始めるには、複数代入可能にするモデル属性を定義する必要があります。これは、モデルの$fillableプロパティを使用して行います。たとえば、Flightモデルのname属性を一括割り当て可能にしましょう。

https://readouble.com/laravel/8.x/ja/eloquent.html

簡単に言うと脆弱性の対策という事です。
因みに、fillableを定義せずに一括代入するとエラーが発生します。


続いて、ユーザーモデルを編集します。
app/Models/User.phpを開き次の様に編集して下さい。

<?php

// ~ 省略 ~

class User extends Authenticatable
{

    // ~ 省略 ~

    // デフォルトの予定を登録
    protected static function booted()
    {
        static::created(function ($user) {
            $user->calendars()->createMany([
                [
                    'name' => '仕事',
                    'color' => 'blue',
                    'visibility' => 1,
                    'user_id' => $user->id,
                ],
                [
                    'name' => 'プライベート',
                    'color' => 'green',
                    'visibility' => 1,
                    'user_id' => $user->id,
                ],
                [
                    'name' => 'その他',
                    'color' => 'red',
                    'visibility' => 1,
                    'user_id' => $user->id,
                ],
            ]);
        });
    }

    // カレンダーとのリレーション
    public function calendars()
    {
        return $this->hasMany(Calendar::class);
    }
}

EloquentはLaravelのORMですが、初回呼び出し時にbootという静的メソッドを呼び出して、初期設定をしています。

その起動処理の中のcreatedをフックして、リレーションで紐付けたカレンダーテーブルへ初期データを登録する様にしています。

便利な機能なので、是非、使ってみて下さい。

詳しくはこちらをご覧ください。

現在、ユーザー登録を行うと/homeへリダイレクトされてしまい、404エラーが発生するので、
app/Http/Controllers/Auth/RegisterController.phpを編集します。

// 省略

    protected $redirectTo = '/'; // この様に編集

// 省略

ログインコントローラーを編集した時と同じ変更をしています。
これで完成です!!
実際にユーザーを新規に登録して試してみて下さい。

デフォルトでカレンダーが3種類登録されて、TOP画面へ遷移すれば成功です。

今回で一応このチュートリアルも最終回なので、ここまでに作成した内容をコミットしたリポジトリをリポジトリを公開してあります。

必要な方は下記よりどうぞ!!
ご自由に改変していただいて構いません。

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

皆さん大変おつかれさまでした!!
これで基本機能の作成は終わりです。
楽しみながら開発できたでしょうか?

今回のチュートリアルでは、バリデーションやunitテスト、データーベースのシーディングなどを後回しにしてきました。
その辺りは近い内に「ステップアップ編」として解説します。

また、Googleカレンダーとの連携なども「ステップアップ編」で解説する予定です。
ユーザーそれぞれのGoogleカレンダーと連携する必要があるので、少し難易度は上がりますが一気に便利なスケジュール管理アプリになるので、是非、皆さんもやってみて下さい。

このチュートリアルを進めてこられた方は、独自の機能をこのスケジュール管理アプリに組み込んでも勉強になるので、おすすめです。
次の様な機能を、ご自身で実装してみて下さい。

  • イベントのドラッグ&ドロップでの更新
  • イベントをユーザー同士が共有できる機能
  • 組織管理機能
  • イベントを作成したタイミングでメール通知
  • イベントの前にメール通知

難易度的には、どの機能もそんなに難しくはありません。
公式ドキュメントなどを参考にしながら実装してみて下さい。

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


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


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

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

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

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


コメントを残す