Anders G. Nordby

Senior Web Developer at CGI

Creating a Custom Property with a Dojo Widget

Background

In my current project, the customer wanted to be able to tag various articles and other page types with categories from their own existing subject category structure. I was originally planning to use the built-in EPiServer categories for this purpose, but it turned out not to be such a good idea after all. While the EPiServer categories exist in a strict hierarchy, the customer’s subject categories are not. The customer’s subject categories exist on two levels. Some of the second-level categories can only be used under a specific first-level category, but some second-level categories can be used under any first-level category. Any article, feature page, etc should have the ability to be tagget by one (and only one) first-level category, and any number of second-level categories useable under that first-level category. I suggested to the customer that we could create a new first-level category in EPiServer called “other”, which would contain the second-level categories that could be placed under any first-level category. While this could work, the customer was not particularly excited about the idea, and wanted something that would map more closely to the subject categories they were already used to. This was the reason for creating a custom property for this purpose.

Deciding upon an underlying representation

The first thing I need to do, is to decide upon how I want my property stored, and the most obvious choice seems to be as a string containing a JSON representation of the primary subject and it’s secondary subjects. The customer’s subject categories are actually a bit more complex (being multilingual etc), but for simplicity’s sake, let’s assume that each single subject option can be represented by something like this:

public class SubjectOption
{
    public string Value { get; set; }
    public string Text { get; set; }
}

As a subject is uniquely identified by it’s Value (which happens to be a Guid), I decide that the structure for my JSON representation of the Subject property which I’m going to make will be looking like this example:

{"MainSubject":"c2efb61b-bef6-e311-80e5-005056bf105c",
"SubSubjects":["c6469cff-a5fa-e311-80e7-005056bf105c",
"ca469cff-a5fa-e311-80e7-005056bf105c","d91bda5e-b1fa-e311-80e7-005056bf105c",
"7907e473-b1fa-e311-80e7-005056bf105c","bd772a24-aefa-e311-80e7-005056bf105c"]}

The Subjects Class

We need a C# object to contain the representation, and which also can be serialized or deserialized as described above.

using System;
using System.Linq;
using System.Web.Script.Serialization; // In System.Web.Extensions
using EPiServer.Data.Entity;

namespace SomeNameSpace
{
    public class Subjects: IReadOnly<Subjects>
    {
        private Guid _mainSubject;
        private Guid[] _subSubjects;

        private bool _isReadOnly;
        private bool _isModified;

        public Guid MainSubject
        {
            get
            {
                return _mainSubject;
            }
            set
            {
                ThrowIfReadyOnly();
                _isModified = true;
                _mainSubject = value;
            }
        }

        public Guid[] SubSubjects
        {
            get
            {
                return _subSubjects;
            }
            set
            {
                ThrowIfReadyOnly();
                _isModified = true;
                _subSubjects = value;
            }
        }

        public void MakeReadOnly()
        {
            _isReadOnly = true;
        }

        public Subjects CreateWritableClone()
        {
            var copy = new Subjects();
            copy._mainSubject = _mainSubject;
            copy._subSubjects = _subSubjects;
            copy._isReadOnly = false;

            return copy;
        }

        object IReadOnly.CreateWritableClone()
        {
            return CreateWritableClone();
        }

        [ScriptIgnore]
        public bool IsReadOnly
        {
            get
            {
                return _isReadOnly;
            }
        }

        private void ThrowIfReadyOnly()
        {
            if (IsReadOnly)
                throw new NotSupportedException(
                    "The property is read-only.");
        }

        [ScriptIgnore]
        public bool IsModified
        {
            get
            {
                return _isModified;
            }
            set
            {
                ThrowIfReadyOnly();
                _isModified = value;
            }
        }

        public Subjects Copy()
        {
            var copy = (Subjects) MemberwiseClone();
            copy._mainSubject = _mainSubject;
            copy._subSubjects = _subSubjects;
            return copy;
        }

        public override int GetHashCode()
        {
            return ToString().GetHashCode();
        }

