The Story of My Life

Diary of a noob programmer

HTML Tree-View Check-List using jQuery

Some days ago my co-worker who i report to, asked me to create a table of check-list, with data that had tree (hierarchy) form. So I thought let use a tree-view component, but non of starting linked came to my liking, and meanwhile I was thinking about how it may be easy to create a Tree-View, I accept this challenge.

I start writing codes from scratch, and start it with creating a stack model using JavaScript array. After it finished, I defined required methods, like: constructor, render, clear, attach-data, and soon it got pretty big out of scope of this post.

 

Getting Started

The code is so tiresome to read, and analyze line by line, but it can be copied and used, easily. So i explain the usage:

First of all the code generate a definition that should be new-ed using JavaScript new keyword. and it’s constructor requires three parameters.

  1. First parameter used to define within which element our tree should get rendered.
  2. Second parameter define the value of name attribute of HTML input tags. This value used for times when you submit the form.
  3. Third parameter is an optional object consists of several optional fields.
    {
        keepReferenceArray - A boolean value that indicates if the tree view should generate a list of references and data, that later can be manipulated by user
        startExpanded - A boolean value that tell the renderer to expanded or collapsed tree nodes by default
        checkAllByDefault - A boolean value that tell the renderer to check or clear check boxes by default
        renderEmptyHTML - A string value, that could be used to render a text or HTML in case tree contains no node at all.
    }

Second we need to attach our data, these data can be either static or dynamic. This method required us to pass two parameters of which:

  1. First parameter is an array of root nodes of a tree like hierarchy.
  2. Second parameter is an object that describe that object to the system.
    {
        id - A string value, that define the name of ID field in each tree node which can be string or number.
        children - A string value, that define the name of children nodes field in each tree node, which is an array.
        displayContent - an string value or a function; that either define the name of used field to be shown as each node text, or could be called to compute and return a string value using all the data that each node contains
    }

Third step, is to call render function to generate and wire HTML elements using jQuery. The render function take no input argument. It create DOM internally and then attach it into your desired element.

 

Other Functionalities

After attaching data, following functionalities can be used.

  1. selectAll will check all check-boxes for you.
  2. clearSelection is to clear all check-boxes.
  3. resetSelection can be used to reset check-boxes, to their default state (default value may be set in constructor).
  4. setSelection sets a desired check-box state programmatically at run time.
  5. getSequentialData retrieve all node within the tree as a single array (keepReferenceArray need to be true for this function to work).
  6. clear remove the tree from container.

 

Sample Usage:
...    
<form>
    <div id="serviceTreeContainer"></div>
</form>
...

<script src="/js/lib/jquery-3.3.1.min.js"></script>
<script src="/js/app/util/stack.js"></script>
<script src="/js/app/treeView.js"></script>
<script>
    let treeView;
    $(document).ready(function() {
        treeView = new TreeView(
            $('#serviceTreeContainer'),
            'services',
            {
                keepReferenceArray: true,
                startExpanded: false,
                checkAllByDefault: false,
                renderEmptyHTML: '<span style="direction: ltr">There is no Service Added Yet</span>'
            }
        );

        var url = '/services/tree-data';
        $.get(url)
            .done(function(data) {
                treeView.attachData(data,
                    {
                        id: 'id',
                        children: 'childFavors',
                        displayContent: 'serviceName',
                        nameOfInputs: 'input'
                    });
            });
    });
</script>

 

The Code

From below you can copy the code.

Before continue, NOTE that jQuery is required. yet, the internal can easily to be replace with browser native code.

Here’s a Stack i create using JavaScript array, this is used as a utility and is needed within the main code.

./util/stack.js
/**
 * Provides stack functionality over an array
 * @class
 * @template T
 */
