Data Lists

In this tutorial we will add a brands data list, as well as giving you the option to specify a brand against the product.

First we'll start by making the appropriate changes to the database via a migration. If you have been following along so far we should now be up to "Migration7.cs", create the file in the "Migrations" folder with the following content:

using FluentMigrator;
using Kit.Security;

namespace DemoShop.Shop.Migrations;

[Migration(201908141834)]
public class Migration7 : AutoReversingMigration {
    public override void Up() {
        Create.Table("Brands")
            .WithColumn("Id").AsInt32().NotNullable().PrimaryKey("PK_Brands").Identity()
            .WithColumn("Name").AsString(100).NotNullable()
            .WithColumn("IsActive").AsBoolean().NotNullable();

        Create.UniqueConstraint("UK_Brands_Name").OnTable("Brands").Column("Name");

        Insert.IntoTable("Permissions").Row(new { GroupId = (int)PermissionGroup.DataLists, Name = "Brands", MinLevel = 3, Namespace = "DemoShop.Shop", ConstantName = "Brands" });

        Insert.IntoTable("RolePermissions")
            .Row(new { RoleId = 1, PermissionId = RawSql.Insert("(SELECT [Id] FROM [dbo].[Permissions] WHERE [Namespace] = 'DemoShop.Shop' AND [ConstantName] = 'Brands')") })
            .Row(new { RoleId = 2, PermissionId = RawSql.Insert("(SELECT [Id] FROM [dbo].[Permissions] WHERE [Namespace] = 'DemoShop.Shop' AND [ConstantName] = 'Brands')") });

        Alter.Table("Products").AddColumn("BrandId").AsInt32().Nullable();

        Create.ForeignKey("FK_Products_Brands").FromTable("Products").ForeignColumn("BrandId").ToTable("Brands").PrimaryColumn("Id");

        Insert.IntoTable("SiteMapNodes")
            .Row(new { ParentId = RawSql.Insert("(SELECT [Id] FROM [dbo].[SiteMapNodes] WHERE [Action] = 'DataLists' AND [Controller] = 'Admin' AND [Area] = 'Admin')"), Name = "Brands", Action = "Brands", Controller = "Admin", Area = "DemoShop.Shop", Order = 1, ShowInMenu = true, ShowInMenuIfHasNoChildren = true, IsLocked = false })
            .Row(new { ParentId = RawSql.Insert("(SELECT [Id] FROM [dbo].[SiteMapNodes] WHERE [Action] = 'Brands' AND [Controller] = 'Admin' AND [Area] = 'DemoShop.Shop')"), Name = "New Brand", Action = "NewBrand", Controller = "Admin", Area = "DemoShop.Shop", Order = 1, ShowInMenu = false, IsLocked = false })
            .Row(new { ParentId = RawSql.Insert("(SELECT [Id] FROM [dbo].[SiteMapNodes] WHERE [Action] = 'Brands' AND [Controller] = 'Admin' AND [Area] = 'DemoShop.Shop')"), Name = "Edit Brand", Action = "EditBrand", Controller = "Admin", Area = "DemoShop.Shop", Order = 1, ShowInMenu = false, IsLocked = false });
    }
}

Note: This will make the appropriate database modifications as well as adding the permissions and  site map nodes.

The next step is to add our brand model, create the following file called "Brand.cs" in the "Models" folder:

using Kit.Entities;

namespace DemoShop.Shop.Models;

public class Brand : Entity<int> {
    public virtual string Name { get; set; } = default!;
    public virtual bool IsActive { get; set; } = true;
}

Next we need to map the model to the database by adding the following "BrandMapping.cs" file within the "Models" folder:

using NHibernate.Mapping.ByCode;
using NHibernate.Mapping.ByCode.Conformist;

namespace DemoShop.Shop.Models;

public class BrandtMapping : ClassMapping<Brand> {
    public BrandtMapping() {
        Table("Brands");
        Id(x => x.Id, m => m.Generator(Generators.Identity));
        Property(x => x.Name);
        Property(x => x.IsActive);
        Cache(x => x.Usage(CacheUsage.ReadWrite));
    }
}

Before we add the view model, controller and view logic, we'll take this opportunity to add the permission to the permission's enum. Open the "Permission.cs" file and replace it with the following:

namespace DemoShop.Shop;

public enum Permission {
    Brands,
    Products,
    ProductsDelete
}

