Appearance
Admin: genres
- Typical for admin pages is that an administrator can fully manage all the tables in the database
- Take for example the genres table: an administrator can add, change and delete a genre
- These operations are referred to as CRUD (C for
create
, R forread
, U forUpdate
and D fordelete
) - In this chapter we will look at how to implement the CRUD for the genres table
IMPORTANT!
- In this chapter, we will use validation rules for the first time and they are based on Livewire version 3.2.0 or higher
- You can check the version of Livewire by running the command
composer show livewire/livewire | grep version
in the terminal - If you are using an older version of Livewire, you have to update it first by running the command
composer update
REMARK
Remember that you can always reset the database to its initial state by running the command:
bash
php artisan migrate:fresh
php artisan migrate:fresh
1
DO THIS FIRST
- Before starting this chapter, make sure you have installed and configured SweetAlert2
Preparation
Create a Genres component
- Create a new Livewire component with the terminal command
php artisan livewire:make Admin/Genres
- app/Livewire/Admin/Genres.php (the component class)
- resources/views/livewire/admin/genres.blade.php (the component view)
- Open the component class and change the layout to
layouts.vinylshop
php
<?php
namespace App\Livewire\Admin;
use Livewire\Attributes\Layout;
use Livewire\Component;
class Genres extends Component
{
#[Layout('layouts.vinylshop', ['title' => 'Genres', 'description' => 'Manage the genres of your vinyl records',])]
public function render()
{
return view('livewire.admin.genres');
}
}
<?php
namespace App\Livewire\Admin;
use Livewire\Attributes\Layout;
use Livewire\Component;
class Genres extends Component
{
#[Layout('layouts.vinylshop', ['title' => 'Genres', 'description' => 'Manage the genres of your vinyl records',])]
public function render()
{
return view('livewire.admin.genres');
}
}
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
Add a new route
- Add a new get-route for the Genres to the routes/web.php file
- Update the navigation menu in resources/views/livewire/layout/nav-bar.blade.php
- Add the route in the admin group
- The URL is admin/genres (prefix is already set to admin)
- The view is admin/genres
- The route name is admin.genres (the group name is already set to admin.)
php
Route::middleware(['auth', 'admin', 'active'])->prefix('admin')->name('admin.')->group(function () {
Route::redirect('/', '/admin/records');
Route::get('genres', Genres::class)->name('genres');
Route::get('records', Demo::class)->name('records');
});
...
Route::middleware(['auth', 'admin', 'active'])->prefix('admin')->name('admin.')->group(function () {
Route::redirect('/', '/admin/records');
Route::get('genres', Genres::class)->name('genres');
Route::get('records', Demo::class)->name('records');
});
...
1
2
3
4
5
6
2
3
4
5
6
Basic scaffolding for view
- Open resources/views/livewire/admin/genres.blade.php and replace the content with the following code:
- Line 16: this section is hidden by default (the show/hide functionality will be added later)
php
<div>
<x-tmk.section
class="p-0 mb-4 flex flex-col gap-2">
<div class="p-4 flex justify-between items-start gap-4">
<div class="relative w-64">
<x-input id="newGenre" type="text" placeholder="New genre"
class="w-full shadow-md placeholder-gray-300"/>
<x-phosphor-arrows-clockwise
class="w-5 h-5 text-gray-200 absolute top-3 right-2 animate-spin"/>
</div>
<x-heroicon-o-information-circle
class="w-5 text-gray-400 cursor-help outline-0"/>
</div>
<x-input-error for="newGenre" class="m-4 -mt-4 w-full"/>
<div
style="display: none"
class="text-sky-900 bg-sky-50 border-t p-4">
<x-tmk.list type="ul" class="list-outside mx-4 text-sm">
<li>
<b>A new genre</b> can be added by typing in the input field and pressing <b>enter</b> or
<b>tab</b>. Press <b>escape</b> to undo.
</li>
<li>
<b>Edit a genre</b> by clicking the
<x-phosphor-pencil-line-duotone class="w-5 inline-block"/>
icon or by clicking on the genre name. Press <b>enter</b> to save, <b>escape</b> to undo.
</li>
<li>
Clicking the
<x-heroicon-o-information-circle class="w-5 inline-block"/>
icon will toggle this message on and off.
</li>
</x-tmk.list>
</div>
</x-tmk.section>
<x-tmk.section>
<table class="text-center w-full border border-gray-300">
<colgroup>
<col class="w-14">
<col class="w-20">
<col class="w-16">
<col class="w-max">
</colgroup>
<thead>
<tr class="bg-gray-100 text-gray-700 [&>th]:p-2 cursor-pointer">
<th>
<span data-tippy-content="Order by id">#</span>
<x-heroicon-s-chevron-up
class="w-5 text-slate-400 inline-block"/>
</th>
<th>
<span data-tippy-content="Order by # records">
<x-tmk.logo class="w-6 mx-auto fill-gray-200 inline-block"/>
</span>
<x-heroicon-s-chevron-up
class="w-5 text-slate-400 inline-block"/>
</th>
<th></th>
<th class="text-left">
<span data-tippy-content="Order by genre">Genre</span>
<x-heroicon-s-chevron-up
class="w-5 text-slate-400 inline-block"/>
</th>
</tr>
</thead>
<tbody>
<tr class="border-t border-gray-300 [&>td]:p-2">
<td>...</td>
<td>...</td>
<td>
<div class="flex gap-1 justify-center [&>*]:cursor-pointer [&>*]:outline-0 [&>*]:transition">
<x-phosphor-pencil-line-duotone
class="w-5 text-gray-300 hover:text-green-600"/>
<x-phosphor-trash-duotone
class="w-5 text-gray-300 hover:text-red-600"/>
</div>
</td>
<td
class="text-left cursor-pointer">...
</td>
</tr>
</tbody>
</table>
</x-tmk.section>
</div>
<div>
<x-tmk.section
class="p-0 mb-4 flex flex-col gap-2">
<div class="p-4 flex justify-between items-start gap-4">
<div class="relative w-64">
<x-input id="newGenre" type="text" placeholder="New genre"
class="w-full shadow-md placeholder-gray-300"/>
<x-phosphor-arrows-clockwise
class="w-5 h-5 text-gray-200 absolute top-3 right-2 animate-spin"/>
</div>
<x-heroicon-o-information-circle
class="w-5 text-gray-400 cursor-help outline-0"/>
</div>
<x-input-error for="newGenre" class="m-4 -mt-4 w-full"/>
<div
style="display: none"
class="text-sky-900 bg-sky-50 border-t p-4">
<x-tmk.list type="ul" class="list-outside mx-4 text-sm">
<li>
<b>A new genre</b> can be added by typing in the input field and pressing <b>enter</b> or
<b>tab</b>. Press <b>escape</b> to undo.
</li>
<li>
<b>Edit a genre</b> by clicking the
<x-phosphor-pencil-line-duotone class="w-5 inline-block"/>
icon or by clicking on the genre name. Press <b>enter</b> to save, <b>escape</b> to undo.
</li>
<li>
Clicking the
<x-heroicon-o-information-circle class="w-5 inline-block"/>
icon will toggle this message on and off.
</li>
</x-tmk.list>
</div>
</x-tmk.section>
<x-tmk.section>
<table class="text-center w-full border border-gray-300">
<colgroup>
<col class="w-14">
<col class="w-20">
<col class="w-16">
<col class="w-max">
</colgroup>
<thead>
<tr class="bg-gray-100 text-gray-700 [&>th]:p-2 cursor-pointer">
<th>
<span data-tippy-content="Order by id">#</span>
<x-heroicon-s-chevron-up
class="w-5 text-slate-400 inline-block"/>
</th>
<th>
<span data-tippy-content="Order by # records">
<x-tmk.logo class="w-6 mx-auto fill-gray-200 inline-block"/>
</span>
<x-heroicon-s-chevron-up
class="w-5 text-slate-400 inline-block"/>
</th>
<th></th>
<th class="text-left">
<span data-tippy-content="Order by genre">Genre</span>
<x-heroicon-s-chevron-up
class="w-5 text-slate-400 inline-block"/>
</th>
</tr>
</thead>
<tbody>
<tr class="border-t border-gray-300 [&>td]:p-2">
<td>...</td>
<td>...</td>
<td>
<div class="flex gap-1 justify-center [&>*]:cursor-pointer [&>*]:outline-0 [&>*]:transition">
<x-phosphor-pencil-line-duotone
class="w-5 text-gray-300 hover:text-green-600"/>
<x-phosphor-trash-duotone
class="w-5 text-gray-300 hover:text-red-600"/>
</div>
</td>
<td
class="text-left cursor-pointer">...
</td>
</tr>
</tbody>
</table>
</x-tmk.section>
</div>
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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
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
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
Read all genres
- Open app/Livewire/Admin/Genres.php and replace the content with the following code:
Show all the genres in the table
- Line 4 - 5: add two properties
$orderBy
and$orderAsc
to the class - Line 10: select all genres with the number of records in each genre
- Line 11: order the results by the
$orderBy
property and the$orderAsc
property- If
$orderAsc
istrue
the results will->orderBy('name', 'asc')
- If
$orderAsc
isfales
the results will->orderBy('name', 'desc')
- If
- Line 13: send the results to the view
php
class Genres extends Component
{
// sort properties
public $orderBy = 'name';
public $orderAsc = true;
#[Layout('layouts.vinylshop', ['title' => 'Genres', 'description' => 'Manage the genres of your vinyl records',])]
public function render()
{
$genres = Genre::withCount('records')
->orderBy($this->orderBy, $this->orderAsc ? 'asc' : 'desc')
->get();
return view('livewire.admin.genres', compact('genres'));
}
}
class Genres extends Component
{
// sort properties
public $orderBy = 'name';
public $orderAsc = true;
#[Layout('layouts.vinylshop', ['title' => 'Genres', 'description' => 'Manage the genres of your vinyl records',])]
public function render()
{
$genres = Genre::withCount('records')
->orderBy($this->orderBy, $this->orderAsc ? 'asc' : 'desc')
->get();
return view('livewire.admin.genres', compact('genres'));
}
}
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
Order the table by clicking on the column headers
- Line 7 - 9: click twice or more on the column header to change the order
- If the column is already ordered by the column header, the order will be reversed
- If the column is not ordered by the column header, the order will be ascending
- Line 10: set the
$orderBy
property to the column header that is clicked
php
class Genres extends Component
{
...
public function resort($column)
{
$this->orderBy === $column ?
$this->orderAsc = !$this->orderAsc :
$this->orderAsc = true;
$this->orderBy = $column;
}
...
}
class Genres extends Component
{
...
public function resort($column)
{
$this->orderBy === $column ?
$this->orderAsc = !$this->orderAsc :
$this->orderAsc = true;
$this->orderBy = $column;
}
...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
Remove the unnecessary chevrons from the table headers
- Only the chevron of the column that is ordered by should be visible, the other chevrons should be hidden
- If the sort order is ascending, the chevron should point up
- If the sort order is descending, the chevron should point down (use the Tailwind class
rotate-180
)
- Line 6 - 9: remove the class
inline-block
from the chevron (class="w-5 text-slate-400"
) and add two new dynamic classes inside the double quotes- Line 7 : if the
$orderAsc
property istrue
, do nothing, else add the classrotate-180
- Line 8 : if the
$orderBy
property is equal to the column name, add the classinline-block
, else add the classhidden
- Line 7 : if the
- Line 16 - 19: same logic as above, but
$orderBy
must be equalrecords_count
- Line 25 - 28: same logic as above, but
$orderBy
must be equalname
php
<thead>
<tr class="bg-gray-100 text-gray-700 [&>th]:p-2 cursor-pointer">
<th wire:click="resort('id')">
<span data-tippy-content="Order by id">#</span>
<x-heroicon-s-chevron-up
class="w-5 text-slate-400
{{$orderAsc ?: 'rotate-180'}}
{{$orderBy === 'id' ? 'inline-block' : 'hidden'}}
"/>
</th>
<th wire:click="resort('records_count')">
<span data-tippy-content="Order by # records">
<x-tmk.logo class="w-6 mx-auto fill-gray-200 inline-block"/>
</span>
<x-heroicon-s-chevron-up
class="w-5 text-slate-400
{{$orderAsc ?: 'rotate-180'}}
{{$orderBy === 'records_count' ? 'inline-block' : 'hidden'}}
"/>
</th>
<th></th>
<th class="text-left" wire:click="resort('name')">
<span data-tippy-content="Order by genre">Genre</span>
<x-heroicon-s-chevron-up
class="w-5 text-slate-400
{{$orderAsc ?: 'rotate-180'}}
{{$orderBy === 'name' ? 'inline-block' : 'hidden'}}
"/>
</th>
</tr>
</thead>
<thead>
<tr class="bg-gray-100 text-gray-700 [&>th]:p-2 cursor-pointer">
<th wire:click="resort('id')">
<span data-tippy-content="Order by id">#</span>
<x-heroicon-s-chevron-up
class="w-5 text-slate-400
{{$orderAsc ?: 'rotate-180'}}
{{$orderBy === 'id' ? 'inline-block' : 'hidden'}}
"/>
</th>
<th wire:click="resort('records_count')">
<span data-tippy-content="Order by # records">
<x-tmk.logo class="w-6 mx-auto fill-gray-200 inline-block"/>
</span>
<x-heroicon-s-chevron-up
class="w-5 text-slate-400
{{$orderAsc ?: 'rotate-180'}}
{{$orderBy === 'records_count' ? 'inline-block' : 'hidden'}}
"/>
</th>
<th></th>
<th class="text-left" wire:click="resort('name')">
<span data-tippy-content="Order by genre">Genre</span>
<x-heroicon-s-chevron-up
class="w-5 text-slate-400
{{$orderAsc ?: 'rotate-180'}}
{{$orderBy === 'name' ? 'inline-block' : 'hidden'}}
"/>
</th>
</tr>
</thead>
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
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
Create a new genre
Add a new genre
- Line 17: add a property
$newGenre
to the class - Line 16: define the validation rules for the
$newGenre
property using Livewire's#[Validate]
attribute- The name is required, minimum 3 characters and maximum 30 characters long
- The name must be unique in the
genres
table (see: info about unique validation rule)
- Line 20 - 28: create a new genre
- Line 23: validate the
$newGenre
property before creating the genre - Line 25 - 27: create a new genre with the
$newGenre
property as the name of the genre
(Tip: the PHPtrim()
function removes all whitespace from the beginning and end of a string)
- Line 23: validate the
php
<?php
namespace App\Livewire\Admin;
use App\Models\Genre;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
class Genres extends Component
{
// sort properties
public $orderBy = 'name';
public $orderAsc = true;
#[Validate('required|min:3|max:30|unique:genres,name')]
public $newGenre;
// create a new genre
public function create()
{
// validate the new genre name
$this->validateOnly('newGenre');
// create the genre
Genre::create([
'name' => trim($this->newGenre),
]);
}
// resort the genres by the given column
public function resort($column){ ... }
#[Layout('layouts.vinylshop', ['title' => 'Genres', 'description' => 'Manage the genres of your vinyl records',])]
public function render() { ... }
}
<?php
namespace App\Livewire\Admin;
use App\Models\Genre;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Validate;
use Livewire\Component;
class Genres extends Component
{
// sort properties
public $orderBy = 'name';
public $orderAsc = true;
#[Validate('required|min:3|max:30|unique:genres,name')]
public $newGenre;
// create a new genre
public function create()
{
// validate the new genre name
$this->validateOnly('newGenre');
// create the genre
Genre::create([
'name' => trim($this->newGenre),
]);
}
// resort the genres by the given column
public function resort($column){ ... }
#[Layout('layouts.vinylshop', ['title' => 'Genres', 'description' => 'Manage the genres of your vinyl records',])]
public function render() { ... }
}
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
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
REMARKS
Validation
- Because we have only one input field that is defered, there is no need to do real-time validation (like we did earlier in this course)
- The validation is taken care of by the
create()
method
- The validation is taken care of by the
Create vs save
- We use the
Genre::create()
method to create a new genre.- This method is not part of the Eloquent ORM.
- It is a static method that is part of the Model class.
- It is a convenience method that creates a new instance of the model and saves it to the database in a single step.
- It is equivalent to the following code:php
$genre = new Genre(); $genre->name = $this->newGenre; $genre->save();
$genre = new Genre(); $genre->name = $this->newGenre; $genre->save();
1
2
3
- One important difference between
Genre::create()
and$genre->save()
is that theGenre::create()
is more secure because it passes the$fillable
(or$guarded
) properties of the model where$genre->save()
does not.
Clear the input field
- After a new genre is created, the input field should be cleared
- This can be done by setting the
$newGenre
property to an empty string - And also the
resetErrorBag()
method or theresetValidation()
method must be called to clear the validation errors (if there are any)
- This can be done by setting the
- When we click on the Esc key, the input field should be cleared as well
- Because we have to do this in two places, we will create a methode
resetValues()
to do this
- Line 21: call the
resetValues()
method after creating the genre - Line 5 - 9: create a new method
resetValues()
to clear the$newGenre
property and the validation errors- Line 7: reset the
$newGenre
property to its default value (an empty string) - Line 8: call the
resetErrorBag()
method to clear all the validation errors
- Line 7: reset the
php
#[Validate('required|min:3|max:30|unique:genres,name')]
public $newGenre;
// reset all the values and error messages
public function resetValues()
{
$this->reset('newGenre');
$this->resetErrorBag();
}
// create a new genre
public function create()
{
// validate the new genre name
$this->validateOnly('newGenre');
// create the genre
Genre::create([
'name' => trim($this->newGenre),
]);
// reset $newGenre
$this->resetValues();
}
#[Validate('required|min:3|max:30|unique:genres,name')]
public $newGenre;
// reset all the values and error messages
public function resetValues()
{
$this->reset('newGenre');
$this->resetErrorBag();
}
// create a new genre
public function create()
{
// validate the new genre name
$this->validateOnly('newGenre');
// create the genre
Genre::create([
'name' => trim($this->newGenre),
]);
// reset $newGenre
$this->resetValues();
}
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
Add a toast response
- When a new genre is created, we want to show a toast message to the user
- We will use the SweetAlert2 JavaScript library for this
- After a new genre is created, we will emit a browser event with the name
swal:toast
and pass the name of the new genre as the html message for out toast
- Line 7: add the newly created genre to the variable
$genre
so we can use it in our toast message
(ReplaceGenre::create([...]);
with$genre = Genre::create([...]);
) - Line 13 - 16: emmit an event with the name
swal:toast
- Line 14: set the background color to
success
(a light green color) - Line 15: include the name of the new genre in the toast message
- Line 14: set the background color to
php
// create a new genre
public function create()
{
// validate the new genre name
$this->validateOnly('newGenre');
// create the genre
$genre = Genre::create([
'name' => trim($this->newGenre),
]);
// reset $newGenre
$this->resetValues();
// toast
$this->dispatch('swal:toast', [
'background' => 'success',
'html' => "The genre <b><i>{$genre->name}</i></b> has been added",
]);
}
// create a new genre
public function create()
{
// validate the new genre name
$this->validateOnly('newGenre');
// create the genre
$genre = Genre::create([
'name' => trim($this->newGenre),
]);
// reset $newGenre
$this->resetValues();
// toast
$this->dispatch('swal:toast', [
'background' => 'success',
'html' => "The genre <b><i>{$genre->name}</i></b> has been added",
]);
}
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
Update the spinner icon
- The spinner icon (the subtle gray icon in the right corner of the input field) should only be visible when the
create()
method is running - We can do this by using the
wire:loading
directive like we did before- Line 9:
wire:loading
runs every time one of the methods is called - Line 10: to make it more specific, we can use
wire:target
to specify ONLY ONE method that should be watched, in our case we want to watch thecreate()
method - Line 11: change the color of
text-gray-200
totext-gray-500
so that the spinner stands out a little more
- Line 9:
php
<div class="relative w-64">
<x-input id="newGenre" type="text" placeholder="New genre"
wire:model="newGenre"
wire:keydown.enter="create()"
wire:keydown.tab="create()"
wire:keydown.escape="resetValues()"
class="w-full shadow-md placeholder-gray-300"/>
<x-phosphor-arrows-clockwise
wire:loading
wire:target="create"
class="w-5 h-5 text-gray-500 absolute top-3 right-2 animate-spin"/>
</div>
<div class="relative w-64">
<x-input id="newGenre" type="text" placeholder="New genre"
wire:model="newGenre"
wire:keydown.enter="create()"
wire:keydown.tab="create()"
wire:keydown.escape="resetValues()"
class="w-full shadow-md placeholder-gray-300"/>
<x-phosphor-arrows-clockwise
wire:loading
wire:target="create"
class="w-5 h-5 text-gray-500 absolute top-3 right-2 animate-spin"/>
</div>
1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
Show/hide the info section
- The info section should toggle when the Info button, in the top right corner, is clicked
- Because this ia a pure client-side action, Alpine is the perfect tool for this
- Line 2: create a new Alpine component with
x-data
with a propertyopen
that is set tofalse
- Line 9: every click on the info icon will toggle the
open
property - Line 14: the section is only visible when the
open
property istrue
- Line 15: add a transition effect to the section
php
<x-tmk.section
x-data="{ open: false }"
class="p-0 mb-4 flex flex-col gap-2">
<div class="m-4 flex justify-between items-start gap-4">
<div class="relative w-64">
...
</div>
<x-heroicon-o-information-circle
@click="open = !open"
class="w-5 text-gray-600 cursor-help outline-0"/>
</div>
<x-input-error for="newGenre" class="m-4 -mt-4 w-full"/>
<div
x-show="open"
x-transition
style="display: none"
class="text-sky-900 bg-sky-50 border-t p-4">
<x-tmk.list type="ul" class="list-outside mx-4 text-sm">
...
</x-tmk.list>
</div>
</x-tmk.section>
<x-tmk.section
x-data="{ open: false }"
class="p-0 mb-4 flex flex-col gap-2">
<div class="m-4 flex justify-between items-start gap-4">
<div class="relative w-64">
...
</div>
<x-heroicon-o-information-circle
@click="open = !open"
class="w-5 text-gray-600 cursor-help outline-0"/>
</div>
<x-input-error for="newGenre" class="m-4 -mt-4 w-full"/>
<div
x-show="open"
x-transition
style="display: none"
class="text-sky-900 bg-sky-50 border-t p-4">
<x-tmk.list type="ul" class="list-outside mx-4 text-sm">
...
</x-tmk.list>
</div>
</x-tmk.section>
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
Update validation messages
- In the default validation messages, the name of the field is shown in the error message
- The variable
$newGenre
will be translated tonew genre
in the error message - You have two options to override the default validation name by adding the property
as
orattribute
to the#[Validate]
attribute
php
#[Validate(
'required|min:3|max:30|unique:genres,name',
attribute: 'name for this genre',
)]
public $newGenre;
#[Validate(
'required|min:3|max:30|unique:genres,name',
attribute: 'name for this genre',
)]
public $newGenre;
1
2
3
4
5
2
3
4
5
Update a genre
- Because we have only one input field, we can make it inline editable
- In edit mode:
- hide the buttons in the third column
- replace the name in the last column with an input field
Enter edit mode
- Line 15: add a new property
$editGenre
:- This property contains an array with the keys
id
andname
and is initialized with empty values (null
)
- This property contains an array with the keys
- Line 10 - 14: define the validation rules for
$editGenre
editGenre.name
is required, minimum 3 characters and maximum 30 characters longeditGenre.id
needs no validation because it is a hidden field- and, as for the creation validation message, we will replace the default validation name
edit genre name
withname for this genre
- Line 21 - 27: when the Pencil icon in the frontend is clicked, the
edit()
method is called and theid
of the genre is passed as a parameter- Line 21: Use "route model binding" to get the full genre as the
id
is passed as a parameter - Line 23 - 27: set the
$editGenre
property to theid
andname
of the selected genre
- Line 21: Use "route model binding" to get the full genre as the
php
class Genres extends Component
{
// sort properties
public $orderBy = 'name';
public $orderAsc = true;
#[Validate('required|min:3|max:30|unique:genres,name', as: 'name for this genre',)]
public $newGenre;
#[Validate([
'editGenre.name' => 'required|min:3|max:30|unique:genres,name',
], as: [
'editGenre.name' => 'name for this genre',
])]
public $editGenre = ['id' => null, 'name' => null];
// reset all the values and error messages
public function resetValues() { ... }
// copy the selected genre to $editGenre
public function edit(Genre $genre)
{
$this->editGenre = [
'id' => $genre->id,
'name' => $genre->name,
];
}
...
}
class Genres extends Component
{
// sort properties
public $orderBy = 'name';
public $orderAsc = true;
#[Validate('required|min:3|max:30|unique:genres,name', as: 'name for this genre',)]
public $newGenre;
#[Validate([
'editGenre.name' => 'required|min:3|max:30|unique:genres,name',
], as: [
'editGenre.name' => 'name for this genre',
])]
public $editGenre = ['id' => null, 'name' => null];
// reset all the values and error messages
public function resetValues() { ... }
// copy the selected genre to $editGenre
public function edit(Genre $genre)
{
$this->editGenre = [
'id' => $genre->id,
'name' => $genre->name,
];
}
...
}
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
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
Update the genre
- The functionality to update the genre is almost the same as the functionality to create a genre
- Click the Enter or Tab key to save the changes
- Click the Escape key to cancel the changes
- If the new name is the same as the original name, do nothing
- If the new name is different from the original name, update the genre in the database
- Validate the input field before saving the changes
- Show an error message when the validation fails
- Show a success toast when the genre is updated
- Line 17: add
editGenre
as a parameter to thereset()
method, so we can reuse theresetValues()
method for both the creation and the update of a genre - Line 24 - 43:
- Line 27: replace the value of
$editGenre['name']
with the trimmed value of$editGenre['name']
- Line 29 - 32: if the name is not changed, do nothing
(Tip: use the PHPstrtolower()
function to compare the names in a case-insensitive way, else 'Afrobeat' and 'afrobeat' are not the same) - Line 33: validate the input field before saving the changes and show an error message when the validation fails
- Line 34: save the original (not yet updated) name of the genre in a variable
$oldName
- Line 35 - 37: update the genre in the database with
$genre->update([...])
- Line 38: call the
resetValues()
method to reset the$editGenre
property and the validation errors - Line 39 - 42: dispatch a success toast
- Line 27: replace the value of
php
class Genres extends Component
{
// sort properties
public $orderBy = 'name';
public $orderAsc = true;
#[Validate('required|min:3|max:30|unique:genres,name', as: 'name for this genre',)]
public $newGenre;
#[Validate(['editGenre.name' => 'required|min:3|max:30|unique:genres,name',], as: ['editGenre.name' => 'name for this genre',
])]
public $editGenre = ['id' => null, 'name' => null];
// reset all the values and error messages
public function resetValues()
{
$this->reset('newGenre', 'editGenre');
$this->resetErrorBag();
}
// copy the selected genre to $editGenre
public function edit(Genre $genre) { ... }
// update the genre
public function update(Genre $genre)
{
$this->editGenre['name'] = trim($this->editGenre['name']);
// if the name is not changed, do nothing
if(strtolower($this->editGenre['name']) === strtolower($genre->name)) {
$this->resetValues();
return;
}
$this->validateOnly('editGenre.name');
$oldName = $genre->name;
$genre->update([
'name' => trim($this->editGenre['name']),
]);
$this->resetValues();
$this->dispatch('swal:toast', [
'background' => 'success',
'html' => "The genre <b><i>{$oldName}</i></b> has been updated to <b><i>{$genre->name}</i></b>",
]);
}
...
}
class Genres extends Component
{
// sort properties
public $orderBy = 'name';
public $orderAsc = true;
#[Validate('required|min:3|max:30|unique:genres,name', as: 'name for this genre',)]
public $newGenre;
#[Validate(['editGenre.name' => 'required|min:3|max:30|unique:genres,name',], as: ['editGenre.name' => 'name for this genre',
])]
public $editGenre = ['id' => null, 'name' => null];
// reset all the values and error messages
public function resetValues()
{
$this->reset('newGenre', 'editGenre');
$this->resetErrorBag();
}
// copy the selected genre to $editGenre
public function edit(Genre $genre) { ... }
// update the genre
public function update(Genre $genre)
{
$this->editGenre['name'] = trim($this->editGenre['name']);
// if the name is not changed, do nothing
if(strtolower($this->editGenre['name']) === strtolower($genre->name)) {
$this->resetValues();
return;
}
$this->validateOnly('editGenre.name');
$oldName = $genre->name;
$genre->update([
'name' => trim($this->editGenre['name']),
]);
$this->resetValues();
$this->dispatch('swal:toast', [
'background' => 'success',
'html' => "The genre <b><i>{$oldName}</i></b> has been updated to <b><i>{$genre->name}</i></b>",
]);
}
...
}
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
Delete a genre
WARNING
- Remember that we built in some integrity in our database tables
- If you delete a genre, all related records are deleted as well (as specified in the foreign key relation inside the records migration)
php
$table->foreignId('genre_id')->constrained()->onDelete('cascade')->onUpdate('cascade');
$table->foreignId('genre_id')->constrained()->onDelete('cascade')->onUpdate('cascade');
1
- Click on the Trash icon to delete a genre
- It's a good practice always to ask the user for a confirmation that he really wants to delete some (database) data
- You can do this with the Livewire
wire:confirm
function
WARNING
If you don't want to lose any data, test this functionality with a newly created genres (that is not linked to any record in the database), e.g. 'afrobeat'!
- Line 6: add a
delete()
method that will be called when the user clicks on the Trash icon
Use route model binding to get the genre that has to be deleted - Line 8: delete the genre
- Line 9 - 12: show a toast message that the genre is deleted
php
class Genres extends Component
{
...
// delete a genre
public function delete(Genre $genre)
{
$genre->delete();
$this->dispatch('swal:toast', [
'background' => 'success',
'html' => "The genre <b><i>{$genre->name}</i></b> has been deleted",
]);
}
...
}
class Genres extends Component
{
...
// delete a genre
public function delete(Genre $genre)
{
$genre->delete();
$this->dispatch('swal:toast', [
'background' => 'success',
'html' => "The genre <b><i>{$genre->name}</i></b> has been deleted",
]);
}
...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Improve the UX
There are some things that can be improved in the user experience:
- In edit mode, the cursor should be in the input field (now the user has to click in the input field to start typing)
- Create and edit a genre: when clicking the Escape, Return or Tab key, the input field is still editable, and we have to wait for the server response before everything is back to normal. We want to temporary disable the input field while the server is processing the request
- The default JavaScript
confirm()
dialog is not very pretty
We can use the SweetAlert2 dialog to create a more appealing dialog
Set the cursor in the input field
- Line 9: add
x-init="$el.focus()"
to input field$el
refers to the element itself (the input field)focus()
is a regular JavaScript function to sets the focus on the input field
php
@if($editGenre['id'] !== $genre->id)
<td
class="text-left cursor-pointer">{{ $genre->name }}
</td>
@else
<td>
<div class="flex flex-col text-left">
<x-input id="edit_{{ $genre->id }}" type="text"
x-init="$el.focus()"
wire:model="editGenre.name"
wire:keydown.enter="update({{ $genre->id }})"
wire:keydown.tab="update({{ $genre->id }})"
wire:keydown.escape="resetValues()"
class="w-48"/>
<x-input-error for="editGenre.name" class="mt-2"/>
</div>
</td>
@endif
@if($editGenre['id'] !== $genre->id)
<td
class="text-left cursor-pointer">{{ $genre->name }}
</td>
@else
<td>
<div class="flex flex-col text-left">
<x-input id="edit_{{ $genre->id }}" type="text"
x-init="$el.focus()"
wire:model="editGenre.name"
wire:keydown.enter="update({{ $genre->id }})"
wire:keydown.tab="update({{ $genre->id }})"
wire:keydown.escape="resetValues()"
class="w-48"/>
<x-input-error for="editGenre.name" class="mt-2"/>
</div>
</td>
@endif
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
Disable the input field
- Line 10 - 12: set the attribute
disabled
in the input field totrue
- Temporary add
sleep(2)
to theupdate()
method to simulate a slow server response
php
@if($editGenre['id'] !== $genre->id)
<td
class="text-left cursor-pointer">{{ $genre->name }}
</td>
@else
<td>
<div class="flex flex-col text-left">
<x-input id="edit_{{ $genre->id }}" type="text"
x-init="$el.focus()"
@keydown.enter="$el.setAttribute('disabled', true);"
@keydown.tab="$el.setAttribute('disabled', true);"
@keydown.esc="$el.setAttribute('disabled', true);"
wire:model="editGenre.name"
wire:keydown.enter="update({{ $genre->id }})"
wire:keydown.tab="update({{ $genre->id }})"
wire:keydown.escape="resetValues()"
class="w-48"/>
<x-input-error for="editGenre.name" class="mt-2"/>
</div>
</td>
@endif
@if($editGenre['id'] !== $genre->id)
<td
class="text-left cursor-pointer">{{ $genre->name }}
</td>
@else
<td>
<div class="flex flex-col text-left">
<x-input id="edit_{{ $genre->id }}" type="text"
x-init="$el.focus()"
@keydown.enter="$el.setAttribute('disabled', true);"
@keydown.tab="$el.setAttribute('disabled', true);"
@keydown.esc="$el.setAttribute('disabled', true);"
wire:model="editGenre.name"
wire:keydown.enter="update({{ $genre->id }})"
wire:keydown.tab="update({{ $genre->id }})"
wire:keydown.escape="resetValues()"
class="w-48"/>
<x-input-error for="editGenre.name" class="mt-2"/>
</div>
</td>
@endif
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
Use SweetAlert2 for the confirmation dialog
Basic version
- Replace the vanilla JavaScript
wire:confirm
dialog box with a SweetAlert2 dialog
- Line 9 - 10: remove the
wire:click
andwire:confirm
attributes - Line 11 - 21: dispatch the browser event
swal:confirm
and add some properties to add/overwrite the default settings for the SweetAlert2 dialog - Line 15 - 20: this object contains the properties to create a new browser event for the triggering the actual delete action
php
<td>
@if($editGenre['id'] !== $genre->id)
<div
class="flex gap-1 justify-center [&>*]:cursor-pointer [&>*]:outline-0 [&>*]:transition">
<x-phosphor-pencil-line-duotone
wire:click="edit({{ $genre->id }})"
class="w-5 text-gray-300 hover:text-green-600"/>
<x-phosphor-trash-duotone
{{-- wire:click="delete({{ $genre->id }})" --}}
{{-- wire:confirm="Are you sure you want to delete this genre?" --}}
@click="$dispatch('swal:confirm', {
html: 'Delete {{ $genre->name }}?',
cancelButtonText: 'NO!',
confirmButtonText: 'YES DELETE THIS GENRE',
next: {
event: 'delete-genre',
params: {
id: {{ $genre->id }}
}
}
})"
class="w-5 text-gray-300 hover:text-red-600"/>
</div>
@endif
</td>
<td>
@if($editGenre['id'] !== $genre->id)
<div
class="flex gap-1 justify-center [&>*]:cursor-pointer [&>*]:outline-0 [&>*]:transition">
<x-phosphor-pencil-line-duotone
wire:click="edit({{ $genre->id }})"
class="w-5 text-gray-300 hover:text-green-600"/>
<x-phosphor-trash-duotone
{{-- wire:click="delete({{ $genre->id }})" --}}
{{-- wire:confirm="Are you sure you want to delete this genre?" --}}
@click="$dispatch('swal:confirm', {
html: 'Delete {{ $genre->name }}?',
cancelButtonText: 'NO!',
confirmButtonText: 'YES DELETE THIS GENRE',
next: {
event: 'delete-genre',
params: {
id: {{ $genre->id }}
}
}
})"
class="w-5 text-gray-300 hover:text-red-600"/>
</div>
@endif
</td>
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
Advanced version
- We will show a different dialog for genres with records and genres without records
- If the genre has no records, we will show a simple, white dialog
- If the genre has records, we will show a red dialog with an extra warning message and an icon
- Line 12: add the genre name to the title of the dialog
- If the genre has records:
- Line 13: add the warning icon
- Line 14: give the background of the dialog a red color
- Line 15: add an extra warning message to the dialog
- Line 17: color the text of the warning message red
php
<td>
@if($editGenre['id'] !== $genre->id)
<div
class="flex gap-1 justify-center [&>*]:cursor-pointer [&>*]:outline-0 [&>*]:transition">
<x-phosphor-pencil-line-duotone
wire:click="edit({{ $genre->id }})"
class="w-5 text-gray-300 hover:text-green-600"/>
<x-phosphor-trash-duotone
{{-- wire:click="delete({{ $genre->id }})" --}}
{{-- wire:confirm="Are you sure you want to delete this genre?" --}}
@click="$dispatch('swal:confirm', {
title: 'Delete {{ $genre->name }}?',
icon: '{{ $genre->records_count > 0 ? 'warning' : '' }}',
background: '{{ $genre->records_count > 0 ? 'error' : '' }}',
html: '{{ $genre->records_count > 0 ?
'<b>ATTENTION</b>: you are going to delete <b>' . $genre->records_count . ' ' . Str::plural('record', $genre->records_count) . '</b> at the same time!' :'' }}',
color: '{{ $genre->records_count > 0 ? 'red' : '' }}',
cancelButtonText: 'NO!',
confirmButtonText: 'YES DELETE THIS GENRE',
next: {
event: 'delete-genre',
params: {
id: {{ $genre->id }}
}
}
})"
class="w-5 text-gray-300 hover:text-red-600"/>
</div>
@endif
</td>
<td>
@if($editGenre['id'] !== $genre->id)
<div
class="flex gap-1 justify-center [&>*]:cursor-pointer [&>*]:outline-0 [&>*]:transition">
<x-phosphor-pencil-line-duotone
wire:click="edit({{ $genre->id }})"
class="w-5 text-gray-300 hover:text-green-600"/>
<x-phosphor-trash-duotone
{{-- wire:click="delete({{ $genre->id }})" --}}
{{-- wire:confirm="Are you sure you want to delete this genre?" --}}
@click="$dispatch('swal:confirm', {
title: 'Delete {{ $genre->name }}?',
icon: '{{ $genre->records_count > 0 ? 'warning' : '' }}',
background: '{{ $genre->records_count > 0 ? 'error' : '' }}',
html: '{{ $genre->records_count > 0 ?
'<b>ATTENTION</b>: you are going to delete <b>' . $genre->records_count . ' ' . Str::plural('record', $genre->records_count) . '</b> at the same time!' :'' }}',
color: '{{ $genre->records_count > 0 ? 'red' : '' }}',
cancelButtonText: 'NO!',
confirmButtonText: 'YES DELETE THIS GENRE',
next: {
event: 'delete-genre',
params: {
id: {{ $genre->id }}
}
}
})"
class="w-5 text-gray-300 hover:text-red-600"/>
</div>
@endif
</td>
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
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
EXERCISES:
1: Make the genre name clickable
- For now, only the Pencil icon is clickable to edit the genre name
- Make the genre name clickable as well to edit the genre
2: Add a spinner to the update input fields
- Add, just like in the create input field, a spinner to the update input fields
- The spinner is only visible when the server is processing
update()
method or theresetValues()
method
(Add a 2-second delay to theupdate()
method to simulate a slow server response)
3: Add pagination to the table
- Add a new public property
$perPage
with a default value of10
to theGenres
class - Use this property to limit the number of genres that are shown in the table
- Append a fifth column to the table and add a select element to the table header to select the number of genres that are shown per page