Creating an orderable list in Episerver

Generally, I try to stay away from anything that even has the slightest whiff of Dojo, however, there are times when exceptions must be made. Not that long ago I encountered a scenario where a lot of content on a page type was not directly editable by editors but there was still a need for them to be able to configure the order of that content individually.

Now it's probably possible to play around with PropertyList and get something that sort of works, but what we wanted — to put it in animated gif form — was the following:

A working orderable list in Episerver

This property allows the editor to order an enum via drag-and-drop and persists it in the database as a comma-separated list.

For example, if we gave it the following enum...

public enum TestOrder
{
    First,
    Second,
    Third,
    Fourth,
    Fifth
}

...and if the editor didn't change the order at all it'd be persisted in the database as: "0,1,2,3,4".

Makes sense so far? Great! Then let's go through a step-by-step of how to create it:

The attribute

First, we need an attribute we can decorate our property with to allow us to specify the enum that we want the editor to be able to sort.

This just needs to be a really simple attribute that takes in a Type in its constructor (which will be an enum).

public class SortableListAttribute : Attribute
{
    public SortableListAttribute(Type enumType)
    {
        EnumType = enumType;
    }
    public Type EnumType { get; }
}

Once we've created this we can quickly apply it to our property, which will look something like:

[Display(
    Name = "Ordered Enum",
    GroupName = SystemTabNames.Content,
    Order = 10)]
[SortableList(typeof(TestOrder))]
public virtual string OrderedEnum { get; set; }

Here we pass in the type of the TestOrder enum I've already shown above. The cool thing about this approach is its flexibility, which means that you can easily reuse it and pass in any enum you want.

Anyway, we've now completed step one and we've now got an enum 'associated' with our property, we now need to get those values down to the CMS UI.

The Episerver Editor Descriptor

In Episerver, an EditorDescriptor is used to pass information about a property and how it's edited to the editing user interface. We therefore need to create an Editor Descriptor which takes our enum, extracts the values (and names) and passes this to the client-side Dojo widget.

This sounds like it should be a lot of code, but actually turns out to be not much:

[EditorDescriptorRegistration(TargetType = typeof(string), UIHint = UIHint)]
public class SortableListDescriptor : EditorDescriptor
{
    public const string UIHint = "SortableList";

    public override void ModifyMetadata(ExtendedMetadata metadata, IEnumerable<Attribute> attributes)
    {
        ClientEditingClass = "example/SortableList";
        base.ModifyMetadata(metadata, attributes);
    }

    protected override void SetEditorConfiguration(ExtendedMetadata metadata)
    {
        // Check for our SortableListAttribute
        var sortableListAttribute = metadata.Attributes.FirstOrDefault(a => typeof(SortableListAttribute) == a.GetType()) as SortableListAttribute;

        if (sortableListAttribute != null)
        {
            Type enumType = sortableListAttribute.EnumType;

            if (!enumType.IsEnum)
            {
                throw new TypeMismatchException("Supplied type must be an enum.");
            }

            // Iterate through the enum and get values and names
            var values = enumType.GetFields(BindingFlags.Static | BindingFlags.Public).Select(fieldInfo => new {
                Name = GetTranslatedValueName(enumType, fieldInfo.Name),
                Value = (int)fieldInfo.GetValue(null)
            });

            EditorConfiguration["sortableItems"] = values;
        }

        base.SetEditorConfiguration(metadata);
    }

    private static string GetTranslatedValueName(Type type, string valueName)
    {
        string localizationPath = $"/enums/{type.Name.ToLowerInvariant()}/{valueName.ToLowerInvariant()}";

        string localizedName;

        if (LocalizationService.Current.TryGetString(localizationPath, out localizedName)) {
            return localizedName;
        }

        return valueName;
    }
}

Let me briefly describe what we're doing here:

  1. We override the ModifyMetadata method to set the ClientEditingClass to our custom Dojo widget.
  2. Override the SetEditorConfiguration method to: get the attribute we created, verify it's an enum, extract the values (translating if possible) into a simple object and, finally, pass this to the Dojo widget via "sortableItems".

Just let me take a minute of your time for the briefest of side-notes: for this to work, you'll need to set the 'example' module identifier fragment I used here ("example/SortableList") to a path in your module.config, more about that here. Mine has a line which looked like:

<add name="example" path="Scripts" />

With our Editor Descriptor created let's go back to the property we created earlier and add a UIHint:

[Display(
    Name = "Ordered Enum",
    GroupName = SystemTabNames.Content,
    Order = 10)]
[UIHint(SortableListDescriptor.UIHint)]
[SortableList(typeof(TestOrder))]
public virtual string OrderedEnum { get; set; }

We're now done with the back-end side of things, which means that there is nothing else left but...

The Dojo widget

We now need a Dojo widget to take those items and allow the editor to drag-and-drop them into the desired order.

This is where we do that and pull all of work so far together:

define([
    "dojo/dom",
    "dojo/_base/array",
    "dojo/_base/connect",
    "dojo/_base/declare",
    "dojo/dom-construct",
    "dijit/_Widget",
    "dijit/_TemplatedMixin",
    "dojo/dnd/Source",
    "dojo/dom-attr",
    "dojo/query",
    "xstyle/css!../styles/SortableList.css"],
    function (
        dom,
        array,
        connect,
        declare,
        domConstruct,
        widget,
        templatedMixin,
        source,
        domAttr,
        query) {
        return declare("example/SortableList", [widget, templatedMixin],
            {
                baseClass: "epi-content-area-editor sortable-list",
                templateString: "<div class=\"dijit dijitReset dijitInline\"> \
                                    <ul data-dojo-attach-point=\"sortableNode\"></ul> \
                                </div>",
                sortableItems: null,
                postMixInProperties: function () {
                    this.inherited(arguments);
                    if (typeof this.params.sortableItems !== "undefined")
                        this.sortableItems = this.params.sortableItems;
                },
                _isValid: function (value) {
                    // We need both a value and some items
                    if (!value || !this.sortableItems)
                        return false;

                    // The value must be a comma seperated array, same length as the items
                    var values = value.split(',');
                    if (!values || values.length !== this.sortableItems.length)
                        return false;

                    // Array must not only contain nulls
                    if (values.join().replace(/,/g, '').length === 0)
                        return false;

                    // Values must match up exactly
                    var differences = this.sortableItems.filter(function (i) { return ! ~values.indexOf(i.value.toString()); });
                    if (differences > 0)
                        return false;

                    return true;
                },
                _setup: function () {
                    // Create a source for draggable items
                    var sortableNode = new source(this.sortableNode, { creator: dojo.hitch(this, this._createSortableItem) });

                    // Insert the (enum) items
                    sortableNode.insertNodes(false, this.sortableItems);

                    // Only order correctly if we everything is valid
                    if (this._isValid(this.value)) {
                        this._setupOrder(sortableNode);
                    }

                    dojo.connect(sortableNode, "onDndDrop", this, this._onMoveItem);
                },
                _setupOrder: function (source) {
                    var values = this.value.split(',');

                    if (values.length == 0)
                        return;

                    array.forEach(values.reverse(), function (i) {
                        var sortableItems = query('[value-data|=\"' + i + '\"]', source.node);

                        if (sortableItems.length > 0) {
                            domConstruct.place(sortableItems[0], source.node, "first");
                        }
                    });
                },
                _createSortableItem: function (item, hint) {
                    var node;

                    if (hint === "avatar") {
                        // This node is used when dragging the item.
                        node = dojo.create("div", { innerHTML: item.name, "value-data": item.value });
                    } else {
                        node = dojo.create("li", { innerHTML: item.name, "value-data": item.value });
                    }

                    return {
                        "node": node,
                        "type": ["sortitem"],
                        "data": item
                    };
                },
                // Checks if the current property value is valid (invoked by EPiServer)
                isValid: function () {
                    return this._isValid(this.value);
                },
                _onMoveItem: function (source) {
                    var items = [];

                    // Create an array of the values
                    array.forEach(source.node.childNodes, function (node) {
                        items.push(domAttr.get(node, "value-data"));
                    });

                    this._set("value", items.join());
                },
                // Setter for value property (invoked by EPiServer on load)
                _setValueAttr: function (value) {
                    this._set("value", value);
                    this._setup();
                }
            });
    });

(If you're following this example and used a similar module.config configuration you'll want to place this in ClientResources/Scripts).

This handles most of the overall functionality, as such, I've added comments throughout to explain what's going on. A couple of pointers:

  1. This starts with the _setValueAttr method, which as noted, is invoked by Episerver when value is initially passed to the Dojo widget. This sets the value and also runs the _setup method.
  2. _setup converts the "sortableNode" DOM node into a dragged item source and then populates it with the enum values (_createSortableItem actually handles creating these individually). If everything looks valid it then orders that list via _setupOrder.
  3. _onMoveItem is called whenever an item is moved and creates the comma-seperated string of values (also setting it as the property value).
  4. Finally, _isValid handles basic validation, hopefully the comments there speak for themselves.

Just to finish things up we should also add a little bit of custom styling:

.sortable-list ul > li {
  border: 1px solid #b1bac0 !important;
  margin-bottom: 6px;
  padding: 3px;
}

.sortable-list ul > li.dojoDndItemAfter {
  -webkit-box-shadow: 0 2px 0 0 #769dc0;
  -moz-box-shadow: 0 2px 0 0 #769dc0;
  box-shadow: 0 2px 0 0 #769dc0;
}

.sortable-list ul > li.dojoDndItemBefore {
  -webkit-box-shadow: 0 -2px 0 0 #769dc0;
  -moz-box-shadow: 0 -2px 0 0 #769dc0;
  box-shadow: 0 -2px 0 0 #769dc0;
}

.sortable-list ul > li:last-child {
  margin-bottom: 0;
}

(Again, you'll probably want this to go in /ClientResources/Styles/SortableList.css if you're copying and pasting.)

We're done!

That's everything we need to get his working and editors should now be able to re-order lists as much as they could possibly want!

In case it wasn't clear, any string property can now be decorated with the SortableList, an enum type and necessary UIHint and it will show the editor a drag-and-droppable list of that enum's values. Well, that's it! I think this idea could definitely be improved and expanded but, as I said before, it's the potential re-usability that I like about this approach.

Comments

There are zero comments 😢