Battling (My Own) Idiocy

a.k.a. do everything wrong the first time just to get it out of the way

Implementing a Nested Set (part two)

Posted by halestock on February 4, 2010

In my last post, I used a custom widget and validator to represent a nested set in Doctrine. Of course, they aren’t much use without actually being implemented, so in this post I will show how to go about creating them.

While it’s fine to use a normal sfWidgetFormDoctrineChoice in the form to select a parent node, I found myself wishing there was a way to display the items in the dropdown formatted like they would be in a tree. I first tried using the sfWidgetFormDoctrineChoiceGrouped, but it turns out that the grouping is accomplished by using the <optgroup> tag, which does not allow the group header itself to be selected. My solution was to create a custom widget, called sfWidgetFormDoctrineNestedSet, which extends sfWidgetFormDoctrineChoice.

// /lib/widget/sfWidgetFormDoctrineChoiceNestedSet.class.php
class sfWidgetFormDoctrineChoiceNestedSet extends sfWidgetFormDoctrineChoice
{
  public function getChoices()
  {
    $choices = array();
    if (false !== $this->getOption('add_empty'))
    {
      $choices[''] = true === $this->getOption('add_empty') ? '' : $this->getOption('add_empty');
    }

    if (null === $this->getOption('table_method'))
    {
      $query = null === $this->getOption('query') ? Doctrine_Core::getTable($this->getOption('model'))->createQuery() : $this->getOption('query');
      $query->addOrderBy('root_id asc')
            ->addOrderBy('lft asc');
      $objects = $query->execute();
    }
    else
    {
      $tableMethod = $this->getOption('table_method');
      $results = Doctrine_Core::getTable($this->getOption('model'))->$tableMethod();

      if ($results instanceof Doctrine_Query)
      {
        $objects = $results->execute();
      }
      else if ($results instanceof Doctrine_Collection)
      {
        $objects = $results;
      }
      else if ($results instanceof Doctrine_Record)
      {
        $objects = new Doctrine_Collection($this->getOption('model'));
        $objects[] = $results;
      }
      else
      {
        $objects = array();
      }
    }

    $method = $this->getOption('method');
    $keyMethod = $this->getOption('key_method');

    foreach ($objects as $object)
    {
      $choices[$object->$keyMethod()] = str_repeat('&nbsp;', ($object['level'] * 4)) . $object->$method();
    }

    return $choices;
  }
}

The configure method here differs from sfWidgetFormDoctrineChoice’s in only two ways: First, a sort is automatically added to the query which displays all the items of the nested set according to their hierarchy. Second, spaces have been added to the displayed choice which indent it according to what level in the hierarchy it is. I specified 4 spaces since that seemed to be the most aesthetically pleasing, but that certainly can be changed. One caveat, however, is that if a table_method is specified the result will not automatically be sorted, because there is no query to attach the sort to (which follows the behavior of sfWidgetFormDoctrineChoice).

Along with the custom widget, I added a new validator to ensure that when moving an existing node, it can not be placed as a descendant of itself. Although, similar to before, there is a validator called sfValidatorDoctrineNestedSetLevel (included in sfFormExtra), it turns out to be not very helpful since it only validates that a node does not exceed the maximum depth specified by the nested set. So, once again, I set out on my own and created sfValidatorDoctrineChoiceNestedSet.

// /lib/validator/sfValidatorDoctrineChoiceNestedSet.class.php
class sfValidatorDoctrineChoiceNestedSet extends sfValidatorBase
{
  /**
   * Configures the validator.
   * Available options:
   *   model: The model class (required)
   *   node:   The node being moved (required)
   *
   * @see sfValidatorBase
   */
  protected function configure($options = array(), $messages = array())
  {
    $this->addRequiredOption('model');
    $this->addRequiredOption('node');

    $this->addMessage('node', 'A node cannot be set as a child of itself.');
  }

  protected function doClean($value)
  {
    if (isset($value) && !$value)
    {
      unset($value);
    }
    else
    {
      $targetNode = Doctrine::getTable($this->getOption('model'))->find($value)->getNode();
      if ($targetNode->isDescendantOfOrEqualTo($this->getOption('node')))
      {
        throw new sfValidatorError($this, 'node', array('value' => $value));
      }

      return $value;
    }
  }
}

As I mentioned, this validator will only check to make sure that the target node is not a descendant of (or is) the node specified in the options. It’s possible that there might be other conditions that we would need to check, but for me this seemed to work pretty well.

Now, when you go to add a new item or move an existing one, you should see this nice, pretty dropdown indicating the current structure of the nested set:

At this point, there’s nothing left to do to in terms of the behavior of the nested set, you should be able to add these widgets and validators as you see fit. As I mentioned in my last post, I still want to show how to display the list of items in the context of the admin generator so that they look good. But as this is already running long, I’ll show how to do so in the next post.

About these ads

11 Responses to “Implementing a Nested Set (part two)”

  1. paulodani said

    Works like a charm. Thanks !

  2. Sean said

    Thanks halestock

    One thing:

    /lib/widget/sfWidgetFormDoctrineChoiceNestedSet.class.php appears to be missing a $ at $object->method(); Line 48

  3. Borcho said

    Thanks. This is exactly what I was looking for!

  4. omnom said

    Very helpful, thank you

  5. strike said

    How can I use this to use this formatted drop-down in one of my forms where I have to relate these categories to some content.

    (I am using it for content categories)

    Thanks for this excellent post.

  6. strike said

    Thank you very much for this excellent implementation.

    I am using form widget sfWidgetFormDoctrineChoiceNestedSet in other forms but I cannot seem to be able to use your validator in other forms as it requires object for ‘node’.

    How can I use it in other form where I am using said form widget?

    • halestock said

      In order to prevent making an object a child of itself, you need to pass the current form’s object to the validator so it can compare the value of the widget with the object. In my example, I assign the current form’s object to ‘node’ by using:

      ‘node’ => $this->getObject(),

      in the configure() method of the categoryForm.

      Let me know if this helps.

  7. vibby said

    I think you have a mistake in this code :

    $query->OrderBy('root_id asc')
    ->addOrderBy('lft asc');

    is better, because Doctine add the first ordering critera “id” as a default. To escape it, set explicitly the first ordering critera.

    On my side, I implemented the possibility to get a tree of checkboxes from nestedset. Coming soon :)

  8. [...] http://halestock.wordpress.com/2010/02/04/symfony-implementing-a-nested-set-part-two/ [...]

Leave a Reply

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out / Change )

Twitter picture

You are commenting using your Twitter account. Log Out / Change )

Facebook photo

You are commenting using your Facebook account. Log Out / Change )

Connecting to %s

 
Follow

Get every new post delivered to your Inbox.

%d bloggers like this: