Embedded forms using Javascript with sfForm

I wanted to create a form that allowed me to dynamically add sub-forms via JS, without round-trips to the server. I had a bit of a google, but failed to find much of use – so I wrote my own. This article contains my code for a proof of concept, and a brief description of what it does. It is proof of concept, and done as an example, so code isn’t done perhaps as I might do in a real configuration, but I hope it provides help to others.

There’s a few things on this page that are implemented reasonably badly. I’ve a much better way of doing this that I’m working on now, and I will publish a few more complicated, but complete post (or series of posts) soon.

The Result

Below is a screen shot showing the end result

The model

The model in this example contains just 2 entities, a TestParent, and a TestChild. A TestParent can have 0 or more TestChildren. Here’s the YAML

TestParent:
  columns:
    id:
      type: integer
      primary: true
      autoincrement: true
    name:
      type: string(255)
      notnull: true

TestChild:
  columns:
    id:
      type: integer
      primary: true
      autoincrement: true
    parent_id:
      type: integer
    name:
      type: string(255)
      notnull: true
  relations:
    Parent:
      class: TestParent
      local: parent_id
      foreignAlias: Children

The View

The form is rendered using the standard/quick method of simply echo’ing out the form, but adds an extra <tr> that we modify via JS.

renderFormTag(url_for('default', array(
      'module' => 'test',
      'action' => 'index',
      'id' => $form->getObject()->id,
))); ?>

The view uses jQuery to modify the DOM, adding the links to all adding and removing Child forms.

  $(document).ready(function() {
    // evil really, but needs be, move the parent_deleted_children link to the top of the form
    $('#testform').before( $('#parent_deleted_children') );
    $('#testform').before(  $('#parent_id') );
    $('#testform input[type="submit"]').before ('');
    $('#testform').find('div.del_add a.lnkAdd').each( function () {
      $(this).click(function (e) { e.preventDefault(); addRow( $(this).parents('tr:eq(1)') ) } );
    });
    createAddDelLinks();
  });

  function delRow(row)
  {
    var hidden = row.find('input[type="hidden"]');
    if (hidden)
    {
      if (hidden.attr('id') && (id = hidden.attr('id').match(/.*_([0-9]+)_id/)))
      {
        $('#parent_deleted_children').val( $('#parent_deleted_children').val() + id[1] + '|');
      }
    }
    row.remove();
    redoLinks();
  }

  function addRow(row)
  {
    var i = Math.floor(Math.random()*1001) + 10;
    $('#additem').before(
      'Child ' + i + '' +
      '' +
      ' ' +
      '  ' +
      '   ' +
      ' '
    );
    redoLinks();
  }


  // perhaps a bit lazy, but we'll remove all spans, and re-add them
  function redoLinks()
  {
    $('#testform').find('table td span.del_add').each( function() { $(this).remove() } );
    createAddDelLinks();
  }

  function createAddDelLinks()
  {
    $('#testform').find('table td').each( function (i) {
      $(this).append(' [ del ] ');
    });

    $('#testform').find('span a.lnkDel').each( function () {
      $(this).click(function (e) { e.preventDefault(); delRow( $(this).parents('tr:eq(1)') ) } );
    });
  }

The form

class myParentForm extends TestParentForm
{

  public function configure()
  {

    $this->widgetSchema->setNameFormat('parent[%s]');

    $this->widgetSchema['deleted_children'] = new sfWidgetFormInputHidden();
    $this->validatorSchema['deleted_children'] = new sfValidatorString(array('required' => false));

    if ($state = $this->getOption('state'))
    {
      $this->rebuildState($state);
    }
    elseif (!$this->getObject()->isNew())
    {
      $this->buildFromObject();
    }
    else
    {
      for($i = 0; $i < $this->getOption('initial_children', 1); $i++)
      {
        $child = new TestChild();
        $this->getObject()->Children[] = $child;
        $form = new TestChildForm($child);
        unset($form['parent_id']);
        $this->embedForm('child_' . $i, $form);
      }
    }

  }

  public function rebuildState(array $state)
  {
    $children = array();
    foreach($this->getObject()->Children as $child)
    {
      $children[$child->id] = $child;
    }

    foreach($state as $key => $val)
    {
      $matches = array();
      if (preg_match('/^child_([0-9]+)$/', $key, $matches))
      {
        if (isset($val['id']) && $val['id'])
        {
          if (array_key_exists($val['id'], $children))
          {
            $child = $children[$val['id']];
          }
          else
          {
            //  $child = Doctrine::getTable('TestChild')->find($val['id']);
          }
        }
        else
        {
          // $child = new TestChild();
        }
        $this->getObject()->Children[] = $child;
        //$child->fromArray($val);
        $form = new TestChildForm($child);
        unset($form['parent_id']);
        $this->embedForm('child_' . $matches[1], $form);
      }
    }
  }

  public function save( $con = null)
  {
    $obj = parent::save( $con );
    $deleted = $this->getValue('deleted_children');
    $deleted = explode('|', trim($deleted, '|'));
    if (count($deleted))
    {
      Doctrine::getTable('TestChild')->createQuery('c')
        ->whereIn('c.id', $deleted)
        ->addwhere('c.parent_id = ?', $obj->id)
        ->delete()
        ->execute();
    }
    return $obj;
  }

  public function buildFromObject()
  {
    foreach($this->getObject()->Children as $child)
    {
      $form = new TestChildForm($child);
      unset($form['parent_id']);
      $this->embedForm('child_' . $child->id, $form);
    }
  }
}

There’s a few things worth noting from the above. Firstly, we’re adding a deleted_children hidden element. The contents of this are maintained by JS, and contain a list of the IDs of the Child sub-forms removed from the Parent form. The save() method is overloaded to delete the Child entities based on this field. The state of the form is re-built into objects when the form is submitted by passing the request parameter in as an object in the action (see below).

class testActions extends sfActions
{
  /**
   * Executes index action
   *
   */
  public function executeIndex( sfWebRequest $request )
  {


    if ($id = $request->getParameter('id'))
    {
      $parent = Doctrine::getTable('TestParent')
        ->createQuery('p')
        ->leftjoin('p.Children c')
        ->where('p.id = ?', $id)
        ->fetchOne();
    }
    else
    {
      $parent = new TestParent();
    }

    $formOptions = array(
      'state' => $request->getParameter('parent', null),
    );

    $this->form = new myParentForm($parent, $formOptions);
    if ($request->isMethod('post') || $request->isMethod('put'))
    {
      $this->form->bind($request->getParameter('parent'));
      if ($this->form->isValid())
      {
        $this->form->save();
      }
    }

    $this->parents = Doctrine::getTable('TestParent')->findAll();
  }
}

There’s bits of this solution that seem a bit hacky – like the fact it only works when JS is turned on. Also, there’s extra queries generated when validation fails that I’ve not managed to track down yet. On the other hand, it does what I need it to do, and I don’t need to cater for non JS users (developing for internal systems makes you lazy 😉 )

Please do let me know if you have any feedback!

3 thoughts on “Embedded forms using Javascript with sfForm”

  1. I am trying to do the example, but i have a doubt. The content mentioned in “the view”, where does it have to be placed?

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.