ASP.NET CORE MVC - ViewModels

ASP.NET CORE MVC TUTORIAL SERIES · PART 10

Using ViewModels to Separate Entities from the User Interface

Create dedicated ViewModels for the Product list and Product form, move search/filter state out of ViewData, reduce direct coupling between Razor views and EF Core entities, and introduce a safer form-binding pattern.

Objective

By the end of this tutorial, the Product list and Product Create/Edit forms will use dedicated ViewModels rather than relying exclusively on entity models and loose ViewData/ViewBag values.

Starting point

Part 9 implemented search, Category filtering, maximum-price filtering and sorting. It worked, but the Index page relied on several ViewData values and the Create/Edit forms posted directly into the Product entity.

In this tutorial
  1. Understand why ViewModels are useful
  2. Create ProductIndexViewModel
  3. Update the Index action
  4. Update Index.cshtml
  5. Create ProductFormViewModel
  6. Update Create GET and POST
  7. Update Create.cshtml
  8. Update Edit GET and POST
  9. Update Edit.cshtml
  10. Understand overposting
  11. Build and test the completed application
  12. Compare full final files in the appendix

1. Open and Verify the Part 9 Project

cd ~/aspnet-mvc-tutorial/ProductManagement
pwd
dotnet build
code .

The expected path is:

/home/xubuntu/aspnet-mvc-tutorial/ProductManagement
Checkpoint

Continue only when the Part 9 project builds successfully.

2. Why Introduce ViewModels?

The database entity currently represents persistent Product data:

Product

But the Index page needs more than a Product collection. It also needs:

Search text
Selected Category
Maximum Price
Sort order
Category choices
Products

A ViewModel can represent exactly what one view needs.

Database Entity ↓ Controller ↓ ViewModel ↓ Razor View

A ViewModel does not have to correspond to a database table.

3. Create the ViewModels Folder

Step 3.1 — Create the folder

mkdir -p ViewModels

Step 3.2 — Verify it

ls -ld ViewModels

4. Create ProductIndexViewModel.cs

Step 4.1 — Create the file

touch ViewModels/ProductIndexViewModel.cs

Step 4.2 — Open it

code ViewModels/ProductIndexViewModel.cs

Step 4.3 — Add the complete ViewModel

using Microsoft.AspNetCore.Mvc.Rendering;
using ProductManagement.Models;

namespace ProductManagement.ViewModels;

public class ProductIndexViewModel
{
    public IEnumerable<Product> Products { get; set; }
        = new List<Product>();

    public string? Search { get; set; }

    public int? CategoryId { get; set; }

    public decimal? MaxPrice { get; set; }

    public string? SortOrder { get; set; }

    public string? NameSort { get; set; }

    public string? PriceSort { get; set; }

    public IEnumerable<SelectListItem> Categories { get; set; }
        = new List<SelectListItem>();
}

5. Understand ProductIndexViewModel

PropertyPurpose
ProductsThe Product rows displayed by the page.
SearchThe current Product-name search value.
CategoryIdThe currently selected Category.
MaxPriceThe current maximum-price filter.
SortOrderThe current sorting mode.
NameSortThe next sort value for the Name link.
PriceSortThe next sort value for the Price link.
CategoriesThe Category choices for the filter dropdown.

6. Update ProductsController.cs Namespace Imports

Step 6.1 — Open the controller

code Controllers/ProductsController.cs

Step 6.2 — Find the using statements

At the top, add:

using ProductManagement.ViewModels;

The top should now include:

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using ProductManagement.Data;
using ProductManagement.Models;
using ProductManagement.ViewModels;

7. Replace the Index Action

Step 7.1 — Find the entire Part 9 Index action

It begins with:

public async Task<IActionResult> Index(
    string? search,
    int? categoryId,
    decimal? maxPrice,
    string? sortOrder)

Step 7.2 — Replace the entire method

