Cómo integrar una colección de formularios

En este artículo, aprenderás cómo crear un formulario que integra una colección de muchos otros formularios. Esto podría ser útil, por ejemplo, si tienes una clase Tarea y quieres crear/editar/eliminar muchos objetos Etiqueta relacionados con esa Tarea, justo dentro del mismo formulario.

Nota

En este artículo, vagamente asume que estás utilizando Doctrine como almacén de base de datos. Pero si no estás usando Doctrine (por ejemplo, Propel o simplemente una conexión directa a la base de datos), es casi lo mismo. Sólo hay unas cuantas partes de esta guía que realmente se preocupan de la «persistencia».

Si estás utilizando Doctrine, tienes que añadir los metadatos de Doctrine, incluyendo el asociación MuchosAMuchos en la definición de asignación en la propiedad tags de la Tarea.

Vamos a empezar por ahí: Supongamos que cada Tarea pertenece a múltiples objetos Tags. Empieza creando una sencilla clase Tarea:

// src/Acme/TaskBundle/Entity/Task.php
namespace Acme\TaskBundle\Entity;

use Doctrine\Common\Collections\ArrayCollection;

class Task
{
    protected $description;

    protected $tags;

    public function __construct()
    {
        $this->tags = new ArrayCollection();
    }

    public function getDescription()
    {
        return $this->description;
    }

    public function setDescription($description)
    {
        $this->description = $description;
    }

    public function getTags()
    {
        return $this->tags;
    }

    public function setTags(ArrayCollection $tags)
    {
        $this->tags = $tags;
    }
}

Nota

El ArrayCollection es específico de Doctrine y básicamente es lo mismo que usar un arreglo (pero este debe ser un ArrayCollection) si estás usando Doctrine.

Ahora, crea una clase Etiqueta. Cómo vimos arriba, una Tarea puede tener muchos objetos Etiqueta:

// src/Acme/TaskBundle/Entity/Tag.php
namespace Acme\TaskBundle\Entity;

class Tag
{
    public $name;
}

Truco

Aquí, la propiedad name es pública, pero fácilmente puede ser protegida o privada (pero entonces necesitaríamos métodos getName y setName).

Ahora veamos los formularios. Crea una clase formulario para que el usuario pueda modificar un objeto Tag:

// src/Acme/TaskBundle/Form/Type/TagType.php
namespace Acme\TaskBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;

class TagType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('name');
    }

    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'Acme\TaskBundle\Entity\Tag',
        ));
    }

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

Con esto, tienes suficiente para que una etiqueta de formulario se dibuje por sí misma. Pero debido a que el objetivo final es permitir que las etiquetas de una Tarea sean modificadas directamente dentro del formulario de la tarea en sí mismo, crea un formulario para la clase Tarea.

Ten en cuenta que integraste una colección de formularios TagType usando el tipo de campo collection:

// src/Acme/TaskBundle/Form/Type/TaskType.php
namespace Acme\TaskBundle\Form\Type;

use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolverInterface;

class TaskType extends AbstractType
{
    public function buildForm(FormBuilderInterface $builder, array $options)
    {
        $builder->add('description');

        $builder->add('tags', 'collection', array('type' => new TagType()));
    }

    public function setDefaultOptions(OptionsResolverInterface $resolver)
    {
        $resolver->setDefaults(array(
            'data_class' => 'Acme\TaskBundle\Entity\Task',
        ));
    }

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

En tu controlador, ahora tendrás que iniciar una nueva instancia de TaskType:

// src/Acme/TaskBundle/Controller/TaskController.php
namespace Acme\TaskBundle\Controller;

use Acme\TaskBundle\Entity\Task;
use Acme\TaskBundle\Entity\Tag;
use Acme\TaskBundle\Form\Type\TaskType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;

class TaskController extends Controller
{
    public function newAction(Request $request)
    {
        $task = new Task();

        // código ficticio - esto está aquí sólo para que la tarea tenga algunas
        // etiquetas, de lo contrario, este no sería un ejemplo interesante
        $tag1 = new Tag();
        $tag1->name = 'tag1';
        $task->getTags()->add($tag1);
        $tag2 = new Tag();
        $tag2->name = 'tag2';
        $task->getTags()->add($tag2);
        // termina el código maniquí

        $form = $this->createForm(new TaskType(), $task);

        // procesa el formulario en POST
        if ($request->isMethod('POST')) {
            $form->bind($request);
            if ($form->isValid()) {
                // posiblemente hagas algún procesamiento del formulario,
                // tal como guardar los objetos Task y Tag
            }
        }

        return $this->render('AcmeTaskBundle:Task:new.html.twig', array(
            'form' => $form->createView(),
        ));
    }
}

La plantilla correspondiente, ahora es capaz de reproducir tanto el campo descripción del formulario de Tarea, así como todos los formularios TagType de las etiquetas que ya están relacionados con esta Tarea. En el controlador anterior, agregamos cierto código ficticio para poder ver esto en acción (debido a que una tarea tiene cero etiquetas al crearla por primera vez).

