Admin: genres (advanced)

  • Version 1 of our genres CRUD implementation is not so user friendly
    • We have different views (index.blade.php, create.blade.php and edit.blade.php) and multiple page reloads
    • The confirmation behind the delete button uses a standard browser dialog box/window
  • In this part, we refactor the whole logic to just one page where all the actions go through jQuery and AJAX

TIPS

  • First, add some dummy genres (e.g. '_genre1', '_genre2', '_genre3', ...) to the database migration you can play with
  • If you mess up your database, you can always bring it back to the original state with the command:
    php artisan migrate:fresh

Preparation

Resource controller and routes

  • Create a new resource controller class Genre2Controller.php in the folder app/Http/Controllers/Admin:
    php artisan make:controller Admin/Genre2Controller --model Genre
  • Open routes/web.php and add, in the admin group, a new resource route:



 



Route::middleware(['auth', 'admin'])->prefix('admin')->group(function () {
    Route::redirect('/', '/admin/records');
    Route::resource('genres', 'Admin\GenreController');
    Route::resource('genres2', 'Admin\Genre2Controller', ['parameters' => ['genres2' => 'genre']]);
    Route::get('records', 'Admin\RecordController@index');
});
1
2
3
4
5
6

REMARK

  • Laravel tries to use the singularized version of the first argument (resource name) of the Route::resource() command to construct the route parameters
    • See e.g. the routes in the basic (genres) CRUD
      • resource name = genres
      • parameter used in routes = genre (e.g. the route admin/genres/{genre}/edit to show the form for editing a specific genre)
  • However, Laravel is unable to determine the singularized version of genres2, and therefore, the resulting routes will not work, unless we include ['parameters' => ['genres2' => 'genre']] as an extra argument in the Route::resource() command!
  • A summary (of the new routes and the corrsponding controller methods) can be found below:
VERB URI Controller Method Comment
GET admin/genres2 index Display a listing of the resource
GET admin/genres2/create create Show the form for creating a new resource
POST admin/genres2 store Store a newly created resource in storage
GET admin/genres2/{genre} show Display the specified resource
GET admin/genres2/{genre}/edit edit Show the form for editing the specified resource
PUT admin/genres2/{genre} update Update the specified resource in storage
DELETE admin/genres2/{genre} destroy Remove the specified resource from storage

Additional menu-item

  • Add a menu-item ' Genres (advanced)' to the admin part in resources/views/shared/navigation.blade.php










 







...
<div class="dropdown-menu dropdown-menu-right">
    <a class="dropdown-item" href="/user/profile"><i class="fas fa-user-cog"></i>Update Profile</a>
    <a class="dropdown-item" href="/user/password"><i class="fas fa-key"></i>New Password</a>
    <a class="dropdown-item" href="/user/history"><i class="fas fa-box-open"></i>Order history</a>
    <div class="dropdown-divider"></div>
    <a class="dropdown-item" href="/logout"><i class="fas fa-sign-out-alt"></i>Logout</a>
    @if(auth()->user()->admin)
        <div class="dropdown-divider"></div>
        <a class="dropdown-item" href="/admin/genres"><i class="fas fa-microphone-alt"></i>Genres</a>
        <a class="dropdown-item" href="/admin/genres2"><i class="fas fa-microphone-alt"></i>Genres (advanced)</a>
        <a class="dropdown-item" href="/admin/records"><i class="fas fa-compact-disc"></i>Records</a>
        <a class="dropdown-item" href="/admin/users"><i class="fas fa-users-cog"></i>Users</a>
        <a class="dropdown-item" href="/admin/orders"><i class="fas fa-box-open"></i>Orders</a>
    @endif
</div>
...
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

'New menu item'

Noty

  • Noty is a JavaScript library to send notifications to the user
  • Install and configure Noty:
    • Stop the NPM watch script
    • Install Noty with the command npm install noty
    • Open resources/sass/app.scss and import the Noty and the Bootstrap theme styles


     
     

    @import url('https://fonts.googleapis.com/css?family=Nunito');
    @import url('https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.1/css/all.min.css');
    @import '~noty/src/noty';
    @import '~noty/src/themes/bootstrap-v4';
    
    1
    2
    3
    4
    • Open resources/js/app.js, import the Noty script and add some defaults for Noty

     
     
     
     
     
     
     

    require('./bootstrap');
    window.Noty = require('noty');
    Noty.overrideDefaults({
        theme: 'bootstrap-v4',
        type: 'warning',
        layout: 'center',
        modal: true,
    });
    
    1
    2
    3
    4
    5
    6
    7
    8
  • Restart the the NPM watch script: npm run watch