var Stack = (function () {
    /**
     * @constructor
     */
    function Stack() {
        /** @type {Array<T>>} */
        this.stack = [];
    }
    /**
     * Add item to top of the Stack
     * @param {T} item - the item to be stored in top of the stack
     */
    Stack.prototype.push = function (item) {
        this.stack.push(item);
    };
    /**
     * Remove and returns the top most item in the Stack
     * Returns either top most item in the Stack or undefined if the stack is empty
     * @return {T | undefined}
     */
    Stack.prototype.pop = function () {
        return this.stack.pop();
    };
    /**
     * Returns top most item in the Stack without removing it
     * @return {T}
     */
    Stack.prototype.peek = function () {
        return this.stack[this.stack.length - 1];
    };
    /**
     * Returns top most item in the Stack without removing it
     * @return {T}
     * @alias Stack.peek
     */
    Stack.prototype.top = function () {
        return this.peek();
    };
    /**
     * Return a value indicating if the Stack is empty or not
     * @return {boolean}
     */
    Stack.prototype.isEmpty = function () {
        return this.stack.length === 0;
    };
    /**
     * Return a value indicating if the Stack has item or not
     * @return {boolean}
     */
    Stack.prototype.hasItem = function () {
        return this.stack.length !== 0;
    };
    /**
     * Returns number of items in the Stack
     * @return {number}
     */
    Stack.prototype.count = function () {
        return this.stack.length;
    };
    Object.defineProperty(Stack.prototype, "length", {
        /**
         * Returns number of items in the Stack
         * @return {number}
         * @alias Stack.count
         */
        get: function () {
            return this.count();
        },
        enumerable: true,
        configurable: true
    });
    return Stack;
}());

This file contains the TreeView class itself, and can be instantiate and used after attaching jQuery and stack.js.

./treeView.js
/**
 * @typedef {import('./util/stack').Stack} Stack
 */

/**
 * class {TreeView}
 */
