Agence web et solutions IT, Experts Symfony contact@avanim-prod.com

Collections de formulaires Symfony2

3 novembre 2015 Grégoire Pourteau Symfony Étiquettes : , , 2 Comments

Vous souhaitez réaliser des formulaires sur des collections d’entités imbriquées, en un minimum de temps ? Cet article est fait pour vous ! Symfony2 nous propose de puissants outils pour y parvenir.

Sans plus tarder, prenons l’exemple d’un formulaire de paramétrage d’autres formulaires dynamiques. Chacun dispose de plusieurs zones, et chaque zone de plusieurs champs de saisie. Le modèle de données ressemble donc à :

DynamicForm 1-n DynamicBox 1-n DynamicInput

Commençons par voir le résultat attendu, avec Bootstrap :

Article_image1

Au début on affiche le nom du formulaire dynamique.

En dessous, les onglets bleus représentent ses zones, avec un code, un libellé, un n° de ligne, un n° de colonne. Le bouton d’ajout d’une zone se trouve à droite des onglets, en bleu, celui de suppression tout en bas, en rouge.

A l’intérieur de chacun, un tableau regroupe ses champs de saisie avec les mêmes informations, ainsi qu’un type de données. Le bouton d’ajout se trouve juste en bas, celui de suppression à droite de chaque ligne.

Rien ne nous empêcherait de gérer davantage d’entités rattachées en 1-n, comme DynamicMainBox par exemple, et représentées sous forme d’onglets juste au dessus de ceux-là.

Dans un premier temps, je vous propose de voir le code spécifique à ce besoin, puis dans un second temps, le code commun à tous nos formulaires. Cela permettra de souligner le gain considérable de temps de développement.

  1. Code spécifique

namespace WorkflowBundle\Entity;
class DynamicForm
{
    /**
     * @ORM\OneToMany(targetEntity="DynamicBox", mappedBy="form", cascade={"all"})
     * @ORM\OrderBy({"rowNumber" = "ASC", "columnNumber" = "ASC"})
     */
    private $boxes;
    public function setBoxes($boxes)
    {
        $this->boxes = $boxes;
        foreach($boxes as $box) {
            $box->setForm($this);
        }
    }
}

Notez que le setter appelle le setter inverse pour chaque DynamicBox dans une boucle.

Le même code est nécessaire dans l’entité DynamicBox avec une relation vers DynamicInput.


namespace WorkflowBundle\Form;
class DynamicFormType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder
            ->add('name', 'text', array(
                'label' => 'Nom'))
            ->add('boxes', 'collection', array(
                'label' => 'Zones',
                'type' => new DynamicBoxType(),
                'allow_add' => true,
                'allow_delete' => true,
                'by_reference' => false,
                'required' => false
            ));
    }
    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'WorkflowBundle\Entity\DynamicForm',
            'cascade_validation' => true
        ));
    }
}

L’option by_reference à false permet d’appeler le setter de l’entité, cascade_validation permet d’invalider le formulaire principal si ceux imbriqués sont invalides.

Le même code est nécessaire dans la classe DynamicBoxType avec une relation vers DynamicInputType, et cette option supplémentaire, sous by_reference :

'attr' => array('class' => 'datatable')

Elle permettra de l’afficher différemment.


namespace WorkflowBundle\Controller;
class DefaultController extends Controller
{
    public function dynamicFormUpdateAction(Request $request, $id)
    {
        $dynamicForm = $em->getRepository('WorkflowBundle:DynamicForm')->find($id);

        $originalBoxes = new ArrayCollection();
        foreach ($dynamicForm->getBoxes() as $box) {
            $originalBoxes->add($box);
        }

        $editForm = $this->createForm(new DynamicFormType(), $dynamicForm, [
            'action' => $this->generateUrl('dynamic_form_update', [
                'id' => $dynamicForm->getId()
            ]),
            'method' => 'PUT'
        ]);
        $editForm->handleRequest($request);

        if ($editForm->isValid()) {

            // On supprime les entités disparus :
            foreach ($originalBoxes as $box) {
                if (!$dynamicForm->getBoxes()->contains($box)) {
                    foreach ($box->getInputs() as $input) {
                        em->remove($input);
                    }
                    $em->remove($box);
                }
            }
            foreach ($dynamicForm->getBoxes() as $box) {
                $originalInputs = $em->getRepository('WorkflowBundle:DynamicInput')->findBy(['box' => $box]);
                foreach ($originalInputs as $input) {
                    if (!$box->getInputs()->contains($input)) {
                        em->remove($input);
                    }
                }
            }
            $em->flush();
        }
        // On retourne le template
    }
}

Template twig :

{% block body %}
    {{ form(form) }}
{% endblock %}
{% block javascripts %}
    {{ parent() }}
    <script type="text/javascript" src="{{ asset('bundles/workflow/js/init.js') }}"></script>
{% endblock %}

Script init.js :

function initializeFormCollectionsRow(row) {
    row.find('input[id$="_choices"]').tag();
}

Cette procédure est appelée par un autre script chargé dans le layout, pour chaque onglet et chaque ligne. Dans cet exemple, on initialise un taginput jQuery.

L’ensemble du code spécifique se résume à ces lignes. Le développement d’une autre collection de formulaires ne demandera aucun travail supplémentaire.

 

  1. Code commun

D’abord, commençons par surcharger le rendu des formulaires :

Config.yml :

twig:
    form:
        resources:
            - 'WorkflowBundle:Default:form_fields.html.twig'

On choisit de l’appliquer par défaut à tout le site, mais il est possible de le surcharger uniquement dans certains templates avec :

{% form_theme form 'WorkflowBundle:Default:form_fields.html.twig' %}

Template Default/form_fields.html.twig :

{% extends 'bootstrap_3_layout.html.twig' %}
{%- block collection_widget -%}
    {% if form.vars.attr.class is defined and form.vars.attr.class == 'datatable' %}
        // La variable prototype regroupe tous les champs du formulaire imbriqué
        // Elle permet de construire son «squelette» dans l'attribut data-prototype
        // Ce dernier est utilisé en javascript à l'ajout d'un onglet ou d'une ligne du tableau :
        {%- set data_prototype = '<tr>' -%}
        {%- for child in prototype -%}
            {%- set data_prototype = data_prototype ~ '<td>' ~ form_widget(child) ~ '</td>' -%}
        {%- endfor -%}
        {%- set data_prototype = data_prototype ~ '</tr>' -%}
        {%- set attr = attr|merge({ 'data-prototype': data_prototype}) -%}

        {%- set attr = attr|merge({ 'data-length': form|length, 'class': attr.class|default('') ~ ' table dataTable' }) -%}

        <table {{ block('widget_container_attributes') }}>
            <thead>
            <tr>
                {%- for child in prototype -%}
                        <th {% if child.vars.attr['data-width'] is defined %}width="{{- child.vars.attr['data-width'] -}}"{% endif %}>
                            {{- form_label(child) -}}
                        </th>
                {%- endfor -%}
                <th></th>
            </tr>
            </thead>
            <tbody>

            // On parcourt l'objet form pour initialiser chaque ligne :
            {%- for formChild in form -%}
                <tr>
                    {%- for child in formChild -%}
                            <td>
                                {{- form_widget(child) -}}
                                {{- form_errors(child) -}}
                            </td>
                    {%- endfor -%}
                </tr>
            {%- endfor -%}
            </tbody>
        </table>

    {% else %}

            {%- set attr = attr|merge({ 'data-prototype': form_widget(prototype), 'data-length': form|length, 'class': attr.class|default('') ~ ' tabbable' }) -%}

        <div {{ block('widget_container_attributes') }}>
            <ul class="nav nav-tabs tab-color-blue">

                // On parcourt l'objet form pour initialiser chaque onglet :
                {%- for key, child in form -%}
                    <li{% if loop.first %} class="active"{% endif %}>

                        {% if child.vars.value.__toString is defined %}
                            {% set title = child.vars.value %}
                        {% else %}
                            {% set title = 'N° ' ~ (key + 1) %}
                        {% endif %}

                        <a href="#{{ form.vars.name ~ key }}" data-toggle="tab" aria-expanded="false">{{ title }}</a>
                    </li>
                {%- endfor -%}

                </ul>
            <div class="tab-content">

                {%- for key, child in form -%}
                    <div id="{{ form.vars.name ~ key }}" class="tab-pane{% if loop.first %} active{% endif %}">
                        {{ form_widget(child) }}
                    </div>
                {%- endfor -%}

            </div>
        </div>
    {% endif %}
{%- endblock collection_widget -%}

 

