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.
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.
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.
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