Archive for the ‘proof of concept’ Category

Drag&Drop Nested Set Editor for Admin Generator

Friday, January 16th, 2009

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!

Nesting Doctrine’s relations in sfForm

Wednesday, November 5th, 2008

I regularly need a form to edit more than one object at once. To explain this more clearly: imagine an Author - Book relation. Doctrine builds you standard forms for the Author and Book objects, but you can only use them separate. Why not combine the forms using code like in the following lines?

$form = new AuthorForm($author);
$form->embedRelation('Books');

Such code extends functionality of your forms with the knowledge already defined in the Doctrine-schema of your project. It will reuse your already available forms into a really smart editor. Very DRY to my opinion…

summing-forms

This article explains how to extend the sfDoctrineForm and make the above code work.


Example schema and definition

Let’s take the typical Author-Book relation as an example. An author has written books, each book has an author. This gives the following schema & fixtures: (config/doctrine/schema.yml, data/fixture/data.yml)

---
Author:
  columns:
    name: string(255)
    description: string
 
Book:
  columns:
    author_id: integer(20)
    title: string(255)
    released: integer(20)
  relations:
    Author:
      foreignAlias: Author
data/fixtures/data.yml:
Author:
  kluun:
    name: Kluun
    description: Author living in Amsterdam
 
Book:
  komt:
    title: Komt een vrouw bij de dokter
    released: 2003
    Author: kluun
  weduw:
    title: De weduwnaar
    released: 2006
    Author: kluun

After doing doctrine:build-all-load for setting up the model&forms we can create a CRUD by executing something like doctrine:generate-module frontend author Author. This gives a familiar CRUD interface:

basic crud list

Steps

So far so good. The steps we need to do to get the right result are:

  1. add a BookForm form for each $author['Books'] object

  2. when data comes back, save the data, also for the nested objects.

Array structure

We have to start with the end in mind. After submitting and validating a form, the returned data should match the form Doctrine uses. So we start with step #2. Each Doctrine record object has a method ->fromArray(boolean $deep), which populates the object. When $deep is true it also populates nested records, this is the functionality we need! If we can create a sfForm which returns data in the exact array structure needed by fromArray, then we can directly populate the record object with the $form->getValues(). (Actually this is the way sfDoctrineForm populates records, but $deep is set to false so it only populates the current object)

Now we need to know which structure this array has. Luckily Doctrine supplies the ‘inverse’ method of fromArray, named ->toArray(boolean $deep). This method gives us the array needed to populate the current object. A sample output of $author->toArray() is below:

Array
(
    [id] => 3
    [name] => Kluun
    [description] => Author living in Amsterdam
    [Books] => Array
        (
            [0] => Array
                (
                    [id] => 5
                    [author_id] => 3
                    [title] => Komt een vrouw bij de dokter
                    [released] => 2003
                )
            [1] => Array
                (
                    [id] => 6
                    [author_id] => 3
                    [title] => De weduwnaar
                    [released] => 2006
                )
        )
)

Let’s give it a first try:

$author = Doctrine::getTable('Author')->find('3');
$book1 = Doctrine::getTable('Book')->find('5');
$book2 = Doctrine::getTable('Book')->find('6');
 
$form = new AuthorForm($author);
$form->embedForm('book1', new BookForm($book1));
$form->embedForm('book2', new BookForm($book2));
 
echo "<table>".$form."</table>";

After doing a $form->bind($request->getParameter('author')); the form returns values: (`$form->getValues()’)

Array
(
    [id] => 3
    [name] => Kluun
    [description] => Author living in Amsterdam
    [book1] => Array
        (
            [id] => 5
            [author_id] => 3
            [title] => Komt een vrouw bij de dokter
            [released] => 2003
        )
    [book2] => Array
        (
            [id] => 6
            [author_id] => 3
            [title] => De weduwnaar
            [released] => 2006
        )
)

Getting close, but actually we need a temporary form for embedding all book-objects first. When all forms are added we can embed this temporaryform with the name of the relation into the form.

The following code gives the right $form->getValues() array structure:

$form = new AuthorForm($author);
 
$tempform = new sfForm();
$tempform->embedForm('0', new BookForm($book1));
$tempform->embedForm('1', new BookForm($book2));
 
$form->embedForm('Books',$tempform);
Array
(
    [id] => 3
    [name] => Kluun
    [description] => Author living in Amsterdam
    [Books] => Array
        (
            [0] => Array
                (
                    [id] => 5
                    [author_id] => 3
                    [title] => Komt een vrouw bij de dokter
                    [released] => 2003
                )
            [1] => Array
                (
                    [id] => 6
                    [author_id] => 3
                    [title] => De weduwnaar
                    [released] => 2006
                )
        )
)

Bingo, this response exactly matches the array we got from the $author->toArray(true) method!

Hooking things up

Now we have to implement this in a new function. The doctrine:build-forms command also creates a BaseFormDoctrine.class.php where we can put our code. It inherits from sfDoctrineForm and is extended by all generated forms. BaseFormDoctrine.class.php is located in the lib/form/doctrine/base directory.

The following function will do the trick:

  public function embedRelation($relationName)
  {    
    $formClass = $this->object->getTable()->getRelation($relationName)->getClass()."Form";
 
    $subForm = new sfForm();
    foreach ($this->object[$relationName] as $index => $childObject)
    {
      $form = new $formClass($childObject);
    }      
    $this->embedForm($relationName, $subForm);
  }

Saving the values

Using the above code, the form will display right, but only the values of the Author are stored. Now we need to override some default behaviour. In sfDoctrineForm the method updateDefaultsFromObject merges arrays only one level deep; all array_merge calls should be changed into sfForm::deepArrayUnion calls. The function updateObject should also be changed to enable ‘deep’ setting of values, change the last line into $this->object->fromArray($values, true);. See the attached code for my full version of BaseFormDoctrine.class.php.

Reflection

Using relations to extend your forms is a powerful feature. Real-life usage demands a bit more than written in this article:

  1. There’s a danger of recursion, so the system has to detect this.

  2. The article only spoke about one-to-many relations, but one-to-one should also be handled.

  3. One-to-many lists (in our example the dropdown in a Book form for selecting the Author) should be removed from the form, as it creates an unintuitive interface.

  4. The developer should be able to override the classname of the embedded form and should also be able to change the options of the embedded forms

All these features are implemented in the attached code. Have a look and have fun! Thanks for reading

Last note: Today the sfForm was updated and now has a a new method ->getEmbeddedForms(). This enables other approaches to the problem, I’ll update when the sfDoctrinePlugin is up to date with this new feature.

baseformdoctrineclass