Embauchez-moi

Je suis freelance ! Besoin d'un professionnel du développement web ? Pourquoi ne pas me passer un coup de fil ?

Plus d'infos sur… http://thibault.jouannic.fr

mots-cles : Ingénieur web freelance Symfony eZ Publish Solr

Symfony form : pick or create

Here’s an other tutorial about the Symfony form framework. Last time, we managed to make embedded i18n forms optionals. In today’s howto, we will talk about embedded forms again.

In the last tutorials, we created a form to edit news. Today we will create another form to write interviews. An interview is just an article about a celebrity. So our Interview class will inherit from our Article one, so we can reuse our last time work.

Here’s the complete schema we will work with :

Article:
  actAs:
    Timestampable: ~
    I18n:
      fields: [ title, body ]
      actAs:
        Sluggable: { fields: [ title ], uniqueBy: [ lang, title ] }
 
  columns:
    title: { type: string(255), notnull: true }
    body: { type: clob, notnull: true }
    author: { type: string(255), notnull: false }
 
News:
  inheritance:
    extends: Article
    type: concrete
 
Celebrity:
  actAs:
    Sluggable:
      fields: [ firstname, lastname ]
  columns:
    firstname: { type: string(255), notnull: true }
    lastname: { type: string(255), notnull: true }
 
Interview:
  inheritance:
    extends: Article
    type: concrete
 
  columns:
    celebrity_id: { type: integer, notnull: true }
 
  relations:
 
    Celebrity:
      local: celebrity_id
      foreign: id
      type: one
      foreignType: one
      foreignAlias: Interviews

And the fixtures :

Interview:
  i1:
    Author: 'Thibault J.'
    Celebrity:
      firstname: Thibault
      lastname: Jouannic
    Translation:
      fr:
        title: Auto-interview
        body: |
          Bla bla bla question et réponses

As usual…

php symfony build --all --and-load
php symfony doctrine:generate-admin backend Interview

Before we start

Before we start, let’s stop and think a few seconds. How do we want to manage our celebrity relation into our form ? Take a look at the default form, without any modification :

01_celebrity
Creative Commons License

Quite bad, isn’t it ? We could make a little better by adding a toString method.

// lib/model/doctrine/Celebrity.class.php
 
class Celebrity extends BaseCelebrity
{
  public function __toString()
  {
    return sprintf('%s %s', $this->getFirstname(), $this->getLastname());
  }
}
02_celebrity
Creative Commons License

That’s a bit better. The problem with this form is that you cannot add a new celebrity. If you want to create a new interview, you’ll have to chose among the existing celebrities. Of course, you could create an other admin module to manage celebrities, but a good programmer is a lazy one. And that would be great if we could create a new celebrity directly in the interview form.

In fact, that’s quite easy, using the embedRelation function.

// lib/form/doctrine/InterviewForm.class.php
class InterviewForm extends BaseInterviewForm
{
  /**
   * @see ArticleForm
   */
  public function configure()
  {
    parent::configure();
 
    unset($this['celebrity_id']);
    $this->embedRelation('Celebrity');
  }
}
03_celebrity
Creative Commons License

Here’s the result, which is quite relevant to what we wanted. But !? Wait ! There is a regression. What if we want to choose among existing celebrities, the way we used to do ? What we really need is a way to combine both methods actually. We need to be able to choose an existing celebrity OR to create a new one.

Pick or create

We will have to use an intermediate form.

// lib/form/doctrine/InterviewForm.class.php
class InterviewForm extends BaseInterviewForm
{
  /**
   * @see ArticleForm
   */
  public function configure()
  {
    parent::configure();
 
    unset($this['celebrity_id']);
 
    $celebrityId = $this->getObject()->isNew() ? NULL : $this->getObject()->getCelebrity()->getId();
    $form = new InterviewCelebrityForm(array(), array('celebrity_id' => $celebrityId));
    $this->embedForm('Celebrity', $form);
  }
}
 