public async Task<IActionResult> Index(
    string? search,
    int? categoryId,
    decimal? maxPrice,
    string? sortOrder)
{
    var products = _context.Products
        .Include(p => p.Category)
        .AsQueryable();

    if (!string.IsNullOrWhiteSpace(search))
    {
        products = products.Where(
            p => p.Name.Contains(search));
    }

    if (categoryId.HasValue)
    {
        products = products.Where(
            p => p.CategoryId == categoryId.Value);
    }

    if (maxPrice.HasValue)
    {
        products = products.Where(
            p => p.Price <= maxPrice.Value);
    }

    products = sortOrder switch
    {
        "name_desc" =>
            products.OrderByDescending(p => p.Name),

        "price" =>
            products.OrderBy(p => p.Price),

        "price_desc" =>
            products.OrderByDescending(p => p.Price),

        _ =>
            products.OrderBy(p => p.Name)
    };

    var viewModel = new ProductIndexViewModel
    {
        Products = await products.ToListAsync(),
        Search = search,
        CategoryId = categoryId,
        MaxPrice = maxPrice,
        SortOrder = sortOrder,

        NameSort =
            sortOrder == "name_desc"
                ? ""
                : "name_desc",

        PriceSort =
            sortOrder == "price"
                ? "price_desc"
                : "price",

        Categories = await _context.Categories
            .OrderBy(c => c.Name)
            .Select(c => new SelectListItem
            {
                Value = c.Id.ToString(),
                Text = c.Name,
                Selected = c.Id == categoryId
            })
            .ToListAsync()
    };

    return View(viewModel);
}
What changed?

The LINQ query is still the same. The difference is that the controller now packages the Products, current filters, sorting values and Category choices into one strongly typed object.

8. Update Index.cshtml to Use ProductIndexViewModel

Step 8.1 — Open the view

code Views/Products/Index.cshtml

Step 8.2 — Replace the @model directive

Find:

@model IEnumerable<Product>

Replace it with:

@model ProductManagement.ViewModels.ProductIndexViewModel

9. Replace ViewData Values in the Filter Form

Step 9.1 — Find the search input

Replace:

value="@ViewData["CurrentSearch"]"

with:

value="@Model.Search"

Step 9.2 — Find the maximum-price input

Replace:

value="@ViewData["CurrentMaxPrice"]"

with:

value="@Model.MaxPrice"

Step 9.3 — Replace the hidden sort field

Replace:

value="@ViewData["CurrentSort"]"

with:

value="@Model.SortOrder"

10. Update the Category Filter Dropdown

Find:

asp-items="ViewBag.CategoryId"

Replace it with:

asp-items="Model.Categories"

11. Update the Sorting Links

Step 11.1 — Name sorting link

Replace the route values that use ViewData with:

<a asp-action="Index"
   asp-route-search="@Model.Search"
   asp-route-categoryId="@Model.CategoryId"
   asp-route-maxPrice="@Model.MaxPrice"
   asp-route-sortOrder="@Model.NameSort">
    Name
</a>

Step 11.2 — Price sorting link

<a asp-action="Index"
   asp-route-search="@Model.Search"
   asp-route-categoryId="@Model.CategoryId"
   asp-route-maxPrice="@Model.MaxPrice"
   asp-route-sortOrder="@Model.PriceSort">
    Price
</a>

12. Update the Product Collection References

The view no longer receives the Product collection directly.

Find:

@if (!Model.Any())

Replace it with:

@if (!Model.Products.Any())

Find:

@foreach (var product in Model)

Replace it with:

@foreach (var product in Model.Products)

13. Create ProductFormViewModel.cs

The Product entity currently contains database-related properties including Id and the navigation property Category. A form only needs the values that the user is allowed to submit.

Step 13.1 — Create the file

touch ViewModels/ProductFormViewModel.cs

Step 13.2 — Open it

code ViewModels/ProductFormViewModel.cs

Step 13.3 — Add the complete class

using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.Rendering;

namespace ProductManagement.ViewModels;

public class ProductFormViewModel
{
    public int Id { get; set; }

