Sencha Touch’s NestedList is an excellent component for creating the compact “drill-down” effect that we’re all used to on our mobile devices. It’s super-flexible, too. Whether you have a canned set of items, or need to retrieve each “level” via AJAX, it’ll handle it.

I’ve used NestedList quite a bit, and have been generally happy with it. One of the issues I’ve come across, though, is that it does not (as far as I’m aware, at least) support jumping around to arbitrary nodes with the NestedList.

Huh? So here’s an example 😉

Let’s say you have a bunch of products that exist within categories of variable depth. Once you reach the “product” level of your NestedList, you can out-of-the-box call getDetailCard(), build out your detail view, and be done with it. NestedList will take care of the navigation bits, and your site’s visitor will get a nice “back” button to return to the category list they used to get to the detail product.

Fine and dandy. BUT…what if you have links on your detail “card” to related products? Maybe these products exist in the same category, at the same “depth” in the nested list. But maybe instead they are in an entirely different category, one that’s two levels higher, or three levels deeper. What should you do?

Well, you could pop up a window (or something) that contains the details for that “related” product. And maybe within that popup you could drive any *additional* links to continue with the pop-up mode indefinitely. I suppose that works, but it feels a bit clunky. And you’ve also lost context. While the context of the original detail card is preserved, any subsequent navigation is essentially orphaned.

So let’s say that instead of trying to wrangle the pop-up window solution, you’d rather just keep with the NestedList model, and “jump” to the appropriate place in the tree. Unfortunately, from what I can tell, there is no way out of the box to do this with NestedList.  Fortunately, there is a workaround…

BIG DISCLAIMER! What follows assumes that your NestedList implementation is using a static set of data (e.g., all the data is set when the NestedList is created, and not determined via AJAX as each “level” is loaded).

Some Context

If you want to jump to the solution, feel free. However, I want to take some time to outline my understanding of the NestedList, just so the solution makes sense if you’re trying to follow along. Cool? Okay.

First, there’s something you need to know about the NestedLIst. It’s built to be as “light” as possible at any given time. What do I mean by that? Well, if you take a look at the underlying HTML that’s generated for each “level” of the tree, you’ll notice that the only “levels” that exist at any one time are the ones that are absolutely necessary to manage the current level that you’re “on”.

So instead of rendering the whole gosh-darn thing right up front (which could be MASSIVE if the NestedList is large and complex), only the bits that are needed are rendered at any one time; the parts that are not are simply removed, and re-rendered later on if requested. In other words, as you move from list to list, drilling your way down the hierarchy, lists are being constantly added and removed, depending on the needs of your view at any given point in time.

While this definitely makes a lot sense from a performance standpoint (after all, you don’t necessarily want or need the entire form of the NestedList’s content rendered all the time), it does create a problem for our dream of arbitrarily jumping to node X in the NestedList. After all, if only the necessary lists are available at any given time, we can’t just jump willy-nilly, hoping that our destination exists within the currently rendered lists. If it does, fine and great (ok, not really, but…), but otherwise, we’ll generate errors, break navigation, or both.

What To Do, What To Do

So the situation is hopeless, right? Since ALL of the lists aren’t rendered at one time, we can’t possibly implement a solution for jumping to random nodes in the tree, right?

Wrong. While it’s true that the dynamic nature of the NestedList is something we have to work around (or with, as the case may be), it’s not impossible or even hard. This is because I left out an important point. Even though the entire NestedList is not rendered at any one time, this doesn’t mean that we don’t know what our NestedList’s data looks like, or where we might find node X within the hierarchy. To the contrary, in our NestedList’s fancy TreeStore, we’ve got all the data we need to be able to figure out where the node we want actually lives. And armed with this knowledge, and an basic understanding of how the lists are being dynamically added and removed from the NestedList, we can begin clawing our way toward our dream of arbitrary NestedList navigation!

Now We Begin

So let’s get started. First, since we have no OOTB way of accomplishing this goal with the NestedList’s existing methods, we’ll need to add our own. And since we want this to be something of a drag-and-drop solution, let’s go ahead and make a custom extension of the NestedList class.

Ext.ns('Ext.ux');
Ext.ux.NestedList = Ext.extend(Ext.NestedList, {
    jumpToNode: function(nodeInfo, doSelect, hideActive) {
    ...
    }
});

We’ll come back to this in more detail, but that’s the skeleton of our custom class. Obviously, we could do a lot more, but for this post, we’ll just be adding a single new method, jumpToNode().

WIth our new class created, let’s use it in our app:

App.views.MyNestedList = Ext.extend(Ext.ux.NestedList, {
   fullscreen: true,
   iconCls: "home",
   store: "Navigation",
   displayField: "text",
   title: "Custom NestedList",
   initComponent: function() {
      App.views.MyNestedList.superclass.initComponent.call(this);
   },
   getDetailCard: function(record,parentrecord) {
      var data = record.attributes.record.data;
      var title = data.title;
      var target= data.target;
      var type  = data.type;
      Ext.ControllerManager.get("Navigation").getContent(target,title,type);
   }
});

Nothing important here…just a vanilla NestedList, but using our fancy new custom class. As with all NestedLists, once we get to a “leaf” node in our data, the getDetailCard() method will fire, and we can set up how we handle the generation of that “card”. In my app, I send off an AJAX request to get the content from the server, handle the response, and dynamically add a card using the retrieved data…no biggie.

Where it Gets Interesting

To this point, we’ve not doing anything outside of the normal NestedList model. In fact, if we stopped here, we would have wasted time creating the custom class. But that would make this post pointless, so we won’t do that!

So let’s imagine that in the content we receive back from the server, we have HTML that contains links to other content in our application. Or to think of it in terms of the NestedList, our generated DetailCard has links to other potential DetailCards.

In my app, I return these *special* links with a class attribute of “cardlink” (e.g., <a href=”…” class=”cardlink”>…</a.>). This allows me to do something kind of cool in the generation of my DetailCard. Here’s a snippet from my “Navigation” controller:

Ext.regController("Navigation",{
   getContent: function(title,target,type) {
      ... generate detail card content ...
      var view = App.views.MyNestedList;
      // add detail card to NestedList
      view.add(detailcard);
      // add listeners to *special* links
      Ext.get("content_wrapper").select("a[class=cardlink]").each(function(){
         this.on("click",function(){
            // when link is selected, invoke find() method
            me.find(target,title,type)
         });
      }),
      find: function(target) {
         var tree = App.views.MyNestedList.store.tree;
         // get the node from the tree store
         var node = tree.getNodeById(target);
         if(node) {
            App.views.MyNestedList.jumpToNode(node,true,false);
         }
      }
   )}
)}

Obviously, I’m not showing everything here, but the important bits are there. First, I add my new DetailCard to my NestedList. Then, I loop over each link within my DetailCard’s content (I have a div with an ID of “content_wrapper”). If the link has the class of “cardlink,” I know it points to another DetailCard within my app, so I add a listener for the click event to fire the find() method. In the find() method of my controller, I lookup the node from the NestedList’s store, and—FINALLY–invoke the method jumpToNode() which we added to our custom NestedList class.

jumpToNode() In a Nutshell

jumpToNode() takes 3 arguments. Here they are:

  • nodeInfo [required]: This is basically which node you want to jump to. You can either look it up beforehand, retrieve the node, and pass it through to this method, or you can simply pass the id of the node you want.
  • doSelect [optional, default: true]: This argument tells the method whether or not you want  to apply the selectedClass to the parent items of the passed node
  • hideActive [options, default: false]: This argument helps prevent a momentary flicker of the target node’s parent when switching from DetailCard to DetailCard. NOTE: This requires a small override tweak to Ext.layout.CardLayout’s setActiveItem() method (more below)

Enough of those boring details, let’s get to some code! Back in our custom extension of the NestedList class, we can begin crafting our jumpToNode() method.

