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.
Comments