Master page

  • Create a new folder resources/views/admin/genres2 and add a new file index.blade.php to this folder
  • In comparison with the master view of the first/basic genres CRUD (resources/views/admin/genres/index.blade.php), this view is quite different because the table will now be composed with AJAX
    • The 'Create new genre' button is updated
      • The href attribute is a null link
      • The link has an id with name btn-create
    • Only the static/fixed parts of the page are retained
      • The tbody tag is left empty
      • The include statement regarding the (shared) alerts is removed
      • The script asking for delete confirmation is removed
@extends('layouts.template')

@section('title', 'Genres (advanced)')

@section('main')
    <h1>Genres</h1>
    <p>
        <a href="#!" class="btn btn-outline-success" id="btn-create">
            <i class="fas fa-plus-circle mr-1"></i>Create new genre
        </a>
    </p>
    <div class="table-responsive">
        <table class="table">
            <thead>
            <tr>
                <th>#</th>
                <th>Genre</th>
                <th>Records for this genre</th>
                <th>Actions</th>
            </tr>
            </thead>
            <tbody>
            </tbody>
        </table>
    </div>
@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

Update the controller

  • Open the file app/Http/Controllers/Admin/Genre2Controller.php
  • Update the index() method


 


public function index()
{
    return view('admin.genres2.index');
}
1
2
3
4
  • The result so far:

'Master page with empty table'

  • Add, at the bottom of the controller Genre2Controller, a new qryGenres() method
    • Get all the genres (ordered by name and with the number of records of this genre)
    • Return the result (as JSON)
public function qryGenres()
{
    $genres = Genre::orderBy('name')
        ->withCount('records')
        ->get();
    return $genres;
}
1
2
3
4
5
6
7

Add a route for the new query

  • Open routes/web.php and add a new route
  • It's important to place this route BEFORE the resource route!



 




Route::middleware(['auth', 'admin'])->prefix('admin')->group(function () {
    Route::redirect('/', '/admin/records');
    Route::resource('genres', 'Admin\GenreController');
    Route::get('genres2/qryGenres', 'Admin\Genre2Controller@qryGenres');
    Route::resource('genres2', 'Admin\Genre2Controller', ['parameters' => ['genres2' => 'genre']]);
    Route::get('records', 'Admin\RecordController@index');
});
1
2
3
4
5
6
7

Build the table with AJAX

  • Add a JavaScript function loadTable() that loads the genres via a call of the getJSON() method with the route admin/genres2/qryGenres as parameter
    • Construct for every genre a table row and append this row to the tbody tag
      (We use this function every time we edit, update or delete a genre, so clear the tbody tag first)
  • Call this function immediately when the page is loaded
  • Add the following script_after section to the page :
@section('script_after')
    <script>
        loadTable();    // Execute the function  loadTable() as soon as the page loads

        // Load genres with AJAX
        function loadTable() {
            $.getJSON('/admin/genres2/qryGenres')
                .done(function (data) {
                    console.log('data', data);
                    // Clear tbody tag
                    $('tbody').empty();
                    // Loop over each item in the array
                    $.each(data, function (key, value) {
                        let tr = `<tr>
                               <td>${value.id}</td>
                               <td>${value.name}</td>
                               <td>${value.records_count}</td>
                               <td data-id="${value.id}"
                                   data-records="${value.records_count}"
                                   data-name="${value.name}">
                                    <div class="btn-group btn-group-sm">
                                        <a href="#!" class="btn btn-outline-success btn-edit">
                                            <i class="fas fa-edit"></i>
                                        </a>
                                        <a href="#!" class="btn btn-outline-danger btn-delete">
                                            <i class="fas fa-trash"></i>
                                        </a>
                                    </div>
                               </td>
                           </tr>`;
                        // Append row to tbody
                        $('tbody').append(tr);
                    });
                })
                .fail(function (e) {
                    console.log('error', e);
                })
        }
    </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

'Master page with filled table'

Delete a genre

Noty confirm box

  • The default confirm box used in the first/basic genres CRUD is replaced with a Noty Confirm
  • When you click on a delete button:
    • Get the all the information about the genre (via the data attributes of the corresponding td tag)
    • Set some default properties (text inside the modal, text inside the button and some colors) for the Noty modal
    • Overwrite these properties if there are records associated to the genre
    • When you click on the delete button inside the modal, send the id of the genre to the deleteGenre() function and close the modal
    • Nothing will be deleted at this moment, you only see the id inside the browser console



 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

 
 
 
 
 





