ASP.NET CORE MVC - ViewModels
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.
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.
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.
- Understand why ViewModels are useful
- Create
ProductIndexViewModel - Update the Index action
- Update
Index.cshtml - Create
ProductFormViewModel - Update Create GET and POST
- Update
Create.cshtml - Update Edit GET and POST
- Update
Edit.cshtml - Understand overposting
- Build and test the completed application
- 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
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.
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
| Property | Purpose |
|---|---|
Products | The Product rows displayed by the page. |
Search | The current Product-name search value. |
CategoryId | The currently selected Category. |
MaxPrice | The current maximum-price filter. |
SortOrder | The current sorting mode. |
NameSort | The next sort value for the Name link. |
PriceSort | The next sort value for the Price link. |
Categories | The 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);
}
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
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
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));
}
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.
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.
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
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.ProductIndexViewModelCategory 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
- What is a ViewModel?
- Does a ViewModel have to correspond to a database table?
- Why is
ProductIndexViewModeluseful? - What did it replace from Part 9?
- Why create a separate
ProductFormViewModel? - What is overposting?
- Why does the Edit POST action load the existing Product first?
- Why is the Category list stored on the ViewModel?
- Does Part 10 require an EF Core migration?
- Why is explicit mapping useful?
Show suggested answers
- A class designed to represent the data and state required by a specific view or UI workflow.
- No.
- It combines Products, filters, sorting state and Category choices into one strongly typed model.
- Multiple ViewData/ViewBag values and a direct Product collection.
- To model exactly the editable form fields and supporting Category choices.
- Binding or applying properties that the user should not be allowed to modify.
- So the controller updates only the allowed properties on the real tracked entity.
- So the view receives both form values and dropdown choices in one strongly typed object.
- No.
- It makes the transfer from submitted UI data to entity data deliberate and visible.
31. Part 10 Summary
- created a
ViewModelsfolder; - created
ProductIndexViewModel; - removed Product Index dependence on
ViewDataandViewBag; - 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
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
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.
In Part 11, we will add user authentication, registration, login and logout, then protect selected MVC actions with [Authorize].