Jonatan Dahl
Dec 17, 2016
visibility 7983
star star star star star star
(6 votes)

Checkbox tree Dojo widget

Hi,

Due to a customer request, to display selectable hierarchical data in an editor, I finally wrote this post.
Unfortunately there are no editors for selectable tree data (except for the category picker which requires a category list property) in Episerver.

More specifically the request was to be able to select multiple units from an organization tree, located in a separate database.
So below is my solution, a checkbox tree dojo editor for hierarchical data...

Since I can´t show their organization tree, below is an example for namespaces:

Image Untitled.png

Editor descriptor

[EditorDescriptorRegistration(TargetType = typeof(IEnumerable<int>), UIHint = Global.UIHint.CheckBoxTreeEditor)]
public class CheckBoxTreeEditorDescriptor : EditorDescriptor
{
    public override void ModifyMetadata(ExtendedMetadata metadata, IEnumerable<Attribute> attributes)
    {
        ClientEditingClass = "mysite/editors/CheckBoxTreeEditor/checkBoxTreeEditor";
        base.ModifyMetadata(metadata, attributes);
    }
}

Selection factory

The selection factory is responsible for feeding data to the dojo widget. Since the SelectItem class isn´t sufficient we create a new class called TreeNodeSelectItem.
The HasChildren property is implemented to be able to tell of a node has any children. This is optional but you will get a plus sign icon in front of each unexpanded node unless you supply a way to determine if it has any children.

public class TreeNodeSelectItem : SelectItem
{
    public int Id { get; set; }
    public int? ParentId { get; set; }
    public bool HasChildren { get; set; }
}

Below you see an example of a selection factory for an organizational unit tree.
The dijit tree can only have one root node and since the unit tree data used in this example has several root nodes
we have to create a dummy root node and attach the unit data root nodes to it.
For this example the HasChildren property is set using a linq query against the retrieved units.

public class UnitTreeSelectionFactory : ISelectionFactory
{
    public IEnumerable<ISelectItem> GetSelections(ExtendedMetadata metadata)
    {
        var unitService = ServiceLocator.Current.GetInstance<IUnitService>();
        var items = new List<TreeNodeSelectItem>();
        var units = unitService.GetAll();

        // Dummy root
        items.Add(new TreeNodeSelectItem
        {
            Id = -1,
            HasChildren = units.Any(),
            Value = -1
        });

        foreach (var unit in units)
        {
             var item = new TreeNodeSelectItem
             {
                 Id = unit.Id,
                 ParentId = unit.ParentId != null ? unit.ParentId : items[0].Id,
                 Text = unit.Name,
                 Value = unit.Id,
                 HasChildren = units.Exists(x => x.ParentId == unit.Id)
             };

             items.Add(item);
        }

        return items;
    }
}

EPiServer property

For storing the unit ids I use a PropertyDefinitionTypePlugin called PropertyIntegerList that stores the ids (IEnumerable<int>) as a string, based on this solution:
http://www.patrickvankleef.com/2015/02/03/episerver-custom-property-in-dojo/

 The PropertyList<T> might be a cleaner solution but it is still in beta.

