Listing

Model example

<?php

namespace App\Models;

use App\Enums\MiniatureStateEnum;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\MorphMany;
use Kiwilan\Steward\Services\Query\FilterModule;
use Kiwilan\Steward\Services\Query\SortModule;
use Kiwilan\Steward\Traits\HasSearchableName;
use Kiwilan\Steward\Traits\HasSeo;
use Kiwilan\Steward\Traits\HasUsername;
use Kiwilan\Steward\Traits\LiveQueryable;
use Kiwilan\Steward\Traits\Mediable;
use Kiwilan\Steward\Traits\Publishable;
use Laravel\Scout\Searchable;

/**
 * @property \App\Enums\MiniatureStateEnum|null $state
 */
class Miniature extends Model
{
    use HasFactory;
    use Mediable;
    use HasSeo;
    use LiveQueryable;
    use HasUsername;
    use HasSearchableName, Searchable {
        HasSearchableName::searchableAs insteadof Searchable;
    }
    use Publishable;

    protected $username_with = 'name';

    protected $username_column = 'slug';

    protected $meta_description_from = 'about';

    protected array $mediables = [
        'picture',
        'gallery',
    ];

    protected $fillable = [
        'name',
        'about',
        'description',
        'price',
        'height',
        'width',
        'depth',
        'state',
        'release_year',
        'is_out',
        'is_new',
        'is_hidden',
        'picture',
        'gallery',

        'base_is_square',
        'base_width',
        'base_height',
    ];

    protected $casts = [
        'state' => MiniatureStateEnum::class,
        'gallery' => 'array',
        'height' => 'float',
        'width' => 'float',
        'depth' => 'float',
        'release_year' => 'integer',
        'is_out' => 'boolean',
        'is_new' => 'boolean',
        'is_hidden' => 'boolean',
        'base_is_square' => 'boolean',
        'base_width' => 'float',
        'base_height' => 'float',
    ];

    public static function sortable()
    {
        return [
            SortModule::make('name', 'Name'),
            SortModule::make('price', 'Price'),
            SortModule::make('height', 'Size'),
            SortModule::make('state', 'State'),
            SortModule::make('created_at', 'Created At'),
            SortModule::make('updated_at', 'Updated At'),
            SortModule::make('published_at', 'Published At'),
            SortModule::scope('name', 'Name', 'orderByName'),
        ];
    }

    public static function filterable()
    {
        return [
            FilterModule::search('q', ['name', 'creator.name']),
            FilterModule::partial('name'),
            FilterModule::scope('state', 'whereState'),
            FilterModule::scope('states', 'whereStates'),
            FilterModule::scope('armies', 'whereArmies'),
            FilterModule::scope('universes', 'whereUniverses'),
            FilterModule::scope('matters', 'whereMatters'),
            FilterModule::scope('techniques', 'whereTechniques'),
        ];
    }

    public function scopeOrderByName(Builder $query, string $direction = 'asc'): Builder
    {
        return $query->join('collectors', 'miniatures.creator_id', '=', 'collectors.id')
            ->orderBy('collectors.name', $direction)
            ->select('miniatures.*')
        ;
    }

    public function scopeWhereState(Builder $query, mixed $state = null): Builder
    {
        if (is_array($state)) {
            $state = $state[0] ?? null;
        }

        if ($state) {
            return $query->where('state', '=', $state);
        }

        return $query;
    }

    public function scopeWhereStates(Builder $query, array $states): Builder
    {
        return $states
            ? $query->whereIn('state', $states)
            : $query;
    }

    public function scopeWhereArmies(Builder $query, array $armies): Builder
    {
        return $armies
            ? $query->whereHas('armies', fn (Builder $query) => $query->whereIn('slug', $armies))
            : $query;
    }

    public function scopeWhereUniverses(Builder $query, array $universes): Builder
    {
        return $universes
            ? $query->whereHas('universe', fn (Builder $query) => $query->whereIn('slug', $universes))
            : $query;
    }

    public function scopeWhereMatters(Builder $query, array $matters): Builder
    {
        return $matters
            ? $query->whereHas('matter', fn (Builder $query) => $query->whereIn('slug', $matters))
            : $query;
    }

    public function scopeWhereTechniques(Builder $query, array $techniques): Builder
    {
        return $techniques
            ? $query->whereHas('techniques', fn (Builder $query) => $query->whereIn('slug', $techniques))
            : $query;
    }

    public function scopeWhereIsNotHidden(Builder $query): Builder
    {
        return $query->where('is_hidden', '=', false);
    }

    public function getArmiesListAttribute(): string
    {
        $armies = $this->armies->implode('name', ', ');

        return empty($armies) ? 'N/A' : $armies;
    }

    public function getGameplaysListAttribute(): string
    {
        $gameplays = $this->gameplays->implode('name', ', ');

        return empty($gameplays) ? 'N/A' : $gameplays;
    }

    public function getTechniquesListAttribute(): string
    {
        $techniques = $this->techniques->implode('name', ', ');

        return empty($techniques) ? 'N/A' : $techniques;
    }

    public function getMattersListAttribute(): string
    {
        $matters = $this->matters->implode('name', ', ');

        return empty($matters) ? 'N/A' : $matters;
    }

    public function getDimensionsListAttribute(): string
    {
        $height = $this->height ? "{$this->height} cm" : 'N/A';
        $width = $this->width ? "{$this->width} cm" : 'N/A';
        $depth = $this->depth ? "{$this->depth} cm" : 'N/A';

        if (! $this->height && ! $this->width && ! $this->depth) {
            return 'Unknown';
        }

        return implode(' x ', array_filter([$height, $width, $depth]));
    }

    public function matter(): BelongsTo
    {
        return $this->belongsTo(Matter::class, 'matter_primary_id');
    }

    public function matters(): BelongsToMany
    {
        return $this->belongsToMany(Matter::class);
    }

    public function armies(): BelongsToMany
    {
        return $this->belongsToMany(Army::class);
    }

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

    public function gameplays(): BelongsToMany
    {
        return $this->belongsToMany(Gameplay::class);
    }

    public function techniques(): BelongsToMany
    {
        return $this->belongsToMany(Technique::class);
    }

    // public function supplier(): BelongsTo
    // {
    //     return $this->belongsTo(Supplier::class);
    // }

    public function creator(): BelongsTo
    {
        return $this->belongsTo(User::class, 'creator_id');
    }

    public function owner(): BelongsTo
    {
        return $this->belongsTo(User::class, 'owner_id');
    }

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

    public function paints(): BelongsToMany
    {
        return $this->belongsToMany(Paint::class);
    }

    public function reviews(): MorphMany
    {
        return $this->morphMany(Review::class, 'reviewable')
            ->orderBy('created_at', 'desc')
        ;
    }

    public function favorites(): MorphMany
    {
        return $this->morphMany(Favorite::class, 'favoritable')
            ->orderBy('created_at', 'desc')
        ;
    }

    public function toSearchableArray()
    {
        $this->load('armies', 'universe', 'gameplays', 'creator', 'owner');

        return [
            'id' => $this->id,
            'name' => $this->name,
            'slug' => $this->slug,
            'about' => $this->about,
            'price' => $this->price,
            'height' => $this->height,
            'state_locale' => $this->state?->locale(),
            'state_value' => $this->state?->value,
            'creator_name' => $this->creator?->name,
            'creator_display_name' => $this->creator?->display_name,
            'owner_name' => $this->owner?->name,
            'owner_display_name' => $this->owner?->display_name,
            'armies' => $this->armies_list,
            'universe' => $this->universe?->name,
            'gameplays' => $this->gameplays_list,
            'picture' => $this->mediable('picture'),
        ];
    }

    protected function makeAllSearchableUsing(Builder $query)
    {
        Model::handleLazyLoadingViolationUsing(fn () => $query->with([
            'creator',
            'owner',
            'armies',
            'universe',
            'gameplays',
        ]));
    }
}

PHP component

<?php

namespace App\Http\Livewire\Listing;

use App\Enums\MiniatureStateEnum;
use App\Models\Army;
use App\Models\Matter;
use App\Models\Miniature;
use App\Models\Technique;
use App\Models\Universe;
use Kiwilan\Steward\Traits\LiveListing;
use Livewire\Component;
use Livewire\WithPagination;

class Miniatures extends Component
{
    use WithPagination;
    use LiveListing;

    public $queryString = [];

    public array $filter = [
        'statuses' => [],
        'armies' => [],
        'universes' => [],
        'matters' => [],
        'techniques' => [],
    ];

    public function model(): string
    {
        return Miniature::class;
    }

    public function relations(): array
    {
        return ['creator', 'owner'];
    }

    public function defaultSort(): string
    {
        return '-created_at';
    }

    public function sortable(): array
    {
        return Miniature::getSortable();
    }

    public function render()
    {
        $models = Miniature::query()
            ->liveFilter([
                ...$this->filter,
                'q' => $this->q,
            ])
            ->liveSort($this->sort)
            ->with($this->relations())
            ->paginate(perPage: $this->size, page: $this->page)
        ;

        return view('components.livewire.listing.miniatures', [
            'models' => $models,
            'states' => MiniatureStateEnum::toArray(),
            'armies' => $this->setFilter(Army::class, 'name', 'slug'),
            'matters' => $this->setFilter(Matter::class, 'name', 'slug'),
            'techniques' => $this->setFilter(Technique::class, 'name', 'slug'),
            'universes' => $this->setFilter(Universe::class, 'name', 'slug'),
        ]);
    }
}

Blade component

<x-stw-listing
  title="Miniatures"
  subtitle="A selection of miniatures for all games"
  :sortable="$sortable"
  :sort="$sort"
  :paginate="$models"
  searchable
>
  <x-slot:filters>
    <livewire:stw-listing.option.filter
      name="states"
      label="States"
      :options="$states"
      :current="$filter['states'] ?? []"
    />
    <livewire:stw-listing.option.filter
      name="armies"
      label="Armies"
      :options="$armies"
      :current="$filter['armies'] ?? []"
    />
    <livewire:stw-listing.option.filter
      name="matters"
      label="Matters"
      :options="$matters"
      :current="$filter['matters'] ?? []"
    />
    <livewire:stw-listing.option.filter
      name="techniques"
      label="Techniques"
      :options="$techniques"
      :current="$filter['techniques'] ?? []"
    />
    <livewire:stw-listing.option.filter
      name="universes"
      label="Universes"
      :options="$universes"
      :current="$filter['universes'] ?? []"
    />
  </x-slot:filters>

  @loop($models as $model)
    <x-card.miniature :model="$model" />
  @endloop
</x-stw-listing>