Symfony: Merge embedded Form (Update)

Symfony provides a nice feature called "embedded Forms" (sfForm::embedForm) to embed subforms into a parent form. This can be used to edit multiple records at the same time. So let's say you have a basic user table called 'sf_guard_user' and a profile table called 'user_profile', then you might follow this guide to merge these forms together:

lib/forms/doctrine/sfUserGuardAdminForm.php:

  1. class sfGuardUserAdminForm extends BasesfGuardUserAdminForm
  2. {
  3. public function configure()
  4. {
  5. parent::configure();
  6.  
  7. // Embed UserProfileForm into sfGuardUserAdminForm
  8. $profileForm = new UserProfileForm($this->object->Profile);
  9. unset($profileForm['id'], $profileForm['sf_guard_user_id']);
  10. $this->embedForm("profile", $profileForm);
  11. }
  12. }

Remember to add "profile" to the list of visible columns in apps/backend/modules/sfGuardUser/config/generator.yml as decribed in the linked guide. The result may look like this:

Embedded form in symfony

This does what it is expected to do, but it doesn't look very nice. Especially for 1:1 related tables I'm more interested in a solution that looks like this:

Merged forms in Symfony

You can reach this using sfForm::mergeForm, but sadly the merged model won't get updated and you'll run into problems if the forms are sharing fieldnames. The solution is the following method embedMergeForm which can be defined in BaseFormDoctrine to be avaible in all other forms:

lib/forms/doctrine/BaseFormDoctrine.php:

  1. abstract class BaseFormDoctrine extends sfFormDoctrine
  2. {
  3. /**
  4.   * Embeds a form like "mergeForm" does, but will still
  5.   * save the input data.
  6.   */
  7. public function embedMergeForm($name, sfForm $form)
  8. {
  9. // This starts like sfForm::embedForm
  10. $name = (string) $name;
  11. if (true === $this->isBound() || true === $form->isBound())
  12. {
  13. throw new LogicException('A bound form cannot be merged');
  14. }
  15. $this->embeddedForms[$name] = $form;
  16.  
  17. $form = clone $form;
  18. unset($form[self::$CSRFFieldName]);
  19.  
  20. // But now, copy each widget instead of the while form into the current
  21. // form. Each widget ist named "formname|fieldname".
  22. foreach ($form->getWidgetSchema()->getFields() as $field => $widget)
  23. {
  24. $widgetName = "$name|$field";
  25. if (isset($this->widgetSchema[$widgetName]))
  26. {
  27. throw new LogicException("The forms cannot be merged. A field name '$widgetName' already exists.");
  28. }
  29.  
  30. $this->widgetSchema[$widgetName] = $widget; // Copy widget
  31. $this->validatorSchema[$widgetName] = $form->validatorSchema[$field]; // Copy schema
  32. $this->setDefault($widgetName, $form->getDefault($field)); // Copy default value
  33.  
  34. if (!$widget->getLabel())
  35. {
  36. // Re-create label if not set (otherwise it would be named 'ucfirst($widgetName)')
  37. $label = $form->getWidgetSchema()->getFormFormatter()->generateLabelName($field);
  38. $this->getWidgetSchema()->setLabel($widgetName, $label);
  39. }
  40. }
  41.  
  42. // And this is like in sfForm::embedForm
  43. $this->resetFormFields();
  44. }
  45.  
  46. /**
  47.   * Override sfFormDoctrine to prepare the
  48.   * values: FORMNAME|FIELDNAME has to be transformed
  49.   * to FORMNAME[FIELDNAME]
  50.   */
  51. public function updateObject($values = null)
  52. {
  53. if (is_null($values))
  54. {
  55. $values = $this->values;
  56. foreach ($this->embeddedForms AS $name => $form)
  57. {
  58. foreach ($form AS $field => $f)
  59. {
  60. if (isset($values["$name|$field"]))
  61. {
  62. // Re-rename the form field and remove
  63. // the original field
  64. $values[$name][$field] = $values["$name|$field"];
  65. unset($values["$name|$field"]);
  66. }
  67. }
  68. }
  69. }
  70.  
  71. // Give the request to the original method
  72. parent::updateObject($values);
  73. }
  74. }

This method ensures that each fieldname is unique (named 'FORMNAME|FIELDNAME') and the subform is validated and saved. It is used like embedForm:

lib/forms/doctrine/sfUserGuardAdminForm.php:

  1. class sfGuardUserAdminForm extends BasesfGuardUserAdminForm
  2. {
  3. public function configure()
  4. {
  5. parent::configure();
  6.  
  7. // Embed UserProfileForm into sfGuardUserAdminForm
  8. // without looking like an embedded form
  9. $profileForm = new UserProfileForm($this->object->Profile);
  10. unset($profileForm['id'], $profileForm['sf_guard_user_id']);
  11. $this->embedMergeForm("profile", $profileForm);
  12. }
  13. }

Feel free to use this method in your own project. Maybe this method get's merged into Symfony some day ;-)

Update

frostpearl reported a problem using embedFormMerge() in conjunction with the autocompleter widget from sfFormExtraPlugin. If you expire these problems try to replace all occurences of "$name|$field" with "$name-$field".

Comments

Pedro Lastra, you forgot to include the function: loadEmbededForms

Can you state what this is supposed to do.....

I too am having a hard time getting this to save m:m tables that are embeded. Can someone suggest a proper solution?

I am having an issue with the generator.yml.

Adding Profile does not seem to work. Instead I need to add Profile|name, Profile|email etc. Is there some way to still set the display of all fields in the merged form?

I have run into another issue. The problem is that the embedded form object is saved twice. It seems that with the first call to save() the embedded form is also saved. This is probably because I am embedding an sfGuardAdminUserForm inside a form that has a one2one relation with sfGuardUser.

protected function doSave($con = null)
{
if (is_null($con))
{
$con = $this->getConnection();
}

$this->updateObject();

$this->object->save($con);

// embedded forms
// $this->saveEmbeddedForms($con);
}

I think this is a good idea overall. I am going to try and expand on it. It would be nice to have 'free-form' forms, where the validators and widgets are taken from various Doctrine objects (in order to use as much as possible the native schema for building widgets and validators). Build the form, then do the binding to the PARTS of the form borrowed (per field), then do the object updating and saving manually. I don't think Symfony will get truly functional, DOCUMENTED, universally usable multi object forms capability for several years. But if there was a universal way to pull parts from the automatic scaffolding that was logical and clear - That would be worth something.

thanks for this tips ! work great on my sf 1.2.4

Something in the embedMergeForm() method appears to break an autocompleter widget from the sfFormExtraPlugin, which is maintained by the core devs, if the autocompleter widget belongs to an embedded form and not the primary form.

A call to embedForm() does not break it and a list of suggested strings appears beneath an input field when letters are typed. Simply changing this to embedMergeForm() prevents the list from appearing.

The only notable difference I've found is the way embedMergeForm() formats widget name. The JavaScript involved with an autocompleter stays in sync with this change, but a list of suggestions does not appear. I'm not a JavaScript expert so I don't know if it's the | character it does not like, or if the problem is elsewhere.

A simple embedForm() call does not add a | character to the widget name, which is the sole reason for my conclusion.

Here is an extract of the HTML source with an autocompleter widget in an embedMergeForm() embedded form. This doesn't work:

jQuery(document).ready(function() {
jQuery("#autocomplete_rn_forum_message_parent|rn_recipients_list")
.autocomplete('/trunk/web/frontend_dev.php/rnUserAutocompleter/autocomplete', jQuery.extend({}, {
dataType: 'json',
parse: function(data) {
var parsed = [];
for (key in data) {
parsed[parsed.length] = { data: [ data[key], key ], value: data[key], result: data[key] };
}
return parsed;
}
}, { }))
.result(function(event, data) { jQuery("#rn_forum_message_parent|rn_recipients_list").val(data[1]); });
});

Here's the same piece with simple embedForm(). This works.

jQuery(document).ready(function() {
jQuery("#autocomplete_rn_forum_message_parent_rn_recipients_list")
.autocomplete('/trunk/web/frontend_dev.php/rnUserAutocompleter/autocomplete', jQuery.extend({}, {
dataType: 'json',
parse: function(data) {
var parsed = [];
for (key in data) {
parsed[parsed.length] = { data: [ data[key], key ], value: data[key], result: data[key] };
}
return parsed;
}
}, { }))
.result(function(event, data) { jQuery("#rn_forum_message_parent_rn_recipients_list").val(data[1]); });
});

Thanks, this is great :)

However, due to problems with uploading files in embedded forms, I've had to do a custom bind() method in my form. (see http://stereointeractive.com/blog/2008/12/23/symfony-12-upload-a-file-in...).

For that to work I also had to move the updateObject() embeddedForms foreach to the bind() method in BaseFormDoctrine.

To avoid problems where uploaded file was empty (value null), I also changed isset($values[“$name|$field”]) to array_key_exists("$name|$field", $this->values).