  • Twig
    {# src/Acme/TaskBundle/Resources/views/Task/new.html.twig #}
    
    {# ... #}
    
    <form action="..." method="POST" {{ form_enctype(form) }}>
        {# reproduce únicamente los campos task: descripción #}
        {{ form_row(form.description) }}
    
        <h3>Tags</h3>
        <ul class="tags">
            {# itera sobre cada etiqueta existente y reproduce su único campo: name #}
            {% for tag in form.tags %}
                <li>{{ form_row(tag.name) }}</li>
            {% endfor %}
        </ul>
    
        {{ form_rest(form) }}
        {# ... #}
    </form>
    
  • PHP
    <!-- src/Acme/TaskBundle/Resources/views/Task/new.html.php -->
    
    <!-- ... -->
    
    <form action="..." method="POST" ...>
        <h3>Tags</h3>
        <ul class="tags">
            <?php foreach($form['tags'] as $tag): ?>
                <li><?php echo $view['form']->row($tag['name']) ?></li>
            <?php endforeach; ?>
        </ul>
    
        <?php echo $view['form']->rest($form) ?>
    </form>
    
    <!-- ... -->

Cuando el usuario envía el formulario, los datos presentados por los campos Tag se utilizan para construir un ArrayCollection de los objetos Tag, que luego se establecen en el campo tag de la instancia Tarea.

La colección Tags, naturalmente, es accesible a través de $task->getTags() y se puede persistir en la base de datos o utilizar donde sea necesaria.

Hasta el momento, esto funciona muy bien, pero aún no te permite agregar dinámicamente nuevas etiquetas o eliminar existentes. Por lo tanto, durante la edición de etiquetas existentes funcionará bien, tu usuario en realidad no puede añadir ninguna nueva etiqueta, todavía.

Prudencia

En esta entrada, integraste una sola colección, pero de ninguna manera estás limitado a esto. También puedes integrar colecciones anidadas con tantos niveles descendientes como quieras. Pero si utilizas XDebug en tu configuración de desarrollo, puedes recibir un error La función alcanzó el máximo nivel de anidamiento de «100», ¡abortando!. Esto se debe a la opción xdebug.max_nesting_level de PHP, que por omisión es de 100.

Esta directiva limita la recursividad a 100 llamadas que pueden no ser suficientes para reproducir el formulario en la plantilla si pintas todo el formulario de una vez (por ejemplo, form_widget(form)). Para solucionar este problema puedes redefinir esta directiva a un valor más alto (ya sea a través de un archivo ini de PHP o por medio de ini_set, por ejemplo, en app/autoload.php) o pintar cada campo del formulario a mano usando form_row.

Permitiendo «nuevas» etiquetas con «prototype»

Permitir al usuario añadir nuevas etiquetas dinámicamente significa que necesitarás usar algo de JavaScript. Anteriormente añadiste dos etiquetas a tu formulario en el controlador. Now let the user add as many tag forms as he needs directly in the browser. Esto se hará a través de un poco de JavaScript.

Lo primero que tienes que hacer es darle a conocer la colección del formulario que va a recibir una cantidad desconocida de etiquetas. Hasta ahora, añadiste dos etiquetas y el tipo de formulario espera recibir exactamente dos, de lo contrario lanzará un error: Este formulario no debe contener campos adicionales. Para que esto sea flexible, añade la opción allow_add a tu campo colección:

// src/Acme/TaskBundle/Form/Type/TaskType.php

// ...

use Symfony\Component\Form\FormBuilderInterface;

public function buildForm(FormBuilderInterface $builder, array $options)
{
    $builder->add('description');

    $builder->add('tags', 'collection', array(
        'type'         => new TagType(),
        'allow_add'    => true,
        'by_reference' => false,
    ));
}

Ten en cuenta que también se añadió 'by_reference' => false. Normalmente, la plataforma de formularios modificaría las etiquetas en un objeto Tarea sin llamar en realidad a setTags. Al configurar by_reference a false, llamará a setTags. Esto, como verás, será muy importante más adelante.

Además de decirle al campo que acepte cualquier número de objetos presentados, allow_add también pone a tu disposición una variable «prototipo». Este «prototipo» es como una «plantilla» que contiene todo el código HTML necesario para poder pintar cualquier nueva etiqueta del formulario. Para ello, haz el siguiente cambio en tu plantilla:

  • Twig
    <ul class="tags" data-prototype="{{ form_widget(form.tags.vars.prototype)|e }}">
        ...
    </ul>
    
  • PHP
    <ul class="tags" data-prototype="<?php echo $view->escape($view['form']->row($form['tags']->vars['prototype'])) ?>">
        ...
    </ul>
    

Nota

Si pintas todas tus etiquetas en subformularios simultáneamente (por ejemplo, form_row(form.tags)), entonces el prototipo estará disponible automáticamente en el div externo como el atributo data-prototype, similar a lo que ves arriba.

Truco

The form.tags.vars.prototype is a form element that looks and feels just like the individual form_widget(tag) elements inside your for loop. This means that you can call form_widget, form_row or form_label on it. Incluso, puedes optar por pintar sólo uno de tus campos (por ejemplo, el campo nombre):

{{ form_widget(form.tags.vars.prototype.name)|e }}

En la página producida, el resultado será muy parecido a este:

<ul class="tags" data-prototype="&lt;div&gt;&lt;label class=&quot; required&quot;&gt;__name__&lt;/label&gt;&lt;div id=&quot;task_tags___name__&quot;&gt;&lt;div&gt;&lt;label for=&quot;task_tags___name___name&quot; class=&quot; required&quot;&gt;Name&lt;/label&gt;&lt;input type=&quot;text&quot; id=&quot;task_tags___name___name&quot; name=&quot;task[tags][__name__][name]&quot; required=&quot;required&quot; maxlength=&quot;255&quot; /&gt;&lt;/div&gt;&lt;/div&gt;&lt;/div&gt;">

El objetivo de esta sección es usar JavaScript para leer este atributo y agregar dinámicamente nuevas etiquetas al formulario cuando el usuario haga clic en un enlace «Agregar una etiqueta». Para simplificar las cosas, este ejemplo usa jQuery y supone que lo has incluido en algún lugar de tu página.

Añade un elemento <script> en algún lugar de tu página para que puedas empezar escribir algún JavaScript.

En primer lugar, añade un enlace a la parte inferior de la lista de «tags» a través de JavaScript. En segundo lugar, vincula el evento click de ese enlace para que puedas añadir una nueva etiqueta al formulario (con addTagForm tal como se muestra a continuación):

// Obtiene la ul que contiene la colección de etiquetas
var collectionHolder = $('ul.tags');

// configura una enlace "Agregar una etiqueta"
var $addTagLink = $('<a href="#" class="add_tag_link">Add a tag</a>');
var $newLinkLi = $('<li></li>').append($addTagLink);

jQuery(document).ready(function() {
    // Añade el ancla "Agregar una etiqueta" y las etiquetas li y ul
    collectionHolder.append($newLinkLi);

    // cuenta las entradas actuales en el formulario (p. ej. 2),
    // la usa como índice al insertar un nuevo elemento (p. ej. 2)
    collectionHolder.data('index', collectionHolder.find(':input').length);

    $addTagLink.on('click', function(e) {
        // evita crear el enlace con una "#" en la URL
        e.preventDefault();

        // añade una nueva etiqueta form (ve el siguiente bloque de código)
        addTagForm(collectionHolder, $newLinkLi);
    });
});

El trabajo de la función addTagForm será el de utilizar el atributo data-prototype para agregar dinámicamente un nuevo formulario cuando se haga clic en ese enlace. El HTML del data-prototype contiene el elemento input de la etiqueta text con el nombre de task[tags][__name__][nombre] ``y el identificador de ``task_tags___name___name. El __name__ es una especie de «comodín», que vamos a sustituir con un número incremental único (por ejemplo, task[tags][3][name]).

Nuevo en la versión 2.1: El marcador de posición cambió de nombre de $$name$$ a __name__ en Symfony 2.1

El código real necesario para hacer que todo esto trabaje puede variar un poco, pero aquí está un ejemplo:

function addTagForm(collectionHolder, $newLinkLi) {
    // Obtiene los datos del prototipo explicado anteriormente
    var prototype = collectionHolder.data('prototype');

    // Consigue el nuevo índice
    var index = collectionHolder.data('index');

    // Sustituye el '__name__' en el prototipo HTML para que
    // en su lugar sea un número basado en cuántos elementos hay
    var newForm = prototype.replace(/__name__/g, index);

    // Incrementa en uno el índice para el siguiente elemento
    collectionHolder.data('index', index + 1);

    // Muestra el formulario en la página en un elemento li,
    \\ antes del enlace 'Agregar una etiqueta'
    var $newFormLi = $('<li></li>').append(newForm);
    $newLinkLi.before($newFormLi);
}

Nota

Es mejor separar tu JavaScript en archivos JavaScript reales que escribirlo dentro del HTML como se está haciendo aquí.

Ahora, cada vez que un usuario haga clic en el enlace Agregar una etiqueta, aparecerá un nuevo subformulario en la página. Cuando envíes el formulario, cualquier nueva etiqueta del formulario se convertirá en nuevos objetos Etiqueta y se añadirá a la propiedad etiquetas del objeto Tarea.

Permitiendo la remoción de etiquetas

El siguiente paso es permitir la supresión de un elemento particular en la colección. La solución es similar a permitir la adición de etiquetas.

Comienza agregando la opción allow_delete en el Tipo del formulario:

// src/Acme/TaskBundle/Form/Type/TaskType.php

// ...
use Symfony\Component\Form\FormBuilderInterface;

public function buildForm(FormBuilderInterface $builder, array $options)
{
    $builder->add('description');

    $builder->add('tags', 'collection', array(
        'type'         => new TagType(),
        'allow_add'    => true,
        'allow_delete' => true,
        'by_reference' => false,
    ));
}

Modificaciones en plantillas

La opción allow_delete tiene una consecuencia: si un elemento de una colección no se envía en la presentación, los datos relacionados se quitan de la colección en el servidor. La solución a esto es eliminar el elemento del DOM del formulario.

En primer lugar, añade un enlace «eliminar esta etiqueta» a cada etiqueta del formulario:

jQuery(document).ready(function() {
    // Añade un enlace para eliminar todas las etiquetas existentes
    // en elementos li del formulario
    collectionHolder.find('li').each(function() {
        addTagFormDeleteLink($(this));
    });

    // ... el resto del bloque de arriba
});

function addTagForm() {
    // ...

    // Añade un enlace eliminar el nuevo formulario
    addTagFormDeleteLink($newFormLi);
}

La función addTagFormDeleteLink se verá similar a esta:

function addTagFormDeleteLink($tagFormLi) {
    var $removeFormA = $('<a href="#">delete this tag</a>');
    $tagFormLi.append($removeFormA);

    $removeFormA.on('click', function(e) {
        // evita crear el enlace con una "#" en la URL
        e.preventDefault();

        // quita el li de la etiqueta del formulario
        $tagFormLi.remove();
    });
}

Cuando se quita una etiqueta del DOM del formulario y se envía, el objeto Etiqueta eliminado no se incluirá en la colección pasada a setTags. Dependiendo de tu capa de persistencia, esto puede o no ser suficiente para eliminar de hecho la relación entre la etiqueta retirada y el objeto Tarea.

Bifúrcame en GitHub