Flag of Ukraine
SymfonyCasts stands united with the people of Ukraine

Customizing the Collection Form Prototype

Keep on Learning!

If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.

Start your All-Access Pass
Buy just this tutorial for $12.00

With a Subscription, click any sentence in the script to jump to that part of the video!

Login Subscribe

There's still one ugly problem with our form, and I promised we would fix: when we click "Add Another Scientist"... well, it don't look right!. The new form should have the exact same styling as the existing ones.

Customizing the Prototype!

Why does it look different, anyways? Remember the data-prototype attribute?

{{ form_start(genusForm) }}
... lines 2 - 23
<div class="row js-genus-scientist-wrapper"
data-prototype="{{ form_widget(genusForm.genusScientists.vars.prototype)|e('html_attr') }}"
... line 26
>
... lines 28 - 41
</div>
... lines 43 - 44
{{ form_end(genusForm) }}

By calling form_widget, this renders a blank GenusScientist form... by using the default Symfony styling. But when we render the existing embedded forms, we wrap them in all kinds of cool markup:

{{ form_start(genusForm) }}
... lines 2 - 27
{% for genusScientistForm in genusForm.genusScientists %}
<div class="col-xs-4 js-genus-scientist-item">
<a href="#" class="js-remove-scientist pull-right">
<span class="fa fa-close"></span>
</a>
{{ form_errors(genusScientistForm) }}
{{ form_row(genusScientistForm.user) }}
{{ form_row(genusScientistForm.yearsStudied) }}
</div>
{% endfor %}
... lines 38 - 44
{{ form_end(genusForm) }}

What we really want is to somehow make the data-prototype attribute use the markup that we wrote inside the for statement.

How? Well, there are at least two ways of doing it, and I'm going to show you the less-official and - in my opinion - easier way!

Head to the top of the file and add a macro called printGenusScientistRow() that accepts a genusScientistForm argument:

... lines 1 - 2
{% macro printGenusScientistRow(genusScientistForm) %}
... lines 4 - 11
{% endmacro %}
... lines 13 - 52

If you haven't seen a macro before in Twig, it's basically a function that you create right inside Twig. It's really handy when you have some markup that you don't want to repeat over and over again.

Next, scroll down to the scientists area and copy everything inside the for statement. Delete it, and then paste it up in the macro:

... lines 1 - 2
{% macro printGenusScientistRow(genusScientistForm) %}
<div class="col-xs-4 js-genus-scientist-item">
<a href="#" class="js-remove-scientist pull-right">
<span class="fa fa-close"></span>
</a>
{{ form_errors(genusScientistForm) }}
{{ form_row(genusScientistForm.user) }}
{{ form_row(genusScientistForm.yearsStudied) }}
</div>
{% endmacro %}
... lines 13 - 52

Use that Macro!

To call that macro, you actually need to import it... even though it already lives inside this template. Whatever: you can do that with {% import _self as formMacros %}:

{% import _self as formMacros %}
... lines 2 - 52

The _self part would normally be the name of a different template whose macros you want to call, but _self is a magic way of saying, no, this template.

The formMacros is an alias I just invented, and it's how we will call the macro. For example, inside the for loop, render formMacros.printGenusScientistRow() and pass it genusScientistForm:

... lines 1 - 13
{{ form_start(genusForm) }}
... lines 15 - 40
{% for genusScientistForm in genusForm.genusScientists %}
{{ formMacros.printGenusScientistRow(genusScientistForm) }}
{% endfor %}
... lines 44 - 50
{{ form_end(genusForm) }}

And now we can do the same thing on the data-prototype attribute: formMacros.printGenusScientistRow() and pass that genusForm.genusScientists.vars.prototype. Continue to escape that that into HTML entities:

... lines 1 - 13
{{ form_start(genusForm) }}
... lines 15 - 36
<div class="row js-genus-scientist-wrapper"
data-prototype="{{ formMacros.printGenusScientistRow(genusForm.genusScientists.vars.prototype)|e('html_attr') }}"
... line 39
>
... lines 41 - 47
</div>
... lines 49 - 50
{{ form_end(genusForm) }}

I love when things are this simple! Go back, refresh, and click to add another scientist. Much, much better! Obviously, we need a little styling help here with our rows but you guys get the idea.

Centralizing our JavaScript

The last problem with our form deals with JavaScript. Go to /admin/genus and click "Add". Well... our fancy JavaScript doesn't work here. Wah wah.

But that makes sense: we put all the JavaScript into the edit template. The fix for this is super old-fashioned... and yet perfect: we need to move all that JavaScript into its own file. Since this isn't a JavaScript tutorial, let's keep things simple: in web/js, create a new file: GenusAdminForm.js.

Ok, let's be a little fancy: add a self-executing block: a little function that calls itself and passes jQuery inside:

(function ($) {
... lines 2 - 32
})(jQuery);

Then, steal the code from edit.html.twig and paste it here. It doesn't really matter, but I'll use $ everywhere instead of jQuery to be consistent:

(function ($) {
$(document).ready(function() {
var $wrapper = $('.js-genus-scientist-wrapper');
$wrapper.on('click', '.js-remove-scientist', function(e) {
e.preventDefault();
$(this).closest('.js-genus-scientist-item')
.fadeOut()
.remove();
});
$wrapper.on('click', '.js-genus-scientist-add', function(e) {
e.preventDefault();
// Get the data-prototype explained earlier
var prototype = $wrapper.data('prototype');
// get the new index
var index = $wrapper.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
$wrapper.data('index', index + 1);
// Display the form in the page before the "new" link
$(this).before(newForm);
});
});
})(jQuery);

Back in the edit template, include a proper script tag: src="" and pass in the GenusAdminForm.js path:

... lines 1 - 2
{% block javascripts %}
{{ parent() }}
<script src="{{ asset('js/GenusAdminForm.js') }}"></script>
{% endblock %}
... lines 8 - 20

Copy the entire javascripts block and then go into new.html.twig. Paste!

... lines 1 - 2
{% block javascripts %}
{{ parent() }}
<script src="{{ asset('js/GenusAdminForm.js') }}"></script>
{% endblock %}
... lines 8 - 20

And now, we should be happy: refresh the new form. Way better!

Avoiding the Weird New Label

But... what's with that random label - "Genus scientists" - after the submit button! What the crazy!?

Ok, so the reason this is happening is a little subtle. Effectively, because there are no genus scientists on this form, Symfony sort of thinks that this genusForm.genusScientists field was never rendered. So, like all unrendered fields, it tries to render it in form_end(). And this causes an extra label to pop out.

It's silly, but easy to fix: after we print everything, add form_widget(genusForm.genusScientists). And ya know what? Let's add a note above to explain this - otherwise it looks a little crazy.

