
Welcome to Part 6 of our series! This is a major milestone for your Code With Ilyasoft project. Adding advanced search and filtering is what separates a basic tutorial from a professional-grade e-commerce application.
In Step 1, we are building the ProductListViewModel. This class is the "data carrier" that allows us to send everything—products, filters, and pagination info—to our View in one neat package.
Step 1: Creating the ProductListViewModel
To handle complex filtering (like searching by price, brand, or category), we shouldn't pass raw data directly from the database. Instead, we use a ViewModel.
Create a new folder in your project named ViewModels.
Inside, create a class file named
ProductListViewModel.cs.Paste the following code:
using MobileShop.Models;
using System.Collections.Generic;
namespace MobileShop.ViewModels
{
public class ProductListViewModel
{
// Data Lists
public List<Product> Products { get; set; } = new List<Product>();
public List<Category> Categories { get; set; } = new List<Category>();
public List<Brand> Brands { get; set; } = new List<Brand>();
// Filter Parameters
public int? SelectedCategoryId { get; set; }
public int? SelectedBrandId { get; set; }
public string? SearchTerm { get; set; }
public string? SortOrder { get; set; }
// Price Range Filtering
public decimal? MinPrice { get; set; }
public decimal? MaxPrice { get; set; }
// Pagination Properties
public int PageNumber { get; set; } = 1;
public int PageSize { get; set; } = 12;
public int TotalPages { get; set; }
public int TotalCount { get; set; }
}
}
Detailed Step-by-Step Explanation
1. Data Collections (Products, Categories, Brands)
These lists hold the actual data. We include Categories and Brands here so that our sidebar or filter dropdowns can be populated dynamically from the database. This ensures the user always sees current filter options.
2. Nullable Filter Properties (The ? Syntax)
Notice properties like int? SelectedCategoryId. The ? means they are Nullable.
Why? When a user first lands on the shop page, they haven't picked a category yet. A nullable type allows the value to be
null, which tells our code: "Don't filter by category yet, show everything."
3. Search and Sort Logic
SearchTerm: Stores the text the user types into the search bar.
SortOrder: Handles logic for "Price: Low to High," "Newest First," etc.
MinPrice / MaxPrice: Essential for the price range slider or input boxes, allowing users to find phones within their budget.
4. Professional Pagination System
Loading 1,000 products on one page would crash the browser and hurt your SEO PageSpeed score.
PageNumber & PageSize: These track which "slice" of data to show (e.g., showing 12 products on page 2).
TotalPages & TotalCount: These are used to generate the "Previous/Next" buttons at the bottom of the page.
Step 2: Implementing Advanced Filter Logic in ProductService
In this step, we update our Service Layer to handle complex filtering, sorting, and pagination. We use IQueryable, which allows us to build a query in multiple steps and execute it only when necessary. This is a vital performance optimization for any e-commerce site.
1. Update the Interface (IProductService.cs)
First, we define the contract. Add this method to your IProductService interface:
using MobileShop.ViewModels;
using System.Threading.Tasks;
namespace MobileShop.Services
{
/// <summary>
/// Interface for Product-related business logic
/// </summary>
public interface IProductService
{
// Asynchronously gets a filtered, sorted, and paginated list of products
Task<ProductListViewModel> GetProductsAsync(ProductListViewModel filter);
}
}
2. Implement the Logic (ProductService.cs)
Add the implementation to your ProductService class. Notice how we build the query dynamically based on which filters the user has selected.
public async Task<ProductListViewModel> GetProductsAsync(ProductListViewModel filter)
{
// 1. Initialize query with related data
var query = _context.Products
.Include(p => p.Brand)
.Include(p => p.Category)
.Include(p => p.Reviews)
.Where(p => p.IsActive)
.AsQueryable();
// 2. Apply Dynamic Filters
if (filter.SelectedCategoryId.HasValue)
query = query.Where(p => p.CategoryId == filter.SelectedCategoryId.Value);
if (filter.SelectedBrandId.HasValue)
query = query.Where(p => p.BrandId == filter.SelectedBrandId.Value);
if (!string.IsNullOrWhiteSpace(filter.SearchTerm))
query = query.Where(p => p.Name.Contains(filter.SearchTerm) ||
p.Description.Contains(filter.SearchTerm) ||
p.Brand.Name.Contains(filter.SearchTerm));
if (filter.MinPrice.HasValue)
query = query.Where(p => p.SalePrice >= filter.MinPrice.Value);
if (filter.MaxPrice.HasValue)
query = query.Where(p => p.SalePrice <= filter.MaxPrice.Value);
// 3. Apply Sorting using C# Switch Expression
query = filter.SortOrder?.ToLower() switch
{
"price_asc" => query.OrderBy(p => p.SalePrice),
"price_desc" => query.OrderByDescending(p => p.SalePrice),
"name_asc" => query.OrderBy(p => p.Name),
"name_desc" => query.OrderByDescending(p => p.Name),
"newest" => query.OrderByDescending(p => p.CreatedAt),
_ => query.OrderByDescending(p => p.CreatedAt)
};
// 4. Calculate Pagination Stats
filter.TotalCount = await query.CountAsync();
filter.TotalPages = (int)Math.Ceiling(filter.TotalCount / (double)filter.PageSize);
// 5. Execute Query with Skip/Take
filter.Products = await query
.Skip((filter.PageNumber - 1) * filter.PageSize)
.Take(filter.PageSize)
.ToListAsync();
// 6. Populate filter dropdowns
filter.Categories = await _context.Categories.Where(c => c.IsActive).ToListAsync();
filter.Brands = await _context.Brands.Where(b => b.IsActive).ToListAsync();
return filter;
}
Detailed Technical Explanation
The Power of AsQueryable()
Using .AsQueryable() is a best practice. It means we are "building" the SQL command in memory. The actual query is not sent to the database until we call .ToListAsync() or .CountAsync(). This prevents loading unnecessary data into the server's RAM.
Efficient Search & Filtering
We use .Contains() for the search term, which translates to the SQL LIKE operator. This allows users to find products by searching for a partial name, description, or even the brand name. Checking HasValue for prices and IDs ensures that filters only apply if the user has actually interacted with them.
Dynamic Sorting
The C# Switch Expression is a modern way to handle multiple sorting options. It makes the code much cleaner than using nested if/else statements. Providing options like "Newest First" or "Price: High to Low" is essential for E-commerce UX (User Experience).
Pagination Logic (Skip & Take)
Skip: Skips the products from previous pages.
Take: Only retrieves the specific number of products (PageSize) for the current page. This ensures your mobile shop website stays fast even if you have thousands of products!
Step 3: Creating the Products Controller
The Controller's job is to listen for user requests. When a user clicks a brand, adjusts a price slider, or searches for a "Galaxy," the request hits the Index action. The controller then packages these requests into our ViewModel and sends them to the ProductService.
The Code: Controllers/ProductsController.cs
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc;
using MobileShop.Models;
using MobileShop.Services;
using MobileShop.ViewModels;
using System.Threading.Tasks;
namespace MobileShop.Controllers
{
public class ProductsController : Controller
{
private readonly IProductService _productService;
// Injecting the Product Service through the constructor
public ProductsController(IProductService productService)
{
_productService = productService;
}
/// <summary>
/// Displays a filtered and paginated list of products
/// </summary>
public async Task<IActionResult> Index(
int? categoryId,
int? brandId,
string? search,
string? sort,
decimal? minPrice,
decimal? maxPrice,
int page = 1)
{
// Mapping URL parameters to our ViewModel
var filter = new ProductListViewModel
{
SelectedCategoryId = categoryId,
SelectedBrandId = brandId,
SearchTerm = search,
SortOrder = sort,
MinPrice = minPrice,
MaxPrice = maxPrice,
PageNumber = page
};
// Calling the service to handle the filtering logic
var result = await _productService.GetProductsAsync(filter);
return View(result);
}
}
}
Detailed Step-by-Step Explanation
1. Constructor Injection
We inject IProductService through the constructor. This is a clean coding practice that makes our controller lightweight. The controller doesn't need to know how the data is fetched; it only needs to know who to ask.
2. Action Parameters (Query Strings)
The Index action accepts several parameters. These correspond to the Query Strings in the URL.
Example: If the URL is
.../Products?brandId=5&search=iphone, ASP.NET Core automatically maps5tobrandIdand"iphone"tosearch.
3. Initializing the ViewModel
Inside the action, we create a new instance of ProductListViewModel. We take the values from the URL and assign them to the properties we defined in Part 6, Step 1.
4. Asynchronous Data Fetching
We use await _productService.GetProductsAsync(filter). Because database operations can take time, using Async/Await ensures our web server stays responsive and can handle other users while waiting for the database results.
5. Returning the Result
Finally, we pass the result (which now contains our list of products, the total page count, and the lists for categories and brands) to the View.
Step 4: Designing the Advanced Product Index View
In this final step of Part 6, we create the User Interface (UI). We use Bootstrap 5 to create a two-column layout: a Filter Sidebar on the left and a Product Grid with Pagination on the right.
The Code: Views/Products/Index.cshtml
@using MobileShop.ViewModels
@model ProductListViewModel
@{
ViewData["Title"] = "Products";
}
<div class="container py-4">
<div class="row">
<!-- Sidebar Filters -->
<div class="col-lg-3">
<div class="card shadow-sm mb-4">
<div class="card-header bg-primary text-white">
<h5 class="mb-0"><i class="bi bi-funnel"></i> Filters</h5>
</div>
<div class="card-body">
<form method="get" asp-action="Index">
<!-- Search -->
<div class="mb-3">
<label class="form-label">Search</label>
<input type="text" name="search" class="form-control" value="@Model.SearchTerm" placeholder="Search products..." />
</div>
<!-- Categories -->
<div class="mb-3">
<label class="form-label">Category</label>
<select name="categoryId" class="form-select">
<option value="">All Categories</option>
@foreach (var category in Model.Categories)
{
<option value="@category.Id" selected="@(Model.SelectedCategoryId == category.Id ? "selected" : null)">@category.Name</option>
}
</select>
</div>
<!-- Brands -->
<div class="mb-3">
<label class="form-label">Brand</label>
<select name="brandId" class="form-select">
<option value="">All Brands</option>
@foreach (var brand in Model.Brands)
{
<option value="@brand.Id" selected="@(Model.SelectedBrandId == brand.Id ? "selected" : null)">@brand.Name</option>
}
</select>
</div>
<!-- Price Range -->
<div class="mb-3">
<label class="form-label">Price Range</label>
<div class="input-group mb-2">
<span class="input-group-text">RS </span>
<input type="number" name="minPrice" class="form-control" value="@Model.MinPrice" placeholder="Min" />
</div>
<div class="input-group">
<span class="input-group-text">RS </span>
<input type="number" name="maxPrice" class="form-control" value="@Model.MaxPrice" placeholder="Max" />
</div>
</div>
<button type="submit" class="btn btn-primary w-100">Apply Filters</button>
<a asp-action="Index" class="btn btn-outline-secondary w-100 mt-2">Clear Filters</a>
</form>
</div>
</div>
</div>
<!-- Product Grid -->
<div class="col-lg-9">
<div class="d-flex justify-content-between align-items-center mb-4">
<h4>@Model.TotalCount Products Found</h4>
<div class="d-flex align-items-center">
<label class="me-2">Sort by:</label>
<select class="form-select form-select-sm" style="width: 150px;" onchange="window.location.href=this.value">
<option value="@Url.Action("Index", new { sort = "newest", categoryId = Model.SelectedCategoryId, brandId = Model.SelectedBrandId, search = Model.SearchTerm, minPrice = Model.MinPrice, maxPrice = Model.MaxPrice })" selected="@(Model.SortOrder == "newest" ? "selected" : null)">Newest</option>
<option value="@Url.Action("Index", new { sort = "price_asc", categoryId = Model.SelectedCategoryId, brandId = Model.SelectedBrandId, search = Model.SearchTerm, minPrice = Model.MinPrice, maxPrice = Model.MaxPrice })" selected="@(Model.SortOrder == "price_asc" ? "selected" : null)">Price: Low to High</option>
<option value="@Url.Action("Index", new { sort = "price_desc", categoryId = Model.SelectedCategoryId, brandId = Model.SelectedBrandId, search = Model.SearchTerm, minPrice = Model.MinPrice, maxPrice = Model.MaxPrice })" selected="@(Model.SortOrder == "price_desc" ? "selected" : null)">Price: High to Low</option>
<option value="@Url.Action("Index", new { sort = "name_asc", categoryId = Model.SelectedCategoryId, brandId = Model.SelectedBrandId, search = Model.SearchTerm, minPrice = Model.MinPrice, maxPrice = Model.MaxPrice })" selected="@(Model.SortOrder == "name_asc" ? "selected" : null)">Name: A-Z</option>
</select>
</div>
</div>
<div class="row g-4">
@foreach (var product in Model.Products)
{
<div class="col-md-4 col-sm-6">
<partial name="_ProductCard" model="product" />
</div>
}
</div>
@if (Model.TotalPages > 1)
{
<nav class="mt-4">
<ul class="pagination justify-content-center">
@if (Model.PageNumber > 1)
{
<li class="page-item">
<a class="page-link" asp-action="Index" asp-route-page="@(Model.PageNumber - 1)"
asp-route-categoryId="@Model.SelectedCategoryId" asp-route-brandId="@Model.SelectedBrandId"
asp-route-search="@Model.SearchTerm" asp-route-sort="@Model.SortOrder"
asp-route-minPrice="@Model.MinPrice" asp-route-maxPrice="@Model.MaxPrice">Previous</a>
</li>
}
@for (int i = Math.Max(1, Model.PageNumber - 2); i <= Math.Min(Model.TotalPages, Model.PageNumber + 2); i++)
{
<li class="page-item @(i == Model.PageNumber ? "active" : "")">
<a class="page-link" asp-action="Index" asp-route-page="@i"
asp-route-categoryId="@Model.SelectedCategoryId" asp-route-brandId="@Model.SelectedBrandId"
asp-route-search="@Model.SearchTerm" asp-route-sort="@Model.SortOrder"
asp-route-minPrice="@Model.MinPrice" asp-route-maxPrice="@Model.MaxPrice">@i</a>
</li>
}
@if (Model.PageNumber < Model.TotalPages)
{
<li class="page-item">
<a class="page-link" asp-action="Index" asp-route-page="@(Model.PageNumber + 1)"
asp-route-categoryId="@Model.SelectedCategoryId" asp-route-brandId="@Model.SelectedBrandId"
asp-route-search="@Model.SearchTerm" asp-route-sort="@Model.SortOrder"
asp-route-minPrice="@Model.MinPrice" asp-route-maxPrice="@Model.MaxPrice">Next</a>
</li>
}
</ul>
</nav>
}
</div>
</div>
</div>
Detailed Step-by-Step Explanation
1. The Sidebar Filter Form
The <form method="get" asp-action="Index"> is the most important part of this view.
Method="get": By using the GET method, all filters appear in the URL (e.g.,
?search=iphone). This makes the search results bookmarkable and great for SEO.Sticky Values: We use
value="@Model.SearchTerm"andselected="..."logic. This ensures that after the page reloads, the user’s selected filters stay visible in the form.
2. Dynamic Sorting Dropdown
We use a small JavaScript trick onchange="window.location.href=this.value". This allows the page to refresh immediately when a user changes the sort order (like "Price: Low to High") without needing a submit button.
3. The Product Grid & Partial Views
Instead of writing the product card code again, we use:
<partial name="_ProductCard" model="product" />
This keeps our code DRY (Don't Repeat Yourself). We reuse the exact same card design we built in Part 3 for the Home Page, ensuring a consistent look across the Mobile Shop.
4. Professional Pagination Logic
The pagination section is intelligently designed:
Preserving State: Every page link includes
asp-route-categoryId,asp-route-search, etc. This is crucial! Without this, clicking "Page 2" would clear all your active filters.Smart Range: The
@forloop usesMath.MaxandMath.Minto show a limited number of page buttons (2 before and 2 after the current page). This prevents a long, ugly list of numbers if you have 100+ pages.
Comments
Post a Comment