Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web / ASP.NET / ASP.NET-Core

An Introduction to ASP.NET Core MVC through an Example (Part 3)

5.00/5 (1 vote)
17 Mar 2022CPOL7 min read 9.1K  
An introduction to ASP.NET Core MVC
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:

C#
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:

C#
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:

Image 1

Selecting the Self-Help genre by using the following URL: http://localhost:44333/?genre=Self-Help

Image 2

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:

C#
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:

C#
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:

Razor
<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

Image 3

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:

C#
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:

Razor
<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:

Image 4

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:

C#
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:

Razor
@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:

Image 5

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.

C#
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:

Razor
@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:

Image 6

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:

Image 7

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:

C#
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:

Image 8

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

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)