Multi-record Forms

6 08 2007

One question that comes up time and again on the Google Group is “How do I make a single form for more than one record?”, which I call multi-record forms. Multi-record forms have a repeated section in the form for multiple entries of the same data. The example that I am going to use is an Author with many Books.

In CakePHP this technique will be most commonly used with hasMany associations. Note that this technique is not specific to CakePHP but the code and conventions used are.

Building the Form

Our example form consists of an Authors name, date of birth and up to 5 book records with title, year of publication and publisher.

Here is the view.

<form action="<?= $html->url('/authors/add'); ?>" method="post">
  <fieldset>
    <legend>Author Details</legend>
    <?= $form->input('Author.name'); ?>
    <?= $form->input('Author.dob', array('type' => 'date')); ?>
  </fieldset>
  <fieldset>
    <legend>Books Published</legend>
    <?php for ($i=0; $i<5; $i++) : ?>
    <fieldset>
      <legend>Book<?= $i; ?></legend>
      <label for="BookTitle<?= $i; ?>">Title</label> <input type="text" name="data[Book][<?= $i; ?>][title]" id="BookTitle<?= $i; ?>" />
      <?= $form->error('Book.'.$i.'.title'); ?><br />
      <label for="BookPublication<?= $i; ?>">Publication Year</label> <input type="text" name="data[Book][<?= $i; ?>][publication]" id="BookPublication<?= $i; ?>" />
      <?= $form->error('Book.'.$i.'.publication'); ?><br />
      <label for="BookPublisher<?= $i; ?>">Publisher</label> <input type="text" name="data[Book][<?= $i; ?>][publisher]" id="BookPublisher<?= $i; ?>" />
      <?= $form->error('Book.'.$i.'.publisher'); ?>
    </fieldset>
    <?php endfor; ?>
  </fieldset>
</form>

The first fieldset is the Author details and is a constructed like a regular form. The Books Published fieldset contains 5 sub-fieldsets that are created by a loop.

Each sub-fieldset contains inputs with names in the format:
name="data[ModelName][index][fieldName]"
N.B. Notice that there are no quotes surrounding the text in the square brackets.

Using existing Data

For an edit page the majority of the view would be the same. The loop would change to:

<?php for ($i=0; $i<count($this->data['Book']); $i++) : ?>
<fieldset>
  <legend>Book<?= $i; ?></legend>
  <input type="hidden" name="data[Book][<?= $i; ?>][id]" id="BookId<?= $i; ?>" value="<?= $this->data['Book'][$i]['id']; ?>" />
  <label for="BookTitle<?= $i; ?>">Title</label> <input type="text" name="data[Book][<?= $i; ?>][title]" id="BookTitle<?= $i; ?>" value="<?= $this->data['Book'][$i]['title']; ?>" />
  <?= $form->error('Book.'.$i.'.title'); ?><br />
  <label for="BookPublication<?= $i; ?>">Publication Year</label> <input type="text" name="data[Book][<?= $i; ?>][publication]" id="BookPublication<?= $i; ?>" value="<?= $this->data['Book'][$i]['publication']; ?>" />
  <?= $form->error('Book.'.$i.'.publication'); ?><br />
  <label for="BookPublisher<?= $i; ?>">Publisher</label> <input type="text" name="data[Book][<?= $i; ?>][publisher]" id="BookPublisher<?= $i; ?>" value="<?= $this->data['Book'][$i]['publisher']; ?>" />
  <?= $form->error('Book.'.$i.'.publisher'); ?>
</fieldset>
<?php endfor; ?>

We simply changed the loop to terminate when all the books have been looped over, added a value attribute to the inputs and added an hidden id field so that our records get updated correctly.

Saving the Submitted Form

To save the submitted form we loop over each index in the Books array and save it to the database with the authors id.

It is important that Model::create() gets called during each iteration otherwise you will end up inserting on the first iteration and then updating each subsequent iteration.

By using Model::set() we can take advantage of validation rules in the model. However, any validation error messages wont be associated with the correct Book fields. To get around this we manually invoke Model::validates() and if this fails we reassign the invalid fields to a temporary array. Then if there are any errors we feed this temporary array back to the model so that the view has the correct data.

