Drag&Drop Nested Set Editor for Admin Generator

Most of my web application have some kind of hierarchical structure in their models. It gives my users the ability to categorize their data in a pleasant way. The only problem is that its hard to create a user interface for editing these hierarchies. I made a prototype which adds nested set support to the symfony (1.2) generator. This extension allows for full drag&drop nested set support! See for yourself by clicking the image:

demo of the nested set editor

click for a real demo of the nested set editor

The editor uses the treeTable jQuery plugin with added D&D support.

Full source is available

Today I created the redotheoffice github repository. Right there you can find the full source of the project.

If you want to join the development or extend it, please send me an email or leave a reply to this entry.

Explanation

As I’m quite short on time right now, so I’ll only do a brief explanation and give you hints where to look in the source.

One first note is that the current implementation of NestedSet has a bug. Moving a deep branch to the root may corrupt the tree. I made a ticket and patch, but it’s not yet patched in the repositories. So manually update your Doctrine library, or only use this code for testing purposes…

The editor is based on a really simple model, using Doctrine’s actAs NestedSet (see config/doctrine/nested.yml):

---
Tree:
  actAs:
    NestedSet:
      hasManyRoots: true
      rootColumnName: root_id
  columns:
    name:
      type: string(255)

Next step is creating a default admin generator for the ‘Tree’ model. How to do this is clearly explained in Jobeet day 12: the Admin Generator. I think I did something like:

./symfony generate:app backend
./symfony doctrine:generate-admin backend Tree --module=tree

jQuery treeTable plugin

First enhancement is to implement the treeTable plugin. This turns the normal generated table into a collapsible tree.

Download the treeTable code, I copied the .js and .css to the web/js and web/css and added them to the apps/backend/config/view.yml. Also make sure to load jQuery:

default:
  http_metas:
    content-type: text/html
 
  metas: ~
 
  stylesheets:
    - jQuery.treeTable.css
    - main.css
 
  javascripts:
    - http://ajax.googleapis.com/ajax/libs/jquery/1.2.6/jquery.min.js
    - http://ajax.googleapis.com/ajax/libs/jqueryui/1.5.3/jquery-ui.min.js
    - jquery.treeTable.js

I happen to like the way jQuery can be loaded through the googleapi system. Symfony can handle the full URL’s by default, this style is easy to maintain and upgrade. Google also makes sure the headers of the js library are set correctly, which minimizes reloads of the files.

TreeTable is then enabled by overwriting the apps/backend/modules/tree/templates/_list.php template with some code that writes the correct classes to the individual rows. Also the table needs an id for easy finding the object:

...
    <table id="main_list" cellspacing="0">
...
      <tbody>
        <?php foreach ($pager->getResults() as $i => $tree): $odd = fmod(++$i, 2) ? 'odd' : 'even' ?>
          <tr id="node-<?php echo $tree['id'] ?>" class="sf_admin_row <?php echo $odd ?><?php
          // insert hierarchical info
          $node = $tree->getNode();
          if ($node->isValidNode() && $node->hasParent())
          {
            echo " child-of-node-".$node->getParent()->getId();
          }
          ?>">
            <?php include_partial('tree/list_td_batch_actions', array('tree' => $tree, 'helper' => $helper)) ?>
            <?php include_partial('tree/list_td_tabular', array('tree' => $tree)) ?>
            <?php include_partial('tree/list_td_actions', array('tree' => $tree, 'helper' => $helper)) ?>
          </tr>
        <?php endforeach; ?>
      </tbody>
