Create versatile page templates, part 1
In this guide, we’ll walk through the process of creating a page template to match this product detail mockup:
See the first guide in this series for an overview of the mockup, and a breakdown of how to meet business requirements with Page Builder.
Prerequisites
This guide uses the main branch of the training guides repository as a starting point.
It relies on the dropdown provider from the previous guide.
If you’re following along while working in a different environment, make sure:
- Your site has Product, ProductPage, ProductFeature, and Benefit content types that match the structure of those in the repository.
- Page Builder is enabled for the Product page content type during the application’s startup sequence.
This guide will take place in the TrainingGuides.Web project of the repository, so assume any folder paths start from the root of this project.
Show structured data
The data in the mockup comes from the Product content type, which is served in the web channel through the Product page content type.
In order to implement a page that matches the mockup without any extra steps for editors, we need to access that data and display it directly in the template.
Let’s start by setting up view models to work with the data.
Set up the view models
Create supporting view models
First, let’s set up the view model for the product page.
If you look at the Product content type in Xperience, you’ll notice that products have collections of Benefit and Product feature content items. Start by making view models for these content types, so we can use them in the product.
- In the ~/Features/Products/Models folder, add a file called ProductFeatureViewModel.cs.
- Create properties corresponding to the content type’s fields.C#ProductFeatureViewModel.cs
... namespace TrainingGuides.Web.Features.Products.Models; public class ProductFeatureViewModel { public string Key { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public HtmlString Label { get; set; } = HtmlString.Empty; public decimal Price { get; set; } public HtmlString Value { get; set; } = HtmlString.Empty; public bool FeatureIncluded { get; set; } public ProductFeatureValueType ValueType { get; set; } public bool ShowInComparator { get; set; } ...
- Include a
ProductFeatureValueType
enumeration to represent the value of theValueType
field at the bottom of the file.C#ProductFeatureViewModel.cs... //(After the end of the ProductFeatureViewModel class's scope) public enum ProductFeatureValueType { //These integers correspond to the data source in the Product feature content type, defined in the Xperience admin interface. Text = 0, Number = 1, Boolean = 2 }
- Add a method to the
ProductFeatureViewModel
class to get the corresponding enum value from an integer.C#ProductFeatureViewModel.cs... private static ProductFeatureValueType GetValueType(string value) { if (string.IsNullOrEmpty(value)) return ProductFeatureValueType.Text; if (int.TryParse(value, out int id)) return (ProductFeatureValueType)id; return ProductFeatureValueType.Text; } ...
- Add a static
GetViewModel
method to create and populate aProductFeatureViewModel
based on aProductFeature
object.ProductFeature
is a generated class, created by the Xperience code generation tool.C#ProductFeatureViewModel.cs... public static ProductFeatureViewModel GetViewModel(ProductFeature feature) => new() { Key = feature.ProductFeatureKey, Name = feature.SystemFields.ContentItemName, Label = new(feature.ProductFeatureLabel), Price = feature.ProductFeaturePrice, Value = new(feature.ProductFeatureValue), FeatureIncluded = feature.ProductFeatureIncluded, ValueType = GetValueType(feature.ProductFeatureValueType), ShowInComparator = feature.ProductFeatureShowInComparator == "1" }; ...
That takes care of Product feature, so let’s move on to the Benefit content type.
- Create a new class called
BenefitViewModel
in the ~/Features/Shared/Models folder. - Add properties that correspond to the Benefit content type’s fields, utilizing the existing
AssetViewModel
class for the asset.C#BenefitViewModel.csusing Microsoft.AspNetCore.Html; namespace TrainingGuides.Web.Features.Shared.Models; public class BenefitViewModel { public HtmlString Description { get; set; } = HtmlString.Empty; public AssetViewModel Icon { get; set; } = new(); }
- Add a static
GetViewModel
method to retrieve aBenefitViewModel
from an object of the generatedBenefit
class.C#BenefitViewModel.cs... public static BenefitViewModel GetViewModel(Benefit benefit) => new() { Description = new(benefit.BenefitDescription), Icon = benefit.BenefitIcon?.FirstOrDefault() != null ? AssetViewModel.GetViewModel(benefit.BenefitIcon.FirstOrDefault()!) : new(), };
using Microsoft.AspNetCore.Html;
namespace TrainingGuides.Web.Features.Products.Models;
public class ProductFeatureViewModel
{
public string Key { get; set; } = string.Empty;
public string Name { get; set; } = string.Empty;
public HtmlString Label { get; set; } = HtmlString.Empty;
public decimal Price { get; set; }
public HtmlString Value { get; set; } = HtmlString.Empty;
public bool FeatureIncluded { get; set; }
public ProductFeatureValueType ValueType { get; set; }
public bool ShowInComparator { get; set; }
public static ProductFeatureViewModel GetViewModel(ProductFeature feature) => new()
{
Key = feature.ProductFeatureKey,
Name = feature.SystemFields.ContentItemName,
Label = new(feature.ProductFeatureLabel),
Price = feature.ProductFeaturePrice,
Value = new(feature.ProductFeatureValue),
FeatureIncluded = feature.ProductFeatureIncluded,
ValueType = GetValueType(feature.ProductFeatureValueType),
ShowInComparator = feature.ProductFeatureShowInComparator == "1"
};
private static ProductFeatureValueType GetValueType(string value)
{
if (string.IsNullOrEmpty(value))
return ProductFeatureValueType.Text;
if (int.TryParse(value, out int id))
return (ProductFeatureValueType)id;
return ProductFeatureValueType.Text;
}
}
public enum ProductFeatureValueType
{
Text = 0,
Number = 1,
Boolean = 2
}
using Microsoft.AspNetCore.Html;
namespace TrainingGuides.Web.Features.Shared.Models;
public class BenefitViewModel
{
public HtmlString Description { get; set; } = HtmlString.Empty;
public AssetViewModel Icon { get; set; } = new();
public static BenefitViewModel GetViewModel(Benefit benefit) => new()
{
Description = new(benefit.BenefitDescription),
Icon = benefit.BenefitIcon?.FirstOrDefault() != null
? AssetViewModel.GetViewModel(benefit.BenefitIcon.FirstOrDefault()!)
: new(),
};
}
Implement the Product page view model
With these in place, let’s move on to the ProductPageViewModel
.
- Create a
ProductPageViewModel
class in the ~/Features/Products/Models folder. - Inherit from the
PageViewModel
class.C#ProductPageViewModel.csnamespace TrainingGuides.Web.Features.Products.Models; public class ProductPageViewModel : PageViewModel { }
Notice theLink
property of thePageViewModel
class, which can be re-used by other page types in the future. - Add properties that mirror the fields of the Product content type, with collections of
ProductFeatureViewModel
andBenefitViewModel
objects where appropriate.C#ProductPageViewModel.cs... public class ProductPageViewModel : PageViewModel { public HtmlString Name { get; set; } = HtmlString.Empty; public HtmlString ShortDescription { get; set; } = HtmlString.Empty; public string Description { get; set; } = string.Empty; public List<AssetViewModel> Media { get; set; } = []; public decimal Price { get; set; } public List<ProductFeatureViewModel> Features { get; set; } = []; public List<BenefitViewModel> Benefits { get; set; } = []; ...
Add a supporting service
You might notice thatProductViewModel
doesn’t have a GetViewModel
method like ProductFeatureViewModel
and BenefitViewModel1
. This is because it needs Dependency injection to get the page’s URL, so we’re going to move it to a service instead.Make sure that any methods you include in your view model classes are simple, with only basic data transformation at most.
- Add an interface called
IProductPageService
to ~/Features/Products/Services. - Include a method signature
GetProductPageViewModel
with the following parameters:- The
ProductPage
object to base the view model on. - Optional boolean values to indicate which values to include.
- Optional settings for the call to action linkC#IProductPageService.cs
using TrainingGuides.Web.Features.Products.Models; namespace TrainingGuides.Web.Features.Products.Services; public interface IProductPageService { public Task<ProductPageViewModel> GetProductPageViewModel( ProductPage? productPage, bool getMedia = true, bool getFeatures = true, bool getBenefits = true, string callToAction = "", string callToActionLink = "", bool openInNewTab = true, bool getPrice = true); }
- The
- Add a
ProductPageService
class that implements this interface.C#ProductPageService.cs... public class ProductPageService : IProductPageService ...
- Use dependency injection to acquire an
IWebPageUrlRetriever
object.C#ProductPageService.cs... private readonly IWebPageUrlRetriever webPageUrlRetriever; public ProductPageService(IWebPageUrlRetriever webPageUrlRetriever) { this.webPageUrlRetriever = webPageUrlRetriever; } ...
- Implement the
GetProductPageViewModel
method.C#ProductPageService.csusing Microsoft.IdentityModel.Tokens; using TrainingGuides.Web.Features.Products.Models; using TrainingGuides.Web.Features.Shared.Models; namespace TrainingGuides.Web.Features.Products.Services; public class ProductPageService : IProductPageService { private readonly IWebPageUrlRetriever webPageUrlRetriever; public ProductPageService(IWebPageUrlRetriever webPageUrlRetriever) { this.webPageUrlRetriever = webPageUrlRetriever; } /// <summary> /// Creates a new instance of <see cref="ProductPageViewModel"/>, setting the properties using ProductPage given as a parameter. /// </summary> /// <param name="productPage">Corresponding Product page object.</param> /// <returns>New instance of ProductPageViewModel.</returns> public async Task<ProductPageViewModel> GetProductPageViewModel( ProductPage? productPage, bool getMedia = true, bool getFeatures = true, bool getBenefits = true, string callToActionText = "", string callToActionLink = "", bool openInNewTab = true, bool getPrice = true) { //Return an empty view model if the provided ProductPage is null. if (productPage == null) { return new ProductPageViewModel(); } //Use the IWebPageUrlRetriever to get the URL of the product page. string url = callToActionLink.IsNullOrEmpty() ? (await webPageUrlRetriever.Retrieve(productPage)).RelativePath : callToActionLink; //Make sure to account for the boolean parameters as you construct the view model. return new ProductPageViewModel { Name = new(productPage.ProductPageProduct.FirstOrDefault()?.ProductName), ShortDescription = new(productPage.ProductPageProduct.FirstOrDefault()?.ProductShortDescription), Description = productPage.ProductPageProduct.FirstOrDefault()?.ProductDescription ?? string.Empty, Media = getMedia ? productPage.ProductPageProduct.FirstOrDefault()? .ProductMedia.Select(AssetViewModel.GetViewModel)? .ToList() ?? [] : [], Link = new LinkViewModel() { Name = productPage.ProductPageProduct.FirstOrDefault()?.ProductName ?? string.Empty, LinkUrl = url, CallToAction = callToActionText.IsNullOrEmpty() ? string.Empty : callToActionText, OpenInNewTab = openInNewTab }, Features = getFeatures ? productPage.ProductPageProduct.FirstOrDefault()? .ProductFeatures .Select(ProductFeatureViewModel.GetViewModel) .ToList() ?? [] : [], Benefits = getBenefits ? productPage.ProductPageProduct.FirstOrDefault()? .ProductBenefits .Select(BenefitViewModel.GetViewModel) .ToList() ?? [] : [], Price = getPrice ? productPage.ProductPageProduct .FirstOrDefault()?.ProductPrice ?? 0 : 0, }; } }
Define page template properties
Now that we have view models to determine the product template’s data, let’s add properties, so that editors can configure its appearance.
The properties will utilize dropdowns fed by the dropdown provider created in the previous guide, so start by defining enumerations for different color scheme and corner style options.
Decorate options with the Description
attribute to provide user-friendly names.
In the ~/Features/Shared/OptionProviders/ColorScheme directory:
C#ColorSchemeOption.csusing System.ComponentModel; namespace TrainingGuides.Web.Features.Shared.OptionProviders.ColorScheme; public enum ColorSchemeOption { [Description("Light background, dark text")] Light1 = 1, [Description("Light background, dark text 2")] Light2 = 2, [Description("Light background, dark text 3")] Light3 = 3, [Description("Transparent background, dark text")] TransparentDark = 4, [Description("Transparent background, medium text")] TransparentMedium = 5, [Description("Transparent background, light text")] TransparentLight = 6, [Description("Dark background, light text")] Dark1 = 7, [Description("Dark background, light text 2")] Dark2 = 8 }
In the ~/Features/Shared/OptionProviders/CornerStyle directory:
C#CornerStyleOption.csusing System.ComponentModel; namespace TrainingGuides.Web.Features.Shared.OptionProviders.CornerStyle; public enum CornerStyleOption { [Description("Sharp corners")] Sharp = 0, [Description("Round corners")] Round = 1, [Description("Very round corners")] VeryRound = 2, }
Next, create a properties class for the ProductPage content type that utilizes these dropdowns in the ~/Features/Products folder.
using Kentico.PageBuilder.Web.Mvc.PageTemplates;
using Kentico.Xperience.Admin.Base.FormAnnotations;
using TrainingGuides.Web.Features.Shared.OptionProviders;
using TrainingGuides.Web.Features.Shared.OptionProviders.CornerStyle;
using TrainingGuides.Web.Features.Shared.OptionProviders.ColorScheme;
namespace TrainingGuides.Web.Features.Products;
public class ProductPagePageTemplateProperties : IPageTemplateProperties
{
[DropDownComponent(
Label = "Color scheme",
ExplanationText = "Select the color scheme of the template.",
DataProviderType = typeof(DropdownEnumOptionProvider<ColorSchemeOption>),
Order = 20)]
public string ColorScheme { get; set; } = nameof(ColorSchemeOption.TransparentDark);
[DropDownComponent(
Label = "Corner style",
ExplanationText = "Select the corner type of the template.",
DataProviderType = typeof(DropdownEnumOptionProvider<CornerStyleOption>),
Order = 30)]
public string CornerStyle { get; set; } = nameof(CornerStyleOption.Round);
}
Set up the template
With the view models and service in place, we can create the page template, register it, and serve it from a controller.
Find more details here
This section briefly goes over processes covered in Build a page template step of our Kickstart series. Please refer to it if you’d like more detailed steps and information in the context of a different example.
Note these sections in particular:
Define the view
With the view model and properties in place, you can create a view under ~/Features/Products.
Use the GetTemplateModel
method to retrieve structured data and display it in a view. We’ll expand on this in the future. For now, let’s keep it simple.
@using TrainingGuides.Web.Features.Products.Models
@using TrainingGuides.Web.Features.Products
@model TemplateViewModel<ProductPagePageTemplateProperties>
@{
var templateModel = Model.GetTemplateModel<ProductPageViewModel>();
}
<div>
<div>
<h3>@templateModel.Name</h3>
<p>@templateModel.ShortDescription</p>
</div>
<div>
<img src="@templateModel.Media.FirstOrDefault()?.FilePath" alt="@templateModel.Media.FirstOrDefault()?.Description"/>
</div>
</div>
Register the template
Create a new file called ProductPagePageTemplate.cs in the ~/Features/Products folder, and use it to house the registration attribute for the page template.
using Kentico.PageBuilder.Web.Mvc.PageTemplates;
using TrainingGuides;
using TrainingGuides.Web.Features.Products;
[assembly: RegisterPageTemplate(
identifier: ProductPagePageTemplate.IDENTIFIER,
name: "Product page template",
propertiesType: typeof(ProductPagePageTemplateProperties),
customViewName: "~/Features/Products/ProductPagePageTemplate.cshtml",
ContentTypeNames = [ProductPage.CONTENT_TYPE_NAME],
IconClass = "xp-box")]
namespace TrainingGuides.Web.Features.Products;
public static class ProductPagePageTemplate
{
public const string IDENTIFIER = "TrainingGuides.ProductPageTemplate";
}
Create the controller
- In the ~/Features/Products folder, add a file called ProductPageController.
- Use an
IContentItemRetriever<ProductPage>
to get the product data from Xperience. - Use an
IProductPageService
from earlier in this guide to convert theProductPage
to aProductPageViewModel
. - Add a new
ProductFeatureViewModel
containing the product’s price, so that it appears in the table.
using Kentico.Content.Web.Mvc;
using Kentico.Content.Web.Mvc.Routing;
using Kentico.PageBuilder.Web.Mvc.PageTemplates;
using Microsoft.AspNetCore.Mvc;
using TrainingGuides;
using TrainingGuides.Web.Features.Products.Models;
using TrainingGuides.Web.Features.Products.Services;
using TrainingGuides.Web.Features.Shared.Services;
[assembly: RegisterWebPageRoute(
contentTypeName: ProductPage.CONTENT_TYPE_NAME,
controllerType: typeof(TrainingGuides.Web.Features.Products.ProductPageController))]
namespace TrainingGuides.Web.Features.Products;
public class ProductPageController : Controller
{
private readonly IWebPageDataContextRetriever webPageDataContextRetriever;
private readonly IContentItemRetrieverService<ProductPage> contentItemRetriever;
private readonly IProductPageService productPageService;
public ProductPageController(
IWebPageDataContextRetriever webPageDataContextRetriever,
IContentItemRetrieverService<ProductPage> contentItemRetriever,
IProductPageService productPageService)
{
this.webPageDataContextRetriever = webPageDataContextRetriever;
this.contentItemRetriever = contentItemRetriever;
this.productPageService = productPageService;
}
public async Task<IActionResult> Index()
{
var context = webPageDataContextRetriever.Retrieve();
var productPage = await contentItemRetriever.RetrieveWebPageById
(context.WebPage.WebPageItemID,
ProductPage.CONTENT_TYPE_NAME,
3);
var model = await productPageService.GetProductPageViewModel(productPage);
model.Features.Add(
new ProductFeatureViewModel
{
Key = "price-from-product-content-item",
Name = "Price",
Label = new("Price"),
Price = model.Price,
Value = new(string.Empty),
FeatureIncluded = false,
ValueType = ProductFeatureValueType.Number,
ShowInComparator = true,
});
return new TemplateResult(model);
}
}
Check your progress
We’ve been focused on code for a while, so let’s double-check that everything is working on the site.
- Log into the Xperience admin and open the Training guides pages channel.
- Choose one of the Product page pages under the Products page in the tree, and edit the page on the Page Builder tab.
- Click the page icon in the bottom left corner of the Page Builder frame to configure the Page Builder properties.
- Choose the Product page template, and see how the data from the product page is now displayed according to your templates.
If you’re not seeing any output, make sure the product page type is included in the UsePageBuilder
call during the application’s startup sequence, try putting breakpoints in the controller and view. If one or both of them is not being hit, it could indicate an issue in one of the registration attributes for the controller or page template.
Style the template based on properties
You probably noticed in the last section that the template is not very pretty yet. You’ll also find that the dropdowns for color scheme and corner style in the template properties don’t change anything.
Let’s create a service that maps the options from the dropdown to CSS classes, and a tag helper that uses it to style a div
element.
Create a service to retrieve styles
- Define an
IComponentStyleEnumService
interface in the ~/Features/Shared/Services folder. - Include method signatures to retrieve CSS classes based on a
ColorSchemeOption
andCornerStyleOption
respectively.C#IComponentStyleEnumService.cs... namespace TrainingGuides.Web.Features.Shared.Services; public interface IComponentStyleEnumService { IEnumerable<string> GetColorSchemeClasses(ColorSchemeOption colorScheme); IEnumerable<string> GetCornerStyleClasses(CornerStyleOption cornerStyle); ...
- Add method signatures to convert the string representation of a selected option to its corresponding enum value.C#IComponentStyleEnumService.cs
... CornerStyleOption GetCornerStyle(string cornerStyleString); ColorSchemeOption GetColorScheme(string colorSchemeString); }
- Implement the interface, mapping the selected options to sets of CSS classes, and using the
Parse
method of the previous guide’sDropdownEnumOptionProvider
for the string conversion methods.C#ComponentStyleEnumService.csusing TrainingGuides.Web.Features.Shared.OptionProviders; using TrainingGuides.Web.Features.Shared.OptionProviders.CornerStyle; using TrainingGuides.Web.Features.Shared.OptionsProviders.ColorScheme; namespace TrainingGuides.Web.Features.Shared.Services; public class ComponentStyleEnumService : IComponentStyleEnumService { public IEnumerable<string> GetColorSchemeClasses(ColorSchemeOption colorScheme) => colorScheme switch { ColorSchemeOption.Light1 => ["tg-bg-light-1", "tg-txt-dark"], ColorSchemeOption.Light2 => ["tg-bg-light-2", "tg-txt-dark"], ColorSchemeOption.Light3 => ["tg-bg-light-3", "tg-txt-dark"], ColorSchemeOption.Dark1 => ["tg-bg-primary", "tg-txt-light"], ColorSchemeOption.Dark2 => ["tg-bg-secondary", "tg-txt-light"], ColorSchemeOption.TransparentLight => ["tg-bg-none", "tg-txt-light"], ColorSchemeOption.TransparentMedium => ["tg-bg-none", "tg-txt-medium"], ColorSchemeOption.TransparentDark => ["tg-bg-none", "tg-txt-dark"], _ => [string.Empty], }; public IEnumerable<string> GetCornerStyleClasses(CornerStyleOption cornerStyle) => cornerStyle switch { CornerStyleOption.Round => ["tg-corner-rnd"], CornerStyleOption.VeryRound => ["tg-corner-v-rnd"], CornerStyleOption.Sharp => ["tg-corner-shrp"], _ => [string.Empty], }; public CornerStyleOption GetCornerStyle(string cornerStyleString) => new DropdownEnumOptionProvider<CornerStyleOption>().Parse(cornerStyleString, CornerStyleOption.Round); public ColorSchemeOption GetColorScheme(string colorSchemeString) => new DropdownEnumOptionProvider<ColorSchemeOption>().Parse(colorSchemeString, ColorSchemeOption.TransparentDark); }
Each of the classes in this example are small, usually only setting one css property to minimize collisions across classes.
Defining the styles themselves is outside the scope of this guide, but you can find their definitions in the ~/scss directory, where .scss styles are defined and later compiled to css.
If you are using the main branch of the training guides repository to follow along, the classes should already exist.
Apply the styles with a TagHelper
- Add a new class called
ComponentStyleTagHelper
to the ~/Features/Shared/Helpers/TagHelpers folder. - Inherit from the .NET
TagHelper
class, and decorate it with theHtmlTargetElement
attribute to set its element name totg-component-style
.C#ComponentStyleTagHelper.cs... [HtmlTargetElement("tg-component-style")] public class ComponentStyleTagHelper : TagHelper ...
- Give the tag helper string properties called
ColorScheme
andCornerStyle
, so that objects that store selections as strings, such as page template properties, can use them directly.C#ComponentStyleTagHelper.cs... public string ColorScheme { get; set; } = string.Empty; public string CornerStyle { get; set; } = string.Empty; ...
- Use a constant to hold the type of tag, in case we need to add more complex logic for determining the tag name in the future.C#ComponentStyleTagHelper.cs
... private const string DIV_TAG = "div"; ...
- Acquire an
IComponentStyleEnumService
object through dependency injection.C#ComponentStyleTagHelper.cs... private readonly IComponentStyleEnumService componentStyleEnumService; public ComponentStyleTagHelper(IComponentStyleEnumService componentStyleEnumService) : base() { this.componentStyleEnumService = componentStyleEnumService; } ...
- In the
Process
method, use the service to retrieve styles for the output tag.C#ComponentStyleTagHelper.cs... public override void Process(TagHelperContext context, TagHelperOutput output) { output.TagName = DIV_TAG; List<string> cssClasses = []; var colorScheme = componentStyleEnumService.GetColorScheme(ColorScheme); cssClasses.AddRange(componentStyleEnumService.GetColorSchemeClasses(colorScheme)); var cornerStyle = componentStyleEnumService.GetCornerStyle(CornerStyle); cssClasses.AddRange(componentStyleEnumService.GetCornerStyleClasses(cornerStyle)); if (cssClasses.Count > 0) { foreach (string cssClass in cssClasses) { output.AddClass(cssClass, HtmlEncoder.Default); } } } ...
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Mvc.TagHelpers;
using Microsoft.AspNetCore.Razor.TagHelpers;
using TrainingGuides.Web.Features.Shared.Services;
namespace TrainingGuides.Web.Features.Shared.Helpers.TagHelpers;
[HtmlTargetElement("tg-component-style")]
public class ComponentStyleTagHelper : TagHelper
{
public string ColorScheme { get; set; } = string.Empty;
public string CornerStyle { get; set; } = string.Empty;
private const string DIV_TAG = "div";
private readonly IComponentStyleEnumService componentStyleEnumService;
public ComponentStyleTagHelper(IComponentStyleEnumService componentStyleEnumService) : base()
{
this.componentStyleEnumService = componentStyleEnumService;
}
public override void Process(TagHelperContext context, TagHelperOutput output)
{
output.TagName = DIV_TAG;
List<string> cssClasses = [];
var colorScheme = componentStyleEnumService.GetColorScheme(ColorScheme);
cssClasses.AddRange(componentStyleEnumService.GetColorSchemeClasses(colorScheme));
var cornerStyle = componentStyleEnumService.GetCornerStyle(CornerStyle);
cssClasses.AddRange(componentStyleEnumService.GetCornerStyleClasses(cornerStyle));
if (cssClasses.Count > 0)
{
foreach (string cssClass in cssClasses)
{
output.AddClass(cssClass, HtmlEncoder.Default);
}
}
}
}
Put the tag helper to use
The tag helper is now ready to be added to the view. If you return to ProductPagePageTemplate.cshtml you can wrap all of the existing markup with the new tag helper tag, and pass along the color scheme and corner style properties from the page template’s configuration.
@using TrainingGuides.Web.Features.Products.Models
@using TrainingGuides.Web.Features.Products
@model TemplateViewModel<ProductPagePageTemplateProperties>
@{
var templateModel = Model.GetTemplateModel<ProductPageViewModel>();
}
<tg-component-style color-scheme="@Model.Properties.ColorScheme" corner-style="@Model.Properties.CornerStyle">
<div>
<div>
<h3>@templateModel.Name</h3>
<p>@templateModel.ShortDescription</p>
</div>
<div>
<img src="@templateModel.Media.FirstOrDefault()?.FilePath" alt="@templateModel.Media.FirstOrDefault()?.Description"/>
</div>
</div>
</tg-component-style>
If you log in to the Xperience administration interface, you’ll notice that you can now adjust the appearance of the box containing the product’s data by clicking the gear icon in the bottom left of the Page Builder pane.
However, if you set the template to use rounded corners and a colored background, you’ll see that the corners of the image do not change to match the style of the parent div. The image may also be an unreasonable size, depending on the file.
Create an image tag helper
- In the ~/Features/Shared/Helpers/TagHelpers folder, add a new self-closing TagHelper called
StyledImageTagHelper
with the tag nametg-styled-image
.C#StyledImageTagHelper.cs... namespace TrainingGuides.Web.Features.Shared.Helpers.TagHelpers; [HtmlTargetElement("tg-styled-image", TagStructure = TagStructure.WithoutEndTag)] public class StyledImageTagHelper : TagHelper ...
- Follow the same procedure as last time to set up properties and an
IComponentStyleEnumService
, leaving out theColorScheme
property.C#StyledImageTagHelper.cs... namespace TrainingGuides.Web.Features.Shared.Helpers.TagHelpers; [HtmlTargetElement("tg-styled-image", TagStructure = TagStructure.WithoutEndTag)] public class StyledImageTagHelper : TagHelper { public string CornerStyle { get; set; } = string.Empty; private const string IMG_TAG = "img"; private readonly IComponentStyleEnumService componentStyleEnumService; public StyledImageTagHelper(IComponentStyleEnumService componentStyleEnumService) : base() { this.componentStyleEnumService = componentStyleEnumService; } ...
- In the
Process
method, set theTagMode
of the rendered HTML tag toTagMode.SelfClosing
and style the image according to the classes returned by the service.C#StyledImageTagHelper.cs... public override void Process(TagHelperContext context, TagHelperOutput output) { output.TagName = IMG_TAG; output.TagMode = TagMode.SelfClosing; List<string> cssClasses = []; var cornerStyle = componentStyleEnumService .GetCornerStyle(CornerStyle ?? string.Empty); cssClasses.AddRange(componentStyleEnumService .GetCornerStyleClasses(cornerStyle)); if (cssClasses.Count > 0) { foreach (string cssClass in cssClasses) { output.AddClass(cssClass, HtmlEncoder.Default); } } } ...
using Microsoft.AspNetCore.Mvc.TagHelpers;
using System.Text.Encodings.Web;
using Microsoft.AspNetCore.Razor.TagHelpers;
using TrainingGuides.Web.Features.Shared.Services;
namespace TrainingGuides.Web.Features.Shared.Helpers.TagHelpers;
[HtmlTargetElement("tg-styled-image", TagStructure = TagStructure.WithoutEndTag)]
public class StyledImageTagHelper : TagHelper
{
public string CornerStyle { get; set; } = string.Empty;
private const string IMG_TAG = "img";
private readonly IComponentStyleEnumService componentStyleEnumService;
public StyledImageTagHelper(IComponentStyleEnumService componentStyleEnumService) : base()
{
this.componentStyleEnumService = componentStyleEnumService;
}
public override void Process(TagHelperContext context, TagHelperOutput output)
{
output.TagName = IMG_TAG;
output.TagMode = TagMode.SelfClosing;
List<string> cssClasses = [];
var cornerStyle = componentStyleEnumService.GetCornerStyle(CornerStyle ?? string.Empty);
cssClasses.AddRange(componentStyleEnumService.GetCornerStyleClasses(cornerStyle));
if (cssClasses.Count > 0)
{
foreach (string cssClass in cssClasses)
{
output.AddClass(cssClass, HtmlEncoder.Default);
}
}
}
}
The tag helper can be used in the view like this:
<tg-styled-image src="@templateModel.Media.FirstOrDefault()?.FilePath"
alt="@templateModel.Media.FirstOrDefault()?.Description"
corner-style="@Model.Properties.CornerStyle"
class="c-product-img object-fit-cover" />
Complete the template
Comparing the current template to the mockup from the first guide in this series, you’ll notice that the Product features table is missing.
Its data is already in the template model, so it should be fairly strightforward to render to the page.
However, if you inspect the ProductFeature content type, you may notice that its value can come from multiple different places depending on the selected ProductFeatureValueType.
Let’s make one last tag helper, so that we don’t need to clutter the view with conditionals.
Add a product feature value tag helper
Since this one is specifically related to Product features, and not general styling, we can put it in the ~/Features/Products/TagHelpers folder.
- Create a new self-closing tag helper called
ProductFeatureValueTagHelper
, with the tag nametg-product-feature-value
. - Add a
ProductFeatureViewModel
property and a constant for thespan
tag. - In the
Process
method, determine the formated value depending on the providedProductFeatureValueType
from the view model and render it.
using System.Globalization;
using Microsoft.AspNetCore.Html;
using Microsoft.AspNetCore.Razor.TagHelpers;
using TrainingGuides.Web.Features.Products.Models;
namespace TrainingGuides.Web.Features.Products.TagHelpers;
/// <summary>
/// Formats Product feature value based on its type.
/// </summary>
[HtmlTargetElement("tg-product-feature-value", TagStructure = TagStructure.WithoutEndTag)] //This TagStructure allows the helper to be called with a single self-closing tag in razor views.
public class ProductFeatureValueTagHelper : TagHelper
{
public ProductFeatureViewModel? Feature { get; set; }
private const string SPAN_TAG = "span";
public override void Process(TagHelperContext context, TagHelperOutput output)
{
output.TagName = SPAN_TAG;
// Make sure to set the output's TagMode to StartTagAndEndTag.
// This ensures that even though the tag helper is called with a single tag, the rendered output closes the `span` tag that wraps the value.
output.TagMode = TagMode.StartTagAndEndTag;
string? formattedValue = Feature?.ValueType switch
{
ProductFeatureValueType.Text => Feature.Value.Value,
ProductFeatureValueType.Number => string.Format(CultureInfo.CurrentUICulture, "{0:0.00}", Feature.Price),
ProductFeatureValueType.Boolean => Feature.FeatureIncluded ? "✔" : "-",
_ => string.Empty
};
output.Content.SetHtmlContent(new HtmlString(formattedValue));
}
}
Add the product features list to the template view
- In the ~/Features/Products/ProductPagePageTemplate.cshtml folder, add a
foreach
loop to cycle through the product features. - Retrieve each feature’s
Label
directly, and use the new tag helper to display its value - Wrap the whole thing in the
tg-component-style
tag helper, settingcolor-scheme
directly andcorner-style
based on the template properties.
...
<tg-component-style color-scheme="@ColorSchemeOption.Light1" corner-style="@Model.Properties.CornerStyle" class="c-table">
@foreach (var feature in templateModel.Features)
{
<div>
<div>@feature.Label</div>
<div>
<tg-product-feature-value feature="@feature"/>
</div>
</div>
}
</tg-component-style>
...
Finish styling the template.
Now if you run the site, you’ll see all the data, but it’s still not formatted in a way that matches the mockup.
Add CSS styles and any necessary elements to make the template more visually appealing.
@using Microsoft.AspNetCore.Html
@using System.Globalization
@using TrainingGuides.Web.Features.Products
@using TrainingGuides.Web.Features.Products.Models
@using TrainingGuides.Web.Features.Shared.OptionProviders.ColumnLayout
@using TrainingGuides.Web.Features.Shared.OptionProviders.ColorScheme
@using TrainingGuides.Web.Features.Shared.ViewComponents
@model TemplateViewModel<ProductPagePageTemplateProperties>
@{
var templateModel = Model.GetTemplateModel<ProductPageViewModel>();
if (!Enum.TryParse(Model.Properties.ColumnLayout, out ColumnLayoutOption columnLayout))
{
columnLayout = ColumnLayoutOption.OneColumn;
}
}
<tg-component-style color-scheme="@Model.Properties.ColorScheme" corner-style="@Model.Properties.CornerStyle">
@if(Model.Properties.UsePageBuilder)
{
<vc:page-builder-columns column-layout-option="@columnLayout" page-builder-area-type="@PageBuilderAreaType.EditableAreas" editable-area-options="@new EditableAreaOptions { DefaultSectionIdentifier = ComponentIdentifiers.Sections.GENERAL }" />
}
else
{
<div class="tg-padding-big">
<div class="row">
<div class="col align-self-center">
<h3>@templateModel.Name</h3>
<p>@templateModel.ShortDescription</p>
</div>
<tg-styled-image src="@templateModel.Media.FirstOrDefault()?.FilePath" alt="@templateModel.Media.FirstOrDefault()?.Description" corner-style="@Model.Properties.CornerStyle" class="col c-product-img object-fit-cover" />
</div>
<div class="row">
<div class="c-pricelist">
<div class="col">
<tg-component-style color-scheme="@ColorSchemeOption.Light1" corner-style="@Model.Properties.CornerStyle" class="c-table">
@foreach (var feature in templateModel.Features)
{
<div class="c-table_row">
<div class="c-table_cell"><div class="c-table_cell">@feature.Label</div></div>
<div class="c-table_cell text-end">
<tg-product-feature-value feature="@feature"/>
</div>
</div>
}
</tg-component-style>
</div>
</div>
</div>
</div>
}
</tg-component-style>
At the end of it all, you should be able to achieve this by configuring your template.
What’s next?
Part 2 of the Create versatile templates guide covers the process of adding toggleable Page Builder functionality to this template, so that your editors can customize its content in special cases.
If you started in this guide, we recommend circling back to check out these guides form earlier in the series:
- Meet business requirements with Page Builder, a guide that analyzes how and when to apply Page Builder to business scenarios
- Add a custom dropdown provider for administration components, a guide that demonstrates the process of creating custom providers that provide options to dropdown menus in Page Builder component properties.