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

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

皆さんこんにちは!!
前回に引き続き、フロント(Vue.js)側の開発を始めていきます。

前回は、Vuexを利用してカレンダーコンポーネントを書き換えました。
Vuexは解説することが多かったので、かなり長い記事になっていますがとても大切な部分なので、じっくり読んでみてください。

今回は、 予定の詳細を表示(一件の予定を表示)ダイアログを作成して行きたいと思います。

Vue.jsなどの基本的な書き方などの解説は前回までで細かく行なってきましたので、初めて出てくる構文以外は、詳細な解説は行いません。

公式ドキュメントも併せて読みながら進めることで、理解が進みますので公式ドキュメントを併せてご覧ください。

Vue.js

イベントの詳細ダイアログを作る

では、早速進めて行きましょう!
resources/js/store/modules/events.jsを開き、次のように編集してください。

import axios from 'axios';

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

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,
// ここまで追加
};

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

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

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

続いて、ダイアログを作ります。
resources/js/components/pageParts/Calendar.vueを次の様に編集してください。

<template>
    <div>
        <v-sheet height="6vh" class="d-flex align-center">
            <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">
            <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"
            ></v-calendar>
        </v-sheet>

        <!--イベント詳細ダイアログ 追加-->
        <v-dialog :value="event !== null" @click:outside="closeDialog" width="600">
            <div v-if="event !== null">
                <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>
                        <v-row>
                            <v-col cols="2" class="d-flex justify-center align-center">
                                <v-icon size="20px" :color="event.color || 'blue'">mdi-square</v-icon>
                            </v-col>
                            <v-col class="d-flex align-center">
                                {{ event.name }}
                            </v-col>
                        </v-row>
                    </v-card-title>
                    <v-card-text>
                        <v-row>
                            <v-col cols="2" class="d-flex justify-center align-center">
                                <v-icon size="20px">mdi-clock-time-three-outline</v-icon>
                            </v-col>
                            <v-col class="d-flex align-center">
                                {{ event.start.toLocaleString() }} ~ {{ event.end.toLocaleString() }}
                            </v-col>
                        </v-row>
                    </v-card-text>
                    <v-card-text>
                        <v-row>
                            <v-col cols="2" class="d-flex justify-center align-center">
                                <v-icon size="20px">mdi-card-text-outline</v-icon>
                            </v-col>
                            <v-col class="d-flex align-center">
                                {{ event.description || 'no description' }}
                            </v-col>
                        </v-row>
                    </v-card-text>
                </v-card>
            </div>
        </v-dialog>

 <!--イベント詳細ダイアログ 追加ここまで-->

    </div>
</template>

<script>
import { format } from 'date-fns';
import { mapGetters, mapActions } from 'vuex';

export default {
    name: 'Calendar',
    data: () => ({
        value: format(new Date(), 'yyyy/MM/dd'),  // 初期値を今日の月にする
    }),
    computed: {
// 'event'を追加
        ...mapGetters('events', ['events', 'event']), // storeのイベントgetterを使用する
        title () {
            return format(new Date(this.value), 'yyyy年 M月');
        },
    },
    methods: {
// 'setEvent'を追加
        ...mapActions('events', ['fetchEvents', 'setEvent']), // storeのsetEventを利用する為追加
        setToday() {
            this.value = format(new Date(), 'yyyy/MM/dd')
        },
//ここから追加
        showEvent({ event }) { // storeへクリックされたイベントを渡しstoreにセット
            this.setEvent(event);
        },
        closeDialog() { // ダイアログを閉じる。storeのイベントへnullを渡す
            this.setEvent(null); 
        },
    }
};
</script>

長くなりましたが、前回までの内容とほぼ変わりません。
追記や編集した箇所はコメントをつけてあります。

npm run watchを実行してブラウザーで確認してみて下さい。
予定をクリックするとダイアログで予定の詳細が表示されます。

変更箇所の解説

少し補足しておきます。

storeへの追記について

eventステートの初期値はnullにしています。
eventデータを取得する時はeventsと同様に、startとendの値をDate型に変換します。
setEventアクションはsetEventミューテーションを呼び出すだけで、受け取ったeventデータを使用するのでAPIなどは必要ありません。

APIリクエストを送って1件のイベントデータを取得しても良いのですが、無駄な通信が発生してしまうので、フロントで処理が完結する部分はフロントで実装した方が良いです。

v-iconについて

アイコンを表示したい時は<v-icon>を使い、アイコンの種類を指定します。
今回はmdi-square・mdi-close・mdi-clock-time-three-outline・mdi-card-text-outlineを使用しています。

VuetifyはデフォルトでMaterial Design Iconsが利用可能です。
こちらでキーワード検索ができるので、キーワードで検索してみてください。

ダイアログの表示・非表示について

<v-card>を囲うようにして<div><v-dialog>を書いています。
eventステートがnullの時に呼び出そうとするとエラーになってしまうため、v-ifでnullではない場合のみ表示するようにします。

<div v-if="event !== null">

一応、詳細表示機能は出来たのですが、このままではコードがとても見にくいですよね。
このまま、書き続けることも出来ますが、見通しが悪くなり保守性が下がるので、コンポーネントを分けて行きます。

イベント詳細ダイアログをコンポーネント化する

早速分離して行きましょう。
ターミナルで次のコマンドを実行してEventDetailDialog.vueを作成して下さい。

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

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>
            <v-row>
                <v-col cols="2" class="d-flex justify-center align-center">
                    <v-icon size="20px" :color="event.color || 'blue'">mdi-square</v-icon>
                </v-col>
                <v-col class="d-flex align-center">
                    {{ event.name }}
                </v-col>
            </v-row>
        </v-card-title>
        <v-card-text>
            <v-row>
                <v-col cols="2" class="d-flex justify-center align-center">
                    <v-icon size="20px">mdi-clock-time-three-outline</v-icon>
                </v-col>
                <v-col class="d-flex align-center">
                    {{ event.start.toLocaleString() }} ~ {{ event.end.toLocaleString() }}
                </v-col>
            </v-row>
        </v-card-text>
        <v-card-text>
            <v-row>
                <v-col cols="2" class="d-flex justify-center align-center">
                    <v-icon size="20px">mdi-card-text-outline</v-icon>
                </v-col>
                <v-col class="d-flex align-center">
                    {{ event.description || 'no description' }}
                </v-col>
            </v-row>
        </v-card-text>
    </v-card>
</template>

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

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

Calendar.vueのダイアログの中身をゴッソリ移しただけです。

続いて、Calendar.vueを下記のように編集しましょう。

<template>
    <div>
        <v-sheet height="6vh" class="d-flex align-center">
            <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">
            <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"
            ></v-calendar>
        </v-sheet>

        <!--イベント詳細 ※ダイアログの中身をEventDetailDialogへ置き換え-->
        <v-dialog :value="event !== null" @click:outside="closeDialog" width="600">
            <EventDetailDialog v-if="event !== null" />
        </v-dialog>
<!--ここまで-->
    </div>
</template>

<script>
import { format } from 'date-fns';
import { mapGetters, mapActions } from 'vuex';
import EventDetailDialog from './EventDetailDialog'; // EventDetailDialog.vueを読み込む

export default {
    name: 'Calendar',
    data: () => ({
        value: format(new Date(), 'yyyy/MM/dd'),  // 初期値を今日の月にする
    }),
// EventDetailDialogを使用することを定義
    components: {
        EventDetailDialog,
    },
// ここまで
    computed: {
        ...mapGetters('events', ['events', 'event']),
        title () {
            return format(new Date(this.value), 'yyyy年 M月');
        },
    },
    methods: {
        ...mapActions('events', ['fetchEvents', 'setEvent']),
        setToday() {
            this.value = format(new Date(), 'yyyy/MM/dd')
        },
        showEvent({ event }) {
            this.setEvent(event);
        },
        closeDialog() {
            this.setEvent(null);
        },
    }
};
</script>

変更前と挙動が変わらないはずです。
選択された予定のデータはストアで管理しているため、コンポーネントを分離したとしても同じようにストアからデータを取得できます。

Vuexを利用し、データを一元管理しているのでコンポーネントを分離しても同じデータを共有する事が可能です。

Vuexを利用しない場合、propsやemitなどでデータやイベントの伝搬を行う必要があり、コードが煩雑になってしまいます。

機能・役割ごとにコンポーネント分離することで、それぞれのコンポーネントの影響範囲を小さくすることができ、機能の修正がしやすくなるというのメリットもあります。

さらなる分離

EventDetailDialogを更に分離してみましょう。
ターミナルで次のコマンドを実行してファイルを作りDialogSection.vueを編集して下さい。

my-calendar $ touch resources/js/components/pageParts/DialogSection.vue
<template>
  <v-row>
    <v-col cols="2" class="d-flex justify-center align-center">
      <v-icon size="20px" :color="color">{{ icon }}</v-icon>
    </v-col>
    <v-col class="d-flex align-center">
      <slot></slot>
    </v-col>
  </v-row>
</template>

<script>
export default {
  name: 'DialogSection',
  props: ['icon', 'color'],
};
</script>

EventDetailDialogコンポーネントを次のように編集します。

<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 || 'blue'">
                {{ event.name }}
            </DialogSection>
        </v-card-title>
        <v-card-text>
            <DialogSection icon="mdi-clock-time-three-outline">
                {{ event.start.toLocaleString() }} ~ {{ event.end.toLocaleString() }}
            </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>

1行1行の<v-row>を分離して、アイコンの種類、カラー、表示するコンテンツを呼び出し側で指定し、DialogSectionコンポーネントを呼び出すようにしました。
propsでiconとcolorを受け取り、<v-icon size="20px" :color="color">{{ icon }}</v-icon>でアイコンを表示するようにしています。
コンテンツの受け渡しには<slot></slot>を使います。

slotは親となるコンポーネント側から、子のコンポーネントのテンプレートの一部を差し込む機能 です。

理解が難しい部分ではありますが、簡単に言うと「テンプレートタグ内の文字列をslotタグへ入れ替えること」です。

詳細は、公式ドキュメントをご覧ください。

スロット

これで取り敢えず分離は終わりました。
同じスタイルを複数の箇所で統一して使い回すことができ、また1回の変更で共通箇所を一度に修正することができます。

コンポーネント分離のメリット

コンポーネントを機能・役割ごとに分離するメリットをまとめます。

  • 1つのファイルのコード量が減り、見通しが良くなる
  • コンポーネントの持つ役割がはっきりする
  • コンポーネントの影響範囲が小さくなり、修正しやすくなる
  • コンポーネントを複数の箇所で使いまわせる

細かく分離していく考え方に分割統治法単一責任の原則コンポーネント指向などの考え方がありますので、興味のある方は覗いてみて下さい。

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

お疲れ様でした!!
今回はイベントの詳細を表示するダイアログの作成と、コンポーネントの分割を行いました。

最後に書きましたが、コンポーネントを分離することで見通しが良く保守性の高いプロジェクトを作成することができます。

今回出てきた「slot」という概念は、ちょっと難しいので、その内詳しい解説記事を書いてみたいと思います。

次回は、イベントの作成から削除まで一気に作りたいと思います。
次回からも複数のコンポーネントを利用することになるので、今回の内容をしっかり覚えて下さいね!!

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

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


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

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

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

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

コメントを残す