RSS

Neil Crookes

Learnings and Teachings on Web Application Development & CakePHP

Feb

9

CakePHP Sequence Behavior

A CakePHP behavior for maintaining a contiguous sequence of order values. Records can be grouped by zero, one or multiple fields and the contiguous sequence is maintained for each group when adding, editing or deleting records.

Share and Enjoy:

  • Digg
  • del.icio.us
  • StumbleUpon
  • Technorati
  • Slashdot

For the impatient among you, here’s a demo (drag’n'drop the table rows, then click refresh to see the order field updated) and the code.

Controlling the order of things on a web site is a very frequent requirement.

It might apply to navigation items, lists of frequently asked questions, blog post categories, blocks of content on a page, images in a gallery etc etc.

You also often need to maintain separate lists in the same database table, identified by a group field, e.g. navigation items are typically grouped by a parent field.

The traditional way of controlling order is to have an order field in your database table, and drag and drop functionality in your index views which, when you drop a row, sends a serialized list of record IDs and their orders via Ajax to a specified controller action. Your application then unserializes the data and iterates through it, updating the order of each record in turn.

This approach is rubbish, for 2 reasons:

  1. A separate database update for every single record.
  2. If you move a record from one group to another, you need special logic to make sure the moved record appears at the bottom of the new group, and the orders in the old group have gaps, until you reorder it again!

This is inefficient and messy, so I wrote the SequenceBehavior.

The SequenceBehavior maintains a contiguous sequence of order values for all records in a database table whether adding, editing (including moving a record to a new group) or deleting them, and they can be grouped by zero, one or multiple fields. It works out which rows need updating then does them all in a single db call.

Here’s some pseudo code:

  • Save:
    • If adding new record:
      • If order not specified:
        • Insert at end of list i.e. highest order + 1
      • If order specified:
        • Inserts at specified order
        • Increments order of all other records whose order >= order of inserted record
    • If editing existing record:
      • If order not specified and group not specified, or same:
        • No Action
      • If group specified and different:
        • Decrement order of all records whose order > old order in the old group, and change order to highest order in new group + 1
      • If order specified and group not specified or same:
        • If new order < old order:
          • Increments order of all other records whose order >= new order and order < old order
        • If new order > old order:
          • Decrements order of all other records whose order > old order and <= new order
        • If new order == old order:
          • No action
  • Delete:
    • Decrement order of all records whose order > order of deleted record

To add it to your application, just grab the behavior from my github account and save it in your app/models/behaviors/ directory, then attach it to your models as follows:

For a model whose records are not grouped:

<?php
class Item extends AppModel {
    var $name = 'Item';
    var $actsAs = array('Sequence');
}
?>

For a model whose records are grouped by a single field:

<?php
class GroupedItem extends AppModel {
    var $name = 'GroupedItem';
    var $actsAs = array('Sequence' => array('group_fields' => 'group_field'));
}
?>

For a model whose records are grouped by multiple fields:

<?php
class MultiGroupedItem extends AppModel {
    var $name = 'MultiGroupedItem';
    var $actsAs = array('Sequence' => array('group_fields' => array('group_field_1', 'group_field_2')));
}
?>

There are 2 other configuration options not seen in the above examples. Firstly, the default order field is ‘order’, but you can override this by passing in an ‘order_field’ option in the config array. The second is a ’start_at’ option which determines the order number your sequence starts at, typically 0 (default), or 1.

The SequenceBehavior also modifies any model’s order property that it is attached to, which automaically gets used as the default order in CakePHP’s Model::find() calls.

I’ve put a demo of the behavior on the labs section of my site. The 3 models listed above are availalable to play with. It’s just a quick baked demo, with some small additions for filtering the results into groups. I’ve also added some javascript magic to do the drag and drop re-ordering and Ajax save with a bit of help from jQuery and the Table Drag n Drop jQuery plugin from Isocra. Drag the rows then to check it’s working you can refresh the page and the row you dragged should be where you left it and the orders of all the records should update to maintain the sequence.

My jQuery is extreme beginner level, but here’s the code I added to my app/layouts/default.ctp to hook the parts together:

<script type="text/javascript">
$(document).ready(function() {
  $("div.index table").tableDnD({
    url: "<?php echo Router::url(array('action' => 'save_order')); ?>",
    originalOrder: null,
    onDragClass: "myDragClass",
    onDragStart: function(table, row) {
      originalOrder = jQuery.inArray(row, $("tr", table));
    },
    onDrop: function(table, row) {
      var newOrder = jQuery.inArray(row, $("tr", table)) - 1;
      if (newOrder != originalOrder) {
        $.post(this.url+'/'+row.id, { 'data[<?php echo $model; ?>][order]': newOrder });
      }
    },
  });
});
</script>

Basically it add the drag’n'drop shizzle to all tables inside a div class index, and when you start dragging a row it notes the initial order/offset, then when you drop, if different, it posts the new order/offset to a controller/action in my app which saves the order, the code for that is not very pretty, but I’ll include it here for completion:

<?php
class AppController extends Controller {
  var $components = array('RequestHandler');
  var $helpers = array('Javascript');
  function save_order($id = null) {
    Configure::write('debug', 0);
    if (!$id || !is_numeric($id) || !isset($this->data[$this->modelClass]['order'])
    || empty($this->data[$this->modelClass]['order'])
    || !is_numeric($this->data[$this->modelClass]['order'])) {
      exit;
    }
    die(json_encode($this->{$this->modelClass}->save($this->data, true, array('order'))));
  }
?>

That’s it, it’s MIT licensed, so enjoy.

Share and Enjoy:

  • Digg
  • del.icio.us
  • StumbleUpon
  • Technorati
  • Slashdot
(6 votes, average: 3.83 out of 5)
Loading ... Loading ...

10 Responses so far

[...] CakePHP Sequence Behavior [...]

Really great! Thanks! This is just what I need for a project I’m working on.

Nice behavior – I was just about to write something with similar functionality but yours is great. Thanks for sharing!

Awesome work Neil! Exactly what I needed.

I did have to add:
$this->{$this->modelClass}->id = $id;

to avoid new records being inserted by save.

Thanks again!

Great behavior, and also a nice tutorial helping with jQuery TableDnD! However, the following line will cause attempts to move rows resulting in order=0 to fail.

empty($this->data[$this->modelClass]['order'])

My solution was simply to remove that sanity check, even though that might not be very elegant.

Another solution might of course have been to set the startAt value to 1.

Extremely useful behavior, thanks very much for sharing it!

However, I’m having a couple of problems using it and I’ve noticed they’re not working in your demo version either.

1) Handling of non-specified Order no longer works – if I try this on your version I get the error:

SQL Error: 1048: Column ‘order’ cannot be null [CORE/cake/libs/model/datasources/dbo_source.php, line 514]

I tried making the column allowed to be null on my version and all that happens is that I end up with a null order value (which then breaks the OldOrder code for any subsequent updates).

2) If I try to reorder the rows using the JQuery drag and drop rather than simply changing the order using the edit option, the moved row always appears one place higher in the order than it should be. So if I move an item into position 3, when I refresh it’ll appear in position 2.

I don’t know if this is perhaps related to the fact I can also drag an item above the paginate headings? (Meaning in the demo with orders 1, 2, 3, 4 and 5, dragging above the headings gives you order 0).

Do you happen to have a fix for either of these issues before I start working out one of my own?

Thanks again,

Nat.

Great article, keep up the good works.

Good stuff Neil!

I am going to try this in a project soon.

Thanks or sharing.

[...] CakePHP Sequence Behavior – Neil Crookes (February 9, 2009) Like so many other features, I was researching the best way to handle re-ordering of records via a CMS (e.g. so a user can change the order of photos in a gallery) when I chanced upon Neil’s handy little model behavior. His implementation is pretty smart in that it only executes database queries when it absolutely needs to, rather than just updating every record in a group when any order is changed. Throw in some JQuery AJAX drag and drop that Neil also provides – I’ve been a Prototype/Scriptaculous guy for a long time, but this is easy as pie! – and you’ve got a great re-ordering system in no time. [...]

Leave a comment