How to create multirecord forms

By Marcel Raaijmakers (Marcelius)
One of the things I found a bit difficult to accomplish was the ability to create, update and delete multiple records in one HTML page. In this article I will show you the basic principles of this concept together with a simple implementation. For advanced Cake developers, this article will most likely sound as a ‘been there done that’ tutorial. But for beginning bakers I think this article will help you better understand this concept, but I do expect you already know the CakePHP basics like naming conventions etc. I should also note that for the sake of simplicity I tried to write as less code as possible just to make things work.

Before you start: You need to download the prototype.js from http://prototypejs.org and put it in the webroot/js folder because I'm using a small piece of ajax here too ;-)
Let’s get to work :-) For demostration purposes, let’s create a table called ‘news’ with the following fields:

id [int 10] title [varchar 255] message [text]
Also, create an empty News model class and a News controller with scaffolding enabled. Now test if your setup is working by adding some records. This is also nessecary because I will start by creating the “edit” method first, so it would come in handy if you already have some records available.

Editing mulitple records

In order to make multirecord “edit forms”, we need to change the structure of the array returned by find(“all”, ...) method calls. The reason for this is the CakePHP convention. Normaly the name of an input field is in the format “model.fieldName”. With multirecord forms, we should be using “model.number.fieldName”. A find(“all”, ..) method will return arrays in this format: “number.model.fieldName”. As you can see, these don’t match, so the FormHelper class will not be able to for example automaticly fill in the input fields with the correct values. I’ve created a Component (MultirecordComponent) with a “rewrite” method for this problem, which you can find at the end of this article. In this component I’ve also added some convenients methods for validation.


Here’s a part of the code for the edit method in the NewsController:

Download code
<?php
    
/**
     * Edit one or more items at the same time
     *
     * @param mixed Array of id's to edit, or a comma seperated string with id's
     */
    
function edit($ids){
        if (!
is_array($ids)){
            
$ids explode(","$ids);
        }

        
//render with data from database
        
if (empty($this->data)){                
            
$this->data $this->Multirecord->toMulti($this->News->findAllById($ids));
        } else {
            
//ommitted
        
}
        
$this->set("ids"$ids);
    }
?>

And here is the code for the view of the edit method. As you can see, I’ve moved the actual code for the input views in a seperate element. The reason for this is that this piece of code shall be used by different other methods. So it keep things DRY :-)

View Template:

Download code
<?php
    
echo $form->create(array("url"=>array("action"=>"edit"implode(","$ids))));
    
    foreach (
$ids as $nr=>$id) {
        echo 
$this->element("news.edit", array("id"=>$id));
    }
    
    echo 
$form->submit("Save");    
    
    echo 
$form->end();
?>

And here is the code of the “news.edit” element:

Download code
<?php
    $tmp 
$form->hidden("{$id}.News.id");    
    
$tmp .= $form->input("{$id}.News.title");    
    
$tmp .= $form->input("{$id}.News.message", array("type"=>"textarea"));
    
    echo 
$html->tag("fieldset"$html->tag("legend""News") . $tmp);
?>

Now if you go to “your_url.com/news/edit/1,2” you should see 2 forms of the records with id 1 and 2 (If available of course). Together with the code in the edit method of the NewsController that I ommited before (see the complete code at end of this article), you should be able to actually save all the records.

Adding multiple records

So now that we’re able to edit multiple records, let’s add multiple records. As you meight have noticed, I’ve named the input fields “model.recordId.fieldName”. When adding one or more records, there is no record id available of course. To overcome this problem, I use a random generated id:

Download code
<?php
    
/**
     * Add one or more items to the database
     *
     */
    
function add(){
        
//if posted data available, validate and save
        
if (empty($this->data)){
            
$this->set("ids", array(rand()));
        } else {
            if (
$this->Multirecord->validate()){
            
                if (
$this->Multirecord->save()){
                    
$this->flash("All items are saved", array("controller"=>"news""action"=>"index"));
                } else {
                    
$this->flash("Nope, that didn't work out quite well...", array("controller"=>"news""action"=>"index"));
                }
            } else {
                
//data does not validate, now show all inputs
                
$this->set("ids"array_keys($this->data));
                
            }
        }
    }
?>

What we do now is create a view for the add method. Beside the normal ‘submit’ button, I’ve also added a ‘Add new record’ button to dynamicly add more “edit forms”. This is done by an ajax call to the addNewsItem method in the NewsController. This method will render the “news.edit” element. which will be inserted just below the exisiting edit form.

