Don't miss out Virtual Happy Hour this Friday (April 26).

Try our conversational search powered by Generative AI!

Jonatan Dahl
Dec 17, 2016
  5798
(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!

Please login to comment.
Latest blogs
Solving the mystery of high memory usage

Sometimes, my work is easy, the problem could be resolved with one look (when I’m lucky enough to look at where it needs to be looked, just like th...

Quan Mai | Apr 22, 2024 | Syndicated blog

Search & Navigation reporting improvements

From version 16.1.0 there are some updates on the statistics pages: Add pagination to search phrase list Allows choosing a custom date range to get...

Phong | Apr 22, 2024

Optimizely and the never-ending story of the missing globe!

I've worked with Optimizely CMS for 14 years, and there are two things I'm obsessed with: Link validation and the globe that keeps disappearing on...

Tomas Hensrud Gulla | Apr 18, 2024 | Syndicated blog

Visitor Groups Usage Report For Optimizely CMS 12

This add-on offers detailed information on how visitor groups are used and how effective they are within Optimizely CMS. Editors can monitor and...

Adnan Zameer | Apr 18, 2024 | Syndicated blog

Azure AI Language – Abstractive Summarisation in Optimizely CMS

In this article, I show how the abstraction summarisation feature provided by the Azure AI Language platform, can be used within Optimizely CMS to...

Anil Patel | Apr 18, 2024 | Syndicated blog

Fix your Search & Navigation (Find) indexing job, please

Once upon a time, a colleague asked me to look into a customer database with weird spikes in database log usage. (You might start to wonder why I a...

Quan Mai | Apr 17, 2024 | Syndicated blog