Filtered display options menu based on content type in EPiServer
This blog post is kind of part 2 of my earlier post How to limit rendering to specific display options and set default display option for a block. I end it with the following question: "Is it possible to disable the display option select item in the context menu if no template is found to support it? Please let me know. It bugs me a lot.".
Since nobody answered my question I just had to figure it out myself. So, here it is. Maybe it's not the most pretty solution but it works.
Specification
I have these display options defined for my site:
For a given block I only want support for Full and Half width so I want the menu filtered into this:
The display option tags is defined in code like this:
public static class DisplayOptions { public const string FullWidth = "FullWidth"; public const string ThreeFourthsWidth = "ThreeFourthsWidth"; public const string TwoThirdsWidth = "TwoThirdsWidth"; public const string HalfWidth = "HalfWidth"; public const string OneThirdWidth = "OneThirdWidth"; public const string OneFourthWidth = "OneFourthWidth"; }
Server side code
ISpecialRenderingContent interface
I need a way to set the supported display options for a content type so I created this interface to implement.
public interface ISpecialRenderingContent { string[] SupportedDisplayOptions { get; } }
Block example
[ContentType(DisplayName = "My full and half width block", GUID = "some-guid")] public class MyBlock : BlockData, ISpecialRenderingContent { public virtual string Heading { get; set; } public virtual XhtmlString MainBody { get; set; } public string[] SupportedDisplayOptions { get { return new[] { DisplayOptions.FullWidth, DisplayOptions.HalfWidth }; } } }
RestStore - SupportedDisplayOptionsStore
I need some way to get the supported display options for a given ContentReference from my client script code (I'll get back to that). I decided to create a simple RestStore for this.
[RestStore("supporteddisplayoptions")] public class SupportedDisplayOptionsStore : RestControllerBase { private readonly IContentLoader _contentLoader; private readonly DisplayOptions _displayOptions; public SupportedDisplayOptionsStore(IContentLoader contentLoader, DisplayOptions displayOptions) { if (contentLoader == null) throw new ArgumentNullException("contentLoader"); if (displayOptions == null) throw new ArgumentNullException("displayOptions"); _contentLoader = contentLoader; _displayOptions = displayOptions; } [HttpGet] public RestResultBase Get(string id) { ContentReference contentLink; if (!ContentReference.TryParse(id, out contentLink)) { return Default(); } IContent content; if (!_contentLoader.TryGet(contentLink, out content)) { return Default(); } var specialRenderingContent = content as ISpecialRenderingContent; if (specialRenderingContent != null) { var supportedDisplayOptions =
_displayOptions
.Where(x => specialRenderingContent.SupportedDisplayOptions.Contains(x.Tag)); return Rest(supportedDisplayOptions.Select(x => x.Id)); } // Default to all display options. return Default(); } private RestResultBase Default() { return Rest(_displayOptions.Select(x => x.Id)); } }
Client side code
~/ClientResources/Scripts/ModuleInitializer.js
To register the rest store I created a module initializer.
define([ "dojo", "dojo/_base/declare", "epi/_Module", "epi/dependency", "epi/routes" ], function ( dojo, declare, _Module, dependency, routes ) { return declare("app.ModuleInitializer", [_Module], { initialize: function () { this.inherited(arguments); var registry = this.resolveDependency("epi.storeregistry"); //Register the store registry.create("supporteddisplayoptions", this._getRestPath("supporteddisplayoptions")); }, _getRestPath: function (name) { return routes.getRestPath({ moduleArea: "app", storeName: name }); } }); });
~/ClientResources/Scripts/CacheManager.js
I don't want to call the rest store every time an editor selects a display option so I created a simple manager to cache them on the client side.
define([ "dojo/_base/array", "dojo/_base/declare", "dojo/_base/lang" ], function( array, declare, lang ) { return declare("app.CacheManager", null, { _data: null, _cacheLength: null, _length: null, constructor: function (args) { this._data = {}; this._cacheLength = 50; this._length = 0; }, add: function(key, value) { if (this._length > this._cacheLength) { this.flush(); } if (!this._data[key]) { this._length++; } this._data[key] = value; }, load: function(key) { if (this._length < 1) { return null; } if (this._data[key]) { return this._data[key]; } return null; }, flush: function() { this._data = {}; this._length = 0; } }); });
~/ClientResources/Scripts/widget/DisplayOptionSelector.js
This is the "kind of" ugly part of this. I had to extract the built-in display option selector code from EPiServer and modify it to my needs. The namespace and class name is important and shouldn't be modified.
define("epi-cms/widget/DisplayOptionSelector", [ "dojo/_base/array", "dojo/_base/declare", "dojo/_base/lang", "dojo/when", "dijit/MenuSeparator", "epi/dependency", "epi-cms/widget/SelectorMenuBase", // Resouces "epi/i18n!epi/cms/nls/episerver.cms.contentediting.editors.contentarea.displayoptions", // Widgets used in template "epi/shell/widget/RadioMenuItem", "app/CacheManager" ], function ( array, declare, lang, when, MenuSeparator, dependency, SelectorMenuBase, // Resouces resources, RadioMenuItem, CacheManager ) { return declare([SelectorMenuBase], { // summary: // Used for selecting display options for a block in a content area // // tags: // internal // model: [public] epi-cms.contentediting.viewmodel.ContentBlockViewModel // View model for the selector model: null, // _resources: [private] Object // Resource object used in the template headingText: resources.title, _rdAutomatic: null, _separator: null, // Custom properties for hacked implementation. supportedDisplayOptions: null, _cacheManager: null, // Adapted from EPiServer and slightly modified. // Added initialization of cache manager. postCreate: function () { // summary: // Create the selector template and query for display options this.inherited(arguments); this.own(this._rdAutomatic = new RadioMenuItem({ label: resources.automatic, value: "" })); this.addChild(this._rdAutomatic); this._rdAutomatic.on("change", lang.hitch(this, this._restoreDefault)); this.own(this._separator = new MenuSeparator({ baseClass: "epi-menuSeparator" })); this.addChild(this._separator); this._cacheManager = this._getCacheManager(); }, // Adapted from EPiServer. No modifications. _restoreDefault: function () { this.model.modify(function () { this.model.set("displayOption", null); }, this); }, // Adapted from EPiServer. No modifications. _setModelAttr: function (model) { this._set("model", model); this._setup(); }, // Adapted from EPiServer. No modifications. _setDisplayOptionsAttr: function (displayOptions) { this._set("displayOptions", displayOptions); this._setup(); }, // Adapted from EPiServer and modified. // Called on load and when a display option is selected. _setup: function () { if (!this.model || !this.displayOptions) { return; } //Destroy the old menu items this._removeMenuItems(); var selectedDisplayOption = this.model.get("displayOption"); if (this.supportedDisplayOptions == null) { this._setSupportedDisplayOptions(selectedDisplayOption); } else { this._setMenuItems(this.supportedDisplayOptions, selectedDisplayOption); } }, // Custom method. Called in _setup method. Calls the rest store to // get supported display options. The result is cached. _setSupportedDisplayOptions: function (selectedDisplayOption) { var cacheKey = this._getCacheKey(); var cachedData = this._cacheManager.load(cacheKey); if (cachedData != null) { this.supportedDisplayOptions = cachedData; this._setMenuItems(this.supportedDisplayOptions, selectedDisplayOption); return; } var storeRegistry = dependency.resolve("epi.storeregistry"); var store = storeRegistry.get("supporteddisplayoptions"); when(store.get(this.model.contentLink), lang.hitch(this, function (supportedDisplayOptions) { this.supportedDisplayOptions = array.filter(this.displayOptions, function (displayOption) { return array.indexOf(supportedDisplayOptions, displayOption.id) > -1; }); // Add to cache this._cacheManager.add(this._getCacheKey(), this.supportedDisplayOptions); // Create menu items. this._setMenuItems(this.supportedDisplayOptions, selectedDisplayOption); }), lang.hitch(this, function (err) { // An error occured. Fallback to unfiltered/standard display options. this.supportedDisplayOptions = this.displayOptions; // Create menu items. this._setMenuItems(this.supportedDisplayOptions, selectedDisplayOption); })); }, // Extracted from the original _setup method. _setMenuItems: function (displayOptions, selectedDisplayOption) { array.forEach(displayOptions, function (displayOption) { var item = new RadioMenuItem({ label: displayOption.name, iconClass: displayOption.iconClass, displayOptionId: displayOption.id, checked: selectedDisplayOption === displayOption.id, title: displayOption.description }); this.own(item.watch("checked", lang.hitch(this, function (property, oldValue, newValue) { if (!newValue) { return; } // Modify the model this.model.modify(function () { this.model.set("displayOption", displayOption.id); }, this); }))); this.addChild(item); }, this); this._rdAutomatic.set("checked", !selectedDisplayOption); }, // Adapted from EPiServer. No modifications. _removeMenuItems: function () { var items = this.getChildren(); items.forEach(function (item) { if (item === this._rdAutomatic || item == this._separator) { return; } this.removeChild(item); item.destroy(); }, this); }, // Custom method. Gets the global cache manager. _getCacheManager: function () { window.supportedDisplayOptionsCache = window.supportedDisplayOptionsCache || new CacheManager(); return window.supportedDisplayOptionsCache; }, // Custom method. Gets the supported display options cache key for current content. _getCacheKey: function() { return "SupportedDisplayOptions-" + this.model.contentLink; } }); });
Wrapping it up
For all this to work you need to update/create module.config with this code:
<?xml version="1.0" encoding="utf-8"?> <module clientResourceRelativePath="" loadFromBin="false"> <assemblies> <!-- The assembly containing the rest store needs to be added. --> <add assembly="MyAssemblyName" /> </assemblies> <dojo> <paths> <add name="app" path="ClientResources/Scripts" /> </paths> </dojo> <clientModule initializer="app.ModuleInitializer"> <moduleDependencies> <add dependency="CMS" type="RunAfter" /> </moduleDependencies> </clientModule> <clientResources> <!-- Inject our custom Display Option selector --> <add name="epi-cms.widgets.base" path="~/ClientResources/Scripts/widget/DisplayOptionSelector.js" resourceType="Script" /> </clientResources> </module>