Pages

Tuesday, January 21, 2014

Dynamic draggable tree in mvc with database backup

Not so far ago I asked on stackoverflow question about updating tree model from the mvc view. Unfortunately (or not?) no one helped me with that and I have to deal with this problem by myself. The most helpful article during that task was "Model binding to a list" by Phil Haack.

So now I want to describe my solution in details - it might be useful for someone...

My main goal was to read flat collection of nodes from the database and then transform it into tree-like structure in the MVC view. For the first half. The second one is the backward operation...

So let`s split problem into parts :)
1) Database access. I`m using NHibernate and here is my entity:
public class Bookmark : Entity
{
    public virtual string Href { get; set; }

    public virtual bool IsFolder { get; set; }

    public virtual Guid? ParentId { get; set; }

    private IList<Bookmark> _children = new List<Bookmark>();

    public virtual IList<Bookmark> Children

    {
        get { return _children; }
    }

    public virtual string Label { get; set; }
}

Mapping (fluent) is simple, the only thing about it:
HasMany(x => x.Children)
    .Access.CamelCaseField(Prefix.Underscore)
    .KeyColumn("ParentId")
    .Cascade.All();

Solved :)

2) Displaying that tree. As the first stage I`ve ended up with Editor template (as in question) but later I`ve moved to knockout.js template because of flexibility. 

<script type="text/html" id="bookmark-template">
<li draggable="true" data-bind="css: {draggableNode: true, 'folder': IsFolder}" ondrag="drag(event)" onclick="toggleFolder(event)">
<a class="bookmarkLink" data-bind="attr: {href: Href} , text: Label, visible: !IsFolder() && !EditableLabel()" target="_parent"/>
<a data-bind="attr: {href: Href} , visible: !IsFolder() && !EditableLabel()" target="_blank"><img src="/media/bookmarks/Arrow-turn-right-icon.png" style="width: 10px; height: 10px" /></a>
<span class="bookmarkLink" data-bind="text: Label, visible: IsFolder() && !EditableLabel()"/>
@if (Model.IsEditable)
{
  @: <input type="hidden" class="prefix" data-bind="value: dropPrefixValue"/>
  @: <input type="hidden" class="prefixIndex" data-bind="value: Id, attr: {name: prefixIndex}"/>

  @: <input type="hidden" class="prefixId"       data-bind="value: Id, attr: { name: prefixId }" />
  @: <input type="hidden" class="prefixParentId" data-bind="value: ParentId, attr: {name: prefixParentId}"  />
  @: <input type="hidden" class="prefixIsFolder" data-bind="value: IsFolder, attr: {name: prefixIsFolder}" />

  @: <input type="text"   class="prefixLabel" required placeholder="Put link label here" oninput="validateLength(this, 150);"
    @: data-bind="visible: EditableLabel, attr: {name: prefixLabel} , value: Label" />
  @: <input type="text"   class="prefixHref" placeholder="Put link address here" oninput="validateLength(this, 500);"
    @: data-bind="visible: Editable, attr: {name: prefixHref} , value: Href" />

  @: <a data-bind="click: ChangeEditable">edit</a>
  @: <a onclick="deleteRow(event)">delete</a>
}

<ul>
<!-- ko template: { name: 'bookmark-template', foreach: Children } -->
<!-- /ko -->
</ul>
</li>
</script>

Yeah, it looks quite ugly because of mixing razor syntax with java but thats life :)

