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
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 routeadmin/genres/{genre}/edit
to show the form for editing a specific genre)
- resource name =
- See e.g. the routes in the basic (genres) CRUD
- 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 theRoute::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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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 - Stop the NPM
- 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 namebtn-create
- The
- 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
- The
- The 'Create new genre' button is updated
@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
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
2
3
4
- The result so far:
- 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)
- Get all the genres (ordered by
public function qryGenres()
{
$genres = Genre::orderBy('name')
->withCount('records')
->get();
return $genres;
}
1
2
3
4
5
6
7
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
2
3
4
5
6
7
- Test the route in a new browser window http://localhost:3000/admin/genres2/qryGenres
Build the table with AJAX
- Add a JavaScript function
loadTable()
that loads the genres via a call of thegetJSON()
method with the routeadmin/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 thetbody
tag first)
- Construct for every genre a table row and append this row to the
- 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
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
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 thedeleteGenre()
function and close the modal - Nothing will be deleted at this moment, you only see the
id
inside the browser console
- Get the all the information about the genre (via the data attributes of the corresponding
<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
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
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
2
3
4
5
6
7
8
Delete a genre and rebuild the table
- Use the
post()
method to post theid
of the selected genre toadmin/genres2/{id}
via AJAX- Don't forget to add the parameters csrf token (
_token
) and the method (_method
) delete
- Don't forget to add the parameters csrf token (
- 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
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')
- 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
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
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
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)
- 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
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>×</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
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
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)
- Get the necessary information about the genre (via the data attributes of the corresponding
<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
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
- 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
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 thepost()
method call - If the
post()
method fails,e.responseJSON.errors
contains an array with all the validation errors
- The jQuery method
<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
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
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
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
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()
Cleanup your code
- Redirect all unnecessary methods inside the controller Genre2Controller (
create()
,show()
andedit()
) to the index page
public function create()
{
return redirect('admin/genres2');
}
1
2
3
4
2
3
4
public function show()
{
return redirect('admin/genres2');
}
1
2
3
4
2
3
4
public function edit(Genre $genre)
{
return redirect('admin/genres2');
}
1
2
3
4
2
3
4
EXERCISE 1: Add Bootstrap tooltips
- Add Bootstrap-styled tooltips to the edit and delete buttons of the dynamically added genre list
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'
ANDtype: 'danger'
show a red background
- Update the function such that
- Don't forget to update the JSdoc as well