Knockout File Tree

2017-06-01 // 7 minutes

As I had mentioned in a previous post, Knockout.js is my preferred framework for front end web development. In a past job, I needed to represent a file tree for a collection of files hosted on a remote server. I researched and evaluated various options - such as JSTree, JQuery Tree, and a few others.

I was dismayed to discover that the libraries I evaluated were either missing features that I needed, had dependencies I did not want (i.e. JQuery), or were overly opinionated about how I structure my code. Before I began this effort, my Knockout viewmodel and the logical flow of my code was already set in place. As such, it would have been a non trivial amount of effort to wrap my code around how the library expected itself to be used. It occurred to me that it might take less effort to write a file tree from scratch than to try and shoehorn a library into my code.

I knew that my file tree would have the following requirements:

  1. The data I loaded from the API would return an array. I would need to restructure it as a tree in a JSON object. (I could have dug into the server side code, but that was put off as future work)
  2. I would need to either find or implement a way to make the file tree have draggable elements.
  3. The file tree events would have to easily integrate with my existing viewmodel.

Let's first take a look at a generic custom binding template. If you want to read up more on the documentation you can do so here.

ko.bindingHandlers.draggable = {
    init: function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext){
    	// element: the DOM element you attached with data-bind
    	// valueAccessor: returns the observable bound to the DOM element, use unwrap to get actual value
    	// allBindingsAccessor: allows access to values (and child values) without unwrapping observable
    	// viewModel: deprecated in Knockout 3.x, included so you know to avoid, use bindingContext.$data instead
    	// bindingContext: the context of the viewmodel for which the DOM element is bound
    },
    update: function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
    	// This is how you unwrap an observable to get at the actual value
        var unwrapped = ko.utils.unwrapObservable(valueAccessor());

        // Allows the binding to control child DOM nodes
        return { controlsDescendantBindings: true };
    }
};

Below is the definiton for my draggable event binding handler. Note that I set up the update function but didn't ultimately use it. Also note that this custom binding is just a pass through for registering event handlers. If you need a referesher on drag and drop events, W3Schools is as a good place to start.

ko.bindingHandlers.draggable = {
    init: function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext){
    	var unwrapped = ko.utils.unwrapObservable(valueAccessor());
        var pkid = unwrapped.pkid;

        ko.utils.registerEventHandler(element, 'dragstart', function (event) {
        	// Event functions are bound in the file tree view
        	// Ostensibly, you could do it without knockout, but it will make referencing the VM difficult
            unwrapped.dragStartHandler(pkid, event, element);
        });

        ko.utils.registerEventHandler(element, 'dragenter', function (event) {
            unwrapped.dragHandler(pkid, event, element);
        });

        ko.utils.registerEventHandler(element, 'dragleave', function (event) {
            unwrapped.dragleaveHandler(element);
        });

        ko.utils.registerEventHandler(element, 'dragover', function (event) {
            // Event.preventDefault necessary here to make a drop zone
            event.preventDefault();
            unwrapped.dragoverHandler(event, element);
        });

        ko.utils.registerEventHandler(element, 'drop', function (event) {
            // Event.preventDefault necessary here to make a drop zone
            event.preventDefault();
            unwrapped.dropHandler(event, element);
        });

        return { controlsDescendantBindings: true };
    },
    update: function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
        var unwrapped = ko.utils.unwrapObservable(valueAccessor());

        return { controlsDescendantBindings: true };
    }
};

And here is the binding handler for where the actual file tree happens. I tried to cut out the code that was specific to how I needed to implement my particular file tree. That code should live in the listToNodeConversion function. As a hint for your own code, you will need to write something recursive in order to navigate the file tree data structure.

Note here that I am not using init but am using the update function. This is all triggered off of editing the flat file array that I pulled from the API and set to an observable variable in my view model. I wanted this as an update and not an init function because I was building an IDE, which would provide users the capability to add a file to the tree or to rearrange the tree structure. If you're just displaying a static file tree, init would work just fine for you.

