Collections

In the previous tutorial we saw an example of using many-to-one mapping. In this tutorial we will introduce you to two other types, many-to-many and one-to-many. There are plenty of tutorials explaining the difference between the two online so we'll dive straight in and show you how to use them.

Many-to-Many

This is perhaps the easiest to explain. For this example we are going to show how you can relate multiple products together, this can then be used to display similar products together.

First we'll start with the migration. Let's create the following "Migration8.cs" file:

using FluentMigrator;

namespace DemoShop.Shop.Migrations;

[Migration(201908141838)]
public class Migration8 : AutoReversingMigration {
    public override void Up() {
        Create.Table("ProductsRelated")
            .WithColumn("ProductId").AsInt32().NotNullable().PrimaryKey("PK_ProductsRelated")
            .WithColumn("RelatedProductId").AsInt32().NotNullable().PrimaryKey("PK_ProductsRelated");

        Create.ForeignKey("FK_ProductsRelated_Products").FromTable("ProductsRelated").ForeignColumn("ProductId").ToTable("Products").PrimaryColumn("Id");
        Create.ForeignKey("FK_ProductsRelated_Products2").FromTable("ProductsRelated").ForeignColumn("RelatedProductId").ToTable("Products").PrimaryColumn("Id");
    }
}

Now we'll add the collection against the Product model. Open the "Product.cs" file in the "Models" folder and add the following property:

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

Next add the following in the constructor of the "ProductMapping.cs" file:

Bag(x => x.RelatedProducts, m => {
    m.Table("ProductsRelated");
    m.Key(k => k.Column("ProductId"));
    m.Cascade(Cascade.None);
}, m => m.ManyToMany(m => m.Column("RelatedProductId")));

Note: The call to Cascade.None makes sure that we don't delete products when we remove them from the collection.

Now 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, IList<Product> relatedProducts) : this() {
        Name = product.Name;
        BrandId = product.Brand?.Id;
        Price = product.Price;
        RelatedProducts = relatedProducts.Select(p => new ProductViewModel(p, product.RelatedProducts.Contains(p))).ToList();
        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 IList<ProductViewModel> RelatedProducts { get; set; } = new List<ProductViewModel>();

    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;

        // 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;
    }

    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 };
        }
    }
}

We have add an inner class “ProductViewModel” to represent our selected related products. The constructor now takes a list of all the products which we will use to convert to this inner class, marking the “IsSelected” property to true for any which are contained in the “RelatedProducts” collection against the product.

Now we'll go ahead and replace the “NewProduct” view with the following:

@model DemoShop.Shop.ViewModels.Admin.ProductFormViewModel
<form method="post" asp-action="NewProduct">
    <nav id="icons">
        <ul class="nav-responsive">
            <li class="save"><button name="Command" value="Save">@Text["Save"]</button></li>
            <li class="cancel"><a asp-action="Products">@Text["Cancel"]</a></li>
        </ul>
    </nav>
    <div asp-validation-summary="All"></div>
    <nav>
        <ul class="nav nav-tabs nav-responsive">
            <li class="nav-item"><a href="#details" class="nav-link active" data-bs-toggle="tab">@Text["Details"]</a></li>
            <li class="nav-item"><a href="#related-products" class="nav-link" data-bs-toggle="tab">@Text["Related Products"]</a></li>
        </ul>
    </nav>
    <section class="tab-content">
        <section id="details" class="tab-pane active">
            <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="BrandId" class="form-label"></label>
                        <editor for="BrandId" template-name="SelectList" view-data-items="Model.Brands" />
                    </div>
                    <div class="form-group">
                        <label asp-for="Price" class="form-label"></label>
                        <editor for="Price" />
                    </div>
                </div>
            </div>
        </section>
        <section id="related-products" class="tab-pane">
            <div class="card shadow">
                <div class="card-header"><h2>@Text["Related Products"]</h2></div>
                <div class="card-body">
                    @if (Model.RelatedProducts.Any()) {
                        <ul class="list-group">
                            @for (var i = 0; i < Model.RelatedProducts.Count; i++) {
                                <li class="list-group-item">
                                    <div class="form-check">
                                        <input asp-for="RelatedProducts[i].IsSelected" class="form-check-input" />
                                        <label asp-for="RelatedProducts[i].IsSelected" class="form-check-label d-block cursor-pointer">@Format(Model.RelatedProducts[i].Name)</label>
                                        <input type="hidden" asp-for="RelatedProducts[i].Id" />
                                        <input type="hidden" asp-for="RelatedProducts[i].Name" />
                                    </div>
                                </li>
                            }
                        </ul>
                    } else {
                        @Text["No {0} exist.", Text["products"]]
                    }
                </div>
            </div>
        </section>
    </section>
</form>

Note: We have separated the related products from the product details by putting them in separate tabs.

