Admin: records
- For the records table we also need some CRUD operations
- This time, we don't create a separate master (index) and detail (show) page for the administrator
- We already have a decent master and detail page on our public part of the site, so all we have to do is add some buttons on these pages to edit/update and delete a record
Preparation
- Delete the old file app/Http/Controllers/Admin/RecordController.php and create a new resource controller:
php artisan make:controller Admin/RecordController --model Record
TIP
You can also use the --force
flag to overwrite an existing controller without deleting it first:
php artisan make:controller Admin/RecordController --model Record --force
- Open routes/web.php and replace the old get route with a new resource route
Route::middleware(['auth', 'admin'])->prefix('admin')->group(function () {
Route::redirect('/', '/admin/records');
Route::get('genres/qryGenres', 'Admin\GenreController@qryGenres');
Route::resource('genres', 'Admin\GenreController');
Route::get('genres2/qryGenres', 'Admin\Genre2Controller@qryGenres');
Route::resource('genres2', 'Admin\Genre2Controller', ['parameters' => ['genres2' => 'genre']]);
Route::resource('records', 'Admin\RecordController');
});
1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
- Run the command
php artisan route:list
and look at the 7 new routes
VERB | URI | Controller Method | Comment |
---|---|---|---|
GET | admin/records | index | Redirect to admin/records/create |
GET | admin/records/create | create | Show the form for creating a new resource |
POST | admin/records | store | Store a newly created resource in storage |
GET | admin/records/{record} | show | Redirect to shop/{record} |
GET | admin/records/{record}/edit | edit | Show the form for editing the specified resource |
PUT | admin/records/{record} | update | Update the specified resource in storage |
DELETE | admin/records/{record} | destroy | Remove the specified resource from storage |
REMARK
Because we don't have a master and detail page and we don't want to break our routes, we redirect
- the
index()
method to the route/page where we create a new record - the
show()
method to the (public) detail page of the specific record (where we will add some edit/delete buttons for the administrator)
- Open app/Http/Controllers/Admin/RecordController.php and update the
index()
andshow()
method
public function index()
{
return redirect('admin/records/create');
}
1
2
3
4
2
3
4
public function show(Record $record)
{
return redirect("shop/$record->id");
}
1
2
3
4
2
3
4
- Open resources/views/shop/show.blade.php and add the shared alert sub-view to the page (to show some flash messages)
@section('main')
<h1>{{ $record->title }}</h1>
@include('shared.alert')
<div class="row">...</div>
@endsection
1
2
3
4
5
2
3
4
5
- Delete the file resources/views/admin/records/index.blade.php
Create a new record
Update the controller
- Update the
create()
method- We use the
select()
method to specify that we only want to query the columnsid
andname
of the genres
- We use the
public function create()
{
// We need a list of genres inside the form
$genres = Genre::select(['id', 'name'])->orderBy('name')->get();
$result = compact('genres');
Json::dump($result);
return view('admin.records.create', $result);
}
1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
Create a view
- Add a new file create.blade.php to the resources/views/admin/records folder
- Edit the code
- As before (for the genres CRUD), we use a sub-view for the form (that will be re-used when editing/updating a record)
@extends('layouts.template')
@section('title', 'Create new record')
@section('main')
<h1>Create new record</h1>
<form action="/admin/records" method="post">
@include('admin.records.form')
</form>
@endsection
@section('script_after')
@endsection
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
Create the form sub-view
- Add a new file form.blade.php to the resources/views/admin/records folder
- Edit the code
@csrf
<div class="row">
<div class="col-8">
<div class="form-group">
<label for="artist">Artist</label>
<input type="text" name="artist" id="artist"
class="form-control @error('artist') is-invalid @enderror"
placeholder="Artist name"
required
value="{{ old('artist', $record->artist ?? '') }}">
@error('artist')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="title">Title</label>
<input type="text" name="title" id="title"
class="form-control @error('title') is-invalid @enderror"
placeholder="Record title"
required
value="{{ old('title', $record->title ?? '') }}">
@error('title')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="title_mbid">Title MusicBrainz ID</label>
<input type="text" name="title_mbid" id="title_mbid"
class="form-control @error('title_mbid') is-invalid @enderror"
placeholder="Title MusicBrainz ID (36 characters)"
required minlength="36" maxlength="36"
value="{{ old('title_mbid', $record->title_mbid ?? '') }}">
@error('title_mbid')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="cover">Cover URL</label>
<input type="text" name="cover" id="cover"
class="form-control @error('cover') is-invalid @enderror"
placeholder="Cover URL"
value="{{ old('cover', $record->cover ?? '') }}">
@error('cover')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="price">Price</label>
<input type="number" name="price" id="price"
class="form-control @error('price') is-invalid @enderror"
placeholder="Price"
required
step="0.01"
value="{{ old('price', $record->price ?? '') }}">
@error('price')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="stock">Stock</label>
<input type="number" name="stock" id="stock"
class="form-control @error('stock') is-invalid @enderror"
placeholder="Items in stock"
required
value="{{ old('stock', $record->stock ?? '') }}">
@error('stock')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<div class="form-group">
<label for="genre_id">Genre</label>
<select name="genre_id" id="genre_id"
class="custom-select @error('genre_id') is-invalid @enderror"
required>
<option value="">Select a genre</option>
@foreach($genres as $genre)
<option value="{{ $genre->id }}"
{{ (old('genre_id', $record->genre_id ?? '') == $genre->id ? 'selected' : '') }}>{{ ucfirst($genre->name) }}</option>
@endforeach
</select>
@error('genre_id')
<div class="invalid-feedback">{{ $message }}</div>
@enderror
</div>
<p>
<button type="submit" id="submit" class="btn btn-success">Save record</button>
</p>
</div>
<div class="col-4">
<img src="/assets/vinyl.png" alt="cover" class="img-thumbnail" id="coverImage">
</div>
</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
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
WARNING
- As with the genres form, we use the null coalescing operator to test if each field has a default value or not e.g.
old('artist', $record->artist ?? '')
Update the store method
- Send the request through the validator
- Create a new Record object and link the request properties to the record properties
- Save the record
- Flash a success message
- Get the id of the newly created record and redirect to the public detail page
public function store(Request $request)
{
// Validate $request
$this->validate($request, [
'artist' => 'required',
'title' => 'required',
'title_mbid' => 'required|size:36|unique:records,title_mbid',
'price' => 'required|numeric',
'stock' => 'required|integer',
'genre_id' => 'required',
]);
// Create new record
$record = new Record();
$record->artist = $request->artist;
$record->title = $request->title;
$record->title_mbid = $request->title_mbid;
$record->cover = $request->cover;
$record->price = $request->price;
$record->stock = $request->stock;
$record->genre_id = $request->genre_id;
$record->save();
// Flash a success message to the session
session()->flash('success', "The record <b>$record->title</b> from <b>$record->artist</b> has been added");
// Redirect to the public detail page for the newly created record
return redirect("/shop/$record->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
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
Examples
TIP: How to find artist_mbid and title_mbid
- Go to the website 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 title_mbid is the code at the end of the URL
Add 'The Doors - L.A. Woman'
- Go to http://localhost:3000/admin/records/create (via the menu-item ' Records') and create a new record with the following data
- Artist: The Doors
- Title: L.A. Woman
- Title MusicBrainz ID: e68f23df-61e3-4264-bfc3-17ac3a6f856b
- Cover URL: leave empty!
- Price: 21.99
- Stock: 5
- Genre: Pop/rock
@csrf
@php
// Or temporarily add this record-object to the form to simplify testing
$record = (object)[];
$record->artist = 'The Doors';
$record->title = 'L.A. Woman';
$record->title_mbid = 'e68f23df-61e3-4264-bfc3-17ac3a6f856b';
$record->cover = null;
$record->price = 21.99;
$record->stock = 5;
$record->genre_id = 1;
@endphp
<div class="row">...</div>
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
Add 'Ramones - End of the Century'
- Go to http://localhost:3000/admin/records/create (via the menu-item ' Records') and create a new record with the following data
- Artist: Ramones
- Title: End of the Century
- Title MusicBrainz ID: 58dcd354-a89a-48ea-9e6e-e258cb23e11d
- Cover URL: leave empty!
- Price: 19.9
- Stock: 2
- Genre: Punk
@csrf
@php
// Or temporarily add this record-object to the form to simplify testing
$record = (object)[];
$record->artist = 'Ramones';
$record->title = 'End of the Century';
$record->title_mbid = '58dcd354-a89a-48ea-9e6e-e258cb23e11d';
$record->cover = null;
$record->price = 19.90;
$record->stock = 2;
$record->genre_id = 2;
@endphp
<div class="row">...</div>
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
REMARK
The cover for this record ('Ramones - End of the Century') is missing!
- As we saw earlier (Shop: master page -> Get cover URL), most cover images can be retrieved automatically (from coverartarchive.org) based on title_mbid, but not all records have linked covers
- If the cover is missing, you have to search for an online cover (and hotlink to it) or download a cover image to your public folder (and put the corresponding link inside the cover field)
Check cover before insertion
- To avoid an image error on the public pages, we disable the submit button until a valid image is entered
- As we need similar functionality on the edit page, add the following script to a new file resources/views/admin/records/script.blade.php
- Check the script (and the comments) in detail for a full understanding!
- The script uses the jQuery
get()
method to asynchronously test whethercoverUrl
exists
<script>
let album_id, coverUrl;
// Disable the submit button
$('#submit').attr('disabled', true);
// One every change inside the title_mbid or cover field, check if a valid cover image can be found
$('#title_mbid, #cover').change(function () {
album_id = $('#title_mbid').val();
coverUrl = $('#cover').val();
// If cover field is empty and the length of Title MusicBrainz ID is 36 characters
if ($('#cover').val() == '' && album_id.length == 36) {
// Update coverUrl
coverUrl = `https://coverartarchive.org/release/${album_id}/front-250.jpg`;
updateImage('MusicBrainz ID');
}
// If cover field is not empty
if ($('#cover').val() != '') {
updateImage('Cover URL');
}
});
// Trigger change event (such that the search for a valid cover image automatically starts on edit page)
$('#title_mbid').change();
function updateImage(from) {
$.get(coverUrl)
.done(function () {
// coverUrl exists: replace it and enable the submit button
$('#coverImage').attr('src', coverUrl);
// Enable submit button
$('#submit').attr('disabled', false);
// Show toast
VinylShop.toast({
type: 'success',
text: `Cover found via ${from}!`
});
})
.fail(function () {
// coverUrl doesn't exist: set the cover back to vinyl.png and disable the submit button
$('#coverImage').attr('src', '/assets/vinyl.png');
// Disable submit button
$('#submit').attr('disabled', true);
// Show toast
VinylShop.toast({
type: 'error',
text: `No cover found via ${from}!<br>Use Google and add a valid (local) URL in the Cover URL field.`
});
});
}
</script>
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
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
- Do not forget to include this script in the script_after section of create.blade.php!
@extends('layouts.template')
@section('title', 'Create new record')
@section('main')
<h1>Create new record</h1>
<form action="/admin/records" method="post">
@include('admin.records.form')
</form>
@endsection
@section('script_after')
@include('admin.records.script')
@endsection
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
- Open resources/sass/_main.scss and change the (view of the) cursor on a button with the disabled attribute
...
.btn[disabled] {
cursor: not-allowed;
}
1
2
3
4
2
3
4
Re-add 'Ramones - End of the Century'
- Remove the album 'Ramones - End of the Century' from the database (via PhpMyAdmin)
- Add the album again via the menu-item ' Records'
- Once you entered a Title MusicBrainz ID (of 36 characters), the script checks if the corresponding cover is available at coverartarchive.org
- If not, a Noty toast is shown
- Search for a cover on Google (preferably 250x250 pixels or larger), e.g: https://upload.wikimedia.org/wikipedia/en/e/ea/Ramones_-_End_of_the_Century_cover.jpg
- Paste the URL in the Cover URL field
- The script checks if the corresponding cover is available at the given cover URL, and a Noty toast is shown
REMARK
- If you want to play safe (the external image might be deleted) you better save the cover image to your local site and link it from there
- Save the 'Ramones - End of the Century' cover as e.g. Ramones_EndOfTheCentury.jpg inside the folder public/assets/covers
- Enter an absolute path in the Cover URL input field:
/assets/covers/Ramones_EndOfTheCentury.jpg
- Save the record
Custom validation messages
- The validation messages are based on the names of the input fields
- Sometimes these names cause a slightly too cryptic error description (title mbid and genre id)
- You can easily overwrite these default messages with your own custom messages by extending the validator with a third parameter:
$this->validate($request, $rules, $messages)
- Add some custom messages:
$this->validate($request, [
'artist' => 'required',
'title' => 'required',
'title_mbid' => 'required|size:36|unique:records,title_mbid',
'genre_id' => 'required',
'price' => 'required|numeric',
'stock' => 'required|integer',
], [
'title_mbid.required' => 'The Title MusicBrainz ID is required.',
'title_mbid.size' => 'The Title MusicBrainz ID must be :size characters.',
'title_mbid.unique' => 'This record already exists!',
'genre_id.required' => 'Please select a genre.',
]);
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
Update public detail page
- Open resources/views/shop/show.blade.php
- Add New record, Edit record and Delete record link buttons on the public detail page (after the shared alert)
- These links/buttons are only visible if the logged-in user has the admin role!
@section('main')
<h1>{{ $record->title }}</h1>
@include('shared.alert')
@auth()
@if(auth()->user()->admin)
<div class="alert alert-primary">
<a href="/admin/records/create" class="btn btn-success">
<i class="fas fa-plus-circle mr-1"></i>New record
</a>
<a href="/admin/records/{{ $record->id }}/edit" class="btn btn-primary">
<i class="fas fa-edit mr-1"></i>Edit record
</a>
<a href="#!" class="btn btn-danger" id="deleteRecord">
<i class="fas fa-trash mr-1"></i>Delete record
</a>
</div>
@endif
@endauth
<div class="row">...</div>
@endsection
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
- Test the links
- New record: this link works correctly
- Edit record: this link works but shows an empty page because the edit/update logic is not finished yet
- Delete record: this null link does nothing and the delete logic is not finished yet
Edit a record
Update the controller
- Update the
edit()
method inside RecordController.php:
public function edit(Record $record)
{
$genres = Genre::select(['id', 'name'])->orderBy('name')->get();
$result = compact('genres', 'record');
Json::dump($result);
return view('admin.records.edit', $result);
}
1
2
3
4
5
6
7
2
3
4
5
6
7
Create a view
- Add a new file edit.blade.php to the resources/views/admin/records folder
@extends('layouts.template')
@section('title', "Edit record: $record->artist - $record->title")
@section('main')
<h1>Update record</h1>
<form action="/admin/records/{{ $record->id }}" method="post">
@method('put')
@include('admin.records.form')
</form>
@endsection
@section('script_after')
@include('admin.records.script')
@endsection
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 update method
- The logic of the
update()
method is almost the same as in thestore()
method (when creating a new record):- Line 7: the unique validation is slightly different
- The line
$record = new Record();
is deleted - Line 29: the flash message is different
public function update(Request $request, Record $record)
{
// Validate $request
$this->validate($request, [
'artist' => 'required',
'title' => 'required',
'title_mbid' => 'required|size:36|unique:records,title_mbid,' . $record->id,
'genre_id' => 'required',
'price' => 'required|numeric',
'stock' => 'required|integer',
], [
'title_mbid.required' => 'The Title MusicBrainz ID is required.',
'title_mbid.size' => 'The Title MusicBrainz ID must be :size characters.',
'title_mbid.unique' => 'This record already exists!',
'genre_id.required' => 'Please select a genre.',
]);
// Update record
$record->artist = $request->artist;
$record->title = $request->title;
$record->title_mbid = $request->title_mbid;
$record->cover = $request->cover;
$record->price = $request->price;
$record->stock = $request->stock;
$record->genre_id = $request->genre_id;
$record->save();
// Flash a success message to the session
session()->flash('success', "The record <b>$record->title</b> from <b>$record->artist</b> has been updated");
// Redirect to the public detail page for the updated record
return redirect("/shop/$record->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
- Test the Edit record link on one of your detail pages (e.g. change the stock and the price of 'Ramones - End of the Century')
Delete a record
Update the controller
- Update the
destroy()
method inside RecordController.php- There is no extra view, so we just return JSON data back the the previous view (that will pop up in a Noty toast)
public function destroy(Record $record)
{
$record->delete();
return response()->json([
'type' => 'success',
'text' => "Record has been deleted"
]);
}
1
2
3
4
5
6
7
8
2
3
4
5
6
7
8
Delete a record and show the master shop page
- To delete a record, you have to add some extra JavaScript logic to resources/views/shop/show.blade.php
- Show a Noty confirm box
- If the user accepts the deletion, do a POST request (with
_method: 'delete'
and the csrf token as parameters) to thedestroy()
method of RecordController.php - Show a Noty toast to inform the administrator that the record is deleted from the database
- After 2 seconds, redirect (using the setTimeout() function) to the master shop page
@section('script_after')
<script>
// Replace vinyl.png with real cover
$('#cover').attr('src', $('#cover').data('src'));
// Delete this record
@auth()
@if(auth()->user()->admin)
$('#deleteRecord').click(function () {
let id = '{{ $record->id }}';
console.log(`delete record ${id}`);
// Show Noty
let modal = new Noty({
text: '<p>Delete the record <b>{{ $record->title }}</b>?</p>',
buttons: [
Noty.button('Delete record', 'btn btn-danger', function () {
// Delete record and close modal
let pars = {
'_token': '{{ csrf_token() }}',
'_method': 'delete'
};
$.post(`/admin/records/${id}`, pars, 'json')
.done(function (data) {
console.log('data', data);
// Show toast
VinylShop.toast({
type: data.type,
text: data.text
});
// After 2 seconds, redirect to the public master page
setTimeout(function () {
$(location).attr('href', '/shop'); // jQuery
// window.location = '/shop'; // JavaScript
}, 2000);
})
.fail(function (e) {
console.log('error', e);
});
modal.close();
}),
Noty.button('Cancel', 'btn btn-secondary ml-2', function () {
modal.close();
})
]
}).show();
});
@endif
@endauth
</script>
@endsection
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
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
- Test the Delete record link on one of your detail pages