Shop: master page
- Open the master view resources/views/shop/index.blade.php
- Open the controller app/Http/Controllers/ShopController.php
- Download the image below and save it in (the subfolder assets in) the public folder as public/assets/vinyl.png
Basic view
- Every record in our database will be visualized as a Bootstrap card with a link to the detail page
- Create a new row (which contains, for the time being, one card with static data) in the view index.blade.php
<h1>Shop</h1>
<div class="row">
<div class="col-sm-6 col-md-4 col-lg-3 mb-3">
<div class="card">
<img class="card-img-top" src="/assets/vinyl.png" alt="">
<div class="card-body">
<h5 class="card-title">Artist</h5>
<p class="card-text">Record title</p>
<a href="#!" class="btn btn-outline-info btn-sm btn-block">Show details</a>
</div>
<div class="card-footer d-flex justify-content-between">
<p>genre</p>
<p>
€ price
<span class="ml-3 badge badge-success">stock</span>
</p>
</div>
</div>
</div>
</div>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Update ShopController
Get all records
- Get all records from the database (no sorting, no filtering, ...) with the statement
$records = Record::get()
REMARK
The same result (retrieving all the records) can be achieved with $records = Record::all()
TIP
- Use the autocompletion of PhpStorm for automatic imports
- Start typing 'Reco..'
- Choose the proper class (first option in the list)
- Push
Enter
- By doing so, you do not have to add the import statement (
use App\Record
) manually
- Display the resulting record set (or collection) in a browser with the Laravel "dump and die" helper function
dd()
, which dumps the given variable(s) and ends the execution of the script
public function index()
{
$records = Record::get(); // get all records
dd($records); // 'dump' the collection and 'die' (stop execution)
return view('shop.index');
}
2
3
4
5
6
- Visit http://localhost:3000/shop
- You see a collection with an array of records
- Each record in the array contains a lot of data, but we are only interested in the attributes
JSON representation of the data
- Obviously, the output shown above is not the easiest presentation of the data
- It's much easier to display the (attributes of the) records as JSON in the browser
JSON
- JSON (or JavaScript Object Notation) is a lightweight text format to interchange data (often between a client and a server)
- JSON is easy to understand and is based on the following syntax rules
- Data is represented in
"name":"value"
pairs - Data is separated by commas
- Curly braces
{ }
contain objects - Square brackets
[ ]
contain arrays
- Data is represented in
- More info: https://itf-web-advanced.netlify.app/ES6/json.html
Json::dump
- Follow the guidelines described in Config -> Laravel helpers so that the statement
Json::dump()
can be used to properly format (the attributes) of the collection retrieved from the database - Delete
dd($records);
and replace it with the code below- For future purposes, we pass the data collection to the view by wrapping it into an associative array using the PHP function
compact()
- For future purposes, we pass the data collection to the view by wrapping it into an associative array using the PHP function
public function index()
{
$records = Record::get(); // get all records
$result = compact('records'); // compact('records') is the same as ['records' => $records]
Json::dump($result); // open http://vinyl_shop.test/shop?json
return view('shop.index', $result); // add $result as second parameter
}
2
3
4
5
6
7
REMARKS
- When using autocompletion (for the automatic import of the
Json
class), you should choose the second or third option in the list
- Replacing
Json::dump($result)
byreturn $result
results in the same (JSON-encoded) output. However, the output is now shown in the browser window/tab of the application, and possible interaction with the application is lost. You thus constantly have to change your code while debugging.
Therefore, it is recommended to use theJson::dump()
approach, which allows to leave the JSON representation http://localhost:3000/shop?json open in a second browser window/tab, so you can easily consult the attributes of the records.
Get records with genre
- We want to display the (name of the) genre on the card, but so far we only have the
genre_id
(and not the name) - Update the query
$records = Record::with('genre')->get();
REMARK
The parameter 'genre' inside with('genre')
refers to the method genre()
of the Record model
public function genre()
{
return $this->belongsTo('App\Genre')->withDefault(); // a record belongs to a genre
}
2
3
4
Get cover URL
REMARKS
- So far, the output of the query was presented in a JSON-encoded format, a technique that is obviously very useful during development/debugging
- If you want to use (and/or manipulate) the results of the query in your Laravel application, you may act as if the collection
$records
is an array of PHPstdClass
objects- You can loop over all records using a
foreach
structure - The properties of these objects correspond to the column names of the corresponding database table, allowing you to write
$record->cover
,$record->title_mbid
,$record->genre->name
, ... for a specific$record
in this array
- You can loop over all records using a
- Some records have a cover URL (e.g. 'Steve Harley & Cockney Rebel - The Best Years of Our Lives'), but most of them haven't
- If the property
cover
isnull
, you can construct it with the MusicBrainz Release ID (stored in thetitle_mbid
property of the record object/column in the database)- For the record 'Queen - Greatest Hits', the
title_mbid
is fcb78d0d-8067-4b93-ae58-1e4347e20216. The resulting URL to the cover then equals https://coverartarchive.org/release/fcb78d0d-8067-4b93-ae58-1e4347e20216/front-250.jpg.
- For the record 'Queen - Greatest Hits', the
- Update the query result
- Loop over each record
- If the property
cover
(of a record) isnull
, then replace this propertycover
with the URL based ontitle_mbid
// Long version
$records = Record::with('genre')->get();
foreach ($records as $record) {
if(!$record->cover) {
$record->cover = "https://coverartarchive.org/release/{$record->title_mbid}/front-250.jpg'";
}
}
2
3
4
5
6
7
// Shorter version (with null coalescing operator)
$records = Record::with('genre')->get();
foreach ($records as $record) {
$record->cover = $record->cover ?? "https://coverartarchive.org/release/{$record->title_mbid}/front-250.jpg";
}
2
3
4
5
Add data to the view
- First, loop over all records so that every record has its own card
<h1>Shop</h1>
<div class="row">
@foreach($records as $record)
<div class="col-sm-6 col-md-4 col-lg-3 mb-3">
<div class="card">
...
</div>
</div>
@endforeach
</div>
2
3
4
5
6
7
8
9
10
- Replace the fixed text within each card with the specific data of a record
- Use the PHP function
number_format()
to show 2 decimal digits of the price
- Use the PHP function
<div class="card">
<img class="card-img-top" src="{{ $record->cover }}" alt="{{ $record->artist }} - {{ $record->title }}">
<div class="card-body">
<h5 class="card-title">{{ $record->artist }}</h5>
<p class="card-text">{{ $record->title }}</p>
<a href="shop/{{ $record->id }}" class="btn btn-outline-info btn-sm btn-block">Show details</a>
</div>
<div class="card-footer d-flex justify-content-between">
<p>{{ $record->genre->name }}</p>
<p>
€ {{ number_format($record->price,2) }}
<span class="ml-3 badge badge-success">{{ $record->stock }}</span>
</p>
</div>
</div>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Pagination
- Because the list of records can be very long, it's better to show only a fraction of the records and add some pagination to the views
- You can limit the number of records per page by replacing
get()
withpaginate(x)
, wherex
is the number of records per view (e.g. 12)
$records = Record::with('genre')->paginate(12);
foreach ($records as $record) {
$record->cover = $record->cover ?? "https://coverartarchive.org/release/{$record->title_mbid}/front-250.jpg";
}
$result = compact('records');
Json::dump($result);
return view('shop.index', $result);
2
3
4
5
6
7
- Add a pagination navigation to the view (e.g. one before and one after the loop) using
$records->withQueryString()->links()
<h1>Shop</h1>
{{ $records->withQueryString()->links() }}
<div class="row">
@foreach($records as $record)
...
@endforeach
</div>
{{ $records->withQueryString()->links() }}
2
3
4
5
6
7
8
REMARKS
- By default, the views rendered to display the pagination links are styled with Bootstrap, but of course you can customize the pagination view
- Laravel adds some extra attributes (
current_page
,first_page_url
, ...) that eventually can be used inside the view
Search the collection
- Every good website has a filter (or search) option
- We use a text field to search for an artist (or a record)
- The available genres will be listed in a dropdown list
Add a search form
- Add a basic form to the view
- Place the form just after the
h1
- The form is submitted using a GET request (
method="get"
) and the data is sent back to the same page (action="/shop"
)- Note that we use the GET method for our search form submission as it only retrieves other data/records (and does not change anything on the server). See "Methods GET and POST in HTML forms - what's the difference?" for a detailed explanation.
- The two text fields have the same
name
attribute as the corresponding columns in the database (artist
andgenre_id
)
- Place the form just after the
<h1>Shop</h1>
<form method="get" action="/shop" id="searchForm">
<div class="row">
<div class="col-sm-6 mb-2">
<input type="text" class="form-control" name="artist" id="artist"
value="" placeholder="Filter Artist Or Record">
</div>
<div class="col-sm-4 mb-2">
<select class="form-control" name="genre_id" id="genre_id">
<option>Genre</option>
</select>
</div>
<div class="col-sm-2 mb-2">
<button type="submit" class="btn btn-success btn-block">Search</button>
</div>
</div>
</form>
<hr>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Fill the genres list
- Make a new collection with all the genres (sorted alphabetically) and pass it to the view
- Use the
orderBy()
method to sort the genres byname
- Use the
$records = Record::with('genre')->paginate(12);
foreach ($records as $record) {
$record->cover = $record->cover ?? "https://coverartarchive.org/release/{$record->title_mbid}/front-250.jpg";
}
$genres = Genre::orderBy('name') // short version of orderBy('name', 'asc')
->get();
$result = compact('genres', 'records'); // $result = ['genres' => $genres, 'records' => $records]
Json::dump($result);
return view('shop.index', $result);
2
3
4
5
6
7
8
9
- Loop, inside the view, over all the genres and add the genres to the select list
- Show the
name
inside the list and use theid
as thevalue
that will be submitted - Add 'All genres' with the MySql wildcard (
%
) asvalue
at the top of the list- The wildcard
%
represents zero or more characters, so the search will go through all the genres (if 'All genres' is selected) - List/examples of MySql wildcards
- The wildcard
- Show the
<select class="form-control" name="genre_id" id="genre_id">
<option value="%">All genres</option>
@foreach($genres as $genre)
<option value="{{ $genre->id }}">{{ $genre->name }}</option>
@endforeach
</select>
2
3
4
5
6
- Most of the genres in the list don't have any records (yet), which is quite frustrating for the user. Therefore, we want to limit the list to the genres that contain one or more records (and count the number of records belonging to a specific genre).
- Update the query
- Use the method call
has('records')
to retrieve only those genres that have records - Use the method call
withCount('records')
to add a new propertyrecords_count
to the Genre models
- Use the method call
$genres = Genre::orderBy('name')
->has('records') // only genres that have one or more records
->withCount('records') // add a new property 'records_count' to the Genre models/objects
->get();
2
3
4
REMARK
The parameter 'records' inside has('records')
and withCount('records')
refers to the method records()
of the Genre model
public function records()
{
return $this->hasMany('App\Record'); // a genre has many records
}
2
3
4
- Add the property
records_count
to the dropdown list and use the PHP functionucfirst()
to capitalize the first letter of the genres
<select class="form-control" name="genre_id" id="genre_id">
<option value="%">All genres</option>
@foreach($genres as $genre)
<option value="{{ $genre->id }}">{{ ucfirst($genre->name) }} ({{ $genre->records_count }})</option>
@endforeach
</select>
2
3
4
5
6
- With the former approach you make your view code (inside the
option
tag) unnecessary complex and unreadable - You can do all the transformations (and concatenations) within the controller and not in the view
- Rewind the code inside the dropdown list back to the previous state
<select class="form-control" name="genre_id">
<option value="%">All genres</option>
@foreach($genres as $genre)
<option value="{{ $genre->id }}">{{ $genre->name }}</option>
@endforeach
</select>
2
3
4
5
6
Transform the genres list
- Add a
transform()
method call betweenget()
and the semicolon- This method iterates over (all the items in) the collection
- The first letter of the name property is capitalized, and the record count is added to this property
- Add a
makeHidden()
method call betweentransform()
and the semicolon- All properties that will not be used in the view are removed (hidden) from the collection
$genres = Genre::orderBy('name', 'asc')
->has('records')
->withCount('records')
->get()
->transform(function ($item, $key) {
// Set first letter of name to uppercase and add the counter
$item->name = ucfirst($item->name) . ' (' . $item->records_count . ')';
return $item;
})
->makeHidden(['created_at', 'updated_at', 'records_count']); // Remove all fields that you don't use inside the view
2
3
4
5
6
7
8
9
10
Get form request
- When you submit the form, there is no entry point inside the controller to capture the submitted values
- Inject the request variable
$request
as a parameter into theindex()
function
- Inject the request variable
public function index(Request $request)
{
...
}
2
3
4
Basic search
- Use
$request->input('name')
to get the values of the text fieldartist
and the dropdown listgenre_id
- "Prepare" the resulting values before you use them in the query
- Extend the query
- Add a
where()
method call to filter the collection
- Add a
$genre_id = $request->input('genre_id') ?? '%'; // $request->input('genre_id') OR $request->genre_id OR $request['genre_id'];;
$artist_title = '%' . $request->input('artist') . '%'; // $request->input('artist') OR $request->artist OR $request['artist'];;
$records = Record::with('genre')
->where([
['artist', 'like', $artist_title],
['genre_id', 'like', $genre_id]
])
->paginate(12);
2
3
4
5
6
7
8
TIPS
- If you search for artists that contain the letters bo, you have to append and prepend a
%
to find these letters at any position:where([['artist', 'like', '%bo%'], [...]])
- The values of
genre_id
are numbers, except the first one. 'All genres' is not a number but corresponds to the value%
. Because of the different types, you have to use'like'
to compare and not'='
. E.g. for 'pop/rock' you getwhere([[...], ['genre_id', 'like', 1]])
and for 'All genres' you getwhere([[...], ['genre_id', 'like', '%']])
.
REMARKS
- When http://localhost:3000/shop is loaded with an empty search form (e.g. by navigating to the page via the menu),
$request->input(...)
returnsnull
, which would lead to errors when used in awhere()
method call.
However, by "preparing" our input values,$genre_id
equals'%'
and$artist_title
equals'%%'
in this case, and no errors will occur. - You can use
$request->artist
and$request->genre_id
as shorthand notations forrequest->input('artist')
and$request->input('genre_id')
, respectively.
Remember the filled-in values
- Every time you submit the form (and load the adjusted view) the filled-in values in the form are gone, which is (again) quite frustrating for the user
- In the view, Laravel's
request()
helper function can be used to get the current request instance and to- set the value of the text field to the value of the current request
- add the
selected
attribute to the value in the dropdown list that was selected in the current request
<div class="row">
<div class="col-sm-7 mb-2">
<input type="text" class="form-control" name="artist" id="artist"
value="{{ request()->artist }}"
placeholder="Filter Artist Or Record">
</div>
<div class="col-sm-4 mb-2">
<select class="form-control" name="genre_id" id="genre_id">
<option value="%">All genres</option>
@foreach($genres as $genre)
<option value="{{ $genre->id }}"
{{ (request()->genre_id == $genre->id ? 'selected' : '') }}>{{ $genre->name }}</option>
@endforeach
</select>
</div>
<div class="col-sm-1 mb-2">
<button type="submit" class="btn btn-success">Search</button>
</div>
</div>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
Give feedback if the collection is empty
- It's a best practice to give some feedback to the user is the collection is empty
- Add a Bootstrap alert below the form
- Use the Blade
@if
directive in combination with thecount()
method to show the alert only if the collection is empty
<h1>Shop</h1>
<form ...>
...
</form>
<hr>
@if ($records->count() == 0)
<div class="alert alert-danger alert-dismissible fade show">
Can't find any artist or album with <b>'{{ request()->artist }}'</b> for this genre
<button type="button" class="close" data-dismiss="alert">
<span>×</span>
</button>
</div>
@endif
2
3
4
5
6
7
8
9
10
11
12
13
Advanced search
- How can we use the text field
artist
to search for an artist OR record title?- Use the two input values to search for ((
artist
ANDgenre_id
) OR (title
ANDgenre_id
)) - Update the query with an extra
orWhere()
method - Use an array of tests inside both methods to implement the AND relation
- Use the two input values to search for ((
$genre_id = $request->input('genre_id') ?? '%'; //OR $genre_id = $request->genre_id ?? '%';
$artist_title = '%' . $request->input('artist') . '%'; //OR $artist_title = '%' . $request->artist . '%';
$records = Record::with('genre')
->where([
['artist', 'like', $artist_title],
['genre_id', 'like', $genre_id]
])
->orWhere([
['title', 'like', $artist_title],
['genre_id', 'like', $genre_id]
])
->paginate(12);
2
3
4
5
6
7
8
9
10
11
12
Better UX
- With a few minor changes, you can increase the user experience (UX) significantly
- The only thing you need is a bit of CSS code and some JavaScript
- These (limited) changes are not integrated globally, but only on the page itself
Update template
- Open the template resources/views/layouts/template.blade.php and add two extra gaps
- One gap after the global CSS file
- One gap after the global JavaScript file
...
<link rel="stylesheet" href="{{ mix('css/app.css') }}">
@yield('css_after')
...
<script src="{{ mix('js/app.js') }}"></script>
@yield('script_after')
...
2
3
4
5
6
7
Add CSS
- Fill the css_after gap in the index page resources/views/shop/index.blade.php
- Give the whole card a
pointer
cursor so it looks like it's clickable - Hide the button inside each card (as you can click on the cards, these buttons are now redundant)
- Hide the submit button. The form will be submitted when the user leaves the text field or selects another genre.
- Give the whole card a
@section('css_after')
<style>
.card {
cursor: pointer;
}
.card .btn, form .btn {
display: none;
}
</style>
@endsection
2
3
4
5
6
7
8
9
10
REMARKS
- The submit button can also be removed (instead of not displaying it), so that the layout of the search form can be optimized (see Exercise)
- The buttons in the cards should not be removed for SEO purposes (they contain links that can be indexed)
- Instead of using internal CSS for hiding these buttons, you can opt to use a different approach and code this (globally) using Sass (see Exercise)
Update the card
- As we made the whole card clickable, we need the
id
of the record on the card itself (to identify which record/card is clicked on)- Add the
id
as a data attributedata-id
to the card
- Add the
- The covers come from a remote server and require some time to load
- This causes the screen to "flicker" because you can't predict which cover will come first
- To solve this problem, initially show the dummy cover and use the script to replace the dummy cover with the real cover
- Replace the source of the image with the dummy cover
- Add the URL of the real cover as a data attribute
data-src
to the image
<div class="card" data-id="{{ $record->id }}">
<img class="card-img-top" src="/assets/vinyl.png" data-src="{{ $record->cover }}" ...>
...
</div>
2
3
4
Add JavaScript
- Fill the script_after gap in the index page resources/views/shop/index.blade.php
- When a card is clicked upon, the id of the corresponding record is retrieved from the data-attribute
data-id
, and the specific detail page is loaded using- the jQuery statement
$(location).attr('href', 'URL')
- template literals (
`/shop/${record_id}`
is a shorthand for'/shop/' + record_id
)
- the jQuery statement
- For all records, the default image is replaced by the real cover (the URL of which is retrieved from the data-attribute
data-src
) by adjusting thesrc
attribute - When you hover over a card, the Bootstrap class
shadow
is added to this card - When you leave the text field or change the (value of the) dropdown list, the form is submitted
- When a card is clicked upon, the id of the corresponding record is retrieved from the data-attribute
@section('script_after')
<script>
$(function () {
// Get record id and redirect to the detail page
$('.card').click(function () {
const record_id = $(this).data('id');
$(location).attr('href', `/shop/${record_id}`); //OR $(location).attr('href', '/shop/' + record_id);
});
// Replace vinyl.png with real cover
$('.card img').each(function () {
$(this).attr('src', $(this).data('src'));
});
// Add shadow to card on hover
$('.card').hover(function () {
$(this).addClass('shadow');
}, function () {
$(this).removeClass('shadow');
});
// submit form when leaving text field 'artist'
$('#artist').blur(function () {
$('#searchForm').submit();
});
// submit form when changing dropdown list 'genre_id'
$('#genre_id').change(function () {
$('#searchForm').submit();
});
})
</script>
@endsection
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
EXERCISE 1: The final details
- Remove the submit button (instead of hiding it) and optimize the layout of the search form such that it spans the complete container width
- Remove (or comment out) the internal CSS code in
@section('css_after')
and write some (global) Sass code (in resources/sass/_main.scss) that results in the same behavior (pointer
cursor on card, hide buttons in cards)
TIP
Add a CSS class cardShopMaster
to the cards on the index page, and implement this class in Sass (to change the cursor and hide the buttons)
- Adjust the code such that the records (complete overview AND filtered results) are sorted alphabetically by
artist
(instead of byid
)
EXERCISE 2: Alternative master page
- Make an alternative, simpler master shop page in which you list all genres (that have records) alphabetically as
h2
-tags, followed by an unordered list of the records (of that genre), sorted alphabetically by artist
TIP
Read some tips to sort all the records of a genre (the hasMany-relation) in this stack overflow post or on the Eloquent documentation page
- Clicking on a record (artist - title) leads to the detail page of that record
- This alternative master page should be reachable through http://localhost:3000/shop_alt
EXERCISE 3: Bootstrap classes
- Add some Bootstrap (flex) classes to give all cards on the same row an equal height.
EXERCISE 4: Alternative feedback message
- Show the selected genre within the feedback message (you may also update the controller if you believe it is necessary)