Book Store Project
Step-by-Step Guide to Building the Bookstore Project (Murach's ASP.NET Core MVC)
Here's a detailed, step-by-step guide on how to build the Bookstore project, drawing directly from the concepts and examples presented in "Murach's ASP.NET Core MVC." This guide will walk you through setting up your environment, implementing the database, building the core MVC components, adding shopping cart functionality, creating an administration area with search, and finally, securing your application with authentication and authorization.
Introduction to the Bookstore Website
The Bookstore website is a comprehensive ASP.NET Core MVC application designed to showcase many of the essential and advanced skills covered in the book. It features both a customer-facing interface for browsing and purchasing books, and an administration area for managing books, authors, and genres.
User Interface Overview (Chapter 13, Figures 13-1)
- Customer Pages: Include a Home page with staff picks, a Book Catalog page with paging, sorting, and filtering options, an Author Catalog page with similar features, and a Shopping Cart page. Navigation is handled by a Bootstrap navbar with icons and a cart badge.
- Admin Pages: Accessible via a separate area, these pages allow administrators to add, edit, and delete books, authors, and genres. A search functionality is also provided for books.
Folder Structure (Chapter 13, Figure 13-2)
The project follows a "fat model, skinny controller" philosophy, with a well-organized Models folder containing subfolders for DataLayer, ExtensionMethods, Grid, ViewModels, and DTOs. Controllers and Views are structured according to MVC conventions, with a dedicated Areas/Admin folder for administration functionalities.
Prerequisites
Before you begin, ensure you have the following software and foundational knowledge:
- Software:
- Visual Studio (Community Edition for Windows/Mac) or Visual Studio Code (Chapter 1, Figures 1-9, 1-10; Appendix A, Figure A-1; Appendix B, Figure B-1).
- .NET Core 3.1 or later (Chapter 1, Figure 1-1).
- SQL Server Express LocalDB (Windows) or SQLite (macOS) for database management (Chapter 1, Figure 1-1; Appendix A, Figure A-3; Appendix B, Figure B-3, B-4).
- Foundational Knowledge (from earlier chapters):
- Basic C# and HTML/CSS.
- How to create a single-page MVC web app (Chapter 2).
- How to make a web app responsive with Bootstrap (Chapter 3).
- How to develop a data-driven MVC web app (Chapter 4).
- How to manually test and debug (Chapter 5).
- Working with controllers and routing (Chapter 6).
- Working with Razor views (Chapter 7).
- Transferring data from controllers (Chapter 8).
- Working with session state and cookies (Chapter 9).
- Working with model binding (Chapter 10).
- Validating data (Chapter 11).
Step-by-Step Construction of the Bookstore Project
Step 1: Setup and Initial Project Creation
-
Start a New ASP.NET Core MVC Web App:
- Open Visual Studio (or VS Code).
- Select
File > New > Project(orFile > Open Folderin VS Code). - Choose the
ASP.NET Core Web Application(ormvctemplate in VS Code CLI) and select theWeb Application (Model-View-Controller)template (Chapter 2, Figures 2-1, 2-2; Chapter 17, Figure 17-6). - Give your project a name (e.g.,
Bookstore) and a solution name.
// Example: dotnet CLI command to create a new MVC project dotnet new mvc -n Bookstore -o BookstoreProject -
Set Up MVC Folders:
- If you used the MVC template, delete unnecessary files from
Controllers,Models, andViewsfolders, but keep the folders themselves (Chapter 2, Figure 2-3). - If you used the Empty template, manually add
Controllers,Models,Views, and their necessary subfolders (Home,Shared) (Chapter 2, Figure 2-3).
// Example: Manual folder structure (if starting from Empty template) // ProjectRoot/ // ├── Controllers/ // ├── Models/ // └── Views/ // ├── Home/ // └── Shared/ - If you used the MVC template, delete unnecessary files from
-
Configure the
Startup.csFile:- Edit
Startup.csto correctly configure the middleware for a basic MVC app. EnsureAddControllersWithViews(),UseSession(),UseRouting(), andUseEndpoints()are correctly set up (Chapter 2, Figure 2-6; Chapter 9, Figure 9-2; Chapter 16, Figure 16-7).
// Startup.cs - ConfigureServices method public void ConfigureServices(IServiceCollection services) { services.AddMemoryCache(); // Required for session state services.AddSession(); // Enables session state services.AddControllersWithViews(); // Adds MVC services } // Startup.cs - Configure method public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } else { app.UseExceptionHandler("/Home/Error"); app.UseHsts(); } app.UseHttpsRedirection(); // Redirects HTTP requests to HTTPS app.UseStaticFiles(); // Enables serving static files (CSS, JS, images) app.UseRouting(); // Marks where routing decisions are made app.UseAuthentication(); // Enables authentication middleware (for Identity) app.UseAuthorization(); // Enables authorization middleware (for Identity) app.UseSession(); // Enables session state middleware app.UseEndpoints(endpoints => // Configures endpoints for routes { endpoints.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); }); } - Edit
Step 2: Database and Entity Framework Core (EF Core) Setup
-
Add EF Core to Your Project:
- Install the
Microsoft.EntityFrameworkCore.SqlServer(for Windows) orMicrosoft.EntityFrameworkCore.Sqlite(for macOS) andMicrosoft.EntityFrameworkCore.ToolsNuGet packages (Chapter 4, Figure 4-3).
// Example: dotnet CLI commands to install NuGet packages dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Microsoft.EntityFrameworkCore.Tools // For SQLite on macOS: // dotnet add package Microsoft.EntityFrameworkCore.Sqlite - Install the
-
Code Entity Classes:
- Create
Author,Book,BookAuthor, andGenreentity classes in yourModelsfolder. Define their properties and relationships (e.g.,Bookhas aGenreIdandGenrenavigation property;BookAuthorhas composite primary key and navigation properties forBookandAuthor). IncludeDataAnnotationsattributes for validation and configuration (Chapter 12, Figures 12-1, 12-11).
// Models/Author.cs using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc; // For Remote attribute namespace Bookstore.Models { public class Author { public int AuthorId { get; set; } [Required(ErrorMessage = "Please enter a first name.")] [StringLength(200)] public string FirstName { get; set; } [Required(ErrorMessage = "Please enter a last name.")] [StringLength(200)] // Example of remote validation, assuming ValidationController [Remote("CheckAuthor", "Validation", "", AdditionalFields = "FirstName, Operation")] public string LastName { get; set; } // Read-only property for full name public string FullName => $"{FirstName} {LastName}"; // Navigation property for many-to-many relationship with Book public ICollection<BookAuthor> BookAuthors { get; set; } } } // Models/Book.cs using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace Bookstore.Models { public partial class Book { public int BookId { get; set; } [Required(ErrorMessage = "Please enter a title.")] [StringLength(200)] public string Title { get; set; } [Range(0.0, 1000000.0, ErrorMessage = "Price must be more than 0.")] public double Price { get; set; } [Required(ErrorMessage = "Please select a genre.")] public string GenreId { get; set; } // Foreign key property public Genre Genre { get; set; } // Navigation property // Navigation property for many-to-many relationship with Author public ICollection<BookAuthor> BookAuthors { get; set; } } } // Models/BookAuthor.cs (Linking entity for many-to-many) namespace Bookstore.Models { public class BookAuthor { // Composite primary key and foreign keys public int BookId { get; set; } public int AuthorId { get; set; } // Navigation properties public Book Book { get; set; } public Author Author { get; set; } } } // Models/Genre.cs using System.Collections.Generic; using System.ComponentModel.DataAnnotations; using Microsoft.AspNetCore.Mvc; // For Remote attribute namespace Bookstore.Models { public class Genre { [StringLength(10)] [Required(ErrorMessage = "Please enter a genre id.")] // Example of remote validation [Remote("CheckGenre", "Validation", "")] public string GenreId { get; set; } [StringLength(25)] [Required(ErrorMessage = "Please enter a genre name.")] public string Name { get; set; } // Navigation property for one-to-many relationship with Book public ICollection<Book> Books { get; set; } } } - Create
-
Code the
DbContextClass:- Create a
BookstoreContextclass that inheritsIdentityDbContext<User>(for Identity later) andDbContextOptions<BookstoreContext>. IncludeDbSetproperties for all your entities. OverrideOnModelCreatingto configure relationships (especially many-to-many forBookAuthor) and disable cascading deletes forGenreas needed (Chapter 12, Figures 12-1, 12-12; Chapter 16, Figure 16-5).
// Models/BookstoreContext.cs using Microsoft.EntityFrameworkCore; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; // For IdentityDbContext namespace Bookstore.Models { public class BookstoreContext : IdentityDbContext<User> // Inherit IdentityDbContext { public BookstoreContext(DbContextOptions<BookstoreContext> options) : base(options) { } // DbSet properties for all your entities public DbSet<Author> Authors { get; set; } public DbSet<Book> Books { get; set; } public DbSet<BookAuthor> BookAuthors { get; set; } public DbSet<Genre> Genres { get; set; } protected override void OnModelCreating(ModelBuilder modelBuilder) { // IMPORTANT: Call the base method for Identity tables base.OnModelCreating(modelBuilder); // BookAuthor: set composite primary key modelBuilder.Entity<BookAuthor>() .HasKey(ba => new { ba.BookId, ba.AuthorId }); // BookAuthor: set foreign keys and relationships modelBuilder.Entity<BookAuthor>().HasOne(ba => ba.Book) .WithMany(b => b.BookAuthors) .HasForeignKey(ba => ba.BookId); modelBuilder.Entity<BookAuthor>().HasOne(ba => ba.Author) .WithMany(a => a.BookAuthors) .HasForeignKey(ba => ba.AuthorId); // Book: remove cascading delete with Genre (to prevent accidental deletion of books) modelBuilder.Entity<Book>().HasOne(b => b.Genre) .WithMany(g => g.Books) .OnDelete(DeleteBehavior.Restrict); // Restrict delete behavior // Seed initial data (assuming SeedGenres, SeedBooks, etc. are defined as IEntityTypeConfiguration) modelBuilder.ApplyConfiguration(new SeedGenres()); modelBuilder.ApplyConfiguration(new SeedBooks()); modelBuilder.ApplyConfiguration(new SeedAuthors()); modelBuilder.ApplyConfiguration(new SeedBookAuthors()); } } } - Create a
-
Manage Configuration Files (Optional but Recommended):
- For cleaner code, create separate configuration classes (e.g.,
SeedGenres,SeedBooks,SeedAuthors,SeedBookAuthors) that implementIEntityTypeConfiguration<T>to define seed data and entity configurations. Apply these configurations inBookstoreContext.OnModelCreating(Chapter 12, Figures 12-3, 12-13).
// Models/Configuration/SeedGenres.cs using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Metadata.Builders; namespace Bookstore.Models { internal class SeedGenres : IEntityTypeConfiguration<Genre> { public void Configure(EntityTypeBuilder<Genre> entity) { entity.HasData( new Genre { GenreId = "novel", Name = "Novel" }, new Genre { GenreId = "memoir", Name = "Memoir" }, new Genre { GenreId = "mystery", Name = "Mystery" }, new Genre { GenreId = "scifi", Name = "Science Fiction" }, new Genre { GenreId = "history", Name = "History" } ); } } } // ... similar classes for SeedBooks, SeedAuthors, SeedBookAuthors - For cleaner code, create separate configuration classes (e.g.,
-
Add Connection String and Enable Dependency Injection:
- Add your database connection string to
appsettings.json(e.g.,BookstoreContext). - In
Startup.cs, configureAddDbContextto use yourBookstoreContextand the connection string, enabling dependency injection for your database context (Chapter 4, Figure 4-6; Chapter 12, Figure 12-15).
// appsettings.json { "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } }, "AllowedHosts": "*", "ConnectionStrings": { "BookstoreContext": "Server=(localdb)\\mssqllocaldb;Database=Bookstore;Trusted_Connection=True;MultipleActiveResultSets=true" // For SQLite: "BookstoreContext": "Filename=Bookstore.sqlite" } }// Startup.cs - ConfigureServices method (partial) public void ConfigureServices(IServiceCollection services) { // ... other services services.AddDbContext<BookstoreContext>(options => options.UseSqlServer(Configuration.GetConnectionString("BookstoreContext"))); // For SQLite: options.UseSqlite(Configuration.GetConnectionString("BookstoreContext"))); // ... other services } - Add your database connection string to
-
Use Migrations to Create the Database:
- Open the Package Manager Console (Visual Studio) or Terminal (VS Code).
- Run
Add-Migration Initial(ordotnet ef migrations add Initialin CLI) to create the initial migration file. - Run
Update-Database(ordotnet ef database updatein CLI) to create the database and seed initial data (Chapter 4, Figure 4-7; Chapter 12, Figures 12-4, 12-5; Chapter 17, Figure 17-4, 17-5).
// Example: Package Manager Console commands PM> Add-Migration Initial PM> Update-Database // Example: dotnet CLI commands dotnet ef migrations add Initial dotnet ef database update
Step 3: Data Layer Implementation (Repository and Unit of Work Patterns)
-
Encapsulate EF Core Code:
- Create a
DataLayerfolder within yourModelsfolder. - Implement extension methods for
IQueryable<T>(e.g.,PageBy) to encapsulate common query operations (Chapter 12, Figure 12-22; Chapter 13, Figure 13-3).
// Models/DataLayer/QueryExtensions.cs using System.Linq; namespace Bookstore.Models { public static class QueryExtensions { // Extension method for paging public static IQueryable<T> PageBy<T>(this IQueryable<T> query, int pageNumber, int pageSize) { return query .Skip((pageNumber - 1) * pageSize) .Take(pageSize); } // Other useful string extensions (from Chapter 13, Figure 13-3) public static string Slug(this string s) { /* ... */ return s; } public static bool EqualsNoCase(this string s, string tocompare) { /* ... */ return true; } public static int ToInt(this string s) { /* ... */ return 0; } public static string Capitalize(this string s) { /* ... */ return s; } } } - Create a
-
Implement Generic
QueryOptionsClass:- Create a generic
QueryOptions<T>class to hold parameters for sorting, paging, and filtering, includingOrderByDirectionandWhereClausesfor multiple filters (Chapter 12, Figure 12-23; Chapter 13, Figure 13-4).
// Models/DataLayer/QueryOptions.cs using System; using System.Collections.Generic; using System.Linq.Expressions; namespace Bookstore.Models { public class QueryOptions<T> { // Public properties for sorting, filtering, and paging public Expression<Func<T, Object>> OrderBy { get; set; } public string OrderByDirection { get; set; } = "asc"; // default public int PageNumber { get; set; } public int PageSize { get; set; } private string[] includes; public string Includes { set => includes = value.Replace(" ", "").Split(','); } public string[] GetIncludes() => includes ?? new string[0]; public WhereClauses<T> WhereClauses { get; set; } public Expression<Func<T, bool>> Where // Write-only property to add where clauses { set { if (WhereClauses == null) { WhereClauses = new WhereClauses<T>(); } WhereClauses.Add(value); } } // Read-only properties for checking if options are set public bool HasWhere => WhereClauses != null; public bool HasOrderBy => OrderBy != null; public bool HasPaging => PageNumber > 0 && PageSize > 0; } // Helper class for multiple where clauses public class WhereClauses<T> : List<Expression<Func<T, bool>>> { } } - Create a generic
-
Implement Generic
RepositoryClass:- Create a generic
Repository<T>class that implements anIRepository<T>interface. This class will handle basic CRUD operations and build queries usingQueryOptions(Chapter 12, Figure 12-24; Chapter 13, Figure 13-5).
// Models/DataLayer/IRepository.cs using System.Collections.Generic; using System.Linq; namespace Bookstore.Models { public interface IRepository<T> where T : class { // Read operations IEnumerable<T> List(QueryOptions<T> options); T Get(int id); T Get(string id); T Get(QueryOptions<T> options); int Count { get; } // Get count of filtered items // Write operations void Insert(T entity); void Update(T entity); void Delete(T entity); void Save(); // Saves changes to the database } } // Models/DataLayer/Repository.cs using Microsoft.EntityFrameworkCore; using System.Collections.Generic; using System.Linq; // For LINQ extension methods namespace Bookstore.Models { public class Repository<T> : IRepository<T> where T : class { protected BookstoreContext context { get; set; } private DbSet<T> dbset { get; set; } public Repository(BookstoreContext ctx) { context = ctx; dbset = context.Set<T>(); } private int? count; // Stores count of filtered items public int Count => count ?? dbset.Count(); // Returns count, or queries if not set public virtual IEnumerable<T> List(QueryOptions<T> options) { IQueryable<T> query = BuildQuery(options); return query.ToList(); } public virtual T Get(int id) => dbset.Find(id); public virtual T Get(string id) => dbset.Find(id); public virtual T Get(QueryOptions<T> options) { IQueryable<T> query = BuildQuery(options); return query.FirstOrDefault(); } public virtual void Insert(T entity) => dbset.Add(entity); public virtual void Update(T entity) => dbset.Update(entity); public virtual void Delete(T entity) => dbset.Remove(entity); public virtual void Save() => context.SaveChanges(); private IQueryable<T> BuildQuery(QueryOptions<T> options) { IQueryable<T> query = dbset; // Apply includes foreach (string include in options.GetIncludes()) { query = query.Include(include); } // Apply where clauses if (options.HasWhere) { foreach (var clause in options.WhereClauses) { query = query.Where(clause); } count = query.Count(); // Get filtered count after applying where clauses } // Apply order by if (options.HasOrderBy) { if (options.OrderByDirection == "asc") query = query.OrderBy(options.OrderBy); else query = query.OrderByDescending(options.OrderBy); } // Apply paging if (options.HasPaging) query = query.PageBy(options.PageNumber, options.PageSize); return query; } } } - Create a generic
-
Implement Unit of Work Pattern:
- Create an
IBookstoreUnitOfWorkinterface and aBookstoreUnitOfWorkclass that implements it. This class will contain properties for all your repositories (e.g.,Books,Authors,Genres,BookAuthors) and a singleSave()method to commit changes across all repositories (Chapter 12, Figure 12-25; Chapter 13, Figure 13-14).
// Models/DataLayer/IBookstoreUnitOfWork.cs namespace Bookstore.Models { public interface IBookstoreUnitOfWork { IRepository<Book> Books { get; } IRepository<Author> Authors { get; } IRepository<BookAuthor> BookAuthors { get; } IRepository<Genre> Genres { get; } void Save(); void DeleteCurrentBookAuthors(Book book); void AddNewBookAuthors(Book book, int[] authorids); } } // Models/DataLayer/BookstoreUnitOfWork.cs using System.Linq; using System.Collections.Generic; namespace Bookstore.Models { public class BookstoreUnitOfWork : IBookstoreUnitOfWork { private BookstoreContext context { get; set; } public BookstoreUnitOfWork(BookstoreContext ctx) => context = ctx; // Private repository instances (lazy loaded) private Repository<Book> bookData; private Repository<Author> authorData; private Repository<BookAuthor> bookAuthorData; private Repository<Genre> genreData; // Public properties for repositories public IRepository<Book> Books { get { if (bookData == null) bookData = new Repository<Book>(context); return bookData; } } public IRepository<Author> Authors { get { if (authorData == null) authorData = new Repository<Author>(context); return authorData; } } public IRepository<BookAuthor> BookAuthors { get { if (bookAuthorData == null) bookAuthorData = new Repository<BookAuthor>(context); return bookAuthorData; } } public IRepository<Genre> Genres { get { if (genreData == null) genreData = new Repository<Genre>(context); return genreData; } } public void Save() => context.SaveChanges(); // Helper methods for managing BookAuthors (many-to-many relationship) public void DeleteCurrentBookAuthors(Book book) { var currentAuthors = BookAuthors.List(new QueryOptions<BookAuthor> { Where = ba => ba.BookId == book.BookId }); foreach (BookAuthor ba in currentAuthors) { BookAuthors.Delete(ba); } } public void AddNewBookAuthors(Book book, int[] authorids) { foreach (int id in authorids) { BookAuthor ba = new BookAuthor { BookId = book.BookId, AuthorId = id }; BookAuthors.Insert(ba); } } } } - Create an
Step 4: Core MVC Components (Controllers and Views)
-
Home Controller and Home/Index View:
- Implement the
HomeControllerwith anIndexaction that selects a random book (staff pick) and passes it to theHome/Indexview (Chapter 13, Figure 13-1).
// Controllers/HomeController.cs using Microsoft.AspNetCore.Mvc; using System; using System.Linq; // For Guid.NewGuid() in OrderBy namespace Bookstore.Controllers { public class HomeController : Controller { private IBookstoreUnitOfWork data { get; set; } // Inject Unit of Work public HomeController(IBookstoreUnitOfWork unit) => data = unit; public IActionResult Index() { // Get a random book for "Staff Selection" var randomBook = data.Books.Get(new QueryOptions<Book> { Include = "Genre,BookAuthors.Author", // Include related entities OrderBy = b => Guid.NewGuid() // Order by random GUID }); return View(randomBook); // Pass the random book as the model } // Other actions like About, Contact, etc. } } // Views/Home/Index.cshtml (Simplified example) @model Bookstore.Models.Book // Model is a single Book object @{ ViewData["Title"] = "Home | Staff Selection"; } <header class="jumbotron text-center"> <img src="~/images/logo.png" class="img-fluid center-block" alt="Bookstore Logo" /> <h1 class="mt-3">Staff Selection</h1> </header> <main class="container"> @if (Model != null) { <h2>@Model.Title</h2> <p>By: @foreach (var ba in Model.BookAuthors) { <span>@ba.Author.FullName</span> @if (!ba.Equals(Model.BookAuthors.Last())) { <text>, </text> } } </p> <p>Genre: @Model.Genre.Name</p> <p>Price: @Model.Price.ToString("C2")</p> <a asp-action="Details" asp-controller="Book" asp-route-id="@Model.BookId" asp-route-slug="@Model.Title.Slug()" class="btn btn-primary">View Details</a> } else { <p>No staff selection available at the moment.</p> } </main> - Implement the
-
Author Catalog (Paging and Sorting):
- Create
AuthorControllerwith aListaction that accepts aGridDTO(for paging/sorting parameters). - Use
GridBuilderto manage route data andQueryOptionsto build the database query. - Pass an
AuthorListViewModel(containing authors, current route, and total pages) to theAuthor/Listview. - Design the
Author/Listview to display authors in a table with sortable columns and paging links (Chapter 13, Figures 13-6, 13-7, 13-8, 13-9, 13-10).
// Models/ViewModels/AuthorListViewModel.cs using System.Collections.Generic; namespace Bookstore.Models { public class AuthorListViewModel { public IEnumerable<Author> Authors { get; set; } public RouteDictionary CurrentRoute { get; set; } // For current paging/sorting state public int TotalPages { get; set; } } } // Controllers/AuthorController.cs using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Http; // For HttpContext.Session using System.Linq; // For OrderBy namespace Bookstore.Controllers { public class AuthorController : Controller { private IBookstoreUnitOfWork data { get; set; } public AuthorController(IBookstoreUnitOfWork unit) => data = unit; public IActionResult Index() => RedirectToAction("List"); // Redirect to List action public ViewResult List(GridDTO vals) // Accepts GridDTO for paging/sorting { // Get GridBuilder object, load route segment values, store in session string defaultSortField = nameof(Author.FirstName); var builder = new GridBuilder(HttpContext.Session, vals, defaultSortField); // Create options for querying authors var options = new QueryOptions<Author> { Includes = "BookAuthors.Book", // Include related books PageNumber = builder.CurrentRoute.PageNumber, PageSize = builder.CurrentRoute.PageSize, OrderByDirection = builder.CurrentRoute.SortDirection }; // OrderBy depends on value of SortField route if (builder.CurrentRoute.SortField.EqualsNoCase(defaultSortField)) options.OrderBy = a => a.FirstName; else options.OrderBy = a => a.LastName; // Create and populate AuthorListViewModel var viewModel = new AuthorListViewModel { Authors = data.Authors.List(options), CurrentRoute = builder.CurrentRoute, TotalPages = builder.GetTotalPages(data.Authors.Count) // Total authors count }; return View(viewModel); } public IActionResult Details(int id) { // Logic to get author details and pass to view var author = data.Authors.Get(new QueryOptions<Author> { Where = a => a.AuthorId == id, Includes = "BookAuthors.Book" }); return View(author); } } } // Views/Author/List.cshtml (Simplified example) @model Bookstore.Models.AuthorListViewModel @{ ViewData["Title"] = "Author Catalog"; // Clone routes for sorting links to avoid modifying current route RouteDictionary currentRoute = Model.CurrentRoute; RouteDictionary routesForSorting = Model.CurrentRoute.Clone(); } <h1>Author Catalog</h1> <table class="table table-bordered table-striped table-sm"> <thead class="thead-dark"> <tr> <th> @{ routesForSorting.SetSortAndDirection(nameof(Author.FirstName), currentRoute); } <a asp-action="List" asp-all-route-data="@routesForSorting" class="text-white">First Name</a> </th> <th> @{ routesForSorting.SetSortAndDirection(nameof(Author.LastName), currentRoute); } <a asp-action="List" asp-all-route-data="@routesForSorting" class="text-white">Last Name</a> </th> <th>Books(s)</th> </tr> </thead> <tbody> @foreach (Author author in Model.Authors) { <tr> <td> <a asp-action="Details" asp-route-id="@author.AuthorId" asp-route-slug="@author.FullName.Slug()">@author.FirstName</a> </td> <td> <a asp-action="Details" asp-route-id="@author.AuthorId" asp-route-slug="@author.FullName.Slug()">@author.LastName</a> </td> <td> @foreach (var ba in author.BookAuthors) { <p><a asp-action="Details" asp-controller="Book" asp-route-id="@ba.Book.BookId" asp-route-slug="@ba.Book.Title.Slug()">@ba.Book.Title</a></p> } </td> </tr> } </tbody> </table> <div class="d-flex justify-content-center mt-3"> @{ // Clone routes for paging links RouteDictionary routesForPaging = Model.CurrentRoute.Clone(); for (int i = 1; i <= Model.TotalPages; i++) { routesForPaging.PageNumber = i; <a asp-action="List" asp-all-route-data="@routesForPaging" class="btn btn-primary @(i == currentRoute.PageNumber ? "active" : "")">@i</a> } } </div> - Create
-
Book Catalog (Paging, Sorting, and Filtering):
- Create
BookControllerwithListandFilteractions. - The
Listaction accepts aBooksGridDTO(which extendsGridDTOto include filter parameters). - Use
BooksGridBuilderandBookQueryOptionsto handle paging, sorting, and multiple filtering criteria (by author, genre, price range). - Pass a
BookListViewModel(containing books, filter options, current route, and total pages) to theBook/Listview. - Design the
Book/Listview to include filter dropdowns, sortable columns, and paging links (Chapter 13, Figures 13-11, 13-12, 13-13, 13-14, 13-15, 13-16).
// Models/ViewModels/BookListViewModel.cs using System.Collections.Generic; using System.Linq; namespace Bookstore.Models { public class BookListViewModel { public IEnumerable<Book> Books { get; set; } public RouteDictionary CurrentRoute { get; set; } public int TotalPages { get; set; } // For filter drop-down data public IEnumerable<Author> Authors { get; set; } public IEnumerable<Genre> Genres { get; set; } public Dictionary<string, string> Prices => new Dictionary<string, string> { { "under7", "Under $7" }, { "7to14", "$7 to $14" }, { "over14", "Over $14" } }; } } // Controllers/BookController.cs (Simplified) using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Http; using System.Linq; namespace Bookstore.Controllers { public class BookController : Controller { private IBookstoreUnitOfWork data { get; set; } public BookController(IBookstoreUnitOfWork unit) => data = unit; public ViewResult List(BooksGridDTO values) // Accepts BooksGridDTO for all options { // Initialize BooksGridBuilder to manage route data in session var builder = new BooksGridBuilder(HttpContext.Session, values, defaultSortField: nameof(Book.Title)); // Create query options for books var options = new BookQueryOptions { Includes = "BookAuthors.Author, Genre", OrderByDirection = builder.CurrentRoute.SortDirection, PageNumber = builder.CurrentRoute.PageNumber, PageSize = builder.CurrentRoute.PageSize }; // Apply sorting and filtering based on builder's state options.SortFilter(builder); // Create and populate BookListViewModel var viewModel = new BookListViewModel { Books = data.Books.List(options), Authors = data.Authors.List(new QueryOptions<Author> { OrderBy = a => a.FirstName }), Genres = data.Genres.List(new QueryOptions<Genre> { OrderBy = g => g.Name }), CurrentRoute = builder.CurrentRoute, TotalPages = builder.GetTotalPages(data.Books.Count) }; return View(viewModel); } [HttpPost] public RedirectToActionResult Filter(string[] filter, bool clear = false) { var builder = new BooksGridBuilder(HttpContext.Session); // Load current state if (clear) { builder.ClearFilterSegments(); } else { // Get author object to include slug if needed in the filter segment var author = data.Authors.Get(filter[0].ToInt()); builder.LoadFilterSegments(filter, author); } builder.SaveRouteSegments(); // Save updated route segments to session // Redirect to List action with updated route parameters return RedirectToAction("List", builder.CurrentRoute); } // Details action for individual book details public IActionResult Details(int id) { var book = data.Books.Get(new QueryOptions<Book> { Where = b => b.BookId == id, Includes = "Genre,BookAuthors.Author" }); return View(book); } } } // Views/Book/List.cshtml (Simplified example) @model Bookstore.Models.BookListViewModel @{ ViewData["Title"] = "Book Catalog"; RouteDictionary currentRoute = Model.CurrentRoute; RouteDictionary routesForSorting = Model.CurrentRoute.Clone(); } <h1>Book Catalog</h1> <form asp-action="Filter" method="post" class="form-inline mb-3"> <label class="mr-2">Author:</label> <vc:author-drop-down selected-value="@currentRoute.AuthorFilter"></vc:author-drop-down> <label class="ml-3 mr-2">Genre:</label> <vc:genre-drop-down selected-value="@currentRoute.GenreFilter"></vc:genre-drop-down> <label class="ml-3 mr-2">Price:</label> <vc:price-drop-down selected-value="@currentRoute.PriceFilter"></vc:price-drop-down> <button type="submit" class="btn btn-primary ml-3 mr-2">Filter</button> <button type="submit" class="btn btn-secondary" name="clear" value="true">Clear</button> </form> <form asp-action="Add" asp-controller="Cart" method="post"> <table class="table table-bordered table-striped table-sm"> <thead class="thead-dark"> <tr> <th> <my-sorting-link sort-field="Title" current="@currentRoute">Title</my-sorting-link></th> <th>Author(s)</th> <th> <my-sorting-link sort-field="Genre" current="@currentRoute">Genre</my-sorting-link></th> <th> <my-sorting-link sort-field="Price" current="@currentRoute">Price</my-sorting-link></th> <th></th> </tr> </thead> <tbody> @foreach (Book book in Model.Books) { <tr> <td><partial name="_BookLinkPartial" model="@book" /></td> <td> @foreach (var ba in book.BookAuthors) { <p><partial name="_AuthorLinkPartial" model="@ba.Author" /></p> } </td> <td>@book.Genre?.Name</td> <td>@book.Price.ToString("C2")</td> <td> <button type="submit" name="id" value="@book.BookId" class="btn btn-success btn-sm">Add To Cart</button> </td> </tr> } </tbody> </table> </form> <partial name="_PagingLinksPartial" model="@Model" /> - Create
-
Common Razor View Skills:
- Ensure your views use Razor syntax effectively for code blocks, inline expressions, and conditional statements (Chapter 7, Figures 7-1, 7-2, 7-3).
- Set up a default layout (
_Layout.cshtml) and_ViewStart.cshtmlfor consistent page structure and to enable tag helpers via_ViewImports.cshtml(Chapter 7, Figures 7-4, 7-6). - Utilize built-in tag helpers (
asp-action,asp-controller,asp-for,asp-items,asp-validation-for,asp-validation-summary) for generating URLs, binding data, and displaying validation messages (Chapter 7, Figures 7-7, 7-14, 7-15; Chapter 11, Figure 11-6). - Format numbers in views (Chapter 7, Figure 7-11).
- Pass models to views and display model properties (Chapter 7, Figures 7-12, 7-13).
// Views/_ViewImports.cshtml @using Bookstore @using Bookstore.Models @using Bookstore.Models.ViewModels @using Bookstore.Models.DataLayer @using Bookstore.Models.DataLayer.SeedData @using Bookstore.Models.ExtensionMethods @using Bookstore.Models.Grid @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers // Built-in tag helpers @addTagHelper *, Bookstore // Custom tag helpers from your project// Views/_ViewStart.cshtml @{ Layout = "_Layout"; // Sets the default layout for all views }// Views/Shared/_Layout.cshtml (Simplified) <!DOCTYPE html> <html lang="en"> <head> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <title>Bookstore | @ViewData["Title"]</title> <link rel="stylesheet" href="~/lib/bootstrap/dist/css/bootstrap.min.css" /> <link rel="stylesheet" href="https://use.fontawesome.com/releases/v5.8.1/css/all.css" xintegrity="sha384-50oBUHEmvpQ+1lW4y57PTFmhCaXp0ML5d60M1M7uH2+nqUivzIebhndOJK28anvf" crossorigin="anonymous"> <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" /> </head> <body> <div class="container"> <nav class="navbar navbar-expand-md navbar-dark bg-dark"> <partial name="_NavbarMenuButtonPartial" /> <div class="collapse navbar-collapse" id="menu"> <ul class="navbar-nav mr-auto"> <li class="nav-item"> <a class="nav-link" asp-action="Index" asp-controller="Home" asp-area=""> <span class="fas fa-home"></span> Home </a> </li> <li class="nav-item"> <a class="nav-link" asp-action="List" asp-controller="Book" asp-area=""> <span class="fas fa-book-open"></span> Books </a> </li> <li class="nav-item"> <a class="nav-link" asp-action="List" asp-controller="Author" asp-area=""> <span class="fas fa-user-tie"></span> Authors </a> </li> </ul> <ul class="navbar-nav ml-auto"> <li class="nav-item"> <a class="nav-link" asp-action="Index" asp-controller="Cart" asp-area=""> <span class="fas fa-shopping-cart"></span> Cart <vc:cart-badge></vc:cart-badge> </a> </li> <li class="nav-item"> <a class="nav-link" asp-action="Register" asp-controller="Account" asp-area="">Register</a> </li> <li class="nav-item"> <a class="nav-link" asp-action="Login" asp-controller="Account" asp-area="" class="btn btn-outline-light">Log In</a> </li> <li class="nav-item"> <a class="nav-link" asp-action="Index" asp-controller="Book" asp-area="Admin" my-mark-area-active> <span class="fas fa-cog"></span> Admin </a> </li> </ul> </div> </nav> <header class="jumbotron text-center"> <a asp-action="Index" asp-controller="Home"> <img src="~/images/logo.png" class="img-fluid center-block" alt="Bookstore Logo" /> </a> </header> <main> <my-temp-message></my-temp-message> @RenderBody() </main> </div> <script src="~/lib/jquery/dist/jquery.min.js"></script> <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script> <script src="~/js/site.js" asp-append-version="true"></script> @RenderSection("Scripts", required: false) </body> </html>
Step 5: Shopping Cart Functionality
-
Implement Session and Cookie Extension Methods:
- Create static extension methods for
ISession(SetObject<T>,GetObject<T>) to store complex objects as JSON in session state. - Create static extension methods for
IRequestCookieCollectionandIResponseCookiesto store and retrieve complex objects as JSON in persistent cookies (Chapter 13, Figure 13-3, 13-17; Chapter 9, Figure 9-4, 9-5, 9-6).
// Models/ExtensionMethods/SessionExtensions.cs using Microsoft.AspNetCore.Http; using Newtonsoft.Json; // Requires Newtonsoft.Json NuGet package namespace Bookstore.Models.ExtensionMethods { public static class SessionExtensions { public static void SetObject<T>(this ISession session, string key, T value) { session.SetString(key, JsonConvert.SerializeObject(value)); } public static T GetObject<T>(this ISession session, string key) { var value = session.GetString(key); return value == null ? default(T) : JsonConvert.DeserializeObject<T>(value); } } } // Models/ExtensionMethods/CookieExtensions.cs using Microsoft.AspNetCore.Http; using Newtonsoft.Json; using System; using System.Linq; // For Sum() namespace Bookstore.Models.ExtensionMethods { public static class CookieExtensions { public static string GetString(this IRequestCookieCollection cookies, string key) => cookies[key]; public static int? GetInt32(this IRequestCookieCollection cookies, string key) => int.TryParse(cookies[key], out int i) ? i : (int?)null; public static T GetObject<T>(this IRequestCookieCollection cookies, string key) { var value = cookies[key]; return value == null ? default(T) : JsonConvert.DeserializeObject<T>(value); } public static void SetString(this IResponseCookies cookies, string key, string value, int days = 30) { cookies.Delete(key); // Delete old value first if (days == 0) // Session cookie { cookies.Append(key, value); } else // Persistent cookie { CookieOptions options = new CookieOptions { Expires = DateTime.Now.AddDays(days) }; cookies.Append(key, value, options); } } public static void SetInt32(this IResponseCookies cookies, string key, int value, int days = 30) => cookies.SetString(key, value.ToString(), days); public static void SetObject<T>(this IResponseCookies cookies, string key, T value, int days = 30) => cookies.SetString(key, JsonConvert.SerializeObject(value), days); } } - Create static extension methods for
-
Define Cart Model Classes:
- Create
CartItemclass (storesBookDTOandQuantity, calculatesSubtotal, usesJsonIgnorefor calculated properties). - Create
BookDTOclass (a Data Transfer Object forBookto avoid circular references and store minimal data). - Create
CartItemDTOclass (minimal data for persistent cookies). - Create
CartViewModel(for passing cart data to the view) (Chapter 13, Figures 13-18, 13-19).
// Models/CartItem.cs using Newtonsoft.Json; // For JsonIgnore namespace Bookstore.Models { public class CartItem { public BookDTO Book { get; set; } public int Quantity { get; set; } [JsonIgnore] // Don't serialize this calculated property public double Subtotal => Book.Price * Quantity; } } // Models/BookDTO.cs (Data Transfer Object for Book) using System.Collections.Generic; using System.Linq; namespace Bookstore.Models { public class BookDTO { public int BookId { get; set; } public string Title { get; set; } public double Price { get; set; } public Dictionary<int, string> Authors { get; set; } // AuthorId and FullName // Method to load data from a full Book entity public void Load(Book book) { BookId = book.BookId; Title = book.Title; Price = book.Price; Authors = new Dictionary<int, string>(); foreach (BookAuthor ba in book.BookAuthors) { Authors.Add(ba.Author.AuthorId, ba.Author.FullName); } } } } // Models/CartItemDTO.cs (Minimal data for persistent cookie) namespace Bookstore.Models { public class CartItemDTO { public int BookId { get; set; } public int Quantity { get; set; } } } // Models/ViewModels/CartViewModel.cs using System.Collections.Generic; namespace Bookstore.Models.ViewModels { public class CartViewModel { public IEnumerable<CartItem> List { get; set; } public RouteDictionary BookGridRoute { get; set; } // For "Back to Shopping" link public double Subtotal { get; set; } } } - Create
-
Implement the
CartClass:- Create a
Cartclass responsible for managing cart items, loading them from session or cookies, adding/editing/removing items, and saving changes to both session state and persistent cookies (Chapter 13, Figure 13-20).
// Models/Cart.cs using Microsoft.AspNetCore.Http; using Bookstore.Models.ExtensionMethods; // For SessionExtensions and CookieExtensions using System.Collections.Generic; using System.Linq; namespace Bookstore.Models { public class Cart { private const string CartKey = "mycart"; private const string CountKey = "mycount"; private List<CartItem> items { get; set; } private List<CartItemDTO> storedItems { get; set; } // For items from persistent cookie private ISession session { get; set; } private IRequestCookieCollection requestCookies { get; set; } private IResponseCookies responseCookies { get; set; } public Cart(IHttpContextAccessor ctx) // Injected HttpContextAccessor { session = ctx.HttpContext.Session; requestCookies = ctx.HttpContext.Request.Cookies; responseCookies = ctx.HttpContext.Response.Cookies; items = new List<CartItem>(); // Initialize to prevent NullReferenceException in tests } // Load cart items from session or persistent cookie public void Load(IRepository<Book> data) { items = session.GetObject<List<CartItem>>(CartKey); // Try to get from session if (items == null) // If not in session, try to get from cookie { items = new List<CartItem>(); storedItems = requestCookies.GetObject<List<CartItemDTO>>(CartKey); } // If items from cookie are more than session, restore session from cookie if (storedItems?.Count > items?.Count) { foreach (CartItemDTO storedItem in storedItems) { var book = data.Get(new QueryOptions<Book> { Include = "BookAuthors.Author, Genre", Where = b => b.BookId == storedItem.BookId }); if (book != null) // Only add if book still exists in DB { var dto = new BookDTO(); dto.Load(book); CartItem item = new CartItem { Book = dto, Quantity = storedItem.Quantity }; items.Add(item); } } Save(); // Save restored items to session and cookie } } // Read-only properties for cart data public double Subtotal => items.Sum(c => c.Subtotal); public int? Count => session.GetInt32(CountKey) ?? requestCookies.GetInt32(CountKey); // Get count from session or cookie public IEnumerable<CartItem> List => items; public CartItem GetById(int id) => items.FirstOrDefault(ci => ci.Book.BookId == id); public void Add(CartItem item) { var itemInCart = GetById(item.Book.BookId); if (itemInCart == null) // If new item, add it { items.Add(item); } else // Otherwise, increase quantity by 1 { itemInCart.Quantity += 1; } } public void Edit(CartItem item) { var itemInCart = GetById(item.Book.BookId); if (itemInCart != null) { itemInCart.Quantity = item.Quantity; // Update quantity } } public void Remove(CartItem item) => items.Remove(item); public void Clear() => items.Clear(); // Save cart items to session and persistent cookie public void Save() { if (items.Count == 0) { session.Remove(CartKey); session.Remove(CountKey); responseCookies.Delete(CartKey); responseCookies.Delete(CountKey); } else { session.SetObject<List<CartItem>>(CartKey, items); session.SetInt32(CountKey, items.Count); responseCookies.SetObject<List<CartItemDTO>>( CartKey, items.ToDTO()); // Convert CartItem list to DTO list for cookie responseCookies.SetInt32(CountKey, items.Count); } } } } - Create a
-
Implement
CartControllerandCart/IndexView:- Create
CartControllerwith actions forIndex(display cart),Add(add book to cart),Remove(remove item),Clear(clear cart), andEdit(edit item quantity). - The
Addaction should use the Post-Redirect-Get (PRG) pattern andTempDatafor messages. - The
Indexaction should load the cart data and pass it to theCartViewModel. - Design the
Cart/Indexview to display cart items, subtotal, and buttons for checkout, clearing the cart, and continuing shopping (Chapter 13, Figures 13-21, 13-22; Chapter 8, Figure 8-13, 8-14).
// Controllers/CartController.cs using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Http; // For IHttpContextAccessor using Bookstore.Models.ExtensionMethods; // For SessionExtensions using Bookstore.Models.ViewModels; using System.Linq; namespace Bookstore.Controllers { public class CartController : Controller { private IBookstoreUnitOfWork data { get; set; } // Injected Unit of Work private Cart cart { get; set; } // Model-level Cart object // Constructor with dependency injection public CartController(IBookstoreUnitOfWork unit, IHttpContextAccessor accessor) { data = unit; cart = new Cart(accessor); // Create Cart object using accessor cart.Load(data); // Load cart data } public ViewResult Index() { var builder = new BooksGridBuilder(HttpContext.Session); // For "Back to Shopping" link var viewModel = new CartViewModel { List = cart.List, Subtotal = cart.Subtotal, BookGridRoute = builder.CurrentRoute // Pass current book catalog route }; return View(viewModel); } [HttpPost] public RedirectToActionResult Add(int id) // BookId from button value { var book = data.Books.Get(new QueryOptions<Book> { Include = "BookAuthors.Author, Genre", Where = b => b.BookId == id }); if (book == null) { TempData["message"] = "Unable to add book to cart."; } else { var dto = new BookDTO(); dto.Load(book); CartItem item = new CartItem { Book = dto, Quantity = 1 }; // Default quantity cart.Add(item); cart.Save(); // Save changes to session and cookie TempData["message"] = $"{book.Title} added to cart."; } var builder = new BooksGridBuilder(HttpContext.Session); return RedirectToAction("List", "Book", builder.CurrentRoute); // PRG pattern } [HttpPost] public RedirectToActionResult Remove(int id) // BookId from button value { CartItem item = cart.GetById(id); if (item != null) { cart.Remove(item); cart.Save(); TempData["message"] = $"{item.Book.Title} removed from cart."; } return RedirectToAction("Index"); // PRG pattern } [HttpPost] public RedirectToActionResult Clear() { cart.Clear(); cart.Save(); TempData["message"] = "Your cart has been cleared."; return RedirectToAction("Index"); // PRG pattern } // Edit actions (GET and POST) are similar to other edit forms [HttpGet] public IActionResult Edit(int id) { var item = cart.GetById(id); if (item == null) { TempData["message"] = "Cart item not found."; return RedirectToAction("Index"); } return View(item); } [HttpPost] public IActionResult Edit(CartItem item) { if (ModelState.IsValid) { cart.Edit(item); cart.Save(); TempData["message"] = $"{item.Book.Title} quantity updated."; return RedirectToAction("Index"); } return View(item); } } } // Views/Cart/Index.cshtml (Simplified) @model Bookstore.Models.ViewModels.CartViewModel @{ ViewData["Title"] = "Your Cart"; } <h1>Your Cart</h1> <form asp-action="Clear" method="post" class="mb-3"> <ul class="list-group"> <li class="list-group-item"> <div class="row"> <div class="col">Subtotal: @Model.Subtotal.ToString("C2")</div> <div class="col"> <div class="float-right"> <a asp-action="Checkout" class="btn btn-primary mr-2">Checkout</a> <button type="submit" class="btn btn-danger mr-2">Clear Cart</button> <a asp-action="List" asp-controller="Book" asp-all-route-data="@Model.BookGridRoute" class="btn btn-secondary">Back to Shopping</a> </div> </div> </div> </li> </ul> </form> <form asp-action="Remove" method="post"> <table class="table table-bordered table-striped"> <thead class="thead-dark"> <tr> <th>Title</th> <th>Author(s)</th> <th>Price</th> <th>Quantity</th> <th>Subtotal</th> <th></th> </tr> </thead> <tbody> @foreach (CartItem item in Model.List) { <tr> <td><partial name="_BookLinkPartial" model="@item.Book" /></td> <td> @foreach (var author in item.Book.Authors) { <p><partial name="_AuthorLinkPartial" model="@new Author { AuthorId = author.Key, FirstName = author.Value.Split(' ')[0], LastName = author.Value.Split(' ')[1] }" /></p> } </td> <td>@item.Book.Price.ToString("C2")</td> <td>@item.Quantity</td> <td>@item.Subtotal.ToString("C2")</td> <td> <div class="float-right"> <a asp-action="Edit" asp-controller="Cart" asp-route-id="@item.Book.BookId" asp-route-slug="@item.Book.Title.Slug()" class="btn btn-info btn-sm mr-2">Edit</a> <button type="submit" name="id" value="@item.Book.BookId" class="btn btn-danger btn-sm">Remove</button> </div> </td> </tr> } </tbody> </table> </form> - Create
Step 6: Admin Area and Search Functionality
-
Set Up Admin Area:
- Create an
Areas/Adminfolder structure mirroring the main MVC structure (Controllers,Models,Views). - Configure a specific route for the Admin area in
Startup.csusingMapAreaControllerRoute(Chapter 6, Figure 6-11).
// Startup.cs - Configure method (partial) public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { // ... other middleware app.UseEndpoints(endpoints => { // Admin area route (most specific) endpoints.MapAreaControllerRoute( name: "admin", areaName: "Admin", pattern: "Admin/{controller=Book}/{action=Index}/{id?}/{slug?}"); // Default to Book in Admin area // Custom route for Book/Author catalog paging/sorting/filtering endpoints.MapControllerRoute( name: "filtered_books_authors", pattern: "{controller}/{action}/page/{pagenumber}/size/{pagesize}/sort/{sortfield}/{sortdirection}/filter/{author}/{genre}/{price}"); // Default route endpoints.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}/{slug?}"); }); } - Create an
-
Implement Admin Book Search:
- Create
SearchDataclass to store search terms and types inTempData(usingPeek()for persistence across multiple actions) (Chapter 13, Figure 13-24). - Create
SearchViewModelfor passing search results to the view. - Implement
Search()action methods (GET and POST) inAdmin/BookController. The POST action validates input, stores search data inTempData, and redirects to the GET action. The GET action retrieves search data fromTempData, builds a query based on search type (title, author, genre), executes it, and passes results to aSearchResultsview (Chapter 13, Figures 13-23, 13-24, 13-25).
// Models/SearchData.cs using Microsoft.AspNetCore.Mvc.ViewFeatures; // For ITempDataDictionary using Bookstore.Models.ExtensionMethods; // For EqualsNoCase, ToInt namespace Bookstore.Models { public class SearchData { private const string SearchKey = "search"; private const string TypeKey = "searchtype"; private ITempDataDictionary tempData { get; set; } public SearchData(ITempDataDictionary temp) => tempData = temp; // Use Peek() rather than a straight read so value will persist public string SearchTerm { get => tempData.Peek(SearchKey)?.ToString(); set => tempData[SearchKey] = value; } public string Type { get => tempData.Peek(TypeKey)?.ToString(); set => tempData[TypeKey] = value; } public bool HasSearchTerm => !string.IsNullOrEmpty(SearchTerm); public bool IsBook => Type.EqualsNoCase("book"); public bool IsAuthor => Type.EqualsNoCase("author"); public bool IsGenre => Type.EqualsNoCase("genre"); public void Clear() { tempData.Remove(SearchKey); tempData.Remove(TypeKey); } } } // Models/ViewModels/SearchViewModel.cs using System.Collections.Generic; using System.ComponentModel.DataAnnotations; namespace Bookstore.Models.ViewModels { public class SearchViewModel { public IEnumerable<Book> Books { get; set; } [Required(ErrorMessage = "Please enter a search term.")] public string SearchTerm { get; set; } public string Type { get; set; } // "book", "author", "genre" public string Header { get; set; } // Header text for search results } } // Areas/Admin/Controllers/BookController.cs (Simplified Search actions) using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ViewFeatures; // For ITempDataDictionary using System.Linq; // For Contains, Any using Bookstore.Models.ExtensionMethods; // For Slug, ToInt, EqualsNoCase namespace Bookstore.Areas.Admin.Controllers { [Area("Admin")] // [Authorize(Roles = "Admin")] // Uncomment to restrict access public class BookController : Controller { private IBookstoreUnitOfWork data { get; set; } public BookController(IBookstoreUnitOfWork unit) => data = unit; // POST action for search form submission [HttpPost] public RedirectToActionResult Search(SearchViewModel vm) { if (ModelState.IsValid) { var search = new SearchData(TempData) { SearchTerm = vm.SearchTerm, Type = vm.Type }; return RedirectToAction("Search"); // Redirect to GET Search action } else { // If validation fails, return to the Index view (Manage Books tab) return RedirectToAction("Index"); } } // GET action to display search results [HttpGet] public ViewResult Search() { var search = new SearchData(TempData); // Load search data from TempData if (search.HasSearchTerm) { var viewModel = new SearchViewModel { SearchTerm = search.SearchTerm }; var options = new QueryOptions<Book> { Includes = "Genre, BookAuthors.Author" // Include related data }; // Build query based on search type if (search.IsBook) { options.Where = b => b.Title.Contains(viewModel.SearchTerm); viewModel.Header = $"Search results for book title '{viewModel.SearchTerm}'"; } else if (search.IsAuthor) { // Handle single word or multiple words in author search term int index = viewModel.SearchTerm.LastIndexOf(' '); if (index == -1) // Single word { options.Where = b => b.BookAuthors.Any( ba => ba.Author.FirstName.Contains(viewModel.SearchTerm) || ba.Author.LastName.Contains(viewModel.SearchTerm)); } else // Multiple words (assume First Last) { string first = viewModel.SearchTerm.Substring(0, index); string last = viewModel.SearchTerm.Substring(index + 1); options.Where = b => b.BookAuthors.Any( ba => ba.Author.FirstName.Contains(first) && ba.Author.LastName.Contains(last)); } viewModel.Header = $"Search results for author '{viewModel.SearchTerm}'"; } else if (search.IsGenre) { options.Where = b => b.GenreId.Contains(viewModel.SearchTerm); viewModel.Header = $"Search results for genre ID '{viewModel.SearchTerm}'"; } viewModel.Books = data.Books.List(options); // Execute query return View("SearchResults", viewModel); } else { // If no search term, redirect back to the main admin index return View("Index"); } } // Other CRUD actions for books (Add, Edit, Delete) // ... } } // Views/Admin/Book/SearchResults.cshtml (Simplified) @model Bookstore.Models.ViewModels.SearchViewModel @{ ViewData["Title"] = "Search Results"; } <h1>@Model.Header</h1> @if (Model.Books.Any()) { <table class="table table-bordered table-striped"> <thead class="thead-dark"> <tr> <th>Title</th> <th>Author(s)</th> <th>Genre</th> <th>Price</th> <th></th> </tr> </thead> <tbody> @foreach (var book in Model.Books) { <tr> <td><partial name="_BookLinkPartial" model="@book" /></td> <td> @foreach (var ba in book.BookAuthors) { <p><partial name="_AuthorLinkPartial" model="@ba.Author" /></p> } </td> <td>@book.Genre?.Name</td> <td>@book.Price.ToString("C2")</td> <td> <a asp-action="Edit" asp-route-id="@book.BookId" class="btn btn-info btn-sm mr-2">Edit</a> <a asp-action="Delete" asp-route-id="@book.BookId" class="btn btn-danger btn-sm">Delete</a> </td> </tr> } </tbody> </table> } else { <p>No books found matching your search criteria.</p> } <a asp-action="Index" class="btn btn-secondary">Back to Manage Books</a> - Create
-
Implement Genre/Author Delete with Related Books Check:
- Modify
Admin/GenreController'sDelete()action (GET) to check for related books. If books exist, redirect to the book search results page, pre-filtered by that genre, usingSearchData(Chapter 13, Figure 13-26). A similar logic applies to authors.
// Areas/Admin/Controllers/GenreController.cs (Simplified Delete action) using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.ViewFeatures; // For ITempDataDictionary using System.Linq; namespace Bookstore.Areas.Admin.Controllers { [Area("Admin")] // [Authorize(Roles = "Admin")] // Uncomment to restrict access public class GenreController : Controller { private IBookstoreUnitOfWork data { get; set; } public GenreController(IBookstoreUnitOfWork unit) => data = unit; [HttpGet] public IActionResult Delete(string id) { var genre = data.Genres.Get(new QueryOptions<Genre> { Include = "Books", // Include related books to check for dependencies Where = g => g.GenreId == id }); if (genre == null) { TempData["message"] = "Genre not found."; return RedirectToAction("Index"); } if (genre.Books.Count > 0) // Check if genre has associated books { TempData["message"] = $"Can't delete genre '{genre.Name}' because it's associated with these books."; return GoToBookSearchResults(id); // Redirect to book search results } else { return View("Genre", genre); // Display confirmation for deletion } } [HttpPost] public RedirectToActionResult Delete(Genre genre) { data.Genres.Delete(genre); data.Save(); TempData["message"] = $"Genre '{genre.Name}' deleted."; return RedirectToAction("Index"); } // Private helper method to redirect to book search results private RedirectToActionResult GoToBookSearchResults(string id) { // Display search results of all books in this genre var search = new SearchData(TempData) { SearchTerm = id, Type = "genre" // Set search type to genre }; return RedirectToAction("Search", "Book"); // Redirect to Admin/Book/Search } // Other CRUD actions for genres (Index, Add, Edit) // ... } } - Modify
Step 7: Authentication and Authorization
-
Introduce ASP.NET Core Identity:
- Understand the concepts of authentication, authorization, and the benefits of ASP.NET Identity (Chapter 16, Figures 16-1, 16-2, 16-3).
- Authentication: Verifies user identity (e.g., username/password).
- Authorization: Checks if the authenticated user has permission to access a resource.
- ASP.NET Identity: A membership system for managing users, passwords, roles, etc.
-
Add Identity to DB Context and Tables:
- Modify
Userentity to inheritIdentityUserand add custom fields (e.g.,FirstName,LastName). - Modify
BookstoreContextto inheritIdentityDbContext<User>. - Add a migration for Identity tables (
AspNetUsers,AspNetRoles, etc.) and update the database (Chapter 16, Figures 16-5, 16-6).
// Models/User.cs (Updated for Identity) using Microsoft.AspNetCore.Identity; using System.Collections.Generic; using System.ComponentModel.DataAnnotations.Schema; // For NotMapped namespace Bookstore.Models { public class User : IdentityUser // Inherit from IdentityUser { // Add custom fields if needed public string FirstName { get; set; } public string LastName { get; set; } [NotMapped] // This property is not mapped to the database public IList<string> RoleNames { get; set; } // For displaying user roles } } // Models/BookstoreContext.cs (Updated for Identity) // ... (using statements) using Microsoft.AspNetCore.Identity.EntityFrameworkCore; // For IdentityDbContext namespace Bookstore.Models { public class BookstoreContext : IdentityDbContext<User> // Inherit IdentityDbContext with your User class { // ... (constructor and DbSet properties) protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); // IMPORTANT: Call the base method for Identity tables // ... (other entity configurations and seed data) } } } - Modify
-
Configure Middleware for Identity:
- In
Startup.cs, addAddIdentityservice configuration (including password options) andUseAuthentication()/UseAuthorization()middleware in the correct order (Chapter 16, Figure 16-7).
// Startup.cs - ConfigureServices method (partial) public void ConfigureServices(IServiceCollection services) { // ... (other services like AddDbContext, AddSession, AddMemoryCache) // Configure Identity services services.AddIdentity<User, IdentityRole>(options => { // Password settings (example: relaxed for development) options.Password.RequiredLength = 6; options.Password.RequireNonAlphanumeric = false; options.Password.RequireDigit = false; options.Password.RequireUppercase = true; options.Password.RequireLowercase = true; }) .AddEntityFrameworkStores<BookstoreContext>() // Use your DbContext for Identity .AddDefaultTokenProviders(); // For password reset tokens, etc. services.AddControllersWithViews() .AddNewtonsoftJson(); // If using Newtonsoft.Json for session/cookies } // Startup.cs - Configure method (partial) public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { // ... (developer exception page, static files, etc.) app.UseRouting(); app.UseAuthentication(); // Must be before UseAuthorization app.UseAuthorization(); // Must be after UseAuthentication app.UseSession(); // If using session state app.UseEndpoints(endpoints => { // ... (your routing configurations, including Admin area route) }); // Seed admin user and role (see next step for implementation) // BookstoreContext.CreateAdminUser(app.ApplicationServices).Wait(); } - In
-
Implement User Registration and Login/Logout:
- Create
RegisterViewModelandLoginViewModelwith validation attributes. - Implement
AccountControllerwithRegister()(GET/POST),LogIn()(GET/POST), andLogOut()(POST) action methods. UseUserManagerandSignInManagerfor user management and authentication. - Update the layout to display login/logout buttons and user info based on authentication status (Chapter 16, Figures 16-8, 16-10, 16-11, 16-12, 16-13, 16-14, 16-15).
// Models/ViewModels/RegisterViewModel.cs using System.ComponentModel.DataAnnotations; namespace Bookstore.Models.ViewModels { public class RegisterViewModel { [Required(ErrorMessage = "Please enter a username.")] [StringLength(255)] public string Username { get; set; } // Custom fields for registration (added in Chapter 16, Figure 16-24) [Required(ErrorMessage = "Please enter a first name.")] [StringLength(255)] public string FirstName { get; set; } [Required(ErrorMessage = "Please enter a last name.")] [StringLength(255)] public string LastName { get; set; } [Required(ErrorMessage = "Please enter an email address.")] [DataType(DataType.EmailAddress)] public string Email { get; set; } [Required(ErrorMessage = "Please enter a password.")] [DataType(DataType.Password)] [Compare("ConfirmPassword")] // Compares with ConfirmPassword property public string Password { get; set; } [Required(ErrorMessage = "Please confirm your password.")] [DataType(DataType.Password)] [Display(Name = "Confirm Password")] // Display name for validation messages public string ConfirmPassword { get; set; } } } // Models/ViewModels/LoginViewModel.cs using System.ComponentModel.DataAnnotations; namespace Bookstore.Models.ViewModels { public class LoginViewModel { [Required(ErrorMessage = "Please enter a username.")] [StringLength(255)] public string Username { get; set; } [Required(ErrorMessage = "Please enter a password.")] [StringLength(255)] public string Password { get; set; } public string ReturnUrl { get; set; } // For redirecting after login public bool RememberMe { get; set; } // For persistent cookie } } // Controllers/AccountController.cs (Simplified) using System.Threading.Tasks; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Identity; // For UserManager, SignInManager using Bookstore.Models; // Your User class using Bookstore.Models.ViewModels; namespace Bookstore.Controllers { public class AccountController : Controller { private UserManager<User> userManager; private SignInManager<User> signInManager; private RoleManager<IdentityRole> roleManager; // For role management // Constructor with dependency injection for Identity managers public AccountController(UserManager<User> userMngr, SignInManager<User> signInMngr, RoleManager<IdentityRole> roleMngr) { userManager = userMngr; signInManager = signInMngr; roleManager = roleMngr; } // GET: /Account/Register [HttpGet] public IActionResult Register() { return View(); } // POST: /Account/Register [HttpPost] public async Task<IActionResult> Register(RegisterViewModel model) { if (ModelState.IsValid) { // Create a new User object var user = new User { UserName = model.Username, FirstName = model.FirstName, // Custom field LastName = model.LastName, // Custom field Email = model.Email // IdentityUser field }; // Create the user in the Identity system var result = await userManager.CreateAsync(user, model.Password); if (result.Succeeded) { // Sign in the newly registered user await signInManager.SignInAsync(user, isPersistent: false); return RedirectToAction("Index", "Home"); // Redirect to home on success } else { // Add Identity errors to ModelState foreach (var error in result.Errors) { ModelState.AddModelError("", error.Description); } } } return View(model); // Return to view with validation errors } // GET: /Account/Login [HttpGet] public IActionResult LogIn(string returnUrl = "") { var model = new LoginViewModel { ReturnUrl = returnUrl }; return View(model); } // POST: /Account/Login [HttpPost] public async Task<IActionResult> LogIn(LoginViewModel model) { if (ModelState.IsValid) { // Attempt to sign in the user var result = await signInManager.PasswordSignInAsync( model.Username, model.Password, isPersistent: model.RememberMe, // Use persistent cookie if "Remember Me" is checked lockoutOnFailure: false); // Don't lock out on failure (for simplicity) if (result.Succeeded) { // Redirect to ReturnUrl if it's a local URL, otherwise to Home if (!string.IsNullOrEmpty(model.ReturnUrl) && Url.IsLocalUrl(model.ReturnUrl)) { return Redirect(model.ReturnUrl); } else { return RedirectToAction("Index", "Home"); } } } ModelState.AddModelError("", "Invalid username/password."); // Generic error for invalid credentials return View(model); // Return to view with error } // POST: /Account/Logout [HttpPost] public async Task<IActionResult> LogOut() { await signInManager.SignOutAsync(); // Sign out the current user return RedirectToAction("Index", "Home"); // Redirect to home after logout } // GET: /Account/AccessDenied [HttpGet] public ViewResult AccessDenied() { return View(); // Displays a simple access denied page } } } - Create
-
Implement User and Role Management:
- Create
UserViewModel(for displaying users and roles). - Implement
Admin/UserControllerwithIndex()(display users/roles),Add()(add user),Delete()(delete user),AddToAdmin()(add user to Admin role),RemoveFromAdmin()(remove user from Admin role),CreateAdminRole()(create Admin role), andDeleteRole()(delete role) actions. - Use
UserManagerandRoleManagerto manage users and roles. - Restrict access to
AdminControllers using[Authorize(Roles = "Admin")](Chapter 16, Figures 16-16, 16-17, 16-18, 16-19, 16-20, 16-21). - Seed an initial Admin user and role in
BookstoreContext(Chapter 16, Figure 16-22).
// Models/ViewModels/UserViewModel.cs using System.Collections.Generic; using Microsoft.AspNetCore.Identity; // For IdentityRole namespace Bookstore.Models.ViewModels { public class UserViewModel { public IEnumerable<User> Users { get; set; } // Collection of users public IEnumerable<IdentityRole> Roles { get; set; } // Collection of Identity roles } } // Areas/Admin/Controllers/UserController.cs (Simplified) using System.Threading.Tasks; using System.Collections.Generic; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Authorization; // For Authorize attribute using Microsoft.AspNetCore.Identity; // For UserManager, RoleManager using Bookstore.Models; // Your User class using Bookstore.Models.ViewModels; namespace Bookstore.Areas.Admin.Controllers { [Area("Admin")] [Authorize(Roles = "Admin")] // Restrict access to users in "Admin" role public class UserController : Controller { private UserManager<User> userManager; private RoleManager<IdentityRole> roleManager; public UserController(UserManager<User> userMngr, RoleManager<IdentityRole> roleMngr) { userManager = userMngr; roleManager = roleMngr; } // GET: /Admin/User/Index - Displays list of users and roles public async Task<IActionResult> Index() { List<User> users = new List<User>(); foreach (User user in userManager.Users) { // Get roles for each user user.RoleNames = await userManager.GetRolesAsync(user); users.Add(user); } UserViewModel model = new UserViewModel { Users = users, Roles = roleManager.Roles // All available roles }; return View(model); } // POST: /Admin/User/Delete - Deletes a user [HttpPost] public async Task<IActionResult> Delete(string id) { User user = await userManager.FindByIdAsync(id); if (user != null) { IdentityResult result = await userManager.DeleteAsync(user); if (!result.Succeeded) { // Handle errors if deletion fails string errorMessage = ""; foreach (IdentityError error in result.Errors) { errorMessage += error.Description + " | "; } TempData["message"] = errorMessage; } } return RedirectToAction("Index"); } // POST: /Admin/User/AddToAdmin - Adds user to "Admin" role [HttpPost] public async Task<IActionResult> AddToAdmin(string id) { IdentityRole adminRole = await roleManager.FindByNameAsync("Admin"); if (adminRole == null) { TempData["message"] = "Admin role does not exist. Click 'Create Admin Role' button to create it."; } else { User user = await userManager.FindByIdAsync(id); await userManager.AddToRoleAsync(user, adminRole.Name); } return RedirectToAction("Index"); } // POST: /Admin/User/RemoveFromAdmin - Removes user from "Admin" role [HttpPost] public async Task<IActionResult> RemoveFromAdmin(string id) { User user = await userManager.FindByIdAsync(id); await userManager.RemoveFromRoleAsync(user, "Admin"); return RedirectToAction("Index"); } // POST: /Admin/User/CreateAdminRole - Creates "Admin" role [HttpPost] public async Task<IActionResult> CreateAdminRole() { await roleManager.CreateAsync(new IdentityRole("Admin")); return RedirectToAction("Index"); } // POST: /Admin/User/DeleteRole - Deletes a role [HttpPost] public async Task<IActionResult> DeleteRole(string id) { IdentityRole role = await roleManager.FindByIdAsync(id); await roleManager.DeleteAsync(role); return RedirectToAction("Index"); } // Other actions like Add, Edit, ChangePassword (similar to AccountController) // ... } } // Views/Admin/User/Index.cshtml (Simplified) @model Bookstore.Models.ViewModels.UserViewModel @{ ViewData["Title"] = "Manage Users"; } <h1>Manage Users</h1> <h5 class="mt-2"><a asp-action="Add">Add a User</a></h5> <table class="table table-bordered table-striped table-sm"> <thead class="thead-dark"> <tr><th>Username</th><th>Roles</th><th></th><th></th><th></th></tr> </thead> <tbody> @if (Model.Users.Count() == 0) { <tr><td colspan="5">There are no user accounts.</td></tr> } else { @foreach (User user in Model.Users) { <tr> <td>@user.UserName</td> <td> @foreach (string roleName in user.RoleNames) { <div>@roleName</div> } </td> <td> <form method="post" asp-action="Delete" asp-route-id="@user.Id"> <button type="submit" class="btn btn-danger btn-sm">Delete User</button> </form> </td> <td> @if (!user.RoleNames.Contains("Admin")) // Only show if not already Admin { <form method="post" asp-action="AddToAdmin" asp-route-id="@user.Id"> <button type="submit" class="btn btn-primary btn-sm">Add To Admin</button> </form> } </td> <td> @if (user.RoleNames.Contains("Admin")) // Only show if user is Admin { <form method="post" asp-action="RemoveFromAdmin" asp-route-id="@user.Id"> <button type="submit" class="btn btn-warning btn-sm">Remove From Admin</button> </form> } </td> </tr> } </tbody> </table> <h1 class="mb-2 mt-4">Manage Roles</h1> @if (Model.Roles.Count() == 0) { <form method="post" asp-action="CreateAdminRole"> <button type="submit" class="btn btn-primary">Create Admin Role</button> </form> } else { <table class="table table-bordered table-striped table-sm"> <thead class="thead-dark"> <tr><th>Role</th><th></th></tr> </thead> <tbody> @foreach (var role in Model.Roles) { <tr> <td>@role.Name</td> <td> <form method="post" asp-action="DeleteRole" asp-route-id="@role.Id"> <button type="submit" class="btn btn-danger btn-sm">Delete Role</button> </form> </td> </tr> } </tbody> </table> }// Models/BookstoreContext.cs - CreateAdminUser method (for seeding) using Microsoft.Extensions.DependencyInjection; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Identity.EntityFrameworkCore; using System.Threading.Tasks; // For async/await namespace Bookstore.Models { public partial class BookstoreContext : IdentityDbContext<User> { // ... (constructor, DbSets, OnModelCreating) // Static method to seed admin user and role public static async Task CreateAdminUser(IServiceProvider serviceProvider) { UserManager<User> userManager = serviceProvider.GetRequiredService<UserManager<User>>(); RoleManager<IdentityRole> roleManager = serviceProvider.GetRequiredService<RoleManager<IdentityRole>>(); string username = "admin"; string password = "Sesame"; // Use a strong password in production! string roleName = "Admin"; // If role doesn't exist, create it if (await roleManager.FindByNameAsync(roleName) == null) { await roleManager.CreateAsync(new IdentityRole(roleName)); } // If username doesn't exist, create user and add to role if (await userManager.FindByNameAsync(username) == null) { User user = new User { UserName = username, Email = "admin@example.com" }; // Add email var result = await userManager.CreateAsync(user, password); if (result.Succeeded) { await userManager.AddToRoleAsync(user, roleName); } } } } } // Program.cs (Updated to allow ValidateScopes = false for seeding) using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.DependencyInjection; // For CreateDefaultBuilder namespace Bookstore { public class Program { public static void Main(string[] args) { CreateHostBuilder(args).Build().Run(); } public static IHostBuilder CreateHostBuilder(string[] args) => Host.CreateDefaultBuilder(args) .ConfigureWebHostDefaults(webBuilder => { webBuilder.UseStartup<Startup>() // This is needed to allow CreateAdminUser to run during startup // In production, you might want to remove this or use a different seeding strategy. .UseDefaultServiceProvider(options => options.ValidateScopes = false); }); } } - Create
Step 8: Advanced UI/Code Organization (Tag Helpers, Partial Views, View Components)
-
Custom Tag Helpers:
- Create custom tag helpers (e.g.,
ButtonTagHelper,SubmitButtonTagHelper,ActiveNavbarTagHelper,TempMessageTagHelper,PagingLinkTagHelper,SortingLinkTagHelper) to encapsulate common HTML patterns and logic. - Learn how to define properties, control scope (
HtmlTargetElement), and generate HTML dynamically (TagBuilder,TagHelperOutput) (Chapter 15, Figures 15-3, 15-4, 15-5, 15-6, 15-7, 15-8, 15-9, 15-10, 15-11, 15-12). - Use dependency injection within tag helpers (Chapter 15, Figure 15-10).
// TagHelpers/ButtonTagHelper.cs using Microsoft.AspNetCore.Razor.TagHelpers; namespace Bookstore.TagHelpers { // Targets any element with type="submit" or <a> with my-button attribute [HtmlTargetElement(Attributes = "type=submit")] [HtmlTargetElement("a", Attributes = "my-button")] public class ButtonTagHelper : TagHelper { public override void Process(TagHelperContext context, TagHelperOutput output) { // Appends Bootstrap button classes to existing classes output.Attributes.AppendCssClass("btn btn-primary"); } } } // TagHelpers/ActiveNavbarTagHelper.cs using Microsoft.AspNetCore.Mvc.Rendering; // For ViewContext using Microsoft.AspNetCore.Mvc.ViewFeatures; // For ViewContextAttribute, HtmlAttributeNotBoundAttribute using Microsoft.AspNetCore.Razor.TagHelpers; using Bookstore.Models.ExtensionMethods; // For EqualsNoCase namespace Bookstore.TagHelpers { // Targets <a> elements with class="nav-link" within an <li> parent [HtmlTargetElement("a", Attributes = "[class=nav-link]", ParentTag = "li")] public class ActiveNavbarTagHelper : TagHelper { [ViewContext] // Injects ViewContext [HtmlAttributeNotBound] // Prevents mapping to HTML attribute public ViewContext ViewCtx { get; set; } [HtmlAttributeName("my-mark-area-active")] // Custom attribute for area-only matching public bool IsAreaOnly { get; set; } public override void Process(TagHelperContext context, TagHelperOutput output) { string currentArea = ViewCtx.RouteData.Values["area"]?.ToString() ?? ""; string currentController = ViewCtx.RouteData.Values["controller"].ToString(); string aspArea = context.AllAttributes["asp-area"]?.Value?.ToString() ?? ""; string aspController = context.AllAttributes["asp-controller"].Value.ToString(); // Mark active if both area and controller match if (currentArea.EqualsNoCase(aspArea) && currentController.EqualsNoCase(aspController)) { output.Attributes.AppendCssClass("active"); } // Mark active if only area matches and IsAreaOnly is true else if (IsAreaOnly && currentArea.EqualsNoCase(aspArea)) { output.Attributes.AppendCssClass("active"); } } } } // TagHelpers/TempMessageTagHelper.cs using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.TagHelpers; using Bookstore.Models.ExtensionMethods; // For BuildTag, SetContent namespace Bookstore.TagHelpers { [HtmlTargetElement("my-temp-message")] // Custom element public class TempMessageTagHelper : TagHelper { [ViewContext] [HtmlAttributeNotBound] public ViewContext ViewCtx { get; set; } public override void Process(TagHelperContext context, TagHelperOutput output) { var tempData = ViewCtx.TempData; // Access TempData from ViewContext if (tempData.ContainsKey("message")) // Check if message exists { // Build an <h4> element with Bootstrap classes output.BuildTag("h4", "bg-info text-center text-white p-2"); output.Content.SetContent(tempData["message"].ToString()); // Set content to message } else { output.SuppressOutput(); // Suppress output if no message } } } } // TagHelpers/PagingLinkTagHelper.cs using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.AspNetCore.Routing; // For LinkGenerator using Bookstore.Models; // For RouteDictionary using Bookstore.Models.ExtensionMethods; // For BuildLink, SetContent namespace Bookstore.TagHelpers { [HtmlTargetElement("my-paging-link")] // Custom element for paging links public class PagingLinkTagHelper : TagHelper { private LinkGenerator linkBuilder; // Injected LinkGenerator public PagingLinkTagHelper(LinkGenerator lg) => linkBuilder = lg; [ViewContext] [HtmlAttributeNotBound] public ViewContext ViewCtx { get; set; } public int Number { get; set; } // Page number for this link public RouteDictionary Current { get; set; } // Current route parameters public override void Process(TagHelperContext context, TagHelperOutput output) { // Clone current route and update page number for this link var routes = Current.Clone(); routes.PageNumber = Number; // Get controller and action from current route data string controller = ViewCtx.RouteData.Values["controller"].ToString(); string action = ViewCtx.RouteData.Values["action"].ToString(); // Generate URL using LinkGenerator string url = linkBuilder.GetPathByAction(action, controller, routes); // Build CSS classes for the link string linkClasses = "btn btn-outline-primary"; if (Number == Current.PageNumber) // Mark active if it's the current page linkClasses += " active"; // Build the <a> tag output.BuildLink(url, linkClasses); output.Content.SetContent(Number.ToString()); // Set link text to page number } } } // TagHelpers/SortingLinkTagHelper.cs (Similar to PagingLinkTagHelper) using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.TagHelpers; using Microsoft.AspNetCore.Routing; using Bookstore.Models; using Bookstore.Models.ExtensionMethods; namespace Bookstore.TagHelpers { [HtmlTargetElement("my-sorting-link")] // Custom element for sorting links public class SortingLinkTagHelper : TagHelper { private LinkGenerator linkBuilder; public SortingLinkTagHelper(LinkGenerator lg) => linkBuilder = lg; [ViewContext] [HtmlAttributeNotBound] public ViewContext ViewCtx { get; set; } public string SortField { get; set; } // Field to sort by for this link public RouteDictionary Current { get; set; } // Current route parameters public override void Process(TagHelperContext context, TagHelperOutput output) { // Clone current route and set sort field/direction for this link var routes = Current.Clone(); routes.SetSortAndDirection(SortField, Current); // Handles toggling asc/desc string controller = ViewCtx.RouteData.Values["controller"].ToString(); string action = ViewCtx.RouteData.Values["action"].ToString(); string url = linkBuilder.GetPathByAction(action, controller, routes); string linkClasses = "text-white"; // Default text color for header links // Add active class if this is the currently sorted field if (SortField.EqualsNoCase(Current.SortField)) linkClasses += " active"; output.BuildLink(url, linkClasses); // Content is usually set by the inner HTML of the tag helper (e.g., "Title") } } } // TagHelpers/TagHelperExtensions.cs (Helper methods for Tag Helpers) using Microsoft.AspNetCore.Mvc.Rendering; using Microsoft.AspNetCore.Razor.TagHelpers; namespace Bookstore.TagHelpers { public static class TagHelperExtensions { // Appends CSS classes to an existing class attribute public static void AppendCssClass(this TagHelperAttributeList list, string newCssClasses) { string oldCssClasses = list["class"]?.Value?.ToString(); string cssClasses = (string.IsNullOrEmpty(oldCssClasses)) ? newCssClasses : $"{oldCssClasses} {newCssClasses}"; list.SetAttribute("class", cssClasses); } // Builds a new HTML tag with specified name and classes public static void BuildTag(this TagHelperOutput output, string tagName, string classNames) { output.TagName = tagName; output.TagMode = TagMode.StartTagAndEndTag; output.Attributes.AppendCssClass(classNames); } // Builds an <a> tag with specified URL and classes public static void BuildLink(this TagHelperOutput output, string url, string className) { output.BuildTag("a", className); output.Attributes.SetAttribute("href", url); } } } - Create custom tag helpers (e.g.,
-
Partial Views:
- Create partial views (e.g.,
_NavbarMenuButtonPartial,_BookLinkPartial,_AuthorLinkPartial,_PagingLinksPartial) for reusable HTML snippets. - Use the
<partial>tag helper to render them in main views or layouts, passing models orViewDataas needed (Chapter 15, Figures 15-13, 15-14).
// Views/Shared/_NavbarMenuButtonPartial.cshtml <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#menu" aria-controls="menu" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button>// Views/Shared/_BookLinkPartial.cshtml @model Bookstore.Models.Book <a asp-action="Details" asp-controller="Book" asp-route-id="@Model.BookId" asp-route-slug="@Model.Title.Slug()"> @Model.Title </a>// Views/Shared/_AuthorLinkPartial.cshtml @model Bookstore.Models.Author <a asp-action="Details" asp-controller="Author" asp-route-id="@Model.AuthorId" asp-route-slug="@Model.FullName.Slug()"> @Model.FullName </a>// Views/Shared/_PagingLinksPartial.cshtml @model Bookstore.Models.ViewModels.GridViewModel<dynamic> // Use dynamic or specific type @{ RouteDictionary routes = Model.CurrentRoute.Clone(); } <div class="d-flex justify-content-center mt-3"> @for (int i = 1; i <= Model.TotalPages; i++) { <my-paging-link number="@i" current="@routes" /> } </div> - Create partial views (e.g.,
-
View Components:
- Create view components (e.g.,
CartBadge,AuthorDropDown,GenreDropDown,PriceDropDown) for complex, reusable UI logic that might require data access. - Implement
Invoke()methods in view component classes to prepare data and return a partial view. - Use dependency injection in view components to get required services (e.g.,
ICart,IRepository<T>). - Render view components using
<vc:component-name>syntax (Chapter 15, Figures 15-15, 15-16, 15-17). - Simplify controllers and view models by offloading data preparation for UI elements to view components (Chapter 15, Figure 15-17).
// Components/CartBadge.cs using Microsoft.AspNetCore.Mvc; using Bookstore.Models; // For Cart namespace Bookstore.Components { // View component for displaying cart item count in a badge public class CartBadge : ViewComponent { private ICart cart { get; set; } // Injected Cart service public CartBadge(ICart c) => cart = c; public IViewComponentResult Invoke() { // Pass the cart item count to the Default.cshtml partial view return View(cart.Count); } } } // Views/Shared/Components/CartBadge/Default.cshtml @model int? // Model is the integer count of cart items <span class="badge badge-light">@Model</span>// Components/AuthorDropDown.cs using Microsoft.AspNetCore.Mvc; using System.Linq; // For ToDictionary using Bookstore.Models; // For Author, IRepository using Bookstore.Models.ViewModels; // For DropDownViewModel namespace Bookstore.Components { // View component for author filter dropdown public class AuthorDropDown : ViewComponent { private IRepository<Author> data { get; set; } // Injected Author repository public AuthorDropDown(IRepository<Author> rep) => data = rep; public IViewComponentResult Invoke(string selectedValue) // selectedValue from tag helper attribute { var authors = data.List(new QueryOptions<Author> { OrderBy = a => a.FirstName }); var viewModel = new DropDownViewModel { SelectedValue = selectedValue, DefaultValue = "all", // Default filter value Items = authors.ToDictionary(a => a.AuthorId.ToString(), a => a.FullName) }; // Use a shared partial view for all dropdowns return View("~/Views/Shared/Components/Common/DropDown.cshtml", viewModel); } } } // Models/ViewModels/DropDownViewModel.cs (Shared for all dropdowns) using System.Collections.Generic; namespace Bookstore.Models.ViewModels { public class DropDownViewModel { public Dictionary<string, string> Items { get; set; } // Key-value pairs for dropdown options public string SelectedValue { get; set; } // Currently selected value public string DefaultValue { get; set; } // Value for "All" option } } // Views/Shared/Components/Common/DropDown.cshtml (Shared partial view for dropdowns) @model Bookstore.Models.ViewModels.DropDownViewModel <select name="filter" class="form-control m-2" asp-items="@(new SelectList(Model.Items, "Key", "Value", Model.SelectedValue))"> <option value="@Model.DefaultValue">All</option> </select> - Create view components (e.g.,
-
Integrate Advanced UI Elements into Bookstore App:
- Update
_Layout.cshtmlandBook/List.cshtmlto utilize the newly created custom tag helpers, partial views, and view components for a cleaner, more modular codebase (Chapter 15, Figures 15-18, 15-19, 15-20, 15-21). - (See
Views/Shared/_Layout.cshtmlandViews/Book/List.cshtmlin previous steps for integration examples).
- Update
Conclusion
Building the Bookstore project step-by-step as outlined in "Murach's ASP.NET Core MVC" provides a comprehensive understanding of ASP.NET Core MVC development. By following these steps, you'll gain practical experience with fundamental concepts like MVC patterns, EF Core, data persistence, and validation, as well as advanced topics such as dependency injection, unit testing, authentication, and UI componentization.
Remember that each step builds upon the previous one, and referring back to the specific figures and code examples in the book is crucial for successful implementation.
Comments
Post a Comment