function add(){
  if (!empty($this->data)) {
    if ($this->Author->save()) {
      // grab the id of the Author we just saved
      $author_id = $this->Author-id;
      
      $invalidBookFields = array();
      foreach($this->data['Book'] as $index => $book) {
        $book = array('Book' => $book);
        $book['Book']['author_id'] = $author_id;

        // clear any previous Book data
        $this->Author->Book->create();
        // We set the Book data this way so that validation is processed correctly
        $this->Author->Book->set($book);
        if (!$this->Author->Book->validates()) {
          // save the validationErrors and reset for the next iteration
          $invalidBookFields[$index] = $this->Author->Book->validationErrors;
          unset($this->Author->Book->validationErrors);
        } else {
          $this->Author->Book->save(); 
        }
      }
      
      if (empty($invalidBookFields)) {
      	// success - set message or redirect
      } else {
      	// put all the errors back in the model so they make it back to the view
      	$this->Author->Book->validationErrors = $invalidBookFields;
      }
    }
  }
}

Enhancements

If you need to make sure that all Books are valid before saving any of them you will need to loop the Books array twice. The first loop should validate each Book, then if there are no errors the second loop saves each Book.

If you do not know before hand how many items you need on your form you can use javascript or AJAX to dynamically add form elements as you need them.

Advertisements

Actions

Information

51 responses

9 08 2007
Luke Barker aka battez

Hi Geoff – very interesting – you did this a bit differently to what I did, the gotcha about no quotes was very revealing!

thanks for replying over in the group too.

Luke aka battez

10 08 2007
jon bennett

Hi Geoff,

I’ve been going through your post, I’ve got it working, except for a couple of things.

1. The array I’m passing to set appears to not work, as it’s missing it’s Model index in the array. Your passing:

$book[‘author_id’] = $author_id;

But the model is expecting (at least in my experience):

$book[‘Book’][‘author_id’] = $author_id;