<script>
    loadTable();    // Execute the function  loadTable() as soon as the page loads

    // Popup a dialog
    $('tbody').on('click', '.btn-delete', function () {
        // Get data attributes from td tag
        const id = $(this).closest('td').data('id');
        const name = $(this).closest('td').data('name');
        const records = $(this).closest('td').data('records');
        // Set some values for Noty
        let text = `<p>Delete the genre <b>${name}</b>?</p>`;
        let type = 'warning';
        let btnText = 'Delete genre';
        let btnClass = 'btn-success';
        // If records not 0, overwrite values for Noty
        if (records > 0) {
            text += `<p>ATTENTION: you are going to delete ${records} records at the same time!</p>`;
            btnText = `Delete genre + ${records} records`;
            btnClass = 'btn-danger';
            type = 'error';
        }
        // Show Confirm Dialog
        let modal = new Noty({
            type: type,
            text: text,
            buttons: [
                Noty.button(btnText, `btn ${btnClass}`, function () {
                    // Delete genre and close modal
                    deleteGenre(id);
                    modal.close();
                }),
                Noty.button('Cancel', 'btn btn-secondary ml-2', function () {
                    modal.close();
                })
            ]
        }).show();
    });

    // Delete a genre
    function deleteGenre(id) {
        // Delete the genre from the database
        console.log('id', id);
    }

    // Load genres with AJAX
    function loadTable() {...}
</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

'Delete a genre without records' 'Delete a genre with records'

Update the controller

  • Update the destroy() method inside the controller Genre2Controller
    • Delete the genre
    • Instead of a redirect (see first/basic version of the genres CRUD), we use the helper function response() to return some JSON-encoded data (that will be used in a popup notification) back to the view


 
 
 
 
 


public function destroy(Genre $genre)
{
    $genre->delete();
    return response()->json([
        'type' => 'success',
        'text' => "The genre <b>$genre->name</b> has been deleted"
    ]);
}
1
2
3
4
5
6
7
8

Delete a genre and rebuild the table

  • Use the post() method to post the id of the selected genre to admin/genres2/{id} via AJAX
    • Don't forget to add the parameters csrf token (_token) and the method (_method) delete
  • Update the deleteGenre() function:


 
 
 
 
 
 
 
 
 
 
 
 
 


