[Part 2] - Twig Extensions - Create a Twig Extension Function to Keep DRY
Towards the end of the previous video we had a basic working implementation that allowed us to add a CSS class (error
) if we had one or more validation errors for the current form field.
In this video we will look at a way to customise the CSS class to be either error
by default, or some other custom value if we prefer.
One way we could do this is to really hack at the set attr = ...
line we already had:
{# /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('') ~ ' ' ~ (attr.class_for_errors|default(' error')))|trim}) %}
{% endif %}
{{- parent() -}}
{%- endblock form_widget_simple %}
{% block textarea_widget -%}
{% if errors|length > 0 -%}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' ' ~ (attr.class_for_errors|default(' error')))|trim}) %}
{% endif %}
{{- parent() -}}
{%- endblock textarea_widget %}
{% block choice_widget_collapsed -%}
{% if errors|length > 0 -%}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' ' ~ (attr.class_for_errors|default(' error')))|trim}) %}
{% endif %}
{{- parent() -}}
{%- endblock choice_widget_collapsed %}
{% block checkbox_widget -%}
{% if errors|length > 0 -%}
{% set attr = attr|merge({class: (attr.class|default('') ~ ' ' ~ (attr.class_for_errors|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('') ~ ' ' ~ (attr.class_for_errors|default(' error')))|trim}) %}
{% endif %}
{% endif %}
{{- parent() -}}
{%- endblock radio_widget %}
Throwing custom CSS into the mix bumps up the amount of cases we need to cover. We now want to augment the attr
variable to have one of the following values:
- 'error' when
attr.class
is not set, andattr.class_for_errors
is not something custom {value of class_for_errors} when
attr.classis not set, and
attr.class_for_errors` is something custom{value of class} error
whenattr.class
is set, andattr.class_for_errors
is not something custom{value of class} {value of class_for_errors}
whenattr.class
is set, andattr.class_for_errors
is something custom
Whilst the above set attr = ...
line does work, it's got the look of something that in a few weeks time, will have you cursing yourself for ever adding to your code.
Lets instead extract this logic out to a separate custom Twig function.
Custom Twig Function
Rather than that long one liner, I want to keep the logic but move it into a PHP class.
Twig allows us to do this with Twig Extensions.
We're going to be making use of Twig's \Twig_Function
class to allow us to, as you might have guessed, create our own function that we can call from our Twig templates.
It's easier than it sounds:
<?php
// /src/AppBundle/Twig/Extension/FormFieldErrorExtension.php
namespace AppBundle\Twig\Extension;
class FormFieldErrorExtension extends \Twig_Extension
{
public function getFunctions()
{
return [
new \Twig_SimpleFunction('merge_errors_with_custom_error_css', [$this, 'mergeErrorsWithCustomErrorCss']),
];
}
public function mergeErrorsWithCustomErrorCss(array $attrs = [])
{
if (count($attrs) === 0) {
return $attrs;
}
if (false === isset($attrs['class'])) {
$attrs['class'] = '';
}
if (isset($attrs['class_for_errors'])) {
$attrs['class'] .= $attrs['class_for_errors'];
}
return $attrs;
}
}
Heads up: We're using Symfony 3.3 in this project. This means with the default autoconfiguration setting, we don't need to do any service configuration here. Symfony will figure out that we've created a Twig Extension (because we've extends \Twig_Extension
) and will tag
our automatically created service accordingly.
If you're not using Symfony 3.3, or you have autoconfiguration disabled, then make sure to create a service definition, and tag accordingly:
# app/config/services.yml
services:
# Extra service config should not be needed in Symfony 3.3+
crv.twig.extension.filter_array:
class: AppBundle\Twig\Extension\FormFieldErrorExtension
tags: ['twig.extension']
Ok, so to break this down:
public function getFunctions()
{
return [
new \Twig_SimpleFunction('merge_errors_with_custom_error_css', [$this, 'mergeErrorsWithCustomErrorCss']),
];
}
getFunctions
will be called by Symfony behind the scenes. This function tells Symfony what custom Twig functions we want to make available inside our Twig templates.
Notice this function is getFunctions
. If we were creating a custom Twig filter we would need getFilters
. If creating a custom Twig test we'd need getTests
, and so on. A good IDE like PhpStorm will give you a useful breakdown if you use the 'Code > Override Methods` option.
getFunctions
return an array.
This means this one FormFieldErrorExtension
file can contain multiple custom functions. Here we only define one, but we still need to return an array.
Our only entry is a new \Twig_SimpleFunction
object, where merge_errors_with_custom_error_css
will be our function name, and the method mergeErrorsWithCustomErrorCss
will be called in the current class ($this
) when this function is invoked.
To call the function, we simply need to wrap a function call in Twig braces as you might expect:
{% merge_errors_with_custom_error_css([]) %}
Note here if we were using a filter then we would use different syntax, e.g. attr|merge({...
where merge
is the filter.
The logic for mergeErrorsWithCustomErrorCss
is exactly as before:
public function mergeErrorsWithCustomErrorCss(array $attrs = [])
{
if (count($attrs) === 0) {
return $attrs;
}
if (false === isset($attrs['class'])) {
$attrs['class'] = '';
}
if (isset($attrs['class_for_errors'])) {
$attrs['class'] .= $attrs['class_for_errors'];
}
return $attrs;
}
To call this function from our template we might do this:
{% block textarea_widget -%}
{% if errors|length > 0 -%}
{% set attr = merge_errors_with_custom_error_css(attr) %}
{% endif %}
{{- parent() -}}
{%- endblock textarea_widget %}
Here, attr
is the array we've been working on throughout.
It might be an empty array, so we check: (count($attrs) === 0)
, and if so, we just return the empty array.
Next we check if attr.class
is set. If not, we set it to a default value:
if (false === isset($attrs['class'])) {
$attrs['class'] = '';
}
We must do this, as we always want a class
attribute to make this process work.
if (isset($attrs['class_for_errors'])) {
$attrs['class'] .= $attrs['class_for_errors'];
}
Next we check if $attrs['class_for_errors']
is set. If not, disregard.
Given that we know we definitely have a $attrs['class']
set, we can then concat .=
with whatever is set in $attrs['class_for_errors']
.
At this point we return the modified $attrs
array.
We could now modify the template accordingly:
{# /app/Resources/views/custom-form-error.html.twig #}
{% extends "form_div_layout.html.twig" %}
{% block form_widget_simple -%}
{% if errors|length > 0 -%}
{% set attr = merge_errors_with_custom_error_css(attr) %}
{% endif %}
{{- parent() -}}
{%- endblock form_widget_simple %}
{% block textarea_widget -%}
{% if errors|length > 0 -%}
{% set attr = merge_errors_with_custom_error_css(attr) %}
{% endif %}
{{- parent() -}}
{%- endblock textarea_widget %}
{% block choice_widget_collapsed -%}
{% if errors|length > 0 -%}
{% set attr = merge_errors_with_custom_error_css(attr) %}
{% endif %}
{{- parent() -}}
{%- endblock choice_widget_collapsed %}
{% block checkbox_widget -%}
{% if errors|length > 0 -%}
{% set attr = merge_errors_with_custom_error_css(attr) %}
{% endif %}
{{- parent() -}}
{%- endblock checkbox_widget %}
{% block radio_widget -%}
{% if 'radio-inline' not in parent_label_class %}
{% if errors|length > 0 -%}
{% set attr = merge_errors_with_custom_error_css(attr) %}
{% endif %}
{% endif %}
{{- parent() -}}
{%- endblock radio_widget %}
There's still a bunch of repetition of the method calls, but not the logic has been de-duped.
What this means is we can update the data-custom-error-css-class
inside WidgetType
to allow us to pass through custom CSS on a per field basis:
<?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', [
'attr' => [
'data-custom-error-css-class' => 'different-error',
]
])
->add('submit', SubmitType::class)
;
}
public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults([
'data_class' => Widget::class,
'attr' => [
'novalidate' => 'novalidate'
]
]);
}
}
And that's about it.
Dump Is Your Friend
One key point is that every variable you can access from inside a Twig template can be seen by using {{ dump()) }}
.
Add this in any template, refresh the page, and your web debug toolbar will contain the output of all available variables.
Remember to remove any extraneous dump
statements before deploying, as its use will cause a 500
error in production (app.php
):
[2017-09-29 14:32:52] request.CRITICAL: Uncaught PHP Exception Twig_Error_Syntax: "Unknown "dump" function." at /Users/Shared/Development/validation-exploration/app/Resources/views/custom-form-error.html.twig line 5 {"exception":"[object] (Twig_Error_Syntax(code: 0): Unknown \"dump\" function. at /Users/Shared/Development/validation-exploration/app/Resources/views/custom-form-error.html.twig:5)"} []
Once you know you can dump
out all the variables, you can start figuring out interesting ways to achieve your goals.
You can add a {{ dump() }}
call directly into any of your existing Twig templates, and take a look at the output: