World is now on Opti ID! Learn more

Stephan Lonntorp
Nov 16, 2018
  44
(0 votes)

Building a content-backed option list for your editors

So here's the premise, you want to let your editors select an option in a list, but, you also know that that list could change over time. A common approach is to use an enum, or a list of strings in an app-settings key. While both of these approaches work, they often leave editors wanting, and are limited to simple property types, like, enums, ints or strings, and require a deploy to update.

Here's an approach of putting your editors in the driver's seat, while showing them something that puts content in context in a more visual way.

This is a view of the end result for the editor.

This list of icons will probably change over time, and we don't want to have to do a deploy every time we add an icon. Granted, there are a few other aspects that will need to be considered, like how to handle values that no longer exist, localization, and probably how these should be handled in your front-end, but I'll leave most of that up to you as a reader and implementor to decide. Here's a way to do this, not the way.

First, let's get the data source into the CMS.

I've opted for the use of a PropertyValueList, but if you're more of a blocks-for-all-the-things kind of developer, that is certainly doable too.

    public class CustomIcon
    {
        [Display(Name = "/CustomIcon/Icon", Order = 1), AllowedTypes(typeof(SvgFile)), UIHint(UIHint.Image)]
        public ContentReference Icon { get; set; }

        [Display(Name = "/CustomIcon/Label", Order = 2)]
        public string Label { get; set; }
    }

    /// <remarks>
    /// This class must exist so that Episerver can store the property list data.
    /// </remarks>
    [PropertyDefinitionTypePlugIn]
    public class CustomIconCollectionProperty : PropertyList<CustomIcon> { }

    [EditorDescriptorRegistration(TargetType = typeof(IList<CustomIcon>))]
    public class CustomIconCollectionEditorDescriptor : CollectionEditorDescriptor<CustomIcon>
    {
        public override void ModifyMetadata(ExtendedMetadata metadata, IEnumerable<Attribute> attributes)
        {
            ClientEditingClass = "CustomIconPicker/CollectionFormatter.js";
            base.ModifyMetadata(metadata, attributes);
        }
    }

OK, so what's going on here? This is just a simple list of a simple POCO, containing a ContentReference and a string property. I always define specific Media typesper extension, instead of having a generic ImageFile media type, since that allows me to do restrictions like above, where I only allow SVG's as icons.

And here's the JavaScript for showing the icon, instead of the ID, in the grid editor for the PropertyValueList (the contents of the CollectionFormatter.js file)

define([
    "dojo/_base/declare",
    "epi/dependency",
    "epi-cms/contentediting/editors/CollectionEditor"
],
    function (
        declare,
        dependency,
        collectionEditor
    ) {
        return declare([collectionEditor], {
            _resolveContentData: function(id) {
                if (!id) {
                    return null;
                }
                var registry = dependency.resolve("epi.storeregistry");
                var store = registry.get("epi.cms.content.light");
                var contentData;
                dojo.when(store.get(id), function (returnValue) {
                    contentData = returnValue;
                });
                return contentData;
            },
            _getGridDefinition: function () {
                var that = this;
                var result = this.inherited(arguments);
                result.icon.formatter = function (value) {
                    var content = that._resolveContentData(value);
                    if (content) {
                        return `<img style="width:16px;display:inline-block;margin-right:4px;" src="${content.publicUrl}" alt="${content.name}" title="${content.name} (${content.contentLink})" />`;
                    }
                    return value;
                };
                return result;
            }
        });
    });

What the above code produces, is something like this:

OK, so now we have a property editor, but we'll need somewhere to store the data too. I've used a block, and an abstracted interface, and for brevity I've left the registering and resolving of it out, but you'll need to have a way if registering it in the IoC container. A simple way could be to have it as a property on your start page, and resolve the property from there, and register it as a scoped instance, but I think that could be a blog post in its own right. If you want me to explain that in detail, let me know in the comments.

    [ContentType(GUID = Guid)]
    public class IconConfiguration : BlockData, ICustomIconConfiguration
    {
        public const string Guid = "52C7D11A-E1CB-4E8F-847F-40DA095F1234";

        [Display(Order = 10), CultureSpecific]
        public virtual IList<CustomIcon> AvailableIcons { get; set; }
    }

    public interface ICustomIconConfiguration
    {
        IList<CustomIcon> AvailableIcons { get; }
    }

OK, now that we have that object to store our configured icons, and we can resolve it from the IoC container, how do we use it? On to part 2.

Using content in our editor

    [EditorDescriptorRegistration(TargetType = typeof(ContentReference), UIHint = "CustomIconPicker")]
    public class CustomIconPickerEditorDescriptor : EditorDescriptor
    {
        public override void ModifyMetadata(ExtendedMetadata metadata, IEnumerable<Attribute> attributes)
        {
            ClientEditingClass = "CustomIconPicker/Editor.js";
            base.ModifyMetadata(metadata, attributes);
            metadata.EditorConfiguration.Add("data", ServiceLocator.Current.GetInstance<IIconConfiguration>().AvailableIcons);
            metadata.EditorConfiguration.Add("noneSelectedName", LocalizationService.Current.GetString("/CustomIcon/Picker/NoneSelected"));
        }
    }
	
	[ContentType(GUID = Guid)]
    public class MyBlockWithCustomIcon : BlockData
    {
        public const string Guid = "5E2556B3-3071-43E6-B4CE-CDD34B13E4DE";

        [Display(Order = 10), UIHint("CustomIconPicker")]
        public virtual ContentReference Icon { get; set; }
    }

So I have a block with a ContentReference property, and a UIHint that makes it use my custom editor. Just a quick note on why I'm using the despicable anti-pattern that is the ServiceLocator: Using constructor injection in an editordescriptor works, but will effectively turn the instance injected into a singleton in the editor descriptor scope, meaning that any changes to the IIconConfiguration instance won't show up until app restart. Thus, using constructor injection in editor descriptors is generally a bad idea, unless you're only using singleton dependencies, but as a consumer you shouldn't have to care about that.

But enough about that, here's the code in the Editor.js file.

define([
    "dojo/on",
    "dojo/_base/declare",
    "dojo/aspect",
    "dijit/registry",
    "dijit/WidgetSet",
    "dijit/_Widget",
    "dijit/_TemplatedMixin",
    "dijit/_WidgetsInTemplateMixin",
    "dijit/form/Select",
    "epi/dependency",
    "epi/i18n!epi/cms/nls/customicon.picker",
    "xstyle/css!./WidgetTemplate.css"
],
    function (
        on,
        declare,
        aspect,
        registry,
        WidgetSet,
        _Widget,
        _TemplatedMixin,
        _WidgetsInTemplateMixin,
        Select,
        dependency,
        localization) {
        return declare([_Widget, _TemplatedMixin, _WidgetsInTemplateMixin], {
            templateString: dojo.cache("customicon.picker", "WidgetTemplate.html"),
            intermediateChanges: false,
            value: null,
            picker: null,
            _resolveContentData: function(id) {
                if (!id) {
                    return null;
                }
                var registry = dependency.resolve("epi.storeregistry");
                var store = registry.get("epi.cms.content.light");
                var contentData;
                dojo.when(store.get(id), function (returnValue) {
                    contentData = returnValue;
                });
                return contentData;
            },
            onChange: function (value) {
            },
            postCreate: function () {
                this.inherited(arguments);
                this.initializePicker(this.value);
            },
            startup: function () {
            },
            destroy: function () {
                this.inherited(arguments);
            },
            _setValueAttr: function (value) {
                if (value === this.value) {
                    return;
                }
                this._set("value", value);
                this.setSelected(value);
            },
            setSelected: function (value) {
                var self = this;
                self.picker.attr("value", value);
            },
            initializePicker: function (initialValue) {
                var self = this;
                var data = self.data;
                if (data != null) {
                    var options = [{ label: self.noneSelectedName, value: " ", selected: initialValue === null }];
                    for (var index = 0; index < data.length; index++) {
                        var item = data[index];
                        var content = self._resolveContentData(item.icon);
                        if (content) {
                            options.push({ label: `<div class="customiconpicker--icon"><img style="width:32px;display:inline-block;margin-right:4px;" src="${content.publicUrl}" alt="${item.label}" title="${item.label}" /><span class="customiconpicker--label">${item.label}</span></div>`, value: item.icon, selected: item.icon === initialValue });
                        }
                    }
                    var select = new Select({
                        name: "customiconpicker",
                        options: options,
                        maxHeight: -1
                    }, "customiconpicker");
                    select.on("change", function () {
                        self._onChange(this.get("value"));
                    });
                    this.picker = select;
                    this.container.appendChild(select.domNode);
                }
            },
            _onChange: function (value) {
                this._set("value", value);
                this.onChange(value);
            }
        });
    });

