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

# 概要
今激アツな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プロジェクト作成
```terminal
$ composer create-project laravel/laravel laravel9_calendar
```
## livewireインストール
```terminal
$ composer require livewire/livewire
```
## laravel-adminlteインストール
```terminal
$ composer require jeroennoten/laravel-adminlte
$ php artisan adminlte:install
$ php artisan adminlte:install --only=main_views
```
### livewire有効化
```diff_php:config/adminlte.php
+ 'livewire' => true,
```
#### Tips
上記での有効化はここに反映されてます。

```php: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パッケージのプラグインとしてインストールが出来るので取り込みます。
```terminal
$　php artisan adminlte:plugins install --plugin=icheckBootstrap 
```
インストールしたプラグインを有効化させる為、configのplugins項目に追記します。
```php:config/adminlte.php
'plugins' => [
 ....
    'Icheck-bootstrap' => [
        'active' => true,
        'files' => [
            [
                'type' => 'css',
                'asset' => true,
                'location' => 'vendor/icheck-bootstrap/icheck-bootstrap.css',
            ],
        ],
    ],

],
```
### fullcalendarインストール

laravel-adminlteパッケージのプラグインとしてインストールが出来るので取り込みます。
```terminal
$　php artisan adminlte:plugins install --plugin=fullcalendar
```
インストールしたプラグインを有効化させる為、configのplugins項目に追記します。
```php: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で読み込みます。
```php: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インストール
```terminal
$ composer require jantinnerezo/livewire-alert
```
有効化させる為adminlteパッケージの親テンプレートに追記します

```diff_php: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日単位での予定の設計にします。

```mermaid:
classDiagram
direction LR;

class ユーザー{
  id
  名前
}

class スケジュール{
  id
  ユーザーid
  日付
  開始時間
  終了時間
}

ユーザー -- スケジュール:1対多
```

## モデル・マイグレーション作成
スケジュールのモデルとマイグレーションを作成します。
> ユーザーのモデルとマイグレーションは最初からあるので不要です。


```
laravel9_calendar > php artisan make:model Schedule -m
```



```php:モデル
class Schedule extends Model
{
    use HasFactory;
    
    protected $fillable = [
        'title',
        'day',
        'start',
        'end',
    ];

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

```

```php:マイグレーションファイル
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();
});
```

ユーザーモデルにリレーションを定義します。
```diff_php: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);
+    }
}

```
## ダミーデータ作成

```php: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',
            ]);
        }
    }
}
```


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

```terminal
$ php artisan migrate:fresh --seed
```


# FullCalendarで予定を表示

## livewireコンポーネント作成
```terminal
$ php artisan make:livewire Schedule/Index
```

## ルーティング

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

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

```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側記述

```html: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>

```

```js: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のようにも使えて便利ですね、
```js
//イベント取得(月が切り替わる度に更新)
events: function (info, successCallback) {
    @this.getEvents(info.start, info.end).then((response) => {
        successCallback(response);
    })
},
```
:::

# 予定を登録する

## livewireコンポーネント作成
```terminal
$ php artisan make:livewire Schedule/Create
```

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

```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' => '終了時間',
        ];
    }
}



```

```html: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に登録用のコンポーネントを組み込む

```diff_html: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>

```