Archive for the ‘user interface’ 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!

Interactive embedded forms

Tuesday, November 11th, 2008

Today I want to demonstrate how to make interactive forms with sfForm and Doctrine. The target is to create an interface which edits an object, edits related objects and allows you to add or remove related objects. See the screenshot below for the end-result:

firefoxscr007

Preparation

  • To illustrate my way of installing I added a full history of what I did. The tutorial will start in the next paragraph. * I started with a fresh 1.2beta project with something like symfony generate:project sandbox. After the project setup I ran:

1) ./symfony generate:app frontend to create the frontend app

2) Enable Doctrine: config/ProjectConfiguration.class.php

<?php
 
require_once dirname(__FILE__).'/../../lib/symfony/1.2/lib/autoload/sfCoreAutoload.class.php';
sfCoreAutoload::register();
 
class ProjectConfiguration extends sfProjectConfiguration
{
  public function setup()
  {
    $this->enablePlugins(array('sfDoctrinePlugin'));
    $this->disablePlugins(array('sfPropelPlugin'));
  }
}

3) I installed a SQLite db: config/databases.yml

all:
  doctrine:
    class:          sfDoctrineDatabase
    param:
      dsn:          sqlite:///<?php echo dirname(__FILE__); ?>/../log/sqlite.db

4) I made the schema.yml

---
Author:
  columns:
    name: string(255)
    description: string
 
Book:
  columns:
    author_id: integer(20)
    title: string(255)
    released: integer(20)
  relations:
    Author:
      foreignAlias: Books

5) I put some sample data in /data/fixtures/fixtures.yml. There’s a link to the file at the end of this post.

6) Run some fun stuff at the command line: ./symfony doctrine:build-all-load followed by ./symfony cc and to setup the right permissions sudo ./symfony project:permissions.

7) Now I create a default doctrine module to edit the Author table: ./symfony doctrine:generate-module frontend author Author. Everything should be up and running now, visiting http://your_host/frontend_dev.php/author should give you something like:

finderscr0011

The Author form is still very basic:

firefoxscr0011

Automatic templates

First step is to remove the strict formatting in the template and enable automattic formatting. As I’m lazy I want sfForm to do the layout work for me. Open up the /apps/frontend/modules/author/templates/_form.php partial and remove some code. The following template will display sfForm using it’s own __toString() rendering system:

<?php include_stylesheets_for_form($form) ?>
<?php include_javascripts_for_form($form) ?>
 
<form action="<?php echo url_for('author/'.($form->getObject()->isNew() ? 'create' : 'update').(!$form->getObject()->isNew() ? '?id='.$form->getObject()->getid() : '')) ?>" method="POST" <?php $form->isMultipart() and print 'enctype="multipart/form-data" ' ?>>
<?php if (!$form->getObject()->isNew()): ?>
<input type="hidden" name="sf_method" value="PUT" />
<?php endif; ?>
  <table>
    <tfoot>
      <tr>
        <td colspan="2">
          &nbsp;<a href="<?php echo url_for('author/index') ?>">Cancel</a>
          <?php if (!$form->getObject()->isNew()): ?>
            &nbsp;<?php echo link_to('Delete', 'author/delete?id='.$form->getObject()->getid(), array('post' => true, 'confirm' => 'Are you sure?')) ?>
          <?php endif; ?>
          <input type="submit" value="Save" />
        </td>
      </tr>
    </tfoot>
    <tbody>
      <?php echo $form ?>
    </tbody>
  </table>
</form>

Don’t be impressed here; I only removed the code in the <tbody></tbody> section and removed the call to $form->renderHiddenFields(). Your form should still look exactly the same as in the screenshot supplied before.

Embed the forms

Now we can add the forms for all the books an Author has written to the form. Open /lib/form/doctrine/AuthorForm.class.php and add some intelligence:

class AuthorForm extends BaseAuthorForm
{
  public function configure()
  {
    foreach ($this->object['Books'] as $index=>$book)
    {
      $this->embedForm('book'.$index, new BookForm($book));
    }
  }
}

This works, now you can edit all books:

firefoxscr002

Adding interactivity

The form works well and saves all values, but it is not possible to remove a book or add a new one yet. To achieve this we can add some more submit buttons, for each book one. This will inform the server what the user wants to delete or insert. I also restructured the code a bit to make it easier to understand.

  public function configure()
  {
    foreach ($this->object['Books'] as $index=>$book)
    {
      $fieldName = 'book_'.$book['id'];
 
      $form = new BookForm($book);
      unset($form['author_id']);
      $this->embedForm($fieldName, $form);
 
      $label = '<input type="submit" name="submit" value="delete_'.$book['id'].'">';
      if (count($this->object['Books']) -1 == $index)
      {
        // this is the last book
        $label.= '<input type="submit" name="submit" value="insert">';
      }
      $label.= ' Book '.($index+1);
 
      $this->widgetSchema->setLabel($fieldName, $label);
    }
  }

firefoxscr005

I admit, it’s quite ugly on the screen. But in the action we will now have a parameter submit which is set to insert when the insert-button is clicked. It’s set to delete_* when one of the delete-buttons is clicked (* is the id of the related book), or it’s not set, then the normal ‘Save’ button is clicked.

Let’s work on /apps/frontend/modules/author/actions/actions.class.php to enable the form to react to these new parameters:

  public function executeUpdate(sfWebRequest $request)
  {
    $this->forward404Unless($request->isMethod('post') || $request->isMethod('put'));
    $this->forward404Unless($author = Doctrine::getTable('Author')->find($request->getParameter('id')), sprintf('Object author does not exist (%s).', $request->getParameter('id')));
 
    //get the submit value from the request, and split it into an array of command, id
    $submit = ($request->hasParameter('submit') ? $request->getParameter('submit') : '_');
    $submit = explode('_',$submit);
 
    switch ($submit[0])
    {
      case 'insert':
        $author['Books'][] = new Book();
        break;
      case 'delete':
        $this->forward404Unless($book = Doctrine::getTable('Book')->find($submit[1]));
        $book->delete();
        break;
    }
    $this->form = new AuthorForm($author);
 
    $this->processForm($request, $this->form);
 
    $this->setTemplate('edit');
  }

This code works! After pressing one time on the insert button we can see that a new book is added to the form. Thanks to Doctrine the newly created book know’s it’s written by the Author. This is because the record defaults are automatically used as form defaults. According to the image below the only thing we need to do is some styling:

firefoxscr006

Styling the submit buttons

Luckily styling will be very easy. The old man at the wc3 will help a little, as they have once defined an <input type='image'>. This input looks like an image, but clicking it will submit the form. Let’s update the AuthorForm.class.php again:

  public function configure()
  {
    foreach ($this->object['Books'] as $index=>$book)
    {
      $fieldName = 'book_'.$book['id'];
 
      $form = new BookForm($book);
      unset($form['author_id']);
      $this->embedForm($fieldName, $form);
 
      $label = '<input type="image" src="/images/delete.gif" name="submit" value="delete_'.$book['id'].'">';
      if (count($this->object['Books']) -1 == $index)
      {
        // this is the last book
        $label.= '<input type="image" src="/images/add.gif" name="submit" value="insert">';
      }
      else
      {
        $label.= '<img src="/images/empty.gif">';
      }
      $label.= ' Book '.($index+1);
 
      $this->widgetSchema->setLabel($fieldName, $label);
    }
  }

Now our form looks and works the way we want:

firefoxscr007

Some more things to consider

Deleting a book will give you an Unexpected extra form field named "book2". error. This can be undone if you unset this data from the request. It needs some more work as the default generated processForm function has to be changed:

  protected function processForm($data, sfForm $form)
  {
    $form->bind($data);
    if ($form->isValid())
    {
      $author = $form->save();
 
      $this->redirect('author/edit?id='.$author['id']);
    }
  }
 
  public function executeUpdate(sfWebRequest $request)
  {
    $this->forward404Unless($request->isMethod('post') || $request->isMethod('put'));
    $this->forward404Unless($author = Doctrine::getTable('Author')->find($request->getParameter('id')), sprintf('Object author does not exist (%s).', $request->getParameter('id')));
    $data = $request->getParameter('author');
 
    //get the submit value from the request, and split it into an array of command, id
    $submit = ($request->hasParameter('submit') ? $request->getParameter('submit') : '_');
    $submit = explode('_',$submit);
 
    switch ($submit[0])
    {
      case 'insert':
        $author['Books'][] = new Book();
        break;
      case 'delete':
        $this->forward404Unless($book = Doctrine::getTable('Book')->find($submit[1]));
        unset($data['book'.$book['id']]);
        $book->delete();
        break;
    }
    $this->form = new AuthorForm($author);
 
    $this->processForm($data, $this->form);
 
    $this->setTemplate('edit');
  }

Also make sure to always check the $request->getParameter('submit') for validity. It’s not secured by the sfForm security system so you have to use it cautiously.

That’s it for today, thanks for reading!

The fixtures.yml file used

Full source of this project (Make sure you change /config/ProjectConfiguration.class.php to point to the right location of the Symfony 1.2 Library)