🥂

Laravel チェックボックスに checked を付けたりするのをコンポーネントを使ってすっきりさせてみる

4 min read

まえがき

Laravelでチェックボックスの扱いはちょっと小面倒な事があります。問い合わせフォームとかの規約チェックボックスであれば、こんな感じでまだシンプルに行けますが、

<label>
    <input type="checkbox" name="accepted" value="1"
    {{ old('accepted') == '1' ? 'checked' : '' }}> 同意する
</label>

ややこしくなってくるのが、中間テーブルがあったりして、例えば、Post hasMany Tags、Tag hasMany Posts な関係の時ですね。

Post モデルにはこんな感じで書かれたりします。

    public function tags()
    {
        return $this->belongsToMany(Tag::class);
    }

そんな時の為に、新コンポーネントを使って view をすっきり書けるようにしようと言うのが狙いです。

本題

上記の場合、ブログ投稿の登録画面には、tags テーブルから取ってきたタグの一覧をチェックボックスで複数選択できるようにします。下の感じです。

web.php 想定

Route::get('/', function () {
    $taglists = Tag::pluck('name', 'id');

    return view('welcome', compact('taglists'));
});

welcome.php

@foreach($taglists as $id => $name)
    <label>
        <input type="checkbox" name="tags[]" value="{{ $id }}"> {{ $name }}
    </label>
@endforeach

登録画面ではなく、もし編集画面だったら、モデルからデータを取ってきて、以下のようになったりします。

Route::get('/', function () {
    $taglists = Tag::pluck('name', 'id');

    $post = Post::first(); // ひとまず決め打ちで

    return view('welcome', compact('post', 'taglists'));
});

welcome.php

@foreach($taglists as $id => $name)
    <label>
        <input type="checkbox" name="tags[]" value="{{ $id }}"
	       {{ $post->tags->contains($id) ? 'checked' : '' }}> {{ $name }}
    </label>
@endforeach

少しややこしくなってきました。
post が所有する tags の中に、該当の $id が含まれていれば、チェックを付けるという方法です。

Eloquent の contains は、ベースのコレクションを更に使い易く上書きしている為、上記のような書き方ができます。
参考:Laravel 8.x コレクション contains()
参考:Laravel 8.x Eloquent:コレクション、contains()

問題は更に続きます。
更新処理をしようとして、バリデーションエラーでリダイレクトされて戻ってきた時は、編集画面で選択していた状態に戻してやらないといけません。

上記のモデルからデータを取ってくるのを一旦忘れれば、下記のように書けます。

@foreach($taglists as $id => $name)
    <label>
        <input type="checkbox" name="tags[]" value="{{ $id }}"
	       {{ in_array($id, (array)old('tags')) ? 'checked' : '' }}> {{ $name }}
    </label>
@endforeach

これで OK ですね。
ですが、現実は、モデルからデータを取ってくるのと、バリデーションエラーで戻ってきた時をうまく両立させねばなりません。

そこで、新コンポーネントを使って、以下のようにしてみました。
まず、コントローラ側(下記は、web.php)では、以下のように一部改変します。

web.php

Route::get('/', function () {
    $taglists = Tag::pluck('name', 'id');

    $post = Post::first();

    $data = old() ?: $post; // ここ

    return view('welcome', compact('post', 'data', 'taglists'));
});

old() 関数で値を取得できればそれを使用し、無ければモデルのデータを使用するよう、$data に仕込みます。

view 側は以下の感じです。

@foreach($taglists as $id => $name)
    <label>
        <x-check name="tags[]" :value="$id" :data="data_get($data, 'tags')" />
	{{ $name }}
    </label>
@endforeach

data_get() は、以下の通り「ドット」記法を使用し、ネストした配列やオブジェクトから値を取得できます。
参考:Laravel 8.x ヘルパ、data_get()

新コンポーネント側は、以下の通りです。

@props([
    'data',
    'value',
    'strict' => false,
])

@php
if ($data instanceof \Illuminate\Database\Eloquent\Collection) {
    $checked = $data->contains($value);
} else {
    $checked = $strict ? collect($data)->containsStrict($value) : collect($data)->contains($value);
}
@endphp

<input {{ $attributes->merge(['type' => 'checkbox']) }} value="{{ $value }}" {{ $checked ? 'checked': '' }} />

まず、$data が EloquentCollection かをチェックします。そうであれば、contains() を通して、チェックを付けるべきか判定します。

それ以外(配列、文字列など)は、一旦 collect() ヘルパー関数を使ってコレクションにした上で、contains() / containsStrict() のふるいに掛けて調べています。
こちらに関しては、型チェックを厳密にするかしないかを選択できるようにしています。デフォルトは、しません。

もし型チェックをする場合は、下記みたいにします。

<x-check ... :strict="true">
// 又は、
<x-check ... strict="1">

EloquentCollection の時は、型チェックのオプションは無効なのは、
そもそも、EloquentCollection 用の contains() メソッドはあっても、EloquentCollection 用の containsStrict() は無いからです。まぁ、無いということは、事実上そうする意味も無いのだと察します。

ちなみに、下記の old()関数の使い方では、バグります。

@foreach($taglists as $id => $name)
    <label>
	<x-check name="tags[]" :value="$id" :data="old('tags', $post->tags)" />
	{{ $name }}
    </label>
@endforeach

これだと、チェックを全て外して、バリデーションエラーで戻ってくると、モデルのデータで復元されてしまいます。

それと、問い合わせフォームの規約のチェックボックスであれば、下記みたいに書けます。

<label>
    <x-check name="accepted" value="1" :data="old('accepted')" /> 同意する
</label>

確認画面がある時は、適当にアレンジして下さい。

雑感

コンポーネントの中で、Illuminate\Database\Eloquent\Collection かどうかのチェックをするのも何ですが、まぁ、あえてクラスベース等にする程のものではないので、そのままにしています。

バグ見等つけましたらコメント下さい。