viernes, 6 de enero de 2012

i18n Registros vacios

Optional translation form for I18n objects with Symfony and Doctrine

Scaffolding: Not just for construction workers anymore
Creative Commons License photo credit : kevindooley
There are people around here claiming that the Symfony form framework is a gift from the gods. One might say that this statement is a bit exaggerated, but it’s true that the release of the 1.3/1.4 version of the Symfony brought many improvements, and yes, the form framework can save you a lot of time.
In one of my current project, I had to developp some basic CMS-like features. In a back-office, an admin should be able to writes different kind of articles (news, interviews…). Hey That’s a job for inheritance ! But wait, there’s more. Articles can be translated in several languages. Easy, with the Doctrine I18n behavior.
One last requirement : article translations are optional. One might publish an article in english, an other in french, and a third in both languages. This is a little more tricky, so let’s see how to do this.

Some references

Before we start, here are the wonderful ressources I read to get my way out :

Let’s start with the schema

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
As it’s friday, I will also give you some fixtures :
News:
  n1:
    author: 'Fantomas'
    Translation:
      fr:
        title: 'OK, l''Ipad est sorti. Vous allez me foutre la paix maintentant ?'
        body: |
          Vous commencez serieusement à me gonfler avec vos articles et vos tweets sur
          une techno inutile et bardée de DRM.
 
  n2:
    author: 'Garcin Fony'
    Translation:
      fr:
        title: 'Sinon, à part ça, ça va ?'
        body: |
          Allez, pour me calmer, je vais me refaire un p'tit café
Let’s build the whole thing, and generate the admin in the same time.
php symfony doctrine:build --all --and-load
php symfony generate:app backend
php symfony doctrine:generate-admin backend News
Check the module admin we’ve just build. Click on the first «  edit  » button. Wait a minute ? The I18n fields are just missing, how are we gonna edit our articles ? If you’re not familiar with the I18n behavior, you must know that the content is split into two tables. The new table, «  article_translation  », holds all the I18n fields.
mysql> SELECT * FROM news;
+----+-------------+---------------------+---------------------+
| id | author      | created_at          | updated_at          |
+----+-------------+---------------------+---------------------+
|  1 | Fantomas    | 2010-01-29 12:14:46 | 2010-01-29 12:14:46 | 
|  2 | Garcin Fony | 2010-01-29 12:14:46 | 2010-01-29 12:14:46 | 
+----+-------------+---------------------+---------------------+
 
mysql> SELECT id, lang, title FROM news_translation;
+----+------+------------------------------------------------------------------+
| id | lang | title                                                            |
+----+------+------------------------------------------------------------------+
|  1 | fr   | OK, l'Ipad est sorti. Vous allez me foutre la paix maintentant ? | 
|  2 | fr   | Sinon, à part ça, ça va ?                                     | 
+----+------+------------------------------------------------------------------+
So, to be able to edit our translations, you have to embed the I18n forms, using the embedI18 function. Let’s do that.
// lib/form/doctrine/NewsForm.class.php
class NewsForm extends BaseNewsForm
{
  /**
   * @see ArticleForm
   */
  public function configure()
  {
    parent::configure();
    $this->embedI18n(array('fr', 'en'));
  }
}
Reload the edit page. Now you can edit your news in french and english. Pretty cool, isn’t it ?

Let’s refactor this mess

Since now, it’s been pretty easy. However, the code we’ve written is bad. It’s bad, for the following reasons :
  1. Each time we will add a new article type (e.g. interview, etc.), we will have to update it’s configure method  ;
  2. Each time we will change the available languages (to add a new one, or disable an existing one), we will have to edit every forms  ;
Fortunately, since Symfony 1.3, the form inheritance follow the model structure. Notice that NewsForm extends BaseNewsForm, which extends ArticleForm.
Let’s refactor our code :
# config/app.yml
all:
  cultures:
    enabled:
      fr: Français
      en: English
// lib/form/doctrine/NewsForm.class.php
 
// Revert the changes we added there
class NewsForm extends BaseNewsForm
{
  /**
   * @see ArticleForm
   */
  public function configure()
  {
    parent::configure();
  }
}
 
// lib/form/doctrine/ArticleForm.class.php
class ArticleForm extends BaseArticleForm
{
  /**
   * Available languages
   *
   * @var array $languages
   **/
  protected $langages;
 
  public function configure()
  {
    $this->languages = sfConfig::get('app_cultures_enabled');
 
    $langs = array_keys($this->languages);
 
    $this->embedI18n($langs);
    foreach($this->languages as $lang => $label)
    {
      $this->widgetSchema[$lang]->setLabel($label);
    }
  }
}
Reload the edit form. Now, no matter how many articles sub-types you add, you still can configure available languages from one place.

Translation edition

Let’s try our brand new admin module by editing a news. If you used the fixtures I provided, you should have a french article, with no english translation. Try to update the french version, and click on the ‘Save’ button.
This is a miserable failure. We can’t save the form, because the english fields are required. We’re stuck, we have to fill every translation for an article before we can save it. Wouldn’t it be nice if we could make a translation form optional ? Let’s add a requirement : if every fields in a translation form are empty, it just should be ignored.
To achieve this goal, we will update the doBind method. Here’s the new ArticleForm class.
// lib/form/doctrine/ArticleForm.class.php
class ArticleForm extends BaseArticleForm
{
  /**
   * Available languages
   *
   * @var array $languages
   **/
  protected $langages;
 
  public function configure()
  {
    $this->languages = sfConfig::get('app_cultures_enabled');
 
    $langs = array_keys($this->languages);
 
    $this->embedI18n($langs);
    foreach($this->languages as $lang => $label)
    {
      $this->widgetSchema[$lang]->setLabel($label);
    }
  }
 
  /**
   * Cleans and binds values to the current form
   *
   * Ignore i18n forms when all their fields are empty
   *
   * @see sfForm::doBind
   **/
  protected function doBind(array $values)
  {
    foreach($this->languages as $lang => $label)
    {
      if($this->embeddedI18nFormIsEmpty($values[$lang]))
      {
        unset(
          $values[$lang],
          $this[$lang]
        );
      }
    }
 
    parent::doBind($values);
  }
 
  /**
   * Check if every fields, except for id and lang, are empty
   **/
  protected function embeddedI18nFormIsEmpty(array $values)
  {
    foreach($values as $key => $value)
    {
      if(in_array($key, array('id', 'lang')))
        continue;
 
      if('' !== trim($value))
      {
        return false;
      }
    }
    return true;
  }
}
In the overidden doBind method, we check, for every I18n form, if some values has been submited. If not, we just unset the corresponding fields.
Reload the edit form, update the french translation, leave the english field empty, and click on ‘Save’. Hurray, the forms is saved correctly.
However, you should keep reading, because you’re gonna have a surprise. Let’s look into the database.
mysql> SELECT id, lang, slug FROM news_translation;
+----+------+--------------------------------------------------------------+
| id | lang | slug                                                         |
+----+------+--------------------------------------------------------------+
|  1 | en   |                                                              | 
|  1 | fr   | ok-l-ipad-est-sorti-vous-allez-me-foutre-la-paix-maintentant | 
|  2 | fr   | sinon-a-part-ca-ca-va                                        | 
+----+------+--------------------------------------------------------------
Something went wrong ! Somewhere in the process, Symfony created an empty translation object for our article. Notice the void «  slug  » field ? If you try to edit another news, you will receive a duplicate content error. WTF ?
I must admit that I don’t fully understand why this happens (sometimes I think I do, and a few minutes later, I realize I don’t). However, here’s the solution to overcome this problem.
// lib/form/doctrine/ArticleForm.class.php
 
  // Add this at the beginnig of the class:
  /**
   * I18n ignored forms
   **/
  protected $I18nFormsIgnored = array();
 
  // update the doBind method:
  /**
   * Unset i18n forms values when every field is empty
   **/
  protected function doBind(array $values)
  {
    foreach($this->languages as $lang => $label)
    {
      if($this->embeddedI18nFormEmpty($values[$lang]))
      {
        $this->I18nFormsIgnored[] = $lang;
        unset(
          $values[$lang],
          $this[$lang]
        );
      }
    }
 
    parent::doBind($values);
  }
 
  // And override the doUpdateObject method:
  /**
   * Updates the values of the object with the cleaned up values.
   *
   * @param  array $values An array of values
   *
   * @see sfFormDoctrine::doUpdateObject()
   */
  protected function doUpdateObject($values)
  {
    parent::doUpdateObject($values);
 
    foreach($this->I18nFormsIgnored as $lang)
    {
      unset($this->object->Translation[$lang]);
      unset($values[$lang]);
    }
  }