Before the previous code example, I mentioned that my draggable binding is just a pass through for the event handlers. That code lives within the applyTreeBindings function. This must be fired after you've constructed the HTML string and inserted it back into the DOM. Otherwise, you'll be binding event handlers to DOM elements that will be destroyed shortly thereafter or don't yet exist.

ko.bindingHandlers.fileTreeView = {
    init: function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
        return { controlsDescendantBindings: true };
    },
    listToNodeConversion: function(obj){
    	// Build temp string then insert HTML all at once. Remember that DOM interactions are costly.
        var tempString = "";
        if(util.isDefined(obj)){
            for(var key in obj) {
                if (obj.hasOwnProperty(key)){
                    // Define how you want to add directories to you JSON tree structure
                    // Make sure you filter for the key != files
                }
            }
            if(util.isDefined(obj.files)){
                for(var i = 0, n = obj.files.length; i < n; ++i){
                    // Define how you want to add files to you JSON tree structure
                }
            }
        }

        return tempString;
    },
    applyTreeBindings: function(flatFiles, context){
        // This fired more than once, and I never did take the time to track down why.
        _.each(flatFiles, function(flat){
            var li = document.getElementById('treeFile-' + flat.pkid());
            if(util.isDefined(li)){
                // Entire list item gets draggable event handlers applied to it
                ko.applyBindingsToNode(li, {draggable: {pkid: flat.pkid(),
                    dragStartHandler: context.$root.handleTreeDragStart,
                    dragHandler: context.$root.handleTreeDrag,
                    dragoverHandler: context.$root.handleTreeDragOver,
                    dragleaveHandler: context.$root.handleTreeDragLeave,
                    dropHandler: context.$root.handleTreeDrop}});

                // First span is for opening a file
                ko.applyBindingsToNode(li.children[0], {click: context.$root.loadFileToEdit.bind(context.$data, flat.pkid()), clickBubble:false});

                // Second span is for editing the file
                ko.applyBindingsToNode(li.children[1], {click: context.$root.editFileModal.bind(context.$data, flat), clickBubble:false});

                // Third span is for deleting the file
                ko.applyBindingsToNode(li.children[2], {click: context.$root.deleteFileModal.bind(context.$data, flat), clickBubble:false});
            }

        });

        var liControls = document.getElementsByClassName("monaco-tree-collapsable");

        Array.prototype.forEach.call(liControls, function(liControl){
            ko.applyBindingsToNode(liControl, {click: context.$root.toggleFileListControl.bind(context.$data, liControl.id), clickBubble:false});
        });
    },
    update: function(element, valueAccessor, allBindingsAccessor, viewModel, bindingContext) {
        var unwrapped = ko.utils.unwrapObservable(valueAccessor());
        var obj = unwrapped.files();
        // API call returns an array, this will be your source for building the JSON tree
        var flatFiles = unwrapped.flatFiles();

        // Set the built HTML structure (as a string) into the DOM
        document.getElementById("treeviewContainer").innerHTML = ko.bindingHandlers.fileTreeView.listToNodeConversion(obj);
        ko.bindingHandlers.fileTreeView.applyTreeBindings(flatFiles, bindingContext);
        return { controlsDescendantBindings: true };
    }
};

And there you have it. I have shown the basic flow of converted your flat array of files into a JSON tree. The draggable bindings get attached on after every insert, and since this binding is defined within my viewmodel, I can easily access all the data and functionality of my viewmodel from within the draggable code. One could abstract out the binding handler into a seperate file in order to share the functionality, but I never needed it and thus never bothered.

I found that integrating this code took notably less time than it would have to integrate the tree generator libraries I was evaluating. Moreover, building a web view for your file tree is usually directly tied to attributes in your specific data. That's a lot of unnecessary glue code to write in order to connect your data to a preexisting library.