Now we'll add our view models. Add a file called “BrandViewModel.cs” with the following content to the “ViewModels/Admin” folder:

using DemoShop.Shop.Models;

namespace DemoShop.Shop.ViewModels.Admin;

public class BrandViewModel {
    public BrandViewModel(Brand brand) {
        Id = brand.Id;
        Name = brand.Name;
        IsActive = brand.IsActive;
    }

    public int Id { get; }
    public string Name { get; }
    public bool IsActive { get; }
}

In the same folder, add a file called “BrandFormViewModel.cs” (which we'll use the insert/edit pages) with the following content:

using System.ComponentModel.DataAnnotations;
using DemoShop.Shop.Models;

namespace DemoShop.Shop.ViewModels.Admin;

public class BrandFormViewModel {
    public BrandFormViewModel() { }

    public BrandFormViewModel(Brand brand) : this() {
        Name = brand.Name;
        IsActive = brand.IsActive;
        Initialize(brand);
    }

    public int Id { get; set; }

    [Required, StringLength(100)]
    public string Name { get; set; } = default!;

    [Required]
    public bool IsActive { get; set; }

    public BrandFormViewModel Initialize(Brand brand) {
        Id = brand.Id;

        return this;
    }

    public Brand ToModel(Brand? brand = null) {
        if (brand == null)
            brand = new Brand();

        brand.Name = Name;
        brand.IsActive = IsActive;

        return brand;
    }
}

Now we can add our controller methods. Open the "AdminController.cs" file and add the following:

#region Brand Members

[HasPermission(Permission.Brands), HttpGet("brands")]
public async Task<IActionResult> Brands(bool isActive = true, int page = 1, int pageSize = 10, string? sortBy = null, ListSortDirection? sortDirection = null) {
    return View((await _dataContext.Repository<Brand>().All().Where(b => b.IsActive == isActive).ToSortedPagedListAsync(page, pageSize, sortBy ?? "Name", sortDirection ?? ListSortDirection.Ascending)).Convert(b => new BrandViewModel(b)));
}

[HasPermission(Permission.Brands), HttpPost("brands"), ValidateAntiForgeryToken]
public async Task<IActionResult> Brands(string command, int? id, bool isActive = true, int page = 1, int pageSize = 10, string? sortBy = null, ListSortDirection? sortDirection = null) {
    if (id.HasValue) {
        switch (command) {
            case "Delete":
                try {
                    // Try to delete the brand.
                    await _dataContext.Repository<Brand>().DeleteAsync((await _dataContext.Repository<Brand>().GetAsync(id))!);
                    await _dataContext.CommitAsync();

                    // Set the result text.
                    TempData.Set("FormResult", _localizer["{0} successfully deleted.", _localizer["Brand"]].Value);
                } catch {
                    // Add the error.
                    ModelState.AddModelError("Error", _localizer["{0} cannot be deleted.", _localizer["Brand"]].Value);

                    // Rollback the changes.
                    await _dataContext.RollbackAsync();

                    return await Brands(isActive, page, pageSize, sortBy, sortDirection);
                }

                break;
            case "Edit":
                return RedirectToAction(nameof(EditBrand), new { id, returnUrl = Url.Action(nameof(Brands), new { isActive, page, pageSize, sortBy, sortDirection }) });
        }
    }

    return RedirectToAction(nameof(Brands), new { page = 1, pageSize, sortBy, sortDirection });
}

[HasPermission(Permission.Brands), HttpGet("brands/new")]
public IActionResult NewBrand() {
    return View(new BrandFormViewModel(new Brand()));
}

[HasPermission(Permission.Brands), HttpPost("brands/new"), ValidateAntiForgeryToken]
public async Task<IActionResult> NewBrand(BrandFormViewModel model) {
    // Validate the form.
    if (!ModelState.IsValid)
        return View(model.Initialize(new Brand()));

    // Insert the brand.
    await _dataContext.Repository<Brand>().InsertOrUpdateAsync(model.ToModel());
    await _dataContext.CommitAsync();

    // Set the result text.
    TempData.Set("FormResult", _localizer["{0} successfully added.", _localizer["Brand"]].Value);

    return RedirectToAction(nameof(Brands));
}

[HasPermission(Permission.Brands), HttpGet("brands/{id:int}/edit")]
public async Task<IActionResult> EditBrand(int id) {
    return View(new BrandFormViewModel((await _dataContext.Repository<Brand>().GetAsync(id))!));
}

[HasPermission(Permission.Brands), HttpPost("brands/{id:int}/edit"), ValidateAntiForgeryToken]
public async Task<IActionResult> EditBrand(int id, BrandFormViewModel model, string? returnUrl = null) {
    var brand = (await _dataContext.Repository<Brand>().GetAsync(id))!;

    // Validate the form.
    if (!ModelState.IsValid)
        return View(model.Initialize(brand));

    // Update the brand.
    model.ToModel(brand);
    await _dataContext.CommitAsync();

    // Set the result text.
    TempData.Set("FormResult", _localizer["{0} successfully updated.", _localizer["Brand"]].Value);

    if (Url.IsLocalUrl(returnUrl))
        return Redirect(returnUrl);
    else
        return RedirectToAction(nameof(Brands));
}

#endregion

Note: It's best practice to group your methods by the model, surround the group in a region block and then order each group alphabetically. This will make it easier to find your methods as the controller logic grows.

Finally let's add our views, first add the "/Views/Admin/Brands.cshtml" file with the following content:

@model ISortedPagedList<DemoShop.Shop.ViewModels.Admin.BrandViewModel>
<form method="post" asp-action="Brands" asp-all-route-data="@Context.Request.GetQueryValues().ToStringDictionary()">
    <nav id="icons">
        <ul class="nav-responsive">
            <li class="new"><a asp-action="NewBrand">@Text["New"]</a></li>
            @if (Model.TotalCount > 0) {
                <li class="divide"></li>
                <li class="edit"><button name="Command" value="Edit">@Text["Edit"]</button></li>
                <li class="delete"><button type="button" data-bs-toggle="modal" data-bs-target="#confirm-delete-modal">@Text["Delete"]</button></li>
            }
        </ul>
    </nav>
    <div asp-validation-summary="All"></div>
    <div class="card shadow">
        <div class="card-header">
            <div class="row align-items-end gx-2">
                <div class="col-auto">
                    <label for="Search" class="form-label">@Text["Search"]</label>
                    <editor name="Search" />
                </div>
                <div class="col-auto">
                    <label for="IsActive" class="form-label">@Text["Is Active"]</label>
                    <editor name="IsActive" template-name="Boolean" default-value="true" />
                </div>
                <div class="col-auto">
                    <button name="Command" value="Filter" class="btn btn-primary">@Text["Find"]</button>
                </div>
            </div>
        </div>
        <div class="card-body">
            @if (Model.TotalCount > 0) {
                <div class="table-responsive">
                    <table class="table table-striped">
                        <colgroup>
                            <col style="width: 35px" />
                        </colgroup>
                        <thead>
                            <tr>
                                <th></th>
                                <th class="sorting@(Model.SortBy == "Name" ? (Model.SortDirection == ListSortDirection.Ascending ? " sorting-asc" : " sorting-desc") : "")"><a asp-action="Brands" asp-all-route-data="@Context.Request.GetQueryValues().AddRange(new { sortBy = "Name", sortDirection = Model.SortDirection == ListSortDirection.Ascending && Model.SortBy == "Name" ? ListSortDirection.Descending : ListSortDirection.Ascending }).ToStringDictionary()">@Text["Name"]</a></th>
                            </tr>
                        </thead>
                        <tbody>
                            @for (var i = 0; i < Model.Count; i++) {
                                <tr>
                                    <td class="text-center"><input type="radio" name="Id" value="@Model[i].Id"@(i == 0 ? " checked=\"checked\"" : "") class="form-check-input" /></td>
                                    <td>@Format(Model[i].Name)</td>
                                </tr>
                            }
                        </tbody>
                    </table>
                </div>
            } else {
                @Text["No {0} found.", Text["brands"]]
            }
        </div>
        @if (Model.TotalCount > 0) {
            <div class="card-footer">
                <div class="row gx-2">
                    <div class="col">
                        <pager model="@Model" />
                    </div>
                    <div class="col-auto">
                        <editor name="PageSize" template-name="SelectList" default-value="10" view-data-items="@Enumerable.Range(1, 5).Select(i => new SelectListItem(Text["{0} per page", i * 10].ToHtmlString(), (i * 10).ToString()))" view-data-showdefaultoption="false" onchange="this.form.submit()" />
                    </div>
                </div>
            </div>
        }
    </div>
    @if (Model.TotalCount > 0) {
        <div id="confirm-delete-modal" class="modal fade">
	        <div class="modal-dialog">
	            <div class="modal-content">
	                <div class="modal-header">
                        <h5 class="modal-title">@Text["Confirm Delete"]</h5>
	                    <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
	                </div>
	                <div class="modal-body">
	                    @Text["Are you sure you want to delete the selected {0}?", Text["brand"]]
	                </div>
	                <div class="modal-footer">
	                    <button name="Command" value="Delete" class="btn btn-danger" data-bs-dismiss="modal">@Text["Delete"]</button>
	                    <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">@Text["Cancel"]</button>
	                </div>
	            </div>
	        </div>
	    </div>
    }
</form>

Note: We have added a filter for the active brands.

Next add "NewBrand.cshtml" file in the same folder with the following content:

@model DemoShop.Shop.ViewModels.Admin.BrandFormViewModel
<form method="post" asp-action="NewBrand">
    <nav id="icons">
        <ul class="nav-responsive">
            <li class="save"><button>@Text["Save"]</button></li>
            <li class="cancel"><a asp-action="Brands">@Text["Cancel"]</a></li>
        </ul>
    </nav>
    <div asp-validation-summary="All"></div>
    <div class="card shadow">
        <div class="card-header"><h2>@Text["Details"]</h2></div>
        <div class="card-body">
            <div class="form-group">
                <label asp-for="Name" class="form-label"></label>
                <editor for="Name" />
            </div>
            <div class="form-group">
                <label asp-for="IsActive" class="form-label"></label>
                <editor for="IsActive" />
            </div>
        </div>
    </div>
</form>

Finally add a "EditBrand.cshtml" file in the same folder with the following content:

@model DemoShop.Shop.ViewModels.Admin.BrandFormViewModel
<form method="post" asp-action="EditBrand" asp-route-id="@Model.Id" asp-route-returnurl="@Context.Request.Query.Get("ReturnUrl")">
    <nav id="icons">
        <ul class="nav-responsive">
            <li class="save"><button>@Text["Save"]</button></li>
            <li class="cancel"><a href="@Format(Context.Request.Query.Get("ReturnUrl") ?? Url.Action("Brands"))">@Text["Cancel"]</a></li>
        </ul>
    </nav>
    <div asp-validation-summary="All"></div>
    <div class="card shadow">
        <div class="card-header"><h2>@Text["Details"]</h2></div>
        <div class="card-body">
            <div class="form-group">
                <label asp-for="Name" class="form-label"></label>
                <editor for="Name" />
            </div>
            <div class="form-group">
                <label asp-for="IsActive" class="form-label"></label>
                <editor for="IsActive" />
            </div>
        </div>
    </div>
</form>

Next we will add the ability to specify the brand a product belongs to. This is where we can now take advantage of our view model since our new/edit product pages will require a list of brands.

We already add a "BrandId" field to the database in the migration above, so we'll start by adding the following property to the "Product" model:

public virtual Brand? Brand { get; set; }

We'll then map it changing the "ProductMapping.cs" file to the following:

using NHibernate.Mapping.ByCode;
using NHibernate.Mapping.ByCode.Conformist;

namespace DemoShop.Shop.Models;

public class ProductMapping : ClassMapping<Product> {
    public ProductMapping() {
        Table("Products");
        Id(x => x.Id, m => m.Generator(Generators.Identity));
        Property(x => x.Name);
        ManyToOne(x => x.Brand);
        Property(x => x.Price);
        Property(x => x.DateAdded);
        Cache(x => x.Usage(CacheUsage.ReadWrite));
    }
}

Note: We use ManyToOne (many-to-one) to specify that the "Brand" property has a relationship to the "Brand". Also by convention we don't have to specify the "Id" at the end of the property name.

Now we'll modify our product form view model so we can select the brand a product belongs to. Open the “ViewModels/Admin/ProductFormViewModel.cs” file and replace it with the following:

using System.Collections.Generic;
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using DemoShop.Shop.Models;
using Microsoft.AspNetCore.Mvc.Rendering;

namespace DemoShop.Shop.ViewModels.Admin;

public class ProductFormViewModel {
    public ProductFormViewModel() { }

    public ProductFormViewModel(Product product, IList<Brand> brands) : this() {
        Name = product.Name;

        BrandId = product.Brand?.Id;
        Price = product.Price;
        Initialize(product, brands);
    }

    public int Id { get; set; }

    [Required, StringLength(100)]
    public string Name { get; set; } = default!;

    [DisplayName("Brand")]
    public int? BrandId { get; set; }

    [Required]
    public decimal Price { get; set; }

    public IList<SelectListItem> Brands { get; set; } = new List<SelectListItem>();

    public ProductFormViewModel Initialize(Product product, IList<Brand> brands) {
        Id = product.Id;
        Brands = brands.Select(b => new SelectListItem(b.Name, b.Id.ToString())).ToList();

        return this;
    }

    public Product ToModel(Product? product = null) {
        if (product == null)
            product = new Product();

        product.Name = Name;
        product.Brand = BrandId.HasValue ? new Brand() { Id = BrandId.Value } : null;
        product.Price = Price;

        return product;
    }
}

We have also added a BrandId property which we convert to/from our view model and model. Also the constructor now takes a list of brands, which we then convert to a  IList<SelectListItem>. This done in the Initialize method since the “Brands” are not posted back with the form (so it won't be model bound), therefore if we were to re display the form (if validation fails) then we would need to reconstruct this list.

Now open the "AdminController.cs" file and change everywhere we feed our model into the new/edit product views to now feed in our View Model:

First change the "NewProduct" method to the following:

[HasPermission(Permission.Products), HttpGet("products/new")]
public async Task<IActionResult> NewProduct() {
    return View(new ProductFormViewModel(new Product(), await _dataContext.Repository<Brand>().All().ToSortedListAsync("Name", ListSortDirection.Ascending)));
}

Next change the call to the “Initialize” method in the “NewProduct” post method with the following:

model.Initialize(new Product(), await _dataContext.Repository<Brand>().All().ToSortedListAsync("Name", ListSortDirection.Ascending))

Next change the "EditProduct" method to the following:

[HasPermission(Permission.Products), HttpGet("products/{id:int}/edit")]
public async Task<IActionResult> EditProduct(int id) {
    return View(new ProductFormViewModel((await _dataContext.Repository<Product>().GetAsync(id))!, await _dataContext.Repository<Brand>().All().ToSortedListAsync("Name", ListSortDirection.Ascending)));
}

Finally change the call to the “Initialize” method in the “EditProduct” post method to the following (similar to what we did above):

model.Initialize(product, await _dataContext.Repository<Brand>().All().ToSortedListAsync("Name", ListSortDirection.Ascending))

Now we'll update our views, first add the following within the card body of the "NewProduct.cshtml" and “EditProduct.cshtml” files:

<div class="form-group">
    <label asp-for="BrandId" class="form-label"></label>
    <editor for="BrandId" template-name="SelectList" view-data-items="Model.Brands" />
</div>

Finally navigate to the brands admin page (under Admin -> Data Lists in the menu) and add some brands. Now return the products admin page and we should now be able to select the brand a product belongs to.

To finish off this tutorial we will display the brand on the front end of our site.

First we need to make sure we fetch the brand when retrieving the products. Open the "HomeController.cs" file and change the "Index" method to the following:

[HttpGet]
public async Task<IActionResult> Index(int page = 1) {
    return View((await _dataContext.Repository<Product>().All().Fetch(p => p.Brand).ToPagedListAsync(page, 10)).Convert(p => new ProductViewModel(p)));
}

Note: The call to Fetch(p => p.Brand) does a JOIN in SQL. Without this it would do an additional query for each product that we iterate over.

Now we have fetched the Brand it is safe to add it to the view model. Open the “ViewModels/Home/ProductViewModel.cs” file and replace it with the following:

using DemoShop.Shop.Models;

namespace DemoShop.Shop.ViewModels.Home;

public class ProductViewModel {
    public ProductViewModel(Product product) {
        Name = product.Name;
        BrandName = product.Brand?.Name;
        Price = product.Price;
    }

    public string Name { get; }
    public string? BrandName { get; }
    public decimal Price { get; }
}

Finally open the "Views/Shared/DisplayTemplates/Product.cshtml" file and replace it with the following:

@model DemoShop.Shop.ViewModels.Home.ProductViewModel
<article class="col-md-4">
    <h2>@Format(Model.Name)</h2>
    @if (!string.IsNullOrEmpty(Model.BrandName)) {
        <p>@Text["Brand"]: @Format(Model.BrandName)</p>
    }
    <p>@Format(Model.Price.ToString("c"))</p>
</article>

Collections »