# Shop: filter section

# Add form elements to the view

  • Add all form elements that we need for the filter to the view
  • 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 the updated method and the results will be updated automatically)
  • None of the input elements have a value attribute
    (The value attribute is not needed, because the wire: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 &le;
            <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

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

# 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


 
 
 





 // 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

# Fill the genre select element

  • Get, inside the render() method, all the genres that has records and add them to the select element
  • 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 by title (we can use the orderBy method multiple times)
  • Line 19: add allGenres to the compact 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

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 first render() method is called
  • Set the min and max 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 and max values will also be calculated in the mount() method because they are not going to change
  • 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

# 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 the render() method
  • 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', '%'], [...]])
    • Line 8: the price of the record must be less or equal to the $price property




 
 
 
 
 









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

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 Search bo
  • 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 the updated() 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

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 $propertyNameand $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?
  • The only difference with the where() clause and the orWhere() clause:
    • Line 6: ['title', 'like', ...]
    • Line 11: becomes ['artist', 'like', ...]









 
 
 
 
 









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

# Create a query scope for the orWhere() clause

  • There is only one line of code that is different between the where() and orWhere() 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 or artist attributes
  • Open the app/Models/Record.php model and add an extra scope method
  • 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

Type hinting

  • Got to the menu Laravel > Generate Helper Code to regenerate the updated type hinting for the Record model
  • Select searchTitleOrArtist() (NOT scopeSearchTitleOrArtist()) from the list
    Type hinting

# 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 the isEmpty() (opens new window) method to show the alert only if the result is empty





 
 
 
 
 
 

{{-- 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

# Update the pagination behavior

  • There is one little problem with the pagination
    1. Reset the filter and go to the last page
    2. Open on the details of a record
    3. 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
  • 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

# 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" with wire:model.debounce.500ms="name"
    • Line 8: replace wire:model="price" with wire:model.debounce.500ms="price"


 




 






...
<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

# 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 Alpine name 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 the name = '' statement

 


 
 



 
 





<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

Clear the filter

# EXERCISES

# 1: Alternative feedback message

  • Show the selected genre and the price within the feedback message Exercise 1

# 2: iTunes top songs Belgium

# Basic version

TIP

This is a live feed and the content changes daily. Compare your result with the live preview (@it-fact.be)

# Advanced version

  • 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 or songs in an <x-tmk.form.switch> component
  • Don't forget the preloader

iTunes filter

Last Updated: 11/16/2022, 11:04:20 AM