#15 FilamentPHP応用

カスタムインフォリストエントリを作る——AbstractEntryで詳細ページをリッチに

InfoListとEntryとは

FilamentのInfoListはレコードの詳細情報を読み取り専用で表示するコンポーネントです。フォームが「入力」のためのコンポーネント群であるのに対し、InfoListは「表示」に特化しています。

標準のEntryにはTextEntryImageEntryBadgeEntryなどがありますが、カスタムEntryを作ることでより表現力豊かな詳細ページが作れます。

Entryクラスの作成

php artisan make:filament-infolist-entry MapEntry

生成ファイル:

app/Infolists/Components/MapEntry.php
resources/views/infolists/components/map-entry.blade.php

Entryクラスの実装

namespace App\Infolists\Components;

use Filament\Infolists\Components\Entry;

class MapEntry extends Entry
{
    protected string $view = 'infolists.components.map-entry';

    // 地図のズームレベル
    protected int $zoom = 14;

    // 地図の高さ
    protected string $height = '300px';

    // マーカーラベル
    protected ?string $markerLabel = null;

    public function zoom(int $zoom): static
    {
        $this->zoom = $zoom;
        return $this;
    }

    public function height(string $height): static
    {
        $this->height = $height;
        return $this;
    }

    public function markerLabel(string $label): static
    {
        $this->markerLabel = $label;
        return $this;
    }

    public function getZoom(): int
    {
        return $this->zoom;
    }

    public function getHeight(): string
    {
        return $this->height;
    }

    public function getMarkerLabel(): ?string
    {
        return $this->markerLabel;
    }
}

Bladeテンプレートの実装

座標(緯度/経度)を地図で表示する例です。ここではLeaflet.jsを使います。

@php
    $state = $getState();  // ['lat' => 35.6762, 'lng' => 139.6503] のような配列を想定
    $lat = is_array($state) ? ($state['lat'] ?? null) : null;
    $lng = is_array($state) ? ($state['lng'] ?? null) : null;
    $zoom = $getZoom();
    $height = $getHeight();
    $label = $getMarkerLabel() ?? $getRecord()?->name ?? '';
@endphp

<x-dynamic-component
    :component="$getEntryWrapperView()"
    :entry="$entry"
>
    @if($lat && $lng)
        <div
            id="map-{{ $getId() }}"
            style="height: {{ $height }}; width: 100%; border-radius: 0.5rem;"
        ></div>

        @push('scripts')
            <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
            <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
            <script>
                document.addEventListener('DOMContentLoaded', function() {
                    var map = L.map('map-{{ $getId() }}').setView(
                        [{{ $lat }}, {{ $lng }}], {{ $zoom }}
                    );
                    L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(map);
                    L.marker([{{ $lat }}, {{ $lng }}])
                        .addTo(map)
                        .bindPopup(@js($label))
                        .openPopup();
                });
            </script>
        @endpush
    @else
        <p class="text-sm text-gray-500 dark:text-gray-400">座標データがありません</p>
    @endif
</x-dynamic-component>

使用例

use App\Infolists\Components\MapEntry;

public static function infolist(Infolist $infolist): Infolist
{
    return $infolist
        ->schema([
            Section::make('基本情報')
                ->schema([
                    TextEntry::make('name')
                        ->label('店舗名'),
                    TextEntry::make('phone')
                        ->label('電話番号'),
                    TextEntry::make('address')
                        ->label('住所'),
                ])
                ->columns(2),

            Section::make('地図')
                ->schema([
                    MapEntry::make('coordinates')
                        ->label('所在地')
                        ->zoom(15)
                        ->height('400px')
                        ->markerLabel(fn ($record) => $record->name)
                        ->columnSpanFull(),
                ]),

            Section::make('営業時間')
                ->schema([
                    RepeatableEntry::make('business_hours')
                        ->label('')
                        ->schema([
                            TextEntry::make('day')->label('曜日'),
                            TextEntry::make('open')->label('開始'),
                            TextEntry::make('close')->label('終了'),
                        ])
                        ->columns(3),
                ]),
        ]);
}

RepeatableEntryのカスタム

複数の値をリスト表示するRepeatableEntryと組み合わせると、ネストした情報も美しく表示できます。

use Filament\Infolists\Components\RepeatableEntry;

RepeatableEntry::make('order_items')
    ->label('注文内容')
    ->schema([
        TextEntry::make('product_name')
            ->label('商品名'),

        TextEntry::make('quantity')
            ->label('数量')
            ->suffix('個'),

        TextEntry::make('unit_price')
            ->label('単価')
            ->money('JPY'),

        TextEntry::make('subtotal')
            ->label('小計')
            ->state(fn ($record) => $record->quantity * $record->unit_price)
            ->money('JPY'),
    ])
    ->columns(4)

カラーチップEntryの例

別の実用例として、カラーコードを色見本付きで表示するEntryです。

namespace App\Infolists\Components;

use Filament\Infolists\Components\Entry;

class ColorEntry extends Entry
{
    protected string $view = 'infolists.components.color-entry';
}
@php
    $color = $getState();
@endphp

<x-dynamic-component
    :component="$getEntryWrapperView()"
    :entry="$entry"
>
    @if($color)
        <div class="flex items-center gap-2">
            <div
                class="h-6 w-6 rounded-full border border-gray-200 shadow-sm"
                style="background-color: {{ $color }}"
            ></div>
            <code class="text-sm text-gray-700 dark:text-gray-300">{{ $color }}</code>
        </div>
    @else
        <span class="text-sm text-gray-400">未設定</span>
    @endif
</x-dynamic-component>

コツ・注意点・ハマりポイント

コツ: $getRecord()でエントリが属するモデルレコード全体を取得できます。フィールドの値だけでなくレコードの他の属性も必要な場合に活用してください。

注意点: InfoListのEntryはデフォルトで読み取り専用です。インタラクティブな要素(クリックでコピーなど)を追加する場合はAlpine.jsを使って実装しますが、データ変更の操作はActionに委ねるのがFilamentの設計思想に沿っています。

ハマりポイント: @push('scripts')で外部ライブラリを読み込む場合、Livewireの再レンダリング時に重複読み込みが発生することがあります。onceディレクティブ(@pushOnce)を使うか、Viteでバンドルする方法を検討してください。

まとめ

Entryクラスを継承してBladeテンプレートを実装することで、地図・カラーチップ・カスタムタイムラインなど、標準Entryでは表現できない豊かな詳細ページが作れます。フォームのカスタムFieldと対になるEntryを用意することで、入力と表示で一貫したUIを維持できます。