// lib/form/doctrine/InterviewCelebrityForm.class.php
class InterviewCelebrityForm extends sfForm
{
  public function configure()
  {
    $this->widgetSchema['celebrity_id'] = new sfWidgetFormDoctrineChoice(array(
      'model' => 'Celebrity',
      'add_empty' => true
    ));
 
    $this->setDefault('celebrity_id', $this->getOption('celebrity_id'));
 
    $form = new CelebrityForm();
    $this->embedForm('new_celebrity', $form);
 
    $this->widgetSchema->setLabel('celebrity_id', 'Choose one…');
    $this->widgetSchema->setLabel('new_celebrity', 'or create a new.');
  }
}
04_celebrity
Creative Commons License

Try to edit the existing interview, and look at the nice result. However, if you try to save the form, you’ll have a lot of validations errors. There is still some work to do before we can get rid of all those red alerts.

05_celebrity
Creative Commons License

Form validation

The form validation strategy is quite simple. If an existing celebrity is chosen with the drop down list, the firstname and lastname should be ignored. If the list is empty, we must validate the firstname and lastname. At last, we must throw an error if all the fields are empty.

// lib/form/doctrine/InterviewCelebrityForm.class.php
 
    // add this  at the end of the configure() function
    $this->validatorSchema['celebrity_id'] = new sfValidatorPass();
 
    $celebrityValidatorSchema = clone $this->validatorSchema['new_celebrity'];
    $this->validatorSchema['new_celebrity'] = new sfValidatorPass();
 
    $this->validatorSchema->setPostValidator(
      new sfValidatorAnd(array(
        new sfValidatorOr(array(
          new sfValidatorSchemaFilter('celebrity_id', new sfValidatorDoctrineChoice(array(
            'model' => 'Celebrity',
            'required' => true
          ))),
          new sfValidatorSchemaFilter('new_celebrity', $celebrityValidatorSchema)
        )),
        new sfValidatorCallback(array(
          'callback' => array($this, 'checkCelebrity')
        ))
      ))
    );
 
  // add this function into the class
  /**
   * Either who choose an existing celebrity, either we create a new one
   *
   * Unset values according to the choice we made
   **/
  public function checkCelebrity($validator, $values, $argument)
  {
    if(!empty($values['celebrity_id']))
    {
      unset(
        $this['new_celebrity'],
        $values['new_celebrity']
      );
    }
    else
    {
      unset(
        $this['celebrity_id'],
        $values['celebrity_id']
      );
    }
 
    return $values;
  }

Try differents combinations to submit your form. The validation should behave correctly now.

Saving your form

You may have noticed that our interview isn’t updated correctly after we submit the form. We will have to override the doUpdateObject method in order to update the object correctly on form validation.

// lib/form/doctrine/InterviewForm.class.php
  public function doUpdateObject($values)
  {
    if(!empty($values['Celebrity']['celebrity_id']))
    {
      $this->getObject()->setCelebrityId($values['Celebrity']['celebrity_id']);
    }
    else
    {
      $celebrity = new Celebrity();
      $celebrity->fromArray($values['Celebrity']['new_celebrity']);
      $this->getObject()->Celebrity = $celebrity;
    }
 
    unset(
      $values['Celebrity'],
      $this['Celebrity']
    );
 
    parent::doUpdateObject($values);
  }

That’s it ! Everything should be alright now. But just to make sure, here’s a few tests.

Test your forms

We are reusing some code from the last day, so I just will output the new part here.

// test/function/backend/interviewActions.class.php
 
<?php
 
include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
$browser = new sfBackendTestFunctional(new sfBrowser());
$browser->loadData();
$browser->setTester('doctrine', 'sfTesterDoctrine');
 
$interview = Doctrine::getTable('Interview')->
  createQuery('i')->
  leftJoin('i.Translation t')->
  andWhere('t.lang = ?', 'fr')->
  orderBy('i.id', 'asc')->
  fetchOne();
 
$celebrity = new Celebrity();
$celebrity->setFirstname('toto');
$celebrity->setLastname('tutu');
$celebrity->save();
 
$editUrl = sprintf('/interview/%s/edit', $interview->getId());
 
$browser->
  get('/interview')->
  with('response')->begin()->
    isStatusCode(200)->
  end()->
 
  info('1 - Interview list')->
  with('response')->begin()->
    checkElement('h1', '/Interview List/')->
    checkElement('body', '/Thibault Jouannic/')->
  end()->
 
