A Custom Tree list control for Sitecore

The title may be a bit misleading. I haven’t made a new control for the sitecore content editor. Although, that was a consideration, the implementation of that control would have been impractical based on the requirements for the control and the time needed to make it. Instead, I opted to create the control using a custom item editor. If you’re unfamiliar with custom item editors, check out the my previous two posts in this series. Here and Here.

The use case

A custom Sitecore Forms control was made to show cascading values. The cascading values shown in this control can differ based on the country or region the form is placed on in the site. For example, the US site might show all of these, but the UK site, might only show some of these. The content authors need to create versions of categories and subcategories for each country based on a master set of configuration.

Defining the options

The author will be configuring a tree of options. In this case, I am showing you a sample configuration. This represents the master set.

Tree List Control Features

Collapsed
Expanded

Above is what the control looks like when data is populated. On the left side, we have a readonly tree that always reads from the categories defined in the Sitecore content tree. On the right side, we have the configured values for the specific country item. The country item is where the configuration for that countries tree will be. In the example below, I have the custom item editor showing when the country is selected.

The above is just the default state. When we delete items from the right tree and save the tree, the tree gets serialized to json and stored on the country item. When we display the custom item editor again, if the json for that country is empty, we show the default configuration. If it’s not, we show the specific configuration.

Empty Json on the UK item because nothing has been saved.

When the save button is selected, the tree is serialized and saved as json on the item.

Other features:

Drag and drop items in the tree to order differently than Sitecore configuration.

Delete entire categories or sub categories by selecting and clicking delete.

After deleting a few items

How all of this works

First, let’s start with the tree control itself. I did a lot of research and testing to find the best tree to use. I landed on this: http://wwwendt.de/tech/fancytree/demo/ There are a lot nice features built that I could use and a pretty good api.

Next, let’s take a look at the custom item editor. It’s pretty straightforward. The key lines to pay attention to are these. Here we defining a new tree list. The tree list constructor isn’t part of the fancy tree. It’s a class I wrote to wrap the fancy tree. You can find that code below as well.

$(function () {
            treeControl = new TreeList("readonly-tree", "editable-tree", "TreeListControl", '<%= this.currentItemId %>', '<%= this.securityToken %>' );
            treeControl.Init();
        });

The Custom Item Editor

<%@ Page Language="C#"%>
<%@ Import Namespace="System" %>
<%@ Import Namespace="Sitecore.Data.Items" %>

<script runat="server">
    protected string currentItemId = null;
    protected string securityToken = null;
</script>
<%
    this.currentItemId = Request.QueryString["id"];
    this.securityToken = new Guid(Constants.GetSetting.SecureApiToken).ToString();
%>

<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
    <title></title>
    <script src="//code.jquery.com/jquery-3.3.1.min.js"></script>
    <link href="/scripts/jquery.fancytree-2.30.2/dist/skin-win8/ui.fancytree.min.css" rel="stylesheet">
    <script src="/scripts/jquery.fancytree-2.30.2/dist/jquery.fancytree-all-deps.min.js"></script>
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/css/bootstrap.min.css" integrity="sha384-GJzZqFGwb1QTTN6wy59ffF1BuGJpLSa9DkKMp0DgiMDm4iYMj70gZWKYbI706tWS" crossorigin="anonymous">
    <script src="https://cdnjs.cloudflare.com/ajax/libs/popper.js/1.14.6/umd/popper.min.js" integrity="sha384-wHAiFfRlMFy6i5SRaxvfOCifBUQy1xHdJ/yoi7FRNXMRBu5WHdZYu1hA6ZOblgut" crossorigin="anonymous"></script>
    <script src="https://stackpath.bootstrapcdn.com/bootstrap/4.2.1/js/bootstrap.min.js" integrity="sha384-B0UglyR+jN6CkvvICOB2joaf5I4l3gm9GU6Hc1og6Ls7i6U/mkkaduKaBhlAXv9k" crossorigin="anonymous"></script>
    <script src="/Sitecore/Shell/CustomEditors/Feature.Forms/Scripts/treenode.js"></script>
    <script src="/Sitecore/Shell/CustomEditors/Feature.Forms/Scripts/treetraverser.js"></script>
    <script src="/Sitecore/Shell/CustomEditors/Feature.Forms/Scripts/treelist.js"></script>

    <!-- Initialize the tree when page is loaded -->
    <script type="text/javascript">
        var treeControl = null;

        $(function () {
            treeControl = new TreeList("readonly-tree", "editable-tree", "TreeListControl", '<%= this.currentItemId %>', '<%= this.securityToken %>' );
            treeControl.Init();
        });

    </script>
</head>
<body>
    <div class="container" id="TreeListControl">
        <div class="row">
            <div class="col">
                <div style="height:200px">
                    <h2>Default Values Tree</h2>
                    <h4>These are the default values that having been created in the category folder.</h4>
                </div>
                <div id="readonly-tree"></div>
                <button class="button-move">Move Selected >></button><br />
            </div>
            <div class="col">
                <div style="height:200px;">
                    <h2>Category Values Tree</h2>
                    <h4>These values are stored as json in the categories json field of this item.</h6>
                </div>
                <div id="editable-tree"></div>
                <button class="button-delete">Delete Selected</button><br />
                <button class="button-save">Save Tree</button><br />
            </div>
        </div>
    </div>

</body>
</html>

TreeList.js

This code is responsible for managing the 2 tree list controls, including moving items, deleting, and saving.

var TreeList = function (readOnlyDomId, editableDomId, containerId, itemId, token) {
    this.ReadOnlyTreeId = readOnlyDomId;
    this.EditableTreeId = editableDomId;
    this.ItemId = itemId;
    this.Token = token;
    this.ReadOnlyTreeEl = $("#" + readOnlyDomId);
    this.EditableTreeEl = $("#" + editableDomId);
    this.ReadOnlyTree = null;
    this.EditableTree = null;
    this.EditableTreeData = null;
    this.Container = document.getElementById(containerId);
    this.DeleteButton = this.Container.querySelector('.button-delete');
    this.SaveButton = this.Container.querySelector('.button-save');
    this.ResetButton = this.Container.querySelector('.button-reset');
    this.RevertButton = this.Container.querySelector('.button-revert');
    this.MoveButton = this.Container.querySelector('.button-move');
}

TreeList.prototype.Init = function () {
    var _this = this;

    this.InitReadyOnlyTree();
    this.InitEditableTree();

    this.DeleteButton.addEventListener('click', function () {
        _this.Delete();
    });

    this.SaveButton.addEventListener('click', function () {
        _this.Save();
    });

    this.ResetButton.addEventListener('click', function () {
        _this.Reset();
    });

    this.RevertButton.addEventListener('click', function () {
        _this.Revert();
    });

    this.MoveButton.addEventListener('click', function () {
        _this.Move();
    });
}

TreeList.prototype.InitReadyOnlyTree = function () {
    var _this = this;

    this.ReadOnlyTreeEl.fancytree({
        checkbox: true,
        source: {
            url: "/api/Feature/Forms/CategoryConfiguration/GetDefaultCategories",
            cache: false
        },
        init: function () {
            _this.ReadOnlyTreeOnInit();
        }
    });
}

TreeList.prototype.InitEditableTree = function () {
    var _this = this;

    this.EditableTreeEl.fancytree({
        checkbox: true,
        extensions: ["dnd5"],
        dnd5: {
            preventVoidMoves: true,
            preventRecursiveMoves: true,
            autoExpandMS: 400,
            dragStart: function (node, data) {
                return true;
            },
            dragEnter: function (node, data) {
                return true;
            },
            dragDrop: function (node, data) {
                data.otherNode.moveTo(node, data.hitMode);
            }
        },
        source: {
            url: "/api/Feature/Forms/CategoryConfiguration/GetConfiguredCategories?itemId=" + _this.ItemId,
            cache: false
        },
        init: function () {
            _this.EditableTreeOnInit();
        }
    });
}

TreeList.prototype.ReadOnlyTreeOnInit = function () {
    this.ReadOnlyTree = this.ReadOnlyTreeEl.fancytree("getTree")
}

TreeList.prototype.EditableTreeOnInit = function () {
    this.EditableTree = this.EditableTreeEl.fancytree("getTree");
    this.EditableTreeData = this.EditableTree.toDict();
}

TreeList.prototype.Save = function () {
    //this.EditableTreeData = this.EditableTree.toDict();
    var treeJson = this.Serialize(this.EditableTree);
    var _this = this;

    $.ajax({
        type: "POST",
        url: '/universalapi/Feature/Forms/CategoryConfiguration/SaveCategories',
        data: { json: treeJson, token: _this.Token, itemId: _this.ItemId },
        dataType: "json"
    }).fail(function () {
        _this.ShowUpdateFail();
    }).done(function () {
        _this.ShowUpdateSuccess();
    });
}

TreeList.prototype.ShowUpdateSuccess = function () {
    alert("Updated Successfully");
}