... lines 1 - 13
{{ form_start(genusForm) }}
... lines 15 - 36
<div class="row js-genus-scientist-wrapper"
... lines 38 - 39
>
... lines 41 - 47
</div>
{# prevents weird label from showing up in new #}
{{ form_widget(genusForm.genusScientists) }}
... lines 51 - 52
{{ form_end(genusForm) }}

And don't worry, this will never actually print anything. Since all of the children fields are rendered above, Symfony knows not to re-render those fields. This just prevents that weird label.

Refresh! Extra label gone. And if you go back and edit one of the genuses, things look cool here too.

Now, I have one last challenge for us with our embedded forms.

Leave a comment!

21
Login or Register to join the conversation
Francisco C. Avatar
Francisco C. Avatar Francisco C. | posted 3 years ago

Hi when I updated to Symfony5.0 I have problem with the little tricky about Avoiding the Weird New Label because this widget is already rendered.

An exception has been thrown during the rendering of a template ("Field has already been rendered, save the result of previous render call to a variable and output that instead.").

If I delete the line {{ form_widget(genusForm.genusScientists) }} it works but in new action I see the "awful" label. Any idea to fix this?

Thanks

1 Reply

Hey Francisco C.

Does it happen on both action (create and edit)? If it only happens on edit action (because the form actually contains some genus scientists), then, the trick Ryan showed us doesn't work anymore on Symfony 5. What you can do is to check if there are any genus scientists before calling {{ form_widget(genusForm.genusScientists) }} (after the for). Or, disable the label of that form field directly on the FormType class https://symfony.com/doc/current/reference/forms/types/text.html#label

Cheers!

1 Reply
Brent Avatar
Brent Avatar Brent | posted 2 years ago | edited

Hi, I have two different collection of forms and in an effort to change the way the fields were displaying, horizontally instead of vertically, after many failed attempts, I created two macros were I added my desired layout and then imported them into my template. Works great! But, if my form doesn't pass validation after submit the page is reloaded and my two collection of forms revert to the vertical layout my macros were overriding. Is there a way to fix this? Perhaps, another type of macro? Thank you in advance!

One of my macros:


{#  macro for rxMaterial collection #}

{% macro printMaterialRow (submitRxForm) %}

    <thead>
    <tr>
        <th scope="col" style="font-weight: normal;">Material Id</th>
        <th scope="col" style="font-weight: normal;">Quantity</th>
        <th scope="col" style="font-weight: normal;">Notes</th>
        <th scope="col"></th>
    </tr>
    </thead>
    <tr>
        <td>{{ form_row(submitRxForm.children.MaterialId, {'label':false}) }}</td>
        <td>{{ form_row(submitRxForm.children.quantity, {'label':false}) }}</td>
        <td id="material-notes">{{ form_row(submitRxForm.children.notes, {'label':false}) }}</td>
    </tr>


{% endmacro %}

How I import it in my template:


{% import 'macros/material_macro.html.twig' as materialMacros %}

How I display it:


 <fieldset>
        <legend>Materials</legend>
        <div id="rx-materials-list" data-prototype="{{ materialMacros.printMaterialRow(submitRxForm.rxMaterial.vars.prototype)|e('html_attr') }}"
             data-index="{{ submitRxForm.rxMaterial|length }}">
        </div>
        {# Prevents an rxMaterial label from printing on the page #}
        {{ form_row(submitRxForm.rxMaterial, {'label':false}) }}
    </fieldset><!-- fieldset -->
Reply

Hey Brentmtc,

Here's the reference to the docs page about how to customize form rendering: https://symfony.com/doc/cur... . But what you want to do is related to customizing form theme I believe, see: https://symfony.com/doc/cur... . If you use Bootstrap 4 layout for your forms - there's another form theme called "bootstrap_4_horizontal_layout.html.twig", see https://symfony.com/doc/cur... - if you will try to use it on the specific page you need (see https://symfony.com/doc/cur... ) - it will automatically render all your form fields horizontally. This would be the easiest win for you I think.

If the horizontal layout is still not render perfectly, like you really want to customize how fields are rendered - take a look at overriding Symfony form theme. You can find how to override form theme on the same docs page: https://symfony.com/doc/cur... - this way you will be able to override some part of the form layout and make it render as you want. But it's kinda advanced level, I'd recommend you to try the built-in horizontal layout first to see if it solves the problem for you.

I hope this helps!

Cheers!

Reply
Brent Avatar
Brent Avatar Brent | posted 2 years ago | edited

Hello, I have a form with a dropdown of accounts associated with the logged in user. The form also has two collections, products and materials. Currently, on my materials collection I have a query builder which has a user id where clause. It's working good. My question is how can I get the account number value chosen in the dropdown into this query so I can have two where clauses, user id and account number?

My repository method:

`public function findAllMaterialByUser()

{
    // get token
    $token = $this->tokenStorage->getToken();

    // use $token to get user
    $user = $token->getUser();

    return $this->createQueryBuilder('vrxm')
        ->andWhere('vrxm.userid = :userId')
        ->andWhere('vrxm.accountnumber = :accountNumber')
        ->setParameter('userId', $user->getId())
        ->setParameter('accountNumber', 'How can I get an account number from dropdown in here?')
        ->orderBy('vrxm.materialId', 'DESC')
        ->getQuery()
        ->execute();
}

`

My materials form type:
`public function buildForm(FormBuilderInterface $builder, array $options)

{
    $builder
        ->add('MaterialId', EntityType::class, [
            'choice_label' => function(v_RXmaterials $vrxm) {
                return $vrxm->getDescription();
            },
            'required' => false,
            'placeholder' => 'Choose a material',
            'class' => v_RXmaterials::class,
            'choices' => $this->v_RXmaterialsRepository->findAllMaterialByUser('need account number from dropdown in here'),
            'attr' => [
                'class' => 'form-control'
            ]
        ])
        ->add('notes', TextAreaType::class,  [
            'label' => 'Notes',
            'required'   => false,
            'attr' => [
                'class' => 'form-control'
            ]
        ])
        ->add('quantity', TextType::class, [
            'label' => 'Quantity',
            'attr' => [
                'class' => 'form-control'
            ]
        ]);
}`
Reply

Hey Brent

It depends on where does the account number comes from. If you can get it at your controller's action level, then you can just pass it to the FormType as an "option". If that's not the case, then you may want to inject a service into your FormType so you can use it to get the account number at the right moment

Cheers!

Reply
Brent Avatar

The aforementioned dropdown of account number is not part of my form that submits. I populate an html dropdown from a query result when my template loads. A user can select an account number when they do an ajax call is made and data for some of the fields in the form gets populated via that ajax call. I don't think passing an account number to the FormType as an 'option' will work because to my knowledge that would be a static value and if a different account number was chosen from the dropdown that account number option would be 'out of date'.

My follow up question is if I were to try and inject a service into my FormType can an account number in the where clause in the query that populates my Materials Collection be updated via javascript or ajax call? Or is there someway I can update the dropdown options in the Material Collection prototype via javascript or ajax? I did create my collection almost exactly as this screencast and Symfony's page describes with addCollection removeCollection functions.

I apologize this is so wordy I am trying to explain how I have this working so far and what I am trying to do. Thank you in advance!

Reply

Interesting, in that case, whenever a user selects an account number, then, you should re-render the FormType and update your HTML using Javascript when the AJAX request finishes.

Reply
Brent Avatar

Hello, I was able to get this working. I'll list what I did in case it can help someone else.
1. Set the choices in my MaterialsFormType EntityType to null 2. Added a method in my controller to query for the data by user id and account number (what I was trying to do in my repository) 3. Added an ajax call to that same method in my addMaterialCollectionForm function (after the card creation) 4. Looped through the response to update the select options html in my Materials Form Collection.

Interesting though looking at injecting a service into a FormType. I've have never done that nor am I exactly sure the use case to do so but thank you for your time!

1 Reply

It's rare when you need to inject a service into a FormType but it's easy to do, FormType classes work just like any other service, you only have to add a constructor and add arguments to it

Reply
Default user avatar
Default user avatar Olivier Mellinger | posted 2 years ago

first, thank you for sharing this easy way to manage Collections in a form.
I come across an "issue" or may be I don't understand one mechanism: how does Doctrine know what items from your collection are new or updates when you submit?
I mean, I had a look at the generated html for the form and i don't see any ids for the form fields managing the scientists data.
It works perfectly with my case with One-To-Many relations but now I want to add a many-to-many relations between some POI and zones.

In my form, i would like to be able to link my POI to existing zones (then i will have to manage a selectbox with the existing ones, fine!) or/and link it to a new zone (using what is explained here so fine as well).

But how could i manage to do this if we don't provide any id(id of one or several existing zones) to the collection form ?
thank you in advance

Reply

Hey Olivier Mellinger!

Sorry for the slow reply - nice to chat with you!

I come across an "issue" or may be I don't understand one mechanism: how does Doctrine know what items from your collection are new or updates when you submit?

That is an excellent question! It's a team effort. You're correct that there are no ids in the form fields managing the scientists. But, each "does" have an "index". What I mean is, if you have 3 scientists, and so 3 rows, the first is submitted with an index 0 as part of its name, the second as 1 and the third as 2. This is important! Here is the process that happens when you submit. And, by the way, you can see this logic here - https://github.com/symfony/symfony/blob/31ee43a8c67897a2f61782060965f5c8a81f58a2/src/Symfony/Component/Form/Extension/Core/EventListener/ResizeFormListener.php#L93-L112 - that is a complex class, and there are other pieces of logic beyond these lines, but it can still give you an idea. Anyways, let's look at some examples:

A) You originally have 3 rows (so, indexes 0, 1 and 2 - called "$name" in that class I linked above as it's looping). You delete the 2nd row. This means you submit indexes 0 and 2 only. This causes the field with index "1" to be removed from the form.

B) You originally have 3 rows (indexes 0, 1 and 2) and you add a new one, with index 3. In submit, the new one is added to the form.

So this is the first part of the equation. The form uses these indexes to add or remove these "embedded fields" from the form.

But, how does that ultimately get communicated to adding/removing scientists to your entity? This relates to the by_reference => false option we set - https://symfonycasts.com/screencast/collections/by-reference-adder-remover#codeblock-92435a5c26

Basically, if the form detects that index "1" has been removed, then it grabs whatever GenusScientist is attached to that form, and calls $genus->removeGenusScientist($genusScientist). That causes us to remove it from that property, which is ultimately why Doctrine will save the Genus without this relation. The same happens when a new row is added: the for system calls ->addGenusScientist() and passes it the new GenusScientist. It all works... but is pretty crazy :p.

So... in theory, all of this "works the same way" with a ManyToMany relationship. The form system actually doesn't know the difference. Think about it: if we changed Genus to be a ManyToMany to GenusScientist, the Genus object would still have addGenusScientist(), removeGenusScientist() and getGenusScientists() methods that all do the exact same thing. The form would call those methods in the same way, and everything would save the same (Doctrine handles inserting and deleting rows in the "join" table automatically for ManyToMany as you add/remove GenusScientist to Genus).

In my form, i would like to be able to link my POI to existing zones (then i will have to manage a selectbox with the existing ones, fine!) or/and link it to a new zone (using what is explained here so fine as well).

The tricky thing will be that I don't think it would be easy with the form collection system to have a checkbox to select form existing zones AND an embedded form to add a new zone "on the fly". It's because either your "zones" field on your form needs to either be a EntityType that renders as checkboxes OR a CollectionType, which renders as embedded forms. Personally, I would probably use EntityType. And if you want a Zone to be created "on the fly", I would add a "Create zone" link which, for example, opens a modal that allows the user to create the Zone in a separate form. On AJAX submit and save of that form, you would add a new checkbox to the main form. Not the easiest thing to do, but it's a better user experience anyways.

Phew! Let me know if any of this helps ;).

Cheers!

Reply
Default user avatar
Default user avatar BlackWiCKED | posted 3 years ago

There is an issue with the next index calculation after you delete an item. I noticed that the children object preserves the index, so for example if you delete the first item from {0: 'A', 1: 'B', 2: 'C'}, instead of getting {0: 'B', 1: 'C'} we actually get {1: 'B', 2: 'C'}. After the submit, this obviously messes up the index, as it will suggest 2 for the next item, but that is already taken so will be overwritten. This is how Collections behave in general, had issues with it earlier, converting the collection to an array then back to Collection in the back-end fixes the index and eliminates the gaps.

If that is not possible, my front-end solution is to use the following formula in the template: data-index="{{ (genusForm.genusScientists|length > 0) ? (max(genusForm.genusScientists.children|keys)+1) : 0 }}" This finds the largest index and adds one to it, seems to be working, but please let me know if there is a better workaround.

Reply

Hey BlackWiCKED

Excellent point. I'm glad you noticed it. We talk about that edge case here and proposed a solution: https://symfonycasts.com/sc...

Cheers!

1 Reply
Default user avatar

Nice one, thank you for sharing. Using a listener is more elegant than my scrappy patch, I'll go with yours. ;)

Reply
Default user avatar

Thank you so much for sharing.

Reply
Default user avatar
Default user avatar Hervé BOYER | posted 5 years ago

Hi .. nice course !
But, how to add a first field automatically if there is not already one (for example, a new ad) ? (genusScientistForm if index == 0)

Reply

Hey Hervé BOYER

Do you mean something like automatically hitting the "Add another scientist" button if the Genus object doesn't have one already ? If so, you could do a check like this


{% if genusForm.genusScientists|length == 0 %}
    // your code
    // simulate clicking that button
{% endif %}

I hope it helps you, if not, let me know!

Have a nice day :)

