Editor for string list properties in Episerver

This time we're creating a custom Dojo widget as an editor for string list properties, making use of the new PropertyList type that was introduced in Episerver 9.

  • Ted Nyberg
  • 14 July 2016
  • 0

What it looks like

How it works

An editor can either input string values manually...

...or select from a pre-defined list of values if a selection factory is attached:

The underlying content type property is an IList<string>...

...which means it's trivial to enumerate the strings in backend code:

Using a selection factory

If we want to present the user with a dropdown with pre-defined values to choose from, we simply attach a selection factory to the property, using the ClientEditor attribute:

The selection factory simply has to return items with string values:

C# Expand
public class KeywordsSelectionFactory : ISelectionFactory
{
    public IEnumerable<ISelectItem> GetSelections(ExtendedMetadata metadata)
    {
        return new []
        {
            new SelectItem { Text = "First keyword", Value = "keyword1" },
            new SelectItem { Text = "Second keyword", Value = "keyword2" }
        };
    }
}

The IList<string> property

The underlying property is of type IList<string>. This in turn is backed by a property definition called PropertyStrings:

C# Expand
[PropertyDefinitionTypePlugIn]
public class PropertyStrings : PropertyListBase<string>
{

}

PropertyStrings inherits PropertyListBase, based on an example by Per Magne Skuseth, which simply serializes/deserializes each item in the list as JSON:

C# Expand
public class PropertyListBase<T> : PropertyList<T>
{
    public PropertyListBase()
    {
        _objectSerializer = this._objectSerializerFactory.Service.GetSerializer("application/json");
    }
    private Injected<ObjectSerializerFactory> _objectSerializerFactory;

    private IObjectSerializer _objectSerializer;

    protected override T ParseItem(string value)
    {
        return _objectSerializer.Deserialize<T>(value);
    }

    public override PropertyData ParseToObject(string value)
    {
        ParseToSelf(value);
        return this;
    }
}

In our case, this means the property value will be a JSON string array. This is relevant because it determines how we can interact with the property value in our Dojo widget, i.e. our editor.

To make our Dojo widget the default editor for properties of type IList<string>, we add an editor descriptor like so:

C# Expand
[EditorDescriptorRegistration(TargetType = typeof(IList<string>))]
public class StringsEditorDescriptor : EditorDescriptor
{
    public override void ModifyMetadata(ExtendedMetadata metadata, IEnumerable<Attribute> attributes)
    {
        ClientEditingClass = "tedgustaf/editors/stringlist/Editor";

        base.ModifyMetadata(metadata, attributes);
    }
}

The Dojo editor widget

Our editor consists of a Dojo widget, with the HTML and CSS in separate files. We also use the i18n features in Dojo for multilingual support (the nls folder):

The widget code isn't too complex, although it is a bit lengthy. The key take-away is that, because of how our property is serialized, the property value (accessible through the value property of our widget) will be a JSON string array:

JavaScript Expand
define([
    "dojo/_base/declare",
    "dojo/aspect",
    "dojo/dom-construct",
    "dojo/dom-attr",
    "dojo/dom-style",
    "dojo/_base/connect",
    "dojo/_base/lang",
    "dojo/query",

    "dijit/_Widget",
    "dijit/_TemplatedMixin",
    "dijit/_WidgetsInTemplateMixin",

    "dijit/form/Button",
    "dijit/form/Select",
    "dijit/form/TextBox",

    "dojo/i18n!./nls/Labels",

    'xstyle/css!./Template.css'
],

function (
    declare,
    aspect,
    domConstruct,
    domAttr,
    domStyle,
    connect,
    lang,
    query,

    _Widget,
    _TemplatedMixin,
    _WidgetsInTemplateMixin,

    Button,
    Select,
    TextBox,

    Labels
) {
    return declare([_Widget, _TemplatedMixin, _WidgetsInTemplateMixin], {

        templateString: dojo.cache("tedgustaf.editors.stringlist", "Template.html"),

        labels: Labels,

        value: null,

        _hasSelectionFactory: false,

        constructor: function () {
            this.inherited(arguments);

            // When the property value is set, we refresh the DOM elements representing the strings in the list
            aspect.after(this, '_set', lang.hitch(this, function () {
                this._refreshStringElements(this.value);
            }));
        },
        
        postCreate: function () {
            this.inherited(arguments);

            // summary: Populates the dropdown (if selection factory options are available), otherwise the textbox is displayed

            if (this.selections && this.selections.length > 0) {
                this._hasSelectionFactory = true;
            }

            if(this._hasSelectionFactory)
            {
                for (var i = 0; i < this.selections.length; i++) {

                    var item = this.selections[i];

                    this.stringSelector.addOption({
                        disabled: false,
                        label: (item.text && item.text !== '') ? item.text : '&nbsp',
                        selected: false,
                        value: item.value
                    });
                }

                // Only display dropdown when we have a selection factory attached
                domStyle.set(this.stringTextbox.domNode, 'display', 'none');
            } else {
                // Only display textbox when there is no selection factory attached
                domStyle.set(this.stringSelector.domNode, 'display', 'none');
            }

            this.stringSelector.setDisabled(this.readOnly);
            this.stringTextbox.setDisabled(this.readOnly);
            this.addButton.setDisabled(true); // Disable add button by default, until string is selected or entered
        },       

        onChange: function (value) {
            this.inherited(arguments);

            // summary: Notifies Episerver that the property value has changed
        },

        _setValue: function () {

            // summary: Sets the property value based on the strings added

            var strings = this._getAddedStrings();

            this.set("value", strings.length > 0 ? strings : null);

            this._setHelpTextVisibility();

            this.onChange(strings);
        },

        _refreshStringElements: function (strings) {
            
            // summary: Make the list of strings match the property (widget) value

            if (strings === undefined || strings === null) {
                return;
            }

            var that = this;

            strings.forEach(function (string, index, array) {
                if (strings.indexOf(string) === -1) {
                    that._removeStringElement(string);
                }
            });

            // Add an element for each string in the list
            strings.forEach(function (string, index, array) {

                var displayName = that._getStringDisplayName(string);

                that._addStringElement(string, displayName);
            });

            this._setHelpTextVisibility();
        },

        _setHelpTextVisibility: function() {
            // summary: Determines whether the help text, indicating that the list is empty, should be displayed

            if (!this.value || this.value.length === 0) {
                domStyle.set(this.helpText, 'display', 'inline');
            } else {
                domStyle.set(this.helpText, 'display', 'none');
            }
        },

        _onTextboxKeyUp: function (e) {

            // summary: Handles when a keyboard key is pressed in the string textbox, primarily to enable/disable the "+" button (when not using a dropdown for a selection factory)

            var value = e.target.value.trim();

            this.addButton.setDisabled(value.trim() === '');
        },

        _onTextboxKeyDown: function (e) {

            // summary: Handles when a keyboard key is pressed in the string textbox, primarily to add a string when Enter is pressed (when not using a dropdown for a selection factory)

            if (e.keyCode === 13) // Enter
            {
                e.target.blur();

                this._addString(e.target.value.trim());
            }
        },

        _selectedStringChanged: function (value) {

            // summary: Handles when the selected string in the dropdown changes

            if (value) {
                this.addButton.setDisabled(false);
            }
        },

        _onRemoveClick: function (e) {

            // summary: Handles when a remove ("x") button is clicked

            // Get the string value that was clicked
            var stringValue = domAttr.get(e.srcElement, "data-value").trim();

            this._removeStringElement(stringValue);

            this._setValue();
        },

        _onAddButtonClick: function () {

            // summary: Handles when the add ("+") button is clicked

            if (this._hasSelectionFactory) { // Add string selected in dropdown
                var selectedValue = this.stringSelector.value;
                var displayName = this.stringSelector.focusNode.innerText;

                if (!selectedValue) {
                    return;
                }

                this._addString(selectedValue,displayName);
            } else { // Add string from textbox

                var enteredValue = this.stringTextbox.value;

                if (!enteredValue) {
                    return;
                }

                this._addString(enteredValue);
            }
        },

        _getStringElements: function() {
            
            // summary: Gets all DOM elements representing added strings

            return query(".epi-categoryButton", this.valuesContainer);
        },

        _getAddedStrings: function () {

            // summary: Gets the values of all DOM elements representing added strings

            var elements = this._getStringElements();

            var strings = [];

            elements.forEach(function (element, index, array) {
                strings.push(domAttr.get(element, 'data-value'));
            });

            return strings;
        },

        _addString: function (value, displayName) {

            // summary: Adds a string to the list and updates the property value

            value = value.trim();

            if (!value) {
                return;
            }

            if (!displayName) {
                displayName = value;
            }

            this._addStringElement(value, displayName);

            this.stringTextbox.set('value', ""); // Reset textbox value

            this._setValue();
        },

        _addStringElement: function (value, displayName) {

            // summary: Adds a DOM element representing a string in the list

            if (!value) {
                return;
            }

            value = value.trim();

            if (value === '') {
                return;
            }

            if (!displayName) {
                displayName = value;
            }

            // Don't add if it's already added
            if (query("div[data-value=" + value + "]", this.valuesContainer).length !== 0) {
                return;
            }

            var containerDiv = domConstruct.create('div', { 'class': 'epi-categoryButton' });
            var buttonWrapperDiv = domConstruct.create('div', { 'class': 'dijitInline epi-resourceName' });
            var categoryNameDiv = domConstruct.create('div', { 'class': 'dojoxEllipsis', innerHTML: displayName });

            domConstruct.place(categoryNameDiv, buttonWrapperDiv);

            domConstruct.place(buttonWrapperDiv, containerDiv);
          
            var removeButtonDiv = domConstruct.create('div', { 'class': 'epi-removeButton', innerHTML: '&nbsp;', title: Labels.clickToRemove });

            var eventName = removeButtonDiv.onClick ? 'onClick' : 'onclick';

            // Add attributes to make added values easy to find and remove
            domAttr.set(containerDiv, 'data-value', value);
            domAttr.set(removeButtonDiv, 'data-value', value);

            if (!this.readOnly) {
                this.connect(removeButtonDiv, eventName, lang.hitch(this, this._onRemoveClick));
                domConstruct.place(removeButtonDiv, buttonWrapperDiv);
            } else {
                domConstruct.place(domConstruct.create("span", { innerHTML: "&nbsp;" }), buttonWrapperDiv);
            }

            domConstruct.place(containerDiv, this.valuesContainer);
        },

        _removeStringElement: function (value) {

            // summary: Removes the DOM element, if any, representing a string in the list

            if (value.trim() === '') {
                return;
            }

            var matchingValues = query("div[data-value=" + value + "]", this.valuesContainer);

            for (var i = 0; i < matchingValues.length; i++) {
                domConstruct.destroy(matchingValues[i]);
            }
        },

        _getStringDisplayName: function(string) {

            // summary: Looks up a string value among the selection factory options, returning the corresponding display name if found

            if (!this._hasSelectionFactory) {
                return string;
            }

            var displayName = string;

            this.selections.some(function (selection) {
                if (selection.value === string) {
                    if (selection.text) {
                        displayName = selection.text;
                    }

                    return true; // Break
                }
            });

            return displayName;
        }
    });
});