Delete the useless line in the mysql database, so we can start on a clean base. Once more, reload the edit form, leave the english translation empty, save the form, et voilà ! The form is saved, the translation is updated, and no empty line is added in the table.

Adding and deleting a translation

Just to be sure, add some data in the english translation title, and try to save the form. You should get a «  body is required  » error, which is the expected behavior. Try to fill correctly the english translation, and save again. Check that the english translation is correctly added in the database. Erase all french fields, save one more time, and check that the french translation is definitly removed from the DB (no dummy entry).
Well, it seems that our form is pretty functional. One last thing : there is still a «  slug  » field in each translation form, that you might want to unset. Easy, just edit the ArticleTranslationForm.class.php file :
// lib/form/doctrine/ArticleTranslationForm.class.php
 
class ArticleTranslationForm extends BaseArticleTranslationForm
{
  public function configure()
  {
    unset($this['slug']);
  }
}
Edit a news. If you’re using Symfony 1.{3,4}.1, you’ll see that the damn slug field is still there. It’s a Symfony bug. Indeed, the translations forms don’t follow the inheritance schema. Notice that BaseNewsTranslationForm extends BaseFormDoctrine instead of ArticleNewsTranslation.
You’ll have to manually change that, however, this will be overidden each time you rebuild your forms.
// lib/form/doctrine/base/BaseNewsTranslationForm.class.php
// Replace
abstract class BaseNewsTranslationForm extends BaseFormDoctrine
 
// with
abstract class BaseNewsTranslationForm extends ArticleTranslationForm
 
// lib/form/doctrine/NewsTranslationForm.class.php
// Edit the configure method
class NewsTranslationForm extends BaseNewsTranslationForm
{
  public function configure()
  {
    parent::configure();
  }
}
Now, the slug field should disapear. At the present moment, the bug is still open.

Let’s test everything

As it’s friday, I feel generous, so as a bonus, I will give you the functional tests to cover our magical form. Enjoy.
// lib/sfBackendTestFunctional.class.php
/**
 * This class is used to run functional tests in a secured backend
 *
 * Takes care of the login action, and the fixtures loading
 */
class sfBackendTestFunctional extends sfTestFunctional
{
  public function __construct($browser, $lime = null, $testers = array())
  {
    parent::__construct($browser, $lime, $testers);
  }
 
  /**
   * Perform user authentication
   *
   * @param   array of String         $user_data
   * @return  sfGuardTestFunctional   $this
   */
  public function signin($user_data)
  {
   return $this->info(sprintf('Login as "%s"', $user_data['username']))->
     get('/login')->
     click('sign in', array('signin' => $user_data), array('_with_csrf' => true))->
 
     with('form')->begin()->
       hasErrors(false)->
     end()->
 
     with('user')->begin()->
       isAuthenticated(true)->
     end()->
 
     with('request')->begin()->
       isParameter('module', 'sfGuardAuth')->
       isParameter('action', 'signin')->
     end()->
 
     with('response')->begin()->
       isRedirected()->
       followRedirect()->
       end()
    ;
  }
  /**
   * Load project fixtures
   **/
  public function loadData()
  {
    Doctrine::loadData(sfConfig::get('sf_data_dir').'/fixtures');
    return $this;
  }
}
// test/functional/backend/newsActionsTest.php
 