const TreeView = /** @class */ (function(){

    /**
     * @callback TreeView~RendererCallback
     * @param {*} object
     */

    /**
     * @typedef NodeConfig
     * @type {object}
     * @property {string} id
     * @property {Array} children
     * @property {string|TreeView~RendererCallback} displayContent
     */

    /**
     * @typedef TreeView~StackItem
     * @type {object}
     * @property {object} node
     * @property {object} parent
     * @property {jQuery | HTMLElement} nodeElem
     * @property {jQuery | HTMLElement} parentElem
     * @property {jQuery | HTMLElement} parentContainer
     */

    /**
     * @typedef TreeView~ReferenceArrayItem
     * @type {object}
     * @property {object} data - a reference to the each node data of the data tree (data source).
     * @property {jQuery | HTMLElement} elem
     * @property {string | number} id
     * @property {boolean} isChecked
     */

    /**
     * @typedef TreeView~Options
     * @type {object}
     * @property {boolean} keepReferenceArray
     * @property {boolean} startExpanded
     * @property {boolean} checkAllByDefault
     * @property {string} renderEmptyHTML - if provided, render a div container, used to indicate that there is no element (root) in the tree
     */

    /**
     *
     * @param {jQuery | HTMLElement} containerJQ - The container to render this component within
     * @param {string} formFieldName - The value of 'name' attributes for check boxes
     * @param {TreeView~Options} options - Store reference of each data node within an array so it can get iterated
     * @constructor
     */
    function TreeView(containerJQ, formFieldName,options) {
        if (!containerJQ || !containerJQ instanceof jQuery) {
            throw new Error("value provided for 'containerJQ' is not object of type jQuery.");
        }
        if(!formFieldName || typeof formFieldName !== "string" ) {
            throw new Error("value provided for 'formFieldName' is not an string value.")
        }

        this._baseRandomId = (Math.random()+'').substr(2);
        /** @type {Array} data in a tree based structure */
        this._refRoots = null;
        /** @type {Object.<string|number, boolean>} */
        this._checkedStates = null;
        /** @type {TreeView~Options}*/
        this._options = options;

        /** @type {jQuery | HTMLElement} */
        this._container = containerJQ;
        this._formFieldName = formFieldName;
    }

    /**
     * First thing to do is to attach Data Source
     * @param {Array<object>} refTreeNodes
     * @param {NodeConfig} nodeConfig
     */
    TreeView.prototype.attachData = function(refTreeNodes, nodeConfig) {
        validateData(refTreeNodes, nodeConfig);

        this._refRoots = refTreeNodes;
        this._checkedStates = {};

        if(!!(this._options && this._options.keepReferenceArray)) {
            /**
             * @type {TreeView~ReferenceArrayItem[]}
             * @private
             */
            this._dataArray = [];
        }
        this._nodeConfig = nodeConfig;
    };
    /** Select all items */
    TreeView.prototype.selectAll=function() {
        if (!this._checkedStates) {
            throw new Error("You did not attached any Data Source yet!");
        }
        let inputList = this._container.find(`input[id^=treeView_${this._baseRandomId}_input_]`);
        inputList.prop('checked', true);
        inputList.each(function (i, e) {
            dispatchEvent(e);
        });
    };
    /** Clear all selections */
    TreeView.prototype.clearSelection = function() {
        if (!this._checkedStates) {
            throw new Error("You did not attached any Data Source yet!");
        }
        let inputList = this._container.find(`input[id^=treeView_${this._baseRandomId}_input_]`);
        inputList.prop('checked', false);
        inputList.each(function (i, e) {
            dispatchEvent(e);
        });
    };
    /** Reset to default */
    TreeView.prototype.resetSelection = function(){
        for(let key in this._checkedStates) {
            delete this._checkedStates[key];
        }
        this._container.find(`input[id^=treeView_${this._baseRandomId}_input_]`).prop('checked', !!this._options.checkAllByDefault);
        if(this._options.keepReferenceArray) {
            for (/**@type {TreeView~ReferenceArrayItem}*/let data in this._dataArray) {
                data.isChecked = !!this._options.checkAllByDefault;
                // since it's just a reference to the actual element we do not need to do it again
                // data.elem.prop('checked', !!this._options.checkAllByDefault);
            }
        }
    };
    /**
     * @typedef TreeView~SelectionState
     * @type {object}
     * @property {number|string} id
     * @property {boolean} state
     */
    /**
     * Change selections selectively
     * @param {TreeView~SelectionState|Array<TreeView~SelectionState>} items
     */
    TreeView.prototype.setSelection = function(items) {
        if (!this._checkedStates) {
            throw new Error("You did not attached any Data Source yet!");
        }
        if (Array.isArray(items)){
            for (let item of items){
                this.setSelection(item);
            }
        } else {
            let state = !!items.state;
            this._checkedStates[items.id] = state;
            let inputElem = this._container.find(`#treeView_${this._baseRandomId}_input_${items.id}`);
            inputElem.prop('checked', state);
            dispatchEvent(inputElem.get(0));
        }
    };

    TreeView.prototype.getSequentialData = function () {
        if(!this._options || !this._options.keepReferenceArray){
            throw new Error('Cannot retrieve Sequential Array while component initialized with keepReferenceArray option of false.');
        }
        return this._dataArray;
    };

    TreeView.prototype.clear = function() {
        if(this._options && this._options.keepReferenceArray) {
            // noinspection StatementWithEmptyBodyJS
            while (this._dataArray.pop());
        }

        const crElem = this._container.get(0);
        // Remove previously loaded messages
        // noinspection StatementWithEmptyBodyJS
        while (crElem.firstChild && crElem.removeChild(crElem.firstChild)); // faster than .html() || innerHTML = ''
    };

    TreeView.prototype.render = function() {
        this.clear();

        if (!this._refRoots || !this._refRoots.length) {
            if(this._options.renderEmptyHTML) {
                this.container.append(this.renderEmpty());
            }
            return;
        }

        let stack = new Stack();

        let rootElem = this.renderEach(null,  "root");

        for (let i = 0, eachRoot = null ; eachRoot=this._refRoots[i], i<this._refRoots.length; i++)
        {
            stack.push(/**@type {TreeView~StackItem}*/{
                node: eachRoot,
                nodeElem: null, //elem is not created yet
                parent: null,
                parentElem: rootElem,
                parentContainer: rootElem
            });
        }

        while (stack.hasItem()) {
            let item = stack.pop();
            /* BEGIN: Change Here */

            let nodeType = (item.node[this._nodeConfig.children] && item.node[this._nodeConfig.children].length > 0 )? 'branch' : 'leaf';
            let elem = this.renderEach(item.node, nodeType);
            let container;
            if (nodeType==='branch') {
                container = elem.find(`ul[id^=treeView_${this._baseRandomId}_childrenContainer_]`);
            }else {
                container = null;
            }

            item.parentContainer.append(elem);

            item.nodeElem = elem;
            /* END: Change Here */
            for (let child of item.node[this._nodeConfig.children]) {
                stack.push(/**@type {TreeView~StackItem}*/{
                    node: child,
                    nodeElem: null, //elem is not created yet
                    parent: item.node,
                    parentElem: item.nodeElem,
                    parentContainer: container
                });
            }
        }

        this._container.append(rootElem);

        rootElem.parent().append(getCssStyles(this._baseRandomId));
        rootElem.attr('class', 'tree-view-'+this._baseRandomId);

        //rootElem.find('li input:checkbox').click(function () { //Simple click won't work on item that are not attached to the DOM yet
        //rootElem.on('click', 'li input:checkbox', function () {
        rootElem.on('click', `li[id^=treeView_${this._baseRandomId}_node_] > span[id^=treeView_${this._baseRandomId}_displayContent_]`, function () {
            $(this).parent().toggleClass('opened');
            $(this).parent().toggleClass('closed');
            $(this).toggleClass('caret-down');
        });
        // ADD DIRECTLY ON -BRANCH- NODE ONLY
        // rootElem.find('li').addClass('closed');
        // rootElem.find('li span').addClass('caret');
    };

    TreeView.prototype.renderEmpty = function() {
        const elem = $(TreeView.EmptyContainer);
        elem.attr('id', `treeView_${this._baseRandomId}_empty`);
        elem.html(this._options.renderEmptyHTML);
        return elem;
    };
    /**
     *
     * @param node {object | null}
     * @param {'root'|'branch'|'leaf'} nodeType
     */
    TreeView.prototype.renderEach = function(node, nodeType) {
        /** @type {string} AutoGenerated id for the TreeView */
        let id = this._baseRandomId;
        function configureRootLI(elem, node) {
            elem.attr('id', `treeView_${id}_node_${node[this._nodeConfig.id]}`);
            elem.attr('class', ((this._options && this._options.startExpanded)?'opened':'closed'));
        }
        function configureInput(elem, node) {
            let input = elem.find('#treeView_id_input_id');
            input.attr('id', `treeView_${id}_input_${node[this._nodeConfig.id]}`);
            input.attr('name', this._formFieldName);
            input.val(node[this._nodeConfig.id]);

            let isChecked = !!(this._options && this._options.checkAllByDefault);
            if (( (typeof this._checkedStates[node[this._nodeConfig.id]]) !== "undefined"
                && this._checkedStates[node[this._nodeConfig.id]] !== null
                && this._checkedStates[node[this._nodeConfig.id]] !== void(0))) {
                isChecked = !!(this._checkedStates[node[this._nodeConfig.id]]);
            }
            input.prop('checked', isChecked);

            if(!!(this._options && this._options.keepReferenceArray)) {
                let newSequentialArrayData = {
                    data: node,
                    elem: elem,
                    id: node[this._nodeConfig.id],
                    isChecked: isChecked
                };
                let self = this;
                input.get(0).addEventListener('change', function ($event) {
                    let value = $event.target.checked;
                    newSequentialArrayData.isChecked = value;
                    self._checkedStates[node[self._nodeConfig.id]] = value;
                });
                this._dataArray.push(newSequentialArrayData);
            }
        }
        function configureSpan(elem, node, isBranch) {
            let span = elem.find('#treeView_id_displayContent_id');
            span.attr('id', `treeView_${id}_displayContent_${node[this._nodeConfig.id]}`);
            if (typeof (this._nodeConfig.displayContent) === "function"){
                span.html(this._nodeConfig.displayContent(node));
            } else {
                span.text(node[this._nodeConfig.displayContent]);
            }

            if(isBranch && this._options && this._options.startExpanded) {
                span.addClass('caret-down');
            }
        }
        function configureUL(elem, node) {
            let ul = elem.find('#treeView_id_childrenContainer_id');
            ul.attr('id', `treeView_${id}_childrenContainer_${node[this._nodeConfig.id]}`);
        }

        let result;
        switch (nodeType) {
            case "root": {
                let elem = $(TreeView.RootNode);
                elem.attr('id', `treeView_${id}_rootContainer`);

                result = elem;
                break;
            }
            case "branch": {
                let elem = $(TreeView.BranchNode);

                configureRootLI.call(this, elem, node);
                configureSpan.call(this, elem, node, true);
                configureInput.call(this, elem, node);
                configureUL.call(this, elem, node);

                result = elem;
                break;
            }
            case "leaf": {
                let elem = $(TreeView.LeafNode);

                configureRootLI.call(this, elem, node);
                configureSpan.call(this, elem, node, false);
                configureInput.call(this, elem, node);

                result = elem;
                break;
            }
        }
        return result;
    };

    function getCssStyles(id) {
        return `
<style>
    /*Stack Style*/
    .tree-view-${id} li input.checkbox { /* input:checkbox is not 100% compatible */
        width: 6px;
        margin: 0 2px;
        /* This makes 10px be the total "width" ofh the checkbox */
    }
    .tree-view-${id},
    .tree-view-${id} ul {
        margin: 0 0 0 10px; /* Or whatever your total checkbox width is */
        padding: 0;
    }
    .tree-view-${id} li {
        padding: 0;
    }
 
     /*My Style*/
    .tree-view-${id},
    .tree-view-${id} ul{
        list-style-type: none;
    }
    .tree-view-${id} li{
        padding-inline-start: 15px;
    }
    
    /*Collapse Expand Style*/
    /* Style the caret/arrow */
    .tree-view-${id} .caret {
      cursor: pointer;
      user-select: none; /* Prevent text selection */
    }
    
    /* Create the caret/arrow with a unicode, and style it */
    .tree-view-${id} .caret::before {
      transition: transform .5s ease;
      content: "\\25B6";
      color: black;
      display: inline-block;
      margin-right: 6px;
      margin-left: 5px;
      font-size: 12px;
    }
    
    /* Rotate the caret/arrow icon when clicked on (using JavaScript) */
    .tree-view-${id} .caret-down::before {
      transform: rotate(90deg);
    }
    
    .tree-view-${id} li ul {
        overflow: hidden;
        transform-origin: top;
    }
    .tree-view-${id} li.opened ul {
        display: block;
    }
    .tree-view-${id} li.closed ul {
        display: none;
    }
</style>`;
    }

    function dispatchEvent(elem) {
        if ("createEvent" in document) {
            let evt = document.createEvent("HTMLEvents");
            evt.initEvent("change", false, true);
            elem.dispatchEvent(evt);
        }
        else {
            elem.fireEvent("onchange");
        }
    }

    function validateData(refTreeNodes, nodeConfig) {
        if (!refTreeNodes || !Array.isArray(refTreeNodes)) {
            throw new Error("'refTreeNodes' is not an array");
        }
        if (!nodeConfig) {
            throw new Error("'nodeConfig' is not provided, which is used to map data fields");
        }
        if (typeof nodeConfig !== "object") {
            throw new Error("'nodeConfig' is not an object");
        }
        if( typeof nodeConfig.id !== "string") {
            throw new Error("'nodeConfig' does not provided a valid 'id' field of type 'string'");
        }
        if( typeof nodeConfig.children !== "string") {
            throw new Error("'nodeConfig' does not provided a valid 'children' field of type 'string'");
        }
        if( typeof nodeConfig.displayContent !== "string" && typeof nodeConfig.displayContent !== "function") {
            throw new Error("'nodeConfig' does not provided a valid 'id' field of type 'string' or 'function'");
        }

        let stack = new Stack();

        for (let i = 0, eachRoot = null ; eachRoot=refTreeNodes[i], i<refTreeNodes.length; i++)
        {
            stack.push(eachRoot);
        }

        let idDictionary = {};
        let repeatedId='';
        while (stack.hasItem()) {
            let node = stack.pop();
            /* BEGIN: Change Here */

            idDictionary[node.id] = idDictionary[node.id]? idDictionary + 1 : 1;
            validateEachNodeData(node, nodeConfig);

            /* END: Change Here */
            for (let child of node[nodeConfig.children]) {
                stack.push(child);
            }
        }
        for (let id in idDictionary) {
            if(idDictionary[id] > 1) {
                if (repeatedId !== '') repeatedId += ', ';
                repeatedId += id;
            }
        }
        if(repeatedId.length > 0){
            throw new Error("the ID field cannot have duplicate values");
        }
    }
    function validateEachNodeData(node, nodeConfig) {
        if (!node[nodeConfig.id] || (typeof node[nodeConfig.id] !== "string" && typeof node[nodeConfig.id] !== "number")) {
            throw new Error("Invalid Data: All or some of data do not provide valid 'id' property ('" + nodeConfig.id + "' field in the model). the value should be either string or number")
        }
        if (node[nodeConfig.children] && !Array.isArray(node[nodeConfig.children])) {
            throw new Error("Invalid Data: All or some of data do not provide valid 'children' property ('" + nodeConfig.children + "' field in the model).");
        }
        let dc; //reduce function call
        if (!node[nodeConfig.displayContent]
            && ((typeof (nodeConfig.displayContent) !== "string"
                    && typeof (nodeConfig.displayContent) !== "function"
                )
                || (typeof (nodeConfig.displayContent) === "string"
                    && (typeof node[nodeConfig.displayContent] !== "string"
                        && typeof node[nodeConfig.displayContent] !== "number"
                    )
                )
                || (typeof nodeConfig.displayContent === "function"
                    && (!(dc = nodeConfig.displayContent(node))
                        || (typeof dc !== "string"
                            && typeof dc !== "number"
                        )
                    )
                )
            )
        ) {
            throw new Error("Invalid Data: All or some of data do not provide valid 'displayContent' property" +
                "(" + ((typeof (nodeConfig.displayContent) === "function") ? "data returned from provided function" : "'" + nodeConfig.displayContent + "' field in the model" + ")."));
        }
    }

    // -------------- IN THE END ---------------
    // Same as STATIC Constructor - SELF INVOKING
    TreeView.__ctor = (function() {
        TreeView.EmptyContainer = '<div id="treeView_id_empty"></div>';
        TreeView.RootNode = '<ul id="treeView_id_rootContainer"></ul>';
        TreeView.BranchNode = '' +
            '<li id="treeView_id_node_id" class="closed">\n' + // initial-state closed...
            '   <input type="checkbox" id="treeView_id_input_id" name="input"/>\n' +
            '   <span id="treeView_id_displayContent_id" class="caret">Text</span>\n' +
            '   <ul id="treeView_id_childrenContainer_id">\n' +
            '   </ul>\n' +
            '</li>';
        TreeView.LeafNode = '' +
            '<li id="treeView_id_node_id">\n' +
            '   <input type="checkbox" id="treeView_id_input_id" name="input"/>\n' +
            '   <span id="treeView_id_displayContent_id">Text</span>\n' +
            '</li>';
    })();

    return TreeView;
}());

 

If you have any improvement, feel free to share it with us.

 

Thank you for reading,
Hassan Faghihi.

Leave a Reply

Your email address will not be published. Required fields are marked *.

*
*
You may use these <abbr title="HyperText Markup Language">HTML</abbr> tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>