Ext.ux.NestedList = Ext.extend(Ext.NestedList, {
   jumpToNode: function(nodeInfo, doSelect, hideActive) {
      var me = this;
      ...
      var tree = me.store.tree;
      // loop over all child lists in this NestedList and remove them
      // we want a clean slate to work with
      for(i=(me.items.length-1);i >= 1;i--){
         me.remove(me.items.getAt(i));
      }
      // at the top level list, deselect any selected records
      me.items.getAt(0).deselect(me.items.getAt(0).getSelectedRecords());
      ....
   }
});

The first important part of the code is this snippet. Remember how I said that NestedList dynamically adds AND removes lists from the main component? Well, if we’re going to jump to an arbitrary point in the NestedLIst, we need a clean slate. This snippet basically rolls over the current state of the NestedList, and removes all items, except for the top-level list. In a nutshell, it’s a form of a “return to home” method. If we stopped here, we’d basically be back at the root of our NestedList.

BTW, this “return to home” functionality is a potentially handy method to have as its own animal. Feel free to break it out into another method in this custom class of NestedList if you want.

Now that we’re back at home base, so to speak, we can start building up the new collection of lists that will form the path to our desired node.

var nodes = [];
var depth = node.getDepth();
var nid = node.id;
// loop over depth, get node ids of node hierarchy
for(i=0;i<depth;i++) {
   nodes.push(nid);
   nid =  tree.nodeHash[nid].parentNode.id;
}

Pretty straightforward here. Since we know our destination (remember, we already passed in the node/nodeid of our desired target…), we know some really important information, namely, what level our node is at. Using the node’s getDepth() method, we can now do a quick loop to build a dirty array of our node’s parents by tapping into the handy “nodehash” we have of all our NestedList’s nodes on the main NestedList store’s tree.

Curious about the nodeHash? In your debugging tools, set a breakpoint somewhere in this process and inspect the “tree”–you’ll see the nodeHash, which is basically an collection of key-value pairs for each node in the NestedList store’s tree.

Ok, at this point, our “nodes” array has a listing–from child to ancestor–of all the nodes that build the path to our target. It’s now time to actually build out our new lists.

// reverse the node array, and we'll build out the new lists
nodes = nodes.reverse();
var newlist,nextlist,prevlist,child,tmpnode,curridx;
// now loop over hierarchy of nodes, adding lists as we go along
for(i=0;i<nodes.length;i++) {
   tmpnode = tree.getNodeById(nodes[i]);
   // if it's not a leaf, we're working with a list
   if(!tmpnode.leaf) {
      // get the sublist
      newlist = me.getSubList(tmpnode);
      // add it to the nestedlist
      nextlist = me.addNextCard(newlist.recordNode);
      me.layout.setActiveItem(nextlist,"fade",hideActive);
      // if doSelect is true, get target node and select it
      if(doSelect) {
         // get the index of the list we just added
         curridx = me.items.indexOf(nextlist);
         // get the one before it
         prevlist = me.items.getAt(curridx-1);
         // find the child node that corresponds to the item in the hierarchy
         child = prevlist.getNode(tmpnode.getRecord())
         prevlist.selModel.select(child)
      }
   }
   // is a leaf; will probably want to fire onItemTap()
   else {
      var idx = nextlist.store.find("id",tmpnode.id);
      if(doSelect) {
         // should be the last added list
         prevlist = me.items.getAt(curridx);
         child = prevlist.getNode(tmpnode.getRecord())
         prevlist.selModel.select(child)
      }
      me.onItemTap(nextlist,idx);
   }
}

The first thing we need to do is to reverse our node array. It’s currently ordered from child-to-ancestor, but we need to build from the top down.

With that done, we begin a loop over the array of nodes., At each iteration, we retrieve the node from our NestedList’s TreeStore using getNodeById(). With this, we look up the appropriate “subList” from our NestedList, which is basically what we’ll use for creating the List “card” at each level.

Once we’ve added the subList card, we need to take a brief detour and set the “activeItem” for our NestedList. If we don’t do this, the automatic “navigation” that’s added to our NestedList’s toolbar will be broken…and we don’t want that.

NOTE: Here’s where the “hideActive” argument comes into play. I would *strongly* suggest setting it to “true”, but hey, it’s your app 🙂 If you want to use it, you’ll need to slightly override the Ext.layout.CardLayout class setActiveItem() method (included below)

With that out of the way, we can now handle the ‘doSelect” argument. As mentioned above, if true, this will tell our custom NestedList to select the parent nodes of our target. If you’re confused by what this means, take a second to try out a vanilla NestedList. As you navigate down, and then use the toolbar nav to go “back,” you’ll notice that the node you previously clicked will be momentarily “selected,” before reverting to an unselected state. The doSelect() method allows the custom NestedList to mimic that behavior. If you don’t care about/want that, you can skip it.

All of that was for non-leaf items. Once our loop gets to a leaf, it will break into the “else”. Since we don’t need to add any more list cards (we’re at the leaf node, remember), all that’s left is to handle our doSelect() and then to fire the onItemTap() method. This automatically handles what would *normally* happen if someone was navigating a NestedList by clicking through the hierarchy. When onItemTap() is invoked, our NestedLists’s getDetailCard() method should automatically fire, and the user should have the experience of fluidly moving from one DetailCard to another, without needing to re-navigate through the entire hierarchy. And furthermore, since we’ve built out the lists in a way that the OOTB NestedList understands, the toolbar nav should just work, even though we’ve jumped arbitrarily from one NestedList node to another.

jumpToNode – The Whole Enchilada

Ext.ux.NestedList = Ext.extend(Ext.NestedList, {
   jumpToNode: function(nodeInfo,doSelect,hideActive) {
      var me = this,
      hideActive = hideActive==undefined ? me.layout.hideActive : hideActive,
      doSelect = doSelect==undefined ? false : doSelect,
      tree = me.store.tree,
      nodes = [],
      node = nodeInfo;
      if(Ext.isString(nodeInfo)) {
         // do lookup of node based on id
         node = tree.getNodeById(nodeInfo);
         if(!nodeInfo) {
            return false;
         }
      }
      // first things first; remove existing lists and detail cards from cur nested list
      for(i=(me.items.length-1);i >= 1;i--){
         me.remove(me.items.getAt(i));
      }
      // at the top level list (which we'll leave), deselect any selected records
      me.items.getAt(0).deselect(me.items.getAt(0).getSelectedRecords());
      // get the depth of the node
      var depth = node.getDepth();
      var nid = node.id;
      // loop over depth, get node ids of node hierarchy
      for(i=0;i<depth;i++) {
         nodes.push(nid);
         nid =  tree.nodeHash[nid].parentNode.id;
      }
      // reverse the node array, and we'll build out the new lists
      nodes = nodes.reverse();
      var newlist,
          // set nextlist to our root list, in case we have leaf items at root
          nextlist = me.items.getAt(0),
          // also set prevlist to our root list, to prevent issues if leaf is at root
          prevlist = me.items.getAt(0),
          child,
          tmpnode,
          curridx = 0;
      // now loop over hierarchy of nodes, adding lists as we go along
      for(i=0;i<nodes.length;i++) {
         tmpnode = tree.getNodeById(nodes[i]);
         // if it's not a leaf, we're working with a list
         if(!tmpnode.leaf) {
            // get the sublist
            newlist = me.getSubList(tmpnode);
            // add it to the nestedlist
            nextlist = me.addNextCard(newlist.recordNode);
            me.layout.setActiveItem(nextlist,"fade",true);
            if(doSelect) {
               // get the index of the list we just added
               curridx = me.items.indexOf(nextlist);
               // get the one before it
               prevlist = me.items.getAt(curridx-1);
               // find the child node that corresponds to the item in the hierarchy
               child = prevlist.getNode(tmpnode.getRecord())
               // if doSelect is true, fire select on current node
               prevlist.selModel.select(child)
            }
         }
         // is a leaf; will probably want to fire onItemTap()
         else {
            var idx = nextlist.store.find("id",tmpnode.id);
            // if we want to select the node AND prevlist is NOT undefined (e.g., a root
            if(doSelect && prevlist != undefined) {
               // should be the last added list
               prevlist = me.items.getAt(curridx);
               child = prevlist.getNode(tmpnode.getRecord())
               prevlist.selModel.select(child)
            }
            me.onItemTap(nextlist,idx);
         }
      }
   }
});