Similarly replace the “EditProduct” view with the following:

@model DemoShop.Shop.ViewModels.Admin.ProductFormViewModel
<form method="post" asp-action="EditProduct" asp-route-id="@Model.Id" asp-route-returnurl="@Context.Request.Query.Get("ReturnUrl")">
    <nav id="icons">
        <ul class="nav-responsive">
            <li class="save"><button name="Command" value="Save">@Text["Save"]</button></li>
            <li class="cancel"><a href="@Format(Context.Request.Query.Get("ReturnUrl") ?? Url.Action("Products"))">@Text["Cancel"]</a></li>
        </ul>
    </nav>
    <div asp-validation-summary="All"></div>
    <nav>
        <ul class="nav nav-tabs nav-responsive">
            <li class="nav-item"><a href="#details" class="nav-link active" data-bs-toggle="tab">@Text["Details"]</a></li>
            <li class="nav-item"><a href="#related-products" class="nav-link" data-bs-toggle="tab">@Text["Related Products"]</a></li>
        </ul>
    </nav>
    <section class="tab-content">
        <section id="details" class="tab-pane active">
            <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="BrandId" class="form-label"></label>
                        <editor for="BrandId" template-name="SelectList" view-data-items="Model.Brands" />
                    </div>
                    <div class="form-group">
                        <label asp-for="Price" class="form-label"></label>
                        <editor for="Price" />
                    </div>
                </div>
            </div>
        </section>
        <section id="related-products" class="tab-pane">
            <div class="card shadow">
                <div class="card-header"><h2>@Text["Related Products"]</h2></div>
                <div class="card-body">
                    @if (Model.RelatedProducts.Any()) {
                        <ul class="list-group">
                            @for (var i = 0; i < Model.RelatedProducts.Count; i++) {
                                <li class="list-group-item">
                                    <div class="form-check">
                                        <input asp-for="RelatedProducts[i].IsSelected" class="form-check-input" />
                                        <label asp-for="RelatedProducts[i].IsSelected" class="form-check-label d-block cursor-pointer">@Format(Model.RelatedProducts[i].Name)</label>
                                        <input type="hidden" asp-for="RelatedProducts[i].Id" />
                                        <input type="hidden" asp-for="RelatedProducts[i].Name" />
                                    </div>
                                </li>
                            }
                        </ul>
                    } else {
                        @Text["No {0} exist.", Text["products"]]
                    }
                </div>
            </div>
        </section>
    </section>
</form>

Finally let's go ahead and update the controller. Open the "AdminController.cs" file and feed in the following collection of all of our products into the "NewProduct" method by replacing it with 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), await _dataContext.Repository<Product>().All().ToSortedListAsync("Name", ListSortDirection.Ascending)));
}

Repeat the above by replacing our "EditProduct" method with 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), await _dataContext.Repository<Product>().All().ToSortedListAsync("Name", ListSortDirection.Ascending)));
}

Now we can rebuild and verify everything is working accordingly.

So far so good, however there's no point in adding the collection if it's not displayed anywhere. So we'll go ahead and take this opportunity to add a product details page to the front end of our website, which will display the related/similar products.

To begin we'll add a site map node via a migration. Create the following file called "Migration9.cs" in the "Migrations" folder:

using FluentMigrator;

namespace DemoShop.Shop.Migrations;

[Migration(201908141842)]
public class Migration9 : AutoReversingMigration {
    public override void Up() {
        Insert.IntoTable("SiteMapNodes").Row(new { ParentId = RawSql.Insert("(SELECT [Id] FROM [dbo].[SiteMapNodes] WHERE [Action] = 'Index' AND [Controller] = 'Home' AND [Area] = 'DemoShop.Shop')"), Name = "View Product", Action = "Details", Controller = "Home", Area = "DemoShop.Shop", Order = 1, ShowInMenu = false, IsLocked = false });
    }
}

Now we'll add the details action method to our home controller. Open the "HomeController.cs" file and add the following:

[HttpGet("{id:int}")]
public async Task<IActionResult> Details(int id) {
    var product = await _dataContext.Repository<Product>().GetAsync(id);

    // Make sure the product exists.
    if (product == null)
        return NotFound();

    return View(new ProductViewModel(product, true));
}

