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");
}