Now it works like a charm. So thanks again:)

hey boy,

symfony is nice but it fails at many points for my point of view.. you fix one of those failures with this snippet! i really love it, its great!

TY!

Hello Roland,
I very appreciate your post, It's very usefull. But I'm wondering if it will work with propel.
I tried it and make same changes but I get a validation error : 'Unexpected extra form field named'
I really don't know how to deal with issue, Can you help me??
thx in advance

Hi Roland,

Thanks for this tip! Work great on SF 1.2.8.

I found this over the symfony-devs mailling list.

German: Nochmals danke,

Rene

hi i do something similar but in the other way, i have a 'client' table (that is the profile of sfGuardUser) so in the 'client' form i embed the 'user' form but the problems begin when i want to select groups in my user form. the user and groups has a many-to-many relation so its a choice widget that show the groups, then i modify the doSave() method of my client, update the object and save the embed form but groups doesnt save so i tray to do it myself and call savesfGuardUserGroupList() method and i get a no valid form exception. my question is: i need to do anything else to manage relations many-to-many in embedded forms?? thx

i found this:

http://groups.google.es/group/symfony-devs/msg/99f98f386da3a2fd

t is an issue because the generated form classes
from your schema have generated functions for saving many to many
relationships which are invoked in the overridden doSave() method of each
model form. Since embedded forms don't call save() or doSave() on the
embedded forms the m2m relationships are never saved. We discovered this
issue last week but haven't gotten to talk to Fabien about it yet. This is
what I did for sfFormDoctrine to fix the issue.

public function saveEmbeddedForms($con = null, $forms = null)
{
if (is_null($con))
{
$con = $this->getConnection();
}
if (is_null($forms))
{
$forms = $this->embeddedForms;
}
foreach ($forms as $key => $form)
{
if ($form instanceof sfFormDoctrine)
{
$form->bind($this->values[$key]);
$form->doSave($con);
$form->saveEmbeddedForms($con);
}
else
{
$this->saveEmbeddedForms($con, $form->getEmbeddedForms());
}
}
}

i try this way and it works, im using 1.2.5, dont know if the issue is solved in newer version. so if anyone has this problem this a clue

Thanks! I think it is time to update this code as I added some further enhancements, too.

Thanks for the embedMergeForm code.

When this code was added to one of our projects other forms mysteriously began to fail.

I tracked the problem to this code in your updateObject method:

// Give the request to the original method
parent::updateObject($values);

The updateObject method must return the object, as other code may rely on this, and should rely on it - getObject() will be null when using embedded forms in the conventional way.

The correct code is:

// Give the request to the original method
return parent::updateObject($values);

Hope this is helpful!

@soussou
you can use validatorSchema->setOption() to get rid of this

like

// this tells that there may be some extra fields that donot have any validation chriteria for them, default is false
$this->validatorSchema->setOption('allow_extra_fields', true);

// this tells to also add the fields in post array that donot have any validation defined,
// by default fields without validations are not submitted and are filtered out
$this->validatorSchema->setOption('filter_extra_fields', false);

Sorry, your comment has been classified as spam wrongly.

I'm afraid I don't have the time to go into your problem at the moment. If you don't find an answer, can you remind me again in, let's say, two weeks?

Hi,
Thanks for your tuto.
However, like many tutos, it doesnt cover the registration. I explain. I have a table user (id, name, email, password) and another user_profile(id, user_id, firstname, lastname...)
In the registration process I only need user info but in my editing template I need both tables.
When I tried it, it always failed.
(error: error: 1452 Cannot add or update a child row: a foreign key constraint fails)
I don't figure out, how I can I a record to user_profile table when a user register.
Any help, please?
Thanks
Regards

The problem is that the ID from the outer object is not transfered in the embedded object. I think the best you can do is not to call $form->save() but $form->bind() and $form->isValid(). If isValid() returned true, save the object by yourself ($form->getObject()->save()).

Hi,

Nice work!

But i'm having problems with getting $this->mergePostValidator() and $this->widgetSchema->moveField() in the target form to work as expected when merged with a form.

for example:

class sfUserGuardUserForm extends sfGuardUserForm
{
public function setup()
{
parent::setup();

unset(
$this['last_login'],
$this['created_at'],
$this['salt'],
$this['algorithm'],
$this['is_active'],
$this['updated_at'],
$this['groups_list'],
$this['permissions_list'],
$this['is_super_admin']
);

# Setup proper password validation with confirmation
$this->widgetSchema['password'] = new sfWidgetFormInputPassword();
$this->widgetSchema['password_confirmation'] = new sfWidgetFormInputPassword();
$this->widgetSchema->moveField('password_confirmation', sfWidgetFormSchema::AFTER, 'password');

$this->mergePostValidator(new sfValidatorSchemaCompare('password', sfValidatorSchemaCompare::EQUAL, 'password_confirmation', array(), array('invalid' => 'The two passwords must be the same.')));

$this->validatorSchema['username'] = new sfValidatorString(array(),array(
'required' => 'You must enter a username'
));

/*$this->validatorSchema->setPostValidator(new sfValidatorAnd(
array(new sfValidatorDoctrineUnique(array('model' => 'sfGuardUser',
'column' => array('username')),
array('invalid'=>'This username has already been taken.')))));*/

$this->validatorSchema['password_confirmation'] = clone $this->validatorSchema['password'];
$this->validatorSchema['password']->setOption('required', true);
$this->validatorSchema['password'] = new sfValidatorString(array(),array(
'required' => 'You must enter a password'
));

$this->validatorSchema['password_confirmation'] = new sfValidatorString(array(),array(
'required' => 'You must enter your password again'
));

$profileForm = new sfGuardUserProfileForm($this->object->Profile);
$this->embedMergeForm('Profile', $profileForm);
}
}

merged with (taget form):

class sfGuardUserProfileForm extends PluginsfGuardUserProfileForm
{
public function setup()
{
parent::setup();

unset($this['id'],
$this['user_id'],
$this['created_at'],
$this['updated_at'],
$this['email']);

$this->widgetSchema['email'] = new sfWidgetFormInput();
$this->widgetSchema['email_confirmation'] = new sfWidgetFormInput();
$this->widgetSchema->moveField('email', sfWidgetFormSchema::BEFORE, 'first_name');
$this->widgetSchema->moveField('email_confirmation', sfWidgetFormSchema::AFTER, 'email');

$this->mergePostValidator(new sfValidatorSchemaCompare('email', sfValidatorSchemaCompare::EQUAL, 'email_confirmation', array(), array('invalid' => 'The emails passwords must be the same.')));

$this->validatorSchema['email'] = new sfValidatorEmail(array(),array(
'required' => 'You must enter your e-mail address'
));

$this->validatorSchema['email']->setOption('required', true);

$this->validatorSchema['email_confirmation'] = clone $this->validatorSchema['email'];

$this->validatorSchema['email_confirmation'] = new sfValidatorEmail(array(),array(
'required' => 'You must enter your e-mail again'
));
}
}

Problems:

When the forms are NOT merged they work as expected.

But when they are merged, in the taget form the $this->mergePostValidator() and $this->widgetSchema->moveField() do not work.

Hope you can help,
Kelta

updateObject() method will not work when embeding nested sfFormDoctrine and sfForm ie. when using sfFormDoctrine->embedRealation() for Doctrine collection, this one works:

public function updateObject($values = null)
{
if (is_null($values))
{
$values = $this->values;
}

foreach ($values as $field => $value) {
$path = explode('|', $field);
if (count($path)>1) {
$p = &$values;
for($i=0;$i<count($path)-1;$i++) {
if (!isset($p[$path[$i]])) {
$p[$path[$i]] = array();
}
$p = &$p[$path[$i]];
}
$p[$path[$i]] = $value;
unset($values[$field]);
}
}

// Give the request to the original method
return parent::updateObject($values);
}

There is an issue with m2m relation in embedded forms. That Jonathan Wage suggested a solution for it.
http://groups.google.es/group/symfony-devs/msg/99f98f386da3a2fd
But it doesn't work for embedMergeForm. So I made a little fix and got it to work. I added a new line at the begining of the method ($this->loadEmbededForms($this, $this->values)). It should look like this:

public function saveEmbeddedForms($con = null, $forms = null)
{
$this->loadEmbededForms($this, $this->values);

if (is_null($con))
{
$con = $this->getConnection();
}

if (is_null($forms))
{
$forms = $this->embeddedForms;
}

foreach ($forms as $key => $form)
{
if ($form instanceof sfFormDoctrine)
{
$form->bind($this->values[$key]);
$form->doSave($con);
$form->saveEmbeddedForms($con);
}
else
{
$this->saveEmbeddedForms($con, $form->getEmbeddedForms());
}
}
}

Hello,

thanks for that great example! At the moment, I have a problem: I use this example to merge 5 forms from which one is a form for 'phone number'. For the user it's optional to fill in this field. So, if he doesn't fill it out, I get a validation error for which reason I wrote a validatorSchema in which I unset the values of the PhoneNumbers-Form: unset($values['PhoneNumbers']);