function deleteGenre(id) {
    // Delete the genre from the database
    let pars = {
        '_token': '{{ csrf_token() }}',
        '_method': 'delete'
    };
    $.post(`/admin/genres2/${id}`, pars, 'json')
        .done(function (data) {
            console.log('data', data);
            // Rebuild the table
            loadTable();
        })
        .fail(function (e) {
            console.log('error', e);
        });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

Noty toast response

  • Open the browser console and take a look at the data that comes back as a response from the server (when deleting e.g. the genre 'afrobeat')

'Delete a genre - console window'

  • Use data.type, data.text and overwrite some default Noty settings to create a Noty toast










 
 
 
 
 
 
 
 
 








// Delete a genre
function deleteGenre(id) {
    // Delete the genre from the database
    let pars = {
        '_token': '{{ csrf_token() }}',
        '_method': 'delete'
    };
    $.post(`/admin/genres2/${id}`, pars, 'json')
        .done(function (data) {
            console.log('data', data);
            // Show toast
            new Noty({
                type: data.type,
                text: data.text,
                // overwrite default Noty settings
                layout: 'topRight',
                timeout: 3000,
                modal: false,
            }).show();
            // Rebuild the table
            loadTable();
        })
        .fail(function (e) {
            console.log('error', e);
        });
}
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

'Show toast'

Refactor the Noty toast response

  • For every toast we make, we have to overwrite some default settings (layout, timeout and modal)
  • We can shorten the toast by moving this logic to our global Vinylshop script
  • Add this code to resources/js/vinylShop.js:




 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 




 



let VinylShop = (function () {

    function hello() { ... }

    /**
     * Show a Noty toast.
     * @param {object} obj
     * @param {string} [obj.type='success'] - background color ('success' | 'error '| 'info' | 'warning')
     * @param {string} [obj.text='...'] - text message
     */
    function toast(obj) {
        let toastObj = obj || {};   // if no object specified, create a new empty object
        new Noty({
            layout: 'topRight',
            timeout: 3000,
            modal: false,
            type: toastObj.type || 'success',   // if no type specified, use 'success'
            text: toastObj.text || '...',       // if no text specified, use '...'
        }).show();
    }

    // Return all functions that are public available. E.g. VinylShop.hello()
    return {
        hello: hello,   // publicly available as: VinylShop.hello()
        toast: toast,   // publicly available as: VinylShop.toast()
    };
})();
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

TIP

  • As a bonus we placed some JSdoc in front of the toast function
    • JSDoc is a markup language used to annotate JavaScript source code and to show some hints in your IDE
    • Hover over the function to see the description of the function (left picture below)
    • Click Ctrl + Shift + Space inside the function to quickly insert one of the object keys (right picture below)

'VinylShop.toast()'

  • Replace the toast script with our new function











 
 
 
 








// Delete a genre
function deleteGenre(id) {
    // Delete the genre from the database
    let pars = {
        '_token': '{{ csrf_token() }}',
        '_method': 'delete'
    };
    $.post(`/admin/genres2/${id}`, pars, 'json')
        .done(function (data) {
            console.log('data', data);
            // Show toast
            VinylShop.toast({
                type: data.type,    // optional because the default type is 'success'
                text: data.text,
            });
            // Rebuild the table
            loadTable();
        })
        .fail(function (e) {
            console.log('error', e);
        });
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22

Edit a genre

Add a Bootstrap modal to the page

  • Add a new file modal.blade.php to the resources/views/admin/genres2 folder
  • Create a Bootstrap modal and place a form with one text field inside the body of the modal




 





 
 








 








<div class="modal" id="modal-genre">
    <div class="modal-dialog">
        <div class="modal-content">
            <div class="modal-header">
                <h5 class="modal-title">modal-genre-title</h5>
                <button type="button" class="close" data-dismiss="modal">
                    <span>&times;</span>
                </button>
            </div>
            <div class="modal-body">
                <form action="" method="post">
                    @method('')
                    @csrf
                    <div class="form-group">
                        <label for="name">Name</label>
                        <input type="text" name="name" id="name"
                               class="form-control"
                               placeholder="Name"
                               minlength="3"
                               required
                               value="">
                    </div>
                    <button type="submit" class="btn btn-success">Save genre</button>
                </form>
            </div>
        </div>
    </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

REMARKS

  • We will use the same form for editing a genre and for creating a new genre!
  • Depending on the action we have to dynamically change the form:
Edit Create
.modal-title Edit "name of genre" New genre
form action /admin/genres2/{id} /admin/genres2
@method PUT POST
input value original value empty
  • Include the sub-view at the end of the index page





 


@section('main')
    ...
    <div class="table-responsive">
        ...
    </div>
    @include('admin.genres2.modal')
@endsection
1
2
3
4
5
6
7
  • When you click on an edit button:
    • Get the necessary information about the genre (via the data attributes of the corresponding td tag)
    • Update the modal window (see table above)
    • Make the modal visible (it is hidden by default)






 
 
 
 
 
 
 
 
 
 
 
 
 








<script>
  loadTable();    // Execute the function  loadTable() as soon as the page loads

  // Popup a dialog
  $('tbody').on('click', '.btn-delete', function () {...});
  
  // Show the Bootstrap modal to edit a genre
  $('tbody').on('click', '.btn-edit', function () {
      // Get data attributes from td tag
      const id = $(this).closest('td').data('id');
      const name = $(this).closest('td').data('name');
      // Update the modal
      $('.modal-title').text(`Edit ${name}`);
      $('form').attr('action', `/admin/genres2/${id}`);
      $('#name').val(name);
      $('input[name="_method"]').val('put');
      // Show the modal
      $('#modal-genre').modal('show');
  });

  // Delete a genre
  function deleteGenre(id) {...}

  // Load genres with AJAX
  function loadTable() {...}
</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

'Edit modal'

Update the controller

  • Update the update() method inside the controller


 
 
 
 
 
 
 
 
 
 
 
 
 
 


public function update(Request $request, Genre $genre)
{
    // Validate $request
    $this->validate($request,[
        'name' => 'required|min:3|unique:genres,name,' . $genre->id
    ]);

    // Update genre
    $genre->name = $request->name;
    $genre->save();

    // Return a success message to master page
    return response()->json([
        'type' => 'success',
        'text' => "The genre <b>$genre->name</b> has been updated"
    ]);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

Update the genre and rebuild the table

  • Post the form with AJAX when the form is submitted
    • The jQuery method serialize() creates a URL encoded text string of the form values, which is used as a parameter of the post() method call
    • If the post() method fails, e.responseJSON.errors contains an array with all the validation errors









 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 








<script>
  loadTable();    // Execute the function  loadTable() as soon as the page loads

  // Popup a dialog
  $('tbody').on('click', '.btn-delete', function () {...});
  
  // Show the Bootstrap modal to edit a genre
  $('tbody').on('click', '.btn-edit', function () {...});
  
  // Submit the Bootstrap modal form with AJAX
  $('#modal-genre form').submit(function (e) {
      // Don't submit the form
      e.preventDefault();
      // Get the action property (the URL to submit)
      const action = $(this).attr('action');
      // Serialize the form and send it as a parameter with the post
      const pars = $(this).serialize();
      console.log(pars);
      // Post the data to the URL
      $.post(action, pars, 'json')
          .done(function (data) {
              console.log(data);
              // show success message
              VinylShop.toast({
                  type: data.type,
                  text: data.text
              });
              // Hide the modal
              $('#modal-genre').modal('hide');
              // Rebuild the table
              loadTable();
          })
          .fail(function (e) {
              console.log('error', e);
              // e.responseJSON.errors contains an array of all the validation errors
              console.log('error message', e.responseJSON.errors);
              // Loop over the e.responseJSON.errors array and create an ul list with all the error messages
              let msg = '<ul>';
              $.each(e.responseJSON.errors, function (key, value) {
                  msg += `<li>${value}</li>`;
              });
              msg += '</ul>';
              // show the errors
              VinylShop.toast({
                  type: 'error',
                  text: msg
              });
          });
  });

  // Delete a genre
  function deleteGenre(id) {...}

  // Load genres with AJAX
  function loadTable() {...}
</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
53
54
55
56

'Edit modal: success' 'Edit modal with empty name' 'Edit modal with exisiting name'

Create a new genre

  • When you click on the 'Create new genre' button:
    • Update the modal window (see table above)
    • Make the modal visible









 
 
 
 
 
 
 
 
 
 











<script>
  loadTable();    // Execute the function  loadTable() as soon as the page loads

  // Popup a dialog
  $('tbody').on('click', '.btn-delete', function () {...});
  
  // Show the Bootstrap modal to edit a genre
  $('tbody').on('click', '.btn-edit', function () {...});
  
  // Show the Bootstrap modal to create a new genre
  $('#btn-create').click(function () {
      // Update the modal
      $('.modal-title').text(`New genre`);
      $('form').attr('action', `/admin/genres2`);
      $('#name').val('');
      $('input[name="_method"]').val('post');
      // Show the modal
      $('#modal-genre').modal('show');
  });
  
  // Submit the Bootstrap modal form with AJAX
  $('#modal-genre form').submit(function (e) {...});

  // Delete a genre
  function deleteGenre(id) {...}

  // Load genres with AJAX
  function loadTable() {...}
</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

Update the controller

  • Update the store() method inside the controller


 
 
 
 
 
 
 
 
 
 
 
 
 
 
 


public function store(Request $request)
{
    // Validate $request
    $this->validate($request,[
        'name' => 'required|min:3|unique:genres,name'
    ]);

    // Create new genre
    $genre = new Genre();
    $genre->name = $request->name;
    $genre->save();

    // Return a success message to master page
    return response()->json([
        'type' => 'success',
        'text' => "The genre <b>$genre->name</b> has been added"
    ]);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

Insert the genre and rebuild the table

  • This will be done automatically, because we use the same form and the same script:
    $('#modal-genre form').submit()

'Create modal' 'Create modal - notify toast'

Cleanup your code

  • Redirect all unnecessary methods inside the controller Genre2Controller (create(), show() and edit()) to the index page


 


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


 


public function show()
{
    return redirect('admin/genres2');
}
1
2
3
4


 


public function edit(Genre $genre)
{
    return redirect('admin/genres2');
}
1
2
3
4

EXERCISE 1: Add Bootstrap tooltips

  • Add Bootstrap-styled tooltips to the edit and delete buttons of the dynamically added genre list

'Bootstrap tooltips'

EXERCISE 2: Update the toast script

  • Add a third parameter to position the toast (default is top right)
  • Noty uses error for a red background while Bootstrap uses danger
    • Update the function such that type: 'error' AND type: 'danger' show a red background
  • Don't forget to update the JSdoc as well
Last Updated: 12/5/2021, 8:18:59 AM