Part 2 - Embedded Forms, Validation and Bootstrap Styling
In this video we are going to continue on with adding in an 'Other' option to our select
box, and specifically we will:
- Create the
TimetableType
Symfony form - Add in custom validation to the
Timetable
entity - Fix up the styling issues currently present in embedded forms and the Bootstrap horizontal layout
There's quite a lot to cover here as this is really where most of the heavy lifting will take place, so let's get started.
Creating the TimetableType
By the end of the previous video we had created a Timetable
entity that has a one-to-one relationship with our DataFeed
entity.
We also managed to saved off some data by brute-forcing our way through the form submission process, cheating a little bit to add in a new
'ed up Timetable
after all the validation and submission logic had already occured. The upshot of this is that we know that - if we can make the form behave - things should 'just work (tm)'.
For the moment let's completely forget about the object relationships and any of that carry on. After all, the relationship we setup was unidirectional, and our Timetable
is completely unaware of who / what it is related too, anyway.
So without any complications, we are already more than capable of creating ourselves a simple Symfony form for any entity that doesn't have relations:
<?php
// /src/AppBundle/Form/Type/TimetableType.php
namespace AppBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\IntegerType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class TimetableType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('presetChoice', ChoiceType::class, [
'choices' => [
'15' => 15,
'30' => 30,
'45' => 45,
'60' => 60,
'Other' => null,
]
])
->add('manualEntry', IntegerType::class, [
'required' => false,
])
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => 'AppBundle\Entity\Timetable',
]);
}
}
There are a few interesting points about this form:
The first is that it doesn't have a 'save' button. This is because this form will be embedded in another form (the DataFeedType
in our case), and the save button will live on that form instead.
The second is that one of our presetChoice
choices is null
. Without updating our Timetable
entity then this may error when trying to save - as the presetChoice
annotation currently is not configured to allow null
's.
Lastly we are setting the manualEntry
field to be required => false
, because this is going to be a kind of an either / or situation, but not exactly. If manualEntry
is in use then presetChoice
is going to have been set to 'Other' / null
, so from the front end perspective, it will have had something selected. The required
field is going to turn off the HTML5 required
element, that's all. We have already seen how to work with that earlier in this series.
Using Arrays To Understand Embedding Symfony Forms
When I first got started with Symfony, the wording around 'embedding a form' scared me. It sounded complicated, and confusing. Forms inside forms inside forms? Good Lord.
Thankfully, as with most anything, the more you use this concept and with a little foundational knowledge, it becomes a lot less scary / confusing.
Disregard objects for the moment, and let's think about arrays.
We know of simple arrays that contain one level of values:
$a = [1,2,3];
And we know about two dimensional arrays:
$a = [
['Cat','mittens',3],
['Dog','bingo',7],
['Tortoise','tula',89],
];
And of course, multidimensional arrays:
$a = [
['Cat'=>['mittens',3]],
['Dog'=>['bingo',7]],
['Tortoise'=>['tula',89]],
];
Well, if you understand this, then you are most of the way there with understanding the way the form handles your data.
In our example we will have a DataFeedType
which embeds a TimetableType
.
We could strip out the concept of entities and work directly with arrays, and the resulting value of a form submission would be a two dimensional array. Let's quickly take a look:
<?php
// /src/AppBundle/Controller/FormExampleController.php
namespace AppBundle\Controller;
use AppBundle\Form\Type\DataFeedType;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
class FormExampleController extends Controller
{
/**
* @Route("/", name="form_add_example")
*/
public function formAddExampleAction(Request $request)
{
$form = $this->createForm(DataFeedType::class, []);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$dataFeed = $form->getData();
dump($dataFeed);
$this->addFlash('success', 'We saved a data feed');
}
return $this->render(':form-example:index.html.twig', [
'myForm' => $form->createView()
]);
}
}
And comment out the data_class
key / value pair inside the TimetableType
and DataFeedType
:
// /src/AppBundle/Form/Type/DataFeedType.php
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
// 'data_class' => 'AppBundle\Entity\DataFeed',
]);
}
// /src/AppBundle/Form/Type/TimetableType.php
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
// 'data_class' => 'AppBundle\Entity\Timetable',
]);
}
Now, when you submit the form you should see the following dumped out:
array:4 [
"name" => "Your name goes here",
"url" => "https://codereviewvideos.com",
"enabled" => true,
"timetable" => array:2 [
"presetChoice" => null,
"manualEntry" => 11
]
]
We don't get an id
set, as that's a Doctrine-managed thing. But the rest? It's all just values.
Hopefully this makes understanding what happens when working with objects that much simpler. Because ultimately, it's very, very similar.
Embedding The TimetableType
Inside DataFeedType
The way I think about the form types that we use in Symfony is that they lay one layer above our objects. Hear me out on this, as this is maybe just how my mind works.
We have our objects:
DataFeed
which contains a Timetable
If you are unsure on this, please take a look at the entity definitions in the previous video.
Our form(s) map over the top of these objects.
So if our DataFeed
has a name
, enabled
, and url
property, then our form has the corresponding fields of the exact same name. You can mess around with this by changing the property_path
, but I wouldn't unless you really have too.
And when we added the relationship, our DataFeed
gained a property called timetable
.
Well, don't overthink this - our form therefore should also have a field called timetable
.
But what should the form field type be?
Well, the TimetableType
of course :)
Let's see this in action:
<?php
// /src/AppBundle/Form/Type/DataFeedType.php
namespace AppBundle\Form\Type;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\UrlType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class DataFeedType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('name')
->add('url', UrlType::class)
->add('enabled', ChoiceType::class, [
'choices' => [
'Yes' => true,
'No' => false,
],
'expanded' => true,
])
->add('timetable', TimetableType::class)
->add('submit', SubmitType::class)
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(array(
'data_class' => 'AppBundle\Entity\ProductFeed',
));
}
}
It is as simple as that to create our nest, and now the form tells the same story as our objects. We expect that the timetable
field contains (or embeds) a form that handles Timetable
entities.
It is strange at first. I really found this whole concept very confusing, so if you do too, don't stress out about it. It will click eventually.
The reason this works like this - and it is very elegant the more you think about it, and it's the same concept / design pattern that JavaScript libraries like React make use of - is because of the Composite Design Pattern. You can read a much more in-depth article on this on Bernhard Schussek's blog. In case you didn't know, Bernhard is the author of the Symfony form component.
Making Our Form Look Nice
The next problem is that there is a known issue with the Bootstrap 3 theming (at the time of writing) which causes embedded forms to gain an extra CSS class that looks bad:
The solution to this is simply to render the form fields manually - which isn't anything like as bad as it sounds, but is still a bit of a pain as it's one more thing to keep on top of when adding new fields to your form.
<!-- /app/Resources/views/form-example/index.html.twig -->
{% extends '::base.html.twig' %}
{% block body %}
<h2>Symfony 3 Form Example</h2>
<hr />
{#{{ form(myForm) }}#}
{{ form_start(myForm) }}
{{ form_errors(myForm) }}
{{ form_row(myForm.name) }}
{{ form_row(myForm.url) }}
{{ form_row(myForm.enabled) }}
<div class="{% if not myForm.timetable.vars.valid %}has-error{% endif %}">
{{ form_row(myForm.timetable.presetChoice) }}
{{ form_row(myForm.timetable.manualEntry) }}
<div class="col-sm-offset-2">
{{ form_errors(myForm.timetable) }}
</div>
</div>
{{ form_row(myForm.save, { 'attr': { 'class': 'btn btn-success' } }) }}
{{ form_end(myForm) }}
{% endblock %}
There's nothing particularly mind-blowing going on here. We've seen the use of vars.valid
before in this series, and adding CSS classes inside the Twig templates (for the 'save' button in this case).
What is new here is the embedded form - myForm.timetable
.
This same dot notation syntax follows down no matter how deeply you nest your forms. My advice, by the way, would be not to nest too deeply. If you are going beyond three layers of embedding / nesting then likely things could be refactored to a simpler implementation.
I've skipped ahead here somewhat as the validation logic isn't yet in place. Let's fix that.
Adding Custom Timetable
Validation Logic
The requirement for our system is to have a form where a user can select one of the preset options, OR they can select the 'other' option and only then add in what we are calling a manual entry.
Right now, without any validation logic, this is not the case. A user can add in any combination they feel like, and generate all kinds of errors along the way.
Firstly, it would make sense to enable either field to be nullable. Currently, without the correct Doctrine annotation this would throw an error on save, whereby Doctrine tries to save a null
to a field that is not nullable
. Oops.
Secondly, there is no Symfony built-in validation constraint to do what we want directly. But thankfully there is a validation constraint that allows us to define our own validation logic - the Callback Validation Constraint.
<?php
// /src/AppBundle/Entity/Timetable.php
namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
use Symfony\Component\Validator\Context\ExecutionContextInterface;
/**
* Timetable
* @ORM\Table(name="timetable")
* @ORM\Entity()
*/
class Timetable
{
/**
* @Assert\Callback()
*/
public function validate(ExecutionContextInterface $context)
{
if ($this->getPresetChoice() === null && $this->getManualEntry() === null) {
$context->buildViolation('Either a preset, or a manual entry must be supplied')->addViolation();
}
if ($this->getPresetChoice() !== null && $this->getManualEntry() !== null) {
$context->buildViolation('Cannot use both a preset and a manual entry')->addViolation();
}
}
/**
* @ORM\Column(name="id", type="integer")
* @ORM\Id()
* @ORM\GeneratedValue(strategy="AUTO")
*/
protected $id;
/**
* @ORM\Column(type="integer", nullable=true)
*/
protected $presetChoice;
/**
* @ORM\Column(type="integer", nullable=true)
*/
protected $manualEntry;
// * snip *
Watch the video for a better understanding of how this validation logic works (starting around the 8:00 mark).
The gist of this is that rather than add the errors to either of the fields directly, I have added the errors to the Timetable
entity as a whole, and that's why I'm only using the single twig form error helper:
{{ form_errors(myForm.timetable) }}
There are two other issues we need to address here.
Firstly, we must tell the parent entity - the DataFeed
entity - that we want to validate its related Timetable
entity:
// /src/AppBundle/Entity/DataFeed.php
/**
* @ORM\OneToOne(targetEntity="Timetable", cascade={"persist"})
* @ORM\JoinColumn(name="timetable_id", referencedColumnName="id")
*
* @Assert\Valid()
*/
protected $timetable;
Secondly, we must stop the form errors bubbling up to the parent form, to stop a situation where an error in the input of a Timetable
property may end up as a generic form error, rather than next to the offending timetable section. This becomes that little bit more important if you have more than one timetable on your form at a time - as we shall do in the next video.
// /src/AppBundle/Form/Type/TimetableType.php
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => 'AppBundle\Entity\Timetable',
'error_bubbling' => false,
]);
}
And with that, the vast majority of the hard work is now done. All that remains is to add in a little bit of JavaScript to show or hide the 'other' box depending on what option has been selected in our presetChoice
dropdown.
To make things a little more interesting though, we will make the JavaScript capable of handing more than one instance of the TimetableType
being embedded at the same time. We'll do that in the very next video.