Appearance
Admin: Managing Records
This chapter guides you through creating a administrative interface for managing your vinyl shop's records. While we previously touched upon a basic record listing in the "Eloquent Models" section, that was primarily to demonstrate database interactions. Here, we'll build a fully functional CRUD (Create, Read, Update, Delete) system for records, leveraging the power and elegance of Livewire 3 and its integrated form objects.
Livewire Form objects are a significant enhancement for handling complex forms, offering a structured way to manage form state, validation, and submission logic separately from your main Livewire component. This approach keeps your components lean and focused on application flow, while abstracting form-specific concerns into dedicated, reusable classes.
REMARK
Remember that you can always reset the database to its initial state by running the command:
bash
php artisan migrate:fresh --seed
1
Preparation
Before diving into the CRUD logic, we need to set up the basic structure for our record management page. This involves creating the Livewire component, defining a route to access it, linking it from the admin navigation, and scaffolding the initial view.
Create the Records Component
We'll encapsulate the record management logic and presentation within a dedicated Livewire component. To keep our admin features organized, we'll place this component within an Admin
subdirectory.
- Open your terminal in the project directory.
- Execute the following Artisan command to generate a new Livewire component named
Admin/Records
:
bash
php artisan livewire:make Admin/Records
1
This command conveniently creates two essential files for us:
app/Livewire/Admin/Records.php
: The component's PHP class, acting as the controller for our admin page. It starts with arender()
method responsible for returning the view.resources/views/livewire/admin/records.blade.php
: The component's Blade view file, where we'll define the HTML structure for displaying and interacting with records.
Update the Route
To make our new admin page accessible via a URL, we need to define a route in Laravel's routing system. This route will map a specific URL path within the /admin
group to our Admin\Records
Livewire component. Previously, this route pointed to a Demo
class, which was a temporary placeholder. Now, we'll update it to point to our new Records
Livewire component.
- Open the
routes/web.php
file. - Locate the route definition that maps the
/admin/records
URL path.- Replace
Demo::class
withRecords::class
and importuse App\Livewire\Admin\Records;
at the top of the file.
Ensure the route nameadmin.records
remains assigned for easy URL generation.
- Replace
php
Route::middleware(['auth', ActiveUser::class, Admin::class])->prefix('admin')->group(function () {
Route::redirect('/', '/admin/records');
Route::get('records', Records::class)->name('admin.records'); // Replace Demo::class with Records::class
Route::get('genres', Genres::class)->name('admin.genres');
Route::view('download_covers', 'admin.download_covers')->name('download_covers');
});
1
2
3
4
5
6
2
3
4
5
6
Now, let's link this new route from the admin section of our main navigation bar, ensuring administrators can easily navigate to the record management page.
- Open the
resources/views/components/layouts/vinylshop/navbar.blade.php
file. - Locate the
<flux:navlist.item>
for the "Records" link within the@auth
block. - Update the
href
attribute to dynamically generate the URL using the route name:{{ route('admin.records') }}
.
php
@auth
<flux:separator variant="subtle"/>
<flux:navlist.group expandable heading="Admin">
<flux:navlist.item href="{{ route('admin.genres') }}">Genres</flux:navlist.item>
<flux:navlist.item href="{{ route('admin.records') }}">Records</flux:navlist.item>
{{-- Other admin links --}}
</flux:navlist.group>
@endauth
1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
Basic Scaffolding for the View
With the component and route ready, let's set up the initial structure for the records.blade.php
view. We'll define the page title and description using named slots, add filtering controls, a "New Record" button, and set up the basic table structure where records will be listed. We'll also define the initial public properties in the Records
component class that will be bound to these view elements.
- app/Livewire/Admin/Records.php:
- Add public properties to control filtering (
$filter
,$noStock
,$noCover
) and pagination ($perPage
). Initialize them with default values.
- Add public properties to control filtering (
- resources/views/livewire/admin/records.blade.php:
- Add
<x-slot>
directives for thetitle
anddescription
. - Use a container (
@container
) and a grid layout to arrange filter inputs and buttons. - Include a
<flux:input>
for text filtering, bound to$filter
with live debounce for performance. - Add two
<flux:switch>
components for toggling "No stock" and "No cover" filters, bound to$noStock
and$noCover
respectively with live updates. - Include a
<flux:button>
for "New Record". - Use the custom
<x-itf.table>
component to define the table structure with placeholder headers for ID, Cover, Price, Stock, and Record details. - Include a
<flux:select>
within a table header for "Records per page", bound to$perPage
. - Add a placeholder table row (
<tr>
) to visualize the structure and aflux:button.group
for actions Edit, Cover, Delete.
(The cover button will be implemented later in this course, so for now, it can be a placeholder.) - Include the
<x-itf.livewire-log/>
component at the end for debugging.
- Add
php
<?php
namespace App\Livewire\Admin;
use Livewire\Component;
class Records extends Component
{
// filter and pagination
public $filter;
public $noStock = false;
public $noCover = false;
public $perPage = 5;
public function render()
{
return view('livewire.admin.records');
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Read All Records
The first step in managing records is displaying the existing ones. We'll modify our Livewire component to fetch record data from the database, apply pagination, and pass it to the view, which will then render it in the table. We'll also start integrating our filtering capabilities.
Show All Records in the Table
We'll query the Record
model in the render()
method, apply default sorting, and paginate the results before passing them to the Blade view. The view will then loop through this data to build the table rows, displaying information like cover, price, stock, artist, title, and genre.
- app/Livewire/Admin/Records.php:
- Import the
App\Models\Record
andLivewire\WithPagination
classes. - Include the
WithPagination
trait within the class definition. - In the
render()
method, query theRecord
model, order byartist
and thentitle
. - Use
->paginate($this->perPage)
to fetch paginated results based on the$perPage
property. - Pass the fetched
$records
collection to the view usingcompact('records')
.
- Import the
- resources/views/livewire/admin/records.blade.php:
- Add
$records->links()
before and potentially after the table to display pagination navigation. - Inside the
<tbody>
tag, replace the placeholder<tr>
with a Blade@forelse($records as $record)
loop. The@forelse
directive is useful here as it allows an@empty
block to be rendered if the$records
collection is empty. - Add the
wire:key="{{ $record->id }}"
directive to the<tr>
element inside the loop. This is crucial for Livewire's DOM diffing and updating mechanism, ensuring efficient updates when the data changes. - Populate the table cells (
<td>
) with dynamic data from the$record
object:$record->id
,$record->cover
,$record->price
(formatted withNumber::currency
),$record->stock
,$record->artist
,$record->title
, and$record->genre_name
. - Pass the
$records
collection to the debugging component:<x-itf.livewire-log :records="$records" />
. - Add a conditional
@if($perPage >= 10)
for the bottom pagination links to only show them if there are enough records per page to warrant it (e.g., to avoid duplicate pagination if all records fit on one page).
- Add
php
<?php
namespace App\Livewire\Admin;
use App\Models\Record;
use Livewire\Component;
use Livewire\WithPagination;
class Records extends Component
{
use WithPagination;
// filter and pagination
public $filter;
public $noStock = false;
public $noCover = false;
public $perPage = 5;
public function render()
{
$records = Record::orderBy('artist')
->orderBy('title');
$records = $records->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
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
$records query in two steps?
In the next steps, we need some conditional logic (if-statements) before we can paginate the $records
query. That's why we need to split the query into multiple steps: first, we filter the records, then comes the if-statements, and finally we paginate the results.
Filter by Artist or Title
We'll integrate the text filter ($this->filter
) into our Eloquent query. As discussed in the "Shop: Master Section" chapter, it's best practice to encapsulate such reusable filter logic in a Query Scope within the model itself. This keeps our Livewire component's render()
method clean and readable.
The searchTitleOrArtist
scope, which checks both title
and artist
fields for the provided search term, is already defined in our App\Models\Record
model from the Shop: Filter chapter. We simply need to apply it here.
- app/Livewire/Admin/Records.php:
- Inside the
render()
method, chain thesearchTitleOrArtist($this->filter)
scope onto theRecord
query builder. This will dynamically filter the records as the user types into the search input.
- Inside the
php
public function render()
{
// filter by artist or title
$records = Record::orderBy('artist')
->orderBy('title')
->searchTitleOrArtist($this->filter);
// paginate the $records
$records = $records->paginate($this->perPage);
return view('livewire.admin.records', compact('records'));
}
1
2
3
4
5
6
7
8
9
10
2
3
4
5
6
7
8
9
10
Filter by Records That Are Out of Stock
Adding a filter for records that are out of stock is straightforward. We simply add a where
clause to our query, conditional on the $noStock
public property being true
.
- app/Livewire/Admin/Records.php:
- Inside the
render()
method, after applying thesearchTitleOrArtist
scope, add anif ($this->noStock)
condition.- If
true
, chain a->where('stock', false)
clause (or->where('stock', 0)
) to the query builder. - If
false
, the query will not filter out any records based on stock status.
- If
- Inside the
php
public function render()
{
// Filter by artist or title
$records = Record::orderBy('artist')
->orderBy('title')
->searchTitleOrArtist($this->filter);
// Filter by records that are out of stock
if ($this->noStock) {
$records = $records->where('stock', false);
}
// Paginate the records
$records = $records->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 with No Cover
Filtering by records with no cover presents a unique challenge: the cover
attribute is a "generated" accessor on the Record
model, meaning it's computed after the records are fetched from the database, not a direct database column. Therefore, we cannot use a simple where
clause on the cover
column. Instead, we need a custom query scope that checks the existence of the cover image file on the storage disk.
We'll define a scopeCoverExists
method in the Record
model that inspects the filesystem.
- app/Models/Record.php:
- Add the
scopeCoverExists
method. This method takes a boolean$exists
argument. - It uses
Storage::disk('public')->exists('covers/' . $mb_id . '.jpg')
to check if the cover image file exists for each record'smb_id
. - It collects the
mb_id
s that either have a cover or don't have a cover, and then useswhereIn
orwhereNotIn
to filter the records based on thesemb_id
s.
- Add the
php
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Storage; // Make sure to import Storage facade
class Record extends Model
{
use HasFactory;
// Other model properties and methods...
public function scopeSearchTitleOrArtist($query, $search = '') { /* ... */ }
public function scopeCoverExists($query, $exists = true)
{
// Get all record IDs that have covers based on file existence
$mb_ids_with_covers = $query->pluck('mb_id')->filter(function ($mb_id) {
return Storage::disk('public')->exists('covers/' . $mb_id . '.jpg');
})->values()->all();
// If $exists is true, we want records WITH covers (where mb_id is in $mb_ids_with_covers)
// If $exists is false, we want records WITHOUT covers (where mb_id is NOT in $mb_ids_with_covers)
$method = $exists ? 'whereIn' : 'whereNotIn';
return $query->$method('mb_id', $mb_ids_with_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
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
REMARK
After adding a new scope to your model, it's highly recommended to run the menu Laravel > Generate Helper Code to update your IDE's auto-completion and type-hinting capabilities.
- app/Livewire/Admin/Records.php:
- Inside the
render()
method, after thenoStock
filter, add anif ($this->noCover)
condition.- If
true
, chain->coverExists(false)
to the query builder to filter for records that do not have a cover image file. - If
false
, the query will not filter out any records based on cover existence.
- If
- Inside the
php
public function render()
{
// Filter by artist or title
$records = Record::orderBy('artist')
->orderBy('title')
->searchTitleOrArtist($this->filter);
// Filter by records that are out of stock
if ($this->noStock) {
$records = $records->where('stock', false);
}
// Filter by records that have no cover
if ($this->noCover) {
$records = $records->coverExists(false);
}
// Paginate the records
$records = $records->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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Reset Pagination After Filter
When users interact with filters or change the number of items per page, it's a good practice to reset the pagination back to the first page. This prevents the user from being left on an empty page if the filtering significantly reduces the result set. Livewire's updated()
lifecycle hook is perfect for this, as it fires whenever a public property bound via wire:model
changes.
(We also discussed this in the Shop: Filter Section chapter.)
- app/Livewire/Admin/Records.php:
- Add the
public function updated($propertyName, $propertyValue)
method to theRecords
component class. - Inside this method, check if the
$propertyName
is one of the properties that should trigger a pagination reset (filter
,noCover
,noStock
,perPage
). - If it is, call
$this->resetPage()
, Livewire's built-in method to reset the paginator.
php
<?php
namespace App\Livewire\Admin;
use App\Models\Record;
use Livewire\Component;
use Livewire\WithPagination;
class Records extends Component
{
use WithPagination;
// filter and pagination
public $filter;
public $noStock = false;
public $noCover = false;
public $perPage = 5;
// reset the paginator
public function updated($propertyName, $propertyValue)
{
// reset if the $search, $noCover, $noStock or $perPage property has changed (updated)
if (in_array($propertyName, ['filter', 'noCover', 'noStock', 'perPage']))
$this->resetPage();
}
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
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
Introduction to Livewire Form Objects
In previous chapters, we managed form input elements directly as public properties within the Livewire component class itself. While this approach is perfectly adequate for simpler forms, it can lead to cluttered component classes as forms grow in complexity, with numerous properties and validation rules.
For more sophisticated forms, Livewire 3 introduces a powerful feature: Livewire Form objects . A Livewire Form object is a dedicated class designed to encapsulate all logic related to a specific form, including:
- Public properties: These represent the form's input elements, much like regular Livewire component properties.
- Validation rules: Each property can have its validation rules defined directly within the form object, either using Livewire's
#[Validate]
attributes or arules()
method. - Submission methods: Methods like
create()
orupdate()
can be defined within the form object to handle the logic of persisting data, often including calling$this->validate()
. (Read and delete operations typically don't require validation and are usually handled directly by the main component or other service classes). - Notifications: Form objects can dispatch events or use traits (like
NotificationsTrait
) to send feedback, such as toast messages, to the user.
Why Use a Form Object?
The primary advantage of a Form object is improved code organization and reusability. It helps keep your main component class clean and focused on managing the overall page state and user interactions, while all form-specific concerns are neatly grouped in a separate, testable class. This separation of concerns leads to more maintainable and scalable applications.
It's important to note that a Form object is not a full-blown Livewire component. It doesn't have its own view file, nor does it possess lifecycle methods like render()
, mount()
, or boot()
. It serves as a specialized helper class that is instantiated and managed by a Livewire component.
Create a New Form Object
Let's create a dedicated Form object for our record management.
- Create a form object class for the records with the Artisan command
php artisan livewire:form RecordForm
. - This will create a new class file
app/Livewire/Forms/RecordForm.php
. - Open the file and replace its content with the following code. This
RecordForm
class defines the properties that will correspond to our record input fields, their validation rules, and methods for creating and updating records.
php
<?php
namespace App\Livewire\Forms;
use App\Models\Record;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Laravel\Facades\Image;
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')] // Unique validation will be handled dynamically later
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';
// 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::findOrFail($this->id);
$this->validate();
$record->update([
'stock' => $this->stock,
'price' => $this->price,
'genre_id' => $this->genre_id,
]);
}
// Placeholder for MusicBrainz API fetch - will be added later
public function fetchRecord() { /* ... */ }
private function fetchAndStoreCover($mbId) { /* ... */ }
}
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
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
The code in this Form object defines the structure of our record data and the basic create
and update
logic.
- Properties: Each public property (
$id
,$artist
,$title
, etc.) will hold the data from the form inputs. #[Validate]
Attributes: These attributes define the validation rules for each property. For instance:$artist
and$title
arerequired
.$mb_id
isrequired
and must be exactly36
characters long and must beunique
.$stock
and$price
arerequired
,numeric
, and must be at least0
.$genre_id
isrequired
and must correspond to anid
thatexists
in thegenres
table.create()
method: This method performs validation on all properties (implicitly defined by#[Validate]
) and then creates a newRecord
entry in the database.update()
method: This method finds the record by$this->id
, performs validation, and then updates thestock
,price
, andgenre_id
fields. Theartist
,title
, andmb_id
fields are typically not updated through this form as they come from MusicBrainz and uniquely identify the record.
Use the Form Object in the Component Class
With the RecordForm
object created, we can now integrate it into our Records
Livewire component. This allows the component to access and manage the form's state and logic.
- Add the following code to the component class
app/Livewire/Admin/Records.php
:- Import the
App\Livewire\Forms\RecordForm
class. - Declare a public property
public RecordForm $form;
. Livewire will automatically instantiate this form object for you.
- Import the
php
<?php
namespace App\Livewire\Admin;
use App\Livewire\Forms\RecordForm;
use App\Models\Record;
use Livewire\Component;
use Livewire\WithPagination;
class Records extends Component
{
use WithPagination;
// filter and pagination
public $filter;
public $noStock = false;
public $noCover = false;
public $perPage = 5;
public RecordForm $form;
public function updated($propertyName, $propertyValue) { /* ... */ }
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
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
Once you declare public RecordForm $form;
in your Livewire component, the public property $form
becomes an instance of RecordForm
, granting you access to all its properties and methods.
- In the Blade View:
- To display a form property's value, use
$form->propertyName
, e.g.,<input value="{{ $form->title }}">
. - To bind an input element to a form property for two-way data binding, use
wire:model="form.propertyName"
, e.g.,<flux:input wire:model="form.title" />
. - To show validation error messages for a form property, use
@error('form.propertyName')
, e.g.,@error('form.title') <span class="text-red-500">{{ $message }}</span> @enderror
.
(FluxUI components automatically display errors by default, so you might not need explicit@error
blocks for them).
- To display a form property's value, use
- In the Livewire Component Class:
- To access a form property, use
$this->form->propertyName
, e.g.,$this->form->artist
. - To call a method defined in the form object, use
$this->form->methodName()
, e.g.,$this->form->create()
.
- To access a form property, use
Create a New Record
Creating a new record involves several steps: retrieving unique record identifiers from MusicBrainz, fetching associated data, handling cover images, and finally, presenting a form for users to input price, stock, and genre before saving.
Find the MusicBrainz ID (mb_id) of the Record
Every record in our database is linked to a unique MusicBrainz ID (mb_id
). This ID is crucial for consistently identifying records and fetching their canonical information (artist, title, cover art) from the MusicBrainz database. Before adding a new record, you'll need to find its mb_id
.
- Go to the MusicBrainz website.
- Use the search bar in the top right corner to look for an artist (e.g., The Doors).
- Click on the artist name in the search results.
- Now, navigate to one of their albums (e.g., L.A. Woman).
- You'll see a list of releases for that album. It's important to select a release that corresponds to a vinyl format, as our shop primarily deals with vinyl.
- Once on the specific release page, the
mb_id
is the unique code at the end of the URL. For example, for "The Doors - L.A. Woman", it might bee68f23df-61e3-4264-bfc3-17ac3a6f856b
. This is the value you'll enter into our form.
Get the Data from MusicBrainz API
With the mb_id
, we can use the MusicBrainz API and the Cover Art Archive API to programmatically fetch key details of the record.
We need:
title
of the record.artist
name.cover
image (if available).
These fields are typically read-only in our admin interface because they represent canonical data. We also need to input:
price
for the record.stock
quantity.genre
this record belongs to.
These fields are editable and managed by our application, unrelated to the MusicBrainz API.
Below are examples of how to access the required data from the JSON responses when querying the MusicBrainz API for a release using the inc=artists&fmt=json
parameters (to include artist information and get JSON output).
- mb_id:
e68f23df-61e3-4264-bfc3-17ac3a6f856b
- API Endpoint: 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- If
true
, cover is available at:
https://coverartarchive.org/release/e68f23df-61e3-4264-bfc3-17ac3a6f856b/front-250.jpg
- If
Add a Modal
We'll implement a modal form for creating new records. This modal will include:
- An input for the MusicBrainz ID (
mb_id
). This field will only be visible when creating a new record. - Hidden inputs for
title
andartist
(their values come from MusicBrainz API). - A select input for
genre
, populated with all available genres. - Number inputs for
price
andstock
. - A preview of the
cover
image.
Now, let's create and populate the modal.
- app/Livewire/Forms/RecordForm.php:
- Import
Http
,Storage
, andImage
facades. - Add a
fetchRecord()
method to handle the API call to MusicBrainz, parse the JSON response, populate$artist
,$title
, and call a helper to fetch the cover if available. - Add a private
fetchAndStoreCover($mbId)
method to download the cover image from Cover Art Archive and save it topublic/storage/covers
. This method uses the Intervention/Image library to process the image (e.g., convert to JPEG and apply quality).
- Import
- app/Livewire/Admin/Records.php:
- Import
App\Models\Genre
andApp\Traits\NotificationsTrait
. - Include
NotificationsTrait
in the class. - Add a public property
$showModal = false;
to control the modal's visibility. - Implement
resetValues()
to clear the form and validation errors. - Implement
newRecord()
to open the modal and reset the form for a new entry. - Implement
getDataFromMusicbrainzApi()
to triggerRecordForm::$this->form->fetchRecord()
. - Implement
createRecord()
to call$this->form->create()
, close the modal, and show a success toast. - In the
render()
method, fetch all genres and pass them to the view for the genre dropdown.
- Import
- resources/views/livewire/admin/records.blade.php:
- Add a
wire:click="newRecord()"
to the "New Record" button. - Include a
<flux:modal>
component, controlled bywire:model.self="showModal"
. Usevariant="flyout"
(or remove it for a centered modal). - Inside the modal:
- Use
flux:heading
with a conditional ternary operator ($form->id ? 'Edit Record' : 'New Record'
) to display the correct title. - Conditionally display the
mb_id
input and "Get Record Info" button (@if(!$form->id)
) only when creating a new record. Bind the input towire:model="form.mb_id"
and the button towire:click="getDataFromMusicbrainzApi()"
. - Display the
artist
,title
, andmb_id
(if present) usingflux:heading
andflux:text
. - Add
flux:select
forgenre_id
(bound towire:model.live="form.genre_id"
), populating options from the$genres
collection passed from the component. - Add
flux:input
forprice
andstock
, bound towire:model.live.debounce.500ms="form.price"
andwire:model.live.debounce.500ms="form.stock"
. - Display the cover image preview using
src="{{ $form->cover }}"
. - Add "Cancel" and "Save" buttons. The "Save" button's
wire:click
will dynamically callupdateRecord()
orcreateRecord()
based on$form->id
. - Update the
x-itf.livewire-log
component to also pass$genres
for debugging.
- Use
- Add a
php
<?php
namespace App\Livewire\Forms;
use App\Models\Record;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Laravel\Facades\Image;
use Livewire\Attributes\Validate;
use Livewire\Form;
class RecordForm extends Form
{
/* ... */
// create a new record
public function create() { /* ... */ }
// update the selected record
public function update() { /* ... */ }
// get artist, title and cover from the MusicBrainz API
public function fetchRecord()
{
$this->resetErrorBag();
$this->validateOnly('mb_id');
$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 (isset($data['cover-art-archive']['front']) && $data['cover-art-archive']['front']) { // Added isset check for robustness
$this->fetchAndStoreCover($this->mb_id);
} else {
$this->cover = '/storage/covers/no-cover.png';
}
} else {
$this->artist = null;
$this->title = null;
$this->cover = '/storage/covers/no-cover.png';
// Optionally, add a notification for API failure
// $this->toastError('Failed to fetch data from MusicBrainz API.');
}
}
// fetch the cover from coverartarchive.org and store it in the public storage folder
private function fetchAndStoreCover($mbId)
{
try {
$imageUrl = "https://coverartarchive.org/release/{$mbId}/front-250.jpg";
$response = Http::timeout(10)->get($imageUrl);
if ($response->successful()) {
$jpgImage = Image::read($response->body())->toJpeg(75);
Storage::disk('public')->put("covers/{$mbId}.jpg", $jpgImage);
$this->cover = "/storage/covers/{$mbId}.jpg";
return;
}
} catch (\Exception $e) {
// Silent catch - will use default cover
// You might log the error for debugging: Log::error("Failed to fetch cover for $mbId: " . $e->getMessage());
}
$this->cover = '/storage/covers/no-cover.png';
}
}
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
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
Test the Modal
Let's test the new record creation process with the modal.
If you click "Save" with empty fields (or before fetching MusicBrainz data), the validation rules defined in RecordForm
will trigger, and error messages will appear.
The cover for the record with MusicBrainz ID 5a4d2ec4-ed6f-47d5-9352-c11917f61dc0
should now be downloaded to your public/storage/covers
directory and accessible via the URL: https://vinyl_shop.test/storage/covers/5a4d2ec4-ed6f-47d5-9352-c11917f61dc0.jpg. You should also see this new record on both the admin management page and the public shop page.
Update a Record
Updating an existing record involves pre-filling our modal form with the record's current data and then allowing the user to modify specific fields. We'll re-use the same modal we built for creating records. We're only allowing updates to the genre_id
, stock
, and price
fields. The mb_id
, title
, and artist
are considered canonical and remain read-only. The cover
field will also remain read-only for now, with dedicated cover management functionality to be added later.
Enter Edit Mode
To enter edit mode, we'll configure the "Edit" button to populate the RecordForm
with the selected record's data and then open the modal.
- resources/views/livewire/admin/records.blade.php:
- Locate the "Edit" button (
<flux:button icon="pencil-square">
) inside the<tbody>
loop. - Add a
wire:click
directive to call theeditRecord
method, passing the current record's ID:wire:click="editRecord({{ $record->id }})"
.
- Locate the "Edit" button (
- app/Livewire/Admin/Records.php:
- Add a public method
editRecord(Record $record)
. This method will receive theRecord
model instance via Laravel's Route Model Binding. - Inside
editRecord()
, call$this->resetValues()
to clear any previous form state or errors. - Then, use
$this->form->fill($record);
. This powerful Livewire Form method automatically fills all matching public properties in theRecordForm
instance with the corresponding attributes from the$record
model. - Finally, set
$this->showModal = true;
to display the modal. - Also, implement the
updateRecord()
method. This method will be called when the user clicks the "Save" button in the modal if anid
is present in the form (indicating an edit operation). It calls$this->form->update()
, closes the modal, and shows an informative toast notification.
- Add a public method
php
<flux:button.group>
<flux:button
wire:click="editRecord({{ $record->id }})"
tooltip="Edit"
icon="pencil-square"/>
<flux:button
tooltip="Cover"
icon="photo"/>
<flux:button
tooltip="Delete"
icon="trash"/>
</flux:button.group>
1
2
3
4
5
6
7
8
9
10
11
12
2
3
4
5
6
7
8
9
10
11
12
Fix the "unique" Validation Rule
The unique
validation rule needs to be conditional: it should ensure mb_id
is unique when creating a new record, but allow the mb_id
to remain the same for the record being updated. The #[Validate]
attribute, while convenient, can only use static values for its rules. For dynamic rules, especially those depending on the id
of the current model, we need to use the rules()
method within the Form object.
We also need to define custom validation attribute names if we want more user-friendly messages than the default property names (e.g., "MusicBrainz ID" instead of "mb id"). This is done using the $validationAttributes
property.
- app/Livewire/Forms/RecordForm.php:
- Remove the
#[Validate]
attribute from the$mb_id
property. - Add a
public function rules()
method to theRecordForm
class. This method should return an array where keys are property names and values are their validation rules. - Inside
rules()
, define themb_id
rule using Laravel's validation rules. Theunique
rule accepts a third parameter to specify an ID to ignore:unique:table,column,id
. We will useunique:records,mb_id,{$this->id}
. The$this->id
will correctly refer to theid
of the record currently being updated (ornull
if creating a new one). - Add a
protected $validationAttributes
property to provide a user-friendly name formb_id
in validation messages.
- Remove the
php
<?php
namespace App\Livewire\Forms;
use App\Models\Record;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Storage;
use Intervention\Image\Laravel\Facades\Image;
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';
// 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',
];
// 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::findOrFail($this->id);
$this->validate(); // Validation now includes the dynamic unique rule
$record->update([
'stock' => $this->stock,
'price' => $this->price,
'genre_id' => $this->genre_id,
]);
}
public function fetchRecord() { /* ... */ }
private function fetchAndStoreCover($mbId) { /* ... */ }
}
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
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
Delete a Record
Finally, administrators need the ability to remove records. Since deleting data is irreversible , it's crucial to ask for confirmation before proceeding. We'll implement a two-step deletion process using the confirmation dialog provided by our NotificationsTrait
, similar to how genres are deleted.
- Initiate Confirmation: Clicking the "Delete" button (trash icon) will trigger a confirmation dialog asking the user if they are sure.
- Perform Deletion (if confirmed): If the user confirms the action in the dialog, a separate method will be called to actually delete the record from the database.
- resources/views/livewire/admin/records.blade.php:
- Locate the "Delete" button (
<flux:button icon="trash">
) inside the<tbody>
loop. - Add a
wire:click
directive to call thedeleteConfirm
method, passing the current record's ID:wire:click="deleteConfirm({{ $record->id }})"
. - Add a descriptive tooltip, e.g.,
tooltip="Delete {{ $record->title }} by {{ $record->artist }}"
.
- Locate the "Delete" button (
- app/Livewire/Admin/Records.php:
- Add a public method
deleteConfirm(Record $record)
. This method is called when the trash icon is clicked.- It uses
$this->confirm()
(from theNotificationsTrait
) to display the modal dialog. - Provide a descriptive confirmation message, including the record's title.
- Configure the
next
key in the options array to dispatch a Livewire event nameddelete-record
if confirmed, passing the record's ID as a parameter ('record' => $record->id
). - You can also customize the dialog's heading, confirm text, and styling.
- It uses
- Add another public method
deleteRecord(Record $record)
.- Add the
#[On('delete-record')]
attribute above this method. This tells Livewire that this method should listen for thedelete-record
event dispatched by the confirmation dialog. Livewire will automatically call this method, resolving therecord
ID from the event data to aRecord
model instance via Route Model Binding. - Inside the method, call
$record->delete()
to remove the record from the database. - Show a success toast using
$this->toastInfo()
to inform the user.
- Add the
- Add a public method
php
<flux:button.group>
<flux:button
wire:click="editRecord({{ $record->id }})"
tooltip="Edit"
icon="pencil-square"/>
<flux:button
tooltip="Cover"
icon="photo"/>
<flux:button
wire:click="deleteConfirm({{ $record->id }})"
tooltip="Delete {{ $record->title }} by {{ $record->artist }}" {-- Update tooltip for clarity --}}
icon="trash"/>
</flux:button.group>
1
2
3
4
5
6
7
8
9
10
11
12
13
2
3
4
5
6
7
8
9
10
11
12
13
Exercises
Now that you have implemented the core CRUD functionality for records, let's refine the user experience and add additional features.
Background Color for Out of Stock Records
Give all records that are out of stock a red background color to make them visually distinct.
Delete the Cover Image from the Server
Currently, when a record is deleted, its associated cover image file remains on the server. Update the delete()
method inside the RecordForm
class so that the corresponding cover image file is also removed from public/storage/covers
when a record is deleted.
- Hint: In the
RecordForm::delete()
method, before deleting the record from the database, you'll need to check if a cover image exists for that record'smb_id
in thepublic/covers
directory usingStorage::disk('public')->exists()
. If it exists, useStorage::disk('public')->delete()
to remove the file. Remember to handle the case whereid
might be null (though in delete scenarios it won't be).
- 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: https://vinyl_shop.test/storage/covers/c0afd87f-2f90-4c4d-b69d-ec150660fa5a.jpg