This article adds support for the BooksStore application to navigate books by genre.
Introduction
In Part 1, the BooksStore
application can display the books in the database on a single page, in Part 2, it can display a smaller number of books on a page, and the user can move from page to page to view the overall catalog. In this article, we will add support for navigating books by genre.
Using the Code
Filtering the Book Objects
First, to filter Book
objects by genre, we are going to add a property called CurrentGenre
to the view model by changing the BooksListViewModel.cs file in the Models/ViewModels folder:
using System.Collections.Generic;
namespace BooksStore.Models.ViewModels
{
public class BooksListViewModel
{
public IEnumerable<Book> Books { get; set; }
public PagingInfo PagingInfo { get; set; }
public string CurrentGenre { get; set; }
}
}
The next step is to update the Home
controller so that the Index
action method will filter Book
objects by genre and use the property we added to the view model to indicate which genre has been selected with the following code:
public IActionResult Index(string genre, int bookPage = 1)
=> View(new BooksListViewModel
{
Books = repository.Books
.Where(p => genre == null || p.Genre == genre)
.OrderBy(p => p.BookID)
.Skip((bookPage - 1) * PageSize)
.Take(PageSize),
PagingInfo = new PagingInfo
{
CurrentPage = bookPage,
ItemsPerPage = PageSize,
TotalItems = repository.Books.Count()
},
CurrentGenre = genre
});
In the preceding code:
- We added a parameter called
genre
. This parameter is used to enhance the LINQ query: if genre is not null
, only those Book
objects with a matching Genre
property are selected. - We also set the value of the
CurrentGenre
property.
Run the application:
Selecting the Self-Help genre by using the following URL: http://localhost:44333/?genre=Self-Help
However, these changes mean that the value of PagingInfo.TotalItems
is incorrectly calculated because it doesn’t take the genre filter into account. And, obviously, we and our users won’t want to navigate to genres by using URLs.
Improving the URL Scheme
We am going to improve the URL scheme by changing the routing configuration in the Configure
method of the Startup
class to create a more useful set of URLs with the following code:
app.UseEndpoints(endpoints =>
{
endpoints.MapControllerRoute("genpage",
"{genre}/{bookPage:int}",
new { Controller = "Home", action = "Index" });
endpoints.MapControllerRoute("page", "{bookPage:int}",
new { Controller = "Home", action = "Index", bookPage = 1 });
endpoints.MapControllerRoute("genre", "{genre}",
new { Controller = "Home", action = "Index", bookPage = 1 });
endpoints.MapControllerRoute("pagination",
"Books/{bookPage}",
new { Controller = "Home", action = "Index", bookPage = 1 });
endpoints.MapDefaultControllerRoute();
});
By using the ASP.NET Core routing system both to handle incoming requests and to generate outgoing URLs, we can ensure that all the URLs in the application are consistent.
Now we need a way to receive additional information from the view without having to add extra properties to the tag helper class. Fortunately, tag helpers have a nice feature that allows properties with a common prefix to be received all together in a single collection. Prefixed values in the MyPageLink.cs file in the BooksStore/MyTagHelper folder with the following code:
public class MyPageLink : TagHelper
{
private IUrlHelperFactory urlHelperFactory;
public MyPageLink(IUrlHelperFactory helperFactory)
{
urlHelperFactory = helperFactory;
}
[ViewContext]
[HtmlAttributeNotBound]
public ViewContext ViewContext { get; set; }
public PagingInfo PageModel { get; set; }
public string PageAction { get; set; }
[HtmlAttributeName(DictionaryAttributePrefix = "page-url-")]
public Dictionary<string, object> PageUrlValues { get; set; }
= new Dictionary<string, object>();
public bool PageClassesEnabled { get; set; } = false;
public string PageClass { get; set; }
public string PageClassNormal { get; set; }
public string PageClassSelected { get; set; }
public override void Process(TagHelperContext context,
TagHelperOutput output)
{
IUrlHelper urlHelper = urlHelperFactory.GetUrlHelper(ViewContext);
TagBuilder result = new TagBuilder("div");
for (int i = 1; i <= PageModel.TotalPages; i++)
{
TagBuilder tag = new TagBuilder("a");
PageUrlValues["bookPage"] = i;
tag.Attributes["href"] = urlHelper.Action(PageAction, PageUrlValues);
tag.Attributes["href"] = urlHelper.Action(PageAction,
new { bookPage = i });
if (PageClassesEnabled)
{
tag.AddCssClass(PageClass);
tag.AddCssClass(i == PageModel.CurrentPage
? PageClassSelected : PageClassNormal);
}
tag.InnerHtml.Append(i.ToString());
result.InnerHtml.AppendHtml(tag);
}
output.Content.AppendHtml(result.InnerHtml);
}
}
We used the HtmlAttributeName
attribute allows us to specify a prefix for attribute names on the element, which in this case will be page-url-
. The value of any attribute whose name begins with this prefix will be added to the dictionary that is assigned to the PageUrlValues
property, which is then passed to the IUrlHelper.Action
method to generate the URL for the href
attribute of the a
elements that the tag helper produces.
In the Index.cshtml file in the BooksStore/Views/Home folder, we are going to add a new attribute to the div
element that is processed by the tag helper, specifying the genre that will be used to generate the URL, with the following markup:
<div page-model="@Model.PagingInfo" page-action="Index" page-classes-enabled="true"
page-class="btn" page-class-normal="btn-outline-dark"
page-class-selected="btn-primary" page-url-genre="@Model.CurrentGenre"
class="btn-group pull-right m-1">
</div>
We added only one new attribute to the view, but any attribute with the same prefix would be added to the dictionary.
Run the application and request https://localhost:44333/Self-Help
The links generated for the pagination links looked like this: http://localhost:44333/1. If the user clicked a page link like this, the genre filter would be lost, and the application would present a page containing books from all genres. By adding the current genre, taken from the view model, we generate URLs like this instead: http://localhost:44333/Self-Help/1. When the user clicks this kind of link, the current genre will be passed to the Index
action method, and the filtering will be preserved.
Creating the Navigation View Component
ASP.NET Core has the concept of view components, which are perfect for creating items such as reusable navigation controls. We are going to create a view component that renders the navigation menu and integrates it into the application by invoking the component from the shared layout.
We are going to create a folder called ViewComponents, which is the conventional home of view components, in the BooksStore
project and added to it a class file named GenreNavigation.cs, which we used to define the class with the following code:
using Microsoft.AspNetCore.Mvc;
namespace BooksStore.ViewComponents
{
public class GenreNavigation : ViewComponent
{
public string Invoke()
{
return "Hello from the Genre Navigation.";
}
}
}
The view component’s Invoke
method is called when the component is used in a Razor view, and the result of the Invoke
method is inserted into the HTML sent to the browser. We want the genre list to appear on all pages, so we are going to use the view component in the shared layout. To do this, we are going to use a view component in the _Layout.cshtml file in the BooksStore/Views/Shared folder with the following markup:
<body>
<div>
<div class="bg-dark text-white p-2">
<span class="navbar-brand ml-2">BOOKS STORE</span>
</div>
<div class="row m-1 p-1">
<div id="genres" class="col-3">
<p>The BooksStore homepage helps you explore Earth's Biggest
Bookstore without ever leaving the comfort of your couch.</p>
<vc:genre-navigation />
</div>
<div class="col-9">
@RenderBody()
</div>
</div>
</div>
</body>
We added the vc:genre-navigation
element, which inserts the view component. This element hyphenates it, such that vc:genre-navigation
specifies the GenreNavigation
class.
Run the application:
Creating the Genre Navigation
We can use the view component to generate the list of components and then use the more expressive Razor syntax to render the HTML that will display them. The first step is to update the view component in the GenreNavigation.cs file in the BooksStore/ViewComponents folder with the following code:
using Microsoft.AspNetCore.Mvc;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using BooksStore.Models;
namespace BooksStore.ViewComponents
{
public class GenreNavigation : ViewComponent
{
private IBooksStoreRepository repository;
public GenreNavigation(IBooksStoreRepository repo)
{
repository = repo;
}
public IViewComponentResult Invoke()
{
return View(repository.Books
.Select(x => x.Genre)
.Distinct()
.OrderBy(x => x));
}
}
}
In the preceding code, the constructor defines an IBooksStoreRepository
parameter. In the Invoke
method, we use LINQ to select and order the set of genres in the repository and pass them as the argument to the View
method, which renders the default Razor partial view, details of which are returned from the method using an IViewComponentResult
object.
Razor uses different conventions for locating with views that are selected by view components. Both the default name of the view and the locations that are searched for the view are different from those used for controllers. To that end, we are going to create the Views/Shared/Components/GenreNavigation folder in the BooksStore
project and add to it a Razor view named Default.cshtml, to which I added the content with the following markup:
@model IEnumerable<string>
<a class="btn btn-block btn-outline-primary" asp-action="Index"
asp-controller="Home" asp-route-genre="">
Home
</a>
@foreach (string genre in Model)
{
<a class="btn btn-block btn-outline-primary"
asp-action="Index" asp-controller="Home"
asp-route-genre="@genre"
asp-route-bookPage="1">
@genre
</a>
}
Run the application to see the genre navigation buttons. If you click a button, the list of items is updated to show only items from the selected genre, as shown in the figure below:
Indicating the Current Genre
We need some clear visual feedback to the user to indicate which genre has been selected. To do this, first step, we are going to use the RouteData
property to access the request data in order to get the value for the currently selected genre. In the following code, we are going to pass the selected genre in the GenreNavigation.cs file in the BooksStore/ViewComponents folder.
public IViewComponentResult Invoke()
{
ViewBag.SelectedGenre = RouteData?.Values["genre"];
return View(repository.Books
.Select(x => x.Genre)
.Distinct()
.OrderBy(x => x));
}
Inside the Invoke
method, we have dynamically assigned a SelectedGenre
property to the ViewBag
object and set its value to be the current genre, which is obtained through the context object returned by the RouteData
property. The ViewBag
is a dynamic object that allows us to define new properties simply by assigning values to them.
Next, we can update the view selected by the view component and vary the CSS classes used to style the links so that the one representing the current genre is distinct. To do this, we are going to change the Default.cshtml file in the Views/Shared/Components/GenreNavigation folder with the following markup:
@model IEnumerable<string>
<a class="btn btn-block btn-outline-primary" asp-action="Index"
asp-controller="Home" asp-route-genre="">
Home
</a>
@foreach (string genre in Model)
{
<a class="btn btn-block
@(genre == ViewBag.SelectedGenre
? "btn-primary": "btn-outline-primary")"
asp-action="Index" asp-controller="Home"
asp-route-genre="@genre"
asp-route-bookPage="1">
@genre
</a>
}
We have used a Razor expression within the class
attribute to apply the btn-primary
class to the element that represents the selected genre and the btn-outline-primary
class otherwise. Run the application and request the Self-Help genre:
Fixing the Pagination
Currently, the number of page links is determined by the total number of books in the repository and not the number of books in the selected genre. This means that we can click the link for page 2 of the Self-Help genre and end up with an empty page because there are not enough books to fill two pages, as the figure below:
We can fix this by updating the Index
action method in the Home
controller so that the pagination information takes the genres into account with the following code:
public IActionResult Index(string genre, int bookPage = 1)
=> View(new BooksListViewModel
{
Books = repository.Books
.Where(p => genre == null || p.Genre == genre)
.OrderBy(p => p.BookID)
.Skip((bookPage - 1) * PageSize)
.Take(PageSize),
PagingInfo = new PagingInfo
{
CurrentPage = bookPage,
ItemsPerPage = PageSize,
TotalItems = genre == null ?
repository.Books.Count() :
repository.Books.Where(e =>
e.Genre == genre).Count()
},
CurrentGenre = genre
});
Run the app:
Points of Interest
We added support for pagination so that the view displays a smaller number of books on a page, and the user can move from page to page to view the overall catalog. We used Bootstrap to style the appearance of the application and we also added support for navigating books by genre. In the next article, we will add a shopping cart that is an important component of an ecommerce application.
History
- 18th March, 2022: Initial version