setActiveItem Override

If you’re using the “hideActive” argument, here’s the override to setActiveItem in the Ext.layout.CardLayout class:

Ext.override(Ext.layout.CardLayout, {
   setActiveItem: function(newCard, animation,hideActive) {
   var me = this,
      owner = me.owner,
      doc = Ext.getDoc(),
      oldCard = me.activeItem,
      newIndex;
   animation = (animation == undefined) ? this.getAnimation(newCard, owner) : animation;
   var hideActive = hideActive == undefined ? false : hideActive;
   newCard = me.parseActiveItem(newCard);
   newIndex = owner.items.indexOf(newCard);
   // If the card is not a child of the owner, then add it
   if (newIndex == -1) {
      owner.add(newCard);
   }
   // Is this a valid, different card?
   if (newCard && oldCard != newCard && owner.onBeforeCardSwitch(newCard, oldCard,             newIndex, !!animation) !== false) {
      // If the card has not been rendered yet, now is the time to do so.
      if (!newCard.rendered) {
         this.layout();
      }
      // Fire the beforeactivate and beforedeactivate events on the cards
      if (newCard.fireEvent('beforeactivate', newCard, oldCard) === false) {
         return false;
      }
      if (oldCard && oldCard.fireEvent('beforedeactivate', oldCard, newCard) === false) {
         return false;
      }
      // Make sure the new card is shown, but only show if not explictly prohibited
      if (newCard.hidden && !hideActive) {
         newCard.show();
      }
      me.activeItem = newCard;
      if (animation) {
         doc.on('click', Ext.emptyFn, me, {
            single: true,
            preventDefault: true
         });
         Ext.Anim.run(newCard, animation, {
            out: false,
            autoClear: true,
            scope: me,
            after: function() {
               Ext.defer(function() {
                  doc.un('click', Ext.emptyFn, me);
               },
               50, me);

               newCard.fireEvent('activate', newCard, oldCard);

               if (!oldCard) {
                  // If there is no old card, the we have to make sure that we fire
                  // onCardSwitch here.
                  owner.onCardSwitch(newCard, oldCard, newIndex, true);
               }
            }
         });
         if (oldCard) {
            Ext.Anim.run(oldCard, animation, {
               out: true,
               autoClear: true,
               after: function() {
                  oldCard.fireEvent('deactivate', oldCard, newCard);
                  if (me.hideInactive && me.activeItem != oldCard) {
                     oldCard.hide();
                  }
                  // We fire onCardSwitch in the after of the oldCard animation
                  // because that is the last one to fire, and we want to make sure
                  // both animations are finished before firing it.
                  owner.onCardSwitch(newCard, oldCard, newIndex, true);
               }
            });
         }
      }
      else {
         newCard.fireEvent('activate', newCard, oldCard);
         if (oldCard) {
            oldCard.fireEvent('deactivate', oldCard, newCard);
            if (me.hideInactive) {
               oldCard.hide();
            }
         }
         owner.onCardSwitch(newCard, oldCard, newIndex, false);
      }
      return newCard;
   }
      return false;
   }
})

Wrapping Up

And that’s about it. I think the final solution is fairly straightforward, and provides what I think is missing functionality within the NestedList. If you think I’ve missed something, or could accomplish parts of this in better or more efficient ways, please let me know–I’d love any and all feedback on this. Thanks!