World is now on Opti ID! Learn more

Jonatan Dahl
Dec 17, 2016
  2074
(0 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

Please login to comment.
Latest blogs
Make Global Assets Site- and Language-Aware at Indexing Time

I had a support case the other day with a question around search on global assets on a multisite. This is the result of that investigation. This co...

dada | Jun 26, 2025

The remote server returned an error: (400) Bad Request – when configuring Azure Storage for an older Optimizely CMS site

How to fix a strange issue that occurred when I moved editor-uploaded files for some old Optimizely CMS 11 solutions to Azure Storage.

Tomas Hensrud Gulla | Jun 26, 2025 |

Enable Opal AI for your Optimizely products

Learn how to enable Opal AI, and meet your infinite workforce.

Tomas Hensrud Gulla | Jun 25, 2025 |

Deploying to Optimizely Frontend Hosting: A Practical Guide

Optimizely Frontend Hosting is a cloud-based solution for deploying headless frontend applications - currently supporting only Next.js projects. It...

Szymon Uryga | Jun 25, 2025

World on Opti ID

We're excited to announce that world.optimizely.com is now integrated with Opti ID! What does this mean for you? New Users:  You can now log in wit...

Patrick Lam | Jun 22, 2025

Avoid Scandinavian Letters in File Names in Optimizely CMS

Discover how Scandinavian letters in file names can break media in Optimizely CMS—and learn a simple code fix to automatically sanitize uploads for...

Henning Sjørbotten | Jun 19, 2025 |