TreeList.prototype.ShowUpdateFail = function () {
    alert("Update Failed");
}

TreeList.prototype.Delete = function () {
    var selectedNodes = this.EditableTree.getSelectedNodes();
    selectedNodes.forEach(function (node) {
        try {
            node.remove();
        }
        catch{
        }
    });
}

TreeList.prototype.Revert = function () {
    var readOnlyData = this.ReadOnlyTree.toDict();
    this.EditableTree.reload(readOnlyData);
    this.EditableTreeData = readOnlyData;
}

TreeList.prototype.Reset = function () {
    this.EditableTree.reload(this.EditableTreeData);
}

TreeList.prototype.Move = function () {
    var editableRoot = this.EditableTree.getRootNode();
    if (editableRoot == null) {
        alert("There is no tree root.  Please reset your data");
        return;
    }

    var selectedNodes = this.ReadOnlyTree.getSelectedNodes();
    var parentNodes = [];

    //get a list of all the possible parents we might need to make
    for (var i = 0; i < selectedNodes.length; i++) {
        var node = selectedNodes[i];
        node.visitParents(function (n) {
            parentNodes.push(n);
        });
    }

    //the root id is not keeping the guid, so we have to check the "title" field to see if we are on the root
    //iterate throught each parent node
    for (var i = parentNodes.length - 1; i >= 0; i--) {
        //check if the parent node is the root and skip it.  we are already checking above to see if the editable root exists.  
        //If not, we don't have a tree to work with.
        if (parentNodes[i].title != "root") {
            //get the parent node from the editable tree
            var editableNode = this.EditableTree.getNodeByKey(parentNodes[i].key);
            //check to see if we have a valid node
            if (editableNode == null) {
                //if we don't have the parent node check to see if the parent's parent is the root
                if (parentNodes[i].parent.title == "root") {
                    //if it is the root, add the node as a child of the editable root
                    this.AddNode(editableRoot, parentNodes[i]);
                    //editableRoot.addNode(parentNodes[i].toDict());
                }
                else {
                    //if it is not, add the node to the equivalent parent
                    var pn = this.EditableTree.getNodeByKey(parent.key);
                    this.AddNode(pn, parentNodes[i]);
                }
            }
        }
    }

    //now that we have all the parents in place lets add the actual nodes
    for (var i = 0; i < selectedNodes.length; i++) {
        var node = selectedNodes[i];
        if (node.parent.title == "root") {
            //editableRoot.addNode(node.toDict());
            this.AddNode(editableRoot, node);
        }
        else {
            var pn = this.EditableTree.getNodeByKey(node.parent.key);
            this.AddNode(pn, node);
        }
    }

    console.log(parentNodes);
}

TreeList.prototype.AddNode = function (parent, child) {
    var node = this.EditableTree.getNodeByKey(child.key);
    if (node == null) {
        parent.addNode(child.toDict()).setSelected(false);
    }
}

TreeList.prototype.Serialize = function(tree) {
    var treeTraverser = new TreeTraverser(tree);
    var treeNode = treeTraverser.TraverseTree();
    var json = JSON.stringify(treeNode);
    return json;
}

TreeNode.js

var TreeNode = function() {
    this.key = "";
    this.title = "";
    this.children = [];
}

TreeTraverser.js

var TreeTraverser = function (tree) {
    this.RootItemID = null;
    this.Tree = tree;
}

TreeTraverser.prototype.TraverseTree = function () {

    var fancyTreeRootNode = this.Tree.getRootNode();
    var treeRoot = new TreeNode();
    treeRoot.key = fancyTreeRootNode.key;
    treeRoot.title = fancyTreeRootNode.title;

    for (var i = 0; i < fancyTreeRootNode.children.length; i++) {
        this.TraverseChildren(fancyTreeRootNode.children[i], treeRoot)
    }

    return treeRoot;
}

TreeTraverser.prototype.TraverseChildren = function (fancyTreeChildItem, parentNode) {
    if (fancyTreeChildItem != null && parentNode != null) {
        var childNode = new TreeNode();
        childNode.key = fancyTreeChildItem.key;
        childNode.title = fancyTreeChildItem.title;
        parentNode.children.push(childNode);

        if (fancyTreeChildItem.children != null) {
            for (var i = 0; i < fancyTreeChildItem.children.length; i++) {
                var fancyTreeItem = fancyTreeChildItem.children[i];
                this.TraverseChildren(fancyTreeItem, childNode);
            }
        }

        return childNode;
    }

    return null;
}

That’s all that’s required on the client side, now let’s take a look at the server side.

The api controller

If you notice in the javascript above, we are making ajax calls into an api. The api is relatively simple actually. A lot of the work is done client side, so our c# needs to only support a simple read and write.

using System;
using System.Security;
using System.Collections.Generic;
using System.Linq;
using System.Web.Mvc;
using System.Web.Helpers;
using Newtonsoft.Json;
using Glass.Mapper.Sc;
using Glass.Mapper.Sc.Web.Mvc;
using Sitecore;
using Sitecore.Data;
using Sitecore.SecurityModel;

namespace Feature.Forms.Controllers.FormConfiguration
{
    public class CategoryConfigurationController : Controller
    {
        private readonly SitecoreService _service;
        
        public CategoryConfigurationController()
        {
            _service = new SitecoreService(Context.Database.Name);
        }

        public ActionResult GetDefaultCategories()
        {
            ID categoryFolderID = new ID(Constants.CategoriesFolder.Id);
            TreeTraverser treeTraverser = new TreeTraverser(categoryFolderID);
            var tree = treeTraverser.TraverseTree();
            return new JsonNetResult(tree, Formatting.Indented);
        }

        public ActionResult GetConfiguredCategories(Guid itemId)
        {
            var configItem = _service.GetItem<IRegion>(itemId);
            if (configItem != null & !string.IsNullOrEmpty(configItem.CategoriesJson))
            {
                var tree = TreeTraverser.DeserializeJson(configItem.CategoriesJson);
                if (tree != null && !string.IsNullOrEmpty(tree.Key) && !string.IsNullOrEmpty(tree.Title))
                {
                    return new JsonNetResult(tree, Formatting.Indented);
                }
            }
            
            //return the defaults as a fallback if there is invalid or no data in the configured field.
            return GetDefaultCategories();
        }

        public ActionResult SaveCategories(string json, Guid itemId,  Guid token)
        {
            if (!TokenValidator.Validate(token))
            {
                throw new SecurityException("Invalid Security API Token.");
            }

            var isSuccessful = this.SaveCategoriesToItem(json, itemId);

            return new JsonNetResult(isSuccessful, Formatting.Indented);
        }

        private bool SaveCategoriesToItem(string json, Guid itemId)
        {
            var configItem = _service.GetItem<ICategoryBase>(itemId);
            configItem.CategoriesJson = json;

            try
            {
                if (configItem == null) return false;

                using (new SecurityDisabler())
                {
                    this._service.Save(configItem);
                }
            }
            catch (Exception exception)
            {
                //Sitecore.Diagnostics.Log.Error(exception.Message, exception, this);
                return false;
            }

            return true;
        }
    }   
}

TreeTraverser.cs

using System.Linq;
using Newtonsoft.Json;
using Sitecore.Data;
using Sitecore.Data.Items;

namespace Feature.Forms.Models.TreeTraversal
{
    public class TreeTraverser
    {
        public ID RootItemID { get; set; }

        public TreeTraverser(ID rootItemID)
        {
            this.RootItemID = rootItemID;
        }

        public TreeNode TraverseTree()
        {
            var rootItem = Sitecore.Context.Database.GetItem(this.RootItemID);

            if (rootItem != null)
            {
                TreeNode treeRoot = new TreeNode();
                treeRoot.Key = rootItem.ID.ToString();
                treeRoot.Title = "root";

                foreach (var childItem in rootItem.Children.ToList())
                {
                    TraverseChildren(childItem, treeRoot);
                }

                return treeRoot;
            }

            return null;
        }

        private TreeNode TraverseChildren(Item childItem, TreeNode parentNode)
        {
            if(childItem != null && parentNode != null)
            {
                 TreeNode childNode = new TreeNode();
                 childNode.Key = childItem.ID.ToString();
                 childNode.Title = childItem.Name;
                 parentNode.Children.Add(childNode);

                 foreach (var item in childItem.Children.ToList())
                 {
                     TraverseChildren(item, childNode);
                 }

                 return childNode;
            }

            return null;
        }

        public static TreeNode DeserializeJson(string json)
        {
            TreeNode deserializedTreeNode = JsonConvert.DeserializeObject<TreeNode>(json);
            return deserializedTreeNode;
        }
    }
}

About Phil Paris

Hi, my name is Phil Paris and I’m a Sitecore Architect and general Sitecore enthusiast. I’ve been working with Sitecore since 2013. Through this blog I will be sharing Sitecore insights, tips and tricks, best practices and general knowledge with the hopes to further the community at large. Please feel free to reach out to me at any time!

View all posts by Phil Paris →