If you liked what you've learned so far, dive in!
Subscribe to get access to this tutorial plus
video, code and script downloads.
With a Subscription, click any sentence in the script to jump to that part of the video!
Login SubscribeThere'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.
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 |
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.
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!
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.
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!
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 -->
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!
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'
]
]);
}`
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!
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!
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.
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!
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
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
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!
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.
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!
Nice one, thank you for sharing. Using a listener is more elegant than my scrappy patch, I'll go with yours. ;)
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)
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 :)
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?
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!
// 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
}
}
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