    [Required(ErrorMessage = "Product name is required.")]
    [StringLength(
        100,
        ErrorMessage = "Product name cannot exceed 100 characters.")]
    public string Name { get; set; } = string.Empty;

    [StringLength(
        500,
        ErrorMessage = "Description cannot exceed 500 characters.")]
    public string? Description { get; set; }

    [Range(
        0.01,
        1000000,
        ErrorMessage = "Price must be greater than zero.")]
    public decimal Price { get; set; }

    [Range(
        0,
        1000000,
        ErrorMessage = "Quantity cannot be negative.")]
    public int Quantity { get; set; }

    public int? CategoryId { get; set; }

    public IEnumerable<SelectListItem> Categories { get; set; }
        = new List<SelectListItem>();
}

14. Why ProductFormViewModel Is Different from Product

Product entity Id Name Description Price Quantity CategoryId Category navigation ↓ ProductFormViewModel Id Name Description Price Quantity CategoryId Category choices

The form ViewModel contains what the UI needs rather than exposing the whole EF Core entity shape to model binding.

15. Add a Private Category-List Helper

Step 15.1 — Open ProductsController.cs

code Controllers/ProductsController.cs

Step 15.2 — Add this method near the bottom of the controller

Place it before ProductExists():

private async Task<List<SelectListItem>>
    GetCategoryItemsAsync(int? selectedId = null)
{
    return await _context.Categories
        .OrderBy(c => c.Name)
        .Select(c => new SelectListItem
        {
            Value = c.Id.ToString(),
            Text = c.Name,
            Selected = c.Id == selectedId
        })
        .ToListAsync();
}

This avoids repeating Category-dropdown query code throughout Create and Edit.

16. Replace the Create GET Action

Step 16.1 — Find the existing Create GET action

Replace the entire action with:

[HttpGet]
public async Task<IActionResult> Create()
{
    var viewModel = new ProductFormViewModel
    {
        Categories = await GetCategoryItemsAsync()
    };

    return View(viewModel);
}

17. Replace the Create POST Action

Step 17.1 — Find the existing Create POST action

Replace it with:

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Create(
    ProductFormViewModel viewModel)
{
    if (!ModelState.IsValid)
    {
        viewModel.Categories =
            await GetCategoryItemsAsync(
                viewModel.CategoryId);

        return View(viewModel);
    }

    var product = new Product
    {
        Name = viewModel.Name,
        Description = viewModel.Description,
        Price = viewModel.Price,
        Quantity = viewModel.Quantity,
        CategoryId = viewModel.CategoryId
    };

    _context.Products.Add(product);
    await _context.SaveChangesAsync();

    return RedirectToAction(nameof(Index));
}

18. Understand the Create Mapping

POST form ↓ ProductFormViewModel ↓ Controller chooses properties ↓ new Product ↓ EF Core ↓ SQLite

The controller explicitly maps allowed form properties into a new Product entity.

19. Update Create.cshtml

Step 19.1 — Open the file

code Views/Products/Create.cshtml

Step 19.2 — Replace the model directive

Replace:

@model Product

with:

@model ProductManagement.ViewModels.ProductFormViewModel

Step 19.3 — Update the Category dropdown

Replace:

asp-items="ViewBag.CategoryId"

with:

asp-items="Model.Categories"

The remaining asp-for attributes can stay the same because the ViewModel has matching form properties.

20. Replace the Edit GET Action

Step 20.1 — Open ProductsController.cs

Step 20.2 — Replace the entire Edit GET action

[HttpGet]
public async Task<IActionResult> Edit(int? id)
{
    if (id == null)
    {
        return NotFound();
    }

    var product = await _context.Products
        .FindAsync(id);

    if (product == null)
    {
        return NotFound();
    }

    var viewModel = new ProductFormViewModel
    {
        Id = product.Id,
        Name = product.Name,
        Description = product.Description,
        Price = product.Price,
        Quantity = product.Quantity,
        CategoryId = product.CategoryId,
        Categories =
            await GetCategoryItemsAsync(
                product.CategoryId)
    };

    return View(viewModel);
}