// …
 
   info('  2.8 - The current celebrity is already selected')->
  get($editUrl)->
  with('response')->begin()->
    checkElement('#interview_Celebrity_celebrity_id option[selected="selected"]', sprintf('/%s/', $interview->getCelebrity()))->
  end()->
 
  info('  2.9 - The current celebrity can be updated')->
  get($editUrl)->
  click('Save', array('interview' => array(
    'Celebrity' => array(
      'celebrity_id' => $celebrity->getId()
      )
    )), array('_with_csrf' => true)
  )->
  with('form')->begin()->
    hasErrors(0)->
  end()->
 
  with('doctrine')->begin()->
    check('Interview', array(
      'id' => $interview->getId(),
      'celebrity_id' => $celebrity->getId()
    ))->
    info('  2.10 - Updating the celebrity does not create empty records')->
    check('Celebrity', array(), 4)->
  end()->
 
  info('  2.11 - A new celebrity can be created')->
  get($editUrl)->
  click('Save', array('interview' => array(
    'Celebrity' => array(
      'celebrity_id' => '',
      'NewCelebrity' => array(
        'firstname' => 'oncle',
        'lastname' => 'picsou'
      )
    )
  )), array('_with_csrf' => true)
  )->
  with('doctrine')->begin()->
    check('Celebrity', array(), 5)->
    check('Interview', array(
      'id' => $interview->getId(),
      'celebrity_id' => $celebrity->getId() + 1
    ))->
  end()
;

That’s all folks ! See you soon.


7 Commentaires

  1. Posté le 02/02/2010 à 17:33 | Permalien

    Sweet, we even get functionals as a bonus :D

  2. Norbert
    Posté le 04/02/2010 à 11:12 | Permalien

    Excellent ! Thanks

  3. fedora
    Posté le 09/03/2010 à 18:52 | Permalien

    i have a some exemple
    but for me i would like to create a user with his messages
    i explain :
    user

  4. fedora
    Posté le 09/03/2010 à 18:54 | Permalien

    i have a some exemple
    but for me i would like to create a user with his messages
    i explain :
    user
    id
    name
    message
    id

    i would like to create a user il the same time of his messages
    user_id

  5. Goska
    Posté le 29/04/2010 à 18:54 | Permalien

    Hi,

    thank you for this tutorial, it’s very good.

    My Celebrity table has one more field : photo. As compared to your example I modified CelebrityForm.class.php only by adding appropriate sfWidgetFileInput() and sfValidatorFile(). Then if I add a new celebrity a new object is created and all data are correct except that the the file and file name are not saved. It seems to me that it is some problem with passing the values in doUpdateObject().
    Could you please advise me how to proceed in this case ?
    Regards,
    Goska

  6. Nek
    Posté le 09/07/2010 à 11:58 | Permalien

    Merci pour ce petit tuto !

    Mais j’ai beau essayer impossible d’imbriquer plus que deux formulaires.
    Par exemple pour 3 formulaires Doctrine_Connection_Mysql_Exception me bloque au second en disant qu’il ne peut pas continuer car il manque l’id du 3ème formulaire (celui qui est en tout dernier).

    Si quelqu’un a une solution ça m’aiderait beaucoup ^^ . Merci d’avance.

    Thank you for this how to.

    But I tried to overlap 3 forms and I fail. Doctrine_Connection_Mysql_Exception signal that he don’t have the id of the last form so the form’s save bug…

    If anyone have a solution I’ll be happy to have it. Thank’s.

  7. Posté le 23/07/2010 à 15:08 | Permalien

    Bravo pour ce tutoriel absolument capital et essentiel, qui correspond tout à fait aux besoins réels du dev Symfony. Merci pour le InterviewCelebrityForm, auquel je n’aurais pas pensé tout seul. Bravo, bravo, bravo.

One Trackback

  1. [...] create new if the option is not available. After googling on the internet, I found an interesting article how to overcome these problem. Inspired by Propel 1.5 features ( embedRelation and mergeRelation), [...]