Appearance
Admin: records
- We already created a
admin/records
page in the Eloquent models section - This page was not meant to be a real admin page, but was only to show how to use Eloquent models and was build with our first Livewire component
- In this chapter we will create a new records page with CRUD operations
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
Preparation
Create a Records component
- Create a new Livewire component with the terminal command
php artisan livewire:make Admin/Records
- app/Livewire/Admin/Records.php (the component class)
- resources/views/livewire/admin/records.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 Records extends Component
{
#[Layout('layouts.vinylshop', ['title' => 'Records', 'description' => 'Manage the records of your vinyl records',])]
public function render()
{
return view('livewire.admin.records');
}
}
<?php
namespace App\Livewire\Admin;
use Livewire\Attributes\Layout;
use Livewire\Component;
class Records extends Component
{
#[Layout('layouts.vinylshop', ['title' => 'Records', 'description' => 'Manage the records of your vinyl records',])]
public function render()
{
return view('livewire.admin.records');
}
}
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
Update the route
- Update the get-route for the Records to the routes/web.php file
- Update the navigation menu in resources/views/livewire/layout/nav-bar.blade.php
(ReplaceDemo::class
withRecords::class
)
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', Records::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', Records::class)->name('records');
});
...
1
2
3
4
5
6
2
3
4
5
6
Basic scaffolding
- Open the resources/views/livewire/admin/records.blade.php and replace the content with the code beneath
- On top of the page, we have a
x-tmk.section
for the filters and a button to create a new record - Below the filters, we have a second
x-tmk.section
for the table with the records - At the bottom of the page, stands a
x-dialog-modal
for the create and update functions- Line 84: the modal is by default hidden and will be shown when the
showModal
property in the component class is set totrue
- Line 92: use Alpine's
@click
directive to set theshowModal
property tofalse
when the Cancel button is clicked
- Line 84: the modal is by default hidden and will be shown when the
blade
<div>
{{-- Filter --}}
<x-tmk.section class="mb-4 flex gap-2">
<div class="flex-1">
<x-input id="search" type="text" placeholder="Filter Artist Or Record"
class="w-full shadow-md placeholder-gray-300"/>
</div>
<x-tmk.form.switch id="noStock"
text-off="No stock"
color-off="bg-gray-100 before:line-through"
text-on="No stock"
color-on="text-white bg-lime-600"
class="w-20 h-auto" />
<x-tmk.form.switch id="noCover"
text-off="Records without cover"
color-off="bg-gray-100 before:line-through"
text-on="Records without cover"
color-on="text-white bg-lime-600"
class="w-44 h-auto" />
<x-button>
new record
</x-button>
</x-tmk.section>
{{-- Table with records --}}
<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-20">
<col class="w-14">
<col class="w-max">
<col class="w-24">
</colgroup>
<thead>
<tr class="bg-gray-100 text-gray-700 [&>th]:p-2">
<th>#</th>
<th></th>
<th>Price</th>
<th>Stock</th>
<th class="text-left">Record</th>
<th>
<x-tmk.form.select id="perPage"
class="block mt-1 w-full">
<option value="5">5</option>
<option value="10">10</option>
<option value="15">15</option>
<option value="20">20</option>
</x-tmk.form.select>
</th>
</tr>
</thead>
<tbody>
<tr class="border-t border-gray-300">
<td>...</td>
<td>
<img src="/storage/covers/no-cover.png"
alt="no cover"
class="my-2 border object-cover">
</td>
<td>...</td>
<td>...</td>
<td class="text-left">...</td>
<td>
<div class="border border-gray-300 rounded-md overflow-hidden m-2 grid grid-cols-2 h-10">
<button
class="text-gray-400 hover:text-sky-100 hover:bg-sky-500 transition border-r border-gray-300">
<x-phosphor-pencil-line-duotone class="inline-block w-5 h-5"/>
</button>
<button
class="text-gray-400 hover:text-red-100 hover:bg-red-500 transition">
<x-phosphor-trash-duotone class="inline-block w-5 h-5"/>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</x-tmk.section>
{{-- Modal for add and update record --}}
<x-dialog-modal id="recordModal"
wire:model.live="showModal">
<x-slot name="title">
<h2>title</h2>
</x-slot>
<x-slot name="content">
content
</x-slot>
<x-slot name="footer">
<x-secondary-button @click="$wire.showModal = false">Cancel</x-secondary-button>
</x-slot>
</x-dialog-modal>
</div>
<div>
{{-- Filter --}}
<x-tmk.section class="mb-4 flex gap-2">
<div class="flex-1">
<x-input id="search" type="text" placeholder="Filter Artist Or Record"
class="w-full shadow-md placeholder-gray-300"/>
</div>
<x-tmk.form.switch id="noStock"
text-off="No stock"
color-off="bg-gray-100 before:line-through"
text-on="No stock"
color-on="text-white bg-lime-600"
class="w-20 h-auto" />
<x-tmk.form.switch id="noCover"
text-off="Records without cover"
color-off="bg-gray-100 before:line-through"
text-on="Records without cover"
color-on="text-white bg-lime-600"
class="w-44 h-auto" />
<x-button>
new record
</x-button>
</x-tmk.section>
{{-- Table with records --}}
<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-20">
<col class="w-14">
<col class="w-max">
<col class="w-24">
</colgroup>
<thead>
<tr class="bg-gray-100 text-gray-700 [&>th]:p-2">
<th>#</th>
<th></th>
<th>Price</th>
<th>Stock</th>
<th class="text-left">Record</th>
<th>
<x-tmk.form.select id="perPage"
class="block mt-1 w-full">
<option value="5">5</option>
<option value="10">10</option>
<option value="15">15</option>
<option value="20">20</option>
</x-tmk.form.select>
</th>
</tr>
</thead>
<tbody>
<tr class="border-t border-gray-300">
<td>...</td>
<td>
<img src="/storage/covers/no-cover.png"
alt="no cover"
class="my-2 border object-cover">
</td>
<td>...</td>
<td>...</td>
<td class="text-left">...</td>
<td>
<div class="border border-gray-300 rounded-md overflow-hidden m-2 grid grid-cols-2 h-10">
<button
class="text-gray-400 hover:text-sky-100 hover:bg-sky-500 transition border-r border-gray-300">
<x-phosphor-pencil-line-duotone class="inline-block w-5 h-5"/>
</button>
<button
class="text-gray-400 hover:text-red-100 hover:bg-red-500 transition">
<x-phosphor-trash-duotone class="inline-block w-5 h-5"/>
</button>
</div>
</td>
</tr>
</tbody>
</table>
</x-tmk.section>
{{-- Modal for add and update record --}}
<x-dialog-modal id="recordModal"
wire:model.live="showModal">
<x-slot name="title">
<h2>title</h2>
</x-slot>
<x-slot name="content">
content
</x-slot>
<x-slot name="footer">
<x-secondary-button @click="$wire.showModal = false">Cancel</x-secondary-button>
</x-slot>
</x-dialog-modal>
</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
90
91
92
93
94
95
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
90
91
92
93
94
95
Read all records
Show all the records in the table
- Line 8 end 12: add the WithPagination trait to the class
- Line 25 - 26: get all the records from the database and order them by artist and title
- Line 27: paginate the records
- Line 28: send the records to the view
php
<?php
namespace App\Livewire\Admin;
use App\Models\Record;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithPagination;
class Records extends Component
{
use WithPagination;
// filter and pagination
public $search;
public $noStock = false;
public $noCover = false;
public $perPage = 5;
// show/hide the modal
public $showModal = false;
#[Layout('layouts.vinylshop', ['title' => 'Records', 'description' => 'Manage the records of your vinyl records',])]
public function render()
{
$records = Record::orderBy('artist')
->orderBy('title')
->paginate($this->perPage);
return view('livewire.admin.records', compact('records'));
}
}
<?php
namespace App\Livewire\Admin;
use App\Models\Record;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Livewire\WithPagination;
class Records extends Component
{
use WithPagination;
// filter and pagination
public $search;
public $noStock = false;
public $noCover = false;
public $perPage = 5;
// show/hide the modal
public $showModal = false;
#[Layout('layouts.vinylshop', ['title' => 'Records', 'description' => 'Manage the records of your vinyl records',])]
public function render()
{
$records = Record::orderBy('artist')
->orderBy('title')
->paginate($this->perPage);
return view('livewire.admin.records', compact('records'));
}
}
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
Add pagination to the table
- Line 14: bind the select element to the
$perPage
property - Line 2 and 26 add the pagination links before and after the table
php
<x-tmk.section>
<div class="my-4">{{ $records->links() }}</div>
<table class="text-center w-full border border-gray-300">
<colgroup> ... </colgroup>
<thead>
<tr class="bg-gray-100 text-gray-700 [&>th]:p-2">
<th>#</th>
<th></th>
<th>Price</th>
<th>Stock</th>
<th class="text-left">Record</th>
<th>
<x-tmk.form.select id="perPage"
wire:model.live="perPage"
class="block mt-1 w-full">
<option value="5">5</option>
<option value="10">10</option>
<option value="15">15</option>
<option value="20">20</option>
</x-tmk.form.select>
</th>
</tr>
</thead>
<tbody> ... </tbody>
</table>
<div class="my-4">{{ $records->links() }}</div>
</x-tmk.section>
<x-tmk.section>
<div class="my-4">{{ $records->links() }}</div>
<table class="text-center w-full border border-gray-300">
<colgroup> ... </colgroup>
<thead>
<tr class="bg-gray-100 text-gray-700 [&>th]:p-2">
<th>#</th>
<th></th>
<th>Price</th>
<th>Stock</th>
<th class="text-left">Record</th>
<th>
<x-tmk.form.select id="perPage"
wire:model.live="perPage"
class="block mt-1 w-full">
<option value="5">5</option>
<option value="10">10</option>
<option value="15">15</option>
<option value="20">20</option>
</x-tmk.form.select>
</th>
</tr>
</thead>
<tbody> ... </tbody>
</table>
<div class="my-4">{{ $records->links() }}</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
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
Filter by artist or title
- Line 10: add the scope
searchByArtistOrTitle()
, that we made earlier in this course, to the query
php
class Records extends Component
{
...
#[Layout('layouts.vinylshop', ['title' => 'Records', 'description' => 'Manage the records of your vinyl records',])]
public function render()
{
$records = Record::orderBy('artist')
->orderBy('title')
->searchTitleOrArtist($this->search)
->paginate($this->perPage);
return view('livewire.admin.records', compact('records'));
}
}
class Records extends Component
{
...
#[Layout('layouts.vinylshop', ['title' => 'Records', 'description' => 'Manage the records of your vinyl records',])]
public function render()
{
$records = Record::orderBy('artist')
->orderBy('title')
->searchTitleOrArtist($this->search)
->paginate($this->perPage);
return view('livewire.admin.records', compact('records'));
}
}
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
Filter by records in stock
- The
<x-tmk.form.switch id="noStock" ... />
is just a wrapper around a checkbox - The state of this checkbox determines whether we should filter or not
- If
checked
, filter the query further by->where('stock', '=', 0)
(or->where('stock', 0)
or->where('stock', false)
) - If
unchecked
, skip this filter
- If
Step 1
- Because the
$noStock
filter is sometimes applied (whentrue
or1
) and sometimes not (whenfalse
or0
), we need to split the query in two parts - The result of the total query is exactly the same as before
- Replace the previous query with the following code:
(Nothing changes in the result, because the$noStock
filter isn't implemented yet)
php
class Records extends Component
{
...
#[Layout('layouts.vinylshop', ['title' => 'Records', 'description' => 'Manage the records of your vinyl records',])]
public function render()
{
// filter by $search
$query = Record::orderBy('artist')
->orderBy('title')
->searchTitleOrArtist($this->search);
// paginate the $query
$records = $query
->paginate($this->perPage);
return view('livewire.admin.records', compact('records'));
}
}
class Records extends Component
{
...
#[Layout('layouts.vinylshop', ['title' => 'Records', 'description' => 'Manage the records of your vinyl records',])]
public function render()
{
// filter by $search
$query = Record::orderBy('artist')
->orderBy('title')
->searchTitleOrArtist($this->search);
// paginate the $query
$records = $query
->paginate($this->perPage);
return view('livewire.admin.records', compact('records'));
}
}
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
Filter by records without cover image
- The
<x-tmk.form.switch id="noCover" ... />
acts just the same as the switch for$noStock
(iftrue
: filter, iffalse
: skip filter) - The problem is that we don't have a column for the cover in the database!
- Yes, we have a "generated" column
cover
, but this is only available AFTER the->get()
(or->paginate()
) method is called - So, we can't use this in our filter 😔
- Yes, we have a "generated" column
- We can make a scope for this in the Record model and use it in our query
- Open the model app/Models/Record.php
- Add a new scope that returns only records when there is no cover available in the
public/storage/covers
folder- Line 10: use the
pluck()
method to fill the$mb_ids
array with themb_id
values
IMPORTANT: ONLY the result from the previous query is used in this scope!
(e.g.im_dbs = ['fcb78d0d-8067-4b93-ae58-1e4347e20216', 'd883e644-5ec0-4928-9ccd-fc78bc306a46', ...]
) - Line 12: initialize an empty array
$covers
- Line 13 - 23: loop through the
$mb_ids
array and check if the cover exists in thepublic/storage/covers
folder
Depending on the state of the$exists
filter, themb_id
is added to the$covers
array - Line 25:
whereIn()
returns only the records where it'smb_id
value is available in the$covers
array
- Line 10: use the
- IMPORTANT:
- open the menu Laravel > Generate Helper Code to add the new scope for type hinting and auto-completion in PhpStorm
php
...
public function scopeMaxPrice($query, $price) { ... }
public function scopeSearchTitleOrArtist($query, $search = '%') { ...}
public function scopeCoverExists($query, $exists = true)
{
// make an array with all the mb_id attributes
$mb_ids = $query->pluck('mb_id');
// empty array to store 'mb_id's that have a cover
$covers = [];
foreach ($mb_ids as $mb_id) {
// $exists = true: if the cover exists, add the mb_id to the $covers array
// $exists = false: if the cover does not exist, add the mb_id to the $covers array
if ($exists) {
if (Storage::disk('public')->exists('covers/' . $mb_id . '.jpg'))
$covers[] = $mb_id;
} else {
if (!Storage::disk('public')->exists('covers/' . $mb_id . '.jpg'))
$covers[] = $mb_id;
}
}
// return only the records with the mb_id in the $covers array
return $query->whereIn('mb_id', $covers);
}
...
public function scopeMaxPrice($query, $price) { ... }
public function scopeSearchTitleOrArtist($query, $search = '%') { ...}
public function scopeCoverExists($query, $exists = true)
{
// make an array with all the mb_id attributes
$mb_ids = $query->pluck('mb_id');
// empty array to store 'mb_id's that have a cover
$covers = [];
foreach ($mb_ids as $mb_id) {
// $exists = true: if the cover exists, add the mb_id to the $covers array
// $exists = false: if the cover does not exist, add the mb_id to the $covers array
if ($exists) {
if (Storage::disk('public')->exists('covers/' . $mb_id . '.jpg'))
$covers[] = $mb_id;
} else {
if (!Storage::disk('public')->exists('covers/' . $mb_id . '.jpg'))
$covers[] = $mb_id;
}
}
// return only the records with the mb_id in the $covers array
return $query->whereIn('mb_id', $covers);
}
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
Introduction Livewire Form objects
- In previous chapters, form input elements were just public properties in the component class itself
- This is perfectly fine for simple forms, but for more complex forms, LiveWire offers a better solution: Livewire Form objects
- A Livewire Form object is an extra class that:
- contains public properties that represent the form input elements
- every property can have validation rules (just like in the component class we used before)
- contains methods to handle the form submission for the CRUD actions
- can send notifications to the browser
- A Form object has no separate view file, has no
render()
,mount()
, ... methods and is not a full-blown Livewire component! - The Form object class is just a nice way to keep your main component class clean by grouping all the form logic in a separate class, and it's a great way to reuse form logic across multiple components
Create a new Form object
- Create a form object class for the records with the command
php artisan livewire:form RecordForm
- This will create a new class file RecordForm.php in the folder App\Livewire\Forms
- Open the file and replace the content with the following code:
php
<?php
namespace App\Livewire\Forms;
use App\Models\Record;
use Livewire\Attributes\Validate;
use Livewire\Form;
class RecordForm extends Form
{
public $id = null;
#[Validate('required', as: 'name of the artist')]
public $artist = null;
#[Validate('required', as: 'title for this record')]
public $title = null;
#[Validate('required|size:36|unique:records,mb_id,id', as: 'MusicBrainz ID')]
public $mb_id = null;
#[Validate('required|numeric|min:0', as: 'stock')]
public $stock = null;
#[Validate('required|numeric|min:0', as: 'price')]
public $price = null;
#[Validate('required|exists:genres,id', as: 'genre')]
public $genre_id = null;
public $cover = '/storage/covers/no-cover.png';
// read the selected record
public function read($record)
{
$this->id = $record->id;
$this->artist = $record->artist;
$this->title = $record->title;
$this->mb_id = $record->mb_id;
$this->stock = $record->stock;
$this->price = $record->price;
$this->genre_id = $record->genre_id;
$this->cover = $record->cover;
}
// create a new record
public function create()
{
$this->validate();
Record::create([
'artist' => $this->artist,
'title' => $this->title,
'mb_id' => $this->mb_id,
'stock' => $this->stock,
'price' => $this->price,
'genre_id' => $this->genre_id,
]);
}
// update the selected record
public function update(Record $record) {
$this->validate();
$record->update([
'stock' => $this->stock,
'price' => $this->price,
'genre_id' => $this->genre_id,
]);
}
// delete the selected record
public function delete(Record $record)
{
$record->delete();
}
}
<?php
namespace App\Livewire\Forms;
use App\Models\Record;
use Livewire\Attributes\Validate;
use Livewire\Form;
class RecordForm extends Form
{
public $id = null;
#[Validate('required', as: 'name of the artist')]
public $artist = null;
#[Validate('required', as: 'title for this record')]
public $title = null;
#[Validate('required|size:36|unique:records,mb_id,id', as: 'MusicBrainz ID')]
public $mb_id = null;
#[Validate('required|numeric|min:0', as: 'stock')]
public $stock = null;
#[Validate('required|numeric|min:0', as: 'price')]
public $price = null;
#[Validate('required|exists:genres,id', as: 'genre')]
public $genre_id = null;
public $cover = '/storage/covers/no-cover.png';
// read the selected record
public function read($record)
{
$this->id = $record->id;
$this->artist = $record->artist;
$this->title = $record->title;
$this->mb_id = $record->mb_id;
$this->stock = $record->stock;
$this->price = $record->price;
$this->genre_id = $record->genre_id;
$this->cover = $record->cover;
}
// create a new record
public function create()
{
$this->validate();
Record::create([
'artist' => $this->artist,
'title' => $this->title,
'mb_id' => $this->mb_id,
'stock' => $this->stock,
'price' => $this->price,
'genre_id' => $this->genre_id,
]);
}
// update the selected record
public function update(Record $record) {
$this->validate();
$record->update([
'stock' => $this->stock,
'price' => $this->price,
'genre_id' => $this->genre_id,
]);
}
// delete the selected record
public function delete(Record $record)
{
$record->delete();
}
}
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
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
- The code in this component is straightforward and doesn't need much explanation.
- For the validation rules:
$artist
and$title
are required$mb_id
is required, must be 36 characters long and must be unique in therecords
table$stock
and$price
are required and must be numeric and greater than or equal to 0$genre_id
is required and must be an existing genre id in thegenres
table
Use the Form object in the component class
- Now that we have a form object, we can use it in our component class
- Add the following code to the component class app/Livewire/Admin/Records.php:
php
<?php
namespace App\Livewire\Admin;
use App\Livewire\Forms\RecordForm;
use App\Models\Record;
...
class Records extends Component
{
use WithPagination;
// filter and pagination
public $search;
public $noStock = false;
public $noCover = false;
public $perPage = 5;
// show/hide the modal
public $showModal = false;
public RecordForm $form;
...
}
<?php
namespace App\Livewire\Admin;
use App\Livewire\Forms\RecordForm;
use App\Models\Record;
...
class Records extends Component
{
use WithPagination;
// filter and pagination
public $search;
public $noStock = false;
public $noCover = false;
public $perPage = 5;
// show/hide the modal
public $showModal = false;
public RecordForm $form;
...
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
HOW TO USE THE FORM OBJECT
- With
public RecordForm $form;
, the public property$form
is of typeRecordForm
and contains all the properties and methods of theRecordForm
class - Now we can use the
$form
object in our component class and in the view with the$form
prefix - Us the
$form
properties in the view:$form->title
to echo the title of the recordwire:model="form.title"
to bind the input element to the$form->title
property@error('form.title')
to show the error message for the$form->title
property
- Use the
$form
methodes in the component:$this->form->create()
to create a new record$this->form->update($record)
to update the selected record$this->form->delete($record)
to delete the selected record
Create a new record
Find the MusicBrainz ID (mb_id) of the record
- Before we can add a new record, we need to find the unique MusicBrainz ID of a record
- Go to https://musicbrainz.org/
- Search, in the right top corner, an artist (e.g. The Doors)
- Click on the artist name
- Now click on one of the albums (e.g. L.A. Woman)
- You get a list of all the releases of this album. Click on one of them (be sure to choose for a release on vinyl!).
- The property mb_id in our records table is the code at the end of the URL (e.g.
e68f23df-61e3-4264-bfc3-17ac3a6f856b
)
Get the data from MusicBrainz API
- Now that we have the mb_id of the record, we can use the MusicBrainz API to extract the data from the record
- We need: the title of the record, the artist and the cover (if there is one)
These fields are not editable, except for the cover later in this course - We also need the price of the record, how many items are in stock and the genre this record belongs to
These fields are editable and don't have anything to do with the MusicBrainz API - Some examples on how the extract the data that we need from the JSON response:
- mb_id:
e68f23df-61e3-4264-bfc3-17ac3a6f856b
- API: https://musicbrainz.org/ws/2/release/e68f23df-61e3-4264-bfc3-17ac3a6f856b?inc=artists&fmt=json
$response->title
= L.A. Woman$response->artist-credit[0]->artist->name
= The Doors$response->cover-art-archive->front
= true- cover is available on https://coverartarchive.org/release/e68f23df-61e3-4264-bfc3-17ac3a6f856b/front-250.jpg
Update the modal
Our modal needs to have the following fields:
- mb_id (text input)
- title and artist (hidden inputs because they are not editable)
- genre (select input with all the genres)
- price and stock (number inputs)
Now we can populate the modal
- Add a new public method
getArtistRecord()
to theRecordForm
class - Line 20: try to fetch the data from the MusicBrainz API
- Line 21 - 29: the API call was successful:
- update the
artist
,title
andcover
properties with the data from the API - Line 27 we use the Intervention Image package to read and compress the original image
$originalCover = Image::make($this->cover)
will create a new image instance from the URL->encode('jpg', 75)
will encode the image to a JPG file with a quality of 75%
- Line 28: use Laravel's File Storage to save the image to the server
Storage::disk('public')
will save the image to thepublic
disk->put('covers/' . $this->mb_id . '.jpg', $originalCover)
will save the image to thecovers
folder with the namemb_id
value and the extension.jpg
- Line 30 - 35: the API call failed:
- reset the
artist
,title
andcover
properties to their original state - Line 34: create a custom error message for the
mb_id
field
- reset the
- update the
- Line 21 - 29: the API call was successful:
php
<?php
namespace App\Livewire\Forms;
use App\Models\Record;
use Http;
use Illuminate\Support\Facades\Storage;
use Image;
use Livewire\Attributes\Validate;
use Livewire\Form;
class RecordForm extends Form
{
...
// get artist, title and cover from the MusicBrainz API
public function getArtistRecord()
{
$response = Http::get('https://musicbrainz.org/ws/2/release/' . $this->mb_id . '?inc=artists&fmt=json');
if ($response->successful()) {
$data = $response->json();
$this->artist = $data['artist-credit'][0]['artist']['name'];
$this->title = $data['title'];
if ($data['cover-art-archive']['front']) {
$this->cover = 'https://coverartarchive.org/release/' . $this->mb_id . '/front-250.jpg';
$originalCover = Image::make($this->cover)->encode('jpg', 75);
Storage::disk('public')->put('covers/' . $this->mb_id . '.jpg', $originalCover);
}
} else {
$this->artist = null;
$this->title = null;
$this->cover = '/storage/covers/no-cover.png';
$this->addError('mb_id', 'MusicBrainz id not found');
}
}
}
<?php
namespace App\Livewire\Forms;
use App\Models\Record;
use Http;
use Illuminate\Support\Facades\Storage;
use Image;
use Livewire\Attributes\Validate;
use Livewire\Form;
class RecordForm extends Form
{
...
// get artist, title and cover from the MusicBrainz API
public function getArtistRecord()
{
$response = Http::get('https://musicbrainz.org/ws/2/release/' . $this->mb_id . '?inc=artists&fmt=json');
if ($response->successful()) {
$data = $response->json();
$this->artist = $data['artist-credit'][0]['artist']['name'];
$this->title = $data['title'];
if ($data['cover-art-archive']['front']) {
$this->cover = 'https://coverartarchive.org/release/' . $this->mb_id . '/front-250.jpg';
$originalCover = Image::make($this->cover)->encode('jpg', 75);
Storage::disk('public')->put('covers/' . $this->mb_id . '.jpg', $originalCover);
}
} else {
$this->artist = null;
$this->title = null;
$this->cover = '/storage/covers/no-cover.png';
$this->addError('mb_id', 'MusicBrainz id not found');
}
}
}
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
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
Test the modal and the validation rules
- Leave the MusicBrainz ID field empty and click on the Get Record info button
- The cover for the record with MusicBrainz ID
5a4d2ec4-ed6f-47d5-9352-c11917f61dc0
is available on http://vinyl_shop.test/storage/covers/5a4d2ec4-ed6f-47d5-9352-c11917f61dc0.jpg - And we find the record on the admin page, as well as on the shop page
Update a record
- All we have to do to edit a record is to set the properties of the
RecordForm
class to the values of the record we want to edit - This can be done by passing the record
id
to theupdate(Record $record)
method, and then we can also re-use the modal to update the record - We're only allowed to update the
genre_id
, thestock
and theprice
fields - The
mb_id
, thetitle
and theartist
fields are read-only (updating themb_id
would mean that we create a new record) - The
cover
field is also not editable for now, but we'll add this functionality later in this course
Enter edit mode
- Line 3: add a
wire:key
to thetr
element to make sure that each row has a unique key - Line 13: add a
wire:click
to the edit button to call theeditRecord()
method with the recordid
as parameter
php
@forelse($records as $record)
<tr
wire:key="{{ $record->id }}"
class="border-t border-gray-300">
<td>{{ $record->id }}</td>
<td> ... </td>
<td>{{ $record->price_euro }}</td>
<td>{{ $record->stock }}</td>
<td class="text-left"> ... </td>
<td>
<div class="border border-gray-300 rounded-md overflow-hidden m-2 grid grid-cols-2 h-10">
<button
wire:click="editRecord({{ $record->id }})"
class="text-gray-400 hover:text-sky-100 hover:bg-sky-500 transition border-r border-gray-300">
<x-phosphor-pencil-line-duotone class="inline-block w-5 h-5"/>
</button>
<button
class="text-gray-400 hover:text-red-100 hover:bg-red-500 transition">
<x-phosphor-trash-duotone class="inline-block w-5 h-5"/>
</button>
</div>
</td>
</tr>
@empty
...
@endforelse
@forelse($records as $record)
<tr
wire:key="{{ $record->id }}"
class="border-t border-gray-300">
<td>{{ $record->id }}</td>
<td> ... </td>
<td>{{ $record->price_euro }}</td>
<td>{{ $record->stock }}</td>
<td class="text-left"> ... </td>
<td>
<div class="border border-gray-300 rounded-md overflow-hidden m-2 grid grid-cols-2 h-10">
<button
wire:click="editRecord({{ $record->id }})"
class="text-gray-400 hover:text-sky-100 hover:bg-sky-500 transition border-r border-gray-300">
<x-phosphor-pencil-line-duotone class="inline-block w-5 h-5"/>
</button>
<button
class="text-gray-400 hover:text-red-100 hover:bg-red-500 transition">
<x-phosphor-trash-duotone class="inline-block w-5 h-5"/>
</button>
</div>
</td>
</tr>
@empty
...
@endforelse
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
Refactor the modal
- We can use e.g. the value of the
$form->id
property to determine if we're in edit mode or not$form->id
is empty: we're in create mode$form->id
is not empty: we're in edit mode
- Use the PHP
is_null($form->id)
function to determine if we're in edit mode or not - In edit mode
is_null($form->id)
isfalse
:- Line 4: change the title of the modal from New record to Edit record
- Line 11, 17 - 22: remove the Save new record button and add a Save changes button in edit mode
- The Save changes button calls the
updateRecord()
method and pass theid
of the record as parameter
- The Save changes button calls the
php
<x-dialog-modal id="recordModal"
wire:model.live="showModal">
<x-slot name="title">
<h2>{{ is_null($form->id) ? 'New record' : 'Edit record' }}</h2>
</x-slot>
<x-slot name="content">
...
</x-slot>
<x-slot name="footer">
<x-secondary-button @click="$wire.showModal = false">Cancel</x-secondary-button>
@if(is_null($form->id))
<x-tmk.form.button color="success"
disabled="{{ $form->title ? 'false' : 'true' }}"
wire:click="createRecord()"
class="ml-2">Save new record
</x-tmk.form.button>
@else
<x-tmk.form.button color="info"
wire:click="updateRecord({{ $form->id }})"
class="ml-2">Save changes
</x-tmk.form.button>
@endif
</x-slot>
</x-dialog-modal>
<x-dialog-modal id="recordModal"
wire:model.live="showModal">
<x-slot name="title">
<h2>{{ is_null($form->id) ? 'New record' : 'Edit record' }}</h2>
</x-slot>
<x-slot name="content">
...
</x-slot>
<x-slot name="footer">
<x-secondary-button @click="$wire.showModal = false">Cancel</x-secondary-button>
@if(is_null($form->id))
<x-tmk.form.button color="success"
disabled="{{ $form->title ? 'false' : 'true' }}"
wire:click="createRecord()"
class="ml-2">Save new record
</x-tmk.form.button>
@else
<x-tmk.form.button color="info"
wire:click="updateRecord({{ $form->id }})"
class="ml-2">Save changes
</x-tmk.form.button>
@endif
</x-slot>
</x-dialog-modal>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Update the record
- Line 7: use route model binding to get the record to update
(The values will first be validated by theupdate()
method in theRecordForm
class) - Line 8: hide the modal
- Line 9 - 13 show a success toast message
php
class Records extends Component
{
...
public function updateRecord(Record $record)
{
$this->form->update($record);
$this->showModal = false;
$this->dispatch('swal:toast', [
'background' => 'success',
'html' => "The record <b><i>{$this->form->title}</i></b> from <b><i>{$this->form->artist}</i></b> has been updated",
'icon' => 'success',
]);
}
...
}
class Records extends Component
{
...
public function updateRecord(Record $record)
{
$this->form->update($record);
$this->showModal = false;
$this->dispatch('swal:toast', [
'background' => 'success',
'html' => "The record <b><i>{$this->form->title}</i></b> from <b><i>{$this->form->artist}</i></b> has been updated",
'icon' => 'success',
]);
}
...
}
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
Fix the "unique" validation rule
- We need to change the validation rules for the
mb_id
field - This cannot be done with the
#[Validate()]
attribute because this attribute can only use static values, not dynamic values - We need to refactor the
#[Validate()]
attribute to therules()
method andas
to$validationAttributes
- Line 8: remove the (static)
#[Validate()]
attribute from themb_id
property - Line 19 - 24: add a new
rules()
method to theRecordForm
class- Line 22:
"required|size:36|unique:records,mb_id,{$this->id}"
will check:- the
mb_id
field isrequired
- the
mb_id
field has asize
of exact36
characters - the
mb_id
value isunique
in therecords
table, except for the record with his ownid
- the
- Line 22:
- Line 27 - 29: add a new
$validationAttributes
property to theRecordForm
class- This property is used to replace the attribute name in the error message
'mb_id' => 'MusicBrainz ID'
will replace the attribute namemb_id
withMusicBrainz ID
in the error message
(#[Validate()]
usesas
whererules()
uses$validationAttributes
to define the attribute name)
php
class RecordForm extends Form
{
public $id = null;
#[Validate('required', as: 'name of the artist')]
public $artist = null;
#[Validate('required', as: 'title for this record')]
public $title = null;
// #[Validate('required|size:36|unique:records,mb_id,id', as: 'MusicBrainz ID')]
public $mb_id = null;
#[Validate('required|numeric|min:0', as: 'stock')]
public $stock = null;
#[Validate('required|numeric|min:0', as: 'price')]
public $price = null;
#[Validate('required|exists:genres,id', as: 'genre')]
public $genre_id = null;
public $cover = '/storage/covers/no-cover.png';
// special validation rule for mb_id (unique:records,mb_id,id) for insert and update!
public function rules()
{
return [
'mb_id' => "required|size:36|unique:records,mb_id,{$this->id}",
];
}
// $validationAttributes is used to replace the attribute name in the error message
protected $validationAttributes = [
'mb_id' => 'MusicBrainz ID',
];
...
}
class RecordForm extends Form
{
public $id = null;
#[Validate('required', as: 'name of the artist')]
public $artist = null;
#[Validate('required', as: 'title for this record')]
public $title = null;
// #[Validate('required|size:36|unique:records,mb_id,id', as: 'MusicBrainz ID')]
public $mb_id = null;
#[Validate('required|numeric|min:0', as: 'stock')]
public $stock = null;
#[Validate('required|numeric|min:0', as: 'price')]
public $price = null;
#[Validate('required|exists:genres,id', as: 'genre')]
public $genre_id = null;
public $cover = '/storage/covers/no-cover.png';
// special validation rule for mb_id (unique:records,mb_id,id) for insert and update!
public function rules()
{
return [
'mb_id' => "required|size:36|unique:records,mb_id,{$this->id}",
];
}
// $validationAttributes is used to replace the attribute name in the error message
protected $validationAttributes = [
'mb_id' => 'MusicBrainz ID',
];
...
}
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
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
Update the new record script
- With the new validation rules, we have another, unexpected problem:
- Click on the New record button
- Try a MusicBrainz ID that already exists in the database (e.g.
7bec22a0-eb73-4b79-a619-b253d5c2af87
) - There is no validation error anymore!
- One important thing to remember about validation in form objects is:
#[Validate()]
attributes are automatically applied when the property changes- but for the
rules()
method, we need to call it manually!
- Let's fix this in the
getDataFromMusicbrainzApi()
method
- Line 3: validate only the
mb_id
field inside theform
before calling thegetArtistRecord()
method
php
public function getDataFromMusicbrainzApi()
{
$this->validateOnly('form.mb_id');
$this->form->getArtistRecord();
}
public function getDataFromMusicbrainzApi()
{
$this->validateOnly('form.mb_id');
$this->form->getArtistRecord();
}
1
2
3
4
5
2
3
4
5
Delete a record
- Line 19: use the Livewire
wire:confirm
function to ask the user if he really wants to delete the record - Line 18: delete the record when the user clicks on the OK button
php
@forelse($records as $record)
<tr
wire:key="record_{{ $record->id }}"
class="border-t border-gray-300">
<td>{{ $record->id }}</td>
<td> ... </td>
<td>{{ $record->price_euro }}</td>
<td>{{ $record->stock }}</td>
<td class="text-left"> ... </td>
<td>
<div class="border border-gray-300 rounded-md overflow-hidden m-2 grid grid-cols-2 h-10">
<button
wire:click="editRecord({{ $record->id }})"
class="text-gray-400 hover:text-sky-100 hover:bg-sky-500 transition border-r border-gray-300">
<x-phosphor-pencil-line-duotone class="inline-block w-5 h-5"/>
</button>
<button
wire:click="deleteRecord({{ $record->id }})"
wire:confirm="Are you sure you want to delete this record?"
class="text-gray-400 hover:text-red-100 hover:bg-red-500 transition">
<x-phosphor-trash-duotone class="inline-block w-5 h-5"/>
</button>
</div>
</td>
</tr>
@empty
...
@endforelse
@forelse($records as $record)
<tr
wire:key="record_{{ $record->id }}"
class="border-t border-gray-300">
<td>{{ $record->id }}</td>
<td> ... </td>
<td>{{ $record->price_euro }}</td>
<td>{{ $record->stock }}</td>
<td class="text-left"> ... </td>
<td>
<div class="border border-gray-300 rounded-md overflow-hidden m-2 grid grid-cols-2 h-10">
<button
wire:click="editRecord({{ $record->id }})"
class="text-gray-400 hover:text-sky-100 hover:bg-sky-500 transition border-r border-gray-300">
<x-phosphor-pencil-line-duotone class="inline-block w-5 h-5"/>
</button>
<button
wire:click="deleteRecord({{ $record->id }})"
wire:confirm="Are you sure you want to delete this record?"
class="text-gray-400 hover:text-red-100 hover:bg-red-500 transition">
<x-phosphor-trash-duotone class="inline-block w-5 h-5"/>
</button>
</div>
</td>
</tr>
@empty
...
@endforelse
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
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
EXERCISES:
1: Background color
- Give all the records that are out of stock a red background color
2: Delete the cover image from the server
- Update the
delete()
method inside theRecordForm
class so that the cover image is also deleted from the server
- Add a new record with a cover image (e.g mb_id =
c0afd87f-2f90-4c4d-b69d-ec150660fa5a
) - Open the cover in a new browser tab: http://vinyl_shop.test/storage/covers/c0afd87f-2f90-4c4d-b69d-ec150660fa5a.jpg
3: Jetstream confirmation modal
- Jetstream has actually two modal components:
x-confirmation-modal
andx-dialog-modal
(see resources/views/components/) - Examine the code for the confirmation modal and try to use them to confirm the deletion of a record
- TIPS:
- add a new property to toggle the modal
- add a new method to update some values in the
$form
class, so they can be used in the modal
3: Clear the search field
- Placed a little X over the input field to clear the search field
- The X is only visible when search field in not empty
- Tip: see Shop component for an example