Reply
Default user avatar
Default user avatar Hervé BOYER | MolloKhan | posted 5 years ago

Hi Diego !
Thank you very much for your answer.
I'll trying when I'll better in JS ^^

Reply
Default user avatar
Default user avatar Petr Vohralík | posted 5 years ago

Would not it be better if we paste <script src="{{ asset('js/GenusAdmonForm.js') }}"></script> into formLayout.html.twig instead of two files edit.html.twig and new.html.twig?

Reply

Hey Petr,

Yes, fair point! It makes sense if you extends formLayout.html.twig only with edit.html.twig and new.html.twig templates.

Cheers!

Reply
Cat in space

"Houston: no signs of life"
Start the conversation!

This course is built on Symfony 3, but most of the concepts apply just fine to newer versions of Symfony.

What PHP libraries does this tutorial use?

// composer.json
{
    "require": {
        "php": "^7.1.3",
        "symfony/symfony": "3.4.*", // v3.4.49
        "doctrine/orm": "^2.5", // 2.7.5
        "doctrine/doctrine-bundle": "^1.6", // 1.12.13
        "doctrine/doctrine-cache-bundle": "^1.2", // 1.4.0
        "symfony/swiftmailer-bundle": "^2.3", // v2.6.7
        "symfony/monolog-bundle": "^2.8", // v2.12.1
        "symfony/polyfill-apcu": "^1.0", // v1.23.0
        "sensio/distribution-bundle": "^5.0", // v5.0.25
        "sensio/framework-extra-bundle": "^3.0.2", // v3.0.29
        "incenteev/composer-parameter-handler": "^2.0", // v2.1.4
        "composer/package-versions-deprecated": "^1.11", // 1.11.99.4
        "knplabs/knp-markdown-bundle": "^1.4", // 1.9.0
        "doctrine/doctrine-migrations-bundle": "^1.1", // v1.3.2
        "stof/doctrine-extensions-bundle": "^1.2" // v1.3.0
    },
    "require-dev": {
        "sensio/generator-bundle": "^3.0", // v3.1.7
        "symfony/phpunit-bridge": "^3.0", // v3.4.47
        "nelmio/alice": "^2.1", // v2.3.6
        "doctrine/doctrine-fixtures-bundle": "^2.3" // v2.4.1
    }
}
userVoice