...
<script type="text/javascript">
function checkAll()
{
  var boxes = document.getElementsByTagName('input'); for(index in boxes) { box = boxes[index]; if (box.type == 'checkbox' && box.className == 'sf_admin_batch_checkbox') box.checked = document.getElementById('sf_admin_list_batch_checkbox').checked } return true;
}
$(document).ready(function()  {
  $("#main_list").treeTable({
    treeColumn: 2,
    initialState: 'expanded'
  });
}
</script>

Doing this should give you a collapsible tree right inside your Admin Generator table.

Adding Drag&Drop support

Adding drag & drop on the client side is done by adding some javascript to the end of the _list.php template, see github. After dropping the item on a new parent the treeTable plugin takes care of updating the client visual interface. It’s up to us to send the data back to the server.

For this I added a hidden select on each row of the table:

<td>
  <input type="checkbox" name="ids[]" value="<?php echo $tree->getPrimaryKey() ?>" class="sf_admin_batch_checkbox" />
  <input type="hidden" id="select_node-<?php echo $tree->getPrimaryKey() ?>" name="newparent[<?php echo $tree->getPrimaryKey() ?>]" />
</td>

To clarify a little I added the screenshot below, in which the input element is still shown. After dropping the javascript seeks the id of the parent dropped on and types it into the input of the dragged item. Sending this to the server will enable the server to update the parent of the item. The code resides in the ‘executeBatchOrder’ function in apps/backen/modules/tree/actions/actions.class.php.

a hidden select to save the new parentId

TreeForm implementation

The implementation of the TreeForm is also noteworthy. It removes all nested set columns (root_id, lft, rgt and level) and adds a fully functional dropdown for selecting the parent. This code can easily be dropped into your project and doesn’t depend on jQuery or any other external library. After submitting the form makes sure all columns for the nested set are properly updated to the new parent. See the image below, and the TreeForm.class.php file at github. dropdown for choosing the parent

Further enhancements

Things can always become better:

  • Add a serialize function to treeTable which enables the possibility to get rid of the hidden input element on each row.
  • Make it possible to edit the order of children from the same parent (now it always inserts asFirstChild). Possible solution could be an interface like nestedSortable
  • Move the Update tree order batch action from the dropdown to the table footer (next to the New link)
  • Change this prototype into a Plugin

Thanks for reading!

Tags:

39 Responses to “Drag&Drop Nested Set Editor for Admin Generator”

  1. zero0x says:

    It looks really neat :) Very useful in CMSs.. Great work :)

  2. Bicou says:

    Great job ! Fantastic.

  3. Hugo says:

    Absolutly awesome !!!! Thanks a lot :)

  4. John says:

    what about using JQuery Simple Tree PLugin:

    http://news.kg/wp-content/uploads/tree/ http://plugins.jquery.com/project/SimpleTree

    seems to be able to sort on a single level too.

    John

  5. admin says:

    @John: That’s a really nice plugin indeed, but the main problem is that it’s based on ‘li’ elements. TreeTable is based on ‘tr’&'td’ elements, which are generated by default by the admin generator. Thanks for the tip though!

  6. COil says:

    Great job, will be very useful for sure. ;)

  7. Madis says:

    Hello! I was wondering if you could tell me how do you get the tree? If I use the query in the doctrine API I still get only one root and it’s tree. But when I have multiple roots how would I get the tree?

    Right now I’m using this: $treeObject = Doctrine::getTable(’MyNestedSetModel’)->getTree(); $tree = $treeObject->fetchTree(); foreach ($tree as $node) { echo str_repeat(’  ’, $node['level']) . $node['name'] . ”; }

    It would be possible to first fetch the roots and then fetch all the trees for them. But I’m not sure if that is correct.

  8. admin says:

    @madis: I didn’t use the getTree method, but I sorted the table on the ‘root_id’ (asc) AND the ‘lft’ value (asc) Then I used the ‘level’ value to add ‘level’ amount of spaces before the name of the node. The funtion is: (in Tree.class.php)

    class Tree extends BaseTree
    {
      …
      public function getIndentedName()
      {
        return str_repeat(’- ‘,$this['level']).$this['name'];
      }
    }

    This way it seems the nodes are nested, but actually it’s just a list of nodes, with spaces added before the name.

  9. Contrast says:

    Would it take much work to make this script run independently of symfony? It would be really useful for a web app I’m working on that uses a nested set.

  10. JoanT says:

    Fabulous!!! Thks very much. I’ve ported your code to Propel 1.3 and if you don’t mind i’ll publish it and link to your original work.

  11. 228Vit says:

    $(document).ready(function() { $(”#main_list”).treeTable({ treeColumn: 2, initialState: ‘expanded’ }); }

    Missing ) before Thanks for your work!

  12. szz says:

    thx! its awesome! @JoanT: im interested id propel version. where can i found that? thx

  13. Madie says:

    Thanks!

  14. Gaston says:

    This is GREAT! Thank you very much! (from Argentina)

  15. Not that I’m totally impressed, but this is a lot more than I expected for when I found a link on Delicious telling that the info here is quite decent. Thanks.

  16. Anton says:

    Well grand!

    Is there any way a simplified version of this to handle one to many relationship in doctrine ? Using the example of Author -> Books it would be 1 level tree with draggable books ?

  17. Jake says:

    Hi,

    I’m trying to view your demo http://nested.redotheoffice.com/ but I’m getting a server 500 error

    Ta

  18. pixelmeister says:

    Are you still working on that? Would be great as a symfony plugin! Its really great anyway!

  19. serg says:

    how can i add i18n in these fantastic script? when i tried to add i18n-field “body”, symfony says: Unknown column ‘t.body’ in ‘field list’

  20. Tim says:

    Hi,

    This post is just amazing, so I decided to adapt it to Propel. However I prefer to use another jquery plugin to use drag n drop to sort items. http://www.amicalement-web.net/symfony-gestion-d-un-arbre-en-propel-via-les-nestedset-part-1/2009/05/12/ http://www.amicalement-web.net/how-to-symfony-gestion-d%e2%80%99un-arbre-en-propel-via-les-nestedset-part-2/2009/05/19/

    Hope it helps

  21. nestedman says:

    I have ~400 categorys with translation/i18n. First there was over 800 database querys. I fixed it until I got ~400 querys but the module is soo slow.

    The loading time is ~12 000ms (was ~30 000 ms) with 435 DB querys. For testing purpose I removed most of the DB querys. The loading time is now ~9000ms with 3 DB querys. Also Firefox freezes for few seconds.

    Also there is no way to push up or down the childs. Only move them under a child.

    I like the solution but it woun’t work in a real world application. :(

  22. Menno says:

    Hi, is it me, or is the demo page broken? Looks promising though…

  23. lex says:

    For different trees it should be if($node->getScopeIdValue() == $parent->getScopeIdValue()) { $node->moveToFirstChildOf($parent); } else { $node->insertAsFirstChildOf($parent); } //for propel

  24. Admin says:

    @Menno: just read your post, the problem is fixed now I think, some permission issues on the server

  25. Stefan says:

    Hi, this is really good work and I tried to integrate your approach in a project. Only problem is , that it is uses too much resources and therefor os not useable for me. I need a tree for about 1600 records. Any idea?

  26. Enrico Stahn says:

    Every getNode() method call triggers a query, because everything in the View-Layer is decorated by sfOutputEscaperIteratorDecorator. This is one of the reasons why the code is slow on large trees. To solve this issue use “$node = $tree->getRawValue()->getNode();” instead of “$node = $tree->getRawValue()->getNode();”.

  27. Joshua says:

    I also experienced massive queries (700 each time). it’s due to the getparent() query. To get rid of that I implemented a little array to keep track of current parent - ideally I would like to modify the initial table_method query of admin but could not figure a simple way to fetch parent. So this is what I did: Before the foreach loop in the _list.php initalize a couple of variables: $level = -1;$parents = array(); Then replace the if loop that looks like: if ($node->isValidNode() && $node->hasParent()) {.. } with: $parents[$category['level']] = $category['id']; $level = $category['level']; if ($node->isValidNode() && $node->hasParent()) { echo ” child-of-node-”.@$parents[$category['level']-1] ;}

    That will drop it down to 3 or so queries. Additionally it seems that symfony’s loading of partials is terribly inefficient - so get rid of the _list_td_tabular and _list_td_batch_actions partials and just slap the code in the _list. You will see a drastic improvement in speed. (~10x). Hope that helps.

  28. I managed to get this working after hours of frustration caused by a mis-matched jQuery and jQuery UI js files.

    If you are using jQuery 1.3.x then you need to use jQuery UI 1.6.x as the jQuery 1.5.x is not compatible with the jQuery 1.3.x

    Alternatively you can use the jQuery 1.2.x with jQuery UI 1.5.x

    Many Thanks.

    Chris Shennan

  29. Karol Sójko says:

    Wow, this is so awesome, thank you this is just what I was looking for !!!!

  30. Norbert Haigermoser says:

    @serg: for I18N you have to add something like this into your Form.class

    $q = Doctrine::getTable(’Tree’) ->createQuery(’c') ->leftJoin(’c.Translation t WITH t.lang = ?’, $culture); // put your culture in the $culture variable!

    $this->widgetSchema['parentid'] = new sfWidgetFormDoctrineChoice(array( ‘model’ => ‘tree’, ‘query’ => $q, ‘addempty’ => ‘~ (object is at root level)’, ‘orderby’ => array(’rootid, lft’,”), ‘method’ => ‘getIndentedName’ ));

  31. medza says:

    I confirm. I join told all above. Let’s discuss this question. I apologise, but, in my opinion, you are not right. This information is true It is possible and necessary :) to discuss infinitely I apologise, but, in my opinion, it is obvious.

  32. Club Penguin says:

    You need to post more often you do a good job

  33. Centurionas says:

    Excellent work! As for me, I’m still unable to load this tree with symfony 1.4 :)))) . I’ trying symfony for more that a week and almost every used cutom symfony plugin for custom task was (sfDoctrine, ysJQueryRevolutionsPlugin) broke due to some backwards compatibility symfony issues. After I put this tree extracted source, I got an error: “Configuration file “C:wampwwwAgilelibvendorsymfonylib/config/config/filters.yml” specifies category “common” with missing class key.” I’m not sure what class should be so I removed it. Also page alerted me that “BaseForm.class.php” is missing. After adding that file I got ” login required” message :). After implementing simple login module (auth) and setting some lines in settings.yml and I got empty div tags. Looks like css or javascript are missing but from the Mozilla source I get see that they are visible and accessible. I even put those files in js folder I still got empty screen. I know that maybe I’m missing something but I don’t know what to look for. Any help would be appreciated :)

  34. Centurionas says:

    Already found a solution. I set “issecure: false” instead of “issecure: off”. Also put and in layout.php and removed sfDoctrinePlugin and sfProtoculousPlugin file shortcuts that were pointing to the wrong path (those plugins after pear instaliation reside in web directory). The only problem that persist is appearance of multiple cyan div boxes with a text link “cache information” and closing img in the box corner on top, header and footer of the grid table.

  35. Null says:

    Its amazing, but I found 1 problem.

    If you pass a child to be father’s father, the application dies

  36. Tom says:

    Hi,

    Great work! Really useful.

    However I need to be able to reorder the entries. Is is possible to extend the draggable/droppable behaviour to do this also?

    Any help would be much appreciated!

    Thanks, Tom

Leave a Reply