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:
Mapping (fluent) is simple, the only thing about it:
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.
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...):
And the main form:
And icing on top of the cake:
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:
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.
Deleting is easy:
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.
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