Darkstarone Darkstarone - 1 month ago 46
Javascript Question

Symfony 3 Multiple Level Nested Forms

I've been working through the Symfony 3 tutorial on embedding a collection of forms, and I want to extend the idea to extra nested levels. I had a look around, and there are partial answers for Symfony 2, but nothing comprehensive (and nothing for 3).

If we take the tutorials

Task
has many
Tag
example, how would I code it so it extends to:
Task
has many
Tag
has many
SubTag
?

So far I think I understand the Form classes:

Task:

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

$builder->add('tags', CollectionType::class, array(
'entry_type' => TagType::class,
'allow_add' => true,
'by_reference' => false,
'allow_delete' => true
));
}

public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'AppBundle\Entity\Task',
));
}
}


Tag:

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

$builder->add('sub_tags', CollectionType::class, array(
'entry_type' => SubTagType::class,
'allow_add' => true,
'by_reference' => false,
'allow_delete' => true
));
}

public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'AppBundle\Entity\Tag',
));
}
}


SubTag:

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

public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'AppBundle\Entity\SubTag',
));
}
}


And the basic Twig class:

{{ form_start(form) }}
{# render the task's only field: description #}
{{ form_row(form.description) }}

<h3>Tags</h3>
<ul class="tags">
{# iterate over each existing tag and render its only field: name #}
{% for tag in form.tags %}
<li>{{ form_row(tag.name) }}</li>
{% for sub_tag in tag.sub_tags %}
<li>{{ form_row(sub_tag.name) }}</li>
{% endfor %}
{% endfor %}
</ul>
{{ form_end(form) }}


But it's at this point I'm unsure of how the prototype and javascript will work. Could somebody explain how I'd take this next step? Is this even the right approach?

My first thought is that if we're doing additional levels, it might be smart to generalize the JS for any number of levels, since the tutorial uses very JS that can only work on a single level.

The closest working code I can find is this stack overflow answer here. However, it doesn't appear to work as described, and Im having trouble working out exactly what's wrong.

Answer

It's not any different than a regular embedded collection of forms.

However, if you want to avoid trouble with the default __NAME__ prototype colliding with a parent form's prototype string, you should take take to choose distinct values for the TagType and SubTag types.

From the Symfony Docs entry on CollectionType:

prototype_name

  • type: string default: name
  • If you have several collections in your form, or worse, nested collections you may want to change the placeholder so that unrelated placeholders are not replaced with the same value.

This can be very helpful if you want to abstract your clone actions with the javascript, like those in this article (pasted below), which - by the way - appears to target symfony3!

You might, for instance want to include the same value you pass to prototype_name, as an attr on the collection holder's html, so that you can access it dynamically, when doing the replace on the data-prototype html.

var $collectionHolder;

// setup an "add a tag" link
var $addTagLink = $('<a href="#" class="add_tag_link">Add a tag</a>');
var $newLinkLi = $('<li></li>').append($addTagLink);

jQuery(document).ready(function() {
// Get the ul that holds the collection of tags
$collectionHolder = $('ul.tags');

// add the "add a tag" anchor and li to the tags ul
$collectionHolder.append($newLinkLi);

// count the current form inputs we have (e.g. 2), use that as the new
// index when inserting a new item (e.g. 2)
$collectionHolder.data('index', $collectionHolder.find(':input').length);

$addTagLink.on('click', function(e) {
    // prevent the link from creating a "#" on the URL
    e.preventDefault();

    // add a new tag form (see next code block)
    addTagForm($collectionHolder, $newLinkLi);
});

function addTagForm($collectionHolder, $newLinkLi) {
    // Get the data-prototype explained earlier
    var prototype = $collectionHolder.data('prototype');

    // get the new index
    var index = $collectionHolder.data('index');

    // Replace '__name__' in the prototype's HTML to
    // instead be a number based on how many items we have
    var newForm = prototype.replace(/__name__/g, index);

    // increase the index with one for the next item
    $collectionHolder.data('index', index + 1);

    // Display the form in the page in an li, before the "Add a tag" link li
    var $newFormLi = $('<li></li>').append(newForm);
    $newLinkLi.before($newFormLi);
}