[Display(Name = "Enhet")
[UIHint(Global.UIHint.CheckBoxTreeEditor)]
[ClientEditor(SelectionFactoryType = typeof(UnitTreeSelectionFactory))]
[BackingType(typeof(PropertyIntegerList))]
public virtual IEnumerable<int> UnitIds { get; set; }

Dojo widget template

checkBoxTreeEditor.html

<div class="dijitInline checkbox-tree-editor-widget">
    <div data-dojo-attach-point="container" id="container">
    </div>
</div>

Dojo widget CSS

checkBoxTreeEditor.css

.checkbox-tree-editor-widget {
    width: 580px;
}
.checkbox-tree-editor-widget .dijitTreeContent img {
    display: none;
}
.checkbox-tree-editor-widget .dijitTreeLabel .tree-checkbox {
    margin: 0 7px 0 5px;
}

Dojo widget

Below is the empty shell of the dojo widget, the methods are described further down

define([
    "dojo/_base/declare",
    "dojo/aspect",
    "dojo/_base/array",
    "dojo/_base/connect",
    "dojo/_base/lang",
    "dijit/registry",
    "dojo/query",
    "dojo/store/Memory",
    "dijit/tree/ObjectStoreModel",
    "dijit/Tree",
    "dijit/_Widget",
    "dijit/_TemplatedMixin",
    "dijit/form/CheckBox",
    "dojo/text!./checkBoxTreeEditor.html",
    'xstyle/css!./checkBoxTreeEditor.css'
], function (
    declare,
    aspect,
    array,
    connect,
    lang,
    registry,
    query,
    Memory,
    ObjectStoreModel,
    Tree,
    _Widget,
    _TemplatedMixin,
    CheckBox,
    template
) {
    return declare("mysite/editors/CheckBoxTreeEditor/checkBoxTreeEditor", [
        _Widget,
        _TemplatedMixin
    ],
        {
            templateString: template,
            value: null,
            postCreate: function () {
                this.inherited(arguments);
                var main = this;
            },
            _refreshDescendantCount: function (container) {
            },
            _getDescendantsCheckCountByItemId: function (itemId) {
            },
            _updateValue: function (value, checked) {
            },
            _setValueAttr: function (value) {
            },
            _refreshTree: function (container) {
            },
            _valueIsSet: function () {
            },
            _valueHasItems: function () {
            }
        });
    });

postCreate:

So to be able to view hierarchical data we use the dijit.Tree. As described in the documentation is consists of the following components: Data store -> Model -> Tree

Data store:

Since the depth of the tree I´m using isn´t that deep I use a Memory store which means that I load all the data at once. It´s possible to lazy load the tree by using another type of store such as “dojo/store/JsonRest”.

var store = new Memory({
     data: this.selections,
     idProperty: "id",
     getChildren: function (object) {
         return this.query({ parentId: object.id });
     }
});

I set the store data to “this.selections” which is the TreeNodeSelectItems supplied by the SelectionFactory. The “idProperty” specifies which property of the TreeNodeSelectItem that represents the item id. The “getChildren” method is responsible for returning the children of an item.

Model:

var model = new ObjectStoreModel({
    store: store,
    query: { id: -1 },
    labelAttr: "text",
    mayHaveChildren: function (item) {
         return item.hasChildren;
    }
});

We create the model and specify our memory store.
query: Specifies how to find the root node (which in this case is id=-1).
labelAttr: Property that holds the text to display
mayHaveChildren: If this is not specified all nodes will initially be prefixed with a “expand-icon” regardless of whether they have children or not.

Tree:

var tree = new Tree({
    model: model,
    persist: false,
    showRoot: false,
    _createTreeNode: function (args) {
        var tnode = new Tree._TreeNode(args);
        tnode.labelNode.innerHTML = args.label;
               
        var cb = new CheckBox({
            value: args.item.value,
            class: "tree-checkbox"
        });
        
        cb.placeAt(tnode.labelNode, "first");

        connect.connect(cb, "onClick", function () {
            var treeNode = dijit.getEnclosingWidget(this.domNode.parentNode);
            main._updateValue(treeNode.item.value, this.checked);
        });
         
        return tnode;
    },
    onClick: function (item, node, event) {
        if (event.target.type != 'checkbox')
            this._onExpandoClick({ node: node });
    },
    onOpen: function (item, node, event) {
        main._refreshTree(node.containerNode);
        node.labelNode.childNodes[1].nodeValue = node.item.text;
        main._refreshDescendantCount(node.containerNode);
    },
    onClose: function (item, node, event) {
        node.labelNode.childNodes[1].nodeValue = node.item.text;

        if (main._valueHasItems()) {
            var count = main._getDescendantsCheckCountByItemId(node.item.id);

            if (count > 0)
                node.labelNode.childNodes[1].nodeValue += " (" + count + ")";
        }
    }
});

this.container.appendChild(tree.domNode);
tree.startup();

We create the tree and specify the model.
_createTreeNode: Adds a CheckBox to each tree node and creates an onClick event for each CheckBox.
onClick: Node click event, open/close the node unless the actual checkbox is clicked
onOpen: Node open event
onClose: Node close event

Finally we add the tree to our container.

_refreshDescendantCount:

Refreshes descendant count for all visible leaf nodes

_refreshDescendantCount: function (container) {
    var main = this;

    if (this._valueHasItems()) {
        dojo.query(".dijitTreeNode", container).forEach(function (node) {
            var nodeItem = dijit.getEnclosingWidget(node);

            if (nodeItem.labelNode.childNodes[1] != null)
                nodeItem.labelNode.childNodes[1].nodeValue = nodeItem.item.text;

            if (nodeItem.isExpandable && !nodeItem.isExpanded) {
                var count = main._getDescendantsCheckCountByItemId(nodeItem.item.id);
    
                if (count > 0)
                    nodeItem.labelNode.childNodes[1].nodeValue += " (" + count + ")";
            }
        });
    }
}

_getDescendantsCheckCountByItemId:

Recursive function that returns descendant check count for a specific item

_getDescendantsCheckCountByItemId: function (itemId) {
    var count = 0;

    var children = this.selections.filter(function (x) {
        return x.parentId == itemId;
    });

    for (var i = 0, len = children.length; i < len; i++) {
        if (this.value.indexOf(children[i].id) != -1)
            count++;

        count += this._getDescendantsCheckCountByItemId(children[i].id);
    }

    return count;
}

_updateValue:

Adds or removes a value from the value property

_updateValue: function (value, checked) {
    var values = this.value;

    if (values == null)
        values = [];

    if (checked == true) {
        if (values.indexOf(value) == -1)
            values.push(value);
    }
    else {
        var index = values.indexOf(value);

        if (index > -1)
            values.splice(index, 1);
    }

    if (values.length == 0)
        values = null;

    // Sets value and notifies watchers
    this._set('value', values);

    // Notifies EPiServer that the property value has changed
    this.onChange(values);
}

_setValueAttr:

Property setter for the value variable, only called initially and if value is not null

_setValueAttr: function (value) {
    var main = this;
    var values = value;

    if (!lang.isArray(values)) {
        values = null;
    }
    else {
        // Removes values not matching a select item
        values = array.filter(values, function (currentValue) {
        var matchingSelectItems = array.filter(main.selections, function (selectItem) {
            return selectItem.value == currentValue;
        });

        return matchingSelectItems.length > 0;
    });

    if (values.length == 0)
        values = null;
    }

    // Sets value and notifies watchers
    this._set('value', values);

    this._refreshTree(this.container);
    this._refreshDescendantCount(this.container);
}

_refreshTree:

Checks or unchecks visible checkboxes under specified container

_refreshTree: function (container) {
    var main = this;

    if (this._valueIsSet()) {
        dojo.query(".tree-checkbox", container).forEach(function (node) {
            var checkbox = dijit.getEnclosingWidget(node);

            if (array.indexOf(main.value, checkbox.value) != -1) {
                checkbox.set('checked', true);
            }
            else {
                checkbox.set('checked', false);
            }
        });
    }
}

_valueIsSet:

_valueIsSet: function() {
    return (this.value != null);
}

_valueHasItems:

_valueHasItems: function() {
    return (this._valueIsSet() && this.value.length > 0);
}
Dec 17, 2016

Comments

Dec 20, 2016 12:53 PM

Nice! Thanks for sharing!

error Please login to comment.
Latest blogs
Surviving a Dirty Feed. Part 3: Error Handling and Event Batching

Committing a 250,000-row Optimizely catalog import row by row, returning a per-row failure report, and coalescing catalog events into one per batch.

Stanisław Szołkowski | Oct 16, 2026 |

Building UI Extensions for Optimizely SaaS CMS

Introduction Developers have been extending the functionality of Optimizely PaaS CMS for years using addons built in C# and deployed as NuGet...

Mark Stott | Oct 15, 2026

Cheapskate AI Translator: Because Translation Should Not Require a Second Mortgage

There is a particular kind of software project that begins with a detailed architecture document, a product roadmap, and a carefully assembled...

Linh Doan Cuu | Oct 15, 2026

Computed Fields in CMS 13 Content Contracts with Optimizely Graph

Content Contracts in Optimizely CMS 13 allow different content types to share a common structure when querying Optimizely Graph. But what if the...

Mardy | Oct 14, 2026