Validation des formulaires dynamiques avec Symfony2

conseils Symfony2

Ce site ne sera plus alimenté de contenu après août 2014. Tous les nouveaux articles seront redigés pour www.waitingforcode.com
Le même projet duquel je me suis inspiré pour écrire l'article Authentification de Symfony2 m'a également servi pour développer un système des formulaires dynamiques. Cette fois-ci on aura une occassion d'approfondir la partie du framework développée par Fabien Potencier et Bernhard Schussek.

Dans cet article on développera un formulaire qui, en fonction de la catégorie sélectionnée, charge les champs dynamiquement en AJAX et, ensuite, les valide avec les contraintes Symfony2. Pour ce faire, on aura besoin des fichiers suivants :
- des contrôlleurs (controller) pour exécuter les actions
- une entité (entity) pour la validation
- des repositories(repository) afin de récupérer les données à insérer
- des formulaires (form) qui serviront à créer les vues
- un écouteur (listener) grâce à qui on pourra gérer dynamiquement les données passées par le formulaire

Tables dans la base de données
On va commencer par créer une table dans la base de données qui contiendra les champs à rajouter.

-- Structure for form fields table
CREATE TABLE IF NOT EXISTS form_fields (
id_field int(4) NOT NULL AUTO_INCREMENT,
fullname varchar(200) NOT NULL, -- form field fullname, used only to better understanding
codename varchar(30) NOT NULL, -- field name, shown in the form
type varchar(11) NOT NULL, -- type of Symfony2 form field (text, textarea, choice...)
type_options text NOT NULL, -- Symfony2 form's types
constraints text NOT NULL, -- Symfony2 constraints
PRIMARY KEY (id)
) ENGINE=InnoDB;

INSERT INTO form_fields (id_field, fullname, codename, type, type_options, constraints) VALUES
(1, 'Nom du site Internet', 'siteweb', 'text', 'a:3:{s:8:"required";b:1;s:4:"trim";b:1;s:10:"max_length";i:100;}', 'a:2:{i:0;a:2:{s:10:"constraint";s:49:"\Symfony\Component\Validator\Constraints\NotBlank";s:7:"options";a:1:{s:7:"message";s:16:"Indiquez le site";}}i:1;a:2:{s:10:"constraint";s:50:"\Symfony\Component\Validator\Constraints\MaxLength";s:7:"options";a:2:{s:5:"limit";i:100;s:7:"message";s:47:"Indiquez le site avec moins que 100 caractères";}}}'),
(2, 'Technologie utilisée', 'technology', 'textarea', 'a:3:{s:8:"required";b:1;s:4:"trim";b:1;s:10:"max_length";i:100;}', 'a:2:{i:0;a:2:{s:10:"constraint";s:49:"\Symfony\Component\Validator\Constraints\NotBlank";s:7:"options";a:1:{s:7:"message";s:23:"Indiquez la technologie";}}i:1;a:2:{s:10:"constraint";s:50:"\Symfony\Component\Validator\Constraints\MaxLength";s:7:"options";a:2:{s:5:"limit";i:100;s:7:"message";s:54:"Indiquez la technologie avec moins que 100 caractères";}}}');

-- Structure of categories table
CREATE TABLE IF NOT EXISTS categories (
id_category int(11) NOT NULL AUTO_INCREMENT,
name_category varchar(150) NOT NULL,
PRIMARY KEY (id_ca)
) ENGINE=InnoDB;

INSERT INTO categories (id_category, name_category) VALUES
(1, 'Internet'),
(2, 'Développement Web'),
(3, 'Web design'),
(4, 'SEO/SEM');