Here's the WidgetTemplate.html referenced in the javascript.

<div class="dijitInline customiconpicker-editor">
    <div data-dojo-attach-point="container"></div>
</div>

Here's the WidgetTemplate.css also referenced from the JavaScipt, they are placed beside the Editor.js file.

.customiconpicker-editor .dijitButtonText {
    height: 40px;
}

.customiconpicker--icon {
    margin-left: 0px;
    display: flex;
    align-items: center;
    min-width: 40px;
}

    .customiconpicker--icon .customiconpicker--label {
        height: 40px;
        line-height: 40px;
        padding-left: 4px;
    }

.dijitSelectLabel .customiconpicker--icon {
    margin-left: 0px;
    display: flex;
    align-items: center;
}

    .dijitSelectLabel .customiconpicker--icon .customiconpicker--label {
        height: 40px;
        line-height: 40px;
        padding-left: 4px;
    }

And, if you're a "don't-hardcode-things" kind of person like me, here's the XML that'll give your editors editing controls in a language they can understand.

<?xml version="1.0" encoding="utf-8"?>
<languages>
  <language name="English" id="en">
    <contenttypes>
      <IconConfiguration>
        <name>Icon Configuration</name>
        <description>A block used to configure the icon feature on the site.</description>
        <properties>
          <AvailableIcons>
            <caption>Available Icons</caption>
            <help>The icons available for editors to select from.</help>
          </AvailableIcons>
        </properties>
      </IconConfiguration>
      <MyBlockWithCustomIcon>
        <name>My block with an icon</name>
        <description>A block with an icon used for this demo.</description>
        <properties>
          <Icon>
            <caption>Icon</caption>
            <help>An optional icon to use for this block.</help>
          </Icon>
        </properties>
      </MyBlockWithCustomIcon>
    </contenttypes>
    <CustomIcon>
      <Label>Label</Label>
      <Icon>Icon</Icon>
      <Picker>
        <NoneSelected>Default icon</NoneSelected>
      </Picker>
    </CustomIcon>
  </language>
</languages>

Hopefully, following these steps should leave you with a smoother editor experience, that will give your editors more control over their site, and help them not have to memorize what icon a text string corresponds to, and also be able to change that icon. Or image, or whatever your imagination can come up with.

Remember, Episerver is a tool built to suit everyone, but that doesn't mean that you can't make it suit your clients even better. Put your editors in the driver's seat, and give them a tool they love to use, not one that they tolerate.

Nov 16, 2018

Comments

Please login to comment.
Latest blogs
Make Global Assets Site- and Language-Aware at Indexing Time

I had a support case the other day with a question around search on global assets on a multisite. This is the result of that investigation. This co...

dada | Jun 26, 2025

The remote server returned an error: (400) Bad Request – when configuring Azure Storage for an older Optimizely CMS site

How to fix a strange issue that occurred when I moved editor-uploaded files for some old Optimizely CMS 11 solutions to Azure Storage.

Tomas Hensrud Gulla | Jun 26, 2025 |

Enable Opal AI for your Optimizely products

Learn how to enable Opal AI, and meet your infinite workforce.

Tomas Hensrud Gulla | Jun 25, 2025 |

Deploying to Optimizely Frontend Hosting: A Practical Guide

Optimizely Frontend Hosting is a cloud-based solution for deploying headless frontend applications - currently supporting only Next.js projects. It...

Szymon Uryga | Jun 25, 2025

World on Opti ID

We're excited to announce that world.optimizely.com is now integrated with Opti ID! What does this mean for you? New Users:  You can now log in wit...

Patrick Lam | Jun 22, 2025

Avoid Scandinavian Letters in File Names in Optimizely CMS

Discover how Scandinavian letters in file names can break media in Optimizely CMS—and learn a simple code fix to automatically sanitize uploads for...

Henning Sjørbotten | Jun 19, 2025 |