Knockout model (I`m sorting labels on the client side...):

<script>

  function sortBookmarks(a, b) {
                if (a.Label < b.Label) return -1;
                if (a.Label > b.Label) return 1;
                return 0;
            }

        var CurrentBookmarksModel = null;
        function BookmarksModel(model) {
            var self = this;
            self.bookmarks = ko.observableArray(ko.utils.arrayMap(model.sort(sortBookmarks), function(bookmark) {
                var currentprefix = "[" + bookmark.Id + "].";
                var b = new Bookmark(
                    bookmark.Id,
                    bookmark.ParentId,
                    bookmark.Href,
                    bookmark.IsFolder,
                    bookmark.Label,
                    bookmark.Children,
                    currentprefix + "Children", //dropPrefix
                    "Index",
                    currentprefix + "Id",
                    currentprefix + "ParentId",
                    currentprefix + "IsFolder",
                    currentprefix + "Href",
                    currentprefix + "Label"
                );
                return b;
            }));

            self.add = function(isFolder) {
                var newId = Math.random();
                var currentprefix = "[" + newId + "].";
                var newLabel = isFolder ? 'new folder' : 'new leaf';
                self.bookmarks.push(new Bookmark(newId, null, null, isFolder, newLabel, null,
                    currentprefix + "Children", //dropPrefix
                    "Index",
                    currentprefix + "Id",
                    currentprefix + "ParentId",
                    currentprefix + "IsFolder",
                    currentprefix + "Href",
                    currentprefix + "Label"
                ));
            };

            self.addFolder = function() {
                self.add(true);
            };
            self.addLeaf = function() {
                self.add(false);
            };
        };

        function Bookmark(id, parentId, href, isFolder, label, children,
            dropPrefixValue, prefixIndex, prefixId, prefixParentId, prefixIsFolder, prefixHref, prefixLabel) {
            var self = this;
            this.Id = ko.observable(id);
            this.ParentId = ko.observable(parentId);
            this.Href = ko.observable(href);
            this.IsFolder = ko.observable(isFolder);
            this.Label = ko.observable(label);

            this.dropPrefixValue =  dropPrefixValue;
            this.prefixIndex = prefixIndex;
            this.prefixId = prefixId;
            this.prefixParentId = prefixParentId;
            this.prefixIsFolder = prefixIsFolder;
            this.prefixHref = prefixHref;
            this.prefixLabel = prefixLabel;
            
            this.Editable = ko.observable(false);
            this.EditableLabel = ko.observable(false);

   if (children != null) children = children.sort(sortBookmarks);
            this.Children = ko.observableArray(ko.utils.arrayMap(children, function(bookmark) {
                var currentprefix = self.dropPrefixValue + "[" + bookmark.Id + "].";
                var b = new Bookmark(bookmark.Id, bookmark.ParentId, bookmark.Href, bookmark.IsFolder, bookmark.Label, bookmark.Children,
                    currentprefix + "Children", //dropPrefix
                    self.dropPrefixValue + ".Index",
                    currentprefix + "Id",
                    currentprefix + "ParentId",
                    currentprefix + "IsFolder",
                    currentprefix + "Href",
                    currentprefix + "Label"
                );
                return b;
            }));

            this.ChangeEditable = function() {
                var prevValue = this.EditableLabel();
                this.EditableLabel(!prevValue);
                
                if (!this.IsFolder()) this.Editable(!prevValue);
            };

            self.getBookmarkFromChildren = function(id) {
                if (self.Id() == id) return self;   
                else {
                    var result = null;
                    ko.utils.arrayForEach(self.Children(), function(bookmark) {
                        var validBookmark = bookmark.getBookmarkFromChildren(id);
                        if (validBookmark != null) {
                            result = validBookmark;
                        }
                    });

                    return result;
                }
            };
        }
</script>


And the main form:

@model BookmarksViewModel


<div id="draggableArea">
@using (Html.BeginForm())
{
    <ul id="bookmarksTree">
    <!-- ko template: { name: 'bookmark-template', foreach: bookmarks } -->
    <!-- /ko -->
    </ul>
    if (Model.IsEditable)
    {
        <div id="rootAnchor">Drop here to add to the root level.. </div>
        <br />
        <a data-bind="click: addFolder" class="linkWithPointer">Add new folder</a>
        <a data-bind="click: addLeaf" class="linkWithPointer">Add new href</a>
        <br />
        <input type="submit" value="Save" class="btnSave"/>
    }
}
</div>

And icing on top of the cake:

  function toggleFolder(e) {
      if (e.target.classList.contains("folder")) {
          $(e.target).children('ul').toggle();

          if ($(e.target).children('ul')[0].style.display == "none") {
              $(e.target).css("background-image", "url('/media/bookmarks/folder-closed.png')");
          } else {
              $(e.target).css("background-image", "url('/media/bookmarks/folder-open.png')");
          }
      }
      e.stopPropagation();
  }

Now I have to make it little clear. As a model I`ve used class inherited from List<Bookmark> with one additional property - IsEditable. Some users of the website were able to edit bookmarks, some of them - not.

Finally, here is the entry point:

<script>

 $(function() {
            var data = @(Html.Raw(Json.Encode(Model)));
            CurrentBookmarksModel = new BookmarksModel(data);
            ko.applyBindings(CurrentBookmarksModel);
            
            @if (Model.IsEditable)
            {
                //assign handlers
                @: document.getElementById("bookmarksTree").ondrop = drop;
                @: document.getElementById("bookmarksTree").ondragover = allowDrop;
                @: document.getElementById("rootAnchor").ondrop = drop;
                @: document.getElementById("rootAnchor").ondragover = allowDrop;
                @: var draggableNodesList = document.getElementsByClassName("draggableNode");
                @: for (var i = 0; i < draggableNodesList.length; i++) {
                @:    draggableNodesList[i].ondrag = drag;
                @: }
            }
      
            //prevent form submition on Enter
            $(window).keydown(function(event) {
                if (event.keyCode == 13) {
                    event.preventDefault();
                }
            });
        });
</script>


Done! :)