Localized strings for the editor are maintained in the Labels.js file:

JavaScript Expand
define({
    root: {
        textboxWatermark: 'Enter a value to add',

        clickToAdd: 'Click to add',
        clickToRemove: 'Click to remove',

        helpText: 'List is currently empty.'
    }
});

Note that the Dojo namespace is based on a root namespace (pointing to the ClientResources folder) defined in module.config in the site root:

The template file consists of standard HTML, with some Dojo attributes for wiring up events:

HTML, XML Expand
<div class="dijit dijitReset dijitInline dijitLeft stringListEditor">

    <!-- Select control used if values are provided through a selection factory -->
    <select name="stringSelector" data-dojo-type="dijit/form/Select" data-dojo-props="maxHeight:'-1'" data-dojo-attach-point="stringSelector" data-dojo-attach-event="onChange:_selectedStringChanged"></select>

    <!-- Standard textbox used if no values are provided through selection factory (i.e. if it's a standard string list without predefined options)-->
    <input name="stringTextbox" type="text" data-dojo-type="dijit/form/TextBox" data-dojo-attach-point="stringTextbox" data-dojo-attach-event="onKeyUp:_onTextboxKeyUp,onKeyDown:_onTextboxKeyDown" placeholder="${labels.textboxWatermark}" />
    
    <button data-dojo-type="dijit/form/Button" data-dojo-attach-point="addButton" data-dojo-attach-event="onClick:_onAddButtonClick" title="${labels.clickToAdd}">+</button>
    <div class="epi-resourceInputContainer epi-categorySelector stringListEditor-values" data-dojo-attach-point="valuesContainer">

    </div>
    <span data-dojo-attach-point="helpText" class="help">${labels.helpText}</span>
</div>

Finally, the stylesheet contains some rudimentary styling:

CSS Expand
.stringListEditor-values {
    display: inline;
}

.stringListEditor .epi-categoryButton {
    padding: 0 5px 4px 0;
}

.stringListEditor .help {
    color: #666;
}