[Part 1] - Twig Extensions - How to add a custom CSS class to an invalid form field
In this video we going to cover one possible solution to the following problem:
How can I add a custom CSS class to the
input
/ other form field when validation fails for that field?
It may be that you are covered by the default way that Symfony renders form error messages in Twig templates. If so, you don't need to continue here.
If you'd like to add your own custom CSS class or classes to the form field itself, then keep reading.
What we will do is to make use of a form field's attr
s to bend the way the HTML is rendered in order to meet our needs.
Is this a hack? I will leave that for you to decide.
As ever, feel free to share improvements and alternatives. Whether you approve of this, or not, hopefully you find the explored concepts to be interesting.
Possible Solution
What we have is a very simple application.
We have a controller that renders out our form.
When we submit the form, it POST
s back to this controller action.
We aren't using the output of the form submission in any meaningful way.
<?php
// src/AppBundle/Controller/DefaultController.php
namespace AppBundle\Controller;
use AppBundle\Form\Type\WidgetType;
use Sensio\Bundle\FrameworkExtraBundle\Configuration\Route;
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
use Symfony\Component\HttpFoundation\Request;
class DefaultController extends Controller
{
/**
* @Route("/", name="homepage")
* @param Request $request
* @return \Symfony\Component\HttpFoundation\RedirectResponse|\Symfony\Component\HttpFoundation\Response
*/
public function indexAction(Request $request)
{
$form = $this->createForm(WidgetType::class);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
dump('VALID');
}
if ($form->isSubmitted() && false === $form->isValid()) {
dump('INVALID');
}
return $this->render('default/index.html.twig', [
'form' => $form->createView()
]);
}
}
We're being as RAD as possible in the way we are rendering the form:
{# app/Resources/views/default/index.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
{{ form(form) }}
{% endblock %}
base.html.twig
in this case is as comes with a fresh installation of Symfony (symfony new ...
).
WidgetType
is next:
<?php
// src/AppBundle/Form/Type/WidgetType.php
namespace AppBundle\Form\Type;
use AppBundle\Entity\Widget;
use Symfony\Component\Form\AbstractType;
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
use Symfony\Component\Form\Extension\Core\Type\TextType;
use Symfony\Component\Form\FormBuilderInterface;
use Symfony\Component\OptionsResolver\OptionsResolver;
class WidgetType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options)
{
$builder
->add('name', TextType::class, [
'attr' => [
// this will always show, and is a standard html attribute
'class' => 'class-added-in-form-type',
// this will only show if there are errors on the form
// but allows you to customise which class to add
// when there are errors
'data-custom-error-css-class' => 'some-css-error-class another-error-class',
]
])
// another form field with no defaults, should not be impacted at all
->add('another')
->add('submit', SubmitType::class)
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Widget::class,
'attr' => [
'novalidate' => 'novalidate'
]
]);
}
}
Now things get more interesting.
Starting with configureOptions
the form is set to novalidate
. This is to allow us to submit the form without HTML5 validation stopping us from submitting empty fields.
From the configureOptions
function we can see AppBundle\Entity\Widget
is the class that will be used to contain the submitted form data.
buildForm
adds our three fields.
name
and another
are properties of our Widget
entity. Both are currently set to simple input
/ TextType::class
fields types. What we are going to cover should work for any other field type also.
$builder
->add('name', TextType::class, [
'attr' => [
'class' => 'class-added-in-form-type',
'data-custom-error-css-class' => 'some-css-error-class another-error-class',
]
])
As the third argument to add
we pass in an array.
This array contains the commonly used attr
key.
This key allows you to set the values of HTML attribute key/value pairs on the form field when rendered by Twig.
Any field we add here will be added, regardless of whether it's some pre-defined attribute or not.
In other words, by doing this our rendered input
for this field would be:
<input
type="text"
id="widget_name"
name="widget[name]"
required="required"
class="class-added-in-form-type"
data-custom-error-css-class="some-css-error-class another-error-class"
>
And what we want is when the form field fails validation, we append the contents of data-custom-error-css-class
to the class
's value:
<input
type="text"
id="widget_name"
name="widget[name]"
required="required"
class="class-added-in-form-type some-css-error-class another-error-class"
data-custom-error-css-class="some-css-error-class another-error-class"
>
Having the data-custom-error-css-class
attribute as a left over is a bit unfortunate.
Simply prepending the attribute with data-
is enough to make it a valid HTML attribute all the same.
To begin with we will work towards a static error
CSS class to be added, and then we will expand on this to make it so we can modify the error
class as above.
Already an Edge Case
But there's already an edge case.
What if attr
is not set. Or attr
is set, but either class
, or data-custom-error-css-class
are not defined.
It's obvious in our heads that if attr
is not set then we don't need to worry. We don't need a class
attribute.
And it makes sense that that if attr
is set, but either class
, or data-custom-error-css-class
are not defined then we can likely just return whatever is defined.
Remember, we need everything to behave as expected, we're just adding some augmentation.
Yellow Alert
We're at yellow alert. Shields are up, and we're ready to get defensive.
Let's take a peek at the entity:
<?php
// src/AppBundle/Entity/Widget.php
namespace AppBundle\Entity;
use Doctrine\ORM\Mapping as ORM;
use Symfony\Component\Validator\Constraints as Assert;
/**
* @ORM\Entity()
* @ORM\Table(name="widget")
*/
class Widget
{
/**
* @ORM\Id
* @ORM\Column(type="integer")
* @ORM\GeneratedValue(strategy="AUTO")
*/
private $id;
/**
* @ORM\column(type="string")
* @Assert\NotBlank(message="This is a required field")
*/
private $name;
/**
* @ORM\column(type="string")
* @Assert\NotBlank(message="This is a required field")
*/
private $another;
// `getter` / `setter` removed for brevity
}
The important parts here being the Assert
annotations.
At this point, we should be able to render our form without issue. Because we have novalidate
set as an attribute of our form
, we can submit the form, and Symfony renders the error messages above the respective fields.
Custom Form Theme
Now we're going to get adventurous.
We're going to create a custom form theme that allows us to pull everything together.
What we have is a presentational problem. We need to push the presentation logic as outermost as we possibly can - from out of the 'core' of our app, and into the Twig-gy / template-y world of HTMhelL.
Thinking back to the original question:
... to the
input
/ form field itself ...
I'm working on the assumption that what I need to fix here is that every form field type may need this feature.
I don't know of a way to apply this 'fix' to all form fields in one go. Instead, we're going to need to alter five different Twig block
s:
{# /app/Resources/views/custom-form-error.html.twig #}
{% extends "form_div_layout.html.twig" %}
{% block form_widget_simple -%}
{% if errors|length > 0 -%}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' error')|trim}) %}
{% endif %}
{{- parent() -}}
{%- endblock form_widget_simple %}
{% block textarea_widget -%}
{% if errors|length > 0 -%}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' error')|trim}) %}
{% endif %}
{{- parent() -}}
{%- endblock textarea_widget %}
{% block choice_widget_collapsed -%}
{% if errors|length > 0 -%}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' error')|trim}) %}
{% endif %}
{{- parent() -}}
{%- endblock choice_widget_collapsed %}
{% block checkbox_widget -%}
{% if errors|length > 0 -%}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' error')|trim}) %}
{% endif %}
{{- parent() -}}
{%- endblock checkbox_widget %}
{% block radio_widget -%}
{% if 'radio-inline' not in parent_label_class %}
{% if errors|length > 0 -%}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' error')|trim}) %}
{% endif %}
{% endif %}
{{- parent() -}}
{%- endblock radio_widget %}
I'm not going to try and take credit for this idea. I borrowed it from the foundation_5_layout.html.twig
file.
In Symfony when using Twig, pretty much everything of importance regarding the way forms and their fields are rendered happens in one file:
/vendor/symfony/symfony/src/Symfony/Bridge/Twig/Resources/views/Form/form_div_layout.html.twig
This is a near 400 line monster at the time of recording.
We start off by extends "form_div_layout.html.twig"
. In doing this we make sure we are inheriting from the base form_div_layout.html.twig
file, and therefore any calls to parent()
will call the code from the same named block after executing our overrides.
Each widget block is roughly the same in logic.
set attr = attr|merge({class: (attr.class|default('') ~ ' error')|trim})
This is the key piece of the puzzle.
We want to override the existing value of attr
.
We want to replace the value stored under the class
key with whatever is currently in the attr.class
key, or an empty string if not it is not set.
Notice that merge
takes a funky hash (basically a key / value pair) using curly braces: {class: ...}
. See the Twig docs for a further example.
The value of our hash is this attr.class
value, or the ''
empty string concatenated (~
) with ' error'
. The space is important as if attr.class
is set, then we don't want error
being stuck right next to it. Think some-classerror
. Oops. We need that space.
Piping through trim
ensures any leading or trailing spaces get removed.
Finally for each block once we've done our necessary changes, we call parent()
to do whatever else needs to be done to output the expected HTML.
To make this work we need to add in an entry under form_themes
to the twig
section of config.yml
:
# app/config/config.yml
# Twig Configuration
twig:
debug: '%kernel.debug%'
strict_variables: '%kernel.debug%'
form_themes:
- 'custom-form-error.html.twig'
At this point our logic should be working.
To make this more obvious add the following extra code to index.html.twig
:
{# app/Resources/views/default/index.html.twig #}
{% extends 'base.html.twig' %}
{% block body %}
{{ form(form) }}
{% endblock %}
{% block stylesheets %}
<style>
.some-css-error-class {
background: red;
padding: 2em;
border: 1px solid green;
}
.another-error-class {
background: blue;
padding: 2em;
border: 1px solid orange;
}
</style>
{% endblock %}
Boy that's some ugly output, but it proves this works.
In the next video we're going to use a Twig extension to tidy it up just a touch.