21. Replace the Edit POST Action

Step 21.1 — Find the existing Edit POST action

Replace it completely with:

[HttpPost]
[ValidateAntiForgeryToken]
public async Task<IActionResult> Edit(
    int id,
    ProductFormViewModel viewModel)
{
    if (id != viewModel.Id)
    {
        return NotFound();
    }

    if (!ModelState.IsValid)
    {
        viewModel.Categories =
            await GetCategoryItemsAsync(
                viewModel.CategoryId);

        return View(viewModel);
    }

    var product = await _context.Products
        .FindAsync(id);

    if (product == null)
    {
        return NotFound();
    }

    product.Name = viewModel.Name;
    product.Description = viewModel.Description;
    product.Price = viewModel.Price;
    product.Quantity = viewModel.Quantity;
    product.CategoryId = viewModel.CategoryId;

    await _context.SaveChangesAsync();

    return RedirectToAction(nameof(Index));
}
Important improvement

The controller loads the existing entity first and then copies only the allowed editable properties. It no longer marks an entire user-bound Product entity as modified.

22. Update Edit.cshtml

Step 22.1 — Open the view

code Views/Products/Edit.cshtml

Step 22.2 — Replace the model directive

Replace:

@model Product

with:

@model ProductManagement.ViewModels.ProductFormViewModel

Step 22.3 — Update the Category dropdown

Replace:

asp-items="ViewBag.CategoryId"

with:

asp-items="Model.Categories"

Keep the hidden ID input:

<input type="hidden" asp-for="Id" />

23. What Is Overposting?

Overposting occurs when model binding accepts properties that the user should not be allowed to change.

Form sends values ↓ Model Binding ↓ Too many entity properties accepted ↓ Potential unintended update

A dedicated form ViewModel narrows the set of values that the form can bind to. The controller then decides which values are copied to the entity.

Security principle

Do not assume a user can submit only the fields visible in the browser. HTTP requests can be constructed manually, so the server should control which values are accepted and applied.

24. Does Part 10 Require a Migration?

No. ViewModels are UI/application-layer classes. They are not added to ApplicationDbContext and do not represent new database tables.

dotnet ef migrations add ...   ← NOT required

25. Build the Project

Save all changed files, then run:

dotnet build
Checkpoint

Continue only when the project builds successfully.

26. Test the Product Index

dotnet run

Test:

/Products

Confirm that:

  • Product search still works;
  • Category filtering still works;
  • maximum-price filtering still works;
  • Name sorting still works;
  • Price sorting still works; and
  • Clear Filters still works.

27. Test Create

Open:

/Products/Create

Confirm that validation and the Category dropdown still work. Create a Product and confirm it appears in Index.

28. Test Edit

Edit the Product you just created. Confirm that:

  • the current values are loaded into the form;
  • the existing Category is selected;
  • validation still works;
  • changes save correctly; and
  • the Product ID remains unchanged.

29. Troubleshooting

ProductIndexViewModel cannot be found

Confirm the file namespace:

namespace ProductManagement.ViewModels;

and confirm the controller has:

using ProductManagement.ViewModels;
Index.cshtml says ProductIndexViewModel is unknown

Use the fully qualified directive:

@model ProductManagement.ViewModels.ProductIndexViewModel
Category dropdown is empty in Create or Edit

Confirm GetCategoryItemsAsync() is called before returning the ViewModel to the view.

Validation fails and the dropdown disappears

Confirm the invalid POST branch repopulates viewModel.Categories before returning the view.

Edit changes are not saved

Confirm the POST action loads the existing Product, copies the editable ViewModel properties, and calls SaveChangesAsync().

30. Knowledge Check

  1. What is a ViewModel?
  2. Does a ViewModel have to correspond to a database table?
  3. Why is ProductIndexViewModel useful?
  4. What did it replace from Part 9?
  5. Why create a separate ProductFormViewModel?
  6. What is overposting?
  7. Why does the Edit POST action load the existing Product first?
  8. Why is the Category list stored on the ViewModel?
  9. Does Part 10 require an EF Core migration?
  10. Why is explicit mapping useful?
