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
  • Run the command php artisan route:list and look at the 7 new routes
VERB URI Controller Method Comment
GET admin/records index Display a listing of the resource
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 Display the specified resource
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() and show() method


 


public function index()
{
    return redirect('admin/records/create');
}
1
2
3
4


 


public function show(Record $record)
{
    return redirect("shop/$record->id");
}
1
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
  • 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 columns id and name of the genres


 
 
 
 
 


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

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

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

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

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

'The doors'

  • 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

'title_mbid'

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

'Add The Doors'

'Add The Doors'

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

'Add Ramones'

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 whether coverUrl 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
  • 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
  • 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

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

'Add Ramones' - No cover found MusicBrainz

'Add Ramones' - Cover found

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 'Absolute path to the cover image'
  • Save the record

'Add Ramones' - save

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)

'Validation with input field names'

  • 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

'Validation with custom messages'

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

'Add links for admin'

  • 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

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

Update the update method

  • The logic of the update() method is almost the same as in the store() 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
  • 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')

'Update a record'

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

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 the destroy() 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
  • Test the Delete record link on one of your detail pages

'Delete a record'

'Delete a record'

Last Updated: 12/5/2021, 8:18:59 AM