Components

Components are a way of grouping database fields into a common construct known as a component. For the purposes of this demonstration we will add a "Head" and "Body" component to our "Product" model.

First let's add the database fields via a migration. Add a "Migration11.cs" file to our "Migrations" folder with the following:

using FluentMigrator;

namespace DemoShop.Shop.Migrations;

[Migration(201908141848)]
public class Migration11 : AutoReversingMigration {
    public override void Up() {
        Alter.Table("Products")
            .AddColumn("PageTitle").AsString(1024).Nullable()
            .AddColumn("Metadata").AsString(int.MaxValue).Nullable()
            .AddColumn("PageLayout").AsString(100).Nullable()
            .AddColumn("CssClass").AsString(100).Nullable()
            .AddColumn("Content").AsString(int.MaxValue).Nullable();
    }
}

Note: The "PageTitle" and "Metadata" fields make up the "Head" component and the "PageLayout", "CssClass" and "Content" fields make up the "Body" component.

Now add the following properties to the the "Product.cs" model file (also add any missing namespaces):

public virtual IHead? Head { get; set; }
public virtual IBody? Body { get; set; }

Note: The "Head" and "Body" fields are grouped together in their own properties, this is known as a component.

Next we must make sure the components are mapped to the correct database fields. Open the "ProductMapping.cs" file in the "Models" folder and add the following in the constructor:

Component(x => x.Head);
Component(x => x.Body);

Next make the following changes to both the "NewProduct" and "EditProduct" views:

First add the following inside the tabs unordered list:

<li class="nav-item"><a href="#head" class="nav-link" data-bs-toggle="tab">@Text["Head"]</a></li>
<li class="nav-item"><a href="#body" class="nav-link" data-bs-toggle="tab">@Text["Body"]</a></li>

Next add the following sections for our tabs within the form:

<section id="head" class="tab-pane">
    <editor for="Head" />
</section>
<section id="body" class="tab-pane">
    <editor for="Body" view-data-pagelayouts="Model.PageLayouts" />
</section>

Note: There are already component editor templates for both the "Head" and "Body" components.

You may have noticed above that the view is now looking for a property called "PageLayouts" within the view model so let's go ahead and add it, as well as our “Head” and “Body” component properties. Open the "ViewModels/Admin/ProductFormViewModel.cs" file and add replace it with the following:

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

namespace DemoShop.Shop.ViewModels.Admin;

public class ProductFormViewModel {
    public ProductFormViewModel() { }

    public ProductFormViewModel(Product product, IList<Brand> brands, IList<Product> relatedProducts, IEnumerable<string> pageLayouts) : this() {
        Name = product.Name;
        Head = product.Head?.Copy();
        Body = product.Body?.Copy();
        BrandId = product.Brand?.Id;
        Price = product.Price;
        Features = product.Features.OrderBy(f => f.Order).Select(f => new FeatureViewModel(f)).ToList();
        RelatedProducts = relatedProducts.Select(p => new ProductViewModel(p, product.RelatedProducts.Contains(p))).ToList();
        Initialize(product, brands, pageLayouts);
    }

    public int Id { get; set; }

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

    public IHead? Head { get; set; }

    public IBody? Body { get; set; }

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

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

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

    public IList<FeatureViewModel> Features { get; set; } = new List<FeatureViewModel>();

    public FeatureViewModel Feature { get; set; } = new FeatureViewModel();

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

    public IList<ProductViewModel> RelatedProducts { get; set; } = new List<ProductViewModel>();

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

        return this;
    }

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

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

        // Remove any existing features.
        product.Features.Clear();

        // Add the features.
        for (var i = 0; i < Features.Count; i++) {
            product.Features.Add(Features[i].ToModel(product, i + 1));
        }

        // Remove any existing related products.
        product.RelatedProducts.Clear();

        // Add the related products.
        foreach (var relatedProduct in RelatedProducts.Where(p => p.IsSelected)) {
            product.RelatedProducts.Add(relatedProduct.ToModel());
        }

        return product;
    }

    [ValidateNever]
    public class FeatureViewModel {
        public FeatureViewModel() { }

        public FeatureViewModel(ProductFeature feature) : this() {
            Name = feature.Name;
        }

        [Required]
        public string Name { get; set; } = default!;

        public ProductFeature ToModel(Product product, int order) {
            return new ProductFeature(product) {
                Name = Name,
                Order = order
            };
        }
    }

    public class ProductViewModel {
        public ProductViewModel() { }

        public ProductViewModel(Product product, bool isSelected) : this() {
            Id = product.Id;
            Name = product.Name;
            IsSelected = isSelected;
        }

        public int Id { get; set; }

        public string Name { get; set; } = default!;

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

        public Product ToModel() {
            return new Product() { Id = Id };
        }
    }
}

Let's break down what we've added. First we added properties for our Head/Body components. Next we added a property “PageLayouts” to represent our list of page layouts. Finally we set each property accordingly, making sure to do the reverse in the “ToModel” method to make sure our changes are persisted to the database.

Now we'll update the controller to feed in the page layouts, but first we must add a package reference to the theming abstractions by adding the following to our project file:

<PackageReference Include="Kit.Themes.Abstractions" Version="1.*" />

Next open the "AdminController.cs" file and make the following modifications:

First we need to inject the “IThemeManager” into the constructor to retrieve the page layouts. Update the constructor with the following (adding any missing namespaces):

public AdminController(IAuthorizationService authorizationService, IDataContext dataContext, IStringLocalizer localizer, IThemeManager themeManager) {
    _authorizationService = authorizationService;
    _dataContext = dataContext;
    _localizer = localizer;
    _themeManager = themeManager;
}

Now add the following field to the controller to store the theme service:

private readonly IThemeManager _themeManager;

Now you need to pass the following when instantiating the ProductFormViewModel and calling the “Initialize” method:

_themeManager.GetLayoutTheme("/").PageLayouts

Note: There should be 2 places each.

Site Map Resolve

Now we just need to display the head and body information against our product when viewing it on the front end of the site. However the page title and metadata is outside of the view. To allow us to set this we must make sure the view model implements ISiteMapResolve. From there we can override the page title and metadata.

For our “Details” view, the model is the "Home/ProductViewModel", therefore we'll make the following changes to this file:

First make sure we specify against the class that it should implement "ISiteMapResolve" (adding any missing namespaces).

Next we need to implement this method by adding the following method which overrides the site map node's head and body with the head and body against the product:

#region ISiteMapResolve Members

public ISiteMapNode GetNode(ISiteMapNode siteMapNode) {
    // Set the name, head and body.
    siteMapNode.Name = Name;
    siteMapNode.SetHead(_product.Head);
    siteMapNode.SetBody(_product.Body);

    return siteMapNode;
}

#endregion

Note: We have also set the site map node's name which will change the breadcrumb and h1 to say the product's name instead of the name of the site map node.

Finally we'll need to add the following private field to store the “Product” (which you should set in the constructor):

private readonly Product _product;

Note: This allows us to access any properties within our view model without exposing them to the view.

Now refresh the application to verified everything is working accordingly.

Static Files »