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

vinyl

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>
1
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)
      Record: automatic import
    • 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');
}
1
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

Dump and die

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

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()



 
 
 


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);
}
1
2
3
4
5
6
7

Get all records

REMARKS

  • When using autocompletion (for the automatic import of the Json class), you should choose the second or third option in the list
    Json: automatic import
  • Replacing Json::dump($result) by return($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 the Json::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();
1

Get all records with genre

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
}
1
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 PHP stdClass 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
  • 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 is null, you can construct it with the MusicBrainz Release ID (stored in the title_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.
  • Update the query result
    • Loop over each record
    • If the property cover (of a record) is null, then replace this property cover with the URL based on title_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';
    }
}
1
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";
}
1
2
3
4
5

Get all records with cover

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

Show all records with dummy data

  • 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

 

 
 
 


 

 
 




<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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

Show all records with real data

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() with paginate(x), where x 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);
1
2
3
4
5
6
7
  • Add a pagination navigation to the view (e.g. one before and one after the loop) using $records->links()

 





 

<h1>Shop</h1>
{{ $records->links() }}
<div class="row">
    @foreach($records as $record)
        ...
    @endforeach
</div>
{{ $records->links() }}   
1
2
3
4
5
6
7
8

Pagination

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

Extra attributes with pagination

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")
    • The two text fields have the same name as the corresponding columns in the database (artist and genre_id)

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
<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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18

Search form

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 by name




 
 
 



$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);
1
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 the id as the value that will be submitted
    • Add 'All genres' with the MySql wildcard (%) as value at the top of the list

 
 
 
 


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

Select with genres

  • 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 property records_count to the Genre models

 
 


$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();
1
2
3
4

Limit genres and add a counter

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
}
1
2
3
4
  • Add the property records_count to the dropdown list and use the PHP function ucfirst() 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>
1
2
3
4
5
6

Select with genres and count

  • 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>
1
2
3
4
5
6

Transform the genres list

  • Add a transform() method call between get() 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
    • All properties that will not be used in the view are removed using the PHP function unset()




 
 
 
 
 
 
 

$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 . ')';
        // Remove all fields that you don't use inside the view
        unset($item->created_at, $item->updated_at, $item->records_count);
        return $item;
    });
1
2
3
4
5
6
7
8
9
10
11

Transform collection

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 the index() function
 




public function index(Request $request)
{
    ...
}
1
2
3
4
  • Use $request->input('name') to get the values of the text field artist and the dropdown list genre_id
  • "Prepare" the resulting values before you use them in the query
  • Extend the query
    • Add two where() method calls to filter the collection
    • Add an appends() method call to append the request parameters to the pagination links
 
 

 
 

 


$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)
    ->where('genre_id', 'like', $genre_id)
    ->paginate(12)
    ->appends(['artist'=> $request->input('artist'), 'genre_id' => $request->input('genre_id')]);
    //OR ->appends(['artist' => $request->artist, 'genre_id' => $request->genre_id]);
1
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 get where('genre_id', 'like', 1) and for 'All genres' you get where('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(...) returns null, which would lead to errors when used in a where() 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 for request->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

Search Search

  • 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>
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19

Search Search

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 the count() 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>&times;</span>
        </button>
    </div>
@endif
1
2
3
4
5
6
7
8
9
10
11
12
13

Feedback if collection is empty

  • 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 AND genre_id) OR (title AND genre_id))
    • Update the query with one where() method and one orWhere() method
    • Use a closure (anonymous function) inside both methods to implement the AND relation



 
 
 
 
 
 
 
 




$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(function ($query) use ($artist_title, $genre_id) {
        $query->where('artist', 'like', $artist_title)
              ->where('genre_id', 'like', $genre_id);
    })
    ->orWhere(function ($query) use ($artist_title, $genre_id) {
        $query->where('title', 'like', $artist_title)
              ->where('genre_id', 'like', $genre_id);
    })
    ->paginate(12)
    ->appends(['artist'=> $request->input('artist'), 'genre_id' => $request->input('genre_id')]);
    //OR ->appends(['artist' => $request->artist, 'genre_id' => $request->genre_id]);
1
2
3
4
5
6
7
8
9
10
11
12
13
14

Search for artist or title

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')
...
1
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.
@section('css_after')
    <style>
        .card {
            cursor: pointer;
        }
        .card .btn, form .btn {
            display: none;
        }
    </style>
@endsection
1
2
3
4
5
6
7
8
9
10

Search form without submit button

REMARK

  • 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 attribute data-id to the card
  • 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>
1
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
    • 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 the src 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
@section('script_after')
    <script>
        $(function () {
            // Get record id and redirect to the detail page
            $('.card').click(function () {
                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
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

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

Optimized layout of search form

  • 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 by id)

Results sorted by artist name

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

  • 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

Alternative master page

EXERCISE 3 (optional): Bootstrap classes

  • Add some Bootstrap (flex) classes to give all cards on the same row an equal height.

Exual height cards

EXERCISE 4 (optional): Alternative feedback message

  • Show the selected genre within the feedback message (you also have to update the controller for this)

Show genre inside the feedback message

Commit "Opdracht 4: <= Shop: master page"

  • Execute the following commands in a terminal window:
git add .
git commit -m "Opdracht 4: <= Shop: master page"
git push
1
2
3
Last Updated: 10/19/2021, 4:20:46 PM