Script initFormCollections.js à charger dans le layout :

$(document).ready(function() {
    initializeFormCollections($('body'));
});

function initializeFormCollections(container) {
    container.find('table[data-prototype]').each(function() {
        var table = $(this);
        (function(table) {

            // On crée un bouton addButton dans la table

            addButton.click(function (e) {
                e.preventDefault();

                // On construit la nouvelle ligne à partir de l'attribut data-prototype :

                var prototype = table.attr('data-prototype');
                var row = $(prototype.replace(/__name__/g, Math.max(table.attr('data-length'), table.find('tbody tr').length)));

                createRemoveButton(row, true);
                
                table.children('tbody').append(row);
            });

            table.find('tbody tr').each(function () {

                createRemoveButton($(this), true);
            });
        })(table);
    });
    container.find('div[data-prototype]').each(function() {
        var div = $(this);
        (function(div) {

            // On crée un bouton addButton dans la balise ul

            addButton.click(function (e) {
                e.preventDefault();

                // On construit le nouvel onglet à partir de l'attribut data-prototype :

                var prototype = div.attr('data-prototype');
                var tabIndex = div.children('div.tab-content').children('div.tab-pane').length;
                var row = $(prototype.replace(/__name__/g, Math.max(div.attr('data-length'), tabIndex)));

                createRemoveButton(row, false);
                initializeFormCollections(row);

                // On récupère le nom de l'entité ("boxes") :
                var divIdPieces = div.attr('id').split('_');
                var divIdSuffix = divIdPieces[divIdPieces.length – 1];

                div.children('.tab-content').append($('<div id="' + divIdSuffix + (tabIndex + 1) + '" class="tab-pane"></div>').append(row));
                var rowLink = $('<a href="#' + divIdSuffix + (tabIndex + 1) + '" data-toggle="tab" aria-expanded="false">N°' + tabIndex + '</a>');
                $('<li></li>').append(rowLink).insertBefore(addButtonLi);

                rowLink.click();
            });

            div.children('div.tab-content').children('div.tab-pane').each(function () {

                createRemoveButton($(this), false);
            });
        })(div);
    });
}


function createRemoveButton(row, isTable) {
    
    // On crée un bouton removeButton

    if (isTable) {
    // On l'ajoute au tableau
    } else {
    // On l'ajoute à la balise div.tab-pane
    }

    removeButton.click(function(e) {
        e.preventDefault();

        if (isTable) {
        // On supprime la ligne parent
        } else {
        // On supprime la balise li.active et la div.tab-pane parent
        }
    });

    if (typeof initializeFormCollectionsRow == 'function') {

        initializeFormCollectionsRow(row);
    }
}

 

C’est ici que s’achève cet article sur les collections de formulaires Symfony2. Comme vous le voyez, ces outils permettent de gérer plusieurs entités en relation, en un minimum de temps.

J’espère que ces lignes vous seront utiles, et je vous donne rendez-vous bientôt, pour partager d’autres techniques de Symfony2 toutes aussi puissantes !

2 Comments

  1. NoX 1 année Répondre

    Salut,

    merci pour ce tuto!

    Malgré tout, un peu plus de détails (renseigner un peu plus les différentes étapes, surtout au niveau du code JS et du template, mais également fournir un diagramme de classe avec une relation parents enfants sur plusieurs niveau; tout cela correctement imbriqué dans une collection de collections d’enfants géré au niveau des forms et de twig) serait cool, car des fois, on a l’impression de sauter des étapes, surtout pour un débutant Symfony comme moi.

  2. NoX 1 année Répondre

    Un code final fourni à la fin pourrait être cool aussi 🙂