Note: When calling the “ProductViewModel” constructor we pass “true” as the second argument, this specifies that we wish to include the related products (as you'll see below).

Next we'll add the related products to the product view model. Open the “ViewModels/Home/ProductViewModel.cs” file and replace it with the following:

using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using DemoShop.Shop.Models;

namespace DemoShop.Shop.ViewModels.Home;

public class ProductViewModel {
    public ProductViewModel(Product product, bool includeRelatedProducts = false) {
        Id = product.Id;
        Name = product.Name;
        BrandName = product.Brand?.Name;
        Price = product.Price;

        if (includeRelatedProducts)
            RelatedProducts = product.RelatedProducts.Select(p => new ProductViewModel(p)).ToSortedList(p => p.Name, ListSortDirection.Ascending);
    }

    public int Id { get; }
    public string Name { get; }
    public string? BrandName { get; }
    public decimal Price { get; }
    public IList<ProductViewModel>? RelatedProducts { get; }
}

Note: We only wish to include the related products on the details page. If we always included them then it would lead us to fetching the related products for every row on our list page. We have also add the “Id” property as we'll need this to link to the details page.

Now let's add the view. Create the following file called "Details.cshtml" in the "Home" folder of the views:

@model DemoShop.Shop.ViewModels.Home.ProductViewModel
<article>
    @if (!string.IsNullOrEmpty(Model.BrandName)) {
        <p>@Text["Brand"]: @Format(Model.BrandName)</p>
    }
    <p>@Format(Model.Price.ToString("c"))</p>
    @if (Model.RelatedProducts!.Any()) {
        <h2>@Text["Related Products"]</h2>
        <div class="row">
            @foreach (var product in Model.RelatedProducts!) {
                <display for="@product" template-name="Product" />
            }
        </div>
    }
</article>

Note: We have used the same display template to display the related products that used for the products listing page.

Finally we'll update the product display template to link to the details page. 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><a asp-action="Details" asp-route-id="@Model.Id">@Format(Model.Name)</a></h2>
    @if (!string.IsNullOrEmpty(Model.BrandName)) {
        <p>@Text["Brand"]: @Format(Model.BrandName)</p>
    }
    <p>@Format(Model.Price.ToString("c"))</p>
</article>

Now we can navigate back to our shop where we should see the related products on the details page.

One-to-Many

To demonstrate one-to-many relationships we will add a list of features against the product. We will then update the front-end to display a comma separated list of all the features for each product.

First we'll start with the migration. Let's create the following "Migration10.cs" file:

using FluentMigrator;

namespace DemoShop.Shop.Migrations;

[Migration(201908141846)]
public class Migration10 : AutoReversingMigration {
    public override void Up() {
        Create.Table("ProductFeatures")
            .WithColumn("Id").AsInt32().NotNullable().PrimaryKey("PK_ProductFeatures").Identity()
            .WithColumn("ProductId").AsInt32().NotNullable()
            .WithColumn("Name").AsString(100).NotNullable()
            .WithColumn("Order").AsInt32().NotNullable();

        Create.ForeignKey("FK_ProductFeatures_Products").FromTable("ProductFeatures").ForeignColumn("ProductId").ToTable("Products").PrimaryColumn("Id");
    }
}

Now we'll add a model for the product features. This is the main difference from many-to-many relationships, which does not need this additional model. Create the following file called "ProductFeature.cs" in the "Models" folder:

using Kit.Entities;

namespace DemoShop.Shop.Models;

public class ProductFeature : Entity<int> {
    public ProductFeature() { }

    public ProductFeature(Product product) : this() {
        Product = product;
    }

    public virtual Product Product { get; set; } = default!;
    public virtual string Name { get; set; } = default!;
    public virtual int Order { get; set; }
}

Next let's map it to the database with the following "ProductFeatureMapping.cs" file:

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

namespace DemoShop.Shop.Models;

public class ProductFeatureMapping : ClassMapping<ProductFeature> {
    public ProductFeatureMapping() {
        Table("ProductFeatures");
        Id(x => x.Id, m => m.Generator(Generators.Identity));
        ManyToOne(x => x.Product);
        Property(x => x.Name);
        Property(x => x.Order, m => m.Column("[Order]"));
        Cache(x => x.Usage(CacheUsage.ReadWrite));
    }
}

Now (as we did with the many-to-many scenario) we'll add the collection against the Product model. Open the "Product.cs" file in the "Models" folder and add the following property:

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

Similarly we'll add the following to the constructor of the "ProductMapping.cs" file to map the collection:

Bag(x => x.Features, m => {
    m.Key(k => k.Column("ProductId"));
    m.Inverse(true);
    m.Cascade(Cascade.All | Cascade.DeleteOrphans);
}, m => m.OneToMany());

Note: Cascade(Cascade.All | Cascade.DeleteOrphans) will make sure any changes (including deletes) made to the features collection will be persisted in the database.

Now we'll add the collection to the view model. 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.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) : this() {
        Name = product.Name;
        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);
    }

    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 IList<FeatureViewModel> Features { get; set; } = new List<FeatureViewModel>();

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

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

    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;

        // 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 };
        }
    }
}

Once again there's a few things going on here so we'll try and break it down. First you'll notice that we have added an additional inner class called "FeatureViewModel". This class will store the features for each product.

We have also added two properties to the view model, one to store a collection of the product's features and another for our form so we can add additional features.

Also in the constructor we set the features, making sure to order the features and convert them to our "FeatureViewModel".

Finally in the “ToModel” method we update the “Features” against the product with the one's posted back from our form.

Now let's add a new tab for our features collection to the "NewProduct.cshtml" and "EditProduct.cshtml" views. Repeat the following steps for both files:

First add the following above the form:

<script type="module" import="list.reorder"></script>

Note: This is only needed if the collection has an “Order” field.

Next add the following inside the tabs unordered list:

<li class="nav-item"><a href="#features" class="nav-link" data-bs-toggle="tab">@Text["Features"]</a></li>

Next add the following section for our tab within the form:

<section id="features" class="tab-pane">
    <div class="card shadow">
        <div class="card-header"><h2>@Text["Features"]</h2></div>
        <div class="card-body">
            <list id="features-list" class="list reorder" :data="@Format(JsonSerializer.Serialize(new { Model.Features, NewFeature = new { Name = "" } }))">
                <div>
                    <table class="table table-striped" v-if="features.length">
                        <colgroup>
                            <col style="width: 220px" />
                        </colgroup>
                        <thead>
                            <tr>
                                <th></th>
                                <th>@Text["Name"]</th>
                            </tr>
                        </thead>
                        <tbody>
                            <tr v-for="feature in features" :class="editItem != feature ? 'view' : 'edit'" :data-val-group="editItem == feature ? 'EditFeature' : null">
                                <template v-if="editItem != feature">
                                    <td class="text-center">
                                        <i class="fa-solid fa-up-down-left-right cursor-pointer handle"></i> &nbsp;
                                        <button type="button" @@click="edit(feature)" class="btn btn-primary">@Text["Edit"]</button>
                                        <button type="button" @@click="remove(feature)" class="btn btn-primary">@Text["Remove"]</button>
                                    </td>
                                    <td><span v-text="feature.name"></span></td>
                                </template>
                                <template v-else>
                                    <td class="text-center">
                                        <button type="button" @@click="update(feature)" class="btn btn-primary">@Text["Update"]</button>
                                        <button type="button" @@click="cancel(feature)" class="btn btn-primary">@Text["Cancel"]</button>
                                    </td>
                                    <td><editor name="EditFeature.Name" v-model="feature.name" data-val="true" data-val-required="@Text["The {0} field is required.", Text["Name"]]" /></td>
                                </template>
                            </tr>
                        </tbody>
                    </table>
                    <p v-if="!features.length">@Text["No {0} exist.", Text["features"]]</p>
                    <div data-val-group="NewFeature">
                        <h2>@Text["Add Feature"]</h2>
                        <div asp-validation-summary="All" prefix="Feature"></div>
                        <div class="form-group">
                            <label asp-for="Feature.Name" class="form-label"></label>
                            <editor for="Feature.Name" v-model="newFeature.name" />
                        </div>
                        <p><button type="button" @@click="add(newFeature)" class="btn btn-primary">@Text["Add"]</button></p>
                    </div>
                </div>
            </list>
        </div>
    </div>
</section>

Note: This uses the JavaScript list component to store a collection of the features in memory. The features will then be posted back once the form is submitted.

Notice we haven't had to update the controller, this is because most of the heavy lifting was done inside the view model. This allows us to keep our controller very light.

Finally we'll update your “Home” controller's “Details” view to display the list of features. First open the “ProductViewModel.cs” in the “ViewModels/Home” directory and replace it with the following:

using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using DemoShop.Shop.Models;

namespace DemoShop.Shop.ViewModels.Home;

public class ProductViewModel {
    public ProductViewModel(Product product, bool includeFeaturesAndRelatedProducts = false) {
        Id = product.Id;
        Name = product.Name;
        BrandName = product.Brand?.Name;
        Price = product.Price;

        if (includeFeaturesAndRelatedProducts) {
            Features = product.Features.OrderBy(f => f.Order).Select(f => f.Name).ToList();
            RelatedProducts = product.RelatedProducts.Select(p => new ProductViewModel(p)).ToSortedList(p => p.Name, ListSortDirection.Ascending);
        }
    }

    public int Id { get; }
    public string Name { get; }
    public string? BrandName { get; }
    public decimal Price { get; }
    public IList<string>? Features { get; }
    public IList<ProductViewModel>? RelatedProducts { get; }
}

Next open the “Details.cshtml” file in the “Views/Home” folder add add the following:

@if (Model.Features!.Any()) {
    <p>@Text["Features"]: @Format(string.Join(", ", Model.Features!))</p>
}

Note: The ! (null-forgiving) operator is used to remove the compiler warning as we know it won't be null here.

Now we should verify everything is in working order.

Components »