# Shop: filter section
# Add form elements to the view
- Add all form elements that we need for the filter to the view
livewire/shop.blade.php
result
- Most of the form elements are Jetstream components
(Tip:Ctrl + Click
on the component name to see how the component is implemented) - There is no
form
tag, because we don't need to submit the form
(Any change in the form elements will trigger theupdated
method and the results will be updated automatically) - None of the input elements have a
value
attribute
(Thevalue
attribute is not needed, because thewire:model
attribute is used)
{{-- filter section: artist or title, genre, max price and records per page --}} <div class="grid grid-cols-10 gap-4"> <div class="col-span-10 md:col-span-5 lg:col-span-3"> <x-jet-label for="name" value="Filter"/> <div class="relative"> <x-jet-input id="name" type="text" class="block mt-1 w-full" placeholder="Filter Artist Or Record"/> <div class="w-5 absolute right-4 top-3 cursor-pointer"> <x-phosphor-x-duotone/> </div> </div> </div> <div class="col-span-5 md:col-span-2 lg:col-span-2"> <x-jet-label for="genre" value="Genre"/> <x-tmk.form.select id="genre" class="block mt-1 w-full"> <option value="%">All Genres</option> </x-tmk.form.select> </div> <div class="col-span-5 md:col-span-3 lg:col-span-2"> <x-jet-label for="perPage" value="Records per page"/> <x-tmk.form.select id="perPage" class="block mt-1 w-full"> <option value="3">3</option> <option value="6">6</option> <option value="9">9</option> <option value="12">12</option> <option value="15">15</option> <option value="18">18</option> <option value="24">24</option> </x-tmk.form.select> </div> <div class="col-span-10 lg:col-span-3"> <x-jet-label for="price">Price ≤ <output id="pricefilter" name="pricefilter"></output> </x-jet-label> <x-jet-input type="range" id="price" name="price" min="0" max="100" oninput="pricefilter.value = price.value" class="block mt-4 w-full h-2 bg-indigo-100 accent-indigo-600 appearance-none"/> </div> </div>
Copied!
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
Dropdown records per page (optional)
- Because we are lazy developers we don't want to write all the options manually
- A more dynamic way for building the dropdown could be:
<x-tmk.form.select id="perPage" class="block mt-1 w-full"> @foreach([3,6,9,12,15,18,24] as $recordsPerPage) <option value="{{$recordsPerPage}}">{{$recordsPerPage}}</option> @endforeach </x-tmk.form.select>
Copied!
1
2
3
4
5
6
2
3
4
5
6
# Bind properties to form elements
- Add 3 properties to the Shop component (one property for each form element)
- Bind the properties to the form elements with the
wire:model
attribute
Livewire/Shop.php
livewire/shop.blade.php
result
// public properties public perPage = 6; public $name; public $genre = '%'; public $price; public $loading = 'Please wait...'; public $selectedRecord; public $showModal = false;
Copied!
1
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
# Fill the genre select element
- Get, inside the
render()
method, all the genres that has records and add them to the select element
Livewire/Shop.php
livewire/shop.blade.php
result
- Line 15: select all the genres that has records (most of the genres don't have records) and count the number of records for each genre
- Line 16: order the records by
artist
and then bytitle
(we can use theorderBy
method multiple times) - Line 19: add
allGenres
to thecompact
array
// public properties public $perPage = 6; public $name; public $genre; public $price; public $loading = 'Please wait...'; public $selectedRecord; public $showModal = false; public function showTracks(Record $record) { ... } public function render() { $allGenres = Genre::has('records')->withCount('records')->get(); $records = Record::orderBy('artist')->orderBy('title') ->paginate($this->perPage); return view('livewire.shop', compact('records', 'allGenres'))) ->layout('layouts.vinylshop', [ 'description' => 'Shop', 'title' => 'Shop' ]); }
Copied!
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
IMPORTANT
We can't use @foreach($allGenres as $genre)
because $genre
is already a property in the component.
That's the reason why we use @foreach($allGenres as $g)
instead of @foreach($allGenres as $genre)
# Update the range input element with min and max values
TIP
- Remember that the
render()
method is called every time a property is updated - Because the minimum and maximum price is not going to change, we can use the
render()
method, but this will slow down the page load and that's not what we want - Livewire has a
mount()
method that is called only once, when the component is loaded and just before the firstrender()
method is called
- Set the
min
andmax
values of the range input element to the minimum and maximum price of the records - Therefor we need the extra properties
$priceMin
and$priceMax
- The
min
andmax
values will also be calculated in themount()
method because they are not going to change
- The
Livewire/Shop.php
livewire/shop.blade.php
result
- Line 6: add the properties
$priceMin
and$priceMax
- Line 16: use the min() (opens new window) method to calculate the minimum price in the records collection and round it down to the nearest integer
- Line 17: use the max() (opens new window) method to calculate the maximum price in the records collection and round it down to the nearest integer
- Line 18: set the default selected
$price
property to the$priceMax
property
// public properties public perPage = 6; public $name; public $genre = '%'; public $price; public $priceMin, $priceMax; public $loading = 'Please wait...'; public $selectedRecord; public $showModal = false; public function showTracks(Record $record){ ... } public function mount() { $this->priceMin = ceil(Record::min('price')); $this->priceMax = ceil(Record::max('price')); $this->price = $this->priceMax; } public function render() { ... }
Copied!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# Basic filter
- Now that all elements are set up, we can start filtering the records
- The filter will be based on the
$name
,$genre
and$price
properties and will be applied in therender()
method
livewire/Shop.php
result
- Add the where() (opens new window) clauses to the
$records
query- Line 6: if you search for record title that contains the letters bo, you have to append and prepend a
%
to find these letters at any position:where([['title', 'like', '%bo%'], [...], [...]])
- Line 7: the values of
$genre
are numbers, except the first one. 'All genres' is not a number but corresponds to the value%
.
Because of the different types, you have to use'like'
to compare and not'='
e.g.- for 'pop/rock' you get
where([[...], ['genre_id', 'like', 1], [...]])
- and for 'All genres' you get
where([[...], ['genre_id', 'like', '%'], [...]])
- for 'pop/rock' you get
- Line 8: the price of the record must be less or equal to the
$price
property
- Line 6: if you search for record title that contains the letters bo, you have to append and prepend a
public function render() { $allGenres = Genre::has('records')->withCount('records')->get(); $records = Record::orderBy('artist')->orderBy('title') ->where([ ['title', 'like', "%{$this->name}%"], ['genre_id', 'like', $this->genre], ['price', '<=', $this->price] ]) ->paginate($this->perPage); return view('livewire.shop', compact('records', 'allGenres')) ->layout('layouts.vinylshop', [ 'description' => 'Shop', 'title' => 'Shop' ]); }
Copied!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
WARNING
- Reload the browser and click on page 6 of the navigation
- Search for
bo
and you will see that view is not properly updated - You're still on page 6, but the one record that was found is on page 1!
- The
paginate()
method is not (yet) aware of this - When you use the
paginate()
in combination with a filter, you always have to reset the page in theupdated()
method - Add the
updated()
method to the component and test the filter again:
public function updated($propertyName, $propertyValue) { // dump($propertyName, $propertyValue); $this->resetPage(); } public function showTracks(Record $record){ ... } public function mount() { ... } public function render() { ... }
Copied!
1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
REMARKS
- The
updated($propertyName, $propertyValue)
(opens new window) method is called every time a property is updated - The (optional)
$propertyName
and$propertyValue
parameters contains the name and the new value of the property that was updated - For now we don't need the
$propertyName
and$propertyValue
parameters yet, but it is good practice to add them anyway - You can also use individual
updated()
methods for each property, e.g.updatedName($value)
,updatedGenre($value)
,updatedPrice($value)
,updatedPerPage($value)
,updatedAllGenres($value)
, ...
# Advanced filter
# orWhere() clause
- How can we use the
$name
property to search for a record title OR artist?- Add an extra orWhere() (opens new window) clause to the
Record
query
- Add an extra orWhere() (opens new window) clause to the
livewire/Shop.php
result
- The only difference with the
where()
clause and theorWhere()
clause:- Line 6:
['title', 'like', ...]
- Line 11: becomes
['artist', 'like', ...]
- Line 6:
public function render() { $allGenres = Genre::has('records')->withCount('records')->get(); $records = Record::orderBy('artist')->orderBy('title') ->where([ ['title', 'like', "%{$this->name}%"], ['genre_id', 'like', $this->genre], ['price', '<=', $this->price] ]) ->orWhere([ ['artist', 'like', "%{$this->name}%"], ['genre_id', 'like', $this->genre], ['price', '<=', $this->price] ]) ->paginate($this->perPage); return view('livewire.shop', compact('records', 'allGenres')) ->layout('layouts.vinylshop', [ 'description' => 'Shop', 'title' => 'Shop' ]); }
Copied!
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
# Create a query scope for the orWhere() clause
- There is only one line of code that is different between the
where()
andorWhere()
clauses - The more lines of code you have to repeat, the more difficult it is to maintain your code
- So this is a good moment to create a query scope for the search in
title
orartist
attributes - Open the app/Models/Record.php model and add an extra scope method
app/Models/Record.php
livewire/Shop.php
result
- The scope method is called
scopeSearchTitleOrArtist()
and takes two parameters:$query
and$search
- The method returns the query with the
orWhere()
clause
... public function scopeMaxPrice($query, $price) { ...} public function scopeSearchTitleOrArtist($query, $search = '%') { return $query->where('title', 'like', "%{$search}%") ->orWhere('artist', 'like', "%{$search}%"); } ...
Copied!
1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
Type hinting
- Got to the menu Laravel > Generate Helper Code to regenerate the updated type hinting for the
Record
model - Select
searchTitleOrArtist()
(NOTscopeSearchTitleOrArtist()
) from the list
# Give feedback if the result is empty
- It's a good practice to give some feedback to the user if the result is empty
- We'll do this by adding an alert box below the form
- Use the Blade
@if
directive in combination with theisEmpty()
(opens new window) method to show the alert only if the result is empty
livewire/shop.blade.php
result
{{-- master section: cards with paginationlinks --}} <div class="my-4">{{ $records->links() }}</div> <div class="grid grid-cols-1 lg:grid-cols-2 2xl:grid-cols-3 gap-8 mt-8" ... > <div class="my-4">{{ $records->links() }}</div> {{-- No records found --}} @if($records->isEmpty()) <x-tmk.alert type="danger" class="w-full"> Can't find any artist or album with <b>'{{ $name }}'</b> for this genre </x-tmk.alert> @endif
Copied!
1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
# Update the pagination behavior
- There is one little problem with the pagination
- Reset the filter and go to the last page
- Open on the details of a record
- Close the modal
- After that, we're redirected back to the first page, because with every change in one of the properties, the
updated()
method is called:public function updated() { $this->resetPage(); }
- We only want to reset the page when the filter has been changed, not when anything else is changed
- So we need to use a more specific paginator behavior inside the
updated()
method- The paginator will only reset the page when the
$perPage
,$name
,$genre
or$price
property has changed
- The paginator will only reset the page when the
- Update the
updated()
method to:
public function updated($propertyName, $propertyValue) { // dump($propertyName, $propertyValue); if (in_array($propertyName, ['perPage', 'name', 'genre', 'price'])) $this->resetPage(); }
Copied!
1
2
3
4
5
6
2
3
4
5
6
# Better UX
- With a few minor changes, you can increase the user experience (UX) significantly
# Debounce the input fields
- Every keystroke in a filter field and moving the price slider, triggers a new search and a roundtrip to the server
- This behavior is very annoying (and slow)
- This can be solved by debouncing (opens new window) the input fields:
- Line 3: replace
wire:model="name"
withwire:model.debounce.500ms="name"
- Line 8: replace
wire:model="price"
withwire:model.debounce.500ms="price"
- Line 3: replace
... <x-jet-input id="name" type="text" wire:model.debounce.500ms="name" class="block mt-1 w-full" placeholder="Filter Artist Or Record"/> ... <x-jet-input type="range" id="price" name="price wire:model.debounce.500ms="price" min="{{ $priceMin }}" max="{{ $priceMax }}" oninput="pricefilter.value = price.value" class="block mt-4 w-full h-2 bg-indigo-100 accent-indigo-600 appearance-none"/> ...
Copied!
1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
# Clear the filter field
- We already placed a little X over the input field to clear the filter but it's not working yet
- Make an Alpine component:
- Line 2: initialize an Alpine component with the
x-data
attribute
Bind (entangle) the Alpinename
property to the$name
property of the Livewire component - LIne 5 - 6: replace the server side Livewire bounding (
wire:model
) with the client side Alpine bounding (x-model
) - Line 10: the div with the X is only visible when the
$name
property is not empty - Line 11: add a
@click
event to the X to empty the value of the input field with thename = ''
statement
- Line 2: initialize an Alpine component with the
<div x-data="{ name: @entangle('name') }" class="relative"> <x-jet-input id="name" type="text" x-model.debounce.500ms="name" {{--wire:model.debounce.500ms="name"--}} class="block mt-1 w-full" placeholder="Filter Artist Or Record"/> <div x-show="name" @click="name = '';" class="w-5 absolute right-4 top-3 cursor-pointer"> <x-phosphor-x-duotone/> </div> </div>
Copied!
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# EXERCISES
# 1: Alternative feedback message
- Show the selected genre and the price within the feedback message
# 2: iTunes top songs Belgium
# Basic version
- Use the native Laravel HTTP-method to show the top 10 of Belgian iTunes albums for today
- Create a Livewire component (
Itunes
) for this route: http://vinyl_shop.test/itunes (opens new window) - Get the top 10 Belgian albums from the iTunes API:
- Feed generator: https://rss.applemarketingtools.com/ (opens new window)
- JSON response for 10 songs: https://rss.applemarketingtools.com/api/v2/be/music/most-played/10/albums.json (opens new window)
TIP
This is a live feed and the content changes daily. Compare your result with the live preview (@it-fact.be)
JSON data
result
# Advanced version
JSON data
result
- https://rss.applemarketingtools.com/ (opens new window)
- Add some filters for:
- Storefront: (= country code)
be
,nl
,lu
, ... in an<x-tmk.form.select>
component - Result Limit:
6
,10
,12
, ... in an<x-tmk.form.select>
component - Type:
albums
orsongs
in an<x-tmk.form.switch>
component
- Storefront: (= country code)
- Don't forget the preloader