Development

EmbeddedForms

You must first sign up to be able to contribute.

Version 2 (modified by dmccullough, 7 years ago)
--

Introduction

Due to heavy normalization in a database structure, high level business objects are sometimes composed of a number of smaller, related data points that all connect to a main Id that exists in a table of only Id's (eg, agPerson). In certain instances, we can use Symfony's auto-generated lists to get data into the lower level tables, but, for sub-objects that are very complex in their own right, the use of embedded forms is necessary. Embedded Forms, at least in the way we'll need to be using them, are not an extensively documented feature Symfony. They allow you to attach the form for a sub-object of related-object into the form (and then page) of your main object. These forms then act as one form from the frontend, and add, update, or delete data from multiple database tables with one submit action.

All of the research and development on embedding forms has aimed at implementing the full functionality of the agPerson business object, specifically enabling a person to have one or more names (in our test case, Given, Middle, Family, Maiden, and Alias). This documentation will use agPerson, agPersonName, agPersonNameType, and agPersonMjAgPersonName for its basis and examples.

Model Descriptions

Descriptions of the relevant models follow.

agPerson (ag_person)

This is our high-level object, the unique Id on which an agPerson and all its related values depend. agPerson is only an Id. The created_ad and updated_at columns (found on this and the other table's discussed in this document) make use of Doctrine's auto-time-stamping feature, which is set with:

    actAs:
      Timestampable:

    +------------+------------+------+-----+---------+----------------+
    | Field      | Type       | Null | Key | Default | Extra          |
    +------------+------------+------+-----+---------+----------------+
    | id         | bigint(20) | NO   | PRI | NULL    | auto_increment |
    | created_at | datetime   | NO   |     | NULL    |                |
    | updated_at | datetime   | NO   |     | NULL    |                |
    +------------+------------+------+-----+---------+----------------+

agPersonName (ag_person_name)

A data-dictionary for names. Each agPersonName has its string value and a unique Id.

    +-------------+-------------+------+-----+---------+----------------+
    | Field       | Type        | Null | Key | Default | Extra          |
    +-------------+-------------+------+-----+---------+----------------+
    | id          | bigint(20)  | NO   | PRI | NULL    | auto_increment |
    | person_name | varchar(64) | NO   | UNI | NULL    |                |
    | created_at  | datetime    | NO   |     | NULL    |                |
    | updated_at  | datetime    | NO   |     | NULL    |                |
    +-------------+-------------+------+-----+---------+----------------+

agPersonNameType (ag_person_name_type)

A data-dictionary for name types. Each agPersonNameType has its string value and a unique Id, which allows for the assignation of various kinds of names: first, given, family, middle, alias, etc..

    +------------------+-------------+------+-----+---------+----------------+
    | Field            | Type        | Null | Key | Default | Extra          |
    +------------------+-------------+------+-----+---------+----------------+
    | id               | smallint(6) | NO   | PRI | NULL    | auto_increment |
    | person_name_type | varchar(30) | NO   | UNI | NULL    |                |
    | app_display      | tinyint(1)  | NO   |     | 1       |                |
    | created_at       | datetime    | NO   |     | NULL    |                |
    | updated_at       | datetime    | NO   |     | NULL    |                |
    +------------------+-------------+------+-----+---------+----------------+

agPersonMjAgPersonName (ag_person_mj_ag_person_name)

This is the join table, where a Person is combined with its Names and Name Types to give it a full Name sub-object. An agPersonMjAgPersonName entry has its own unique Id, the unique Id of the agPerson with which it is associated (person_id), the unique Id of the agPersonName with which it is associated (person_name_id), and the unique Id of the agPersonNameType with which it is associated (person_name_type_id).

    +---------------------+-------------+------+-----+---------+----------------+
    | Field               | Type        | Null | Key | Default | Extra          |
    +---------------------+-------------+------+-----+---------+----------------+
    | id                  | bigint(20)  | NO   | PRI | NULL    | auto_increment |
    | person_id           | bigint(20)  | NO   | MUL | NULL    |                |
    | person_name_id      | bigint(20)  | NO   | MUL | NULL    |                |
    | person_name_type_id | smallint(6) | NO   | MUL | NULL    |                |
    | is_primary          | tinyint(1)  | NO   |     | NULL    |                |
    | created_at          | datetime    | NO   |     | NULL    |                |
    | updated_at          | datetime    | NO   |     | NULL    |                |
    +---------------------+-------------+------+-----+---------+----------------+

Building a Name

The full definition of an agPersonName is achieved with the ag_person_mj_ag_person_name table. The contents of a table that describes four people with three names/name-types might look like this:

    +----+-----------+----------------+---------------------+
    | id | person_id | person_name_id | person_name_type_id |
    +----+-----------+----------------+---------------------+
    |  1 |         1 |              5 |                   1 |
    |  2 |         1 |              3 |                   2 |
    |  3 |         1 |              4 |                   3 |
    +----+-----------+----------------+---------------------+
    |  4 |         2 |              8 |                   1 |
    |  5 |         2 |              5 |                   2 |
    |  6 |         2 |             10 |                   3 |
    +----+-----------+----------------+---------------------+
    |  7 |         3 |              1 |                   1 |
    |  8 |         3 |              6 |                   2 |
    |  9 |         3 |              2 |                   3 |
    +----+-----------+----------------+---------------------+
    | 10 |         4 |              7 |                   1 |
    | 11 |         4 |             18 |                   2 |
    | 12 |         4 |              5 |                   3 |
    +----+-----------+----------------+---------------------+

Or, if we replace the person_name_ids and person_name_type_ids with the assigned values we actually want:

    +----+-----------+----------------+---------------------+
    | id | person_id | person_name_id | person_name_type_id |
    +----+-----------+----------------+---------------------+
    |  1 |         1 |         Thomas |          Given Name |
    |  2 |         1 |        Michael |         Middle Name |
    |  3 |         1 |          Smith |         Family Name |
    +----+-----------+----------------+---------------------+
    |  4 |         2 |           John |          Given Name |
    |  5 |         2 |         Thomas |         Middle Name |
    |  6 |         2 |      Schneider |         Family Name |
    +----+-----------+----------------+---------------------+
    |  7 |         3 |          Alice |          Given Name |
    |  8 |         3 |         Louise |         Middle Name |
    |  9 |         3 |         Vargas |         Family Name |
    +----+-----------+----------------+---------------------+
    | 10 |         4 |           Lisa |          Given Name |
    | 11 |         4 |      Elizabeth |         Middle Name |
    | 12 |         4 |         Thomas |         Family Name |
    +----+-----------+----------------+---------------------+

Notice the reuse of the name Thomas as a Given Name, Middle Name, and Family Name. The use of the agPerson, agPersonName, and agPersonNameType allows us to avoid separate tables for different name types, and the duplication of content they would entail.

The Forms (and Embedded Forms)

agPersonForm

To implement the creation of an agPerson along with an agPersonName for at least one agPersonNameType, you begin by making a copy of agPerson.class.php from lib/form/doctrine/ to apps/frontend/lib/form/doctrine (all the form files worked within this document will be stored in this path) and then customizing it.

Unaltered:

    1 class agPersonForm extends BaseagPersonForm
    2 {
    3   public function configure()
    4   {
    5   }
    6 }

Customized to implement embedded forms for agPersonName:

     1 class agPersonForm extends BaseagPersonForm
     2 {
     3   public function configure()
     4   {
     5     unset($this['created_at'], 
     6           $this['updated_at'],
     7           $this['ag_nationality_list'],
     8           $this['ag_religion_list'],
     9           $this['ag_profession_list'],
    10           $this['ag_language_list'],
    11           $this['ag_country_list'],
    12           $this['ag_ethnicity_list'],
    13           $this['ag_sex_list'],
    14           $this['ag_marital_status_list'],
    15           $this['ag_import_list'],
    16           $this['ag_residential_status_list'],
    17           $this['ag_import_type_list'],
    18           $this['ag_account_list'],
    19           $this['ag_phone_contact_type_list'],
    20           $this['ag_email_contact_type_list'],
    21           $this['ag_address_contact_type_list'],
    22           $this['ag_phone_contact_list'],
    23           $this['ag_email_contact_list'],
    24           $this['ag_scenario_list'],
    25       $this['ag_person_name_list'],
    26           $this['ag_person_name_type_list']);
    27 
    28     $this->embedForm('name', new agEmbeddedNamesForm($this->object)); #This line embeds our name container form, agEmbeddedNamesForm, and passes the current agPerson object to it.
    29   }
    30 
    31   public function updateObjectEmbeddedForms($values, $forms = null)
    32   {
    33     if (is_array($forms))
    34     {
    35       foreach ($forms as $key => $form)
    36       {
    37         if ($form instanceof agEmbeddedAgPersonMjAgPersonNameForm)
    38         {
    39           $formValues = isset($values[$key]) ? $values[$key] : array();
    40 
    41           if (agEmbeddedAgPersonMjAgPersonNameForm::formValuesAreBlank($formValues))
    42           {
    43             if($id = $form->getObject()->getId())
    44             {
    45               $this->object->unlink('agPersonMjAgPersonName', $id);
    46               $form->getObject()->delete();
    47             }
    48 
    49             unset($forms[$key]);
    50           }
    51 
    52           #unset($forms[$key]);
    53         }
    54       }
    55     }
    56 
    57     return parent::updateObjectEmbeddedForms($values, $forms);
    58   }
    59 
    60   public function saveEmbeddedForms($con = null, $forms = null)
    61   {
    62     if (is_array($forms))
    63     {
    64       foreach ($forms as $key => $form)
    65       {
    66         if ($form instanceof agEmbeddedAgPersonMjAgPersonNameForm)
    67         {
    68           if ($form->getObject()->isModified())
    69           {
    70             $form->getObject()->agPerson = $this->object;
    71           }
    72           else
    73           {
    74             unset($forms[$key]);
    75           }
    76         }
    77       }
    78     }
    79 
    80     return parent::saveEmbeddedForms($con, $forms);
    81   }
    82 }

Line 28 is the most important part of this for now. It embeds our container (agEmbeddedNamesForm) form into the agPerson form and passes the agPerson object to it. agEmbeddedNamesForm is not based on any table or model, it is simply there to hold all of the agEmbeddedAgPersonMjAgPersonName forms, which is where the real action takes place.

updateObjectEmbeddedForms and saveEmbeddedForms override Symfony's default functions of the same name. They're necessary to ensure that forms update properly and that agPersonMjAgPersonName values can be deleted from a full agPerson (if an Alias was unneeded, for example).

The many fields that are unset in configure are those that Symfony automatically adds to the agPersonForm. Some have been removed because they do not need any data input from the end user (created_at, updated_at), or simply to narrow down the variables encountered while developing and testing (everything else). In the latter case, we will later remove the unset code for these fields. agEmbeddedNamesForm

The next class we'll work with is the agEmbeddedNamesForm. This form is created in apps/frontend/lib/form/doctrine.

     1 class agEmbeddedNamesForm extends sfForm
     2 {
     3   protected $person;
     4 
     5   public function __construct(agPerson $person)
     6   {
     7     $this->person = $person;
     8 
     9     parent::__construct();
    10   }
    11 
    12   public function configure()
    13   {
    14     $this->name_types = Doctrine::getTable('agPersonNameType')->createQuery('a')->execute();
    15 
    16     foreach ($this->name_types as $name_type)
    17     {
    18       $emb = new agEmbeddedAgPersonMjAgPersonNameForm();
    19       $emb->setDefault('person_name_type_id', $name_type->getId());
    20 
    21       foreach ($this->person->getAgPersonMjAgPersonName() as $current)
    22       {
    23         if ($current->getPersonNameTypeId() == $name_type->getId())
    24         {
    25            $emb = new agEmbeddedAgPersonMjAgPersonNameForm($current);
    26         }
    27       }
    28       $this->embedForm($name_type->getPersonNameType(), $emb);
    29     }
    30   }
    31 }
    

The construct function is the counterpart to line 28 in agPersonForm and completes the passage of the agPerson object to agEmbeddedNamesForm.

The configure function is where the bulk of the action happens. Doctrine::getTable on line 14 gives this class access to all values in the ag_person_name_type table, which are needed to define labels and hidden field names for the forms that are embedded on line 28.

The two loops in lines 16-29 handle determining the form titles and default field values and the embedding of the forms. The name_types that have been accessed on line 14 are looped through, creating a new agEmbeddedAgPersonMjAgPersonNameForm as $emb for each instance of ag_person_name_type, and assigning a default value of that instance's Id to the new agEmbeddedAgPersonMjAgPersonNameForm's person_name_type_id.

The second loop that compares each of the current agPerson's agPersonMjAgPersonName objects name_type_id values to the Id of the current name_type. If the values are equal, $emb is re-assigned the value of a new agEmbeddedAgPersonMjAgPersonNameForm that has been filled with the agPersonMjAgPersonName that is being evaluated (as $current). After that, $emb is embedded, with it's current values if line 23 returns true, or as a blank form if 23 returns false.

agEmbeddedAgPersonMjAgPersonNameForm

     1 class agEmbeddedAgPersonMjAgPersonNameForm extends agPersonMjAgPersonNameForm
     2 {
     3   public function configure()
     4   {
     5     parent::configure();
     6 
     7     unset($this['person_id']);  
     8   }
     9 
    10   public function setup()
    11   {
    12     parent::setup();
    13 
    14      /****
    15      * setWidgets below sets the person_name_type_id to hidden. The value for this field is assigned in
    16      * agEmbeddedNamesForm and is kept even though the field is hidden. widgetSchema below removes the 
    17      * field labels from the person_name_id fields. The table labels, generated for each value in the 
    18      * ag_person_name_type table, are what we want and are descriptive of the values that should be     entered 
    19      * in this form.
    20      ****/
    21 
    22     $this->setWidgets(array(
    23       'person_name_type_id' => new sfWidgetFormInputHidden(),
    24       'person_name_id'      => new sfWidgetFormDoctrineChoice(array('model' => $this->getRelatedModelName('agPersonName'), 'add_empty' => true)),
    25     ));
    26     $this->widgetSchema->setLabel('person_name_id', false);
    27 
    28     $this->setValidators(array(
    29 #      'id'                  => new sfValidatorChoice(array('choices' => array($this->getObject()->get('id')), 'empty_value' => $this->getObject()->get('id'), 'required' => false)),
    30 #      'person_id'           => new sfValidatorDoctrineChoice(array('model' => $this->getRelatedModelName('agPerson'))),
    31       'person_name_id'      => new sfValidatorDoctrineChoice(array('model' => $this->getRelatedModelName('agPersonName'), 'required' => false)),
    32       'person_name_type_id' => new sfValidatorDoctrineChoice(array('model' => $this->getRelatedModelName('agPersonNameType'))),
    33 #      'is_primary'          => new sfValidatorBoolean(),
    34     ));
    35 
    36   }
    37 
    38   public static function formValuesAreBlank(array $values)
    39   {
    40     $fieldNames = array_diff(Doctrine::getTable('agPersonMjAgPersonName')->getFieldNames(), array(
    41       'id',
    42       'person_id',
    43       'person_name_type_id',
    44       'is_primary',
    45     ));
    46 
    47     return parent::_formValuesAreBlank($fieldNames, $values);
    48   }
    49 }
    

Reference Links