How to create multirecord forms
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 ;-)
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.
Here’s a part of the code for the edit method in the NewsController:
Download code
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 :-)
And here is the code of the “news.edit” element:
Download code
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.
Download code
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.
Download code
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:
Here’s the complete code of both the NewsController and the MultirecordComponent:
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
Comment
1 Field names not consistent
$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?
Comment
2 Reply to 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.
Comment
3 parsing 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
Bug
4 I don't know this is a bug or not
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
Comment
5 Thanks Marcel and Rudy!