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
  • 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('/', 'records');
    Route::get('genres/qryGenres', 'Admin\GenreController@qryGenres');
    Route::resource('genres', 'Admin\GenreController');
    Route::resource('records', 'Admin\RecordController');
});
1
2
3
4
5
6
  • 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')
    ...
@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();
    // To avoid errors with the 'old values' inside the form, we have to send an empty Record object to the view
    $record = new Record();
    $result = compact('genres', 'record');
    Json::dump($result);
    return view('admin.records.create', $result);
}
1
2
3
4
5
6
7
8
9
10

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
1
2
3
4
5
6
7
8
9
10

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="artist_mbid">Artist MusicBrainz ID</label>
            <input type="text" name="artist_mbid" id="artist_mbid"
                   class="form-control @error('artist_mbid') is-invalid @enderror"
                   placeholder="Artist MusicBrainz ID (36 characters)"
                   required minlength="36" maxlength="36"
                   value="{{ old('artist_mbid', $record->artist_mbid) }}">
            @error('artist_mbid')
                <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
93
94
95
96
97
98
99
100
101
102
103

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)
{
    $this->validate($request, [
        'artist' => 'required',
        'artist_mbid' => 'required|size:36',  // size:36 length is exact 36 characters
        'title' => 'required',
        'title_mbid' => 'required|size:36|unique:records,title_mbid',
        'price' => 'required|numeric',
        'stock' => 'required|integer',
        'genre_id' => 'required',
    ]);

    $record = new Record();
    $record->artist = $request->artist;
    $record->artist_mbid = $request->artist_mbid;
    $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();
    // Go to the public detail page for the newly created record
    session()->flash('success', "The record <b>$record->title</b> from <b>$record->artist</b> has been added");
    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

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'

  • The property artist_mbid is the code at the end of the URL

'artist_mbid'

  • 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
    • Artist MusicBrainz ID: 9efff43b-3b29-4082-824e-bc82f646f93d
    • Title: L.A. Woman
    • Title MusicBrainz ID: e68f23df-61e3-4264-bfc3-17ac3a6f856b
    • Cover URL: leave empty!
    • Price: 21.99
    • Stock: 5
    • Genre: Pop/rock
// Or temporarily add this to the create() method to simplify testing
$record->artist = 'The Doors';
$record->artist_mbid = '9efff43b-3b29-4082-824e-bc82f646f93d';
$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;
1
2
3
4
5
6
7
8
9

'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
    • Artist MusicBrainz ID: d6ed7887-a401-47a8-893c-34b967444d26
    • Title: End of the Century
    • Title MusicBrainz ID: 58dcd354-a89a-48ea-9e6e-e258cb23e11d
    • Cover URL: leave empty!
    • Price: 19.9
    • Stock: 2
    • Genre: Punk
// Or temporarily add this to the create() method to simplify testing
$record->artist = 'Ramones';
$record->artist_mbid = 'd6ed7887-a401-47a8-893c-34b967444d26';
$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;
1
2
3
4
5
6
7
8
9

'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>
    $(function () {
        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
                    new Noty({
                        type: 'success',
                        text: `Cover found via ${from}!`
                    }).show();
                })
                .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
                    new Noty({
                        type: 'error',
                        text: `No cover found via ${from}!<br>Use Google and add a valid (local) URL in the Cover URL field.`
                    }).show();
                });
        }
    });
</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
51
52
  • 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')
    ...
@endsection

@section('script_after')
    @include('admin.records.script')
@endsection
1
2
3
4
5
6
7
8
9
10
11
  • 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 (artist mbid, 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',
    'artist_mbid' => 'required|size:36',  // size:36 length is exact 36 characters
    'title' => 'required',
    'title_mbid' => 'required|size:36|unique:records,title_mbid',
    'genre_id' => 'required',
    'price' => 'required|numeric',
    'stock' => 'required|integer',
], [
     'artist_mbid.required' => 'The Artist MusicBrainz ID is required.',
     'artist_mbid.size' => 'The Artist MusicBrainz ID must be :size characters.',
     '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
14
15
16

'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
    ...
@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 30: the flash message is different






 






















 



public function update(Request $request, Record $record)
{
    $this->validate($request, [
        'artist' => 'required',
        'artist_mbid' => 'required|size:36',
        'title' => 'required',
        'title_mbid' => 'required|size:36|unique:records,title_mbid,' . $record->id,
        'genre_id' => 'required',
        'price' => 'required|numeric',
        'stock' => 'required|integer',
    ], [
        'artist_mbid.required' => 'The Artist MusicBrainz ID is required.',
        'artist_mbid.size' => 'The Artist MusicBrainz ID must be :size characters.',
        '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.',
    ]);

    $record->genre_id = $request->genre_id;
    $record->artist = $request->artist;
    $record->artist_mbid = $request->artist_mbid;
    $record->title = $request->title;
    $record->title_mbid = $request->title_mbid;
    $record->cover = $request->cover;
    $record->price = $request->price;
    $record->stock = $request->stock;
    $record->save();
    // Go to the public detail page for the updated record
    session()->flash('success', "The record <b>$record->title</b> from <b>$record->artist</b> has been updated");
    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>
        $(function () {
            // Replace vinyl.png with real cover
            $('#cover').attr('src', $('#cover').data('src'));

            // Get tracks from MusicBrainz API
            $.getJSON('{{ $record->recordUrl }}')
                .done(function (data) {...})
                .fail(function (error) {...});

            // 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({
                            timeout: false,
                            layout: 'center',
                            modal: true,
                            type: 'warning',
                            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
                                            new Noty({
                                                type: data.type,
                                                text: data.text
                                            }).show();
                                            // 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
51
52
53
54
55
56
57
58
59
60
61
  • Test the Delete record link on one of your detail pages

'Delete a record'

'Delete a record'

Commit "Opdracht 9: <= Admin: records"

  • Execute the following commands in a terminal window:
git add .
git commit -m "Opdracht 9: <= Admin: records"
git push
1
2
3
Last Updated: 1/20/2020, 7:00:03 PM