include(dirname(__FILE__).'/../../bootstrap/functional.php');
 
$browser = new sfBackendTestFunctional(new sfBrowser());
$browser->loadData();
$browser->setTester('doctrine', 'sfTesterDoctrine');
 
$languages = sfConfig::get('app_cultures_enabled');
 
$news = Doctrine::getTable('News')->
  createQuery('n')->
  leftJoin('n.Translation t')->
  andWhere('t.lang = ?', 'fr')->
  orderBy('n.id', 'asc')->
  fetchOne();
 
$editUrl = sprintf('/news/%s/edit', $news->getId());
 
$browser->
  // Uncomment this if your backend is protected by a login
  //signin(array(
  //  'username' => 'admin',
  //  'password' => 'admin'
  //)))->
  get('/news')->
  with('response')->begin()->
    isStatusCode(200)->
  end()->
 
  info('1 - News list')->
  with('response')->begin()->
    checkElement('h1', '/News List/')->
    checkElement('body', '/Fantomas/')->
  end()->
 
  info('2 - News edit')->
  info('  2.1 - I18n forms for all languages are embedded')->
  get($editUrl)->
  with('response')->begin()->
    isStatusCode(200)->
    checkElement('input[name$="[title]"]', count($languages))->
  end()->
 
  info('  2.2 - I18n forms are validated')->
  get($editUrl)->
  click('Save', array('news' => array(
    'en' => array(
      'title' => 'toto',
      'body' => ''
    ))
    ), array('_with_csrf' => true)
  )->
  with('form')->begin()->
    hasErrors(1)->
    isError('en[body]', 'required')->
  end()->
 
  info('  2.3 - Empty forms are validated')->
  get($editUrl)->
  click('Save', array('news' => array(
    'en' => array(
      'title' => '',
      'body' => ''
    ))
    ), array('_with_csrf' => true)
  )->
  with('form')->begin()->
    hasErrors(0)->
  end()->
 
  info('  2.4 - Translations are updated')->
  get($editUrl)->
  click('Save', array('news' => array(
    'fr' => array(
      'title' => 'toto tutu tata',
      'body' => 'riri fifi loulou'
    ))
    ), array('_with_csrf' => true)
  )->
  with('form')->begin()->
    hasErrors(0)->
  end()->
 
  with('doctrine')->begin()->
    check('NewsTranslation', array(
      'id' => $news->getId(),
      'lang' => 'fr',
      'title' => 'toto tutu tata'
    ))->
    info('  2.5 - No empty translation is created')->
    check('NewsTranslation', array(
      'id' => $news->getId(),
      'lang' => 'en',
    ), false)->
  end()->
 
  info('  2.6 - New translations can be added')->
  get($editUrl)->
  click('Save', array('news' => array(
    'en' => array(
      'title' => 'toto tutu tata',
      'body' => 'riri fifi loulou'
    ))
    ), array('_with_csrf' => true)
  )->
 
  with('form')->begin()->
    hasErrors(0)->
  end()->
 
  with('doctrine')->begin()->
    check('NewsTranslation', array(
      'id' => $news->getId(),
      'lang' => 'en',
      'title' => 'toto tutu tata'
    ))->
  end()->
 
  info('  2.7 - Existing translations can be deleted')->
  get($editUrl)->
  click('Save', array('news' => array(
    'fr' => array(
      'title' => '',
      'body' => ''
    ))
    ), array('_with_csrf' => true)
  )->
  with('form')->begin()->
    hasErrors(0)->
  end()->
 
  with('doctrine')->begin()->
    check('NewsTranslation', array(
      'id' => $news->getId(),
      'lang' => 'fr',
    ), false)->
  end()
;
Here we are. Hope you find this helpful. I thought some kind of requirement would be pretty common, however, I didn’t find any directly related howto’s. If you can think of any other way to achieve this, please, let me know.

No hay comentarios: