🦔

【Laravel × livewire × FullCalendar】動的なスケジュールアプリを作ろう。Part1【予定表示〜作成編】

2022/10/31に公開

概要

今激アツなLaravelライブラリ livewireとFullCalendarを使ってカレンダーアプリを作っていきます。

example的なものなのでカスタムして使ってください。

記事の背景

exmaple的なものから逆引きしてドキュメントを読んだりする方が、理解がしやすいのではないか?
と思い、他の人や自分が似たような事をする時に役立つんじゃないかなと思い記述に至りました。

この記事の対象

  • livewireとfullcalendarでカレンダー機能を作りたい人
  • fullcalendarで機能を作りたいけど使い方とかドキュメントが分からない人

作る機能

  • 非同期更新するカレンダー ← 今回の記事
  • 予定の作成 ← 今回の記事
  • 予定の編集
  • ドラッグ&ドロップでの予定の移動

使用するフレームワーク/ライブラリ

  • Laravel9
  • livewire/livewire
    • jsレスで動的な機能が作れる最強ライブラリ
  • jeroennoten/laravel-adminlte
    • 見た目用に使うデザインパッケージ
    • 付属しているBladeコンポーネントを使用します。
  • fullcalendar
    • カレンダーjsライブラリ
  • icheckBootstrap
    • チェックボックスをリッチな見た目にするライブラリ
  • jantinnerezo/livewire-alert
    • アラートを表示するlivewireで作られたライブラリ
  • flatpickr
    • 軽量ピッカー系ライブラリ(livewireと相性が良い)
    • ※livewireはbootstrapのdatepickerなどとは相性が悪い

環境用意

Laravelプロジェクト作成

$ composer create-project laravel/laravel laravel9_calendar

livewireインストール

$ composer require livewire/livewire

laravel-adminlteインストール

$ composer require jeroennoten/laravel-adminlte
$ php artisan adminlte:install
$ php artisan adminlte:install --only=main_views

livewire有効化

config/adminlte.php
+ 'livewire' => true,

Tips

上記での有効化はここに反映されてます。

resources/views/vendor/adminlte/master.blade.php
....
    {{-- Livewire Styles --}}
    @if(config('adminlte.livewire'))
        @if(app()->version() >= 7)
            @livewireStyles
        @else
            <livewire:styles/>
        @endif
    @endif
....

{{-- Livewire Script --}}
@if(config('adminlte.livewire'))
    @if(app()->version() >= 7)
        @livewireScripts
    @else
        <livewire:scripts/>
    @endif
@endif

icheckBootstrapインストール

laravel-adminlteパッケージのプラグインとしてインストールが出来るので取り込みます。

$ php artisan adminlte:plugins install --plugin=icheckBootstrap 

インストールしたプラグインを有効化させる為、configのplugins項目に追記します。

config/adminlte.php
'plugins' => [
 ....
    'Icheck-bootstrap' => [
        'active' => true,
        'files' => [
            [
                'type' => 'css',
                'asset' => true,
                'location' => 'vendor/icheck-bootstrap/icheck-bootstrap.css',
            ],
        ],
    ],

],

fullcalendarインストール

laravel-adminlteパッケージのプラグインとしてインストールが出来るので取り込みます。

$ php artisan adminlte:plugins install --plugin=fullcalendar

インストールしたプラグインを有効化させる為、configのplugins項目に追記します。

config/adminlte.php
'plugins' => [
 ....
    'fullcalendar' => [
        'active' => true,
        'files' => [
            [
                'type' => 'css',
                'asset' => true,
                'location' => 'vendor/fullcalendar/main.css',
            ],
            [
                'type' => 'js',
                'asset' => true,
                'location' => 'vendor/fullcalendar/main.js',
            ],
            //プレミアム機能
            [
                'type' => 'js',
                'asset' => true,
                'location' => 'https://cdn.jsdelivr.net/npm/fullcalendar-scheduler@5.10.1/main.min.js'
            ],
            //プレミアム機能
            [
                'type' => 'css',
                'asset' => true,
                'location' => 'https://cdn.jsdelivr.net/npm/fullcalendar-scheduler@5.10.1/main.min.css'
            ],
            //日本語化
            [
                'type' => 'js',
                'asset' => true,
                'location' => 'vendor/fullcalendar/locales/ja.js',
            ],

        ],
    ],
],

flatpickrインストール

今回はCDNで読み込みます。

config/adminlte.php
'plugins' => [
 ....
    'flatpickr' => [
        'active' => true,
        'files' => [
            [
                'type' => 'css',
                'asset' => true,
                'location' => 'https://cdn.jsdelivr.net/npm/flatpickr/dist/flatpickr.min.css',
            ],
            [
                'type' => 'js',
                'asset' => true,
                'location' => 'https://cdn.jsdelivr.net/npm/flatpickr',
            ],
            //日本語化
            [
                'type' => 'js',
                'asset' => true,
                'location' => 'https://cdn.jsdelivr.net/npm/flatpickr/dist/l10n/ja.js',
            ],
        ],
    ],
],

livewire-alertインストール

$ composer require jantinnerezo/livewire-alert

有効化させる為adminlteパッケージの親テンプレートに追記します

resources/views/vendor/adminlte/master.blade.php
...
{{-- Custom Scripts --}}
@yield('adminlte_js')
+ <script src="//cdn.jsdelivr.net/npm/sweetalert2@11"></script>
+ <x-livewire-alert::scripts />
</body>

</html>

DBセットアップ

テーブル関係

日付を跨いだ予定も対応出来るようにしようか迷ったのですが、シンプルなサンプルの方が好ましい為1日単位での予定の設計にします。

モデル・マイグレーション作成

スケジュールのモデルとマイグレーションを作成します。

ユーザーのモデルとマイグレーションは最初からあるので不要です。

laravel9_calendar > php artisan make:model Schedule -m
モデル
class Schedule extends Model
{
    use HasFactory;
    
    protected $fillable = [
        'title',
        'day',
        'start',
        'end',
    ];

    public function user():BelongsTo
    {
        return $this->belongsTo(User::class);
    }
}

マイグレーションファイル
Schema::create('schedules', function (Blueprint $table) {
    $table->id();
    $table->foreignId('user_id')->comment('ユーザーid');
    $table->string('title')->comment('予定名');
    $table->date('day')->comment('予定日');
    $table->time('start')->comment('開始時間');
    $table->time('end')->comment('終了時間');
    $table->timestamps();
});

ユーザーモデルにリレーションを定義します。

app/Models/User.php

<?php

namespace App\Models;

// use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable
{
    use HasApiTokens, HasFactory, Notifiable;

    /**
     * The attributes that are mass assignable.
     *
     * @var array<int, string>
     */
    protected $fillable = [
        'name',
        'email',
        'password',
    ];

    /**
     * The attributes that should be hidden for serialization.
     *
     * @var array<int, string>
     */
    protected $hidden = [
        'password',
        'remember_token',
    ];

    /**
     * The attributes that should be cast.
     *
     * @var array<string, string>
     */
    protected $casts = [
        'email_verified_at' => 'datetime',
    ];

+    public function schedules():HasMany
+    {
+        return $this->hasMany(Schedule::class);
+    }
}

ダミーデータ作成

database/seeders/DatabaseSeeder.php
class DatabaseSeeder extends Seeder
{
    public function run()
    {
        $createUserNames = ['田中太郎', '高坂桐乃', '五更瑠璃', '久遠寺有珠'];
        foreach ($createUserNames as $userName) {
            User::factory()->create([
                'name' => $userName,
            ])->schedules()->create([
                'title' => 'プログラミング',
                'day' => Carbon::now()->addDay()->format('Y-m-d'),
                'start' => '07:00:00',
                'end' => '11:00:00',
            ]);
        }
    }
}

マイグレーション・シーダー実行

$ php artisan migrate:fresh --seed

FullCalendarで予定を表示

livewireコンポーネント作成

$ php artisan make:livewire Schedule/Index

ルーティング

web.php
Route::prefix('schedule')->group(function () {
    // スケジュール一覧
    Route::get('/index', \App\Http\Livewire\Schedule\Index::class)->name('schedule.index');
});

コンポーネント:PHP側記述

app/Http/Livewire/Schedule/Index.php
<?php

namespace App\Http\Livewire\Schedule;

use App\Models\Schedule;
use App\Models\User;
use Carbon\CarbonImmutable;
use Livewire\Component;

class Index extends Component
{
    //選択中のユーザーid群
    public array $selectedUserIds = [];

    //カレンダー更新用イベントリスナー
    protected $listeners = ['refreshCalendar'];

    public function mount(): void
    {
        $this->selectedUserIds = User::all()->pluck('id')->toArray();
    }

    //選択中のユーザーid群に更新があった時のライフサイクルイベント
    public function updatedSelectedUserIds(): void
    {
        $this->dispatchBrowserEvent('refreshCalendar');
    }

    public function render()
    {
        $users = User::all();
        return view('livewire.schedule.index', compact('users'))
            ->extends('adminlte::page')
            ->section('content');
    }

    //FullCalendarレンダリング時に取得するResources
    public function getResources(): array
    {
        return User::query()->findMany($this->selectedUserIds)
            ->map(fn($user) => $this->convertToResourceByUserForFullcalendar($user))
            ->toArray();
    }

    //FullCalendarで使えるresourceの配列に整形
    private function convertToResourceByUserForFullcalendar(User $user): array
    {
        return [
            'id' => $user->id,
            'title' => $user->name,
        ];
    }

    //FullCalendarレンダリング時に取得するEvents
    public function getEvents($start, $end): array
    {
        $range = [
            CarbonImmutable::create($start)->format('Y-m-d'),
            CarbonImmutable::create($end)->format('Y-m-d'),
        ];

        return Schedule::query()->whereIn('user_id', $this->selectedUserIds)
            ->whereBetween('day', $range)
            ->get()
            ->map(fn($schedule) => $this->convertToEventByScheduleForFullcalendar($schedule))
            ->toArray();
    }

    //FullCalendarで使えるeventの配列に整形
    private function convertToEventByScheduleForFullcalendar(Schedule $schedule): array
    {
        $startDateTime = new CarbonImmutable($schedule->day . ' ' . $schedule->start);
        $endDateTime = new CarbonImmutable($schedule->day . ' ' . $schedule->end);
        return [
            'title' => $schedule->title,
            'start' => $startDateTime->format('c'),
            'end' => $endDateTime->format('c'),
            'resourceId' => $schedule->user_id,
            'extendedProps' => [
                'schedule_id' => $schedule->id
            ]
        ];
    }

    //カレンダー更新用イベント
    public function refreshCalendar(): void
    {
        $this->dispatchBrowserEvent('refreshCalendar');
    }
}

コンポーネント:Blade側記述

resources/views/livewire/schedule/index.blade.php
<div class="pt-3">
    <x-adminlte-card>
        <div class="d-flex">
            <div class="mr-auto d-flex align-items-center justify-content-center">
                <h4>スケジュール</h4>
            </div>
            <div class="d-flex">
                <div class="mr-2 dropdown" wire:ignore>
                    <button class="btn btn-secondary dropdown-toggle" type="button" id="dropdownMenu2"
                            data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                        ユーザーフィルター
                    </button>
                    <div class="dropdown-menu" aria-labelledby="dropdownMenu2">
                        @foreach($users as $user)
                            <div class="dropdown-item">
                                <div class="icheck-info">
                                    <input type="checkbox" id="filter_user_{{ $user->id }}"
                                           name="selectStoreIds" value="{{ $user->id }}"
                                           wire:model.lazy="selectedUserIds"/>
                                    <label class="font-weight-normal" for="filter_user_{{ $user->id }}">
                                        {{ $user->name }}
                                    </label>
                                </div>
                            </div>
                        @endforeach
                    </div>
                </div>
            </div>
        </div>
        <div wire:loading.flex class="align-items-center justify-content-center">
            読み込み中...
        </div>
        <div id="calendar-container" wire:ignore>
            <div id="calendar"></div>
        </div>
    </x-adminlte-card>
   
</div>

resources/views/livewire/schedule/index.blade.php
    @push('js')
        <script>
            //ドロップダウンメニュー内でクリックされてもメニューを閉じないように制御
            $('.dropdown-menu').click(function (e) {
                e.stopPropagation();
            });
            document.addEventListener('livewire:load', function () {
                var calendarEl = document.getElementById('calendar');

                var calendar = new FullCalendar.Calendar(calendarEl, {
                    //プレミアム機能を使うためのライセンスキー(これはトライアル)
                    schedulerLicenseKey: 'CC-Attribution-NonCommercial-NoDerivatives',
                    //表示テーマ
                    themeSystem: 'bootstrap',
                    //カレンダーそのものの高さ
                    height: 700,
                    //各ボタンの表示テキスト変更
                    buttonText: {
                        resourceTimeGridDay: '日(グリッド)',
                        resourceTimelineDay: '日',
                    },
                    //ツールバー
                    headerToolbar: {
                        left: "prev,next today",
                        center: "title",
                        right: "dayGridMonth,resourceTimelineDay,resourceTimeGridDay",
                    },
                    //初期表示
                    initialView: 'dayGridMonth',
                    //日本語化
                    locale: 'ja',
                    //リソースヘッダー名(日付モードの際に見える)
                    resourceAreaHeaderContent: "ユーザー",
                    //ユーザーが別の日付に移動したりビューを変更したりしたときに、リソースを再取得して再レンダリングするかどうか。
                    refetchResourcesOnNavigate: true,
                    //全日表示モードで時間表示するか
                    displayEventTime: false,
                    //日付クリックで日付モードにするかどうか
                    navLinks: true,
                    //月表示の時に見える「日」が邪魔なので消す
                    dayCellContent: function (e) {
                        e.dayNumberText = e.dayNumberText.replace('日', '');
                    },
                    //リソース取得(月が切り替わる度に更新)
                    resources: function (fetchInfo, successCallback, failureCallback) {
                        @this.
                        getResources().then((response) => {
                            successCallback(response);
                        })
                    },
                    //イベント取得(月が切り替わる度に更新)
                    events: function (info, successCallback) {
                        @this.
                        getEvents(info.start, info.end).then((response) => {
                            successCallback(response);
                        })
                    },
                });
                calendar.render();
                window.addEventListener('refreshCalendar', event => {
                    calendar.refetchResources();
                    calendar.refetchEvents();
                });
            });
        </script>
    @endpush

:::note info

js内で@this.hoge()とlivewireのメソッドを呼び出すと、Promiseオブジェクトが帰ってきます。
簡易的なAPIのようにも使えて便利ですね、

//イベント取得(月が切り替わる度に更新)
events: function (info, successCallback) {
    @this.getEvents(info.start, info.end).then((response) => {
        successCallback(response);
    })
},

:::

予定を登録する

livewireコンポーネント作成

$ php artisan make:livewire Schedule/Create

コンポーネント:PHP側記述

app/Http/Livewire/Schedule/Create.php
<?php

namespace App\Http\Livewire\Schedule;

use App\Models\Schedule;
use App\Models\User;
use Jantinnerezo\LivewireAlert\LivewireAlert;
use Livewire\Component;

class Create extends Component
{
    //アラート表示用
    use LivewireAlert;

    public Schedule $schedule;

    public function mount(): void
    {
        $this->schedule = new Schedule();
    }

    public function render()
    {
        $users = User::all();
        return view('livewire.schedule.create', compact('users'));
    }

    public function openModal(): void
    {
        $this->dispatchBrowserEvent('showCreateScheduleModal');
    }

    //開始時間が終了時間より遅い時間の時に終了時間をリセットする
    public function setScheduleStart($start): void
    {
        $this->schedule->start = $start;
        if ($this->isStartTimeExceededEndTime()) {
            $this->schedule->end = null;
        }
    }

    //開始時間が終了時間より遅い時間かどうか
    private function isStartTimeExceededEndTime(): bool
    {
        return strtotime($this->schedule->start) >= strtotime($this->schedule->end);
    }

    public function create(): void
    {
        $this->validate();
        $this->schedule->save();
        $this->schedule = new Schedule();
        $this->dispatchBrowserEvent('closeCreateScheduleModal');
        $this->emitUp('refreshCalendar');
        $this->alert('success', '登録完了');
    }

    protected function rules(): array
    {
        return [
            'schedule.user_id' => 'required',
            'schedule.title' => 'required',
            'schedule.day' => 'required',
            'schedule.start' => 'required',
            'schedule.end' => 'required',
        ];
    }

    protected function validationAttributes(): array
    {
        return [
            'schedule.user_id' => 'ユーザー',
            'schedule.title' => 'タイトル',
            'schedule.day' => '日付',
            'schedule.start' => '開始時間',
            'schedule.end' => '終了時間',
        ];
    }
}


コンポーネント:Blade側記述

resources/views/livewire/schedule/create.blade.php
<div>
    <x-adminlte-modal id="createScheduleModal" title="予定登録" theme="info"
                      v-centered
                      scrollable wire:ignore.self>
        <div class="row">
            <div class="col-12" style="height:500px">
                <div class="row">
                    <x-adminlte-select name="schedule.user_id" label="ユーザー" fgroup-class="col-12"
                                       wire:model.defer="schedule.user_id">
                        @foreach($users as $user)
                            <option value="{{ $user->id }}">{{ $user->name }}</option>
                        @endforeach
                    </x-adminlte-select>
                </div>
                <div class="row">
                    <x-adminlte-input name="schedule.title" fgroup-class="col-12" label="タイトル"
                                      wire:model.defer="schedule.title"/>
                </div>
                <div class="row">
                    <x-adminlte-input name="schedule.day" fgroup-class="col-12" class="flatpickrDay" label="日付"
                                      wire:model.lazy="schedule.day"/>
                </div>
                <div class="row">
                    <x-adminlte-input name="schedule.start" label="開始時間" fgroup-class="col-6"
                                      class="flatpickrStartTime" wire:model.defer="schedule.start" />

                    <x-adminlte-input name="schedule.end" label="終了時間" fgroup-class="col-6"
                                      class="flatpickrEndTime" wire:model.defer="schedule.end"/>
                </div>
            </div>

        </div>

        <x-slot name="footerSlot">
            <x-adminlte-button label="閉じる" class="secondary" data-dismiss="modal"/>
            <x-adminlte-button label="登録" theme="primary" wire:click="create()" />

        </x-slot>
    </x-adminlte-modal>

    <button type="button" class="btn btn-success" data-toggle="modal" wire:click="openModal">予定登録
    </button>


@push('js')
        <script>
            document.addEventListener("DOMContentLoaded", () => {
                Livewire.hook('message.processed', (message, component) => {
                    flatpickr('.flatpickrDay',{
                        locale: 'ja',
                    });
                    flatpickr('.flatpickrStartTime', {
                        locale: 'ja',
                        enableTime: true,   // 時間の選択可否
                        noCalendar: true,   // カレンダー非表示
                        dateFormat: "H:i",  // 表示フォーマット
                        time_24hr: true,    // 24時間表記
                        //セレクターを閉じた時に発動する処理
                        onClose: function(selectedDates, dateStr, instance){
                            @this.setScheduleStart(dateStr);
                        }
                    });
                    flatpickr('.flatpickrEndTime', {
                        locale: 'ja',
                        enableTime: true,   // 時間の選択可否
                        noCalendar: true,   // カレンダー非表示
                        dateFormat: "H:i",  // 表示フォーマット
                        time_24hr: true,    // 24時間表記
                        minDate: @this.schedule.start // 開始時間より前にセットできないように
                    });
                })
            });
        </script>

        <script>
            //モーダル展開用
            window.addEventListener('showCreateScheduleModal', event => {
                $('#createScheduleModal').modal('show');
            });
            window.addEventListener('closeCreateScheduleModal', event => {
                $('#createScheduleModal').modal('hide');
            });
        </script>
    @endpush

</div>

一覧のbladeに登録用のコンポーネントを組み込む

resources/views/livewire/schedule/index.blade.php
<div class="pt-3">
    <x-adminlte-card>
        <div class="d-flex">
            <div class="mr-auto d-flex align-items-center justify-content-center">
                <h4>スケジュール</h4>
            </div>
            <div class="d-flex">
                <div class="mr-2 dropdown" wire:ignore>
                    <button class="btn btn-secondary dropdown-toggle" type="button" id="dropdownMenu2"
                            data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
                        ユーザーフィルター
                    </button>
                    <div class="dropdown-menu" aria-labelledby="dropdownMenu2">
                        @foreach($users as $user)
                            <div class="dropdown-item">
                                <div class="icheck-info">
                                    <input type="checkbox" id="filter_user_{{ $user->id }}"
                                           name="selectStoreIds" value="{{ $user->id }}"
                                           wire:model.lazy="selectedUserIds"/>
                                    <label class="font-weight-normal" for="filter_user_{{ $user->id }}">
                                        {{ $user->name }}
                                    </label>
                                </div>
                            </div>
                        @endforeach
                    </div>
                </div>
+                <livewire:schedule.create/>
            </div>
        </div>
        <div wire:loading.flex class="align-items-center justify-content-center">
            読み込み中...
        </div>
        <div id="calendar-container" wire:ignore>
            <div id="calendar"></div>
        </div>
    </x-adminlte-card>
</div>

Discussion