View Template:

Download code
<?php

    
echo $javascript->link("prototype"false);
    
    echo 
$form->create();
        echo 
$ajax->div("newsEditContainer");
        
        
//foreach posted id, show an input form
        
foreach ($ids as $id) {
            echo 
$this->element("news.edit", array("id"=>$id));
        }
            
        echo 
$ajax->divEnd("newsEditContainer");

        echo 
$form->submit("Save");    
    echo 
$form->end();
    
    
    
//create an 'add' link
    
echo $html->link("Add another news item"
        
"javascript:new Ajax.Updater('newsEditContainer', '" Helper::url(array("controller"=>"news""action"=>"addNewsItem")) . "', {insertion: Insertion.Bottom});");
?>

Deleting multiple records

Now you should be able to add and edit multiple records. All that is left is deleting multiple records. Here’s the code in NewsController:

Download code
<?php
        
/**
         * Deletes one or more items
         *
         * @param mixed int or array of id's to delete
         */
        
function delete($ids){
            if (!
is_array($ids)){
                
$ids explode(","$ids);
            }

            
$this->News->deleteAll( array("News.id"=>$ids) );            
            
            
$this->flash("Item(s) are deleted", array("controller"=>"news""action"=>"index"));
        }
?>

You can test this with the following url: “your_url.com/news/delete/1,2”.

So there we have it, creating, editing and deleting multiple records in one simple user interface.

Some final notes:
  • The toMulti() method in the MultirecordComponent class doesn't work well if you have relations relations in your model. With some extra programming effords it is possible to adjust the code so it will also rewrite records from related models. I haven't done this to keep things simple
  • I wasn't able to test the code in PHP4. It is possible that some problems can occur with objects & references

Here’s the complete code of both the NewsController and the MultirecordComponent:

Controller Class:

Download code <?php 
    
class NewsController extends AppController{
        var 
$scaffold;
    
        var 
$components = array("Multirecord");
        
        var 
$helpers = array("html""form""ajax""javascript");
        
        
        
/**
         * Add one or more items to the database
         *
         */
        
function add(){
            
//if posted data available, validate and save
            
if (empty($this->data)){
                
$this->set("ids", array(rand()));
            } else {
                if (
$this->Multirecord->validate()){
                
                    if (
$this->Multirecord->save()){
                        
$this->flash("All items are saved", array("controller"=>"news""action"=>"index"));
                    } else {
                        
$this->flash("Nope, that didn't work out quite well...", array("controller"=>"news""action"=>"index"));
                    }
                } else {
                    
//data does not validate, now show all inputs
                    
$this->set("ids"array_keys($this->data));
                    
                }
            }
        }
        
        
        
/**
         * Edit one or more items at the same time
         *
         * @param mixed Array of id's to edit, or a comma seperated string with id's
         */
        
function edit($ids){
            if (!
is_array($ids)){
                
$ids explode(","$ids);
            }
            
            
//render with data from database
            
if (empty($this->data)){
                
                
$this->data $this->Multirecord->toMulti($this->News->findAllById($ids));

            } else {
                
                if (
$this->Multirecord->validate()){
                    if (
$this->Multirecord->save()){
                        
$this->flash("All saved!", array("action"=>"index"));
                    } else {
                        
$this->flash("Too bad something unexcpected happend", array("action"=>"index"));
                    }
                }
                
            }
            
            
            
$this->set("ids"$ids);
        }
        
        
        
/**
         * Deletes one or more items
         *
         * @param mixed int or array of id's to delete
         */
        
function delete($ids){
            if (!
is_array($ids)){
                
$ids explode(","$ids);
            }

            
$this->News->deleteAll( array("News.id"=>$ids) );            
            
            
$this->flash("Item(s) are deleted", array("controller"=>"news""action"=>"index"));
        }
        
        
        
/**
         * Called via ajax call to render another input screen
         *
         */
        
function addNewsItem(){
            
Configure::write("debug"0); //don't want the cake debug in de ajax response
            
$this->set("id"rand());
            
$this->render("","""../elements/news.edit");
        }
        
        
    }
?>


Component Class:

Download code <?php 
    
/**
     * Component class to help saving multiple records
     * @author Marcel Raaijmakers aka Marcelius
     *
     */
    
class MultirecordComponent extends Component{
        
        
// Saving a reference to the controller on the component instance
        
public function startup(&$controller) {
            
$this->controller = &$controller;
        }
        
        
        
/**
         * Converts array from findAll* methods (nr.className.fieldName) in the format of className.recordId.fieldName
         *
         * @param array The data in the format nr.className.fieldName
         * @return array in the format className.recordId.fieldName
         */
        
function toMulti($data){
            
$primaryKey $this->controller->{$this->controller->modelClass}->primaryKey//usualy 'id'

            
$result = array();

            if (
is_array($data)){
                foreach (
$data as $record) {
                    if (
$ar = @each($record)){
                        
$result[$ar["value"][$primaryKey]][$ar["key"]] = $ar["value"]; 
                    }
                }
                
            }

            return 
$result;
        }
    
        
        
/**
         * Validate mulitple records
         * On validate failure, the validation result will be passed to that model
         *
         * @return boolean True if all records validate
         */
        
function validate(){
            
$validationErrors = array();
            
            
$model $this->controller->{$this->controller->modelClass};

            foreach (
$this->controller->data as $id=>$data) {
                
$model->create($this->controller->data[$id]);

                
                
//if doesn't validate, add to array
                
if (!$model->validates()){
                    
$validationErrors[$id][$this->controller->name] = $model->validationErrors;
                }
            }
            
            
            if (!empty(
$validationErrors)){
                
$model->validationErrors $validationErrors;
            }
            
            
            return empty(
$validationErrors);
        }
        
        
        
/**
         * Saves all records in this->controller->data
         *
         * @return boolean True if all records are saved
         */
        
function save(){
            
$model $this->controller->{$this->controller->modelClass};
            
            
$result $model->saveAll($this->controller->data, array("validate"=>false));
            
            return 
$result;
        }
        
        
    }
?>

 

Comments 736

CakePHP Team Comments Author Comments
 

Comment

1 Field names not consistent

In your comments, you are saying As you meight have noticed, I’ve named the input fields “model.recordId.fieldName”., but in your sample code $tmp = $form->hidden("{$id}.News.id"); , you're putting IDs first. In fact, your "toMulti" has the same problem -- the toMulti comments indicate that the function is creating model.id.field, yet it actually creates id.model.field...

From my experience, formHelper prefers model.id.field, i.e. you can simply say $form->input("$id.field") without having to explicitly specify the model.

One concern I'm having is that once ID is embedded in the form field name, how do I display a blank form for new additions? You seem to handle it by generating random IDs:         if (empty($this->data)){
            $this->set("ids", array(rand())); 

...but wouldn't that be a problem if one of your randomly generated IDs matches a real ID when you try to save these new items?
Posted Oct 23, 2008 by Stan
 

Comment

2 Reply to Stan

Hi Stan,

Thanks for your reply and minding me of that inconsistency, I'll correct that. It should be model.id.field everywhere. The reason for this is that a field named "0.field" is not correctly parsed as an array when writing that article. So that's the reason for allways using model.id.field. Another reason is that it's easy to edit / handle multiple records on multiple models by using "News.3.title" or "Person.4.name".

For that random id thing you are correct that is a problem. In my original situation what inspired me to write this article I wasn't using a random number (or string), but a language prefix. So adding records would be for example:

News.nl.title
News.en.title

That whay you can easily tell what records belong to what language.
Posted Oct 24, 2008 by Marcel Raaijmakers
 

Comment

3 parsing error

I was trying to run this example, and I get this error:
Parse error: syntax error, unexpected T_STRING, expecting T_OLD_FUNCTION or T_FUNCTION or T_VAR or '}' in /my_url/app/controllers/components/multirecord.php on line 10

What did I do grong?

thanks in advance,
Carlos
Posted Jan 2, 2009 by Carlos Viscarra
 

Bug

4 I don't know this is a bug or not

Hi,

I had to change your Add another news item to be like this, because I couldn't get your working...


echo $html->link("Add another news item","#",array('onclick' => "javascript:new Ajax.Updater('newsEditContainer', '" . Helper::url(array("controller"=>"news", "action"=>"addNewsItem")) . "', {insertion: Insertion.Bottom});"));

Is it allright ? or I am doing it wrong

I am using Cake 1.2.2.8120 Stable

Thx
Posted Apr 5, 2009 by Rudy
 

Comment

5 Thanks Marcel and Rudy!

This is exactly what I needed for my project! And yes Rudy, I was having the same problem, and your add link fixed it!
Posted Apr 11, 2009 by Mathieu Manaigre