Xperience by Kentico's Page Builder gives marketers control over their customer's website experiences. They can create and compose content and design using the Page Templates, Sections, and Widgets that developers author. Once these components are added to a solution, marketers can mix and match them, customize their options, and select content from other website pages or the Content Hub to express and endless variety of ways to engage their audience.
Sometimes, however, it's helpful to limit the options available to marketers in the Page Builder to specific combinations that work best together.
Basic restrictions
Developers can simplify marketers workflows by restricting which Sections and Widgets are used in each Editable area on a page.
In its most basic form, this requires an array of Section or Widget identifiers to be assigned to the "restriction" properties of an Editable Area where its defined in a View.
<editable-area
area-identifier="main"
area-options-allowed-widgets='new[] { "Sandbox.Widget.Hero", "Sandbox.Widget.CTA" }'
area-options-allowed-sections='new[] { "Sandbox.Section.OneColumn" }' />
In the example above, we can assume that in this area of this type of page's layout, we only want to allow Single Column sections that have a Hero or CTA widget in them.
It's also worth noting that Sections have their own ability to restrict which Widgets they allow using the <widget-zone />
Tag Helper, which works similar to the Editable Area restrictions.
<widget-zone zone-name="main"
allowed-widgets='new[] { "Sandbox.Widget.CTA", "Sandbox.Widget.List" }' />
All of the discussion that follows applies equally to Widget Zones, but I won't mention them explicitly except where they differ from Editable Areas.
Why and when
Why would we specify these kinds of restrictions and not use the <editabl-area />
without the allowed-widgets
and allowed-sections
properties?
Isn't it better to just create a bunch of Page Builder components and let marketers pick the ones they want to use?
Well, maybe!
I've worked on several projects where we defined all <editable-area />
Tag Helpers without any restrictions. The sections and components were designed in a way to compose together well and we didn't find it necessary to enforce which combinations of components could be used together, or on which pages those components could be used.
However, as a project increases in size and complexity - and the number of types of pages and Page Builder components grows - there will often be a need to build guard rails for marketers using the Editable Area restrictions.
So, in these situations, what are the ways we can define these restrictions?
String identifiers
The most straightforward approach to define restrictions for Page Builder components is to assign the string values of the component identifiers in-line, like we saw above.
<editable-area
area-identifier="main"
area-options-allowed-widgets='new[] { "Sandbox.Widget.Hero", "Sandbox.Widget.CTA" }'
area-options-allowed-sections='new[] { "Sandbox.Section.OneColumn" }' />
This is a quick way to test out component restrictions, but I'd advise against using these "scattered strings".
Sometimes these hardcoded strings are referred to as magic strings, but magic strings are usually associated with unexpected behavior by using a "magic" value. In this situation, the behavior we see is expected. The issue is that we are copying and pasting easy to typo and difficult to refactor string values across our application - hence the term "scattered strings".
Code that changes together should live together. A Section and its identifier have high coupling but any two Sections identifiers typically have low cohesion with each other. That's why it makes the most sense to keep the identifiers near their Views and component classes.
Identifier References
Instead of repeating the identifier values, we can define them in a single place and reference them across our application.
My favorite place to define component identifiers is in the component class that they represent (and not a separate "identifiers" class).
Here's an example of a Page Builder Section.
[assembly: RegisterSection(
identifier: OneColumnSection.IDENTIFIER,
viewComponentType: typeof(OneColumnSection),
name: "One Column",
Description = "A simple section with a single column for widgets"
)]
namespace Sandbox.Components.Sections;
public class OneColumnSection : ViewComponent
{
public const string IDENTIFIER = "Sandbox.Section.OneColumn";
public IViewComponentResult Invoke() =>
View("/Components/Sections/OneColumn/OneColumn.cshtml");
}
What if we don't have a ViewComponent
class for our component? Well, in general I'd recommend creating one anyway because it's a convenient place to define the component identifier and makes it easier to add functionality,
like content retrieval, in the future.
We can now reference this Section in an <editable-area />
restriction.
@using Sandbox.Components.Sections
<editable-area
area-identifier="main"
area-options-allowed-sections="new[] { OneColumnSection.IDENTIFIER }" />
We can get rid of that @using
directive at the top of the View by adding it to our _ViewImports.cshtml
file,
which will include it for every View file that is a sibling or child in the file system.
Static Identifiers Class
If we don't include the right @using
, it can difficult to discover what components are in our solution
and we might not get great C# intellisense unless we already have an idea of the name of the Section we are looking for.
Wouldn't it be great if we had an easy to access list of all our components? Well, we can create one!
namespace Sandbox.Components.Sections;
public static class SectionIdentifiers
{
public const string OneColumn = OneColumnSection.IDENTIFIER;
public const string TwoColumn = TwoColumnSection.IDENTIFIER;
public const string Grid = GridSection.IDENTIFIER;
public const string Form = FormSection.IDENTIFIER;
public const string Unstyled = UnstyledSection.IDENTIFIER;
}
Now, we can use an alias to create an easy to reference type in each of our Views that includes all identifiers.
@* _ViewImports.cshtml *@
@using SI = Sandbox.Components.Sections
@* ... *@
<editable-area
area-identifier="main"
allow-widget-output-cache="true"
widget-output-cache-expires-after="TimeSpan.FromMinutes(1)"
area-options-allowed-widgets='new[] { "..." }'
area-options-default-section-identifier="@SI.OneColumn"
area-options-allowed-sections="new[] { SI.OneColumn }" />
This is easy to read for a developer looking to see how an Editable Area is defined. Each Editable Area will usually include quite a few options and be surrounded by HTML, so readability is valuable here.
Out of all the approaches mentioned for defining component restrictions, this is my favorite one!
Injected Service
Another option is use a service that is dependency injected into the View.
public class ComponentService
{
public IReadOnlyList<string> HomePageWidgets() =>
new List<string>{ "..." };
public IReadOnlyList<string> HomePageSections() =>
new List<string>{ OneColumnSection.IDENTIFIER };
}
// Program.cs
// ...
builder.Services.AddSingleton<ComponentService>();
// ...
@* _ViewImports.cshtml *@
@inject ComponentService Components
@* ... *@
@* HomePage.cshtml *@
<editable-area
area-identifier="main"
area-options-allowed-widgets="Components.HomePageWidgets()"
area-options-allowed-sections="Components.HomePageSections()" />
I'm typically more of a fan of the explicit approach we see above rather than hiding the component identifiers behind some runtime evaluated method or filters. This often just makes the code slightly more maintainable for far worse readability.
The benefit of this approach comes with scenarios that are more complex - like a solution that has multiple website channels but where some of the Page Builder components should only be available in specific channels.
In this case, our ComponentService
can be registered as "scoped" in DI and take a dependency on IWebsiteChannelContext
to determine what channel the current request is associated with.
public class ComponentService
{
private readonly IWebsiteChannelContext context;
public ComponentService(IWebsiteChannelContext context) =>
this.context = context;
public IReadOnlyList<string> HomePageWidgets() =>
string.Equals(context.WebsiteChannelName, "Sandbox.Corporate", StringComparison.OrdinalIgnoreCase)
? new[] { "..." }
: new[] { "..." };
public IReadOnlyList<string> HomePageSections() =>
string.Equals(context.WebsiteChannelName, "Sandbox.Corporate", StringComparison.OrdinalIgnoreCase)
? new[] { OneColumnSection.IDENTIFIER, FormSection.IDENTIFIER }
: new[] { OneColumnSection.IDENTIFIER };
}
// Program.cs
// ...
builder.Services.AddScoped<ComponentService>();
// ...
We could even customize the Administration UI and include a screen to let us associate component identifiers with various Web pages or Content Types to dynamically define component restrictions are runtime.
I haven't had a need for this amount of complexity myself, but it's nice to know it's possible!