Show suggested answers
  1. A class designed to represent the data and state required by a specific view or UI workflow.
  2. No.
  3. It combines Products, filters, sorting state and Category choices into one strongly typed model.
  4. Multiple ViewData/ViewBag values and a direct Product collection.
  5. To model exactly the editable form fields and supporting Category choices.
  6. Binding or applying properties that the user should not be allowed to modify.
  7. So the controller updates only the allowed properties on the real tracked entity.
  8. So the view receives both form values and dropdown choices in one strongly typed object.
  9. No.
  10. It makes the transfer from submitted UI data to entity data deliberate and visible.

31. Part 10 Summary

  • created a ViewModels folder;
  • created ProductIndexViewModel;
  • removed Product Index dependence on ViewData and ViewBag;
  • created ProductFormViewModel;
  • updated Create to bind to a form ViewModel;
  • updated Edit to bind to a form ViewModel;
  • added explicit ViewModel-to-entity mapping;
  • introduced overposting risk and safer binding practices; and
  • kept the EF Core database schema unchanged.

Appendix — Full Code for Final Verification

Purpose

Use this appendix after completing Part 10. Compare each modified file with the complete final version below.

Appendix A — ViewModels/ProductIndexViewModel.cs

using Microsoft.AspNetCore.Mvc.Rendering;
using ProductManagement.Models;

namespace ProductManagement.ViewModels;

public class ProductIndexViewModel
{
    public IEnumerable<Product> Products { get; set; }
        = new List<Product>();

    public string? Search { get; set; }

    public int? CategoryId { get; set; }

    public decimal? MaxPrice { get; set; }

    public string? SortOrder { get; set; }

    public string? NameSort { get; set; }

    public string? PriceSort { get; set; }

    public IEnumerable<SelectListItem> Categories { get; set; }
        = new List<SelectListItem>();
}

Appendix B — ViewModels/ProductFormViewModel.cs

using System.ComponentModel.DataAnnotations;
using Microsoft.AspNetCore.Mvc.Rendering;

namespace ProductManagement.ViewModels;

public class ProductFormViewModel
{
    public int Id { get; set; }

    [Required(ErrorMessage = "Product name is required.")]
    [StringLength(
        100,
        ErrorMessage = "Product name cannot exceed 100 characters.")]
    public string Name { get; set; } = string.Empty;

    [StringLength(
        500,
        ErrorMessage = "Description cannot exceed 500 characters.")]
    public string? Description { get; set; }

    [Range(
        0.01,
        1000000,
        ErrorMessage = "Price must be greater than zero.")]
    public decimal Price { get; set; }

    [Range(
        0,
        1000000,
        ErrorMessage = "Quantity cannot be negative.")]
    public int Quantity { get; set; }

    public int? CategoryId { get; set; }

    public IEnumerable<SelectListItem> Categories { get; set; }
        = new List<SelectListItem>();
}

Appendix C — Controllers/ProductsController.cs

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using ProductManagement.Data;
using ProductManagement.Models;
using ProductManagement.ViewModels;

namespace ProductManagement.Controllers;

public class ProductsController : Controller
{
    private readonly ApplicationDbContext _context;

    public ProductsController(ApplicationDbContext context)
    {
        _context = context;
    }

