Jonatan Dahl
Dec 17, 2016
visibility 7754
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
Architecting an Enterprise-Grade Development Pipeline in Optimizely SaaS CMS

Most enterprise teams show up to Optimizely SaaS CMS with a clear roadmap for their release pipeline: DEV → QA → Stage → Prod. Four logical...

Vipin Banka | Jul 12, 2026

Bynder DAM Connector for Optimizely SaaS CMS: Improved Metadata Property Synchronization

While working with the Bynder DAM Connector for Optimizely SaaS CMS , one of the key areas I explored was how Bynder asset metadata is synchronized...

Vipin Banka | Jul 11, 2026

Optimizely DXP: Every Supported Culture, One Searchable Page

Quick one for anyone building multi-language sites on Optimizely DXP. I put together a reference tool listing all 806 supported cultures. More...

Adnan Zameer | Jul 10, 2026 |

A day in the life of an Optimizely OMVP: London Meetup 2026

On 2nd July 2026 the Optimizely London Developer Meetup returned to The Lightwell, and the running theme across the evening was less about individu...

Graham Carr | Jul 10, 2026

Optimizely’s Summer ’26 Roadmap: The CMS Is Starting to Look Less Like a Publishing Tool and More Like Marketing Infrastructure

Optimizely’s Summer ’26 Product Roadmap event was not just a list of product updates. At least, that is not the part I found most interesting. The...

Augusto Davalos | Jul 9, 2026

Optimizely Content JS SDK v2.1.0 — What's New and Why It Matters

  v2.1.0 of the Optimizely Content JS SDK and CLI landed on July 7, 2026. This is a substantial release bringing a wave of capabilities for...

Vipin Banka | Jul 8, 2026