3) Changing. Users should be able to edit tree by dragging nodes from one folder to another, by deleting them and by adding new elements. For drag-drop functionality I`ve used built-in HTML5 stuff.

<script type="text/javascript">

        function allowDrop(ev) {
            ev.preventDefault();
        }

        var draggedNode = null;

        function drag(ev) {
            draggedNode = $(ev.target).closest("li")[0];
        }

//drop a little bigger :)

        function drop(ev) {
            //this don`t work...
            //var data = ev.dataTransfer.getData("Text");
            var draggedParents = $(ev.target).closest(draggedNode);
            var target = $(ev.target).closest("li");

            if (!draggedParents.length &&
                (target.is(".folder") || ev.target.id == "rootAnchor")) {

                ev.preventDefault();

                var parentPrefix;
                var parentId;
                if (ev.target.id == "rootAnchor") {
                    target = $("#bookmarksTree");
                    parentPrefix = "";
                    parentId = "";
                } else {
                    parentPrefix = target.children(".prefix").val();
                    parentId = target.children(".prefixId").val();
                    target = target.children("ul");
                }

                draggedNode.querySelector(".prefixParentId").value = parentId;

                var draggedID = draggedNode.querySelector(".prefixId").value;
                var draggedBookmarkModel = null;
                
                ko.utils.arrayForEach(CurrentBookmarksModel.bookmarks(), function(bookmark) {
                    var validDraggedBookmark = bookmark.getBookmarkFromChildren(draggedID);
                    if (validDraggedBookmark != null) draggedBookmarkModel = validDraggedBookmark;
                });
                
                updateModelPrefixes(draggedBookmarkModel, parentPrefix);
                updateChildrenPrefixes(draggedNode, parentPrefix);

                target.append(draggedNode);
            }
        }

        function updateModelPrefixes(model, parentPrefix) {
            var prefixIndex = parentPrefix + (parentPrefix == "" ? "" : ".") + "Index";
            var newPrefix = parentPrefix + "[" + model.Id() + "]";
            var newPrefixDrop = newPrefix + ".Children";
            var prefixHref = newPrefix + ".Href";
            var prefixLabel = newPrefix + ".Label";
            var prefixId = newPrefix + ".Id";
            var prefixParentId = newPrefix + ".ParentId";
            var prefixIsFolder = newPrefix + ".IsFolder";

            model.prefixIndex = prefixIndex;
            model.dropPrefixValue = newPrefixDrop;
            model.prefixId = prefixId;
            model.prefixHref = prefixHref;
            model.prefixParentId = prefixParentId;
            model.prefixIsFolder = prefixIsFolder;
            model.prefixLabel = prefixLabel;
        }
        
        function updateChildrenPrefixes(child, parentPrefix) {
            var draggedId = child.querySelector(".prefixId").value;

            var prefixIndex = parentPrefix + (parentPrefix == "" ? "" : ".") + "Index";
            var newPrefix = parentPrefix + "[" + draggedId + "]";
            var newPrefixDrop = newPrefix + ".Children";
            var prefixHref = newPrefix + ".Href";
            var prefixLabel = newPrefix + ".Label";
            var prefixId = newPrefix + ".Id";
            var prefixParentId = newPrefix + ".ParentId";
            var prefixIsFolder = newPrefix + ".IsFolder";

            child.querySelector(".prefixIndex").name = prefixIndex;
            child.querySelector(".prefix").value = newPrefixDrop;
            child.querySelector(".prefixId").name = prefixId;
            child.querySelector(".prefixHref").name = prefixHref;
            child.querySelector(".prefixLabel").name = prefixLabel;
            child.querySelector(".prefixParentId").name = prefixParentId;
            if (child.querySelector(".prefixIsFolder") != null)
                child.querySelector(".prefixIsFolder").name = prefixIsFolder;

            var subChildren = $(child).children("ul").children("li");
            for (var i = 0; i < subChildren.length; i++) {
                
                var draggedBookmarkModel = null;
                
                ko.utils.arrayForEach(CurrentBookmarksModel.bookmarks(), function(bookmark) {
                    var validDraggedBookmark = bookmark.getBookmarkFromChildren(subChildren[i].querySelector(".prefixId").value);
                    if (validDraggedBookmark != null) draggedBookmarkModel = validDraggedBookmark;
                });
                updateModelPrefixes(draggedBookmarkModel, newPrefixDrop);

                updateChildrenPrefixes(subChildren[i], newPrefixDrop);
            }
        }
</script>


Deleting is easy:

function deleteRow(e) {
    if (confirm("Are you sure?"))
        $(e.target).closest("li").remove();
}

And creation of new ones are from Bookmarks model - they are addFolder  and addLeaf functions!
Few! Almost done!

4) Getting data back. As far as everything is under form, then I have to create POST variant of my view. All the magic work is already done in the drop handler. I have the correct parent ID, so it is really easy to rebuild tree once again.

[HttpPost]
[TransactionScopeActionFilter(IsReadOnly = false, EndTransactionOnActionExecuted = true)]
public ActionResult Bookmarks(List<Bookmark> model)
{
    if (model == null) model = new List<Bookmark>();
    QueryProvider.UpdateBookmarks(model);

    return RedirectToAction("Bookmarks");
}

No comments:

Post a Comment