    public async Task<IActionResult> Index(
        string? search,
        int? categoryId,
        decimal? maxPrice,
        string? sortOrder)
    {
        var products = _context.Products
            .Include(p => p.Category)
            .AsQueryable();

        if (!string.IsNullOrWhiteSpace(search))
        {
            products = products.Where(
                p => p.Name.Contains(search));
        }

        if (categoryId.HasValue)
        {
            products = products.Where(
                p => p.CategoryId == categoryId.Value);
        }

        if (maxPrice.HasValue)
        {
            products = products.Where(
                p => p.Price <= maxPrice.Value);
        }

        products = sortOrder switch
        {
            "name_desc" =>
                products.OrderByDescending(p => p.Name),

            "price" =>
                products.OrderBy(p => p.Price),

            "price_desc" =>
                products.OrderByDescending(p => p.Price),

            _ =>
                products.OrderBy(p => p.Name)
        };

        var viewModel = new ProductIndexViewModel
        {
            Products = await products.ToListAsync(),
            Search = search,
            CategoryId = categoryId,
            MaxPrice = maxPrice,
            SortOrder = sortOrder,

            NameSort =
                sortOrder == "name_desc"
                    ? ""
                    : "name_desc",

            PriceSort =
                sortOrder == "price"
                    ? "price_desc"
                    : "price",

            Categories = await _context.Categories
                .OrderBy(c => c.Name)
                .Select(c => new SelectListItem
                {
                    Value = c.Id.ToString(),
                    Text = c.Name,
                    Selected = c.Id == categoryId
                })
                .ToListAsync()
        };

        return View(viewModel);
    }

    public async Task<IActionResult> Details(int? id)
    {
        if (id == null)
        {
            return NotFound();
        }

        var product = await _context.Products
            .Include(p => p.Category)
            .FirstOrDefaultAsync(p => p.Id == id);

        if (product == null)
        {
            return NotFound();
        }

        return View(product);
    }

    [HttpGet]
    public async Task<IActionResult> Create()
    {
        var viewModel = new ProductFormViewModel
        {
            Categories = await GetCategoryItemsAsync()
        };

        return View(viewModel);
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Create(
        ProductFormViewModel viewModel)
    {
        if (!ModelState.IsValid)
        {
            viewModel.Categories =
                await GetCategoryItemsAsync(
                    viewModel.CategoryId);

            return View(viewModel);
        }

        var product = new Product
        {
            Name = viewModel.Name,
            Description = viewModel.Description,
            Price = viewModel.Price,
            Quantity = viewModel.Quantity,
            CategoryId = viewModel.CategoryId
        };

        _context.Products.Add(product);
        await _context.SaveChangesAsync();

        return RedirectToAction(nameof(Index));
    }

    [HttpGet]
    public async Task<IActionResult> Edit(int? id)
    {
        if (id == null)
        {
            return NotFound();
        }

        var product = await _context.Products
            .FindAsync(id);

        if (product == null)
        {
            return NotFound();
        }

        var viewModel = new ProductFormViewModel
        {
            Id = product.Id,
            Name = product.Name,
            Description = product.Description,
            Price = product.Price,
            Quantity = product.Quantity,
            CategoryId = product.CategoryId,
            Categories =
                await GetCategoryItemsAsync(
                    product.CategoryId)
        };

        return View(viewModel);
    }

    [HttpPost]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> Edit(
        int id,
        ProductFormViewModel viewModel)
    {
        if (id != viewModel.Id)
        {
            return NotFound();
        }

        if (!ModelState.IsValid)
        {
            viewModel.Categories =
                await GetCategoryItemsAsync(
                    viewModel.CategoryId);

            return View(viewModel);
        }

        var product = await _context.Products
            .FindAsync(id);

        if (product == null)
        {
            return NotFound();
        }

        product.Name = viewModel.Name;
        product.Description = viewModel.Description;
        product.Price = viewModel.Price;
        product.Quantity = viewModel.Quantity;
        product.CategoryId = viewModel.CategoryId;

        await _context.SaveChangesAsync();

        return RedirectToAction(nameof(Index));
    }

    [HttpGet]
    public async Task<IActionResult> Delete(int? id)
    {
        if (id == null)
        {
            return NotFound();
        }

        var product = await _context.Products
            .Include(p => p.Category)
            .FirstOrDefaultAsync(p => p.Id == id);

        if (product == null)
        {
            return NotFound();
        }

        return View(product);
    }

    [HttpPost, ActionName("Delete")]
    [ValidateAntiForgeryToken]
    public async Task<IActionResult> DeleteConfirmed(int id)
    {
        var product = await _context.Products
            .FindAsync(id);

        if (product != null)
        {
            _context.Products.Remove(product);
            await _context.SaveChangesAsync();
        }

        return RedirectToAction(nameof(Index));
    }

    private async Task<List<SelectListItem>>
        GetCategoryItemsAsync(int? selectedId = null)
    {
        return await _context.Categories
            .OrderBy(c => c.Name)
            .Select(c => new SelectListItem
            {
                Value = c.Id.ToString(),
                Text = c.Name,
                Selected = c.Id == selectedId
            })
            .ToListAsync();
    }

    private bool ProductExists(int id)
    {
        return _context.Products.Any(
            p => p.Id == id);
    }
}

Appendix D — Views/Products/Index.cshtml

@model ProductManagement.ViewModels.ProductIndexViewModel

@{
    ViewData["Title"] = "Products";
}

<h1>Products</h1>

<p>
    <a asp-action="Create"
       class="btn btn-primary">Create New Product</a>
</p>

<form asp-action="Index"
      method="get"
      class="row g-3 mb-4">

    <input type="hidden"
           name="sortOrder"
           value="@Model.SortOrder" />

    <div class="col-md-4">
        <label for="search"
               class="form-label">Search</label>

        <input type="text"
               id="search"
               name="search"
               value="@Model.Search"
               class="form-control"
               placeholder="Product name" />
    </div>

    <div class="col-md-3">
        <label for="categoryId"
               class="form-label">Category</label>

        <select id="categoryId"
                name="categoryId"
                class="form-select"
                asp-items="Model.Categories">
            <option value="">All Categories</option>
        </select>
    </div>

    <div class="col-md-3">
        <label for="maxPrice"
               class="form-label">Maximum Price</label>

        <input type="number"
               id="maxPrice"
               name="maxPrice"
               value="@Model.MaxPrice"
               class="form-control"
               min="0"
               step="0.01" />
    </div>

    <div class="col-md-2 d-flex align-items-end">
        <button type="submit"
                class="btn btn-primary w-100">Apply</button>
    </div>
</form>

<p>
    <a asp-action="Index"
       class="btn btn-outline-secondary">Clear Filters</a>
</p>

@if (!Model.Products.Any())
{
    <p>No products match the current search and filters.</p>
}
else
{
    <table class="table table-striped">
        <thead>
            <tr>
                <th>ID</th>

                <th>
                    <a asp-action="Index"
                       asp-route-search="@Model.Search"
                       asp-route-categoryId="@Model.CategoryId"
                       asp-route-maxPrice="@Model.MaxPrice"
                       asp-route-sortOrder="@Model.NameSort">
                        Name
                    </a>
                </th>

                <th>
                    <a asp-action="Index"
                       asp-route-search="@Model.Search"
                       asp-route-categoryId="@Model.CategoryId"
                       asp-route-maxPrice="@Model.MaxPrice"
                       asp-route-sortOrder="@Model.PriceSort">
                        Price
                    </a>
                </th>

                <th>Quantity</th>
                <th>Category</th>
                <th>Action</th>
            </tr>
        </thead>

        <tbody>
            @foreach (var product in Model.Products)
            {
                <tr>
                    <td>@product.Id</td>
                    <td>@product.Name</td>
                    <td>RM @product.Price.ToString("N2")</td>
                    <td>@product.Quantity</td>
                    <td>@(product.Category?.Name ?? "Unassigned")</td>
                    <td>
                        <a asp-action="Details"
                           asp-route-id="@product.Id">Details</a>
                        |
                        <a asp-action="Edit"
                           asp-route-id="@product.Id">Edit</a>
                        |
                        <a asp-action="Delete"
                           asp-route-id="@product.Id">Delete</a>
                    </td>
                </tr>
            }
        </tbody>
    </table>
}

Appendix E — Views/Products/Create.cshtml

@model ProductManagement.ViewModels.ProductFormViewModel

@{
    ViewData["Title"] = "Create Product";
}

<h1>Create Product</h1>

<form asp-action="Create" method="post">

    <div asp-validation-summary="ModelOnly"
         class="text-danger"></div>

    <div class="mb-3">
        <label asp-for="Name" class="form-label"></label>
        <input asp-for="Name" class="form-control" />
        <span asp-validation-for="Name"
              class="text-danger"></span>
    </div>

    <div class="mb-3">
        <label asp-for="Description" class="form-label"></label>
        <textarea asp-for="Description"
                  class="form-control"></textarea>
        <span asp-validation-for="Description"
              class="text-danger"></span>
    </div>

    <div class="mb-3">
        <label asp-for="Price" class="form-label"></label>
        <input asp-for="Price" class="form-control" />
        <span asp-validation-for="Price"
              class="text-danger"></span>
    </div>

    <div class="mb-3">
        <label asp-for="Quantity" class="form-label"></label>
        <input asp-for="Quantity" class="form-control" />
        <span asp-validation-for="Quantity"
              class="text-danger"></span>
    </div>

    <div class="mb-3">
        <label asp-for="CategoryId"
               class="form-label">Category</label>

        <select asp-for="CategoryId"
                class="form-select"
                asp-items="Model.Categories">
            <option value="">-- Select Category --</option>
        </select>
    </div>

    <button type="submit"
            class="btn btn-primary">Create</button>

    <a asp-action="Index"
       class="btn btn-secondary">Cancel</a>
</form>

@section Scripts {
    @{
        await Html.RenderPartialAsync("_ValidationScriptsPartial");
    }
}

Appendix F — Views/Products/Edit.cshtml

@model ProductManagement.ViewModels.ProductFormViewModel

@{
    ViewData["Title"] = "Edit Product";
}

<h1>Edit Product</h1>

<form asp-action="Edit" method="post">

    <input type="hidden" asp-for="Id" />

    <div asp-validation-summary="ModelOnly"
         class="text-danger"></div>

    <div class="mb-3">
        <label asp-for="Name" class="form-label"></label>
        <input asp-for="Name" class="form-control" />
        <span asp-validation-for="Name"
              class="text-danger"></span>
    </div>

    <div class="mb-3">
        <label asp-for="Description" class="form-label"></label>
        <textarea asp-for="Description"
                  class="form-control"></textarea>
        <span asp-validation-for="Description"
              class="text-danger"></span>
    </div>

    <div class="mb-3">
        <label asp-for="Price" class="form-label"></label>
        <input asp-for="Price" class="form-control" />
        <span asp-validation-for="Price"
              class="text-danger"></span>
    </div>

    <div class="mb-3">
        <label asp-for="Quantity" class="form-label"></label>
        <input asp-for="Quantity" class="form-control" />
        <span asp-validation-for="Quantity"
              class="text-danger"></span>
    </div>

    <div class="mb-3">
        <label asp-for="CategoryId"
               class="form-label">Category</label>

        <select asp-for="CategoryId"
                class="form-select"
                asp-items="Model.Categories">
            <option value="">-- Select Category --</option>
        </select>
    </div>

    <button type="submit"
            class="btn btn-primary">Save Changes</button>

    <a asp-action="Index"
       class="btn btn-secondary">Cancel</a>
</form>

@section Scripts {
    @{
        await Html.RenderPartialAsync("_ValidationScriptsPartial");
    }
}

Appendix G — Final Verification Commands

cd ~/aspnet-mvc-tutorial/ProductManagement

dotnet build
dotnet run

Test:

/Products
/Products/Create
/Products/Edit/1
/Products/Details/1
/Products/Delete/1
Final Part 10 checkpoint

If Product Index search/filter/sort still works, Create and Edit work with validation and Categories, and Product CRUD remains functional, Part 10 is complete.

Next: Part 11 — ASP.NET Core Identity

In Part 11, we will add user authentication, registration, login and logout, then protect selected MVC actions with [Authorize].