        public override bool Equals(object obj)
        {
            var compareTo = obj as Subjects;
            if (compareTo == null)
                return false;

            return compareTo._mainSubject == _mainSubject 
                && _subSubjects.SequenceEqual(compareTo._subSubjects);
        }

        public override string ToString()
        {
            var ser = new JavaScriptSerializer();
            return ser.Serialize(this);
        }

        public static Subjects Parse(string value)
        {
            if (value == null)
                return new Subjects();

            var ser = new JavaScriptSerializer();
            return ser.Deserialize<Subjects>(value);
        }
    }
}

A Class for Storing Objects of Type Subjects in EPiServer

Next, we either need to inherit from PropertyData, or from one of the existing types that already does so. I’ll actually show you both ways to do this. First, by inheriting from PropertyLongString:

using System;
using System.Web.Script.Serialization;
using EPiServer.Core;
using EPiServer.PlugIn;

namespace SomeNameSpace
{
    [PropertyDefinitionTypePlugIn(DisplayName = "Subjects")]
    class PropertySubjects : PropertyLongString
    {
        public override Type PropertyValueType
        {
            get { return typeof(Subjects); }
        }

        public override object SaveData(PropertyDataCollection properties)
        {
            return LongString;
        }

        public override object Value
        {
            get
            {
                var value = base.Value as string;

                if (value == null)
                    return null;

                var serializer = new JavaScriptSerializer();
                return serializer.Deserialize(value, typeof(Subjects));
            }
            set
            {
                if (value is Subjects)
                {
                    var serializer = new JavaScriptSerializer();
                    base.Value = serializer.Serialize(value);
                }
                else
                {
                    base.Value = value;
                }
            }
        }

        public override IPropertyControl CreatePropertyControl()
        {
            //No support for legacy edit mode
            return null;
        }
    }
}

Second, the other way to do it, which is to inherit directly from PropertyData:

using System;
using EPiServer.Core;
using EPiServer.PlugIn;

namespace SomeNameSpace
{
    [PropertyDefinitionTypePlugIn(DisplayName = "Subjects")]
    class PropertySubjects : PropertyData
    {
        private Subjects _subjects;

        public PropertySubjects()
        { }

        public PropertySubjects(Subjects subjects)
        {
            _subjects = subjects ?? new Subjects();
        }

        public Subjects Subjects
        {
            get
            {
                return _subjects;
            }
            set
            {
                ThrowIfReadOnly();

                if (value == null)
                {
                    Clear();
                    return;
                }

                _subjects = value;
            }
        }


        protected override void SetDefaultValue()
        {
            ThrowIfReadOnly();
            _subjects = new Subjects();
        }

        public override PropertyData ParseToObject(string value)
        {
            var parsed = ParseSubjects(value);
            return new PropertySubjects(parsed);
        }

        public override void ParseToSelf(string value)
        {
            Value = ParseSubjects(value);
        }

        private Subjects ParseSubjects(string value)
        {
            return QualifyAsNullString(value) 
                ? new Subjects() 
                : Subjects.Parse(value);
        }

        public override object Value
        {
            get
            {
                return IsNull ? new Subjects() : _subjects;
            }
            set
            {
                SetPropertyValue(value, () =>
                {
                    if (value is Subjects)
                    {
                        _subjects = (Subjects)value;
                    }
                    else if (value is string)
                    {
                        ParseToSelf((string) value);
                    }
                    else
                    {
                        throw new ArgumentException("Passed object must be of 
                                type Subjects or a string that can be 
                                deserialized to a Subjects object.");
                    }
                });
            }
        }

        public override PropertyDataType Type
        {
            get { return PropertyDataType.LongString; }
        }

        public override Type PropertyValueType
        {
            get { return typeof (Subjects); }
        }

        public override object SaveData(PropertyDataCollection properties)
        {
            return _subjects.ToString();
        }

        public override bool IsNull
        {
            get
            {
                // Cannot use secondary subjects without primary subject
                return _subjects == null || _subjects.MainSubject == Guid.Empty; 
            }
        }

        public override bool IsModified
        {
            get
            {
                return base.IsModified || _subjects.IsModified;
            }
            set
            {
                ThrowIfReadOnly();
                _subjects.IsModified = value;
                base.IsModified = value;
            }
        }

        public override PropertyData CreateWritableClone()
        {
            var clone = (PropertySubjects) base.CreateWritableClone();
            clone._subjects = _subjects.CreateWritableClone();
            return clone;
        }

        public override PropertyData Copy()
        {
            var copy = (PropertySubjects) base.Copy();
            copy._subjects = _subjects.Copy();
            return copy;
        }

        public override IPropertyControl CreatePropertyControl()
        {
            //No support for legacy edit mode
            return null;
        }
    }
}

A WebAPI for Getting the Subjects

I’m not going to show this here, just assume that there exists such an API, which is fetching subjects from the database. Calling GET without any parameters will return the list of first-level subjects, while calling GET with a Guid parameter will return all the possible second-level subjects for the given Guid (or an empty list if the parameter is not parseable, null, or not a first-level subject).

The Module.config File

There was no Module.config file in my solution, so I had to create one. It should be placed in the site root folder, and look something like this:

<?xml version="1.0" encoding="utf-8" ?>
<module>
  <clientResources>
    <add name="epi-cms.widgets.base" path="Styles/Styles.css" resourceType="Style"/>
  </clientResources>
  <dojoModules>
    <add name="myApp" path="Scripts" />
  </dojoModules>
</module>

There was no folder in my solution called ClientResources, so I also had to create this. To get the Dojo widget working, the folder structure must be correct, the ClientResources folder must exist in the site root folder, and the naming must be correct. So, this is the folder structure for the Dojo widget:
widget-folder-structure

Hooking Up the Dojo Widget with the SubjectsEditorDescriptor

When the folder structure is in place (I suggest just starting out with empty files), we can create the editor descriptor that will make EPiServer hook up the Dojo widget when editing our new property:

using EPiServer.Shell.ObjectEditing.EditorDescriptors;

namespace SomeNameSpace
{
    [EditorDescriptorRegistration(TargetType = typeof(Subjects))]
    public class SubjectsEditorDescriptor : EditorDescriptor
    {
        public SubjectsEditorDescriptor()
        {
            ClientEditingClass = "myApp.editors.Subjects";
        }
    }
}

Please note that the highlighted lines in this code (and the previous code) must both match parts of the folder structure, as my exammples here shows.

The Subjects.html Template File

The easiest way to create (and maintain) a more-or-less complex Dojo (or actually Dijit) widget, is to make a separate template file for the HMTL:

<div>
    <div hidden="true" data-dojo-attach-point="trueContent"></div>
    <div>
        <h4>Primary Subject</h4>
        <select data-dojo-attach-point="mainSubject" 
                data-dojo-attach-event="onchange:_onMainSubjectChanged"></select>
    </div>
    <div>
        <h4>Secondary Subjects</h4>
        <div class="dijit dijitReset dijitInline epi-checkBoxList" 
                data-dojo-attach-point="subSubjects"></div>
    </div>
</div>

The Dojo Plugin JavaScript Code

This is quite long, but actually not that complex anyway. During development, I had to litter the code with console.log() statements to see if things were going correctly, but I’ve removed them later. Here is the widget:

define([
    "dojo/_base/declare",
    "dijit/_Widget",
    "dijit/_TemplatedMixin",
    "dojo/text!./templates/Subjects.html",

    "epi/dependency",
    "epi/epi"
], function(
    declare,
    _Widget,
    _TemplatedMixin,
    template,

    dependency,
    epi
) {
    return declare("myApp.editors.Subjects",
        [_Widget, _TemplatedMixin], {
        templateString: template,
        intermediatehanges: false,
        value: null,
        
        // Event that tells EPiServer when the widget's value has changed.
        onChange: function (value) { },

        _onChange: function (value) {
            console.log("Notifying EPiServer with onChange: " + JSON.stringify(value));
            this.onChange(value);
            console.log("Done notitying EPiServer");
        },

        postCreate: function() {
            this.inherited(arguments);
            this._populateMainSubjects(this.value);
            this._refreshSubSubjects(this.value.subSubjects);
            
            this.trueContent.innerHTML = JSON.stringify(this.value);
        },

        _populateMainSubjects: function (value) {
            var mainSubjects;
            jQuery.ajax({
                url: '/api/subjectoptions',
                type: 'GET',
                contentType: 'application/json; charset=utf-8',
                success: function(result) {
                    mainSubjects = JSON.parse(result);
                },
                async: false
            });

            var emptyOpt = this._createOption('00000000-0000-0000-0000-000000000000', '');
            this.mainSubject.appendChild(emptyOpt);

            for (var i = 0; i < mainSubjects.length; i++) {
                var opt = this._createOption(mainSubjects[i].Value, mainSubjects[i].Text);
                this.mainSubject.appendChild(opt);
            }

            if (value) {
                this.mainSubject.value = value.mainSubject;
            }
        },

        _createOption: function(value, text) {
            var opt = document.createElement('option');
            opt.value = value;
            opt.innerHTML = text;
            return opt;
        },

        _refreshSubSubjects: function (selected) {
            var subSubjects;
            var mainSubject = '00000000-0000-0000-0000-000000000000';
            if (this.mainSubject.value) {
                mainSubject = this.mainSubject.value;
            }

            jQuery.ajax({
                url: '/api/subjectoptions/' + mainSubject,
                type: 'GET',
                contentType: 'application/json; charset=utf-8',
                success: function (result) {
                    subSubjects = JSON.parse(result);
                },
                async: false
            });

            this.subSubjects.innerHTML = '';
            for (var i = 0; i < subSubjects.length; i++) {
                var checked = selected.indexOf(subSubjects[i].Value) > -1;
                var span = this._createCheckbox(subSubjects[i].Value, subSubjects[i].Text, checked);
                this.subSubjects.appendChild(span);
            }
        },

        _createCheckbox: function (value, text, checked) {
            var id = 'chk_' + value;
            var checkbox = document.createElement('input');
            checkbox.type = "checkbox";
            checkbox.value = value;
            checkbox.id = id;
            checkbox.checked = checked;

            var label = document.createElement('label');
            label.htmlFor = id;
            label.appendChild(document.createTextNode(text));

            var span = document.createElement('span');
            span.appendChild(checkbox);
            span.appendChild(label);

            this.connect(span, "onchange", this._onCheckboxChanged);

            return span;
        },

        _arrayAddOrRemove: function(array, value) {
            var index = array.indexOf(value);
            if (index > -1) {
                array.splice(index, 1);
            } else { 
                array.push(value);
            }
        },

        _onCheckboxChanged: function(event) {
            if (this.value.subSubjects) {
                var array = this.value.subSubjects;
                this._arrayAddOrRemove(array, event.target.value);
            } else {
                this.value.subSubjects = [event.target.value];
            }

            this._updateValue(false);
        },

        _onMainSubjectChanged: function () {
            this._updateValue(true);
        },

        _updateValue: function (isMainSubjectChanged) {
            if (isMainSubjectChanged) {
                this.value.mainSubject = this.mainSubject.value;
                this.value.subSubjects = [];
                this._refreshSubSubjects([]);
            }

            this.value.isModified = true;
            this._onChange(this.value); 

            this.trueContent.innerHTML = JSON.stringify(this.value);
        }
    });
});

Usage of the new Subjects Property

The property can now be placed in any page type, e.g. in the ArticlePage:

[BackingType(typeof(PropertySubjects))]
[Display(GroupName = SystemTabNames.PageHeader)]
public virtual Subjects Subjects { get; set; }

When editing a page of type ArticlePage, we now can edit the Subjects property:
editing-subjects-property

I hope this long example can get more people started with developing Dojo/Dijit widgets for EPiServer. It is actually not as complicated as it looks.

Follow

Get every new post delivered to your Inbox.