2. I have no property $this->Author->Book->invalidFields – from looking through 1.2 code, model.php has a method invalidFields(0 not a property.

What I’ve ended up with is http://bin.cakephp.org/view/661444537

Which is working, but I’m unsure of why I needed to make those changes.

I’m using the nightly of 1.2

Thanks, Jon

10 08 2007
Geoff

Hi Jon,

1. Just took a quick look at the code. If you pass in a single dimension array it will assign that array to a key of ModelName so both mine and yours should work. Although adding the ModelName key explicitly is probably better. I will update the code in the post.

2. You are correct here – I will update the code. I wrote most of the code from memory and confused invalidFIelds() with validationErrors. 🙂

Thanks for the feedback.
Geoff

10 08 2007
jon bennett

Hi Geoff,

Something else I’ve noticed with my changes to adding the ModelName in point 1, is that my created/modified fields are no longer populated by cake automagically, which is a bit weird, though could be a bug as this is 1.2 alpha.

2. Ahh, cool.

On a side note, in your post above you mention using AJAX/js to add new items to the form. This is something I need to implement, but I’m not so far having much luck, do you have an example/code snippet I could look through, or perhaps point me me in the direction of a tute (not having much luck with google).

my project is using Prototype not jQuery so far.

Thanks man!

jb

10 08 2007
Geoff

@Jon – I forgot to mention – use Model::validationErrors over Model::invalidFields() as invalidFields is the method that actually calls the validation routines. Therefore calling validates() followed by invalidFields() will run the validation twice. This is not a real big problem, just a minor performance one.

Geoff

10 08 2007
Geoff

I have not actually created an AJAX implemention but there are two ways to tackle it.

1. WIth AJAX – load an action that takes one parameter ($index) that returns only the part of the form you are interested in. $index is used in the name=”data[Model][$Index][fieldName]”.

2. Use javascript to a add each field individually with no remote call.

I think that method 1 will be easier to manage. Personally I use jQuery which also means that I don’t use the AjaxHelper so I’m not sure on the best way to do method 1 using the “Cake way”.

10 08 2007
jon bennett

I’ve got route 1, but using Prototype. I’ve got the request going to a controller function, which renders a view (with the ajax layout) back. Couple of things I’m not sure about:

1. How to track how many items have been added (to keep each input unique. The way I’ve been trying is having a hidden input that holds the current value which is replaced with the view that gets rendered.

2. When I’ve worked with AJAX in the past, I usually replace an entire contents of the #id I passed to the function – this is how the $ajax helper works. I really need to add new stuff to the bottom of the #id, and change part of 1 so it updates the value.

This is what I have so far:


//Add a new item
Event.observe('add-feature-link', 'click', function(event) {
var url = 'ajax_insert_html/features/'+$('features-num').getValue();
new Ajax.Request(url, {
update: 'features-form-inputs',
})}, false);

Anywho, I don’t think I’m far off from a theoretical point of view, just need to nail the implementation.

Thanks for you help,

Jon

10 08 2007
jon bennett

Reckon this might hold the key http://prototypejs.org/api/ajax/updater

will let you know how i get on!

jb

10 08 2007
Geoff

1. This works but I would proabaly use a JS counter.

2. jQuery rocks! One line of code to append to a div – 4 lines for the whole event:
$('#add-feature-link').click(function(){
$('#feature-form-inputs).append($.get("ajax_insert_html/features/" + inputCounter);
inputCounter++;
});

Sold on jQuery yet? 😀

10 08 2007
jon bennett

JS counter, good idea…

hmm, jquery’s been something I’ve been meaning to get into for quite some time, would need to re-do my live searching etc as well.

hmm, maybe I’ll give jquery a try, and see how long it will take to recode my prototype stuff.

btw, aside from the inputcounter issue, all I needed to do was:


Event.observe('add-accessory-link', 'click', function(event) {
var url = 'ajax_insert_html/accessories/'+$('accessories-num').getValue();
new Ajax.Updater('accessories-form-inputs', url, {insertion: Insertion.Top})
}, false);

Actually, I’m using Nifty corners on this site, might have to find a suitable JQuery alternative as well.

jb

10 08 2007
jon bennett

doh, counter was EASY (it’s late here!)


var accessoriesCounter = 1;
Event.observe('add-accessory-link', 'click', function(event) {
var url = 'ajax_insert_html/accessories/'+accessoriesCounter++;
new Ajax.Updater('accessories-form-inputs', url, {insertion: Insertion.Top})
}, false);

10 08 2007
Geoff

It’d be nice to see a demo of this if possible…?

Just as a sidenote – you can run prototype and jQuery side-by-side, despite what a lot of people say. Take a look a jQuery.noConflict

10 08 2007
jon bennett

Sure, I’ll knock something up, not now though, it’s 01:30am here, got a long day tomorrow!

Yeah, heard that, what with proto and scriptalicious, would rather avoid any more libs if I can!

10 08 2007
jon bennett

hi Geoff,

Still running into a few issues! I’ve got the validation working in the sense that each Models invalidFields array is populated if there’s errors. but, these errors never show up in the view. From looking at the code in form.php and helper.php $form->error is calling $this->tagIsInvalid() which is checking $this->validationErrors[model][field] not $this->validationErrors[index][model][field]:

I’ve written a private method to handle validating data for associated models:


private function __validateAssociatedData($model)
{
$invalidFields = array();
foreach($this->data[$model] as $index => $tmpData)
{
$data = array();
$data[$model] = $tmpData;

// clear any previous Model data
$this->Product->{$model}->create();
// We set the Model data this way so that validation is processed correctly
$this->Product->{$model}->set($data);
// check validation
if (!$this->Product->{$model}->validates())
{
// save the invalidFields and reset for the next iteration
$invalidFields[$index] = $this->Product->{$model}->validationErrors;
unset($this->Product->{$model}->validationErrors);
}
}
// add errors if they are there
if (!empty($invalidFields))
{
// add validation errors
$this->Product->{$model}->validationErrors = $invalidFields;
// return false to stop everything saving
return false;
}
else
{
return true;
}
}

in my view I have:


1. Text

error('ProductFeature.1.name'); ?>

and the pr($invalidFields) outputs:


Array
(
[1] => Array
(
[name] => This field cannot be left blank
)

)

Any ideas? I’m a bit stumped really!

thanks,

Jon

10 08 2007
jon bennett

I meant: $this->validationErrors[model][index][field] above, not $this->validationErrors[index][model][field] !

10 08 2007
Geoff

OK – I admit I didn’t test that the errors are actually displayed so it looks like I should check my work more thoroughly. 😦

You’re going to have to write a custom error function that pulls the correct message from the invalid fields array.

Looks like I might need to make a MultiRecordHelper that can do all this a bit easier…I’ll let you know how I go.

10 08 2007
jon bennett

Hey, no worries dude – just glad it’s not me missing something!

I’ve done this in the past by passing the value to the main Models array, so I’ll try that and get back to you. It will be something along the lines of:

$invalidFields[$model.$index] = $this->Product->{$model}->validationErrors;

$this->Product->validationErrors[] = am(
$this->Product->validationErrors[], $invalidFields[$model.$index]);

You then tweak the $form->error

error(‘AssociatedModel1.field’); ?>

hmm, that might do it actually, let me check!

10 08 2007
jon bennett

Sorted!! Check out http://cakeforge.org/snippet/detail.php?type=snippet&id=198

Use in your views like so:


SKU

error('Product.ProductModel.1.sku'); ?>

11 08 2007
Geoff

Very nice Jon – thanks.

23 08 2007
Chad

This is a great tutorial. Do you by chance have an example of how this could be used in saving an update/edit action?

23 08 2007
Geoff

Chad,
The function add() in the section Saving the Submitted Form is a complete example of how to save the data sent back to the server.

To make it an update/edit function simply ensure that the form has the id field – see the section title Using existing data.

Geoff

24 08 2007
Chad

Thanks Geoff, I was missing the Model->set.

This tutorial was great. A suggestion for expanding it – Dynamically adding multiple records on the fly instead of hard coding a specific number in the for loop.

24 08 2007
Geoff

Chad
I mentioned dynamic elements in the enhancements section and you can follow jons eventual success at this in the comments. I probably wont expand my tutorial simply because I use jQuery which is not used by the AjaxHelper therefore anything I suggest will not be a “standard” cake approach.

Geoff

28 08 2007
Chad

Geoff, finally got the add working. For the increment, I just set $i = $this->params[‘pass’][0];

It’s working, probably not the best way to do it oh well. Now i’m trying to figure out how to delete an item if I didn’t mean to add it. Since it’s not in the db, I don’t want to call a controller action.. I just want to call the delete via view and lower the js count.

How would you go about doing that? Maybe Jon can chime in as well on how he accomplished this.

Thanks!

28 08 2007
Geoff

Chad,

I assume you mean deleting dynamically added form elements.

My suggestion, when you add a new set of fields, wrap it in a div with a unique id like fooItem_jsCounter. This way you can have a remove option by simply removing the div with id equal to “fooItem_” + (jsCounter-1) and then decrement the counter.

Geoff

4 09 2007
prond

I think it all can be done much simpler (== less work):

controllers/pages_controller.php :
function addMany($count = 2) {
for ($i=1;$i<=$count;$i++) {
$modelName = “Page{$i}”;
$this->{$modelName} = & new Page;
$this->{$modelName}->useTable = “page”;
$this->{$modelName}->name = $modelName;
ClassRegistry::addObject(“Page{$i}”,$this->{$modelName});
}

if (!empty($this->data)) {
for ($i=1;$i<=$count;$i++) {
$modelName = “Page{$i}”;
$this->{$modelName}->create($this->data);
$this->{$modelName}->save();
}
}

$languages = $this->Page->Language->generateList();

$this->set(‘count’,$count);
$this->set(compact(‘languages’));
}

views/pages/add_many.php :

<?php for ($i=1;$i

input(“Page{$i}.language_id”);
echo $form->input(“Page{$i}.name”);
echo $form->input(“Page{$i}.urn”);
echo $form->input(“Page{$i}.description”);
echo $form->input(“Page{$i}.keywords”);
echo $form->input(“Page{$i}.published_from”);
echo $form->input(“Page{$i}.published_to”);
echo $form->input(“Page{$i}.published”);
echo $form->input(“Page{$i}.contents”);
?>

end(‘Submit’);?>

link(__(‘List’, true).’ ‘.__(‘Pages’, true), array(‘action’=>’index’));?>
link(__(‘List’, true).’ ‘.__(‘Languages’, true), array(‘controller’=> ‘languages’, ‘action’=>’index’)); ?>
link(__(‘New’, true).’ ‘.__(‘Language’, true), array(‘controller’=> ‘languages’, ‘action’=>’add’)); ?>

19 07 2012
jetconnect india

Very nice blog yaar…keep updating….

5 09 2007
Geoff

Prond,

I have not had much to do with ClassRegistry, but that is a relly neat feature and is cleaner and more elegant than my solution.

I assuming that when using dynamic ajax forms you would have to update the form submission url so that the count is correct.

Thanks for the tip and I’ll try it out sometime soon.

Geoff

18 02 2008
David Boyer

Great article, certainly pointed me in the right direction with things 🙂

I also ran into the problem of validation errors not being displayed. Went digging and found that someone has already submitted a patch, which works pretty well. It’s a small edit of the helper.php file to include the modelId

https://trac.cakephp.org/ticket/4076

When patched, errors get displayed.

9 03 2008
anton - links for 2008-03-08 at antonolsen.com

[…] Multi-record Forms « Another Cake Baker Handling multi-record forms in cake. (tags: cakephp multirecord forms) […]

30 04 2008
Tamerlan

Hi!
im a newbie at cakephp, can you explain a which models you use?
look like you have a 3 tables? im right?
sorry for my english, its not my native language.

9 08 2008
Catalin

Hey there Geoff

I did this a different way, but i reached your article looking for something else:a way to use transactions when doing this. If you do the inserts in a for loop, and one insert raises an error, or something stops the script from running, won’t there be inconsistent data?

With multi-record fields i think we should always try to use transactions… but HOW? Especially in PHP4 which doesn’t accept try/except instructions…

This may not be a question 100% in the direction of the theme here discusses, but it’s my big question of the moment

Thanks

Cata

26 10 2008
dfg

Man,

why is this not working….

are you supposed to have a button?
or maybe tables even……

help out the noobs here bro. you DID tag it as a noob tutorial

19 02 2009
Scuba Steve

Cheers for the code. Exactly what I needed. Working perfectly now…

22 05 2009
Guillermo Mansilla

Why not just to use Model::saveAll( )?

9 09 2009
mihai

Thanks! I looked for hours for a way to fix this. Big and complex form with an array of smaller forms at the bottom (with some javascript for adding and removing items). Now it works fine 🙂

12 03 2010
Paul

thanks

13 04 2010
make my trip india

Thanks for the code, will implement it on thttp://www.makemytrip-india.co.in

14 04 2010
ksu

thanks, a lot to try

4 07 2010
Jet Airways Konnect

cheers. i will try to use the code to my website. If works fine will surely add.

20 07 2010
Ratting Gergely

If somebody uses just copy/paste, in the saving code line 4:

$author_id = $this->Author-id

it should be $author_id = $this->Author->id

there is a missing >

Nice post btw, thx!

27 07 2010
Jet Airways Konnect

thanks for sharing this will surely try to add this in one of my site

27 09 2010
London Paris Train Tickets

Nice Code. I have created the website on London Paris Trains and will surely add code to my website. This is a great help.
Thanks a lot.

27 09 2010
Jet Airways Konnect

My new website is on Jet Airway Konnect. the new airline service in India and your shared code will help me to add on this.

thanks

2 11 2010
Rahul

Hey,

How to add a new functionality “Edit” to this form??
Any suggestions??

18 03 2011
Umbrella Insurance

Thanks! I looked for hours for a way to fix this. Big and complex form with an array of smaller forms at the bottom (with some javascript for adding and removing items). Now it works fine

27 05 2011
Mod

Thank you! This is so helpful. What a great article! It works for me with saving multi-records. Keep on the good work!

28 06 2011
Jet Airways

nice posting. I would like this post to add on my daily things blog.

30 07 2011
jetconnect

thanks for sharing this will surely try to add this in one of my site

21 09 2011
besteducationblog

Thanks for the post really find it useful
Will you please show how to alter this one?
irctc pnr status

12 03 2012
Almog

Hi this is a great post, how will this work with a muilt selct drop down list I have the following in my view.

input(‘game_id’, array(‘label’ => ‘Notification applies to’, ‘options’ => $games, ’empty’=>’All games’, ‘select multiple’ => ‘multiple’, ‘class’ => ‘widthx3’)); ?>

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 )

Google+ photo

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

Connecting to %s




%d bloggers like this: