Appearance
Shop: Filter Section
REMARKS
This is Part 3 of the Shop implementation.
- Part 1: Displaying records with pagination.
- Part 2: Showing record details (tracks) in a modal.
- Part 3: Adding filtering and sorting controls (this chapter).
In the previous chapters, we built the foundation of our vinyl shop, displaying records and allowing users to view track details in a modal. This final chapter for the core shop functionality focuses on enhancing user interaction by adding dynamic filtering and display controls. We will empower users to refine the record list by searching for artist or title, selecting a specific genre, setting a maximum price, and choosing how many records appear per page. Crucially, thanks to Livewire's reactive nature, these filters will update the displayed records instantly without requiring disruptive full-page reloads, creating a smooth browsing experience.
Adding Filter Form Elements
The first step is to add the necessary input fields and controls to our shop's user interface. These elements will allow users to specify their filtering criteria. We will utilize components from the Flux UI library, consistent with the rest of our application, to ensure a cohesive look and feel. We'll also incorporate our custom range slider component for the price filter.
- resources/views/livewire/shop.blade.php:
- Locate the HTML comment indicating the filter section placeholder:
{{-- Filter section will go here --}}
. - Replace the comment with the following Flux UI and custom components, arranged within a grid layout:
- A
<flux:input>
component for the text search. Configure it with an appropriateicon
(like "magnifying-glass"), a helpfulplaceholder
("Search artist or title"), a descriptivelabel
("Filter"), and theclearable
attribute to allow users to easily reset the search field. - A
<flux:select>
component for the Genre dropdown. Add alabel
("Genre"). Include a default<flux:select.option>
representing "All Genres", typically with a value like%
which we'll use later in our query logic. We will populate the rest of the options dynamically later. - Another
<flux:select>
component for controlling the "Records per page". Add alabel
("Records per page") and provide several<flux:select.option>
elements with different numeric values (e.g., 3, 6, 9, 12, 15, 18, 24) representing the desired number of items per page. - Our custom
<x-itf.range-slider>
component for the price filter. Set thename
attribute (e.g., "max_price"), provide alabel
("Price ≤"), and optionally add aprefix
(like "€") for clarity. We will bind themin
,max
, andvalue
attributes later.
- A
- Locate the HTML comment indicating the filter section placeholder:
php
{{-- Filter section will go here --}}
<div class="grid grid-cols-10 gap-4 mb-4">
<div class="col-span-10 md:col-span-5 lg:col-span-3">
<flux:input
icon="magnifying-glass" placeholder="Search artist or title" label="Filter" clearable/>
</div>
<div class="col-span-5 md:col-span-3 lg:col-span-2">
<flux:select
label="Genre">
<flux:select.option value="%">All genres</flux:select.option>
</flux:select>
</div>
<div class="col-span-5 md:col-span-2 lg:col-span-2">
<flux:select
label="Records per page">
@foreach ([3,6,9,12,15,18,24] as $value)
<flux:select.option value="{{ $value }}">{{ $value }}</flux:select.option>
@endforeach
</flux:select>
</div>
<div class="col-span-10 lg:col-span-3">
<x-itf.range-slider
name="max_price" label="Price ≤" prefix="€"/>
</div>
</div>
<flux:separator variant="subtle" class="mb-4"/>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Livewire Data Binding and Properties
With the UI elements in place, we now need to connect them to our Livewire component's logic. This is achieved using Livewire's powerful wire:model
directive, which establishes a two-way data binding between the HTML form elements and public properties defined in our Shop
component class. When a user interacts with a filter (e.g., types in the search box or selects a genre), the corresponding public property in the component updates automatically. Conversely, if the property changes in the component, the form element's value updates in the browser.
- app/Livewire/Shop.php:
- Define new public properties within the
Shop
class to hold the state of each filter:public $filter = "";
- To store the text input for searching artist or title. Initialized as an empty string.public $genre = '%';
- To store the selected genre ID. Initialized to'%'
, our placeholder value for "All Genres".public $price;
- To store the current value selected by the price range slider. We will initialize this later.public $priceMin, $priceMax;
- To hold the minimum and maximum possible values for the price slider. These will be calculated when the component loads.- Recall that
public $perPage = 6;
was already defined in the previous chapter for pagination control.
- Define new public properties within the
- resources/views/livewire/shop.blade.php:
- Add the
wire:model
directive to each filter element in the view to bind it to its corresponding public property:- For the
<flux:input>
text search: Usewire:model.live.debounce.500ms="filter"
. The.live
modifier tells Livewire to update the$filter
property in the component as the user types. The.debounce.500ms
modifier adds a small delay (500 milliseconds in this case), waiting for the user to pause typing before sending the update. This prevents excessive network requests and component re-renders during rapid typing, improving performance and user experience. - For the
<flux:select>
genre dropdown: Usewire:model.live="genre"
. Here,.live
ensures the$genre
property updates immediately whenever the user selects a new option. - For the
<flux:select>
records per page dropdown: Usewire:model.live="perPage"
. Similar to the genre select, this updates the$perPage
property instantly upon selection. - For the
<x-itf.range-slider>
: Usewire:model.live.debounce="price"
. The.debounce
(without a specific time defaults to 150ms) is useful here to avoid triggering updates too frequently if the user drags the slider rapidly. We also need to bind the slider's bounds and initial value using standard Blade syntax:min="{{ $priceMin }}"
,max="{{ $priceMax }}"
,value="{{ $price }}"
.
- For the
- Add the
php
<?php // Note: Ensure necessary imports like `use Livewire\WithPagination;` are present
namespace App\Livewire;
use App\Models\Record;
use App\Models\Genre; // Make sure Genre model is imported
use Livewire\Component;
use Livewire\WithPagination;
// Potentially other imports like Http, Flux, Carbon from previous parts
class Shop extends Component
{
use WithPagination;
// Define public properties for filters
public $perPage = 6;
public $filter = ""; // For text search
public $genre = '%'; // For genre dropdown ('%' = All)
public $price; // Current price slider value
public $priceMin, $priceMax; // Slider bounds
public $selectedRecord; // For detail view
// ... methods: showTracks, addToBasket ...
// mount() method will be added later
// render() method will be modified later
// updated() method will be added later
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
NOTE ABOUT THE wire:model
DIRECTIVE
Livewire offers several modifiers to control when the data binding synchronization occurs:
Directive | Description |
---|---|
wire:model (no modifier) | Deferred: Updates the component property only when a subsequent Livewire action is triggered (e.g., a button click with wire:click , or a form submission with wire:submit ). |
wire:model.live | Live: Updates the component property immediately whenever the element's value changes (e.g., on select change, checkbox toggle, or each key press in an input). |
wire:model.blur | Blur: Updates the component property only when the form element loses focus (e.g., the user clicks or tabs out of a text field). |
wire:model.live.debounce.500ms | Live + Debounce: Updates the property like .live , but only after the user pauses input for a specified duration (e.g., 500ms ). Useful for text inputs to avoid excessive updates while typing. |
wire:model.live.throttle.500ms | Live + Throttle: Updates the property like .live , but ensures updates happen at most once within a specified interval (e.g., every 500ms ), even if the input changes faster. |
Choosing the right modifier depends on the desired user experience and performance considerations for each specific input.
Adding Genre Options
Currently, our genre dropdown only shows the "All Genres" option. We need to populate it dynamically with the actual genres available in our record collection. A good place to fetch this data is within the render()
method of the Shop
component, as it ensures the list is potentially updated if the available genres change over time (though fetching in mount()
would also be viable if genres are static). We should only list genres that actually have associated records and perhaps show a count of records for each genre to guide the user.
- app/Livewire/Shop.php:
- Inside the
render()
method, before fetching the$records
, add an Eloquent query to retrieve the genres:- Use
Genre::has('records')
to ensure only genres linked to at least one record are included. - Use
->withCount('records')
to efficiently retrieve the number of records associated with each genre. This count will be available as arecords_count
attribute on each genre object. - Use
->orderBy('name')
to list the genres alphabetically. - Use
->get()
to execute the query and retrieve the collection of genre objects.
- Use
- Pass this
$allGenres
collection to the view using thecompact()
function in thereturn view(...)
statement.
- Inside the
- resources/views/livewire/shop.blade.php:
- Locate the
<flux:select>
component for the genre filter. - After the static "All genres" option, add a Blade
@foreach($allGenres as $g)
loop. - Inside the loop, create a
<flux:select.option>
for each genre$g
. - Set the
value
attribute of the option to the genre's ID:value="{{ $g->id }}"
. - Set the display text of the option to the genre's name and include the record count in parentheses:
{{ $g->name }} ({{ $g->records_count }})
.
- Locate the
php
// Make sure 'use App\Models\Genre;' is present at the top
public function render()
{
$allGenres = Genre::has('records') // Fetch genres for the dropdown
->withCount('records')
->orderBy('name')
->get();
// Query for $records will be modified later to include filters
$records = Record::orderBy('artist')
->orderBy('title')
// ->where(...) filters will be added here
->paginate($this->perPage);
// Pass both $records and $allGenres to the view
return view('livewire.shop', compact('records', 'allGenres')); //
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Implementing Filter Logic
Now for the core part: making the filters actually work. We need to modify the Eloquent query that fetches the$records
within the render()
method of Shop.php
to incorporate the current values held by our filter properties ($filter
, $genre
, $price
).
Create a Query Scope for Text Search
The text search needs to check both the title
and artist
fields for the search term contained in the $filter
property. Building this WHERE title LIKE ? OR artist LIKE ?
logic directly in the render
method can become cumbersome, especially if more search fields are added later. A cleaner approach is to define an Eloquent Query Scope on the Record
model itself. This encapsulates the search logic, making the query in our component more readable.
- app/Models/Record.php:
- Open the
Record
model file. - Define a new public method starting with
scope
, for example,scopeSearchTitleOrArtist
. Query scope methods always receive the query builder instance ($query
) as the first argument, followed by any custom arguments (in our case, the$search
term). - Inside the method, first check if the
$search
term is empty. If it is, we simply return the$query
builder unmodified to avoid applying an overly broadLIKE '%%'
filter unnecessarily. - If the
$search
term is not empty, construct the actual search term by wrapping the input$search
string with%
wildcards ($searchTerm = "%{$search}%";
). This ensures we find records where the search term appears anywhere within the title or artist. - Chain
where()
andorWhere()
clauses onto the$query
builder to filter records where thetitle
islike
the$searchTerm
OR theartist
islike
the$searchTerm
. - Return the modified
$query
builder instance.
- Open the
php
<?php
namespace App\Models;
// other imports
class Record extends Model
{
// ... existing properties and methods ...
public function scopeSearchTitleOrArtist($query, $search = '')
{
if (empty($search)) { // Avoid applying filter if search term is empty
return $query;
}
$searchTerm = "%{$search}%"; // Ensure we are looking for the search term anywhere in the string
return $query // Apply the WHERE (title LIKE ?) OR (artist LIKE ?) condition
->where('title', 'like', $searchTerm)
->orWhere('artist', 'like', $searchTerm);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Initializing Price Filter
The price range slider requires minimum and maximum bounds ($priceMin
, $priceMax
) to function correctly, and we need to set an initial value ($price
). Calculating the absolute minimum and maximum price across all records in the database should ideally happen only once when the component is first loaded, rather than on every re-render triggered by filter changes. Livewire's mount()
lifecycle hook is the perfect place for such one-time initialization logic.
- app/Livewire/Shop.php:
- Add the
public function mount()
method to theShop
component class.- Inside
mount()
:- Query the
Record
model to find the minimum price usingRecord::min('price')
and the maximum price usingRecord::max('price')
. - Use the
ceil()
PHP function to round these values up to the nearest integer. This ensures the slider covers the full range inclusively and provides slightly friendlier bounds (e.g., €23.50 becomes €24). - Assign the calculated values to the component's public properties:
$this->priceMin = ceil(Record::min('price'));
and$this->priceMax = ceil(Record::max('price'));
. - Set the initial value for the slider's current position (
$this->price
). A sensible default is to set it to the maximum calculated price ($this->price = $this->priceMax;
), so that initially, all records are displayed regardless of price.
- Query the
- Inside
php
<?php
// ... use statements ...
class Shop extends Component
{
use WithPagination;
// ... existing properties: $perPage, $filter, $genre, $price, $priceMin, $priceMax, $selectedRecord ...
public function mount()
{
// Calculate min/max prices once when the component is initialized
$this->priceMin = ceil(Record::min('price') ?? 0); // Add ?? 0 fallback if no records
$this->priceMax = ceil(Record::max('price') ?? 100); // Add ?? 100 fallback
// Set the initial slider value to the maximum possible price
$this->price = $this->priceMax;
}
// ... other methods: showTracks, addToBasket ...
// render() method will be updated next
// updated() method will be added later
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
With $priceMin
, $priceMax
, and $price
now properly initialized in mount()
and bound in the view using min=""
, max=""
, and value=""
, the range slider will correctly reflect the available price range from the start.
Final render()
Method Query
Now, let's bring all the filter logic together within the render()
method. We'll modify the main Eloquent query that fetches the $records
to conditionally apply where
clauses based on the current values of our filter properties ( $filter
, $genre
, $price
).
- app/Livewire/Shop.php:
- Locate the
$records = Record::...
query inside therender()
method. - Chain the following methods onto the query builder before the
->paginate(...)
call:- Text Search (Title/Artist): Apply the query scope we created earlier by calling
->searchTitleOrArtist($this->filter)
. This neatly encapsulates thetitle LIKE ? OR artist LIKE ?
logic. - Genre Filter: Add
->where('genre_id', 'like', $this->genre)
. We uselike
here because our "All Genres" option has the value'%'
. When$this->genre
is'%'
, this clause effectively becomesWHERE genre_id LIKE '%
, matching all genre IDs (including null if applicable, though ourhas('records')
on genre fetch likely prevents nulls). When a specific genre ID (e.g.,5
) is selected, it becomesWHERE genre_id LIKE '5'
, which functions identically toWHERE genre_id = 5
for numeric IDs. - Price Filter: Add
->where('price', '<=', $this->price)
. This filters the records to include only those whose price is less than or equal to the current value selected on the range slider ($this->price
).
- Text Search (Title/Artist): Apply the query scope we created earlier by calling
- Locate the
php
public function render()
{
// Fetch genres for the dropdown (as defined before)
$allGenres = Genre::has('records')
->withCount('records')
->orderBy('name')
->get();
// Build the final query, incorporating all filters
$records = Record::orderBy('artist') // Start with base query and sorting
->orderBy('title')
->searchTitleOrArtist($this->filter) // Apply text search scope using the $filter property
->where('genre_id', 'like', $this->genre) // Apply genre filter using the $genre property
->where('price', '<=', $this->price) // Apply price filter using the $price property
->paginate($this->perPage); // Finally, paginate the results based on $perPage
// Pass data to the view
return view('livewire.shop', compact('records', 'allGenres'));
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Resetting Pagination on Filter Change
A common usability issue with filtered lists and pagination arises when a filter change significantly reduces the number of results. For example, if a user is on page 5 of the results, and then applies a filter that yields only 2 pages of data, they would be left viewing a non-existent page 5 (which would appear empty). To prevent this jarring experience, we should automatically reset the pagination back to page 1 whenever any of the filter criteria ($filter
, $genre
, $price
) or the items per page setting ($perPage
) is modified.
Livewire provides a convenient lifecycle hook, updated($property, $value)
, which is automatically executed whenever a public property's value is successfully updated after a wire:model
change. We can use this hook to detect changes to our filter properties and trigger a pagination reset.
- app/Livewire/Shop.php:
- Add the
public function updated($property, $value)
method to theShop
component class. This method receives the name of the property that was just updated ($property
) and its new value ($value
). - Inside the
updated()
method, check if the$property
name matches any of our filter-related properties. We can use PHP'sin_array()
function for this:if (in_array($property, ['perPage', 'filter', 'genre', 'price']))
. - If the updated property is one of our filter properties, call Livewire's built-in
$this->resetPage()
method. This method specifically resets the paginator instance used by the component back to its first page.
- Add the
php
<?php // In app/Livewire/Shop.php
// ... use statements ...
class Shop extends Component
{
use WithPagination;
// ... existing properties ...
// ... mount() method ...
// ... showTracks(), addToBasket() methods ...
// ... render() method ...
public function updated($property, $value)
{
// $property: The name of the current property being updated
// $value: The value about to be set to the property (available but often not needed here)
// Check if the updated property is one of the filters or pagination control
if (in_array($property, ['perPage', 'filter', 'genre', 'price']))
{
// If yes, reset the paginator back to page 1
$this->resetPage();
}
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
Handling "No Records Found"
When users apply filters, it's possible that their specific combination of criteria results in no matching records. Displaying just an empty space can be confusing. It's much better practice to provide clear feedback to the user, acknowledging their filters and informing them that no results were found.
We can achieve this in the Blade view by checking if the paginated $records
collection is empty after applying all the filters in the render
method.
- resources/views/livewire/shop.blade.php:
- Locate the comment
{{-- No records found message will go here --}}
. - Replace the comment with a Blade conditional block:
@if($records->isEmpty())
. TheisEmpty()
method is available on Laravel collections (including the paginator instance) and returnstrue
if the collection contains no items. - Inside the
@if
block, add an alert message component (e.g., our custom<x-itf.alert variant="error" dismissible>
). - Make the message informative by referencing the user's current filter settings. You can directly use the bound properties like
{{ $filter }}
. Displaying the selected genre name requires a little extra logic if$genre
holds an ID, but for simplicity here, we'll just show the search term. You could enhance this further by fetching the genre name based on$this->genre
in the component if needed for the message.
- Locate the comment
php
{{-- No records found message will go here --}}
@if($records->isEmpty()) // Check if the $records collection (after filtering) is empty
<x-itf.alert variant="error" dismissible>
Can't find any artist or album with <b>'{{ $filter }}'</b> for this genre
</x-itf.alert>
@endif
1
2
3
4
5
6
2
3
4
5
6
With these interactive filtering mechanisms, intelligent data binding, dynamic query updates, pagination handling, and user-friendly feedback for empty results now implemented, your Livewire Shop component provides a significantly more powerful and pleasant browsing experience for your users. They can now efficiently navigate and find the vinyl records they are looking for.
Exercises
Alternative feedback message
Enhance the "No Records Found" message to be more specific by including the selected genre name (if a specific genre is selected, not "All Genres") and the selected maximum price.
- Consider how to phrase the message clearly incorporating these details.
iTunes top songs Belgium
Create a new page that displays the current top 10 music albums from the Belgian iTunes store using an external API.
- Create a new Livewire component, e.g.,
Itunes
. Runphp artisan livewire:make Itunes
. - Define a route in
routes/web.php
that maps the URL/itunes
to this newApp\Livewire\Itunes
component. Assign it a name likeitunes
. - URL: (https://vinyl_shop.test/itunes)[https://vinyl_shop.test/itunes]
- Get the top 10 Belgian albums from the iTunes API:
- Feed generator: https://rss.applemarketingtools.com/
- JSON response for 10 songs: https://rss.applemarketingtools.com/api/v2/be/music/most-played/10/albums.json
TIP
The content of this API feed changes frequently. Compare your output with the live preview links provided in the original prompt to verify your structure and data extraction.
iTunes Advanced version (Optional Challenge)
Extend the iTunes browser:
- Add public properties to your
Itunes
component for:- A select dropdown for Storefront (Country): Options for 'be', 'nl', 'lu', 'fr', 'de', etc. (use the Apple RSS Feed Generator.)
- A select dropdown for Result Limit: Options like 6, 10, 12, 25.
- A select dropdown for Type: Options for 'albums' or 'songs'.
- Consider adding a loading indicator (e.g., using Livewire's
wire:loading
directive) while the API request is in progress.