Views: 2172
Number of votes: 4
Average rating:

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

Jake Jones
( By Jake Jones, 11/19/2018 6:06:38 PM)

Hi Stephan,

Really interested in your CollectionEditor, I was messing around with something similar last week and your code looks nice and simple.

The one question I have is how can you be sure that your callback function has fired here:

dojo.when(store.get(id), function (returnValue) {
    contentData = returnValue;
});

Before you try and use that content data in your _getGridDefinition?

Unless I'm missing something it seems like a coincidence in your example that all your callbacks fired before you needed the results?

/Jake

Stephan Lonntorp
( By Stephan Lonntorp, 11/19/2018 7:43:26 PM)

Hi Jake,

Your question is a really good one, and you are totally correct in that I was lucky. Feel free to fix the issue when you copy the script, and please post back here with your solution, I'm sure your JavaScript/dojo skills are far better than mine (I have none, I'm great at hacky solutions though) :)

Please login to comment.