But what a pitty, the form is still saved in the database in the phone_number-table. So I overrode the saveEmbeddedForm-method with

if (null === $forms) {
sfContext::getInstance()->getLogger()->debug('saveEmbeddedForms() - formular != null');

$phone = $this->getValue('PhoneNumbers');

$forms = $this->embeddedForms;//['PhoneNumbers'];

if (!isset($phone)) {
sfContext::getInstance()->getLogger()->debug('saveEmbeddedForms() phoneNumbers ist leer -> unset des Formulars "PhoneNumbers"');
unset($forms['PhoneNumbers']);
}
}

return parent::saveEmbeddedForms($con, $forms);

If the phoneNumber-Values are empty I want to unset the whole PhoneNumber-form, but it doesn't work.

In my phone_number-table the following is saved (example):

id | user_id | category_id | phone_number
1 1 2

phone_number is null or empty but everything else is saved.

What do I have to do to be able to not save the form, if it isn't set?

Thanks and best regards, Dirk

(Placeholder: contacted Dirk via private mail. If we find a solution I'll post it here)

Hi,
I made your post into a plugin: http://github.com/miguelibero/miMergeEmbedFormPlugin
hope it helps other people looking for this functionality.

Hey,

i want to thank you first for the great Howto.
But i've a problem...am using your workflow for my registrationform. It is possible to get e.g. the Firstname on the 1st position so above the username?

regards
Lucas

Lucas,

you can move fields using sfWidgetSchema::moveField():

// Move username behind embedded first name
$this->widgetSchema->moveField('username', 'after', "profile|firstname");

regards,

Roland

Thanks Roland, these methods saved my day.

But I'd like to mention, that you forgot to add support for widget helps.

In "embedMergeForm" method, after
"
if (!$widget->getLabel())
{
...
}
"
add

$this->getWidgetSchema()->setHelp($widgetName, $form->getWidgetSchema()->getHelp($field));

Now embedMerged forms will have helps set :)

Hi, can't get this working with symfony 1.4.
Error: Widget "Profile" does not exist
Any modifications that can solve the problem?

Check this post on embedded form using symfony 1.4

there's a big problem with this solution, because embeded forms postValidators are skipped.

When you do this :
$this->validatorSchema[$widgetName] = $form->validatorSchema[$field];

You don't retrieve the pre and postValidators, so if you have a sfValidatorDoctrineUnique on an embeded form for example, this validator won't be executed, and you will have an exception.

Anybody have a solution for this ?

Thanx

This is not so easy. A trivial approach may look like this:

$this->validatorSchema->setPostValidator(new sfValidatorAnd(array($this->validatorSchema->getPostValidator(), $form->validatorSchema->getPostValidator()));

Sadly, in most cases this will not work because a Post or Pre-Validator has to now it's fieldname, and this fieldname changed. The easiest solution seems to be to add required post- or pre-validators manually after embedding the form.

I have a problem, I have my embedded form to work fine, but when the values of the form that is embedded are not filled out, I don't want it to save the form, and I continue to get validation errors, can someone please help me telling me where can I unset the form, or what do I have to do??? Please, sorry but I am newby in symfony and don't have much experience

I tried to deal with the same problems with a completely different approach. It's a work in progress, but I hope someone could find it useful

http://arialdomartini.wordpress.com/2011/04/01/how-to-kill-symfony%E2%80...

Struggling with Symfony and embedded forms I also found this approach very effective http://arialdomartini.wordpress.com/2011/04/01/how-to-kill-symfony%E2%80...

Oh, the same link of previous user. Doh

Apart final return, I had to add an unset of embedded form. Without unsetting the embedded form, the save() of original form performs additional saving on embedded form, resulting in a duplicate insert.

This is a good solution - personally I feel underscore would be a better separator than the pipe symbol, but the solution works all the same.

Thanks for this great post, nice solution!
Fitting perfectly to my needs: to merge the update of a guard_user and a profile.

Unfortunately some things seem to have changed in die symfony framework regarding the naming of fields, why the embedMergeForm function respectively the updateObject function fails to remove duplicate fields.

I have to admit that i don't fully understand what you're doing inside those functions yet, but it seems to me that the use of these 'fieldsets' in the resulting HTML puts a spoke into its wheels.
e.g. there is a field 'sf_guard_user[username]' inside the fieldset 'sf_fieldset_user' as in the fieldset 'sf_fieldset_none'.
I am using Symfony 1.4.11 btw.