Filtered display options menu based on content type in EPiServer

D e v e l o p m e n t , E p i s e r v e r
Mattias Olsson 30.06.2015 02:31:04

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>