Cascading filters with Livewire 3
I mentioned in another discussion that I am trying to build some cascading filters. I was originally using Filament, hence the other post, but I have now switched to Livewire 3.
Recap
I am building an app for sport league administrators. For each season there can be multiple competitions, and for each competitions multiple divisions. Finally each divisions has multiple fixtures. These are all 1-M relationships.
When looking at the data I want the user to be able to filter it. The number of filters depends on what data they are looking at. For example, competitions can only be filtered by seasons, but divisions can be filtered by seasons first and then competition, and fixtures by season, competitions and then divisions. So, the filters need to be flexible, and since there will be a lot of repetition, composable.
I read a few articles and I ended up with two Livewire components: Filters and SelectFilter. The idea is that a component that needs filtering will define the filters it needs, and then use the Filters component. This in turn will use the SelectFilter components for each of the filters required.
What should happen
Let's take the example of the divisions, as it's simpler with only two filters.
Scenario 1
On page loading:
- the seasons filter is populated with all seasons, and the select defaults to the first season, e.g.
23/24 - the competitions filter is populated with all competition in the first season,
23/24, and the select defaults to the first competition, e.g.Comp1 - the table is populated with all the divisions in that first competition,
Comp1
Scenario 2
When the user chooses another competition, e.g. Comp2, then:
- the table is populated with all the divisions in the selected competition,
Comp2
Scenario 3
When the user chooses another season, e.g. 22/23, then:
- the competitions filter is populated with all competitions in the selected season, and the select defaults to the first competition, e.g.
CompA - the table is populated with all the divisions in that competition,
CompA
What actually happens
Scenario 1 and 2 work as expected, but scenario 3 does not. This is what happens instead (when the user chooses another season, e.g. 22/23):
- the competitions filter is NOT populated with all competitions in the selected season
- the table IS populated with all the divisions in the first competition in that season,
CompA
Basically, the only thing that does not work is updating the select with a new list of competitions.
What I have tried
My current implementation revolves around the idea of dispatching events: each filter will dispatch an event when the selection has changed. This event will bubble up to the main component which will be responsible for updating the data and the other filters, if necessary.
In short, when the user chooses a new season, for example, then
- an event
season-selectedis dispatched - the main component listens to it and:
- get all the competitons in the selected season
- get all the divisions in the first competition from above
- the component is refreshed, with new filters and data
Unfortunately, as I said, when the component is refreshed the competitions select still has the ones from the original season.
The code
app/Livewire/Divisions/Index.php
<?php
namespace App\Livewire\Divisions;
use App\Models\Division;
use App\Models\Season;
use Illuminate\View\View;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Component;
use Livewire\WithPagination;
class Index extends Component
{
use WithPagination;
public string $competitionId;
public array $filters;
public function mount(): void
{
$seasons = Season::latest('year')->get();
$latestSeason = $seasons->first();
$competitions = $latestSeason->competitions;
$this->competitionId = $competitions->first()->getKey();
$this->filters = [
'seasons' => [
'label' => 'Seasons',
'options' => $seasons,
'currentOption' => $latestSeason->getKey(),
'event' => 'season-selected',
],
'competitions' => [
'label' => 'Competitions',
'options' => $competitions,
'currentOption' => $this->competitionId,
'event' => 'competition-selected',
],
];
}
#[Layout('layouts.app')]
public function render(): View
{
$divisions = Division::query()
->inCompetition($this->competitionId)
->with(['competition', 'competition.season'])
->oldest('display_order')
->simplePaginate(10);
return view('livewire.division.index', compact('divisions'))
->with('i', $this->getPage() * $divisions->perPage());
}
public function delete(Division $division): void
{
$division->delete();
$this->redirectRoute('divisions.index', navigate: true);
}
#[On('season-selected')]
public function updateCompetitions($seasonId)
{
$latestSeason = Season::findOrFail($seasonId);
$competitions = $latestSeason->competitions;
$this->competitionId = $competitions->first()->getKey();
$this->filters['competitions']['options'] = $competitions;
$this->filters['competitions']['currentOption'] = $this->competitionId;
}
#[On('competition-selected')]
public function setCurrentCompetition($competitionId): void
{
$this->competitionId = $competitionId;
}
}
resources/views/livewire/division/index.blade.php
<x-crud.header>Divisions</x-crud.header>
<div class="w-full">
<x-crud.subheader add-route="divisions.create" class="mb-4">
A list of all the divisions in the system
</x-crud.subheader>
<livewire:filters :filters="$filters"/>
<x-crud.content>
<x-crud.index.table columns="name">
@foreach ($divisions as $division)
...
@endforeach
</x-crud.index.table>
<div class="mt-4 px-4">
{!! $divisions->withQueryString()->links() !!}
</div>
</x-crud.content>
</div>
resources/views/livewire/filters.blade.php
<?php
new class extends \Livewire\Volt\Component
{
public array $filters;
}
?>
<div class = 'my-8 flex flex-col space-y-4 md:flex-row md:space-x-4 md:space-y-0'>
@foreach( $filters as $key => $filter )
<livewire:select-filter
:wire:model="'filters.'.$key.'.currentOption'"
:key="$key.now()"
:label="$filter['label']"
:options="$filter['options']"
:selectedOption="$filter['currentOption']"
:eventToEmit="$filter['event']"
/>
@endforeach
</div>
resources/views/livewire/select-filter.blade.php
<?php
new class extends \Livewire\Volt\Component
{
public string $label;
public \Illuminate\Database\Eloquent\Collection $options;
#[\Livewire\Attributes\Modelable]
public string $selectedOption;
public string $eventToEmit;
}
?>
<div class="flex flex-col gap-2">
<x-input-label for="{{ $label . '_filter' }}" value="{{ $label }}" />
<x-select-input class="dark:bg-gray-700 dark:text-gray-100 dark:border-gray-100"
wire:model="selectedOption"
:name="$label . '_filter'"
:id="$label . '_filter'"
:$options
:currentOption="$selectedOption"
wire:change="$dispatch('{{ $eventToEmit }}', [$wire.selectedOption])"
/>
</div>
resources/views/components/select-input.blade.php
For good measure, here is the x-select-input Blade component
@props(['disabled' => false, 'options', 'currentOption' => '', 'placeholder' => "Select your option"])
<select required {{ $disabled ? 'disabled' : '' }}
{!! $attributes->merge(['class' => 'appearance-none invalid:text-gray-500 border-gray-300 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 focus:border-indigo-500 dark:focus:border-indigo-600 focus:ring-indigo-500 dark:focus:ring-indigo-600 rounded-md shadow-sm']) !!}>
@empty($currentOption)
<option value="">{{ $placeholder }}</option>
@endempty
@foreach($options as $option)
<option
value="{{ $option->getKey() }}"
{{ !empty($currentOption) && $option->getKey() === $currentOption ? 'selected' : '' }}
>
{{ $option->getName() }}
</option>
@endforeach
</select>
Please or to participate in this conversation.