-- Structure for form fields attached to categories
CREATE TABLE IF NOT EXISTS form_fields_categories (
id_category int(11) NOT NULL,
id_field int(4) NOT NULL,
label varchar(300) NOT NULL,
PRIMARY KEY (id_category,id_field),
KEY id_field (id_field)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

INSERT INTO form_fields_categories (id_category, id_field, label) VALUES
(1, 1, 'Le nom du site Internet'),
(1, 2, 'Technologie #1'),
(2, 1, 'Le nom du site #2'),
(2, 2, 'Technologie #2'),
(3, 1, 'Le nom du site #3'),
(3, 2, 'Technologie #3'),
(4, 1, 'Le nom du site #4'),
(4, 2, 'Technologie #4');

Le code ne nécessites pas de grandes explications. La seule chose qui peut choquer est le champ label dans la table form_fields_categories. Il y se trouve parce qu'on voudra personnaliser chaque libellé afin de voir l'effet sans rajouter 20 champs dans la table form_fields.

Vous pouvez, bien évidemment selon la spécification, transférer ce champs dans la table form_fields ou rajouter une nouvelle table labels et la joindre au form_fields_categories.

Les entités
Et maintenant on va passer à la définition des entités. Elles vont déjà contenir les méthodes qu'on utilisera pour le système de formulaire dynamique.

namespace Category\CategoriesBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
* @ORM\Table(name="form_fields")
* @ORM\Entity(repositoryClass="Category\CategoriesBundle\Repository\FormFieldsRepository")
*/
class FormFields
{

/**
* @ORM\Id
* @ORM\Column(name="id_field", type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
*/
protected $idField;

/**
* @ORM\Column(name="fullname", type="string", length="200", nullable=false)
*/
protected $fullName;

/**
* @ORM\Column(name="codename", type="string", length="30", nullable=false)
*/
protected $codeName;

/**
* @ORM\Column(name="type", type="string", length="11", nullable=false)
*/
protected $typeForm;

/**
* @ORM\Column(name="type_options", type="text", nullable=false)
*/
protected $typeOptionsForm;

/**
* @ORM\Column(name="constraints", type="text", nullable=false)
*/
protected $constraintsForm;

}




namespace Category\CategoriesBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
* @ORM\Table(name="categories")
* @ORM\Entity(repositoryClass="Category\CategoriesBundle\Repository\CategoriesRepository")
*/
class Categories
{

/**
* @ORM\Id
* @ORM\Column(name="id_category", type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
*/
protected $idCategory;

/**
* @ORM\Column(name="name_category", type="string", length="150", nullable=false)
*/
protected $nameCategory;

}




namespace Category\CategoriesBundle\Entity;

use Doctrine\ORM\Mapping as ORM;

/**
* @ORM\Table(name="form_fields_categories")
* @ORM\Entity(repositoryClass="Category\CategoriesBundle\Repository\FormFieldsCategoriesRepository")
*/
class FormFieldsCategories
{

/**
* @ORM\Id
* @ORM\ManyToOne(targetEntity="Category\CategoriesBundle\Entity\Categories")
* @ORM\JoinColumn(name="categories", referencedColumnName="id_category")
*/
protected $idCategory;

/**
* @ORM\Id
* @ORM\ManyToOne(targetEntity="Category\CategoriesBundle\Entity\FormFields")
* @ORM\JoinColumn(name="id_field", referencedColumnName="id_field")
*/
protected $idField;

/**
* @ORM\Column(name="label", type="string", length="300", nullable=false)
*/
protected $labelForm;

}




namespace Web\ProjectsBundle\Entity;

use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Mapping\ClassMetadata;


use Symfony\Component\Validator\ExecutionContext;
use Symfony\Component\Validator\Constraints\Callback;
use Symfony\Component\Validator\Constraints\CallbackValidator;

/**
* @ORM\Table(name="projects")
* @ORM\Entity(repositoryClass="Web\ProjectsBundle\Repository\ProjectsRepository")
*/
class Projects
{

/**
* @ORM\Id
* @ORM\Column(name="id_project", type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
*/
protected $idProject;

/**
* @ORM\ManyToOne(targetEntity="Category\CategoriesBundle\Entity\Categories")
* @ORM\JoinColumn(name="id_category", referencedColumnName="id_category")
*/
protected $idCategory;

/**
* @ORM\Column(name="name", type="string", length="200", nullable=false)
*/
protected $nameProject;

/**
* @ORM\Column(name="text", type="text", nullable=false)
*/
protected $textProject;

private $categoriesList = array();
private $formFields = array();
// dynamic fields
private $technology;
private $siteweb;

/**
* Getters
*/
public function getNameProject()
{
return $this->nameProject;
}
public function getTextProject()
{
return $this->textProject;
}
public function getIdCategory()
{
return $this->idCategory;
}
public function getCategoriesList()
{
$categories = array();
foreach($this->categoriesList as $c => $category)
{
$categories[$category['id_category']] = $category['categoryName'];
}
return $categories;
}
public function getFormFields()
{
return $this->formFields;
}
/**
* Setters
*/
public function setNameProject($value)
{
$this->nameProject = $value;
}
public function setTextProject($value)
{
$this->textProject = $value;
}
public function setIdCategory($value)
{
$this->idCategory = $value;
}
public function setFormFields($value)
{
$this->formFields = $value;
}
/**
* Sets data after submitting an addition form.
* @access public
* @param array $params Params list.
* @return void
*/
public function setDataAdded($params)
{
foreach($params as $p => $param)
{
$method = 'set'.ucfirst($p);
$this->$method($param);
}
}

public function getSiteweb()
{
return $this->siteweb;
}
public function getTechnology()
{
return $this->technology;
}
public function setTechnology($value)
{
$this->technology = $value;
}
public function setSiteweb($value)
{
$this->siteweb = $value;
}

/**
* Validates ad addition form. It's called by Callback validator.
* @access public
* @param $context Symfony\Component\Validator\ExecutionContext Currenct context.
* @return void
*/
public function validAdd(ExecutionContext $context)
{
$contextPath = $context->getPropertyPath();
foreach($this->formFields as $field)
{
$constraints = (array)unserialize($field['constraintsForm']);
foreach($constraints as $constraint)
{
$constraintValidator = $constraint['constraint'].'Validator';
$cons = new $constraint['constraint']($constraint['options']);
$validator = new $constraintValidator;
$fieldGetter = 'get'.ucfirst($field['codeName']);
if(!$validator->isValid($this->$fieldGetter(), $cons))
{
$property_path = $contextPath.'.'.$field['codeName'];
$context->setPropertyPath($property_path);
// echo $property_path.'===>'.$field['codeName'].' has not valid value '.$cons->message.'
';
$context->addViolation($cons->message, array(), null);
}
}
}
}

/**
* Form constraints.
*/
public static function loadValidatorMetadata(ClassMetadata $metadata)
{
// for do simple, we don't use constraints on fields loaded statically
$metadata->addConstraint(new Callback(array('methods' => array('validAdd'), 'groups' => array('addProject'))));
}

/**
* Sets categories list used by the form.
* @access public
* @param array $categories Categories list.
* @return void
*/
public function setCategoriesList($categories)
{
$this->categoriesList = $categories;
}

}


La classe principale s'appelle Projects. C'est elle qui gère la validation des champs rajoutés dynamiquement. Afin de le faire on utilise le validateur Callback qui permet d'effectuer la vérification pour les champs rajoutés dynamiquement. Il est important de savoir que les paramètres passés (methods, groups) doivent être des tableaux. Cela vous permettra d'exécuter plusieurs méthodes de validation.

Les repositories

namespace Category\CategoriesBundle\Repository;

use Doctrine\ORM\EntityRepository;
use Doctrine\ORM\Query\ResultSetMapping;

class FormFieldsCategoriesRepository extends EntityRepository
{

/**
* Gets all form fields.
* @access public
* @param int $category Categorie's id.
* @return array Categorie's list.
*/
public function getFields($category)
{
$query = $this->getEntityManager()
->createQuery("SELECT ffc.labelForm, f.codeName, f.typeForm, f.typeOptionsForm,
f.constraintsForm
FROM CategoryCategoriesBundle:FormFieldsCategories ffc
JOIN ffc.id_category c
JOIN ffc.id_field f
WHERE ffc.id_category = :category")
->setParameter("category", $category);
return $query->getResult();
}

}


La seule méthode, getFields($category), reprend la liste des champs à rajouter dans la partie statique du formulaire.

Les formulaires

namespace Others;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;

/**
* Form template used to generate form fields dynamically.
*/
class FormTemplate extends AbstractType
{

/**
* Form's name.
* @access private
* @var string
*/
private $name;

/**
* List of form's elements.
* @access private
* @var array
*/
private $fields;

public function __construct($name, $fields)
{
$this->name = $name;
$this->fields = $fields;
}

/**
* Builds form fields.
*/
public function buildForm(FormBuilder $builder, array $options)
{
foreach($this->fields as $field)
{
$options = (array)unserialize($field['typeOptionsForm']);
$builder->add($field['codeName'], $field['typeForm'], $options);
}
}

public function getDefaultOptions(array $options)
{
return array('validation_groups' => array($this->name));
}

public function getName()
{
return $this->name;
}
}

Ce formulaire sera utilisé par la méthode qui récupère les champs à rajouter dans le formulaire statique. Il ne se distingue pas d'un formulaire classique. Une seule différence s'illustre par la génération dynamique des champs dans la fonction buildForm().


namespace Web\ProjectsBundle\Form;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilder;

use Others\FormTestListener;

/**
* Form used to add a new project.
*/
class AddProject extends AbstractType
{
public function buildForm(FormBuilder $builder, array $options)
{
$builder->add('adName', 'text', array('required' => true, 'trim' => true, 'max_length' => 200));
$builder->add('adText', 'textarea', array('required' => true, 'trim' => true));
$builder->add('adCategory', 'choice', array('required' => true, 'choices' => $options['data']->getCategoriesList()));
// If we already have some additional fields (for exemple, after the first submit of our form)
foreach($options['data']->getFormFields() as $f => $field)
{
$options = (array)unserialize($field['typeOptionsForm']);
$builder->add($field['codeName'], $field['typeForm'], $options);
}
$listener = new FormTestListener($builder->getFormFactory());
$builder->addEventSubscriber($listener);
}

public function getDefaultOptions(array $options)
{
return array('validation_groups' => array('addProject'));
}

public function getName()
{
return 'AddProject';
}
}


Là encore, presque rien d'anormal. La seule chose spéciale se cache dans la méthode buildForm() sous la variable $listener. Le listener va servir à définir les données supplémentaires passées avec notre formulaire. Cela va nous permettre de valider les contraintes définies dans la base de données pour ces champs et appelées par CallbackValidator.

L'écouteur

namespace Others;

use Symfony\Component\Form\FormEvents;
use Symfony\Component\Form\FormFactoryInterface;
use Symfony\Component\Form\Event\DataEvent;
use Symfony\Component\EventDispatcher\EventSubscriberInterface;

class FormTestListener implements EventSubscriberInterface
{
private $factory;

public function __construct(FormFactoryInterface $factory)
{
$this->factory = $factory;
}

public static function getSubscribedEvents()
{
return array(FormEvents::BIND_CLIENT_DATA => 'preSetData');
}

/**
* Appends not defined data to sending form.
* @access public
* @param Symfony\Component\Form\Event\DataEvent Handled event.
* @return void
*/
public function preSetData(DataEvent $event)
{
$form = $event->getForm();
// gets used Entity and form fields
$newFields = $form->getClientData()->getFormFields();
foreach($newFields as $field)
{
// appends new fields to the form
$form->add($this->factory->createNamed($field['typeForm'], $field['codeName']));
}
}
}


Le voici, le listener qu'on a défini dans la classe AddProject. Il intercepte la requête et y rajoute les champs dynamiques. Ensuite, l'ensemble passe à l'entité Projects qui se charge de valider les données. Si l'on néglige cette étape, le formulaire sera toujours invalide parce que cette entité n'exécutera pas les setters pour les partie rajoutées dynamiquement. Pour voir ça, il vous suffit de commenter le fragment $form->add().

Les contrôlleurs

namespace Category\CategoriesBundle\Controller;

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Frontend\FrontBundle\Controller\FrontController;

use Others\FormTemplate;

class CategoriesController extends FrontController
{

/**
* Protected variable which contains fields handled by dynamic form generation.
* @access protected
* @var array
*/
protected $_handledForms = array('AddProject');

/**
* Gets categories list by AJAX request.
* @return Displayed template.
*/
public function getAjaxListAction(Request $request)
{
$category = (int)$request->request->get('category');
$formQuery = $request->request->get('form');
$formData = rawurldecode($request->request->get('data'));
if(in_array($formQuery, $this->_handledForms))
{
$fields = $this->enMan->getRepository('CategoryCategoriesBundle:FormFieldsCategories')->getFields($category);
// prepares form data (if one field for two or more rows)
$defaultData = array();
foreach(explode('&', $formData) as $data)
{
if(preg_match_all('/^'.$formQuery.'\[(.*)\]=(.*)$/i', $data, $matches))
{
$defaultData[$matches[1][0]] = $this->filtXss->filterXss($matches[2][0]);
}
}
// makes form field to render
$form = $this->createForm(new FormTemplate($formQuery, $fields));
$form->setData($defaultData);
return $this->render('CategoryCategoriesBundle:Categories:getAjaxList.html.php', array('form' => $form->createView(),
'fields' => $fields));
}
else
{
return new Response('');
}
}

}


Ce contrôlleur est utilisé pour récupérer la liste des catégories. On y retrouve la classe FormTemplate qui sert à génerer un forumaire vierge. La vue pour cette action se présente ainsi :


<div><label for="">label($form[$field['codeName']], $field['labelForm']) ?>widget($form[$field['codeName']], array('attr' => array('class' => 'genElement'))) ?></div>


Les champs ($fields) sont rajoutés un par un, avec la création d'une nouvelle ligne du formulaire. On y présente une version simplifiée, avec le principe que tous les formulaires ont la même structure. Vous allez peut-être devoir modifier cette partie afin de l'adapter aux différentes mises en page de vos formulaires.


namespace Web\ProjectsBundle\Controller;

use Symfony\Component\HttpFoundation\RedirectResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Security\Core\SecurityContext;
use Frontend\FrontBundle\Controller\FrontController;
use Web\ProjectsBundle\Form\AddProject;
use Web\ProjectsBundle\Entity\Projects;

class ItemsController extends FrontController
{

/**
* Add project action.
* @return Displayed template.
*/
public function addProjectAction(Request $request)
{
$flashSess = $request->getSession();
$postData = $flashSess->getFlash('formData');
$proEnt = new Projects();
if(count($flashData = $flashSess->getFlash('formData')) > 0)
{
$proEnt->setDataAdded($flashData['AddProject']);
}
$proEnt->setCategoriesList($this->enMan->getRepository('CategoryCategoriesBundle:Categories')->getCategories(false));
$data = $request->request->all('AddProject');
// set form fields : form is submit or form was submit and had the validation errors (category taken from flash session)
if(isset($data['AddProject']))
{
$proEnt->setFormFields($this->enMan->getRepository('CategoryCategoriesBundle:FormFieldsCategories')->getFields((int)$data['AddProject']['idCategory']));
}
elseif($proEnt->getIdCategory() != '')
{
$proEnt->setFormFields($this->enMan->getRepository('CategoryCategoriesBundle:FormFieldsCategories')->getFields((int)$proEnt->getIdCategory()));
}
$formAdd = $this->createForm(new AddProject(), $proEnt);
$formAdd->setData($proEnt);
if($request->getMethod() == 'POST')
{
$formAdd->bindRequest($request);
if($formAdd->isValid())
{
echo 'is valid'; die();
}
else
{
$flashSess->setFlash('formData', $data);
$flashSess->setFlash('formErrors', $this->getAllFormErrors($formAdd));
return $this->redirect($this->generateUrl('projectAdd'));
}
}
return $this->render('WebProjectsBundle:Items:add.html.php', array('form' => $formAdd->createView(),
'formErrors' => $flashSess->getFlash('formErrors'), 'formFields' => $proEnt->getFormFields()));
}

}
?>

Ce contrôlleur se charge de créer le formulaire statique, avec les champs de base (titre, contenu, catégorie). Si le formulaire est correctement validé, il affiche "is valid". Sinon, il redirige vers le formulaire et affiche les erreurs. La vue se présente ainsi :

extend('::frontend_base.html.php') ?>

<script type="text/javascript">
$(document).ready(function() {
$('#AddProject_idCategory').change(function() {
$('#loaderForm').show();
$('#restForm div.fields').hide();
$.ajax({
type: "POST",
url: "generate('ajaxGetCategories') ?>",
data: {category: $('#AddProject_idCategory').val(), form: 'AddProject', data: $('#restForm div.fields .genElement').serialize()},
success: function(result)
{
$('#restForm div.fields').html(result).show();
$('#loaderForm').hide();
}
});
});
});
</script>

<form action="generate('projectAdd') ?>" method="post">
<div><label for="">label($form['nameProject'], "Titre") ?></label>widget($form['nameProject']) ?></div>
<div><label for="">label($form['textProject'], "Description") ?></label>widget($form['textProject']) ?></div>
<div><label for="">label($form['idCategory'], "Catégorie") ?></label>widget($form['idCategory']) ?></div>
<div id="restForm">
<b>Added fields</b>
<div id="loaderForm" class="hidden">Form loader...</div>
<div class="fields">
$field) { ?>
<div><label for="">label($form[$field['codeName']], $field['labelForm']) ?></label>widget($form[$field['codeName']]) ?></div>

</div>
</div>
<input type="submit" name="send" value="submit form" />
</form>

Dans le template on retrouve la partie JavaScript qui charge les champs et les rattache au div avec l'identifiant restForm.

A travers cet article vous avez pu voir que la validation des champs dynamiques sous Symfony2 n'est pas un problème. Tout comme plein d'autres choses qu'on allait découvrir en travaillant avec ce framework.

L'article écrit en rythme de:
Victor'O - Tan ja pasé
Bartosz KONIECZNY 21-08-2011 14:22 Symfony2
Moi

Développeur d'applications Internet et journaliste passionné par l'adjectif français. Un aigle polonais orienté vers la progression, volant très haut et écoutant du zouk après les matches du foot français.

Vous appréciez mon travail ?

Pour contribuer au développement de ce site, ou pour remercier pour des articles rédigés, vous pouvez faire un don.

Un conseil Android

Comment accéder au localhost ?

A la place de 127.0.0.1 il faut utiliser 10.0.2.2.