Controlling Episerver Display Options Via A Custom Attribute
Introduction To The Problem
Episerver out of the box uses a system called blocks which everyone should be familiar with. These blocks are the broken down components of the page that build up the functionallity of the page and allow editors to control the page content. Block supports a concept called Display Options which you will see in the Alloy demo, these options allow you to configure a set layout options for blocks so that the blocks themselves can adapt. Most commonly these options are most simply aligned to grid layouts allowing blocks to either appear full width, half width, 1/3 for example.
So what's the problem? Well currently out of the box these display options are defined in an Initialization Module which is applied to all blocks, therefore there is no granularity on controlling the sizing of these blocks on a per block basis. For example based upon a defininiation that is set to Narrow, Wide, Full Width you set the same 3 options appear for every block
Solution
In trying to solve this problems over half a year ago Erik Nilsson was able to help me with the Dojo part of this on the following forum post https://world.episerver.com/forum/developer-forum/Feature-requests/Thread-Container/2016/11/episerver-cms-ability-to-define-support-block-display-options-at-a-block-level/ which helped me with the solution.
My solution was to use custom attributes to control everything. At the heart everything is Episerver on the content models are controlled via the use of Attributes so why not make the block controls would the same way right?
There are 6 main things that make up my solution
- DisplayOptionNames - Used to hold the supported sizes
- DisplayOptionsAttribute - Used to control the sizes on the block level
- DisplayOptionRestrictions.ashx - A service for the Dojo script to allow the block sizes to be evaluated
- SelectDisplayOption.js - Custom version of this core Episerver script
- ClientResourceProvider - Handles the addition of the custom scripts for the solution to work
- DisplayRegistryInitializationModule - The initalization module for registering the display options
DisplayOptionNames
This is a simple static class that holds the correct display names that are supported in the solution. For my current project where I have this configured I'll be using Full, Quarter, Half and ThreeQuarters.
/// <summary>
/// The different display option names.
/// </summary>
public static class DisplayOptionNames
{
/// <summary>
/// The full
/// </summary>
public const string Full = "Full";
/// <summary>
/// The quarter
/// </summary>
public const string Quarter = "Quarter";
/// <summary>
/// The half
/// </summary>
public const string Half = "Half";
/// <summary>
/// The three quarters
/// </summary>
public const string ThreeQuarters = "Three Quarters";
}
DisplayOptionsAttribute
This is the core custom attribute which I have created to control the display options that are supported in the editor and is added at the block level.
using System;
/// <summary>
/// Sets the selected display options
/// </summary>
[AttributeUsage(AttributeTargets.Class)]
public class DisplayOptionsAttribute : Attribute
{
/// <summary>
/// Gets or sets the options.
/// </summary>
/// <value>The options.</value>
public string[] Options { get; protected set; }
/// <summary>
/// Initializes a new instance of the <see cref="DisplayOptionsAttribute" /> class.
/// </summary>
/// <param name="options">The options.</param>
public DisplayOptionsAttribute(string[] options)
{
Options = options;
}
}
An example of this then being used is as follow
[DisplayOptions(new[] { DisplayOptionNames.ThreeQuarters, DisplayOptionNames.Half })]
DisplayOptionRestrictions.ashx
This is the handler that is called from the Dojo script and returns the correct formatted list of supported options for each block. In this code there is a const string ModelsDll that needs to be replaces with the solution that holds your block modesl. Feel free to change this to a different solution if you have custom requirements but for us all our models are stored in this location.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using Castle.Core.Internal;
using DotNet.Global.Extensions;
using EPiServer.Core;
using Models.Attributes;
/// <summary>
/// Handler that generates some dynamic JavaScript for the supported display options
/// </summary>
public class DisplayOptionRestrictions : IHttpHandler
{
private const string ContentType = "application/javascript";
private const string ModelsDll = "Redweb.EpiServer.Models.dll";
private static string _restrictedTypeJs;
/// <summary>
/// Enables processing of HTTP Web requests by a custom HttpHandler that implements the <see cref="T:System.Web.IHttpHandler" /> interface.
/// </summary>
/// <param name="context">An <see cref="T:System.Web.HttpContext" /> object that provides references to the intrinsic server objects (for example, Request, Response, Session, and Server) used to service HTTP requests.</param>
public void ProcessRequest(HttpContext context)
{
var js = GenerateRestrictionScript();
context.Response.ContentType = ContentType;
context.Response.Write(js);
}
/// <summary>
/// Generates the restriction script.
/// </summary>
/// <returns>System.String.</returns>
private string GenerateRestrictionScript()
{
if (string.IsNullOrEmpty(_restrictedTypeJs))
{
var types = GetBlockTypes();
var supportingTagJs = GenerateSupportingTagJs(types);
_restrictedTypeJs = "var cmsSupportedTags = { " + supportingTagJs + " };";
}
return _restrictedTypeJs;
}
/// <summary>
/// Gets the block types.
/// </summary>
/// <returns>IEnumerable<Type>.</returns>
private IEnumerable<Type> GetBlockTypes()
{
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
var modelAssembly = assemblies.First(a => a.ManifestModule.Name == ModelsDll);
return GeneralHelpers.FindDerivedTypes(modelAssembly, typeof(BlockData));
}
/// <summary>
/// Generates the supporting tag js code.
/// </summary>
/// <param name="types">The types.</param>
/// <returns>System.String.</returns>
private string GenerateSupportingTagJs(IEnumerable<Type> types)
{
var list = new List<string>();
foreach (var type in types)
{
if (type != null && !string.IsNullOrEmpty(type.FullName))
{
var displayAttribute = type.GetAttributes<DisplayOptionsAttribute>().FirstOrDefault();
if (displayAttribute != null)
{
var displayOptionSizes = GetDisplayOptionSizes(displayAttribute);
// ReSharper disable once PossibleNullReferenceException
list.Add($"\"{type.FullName.ToLower()}\": [{displayOptionSizes}]");
}
}
}
return string.Join(", ", list);
}
/// <summary>
/// Gets the display option sizes.
/// </summary>
/// <param name="option">The option.</param>
/// <returns>System.String.</returns>
private string GetDisplayOptionSizes(DisplayOptionsAttribute option)
{
return string.Join(", ", option.Options.Select(s => $"\"{s}\""));
}
/// <summary>
/// Gets a value indicating whether another request can use the <see cref="T:System.Web.IHttpHandler" /> instance.
/// </summary>
/// <value><c>true</c> if this instance is reusable; otherwise, <c>false</c>.</value>
public bool IsReusable => false;
}
This also has a reference to using DotNet.Global.Extensions; in this class we have an extension method as follows used for getting the derived types for the assembly containing our models. The extension class is as follows
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
/// <summary>
/// Set of general helper that support common needs
/// </summary>
public static class GeneralHelpers
{
/// <summary>
/// Finds the derived types.
/// </summary>
/// <param name="assembly">The assembly.</param>
/// <param name="baseType">Type of the base.</param>
/// <returns>IEnumerable<Type>.</returns>
public static IEnumerable<Type> FindDerivedTypes(this Assembly assembly, Type baseType)
{
return assembly.GetTypes().Where(baseType.IsAssignableFrom);
}
}
SelectDisplayOption.js
This is a slight tweak to the Dojo js file to support our custom implementation taken from "\modules\_protected\CMS\CMS.zip\[VERSION]\ClientResources\epi-cms\contentediting\command\SelectDisplayOption.js". In the file below is is my current version, I have wrapped the changed code in a comment // REDWEB: begin display option hack. In a practical use you should have a policy for updating this if the default Dojo file changes when a CMS update happens
define("epi-cms/contentediting/command/SelectDisplayOption", [
// General application modules
"dojo/_base/declare",
"dojo/_base/lang",
"dojo/when",
"epi/dependency",
"epi-cms/contentediting/command/_ContentAreaCommand",
"epi-cms/contentediting/viewmodel/ContentBlockViewModel",
"epi-cms/widget/DisplayOptionSelector",
// Resources
"epi/i18n!epi/cms/nls/episerver.cms.contentediting.editors.contentarea.displayoptions"
], function (declare, lang, when, dependency, _ContentAreaCommand, ContentBlockViewModel, DisplayOptionSelector, resources) {
return declare([_ContentAreaCommand], {
// tags:
// internal
// label: [public] String
// The action text of the command to be used in visual elements.
label: resources.label,
// category: [readonly] String
// A category which hints that this item should be displayed as an popup menu.
category: "popup",
_labelAutomatic: lang.replace(resources.label, [resources.automatic]),
constructor: function () {
this.popup = new DisplayOptionSelector();
},
postscript: function () {
this.inherited(arguments);
if (!this.store) {
var registry = dependency.resolve("epi.storeregistry");
this.store = registry.get("epi.cms.displayoptions");
}
when(this.store.get(), lang.hitch(this, function (options) {
// Reset command's available property in order to reset dom's display property of the given node
this._setCommandAvailable(options);
this.popup.set("displayOptions", options);
this.popup.set("displayOptionsMaster", options);
}));
},
_onModelChange: function () {
// summary:
// Updates canExecute after the model value has changed.
// tags:
// protected
this.inherited(arguments);
var options = this.popup.displayOptionsMaster,
selectedOption = this.model.get("displayOption"),
isAvailable = options && options.length > 0;
isAvailable = isAvailable && (this.model instanceof ContentBlockViewModel);
// REDWEB: begin display option hack
if (isAvailable && window.cmsSupportedTags != null) {
if (window.cmsSupportedTags[this.model.typeIdentifier] != null) {
var op = [];
var i = options.length;
while (i--) {
if (window.cmsSupportedTags[this.model.typeIdentifier].indexOf(options[i].id) > -1) {
op.push(options[i]);
}
}
options = op;
this.popup.displayOptions = op;
}
}
// REDWEB: end display option hack
this._setCommandAvailable(options);
if (!isAvailable) {
this.set("label", this._labelAutomatic);
return;
}
this.popup.set("model", this.model);
if (!selectedOption) {
this.set("label", this._labelAutomatic);
} else {
this._setLabel(selectedOption);
}
this._watch("displayOption", function (property, oldValue, newValue) {
if (!newValue) {
this.set("label", this._labelAutomatic);
} else {
this._setLabel(newValue);
}
}, this);
},
_setCommandAvailable: function (/*Array*/displayOptions) {
// summary:
// Set command available
// displayOptions: [Array]
// Collection of a content display mode
// tags:
// private
this.set("isAvailable", displayOptions && displayOptions.length > 0 && this.model instanceof ContentBlockViewModel);
},
_setLabel: function (displayOption) {
when(this.store.get(displayOption), lang.hitch(this, function (option) {
this.set("label", lang.replace(resources.label, [option.name]));
}), lang.hitch(this, function (error) {
console.log("Could not get the option for: ", displayOption, error);
this.set("label", this._labelAutomatic);
}));
},
_onModelValueChange: function () {
this.set("canExecute", !!this.model && this.model.contentLink && !this.model.get("readOnly"));
}
});
});
ClientResourceProvider
This is a simple class that allows us to replace the default implementation for our new ashx handler and dojo file,
using System.Collections.Generic;
using EPiServer.Framework.Web.Resources;
using EPiServer.Shell;
/// <summary>
/// A client resource provider that allows overriding of DOJO scripts.
/// </summary>
/// <seealso cref="IClientResourceProvider" />
[ClientResourceProvider]
public class ClientResourceProvider : IClientResourceProvider
{
/// <inheritdoc />
public IEnumerable<ClientResource> GetClientResources()
{
// this is the script you generate with display option restrictions
yield return new ClientResource
{
Name = "epi-cms.widgets.base",
Path = Paths.ToClientResource("", "Handlers/DisplayOptionRestrictions.ashx"),
ResourceType = ClientResourceType.Script
};
// this will override the built-in episerver script file with our hacked version
// there is possibly a better way, more "correct" way to do this, but this works for us
yield return new ClientResource
{
Name = "epi-cms.widgets.base",
Path = Paths.ToClientResource("", "Scripts/Dojo/SelectDisplayOption.js"),
ResourceType = ClientResourceType.Script
};
}
}
This also needs to be registered in the Episerver DI in your dependancy resolver initialization module as follws
context.Services.AddTransient<IClientResourceProvider, ClientResourceProvider>();
If this does not work for you as I had trouble re-adding recently, you can also use the module config by creating a module.config folder in the root
<module xmlns:xdt="http://schemas.microsoft.com/XML-Document-Transform">
<clientResources>
<add name="epi-cms.widgets.base" path="/Scripts/Dojo/SelectDisplayOption.js" resourceType="Script" />
<add name="epi-cms.widgets.base" path="/Handlers/DisplayOptionRestrictions.ashx" resourceType="Script" />
</clientResources>
</module>
DisplayRegistryInitializationModule
The common and final piece of the puzzle is just the module for registering our display options
using System.Web.Mvc;
using EPiServer.Framework;
using EPiServer.Framework.Initialization;
using EPiServer.ServiceLocation;
using EPiServer.Web;
using Models.Definitions;
/// <summary>
/// The moduel for setting the block display.
/// </summary>
[InitializableModule]
[ModuleDependency(typeof(EPiServer.Web.InitializationModule))]
public class DisplayRegistryInitializationModule : InitializableModuleBase, IInitializableModule
{
/// <summary>
/// Initializes the specified context.
/// </summary>
/// <param name="context">The context.</param>
public void Initialize(InitializationEngine context)
{
if (context.HostType == HostType.WebApplication)
{
// Register Display Options
var options = ServiceLocator.Current.GetInstance<DisplayOptions>();
options
.Add(DisplayOptionNames.Full, DisplayOptionNames.Full, ApplicationSettings.BlockSizes.BlockSizeCssClassFull, "", "epi-icon__layout--full")
.Add(DisplayOptionNames.Quarter, DisplayOptionNames.Quarter, ApplicationSettings.BlockSizes.BlockSizeCssClassQuarter, "", "epi-icon__layout--one-quarter")
.Add(DisplayOptionNames.Half, DisplayOptionNames.Half, ApplicationSettings.BlockSizes.BlockSizeCssClassHalf, "", "epi-icon__layout--half")
.Add(DisplayOptionNames.ThreeQuarters, DisplayOptionNames.ThreeQuarters, ApplicationSettings.BlockSizes.BlockSizeCssClassThreeQuarters, "", "epi-icon__layout--two-thirds");
AreaRegistration.RegisterAllAreas();
}
}
/// <summary>
/// Preloads the specified parameters.
/// </summary>
/// <param name="parameters">The parameters.</param>
public void Preload(string[] parameters){}
/// <summary>
/// Uninitializes the specified context.
/// </summary>
/// <param name="context">The context.</param>
public void Uninitialize(InitializationEngine context){}
}
In the above I have the css classes regiserted to each of my sizes coming from the appSettings in the web config. The ApplicationSettings code is simply our service that loads these settings.
Summary
With all the above configured you now have a way to easily set the options on a per block level, as you saw in the above we have the 4 display options as shown but here is the editor showing just one availible
Hopefully this all makes sense, any questions feel free to ask me in the comments.
Nice, this might work great together with Bootstrap Area and restricting available options based on content ;)
Nice work Scott, is there any plans to create a package for this for installation? Having the ability to control the display options on a per block basis is a really nice feature.
Nice job, thanks for sharing!
Hi Valdis, I hadn't seen Bootstrap Area before but yeah probably, we actually have a little extra code I hadn't posted in these attibutes as I didn't want to over complicate that works out default sizes and fallback to the first supported sizes if availible in an area. I was thinking I was thinking down the line of adding attributes on ContentAreas shows the supported sizes and then joining in this rendered so you can more granularly control sizes not only per block but for each ContentArea that you use them but havn't got that far yet. :-)
Hi Paul, I was thinking of a package for this my one worry if getting the time to keep this updated with the latest version. Also at least half of these files you'd want to maybe tweak in a standard build. I'll have a think about if I can get some time to do this :-)
Great work Scott and thanks for sharing this. If you don't have time to keep it updated then maybe you could share the code as demo with Alloy on GitHub so others could use that as a reference? Or better yet, involve the Episerver community to development and keeping it updated :)
I'll look at throwing up an Alloy demo as soon as I can as least that way there's a reference. Near the end of a commerce project at the moment that's using 200% of my time lol :p
@scott with bootstrap area you can even decorate area and have default display option added to the blocks dropped there ;)
Ah I see, I have that in our code in my DisplayOptionsAttribute (removed in this example) but it's only on the Block not the content area. Another nice package tho Valdis :-)
Interesting writeup!
wow this is really interesting .. if visitor group or any other factor can be injection to decision making would be great .. for example people on mobile we have different option! like the approach
If anyone is using this I've just updated it as there was a bug in SelectDisplayOption.js please replace that file to implement the fix
Hi,
I just want to update change if we use Optimizely CMS 12 and .NET Core. is replacing middleware like this:
- Instead of using IHttpHandler then we could use MiddleWare in .NET Core like this
Then use this middleware in Startup file like this: