As it turns out the Drag & Drop feature is like the gift that keeps on giving – new year and new enhancement to the functionality! May be because the Ignite UI Tree is flexible enough or maybe because the functionality itself is based on simple Add and Remove API coupled with jQuery UI interactions, both of which are easy to understand, manipulate and extend :)
I’m thinking once you allow a user to move nodes around at one point you might come to the conclusion that some of those changes can be persisted. And as with most user actions - no surprise here! - mistakes do and will happen. That gets us to yet another point – why not add functionality for the user to undo and redo Drag & Drop operations? Indeed, why not!
And while it does require some custom code, as we are trying to make this interaction feature into an editing one, which is not how it is intended in its current implementation. On the other hand, the custom code is heavily reduced as we have a number of building blocks right there at our disposal. So far we’ve been looking into the usability side in the previous post with Tips on Drag & Drop with the Ignite UI Tree and some basics of the Drag & Drop with the Ignite UI Tree– make sure you check those out as we’ll be building on top of some of the knowledge gained and this time dive into the functionality. I’m not saying the enhanced selection and Load On Demand were not functional, they were, but if you look at it this way – they were tweaks based on the Draggable and Droppable nature implemented by the control. Now remember the other API part? That’s our target now..
Leaving a trace behind
I’ll try not to bore you too much with Add/Remove ‘getting-started’ stuff, as someone else was kind enough to do that – check out the Adding and Removing Nodes Help topic and there are some online samples for those API methods too! The thing that matters the most to us is that those operations create transactions in the underlying data source log and that Drag & Drop uses them. Essentially, we already have a history of the node moves, all you have to do is send it and provide server-side logic to handle the changes. The only thing to keep in mind is that a drop actually calls both methods – removing the original node and adding it to it’s new place. That in turn equals two transactions per drop, rather than one.
The tree also provides an update URL property to set to an endpoint to send transactions to (I’ll use the a similar Tree as the last two blogs for simplicity, again with Northwind’s Categories) :
- @model IQueryable<TreeDragDrop.Models.Category>
- @using Infragistics.Web.Mvc;
- @(Html.Infragistics().Tree(Model).ID("tree")
- .DragAndDrop(true)
- .DragAndDropSettings(d =>
- {
- d.DragAndDropMode(DragAndDropMode.Default);
- d.Revert(true);
- d.CustomDropValidation("customValidation");
- })
- .UpdateUrl(Url.Action("saveChanges"))
- .Bindings(binding =>
- {
- binding.PrimaryKey("CategoryID")
- .TextKey("CategoryName")
- .ValueKey("CategoryID")
- .ChildDataProperty("Products")
- .Bindings(b1 =>
- {
- b1.TextKey("ProductName")
- .ValueKey("ProductID")
- .PrimaryKey("ProductID");
- });
- })
- .DataBind().Render()
- )
Note line 11 where we set the action link. And, of course, this can be in client-only script as well:
- $('#tree').igTree({
- dataSource: '/Home/getData',
- dragAndDrop: true,
- dragAndDropSettings : {
- dragStartDelay : 100,
- revert : true,
- customDropValidation : 'customValidation'
- },
- updateUrl: '/Home/saveChanges',
- bindings : {
- primaryKey : 'CategoryID',
- textKey : 'CategoryName',
- valueKey : 'CategoryID',
- childDataProperty : 'Products',
- bindings : {
- textKey : 'ProductName',
- valueKey : 'ProductID',
- primaryKey : 'ProductID'
- }
- }
- });
The controller action will have to deserialize the transactions and make sense of them. You have to do the same too I think, here a quick anatomy of a Tree transaction:
- type – either "addnode" or "removenode" based on the method used. Remember you get both per single drop!
- tid – generated ID of the transaction, useful for deleting a specific transaction.
- tdata – all the important goodies are here : the ‘path’ of the node on which the method was performed and its entire ‘data’ record. It also contains a ‘parentPath’ for add operations.
As you can tell from the path we can assert from where the node was removed or to which parent was it added. Don’t forget to validate! That is the reason of the custom function, the Drag & Drop would usually allow much more that it might make sense to your model, especially so if you slack on the binding definitions. The function in this example denies a node from being dropped on it’s current parent or one of the other nodes with the same depth as they are all products. Saving the changes should generally be as simple as calling a method, however in this case, since the Add Node API method accepts multiple nodes (unlike the remove that is by path which is pretty specific), its path is an array instead of simple string. In a Drag & Drop scenario the add method will only be called with one value so you can go through the changes and normalize the path of the transaction (Drag & Drop would not add multiples) and unify them:
- function save() {
- var transLog = $('#tree').igTree('option', 'dataSource').root().allTransactions();
- //normalize transactions
- for (var i = 0; i < transLog.length; i++) {
- if (transLog[i].tdata.path instanceof Array) transLog[i].tdata.path = transLog[i].tdata.path[0];
- }
- //send to server
- $('#tree').igTree('option', 'dataSource').root().saveChanges();
- }
Here’s how the controller action looks like:
- publicActionResult saveChanges()
- {
- var transactions = JsonConvert.DeserializeObject<List<JObject>>(Request.Form["ig_transactions"]);
- if (transactions.Count % 2 != 0)
- {
- returnnewHttpStatusCodeResult(System.Net.HttpStatusCode.Conflict);
- }
- for (var i = 0; i < transactions.Count; i+=2 )
- {
- // a single drag&drop produces 2 transactions - one removeing from the original spot and one adding to the new one.
- if (((string)transactions[i]["type"] == "addnode"&& (string)transactions[i + 1]["type"] == "removenode") ||
- ((string)transactions[i]["type"] == "removenode"&& (string)transactions[i + 1]["type"] == "addnode"))
- {
- // node was moved moved
- if ((string)transactions[i]["tdata"]["path"] == (string)transactions[i + 1]["tdata"]["path"])
- {
- //merely a change in order, ignore (this will also skip category moves)
- continue;
- }
- Product prod = JsonConvert.DeserializeObject<Product>(transactions[i]["tdata"]["data"].ToString());
- var product = db.Products.Where(x => x.ProductID == prod.ProductID).Single();
- int catId;
- if ((string)transactions[i]["type"] == "addnode")
- {
- catId = int.Parse(transactions[i]["tdata"]["path"].ToString().Split('_').First());
- }
- else
- {
- catId = int.Parse(transactions[i+1]["tdata"]["path"].ToString().Split('_').First());
- }
- product.CategoryID = catId;
- db.SaveChanges();
- }
- }
- // tell the data source we are cool
- Response.Write("{\"Success\": true}");
- returnnewJsonResult();
- }
As you can see I’ve pretty much made this for the Drag& Drop only (thus the validation if it divides by 2). If you use this in addition to programmatic calls to the Add/Remove API this will require more work ..or the result will be unpredictable. I’m also validating that each two are an add and remove combo and if the path is identical it means this is merely a change in order. Once that is done the actual business part is getting the Category ID (which is the parent part of the path when you have primary keys defined) and assign the one from the add operation to the product.
Go there! No, come back! Actually…
Undo that and redo this. It’s those ‘phew’ moments that always make me happy when I realize I haven’t forever lost something. This part however can be either pretty simple ..or somewhat harder to implement than updating depending if you care for nodes being dragged and dropped around under their parent – as in if you care for order. The reason for this is that the paths for nodes generated by the control instead indexes normally, will actually consist of the primary keys if you have some. However, primary keys will only reflect the node’s place in hierarchy but not the order. Since the igTree I’ve been using so far has primary key I figured going the extra mile for a better experience is totally worth it.
- function undo() {
- igtree = igtree || $("#tree").data("igTree");
- treeDS = treeDS || $('#tree').igTree('option', 'dataSource');
- var allTransactions = treeDS.root().allTransactions();
- if (allTransactions.length > 0 && (allTransactions.length % 2 === 0)) {
- var transactions = [allTransactions[allTransactions.length - 2], allTransactions[allTransactions.length - 1]];
- if (transactions[0].type == "removenode") {
- //always need to remove node before adding again!
- transactions = transactions.reverse();
- }
- var deferredIndex = {};
- for (var i = 0; i < 2; i++) {
- var transaction = transactions[i];
- var parentPath = ''; //for root node
- if (transaction.tdata.path.indexOf(igtree.options.pathSeparator) !== -1) {
- parentPath = transaction.tdata.path.split(igtree.options.pathSeparator).slice(0, this.length - 1).join(igtree.options.pathSeparator);
- }
- if (transaction.type == "removenode") {
- var parent = parentPath == '' ? igtree.element.children('ul') : igtree.nodeByPath(parentPath);
- var index = null;
- if (indexMap[transaction.tdata.path] < parent.children().length) {
- index = indexMap[transaction.tdata.path];
- }
- igtree.addNode(transaction.tdata.data, parent, index);
- }
- else {
- //save index only after add
- var theNode = igtree.nodeByPath(transaction.tdata.path[0]);
- deferredIndex[transaction.tdata.path[0]] = theNode.parent().children().index(theNode);
- igtree.removeAt(transaction.tdata.path[0]);
- }
- treeDS.root()._removeTransactionByTransactionId(transaction.tid, true);
- redoStack.push(allTransactions.slice(-1)[0]);
- // also remove the two newly created last transaction..
- treeDS.root()._removeTransactionByTransactionId(allTransactions.slice(-1)[0].tid, true);
- }
- $.extend(indexMap, deferredIndex);
- }
- }
- function redo() {
- igtree = igtree || $("#tree").data("igTree");
- treeDS = treeDS || $('#tree').igTree('option', 'dataSource');
- if (redoStack.length > 0 && (redoStack.length % 2 === 0)) {
- //always need to remove node before adding again!
- //this keeps the order:
- var transactions = [redoStack.pop(), redoStack.pop()];
- var deferredIndex = {};
- for (var i = 0; i < 2; i++) {
- var parentPath = ''; //for root node
- if (transactions[i].tdata.path.indexOf(igtree.options.pathSeparator) !== -1) {
- parentPath = transactions[i].tdata.path.split(igtree.options.pathSeparator).slice(0, this.length - 1).join(igtree.options.pathSeparator);
- }
- if (transactions[i].type == "removenode") {
- var parent = parentPath == '' ? igtree.element.children('ul') : igtree.nodeByPath(parentPath);
- var index = null;
- if (indexMap[transactions[i].tdata.path] < parent.children().length) {
- index = indexMap[transactions[i].tdata.path];
- }
- igtree.addNode(transactions[i].tdata.data, parent, index);
- }
- else {
- //save index only after add
- var theNode = igtree.nodeByPath(transactions[i].tdata.path[0]);
- deferredIndex[transactions[i].tdata.path[0]] = theNode.parent().children().index(theNode);
- igtree.removeAt(transactions[i].tdata.path[0]);
- }
- }
- $.extend(indexMap, deferredIndex);
- }
- }
We always want to remove first and then add, this is because the way path is created by primary keys as mentioned. If we add a node with the same path (change in order) and then call remove it will delete both the old and new ones. Also notice the two functions deal with somewhat different issues – the ‘undo’ must clean up additional transactions created by the API methods, while the redo takes advantage the latter in order to have transactions back to the log. Furthermore, processing the ‘addnode’ transaction first means more complications when saving an index, since we need the ‘indexMap’ to still contain the old node’s place when undoing the ‘removenode’, so this calls for saving that index to the side and updating it last. Also to gain initial index we hook up to the node dropping event to save it:
- $(document).delegate("#tree", "igtreenodedropping", function (evt, ui) {
- indexMap[ui.draggable.data('path')] = ui.draggable.parent().children().index(ui.draggable);
- });
Note that caring for node index in this implementation only has meaning on the client-side, but I think it’s nice to have. Check out below the (slightly large) capture of the Undo/Redo in action – note how the transaction log gets cleared when you undo and the ‘Tofu’ node is back under produce, but the redo stack now holds transactions for it. And as expected it shuffles the two moved nodes afterwards as well:
Resources
As usual the Ignite UI Tree Documentation and jQuery API Reference and detailed samples are at your disposal to draw code and knowledge from!
For the batch updating and undo/redo sample grab the ASP.NET MVC demo project ( with everything from here included in with both MVC helper and script-only demos). Make sure to check that one out! You would need at least a trial version of Ignite UI so hit the banner below if you are still lacking one.
Also you can go for fiddling right now on JSFiddle: Ignite UI Tree Drag & Drop + Updating demo.
If this has caught your interest and want to know what other cool tricks you can do with this feature – once more the Tips on Drag & Drop with the Ignite UI Tree await!
Summary
Yet another spin of the in-depth look at the Ignite UI Tree’s Drag and Drop feature. With some insight on the API methods used we were able to use this interaction feature as editing one, by implementing batch updating with the help of the underlying data source. Building on top of that you can actually manipulate the log and use the very same API to add Undo / Redo functionality. And since we change the actual log, transactions going to the server for saving will also be in tune! Again it’s all thanks to the building blocks of the feature and their extensibility!
And as always, you can follow us on Twitter @DamyanPetev and @Infragistics and stay in touch on Facebook, Google+ and LinkedIn!