Building a form theme out of Twig components
08 november 2023
5 minutes
In previous article, you can read how you can build atomic Twig components. One of the benifits of making components atomic, is that you can reuse them everywhere. In this article, we want to link the form specific atoms we've created to the symfony/form package. This makes it possible to configure advances form types in our application, and visualize the form component by using a Twig component.
Linking a simple text input field
Let's start with a very basic implementation: we want to link our input atom to Symfony's TextType
. To configure an input in Symfony forms, the configuration looks like this:
$builder
->add('query', TextType::class, [
'placeholder' => 'Search',
])
As you can read in our previous article, the form input atom can be configured like this:
{{ component('atoms:input', {placeholder: 'Search', name: 'query'}) }}
In order to link up the TextType to our input atom, we have to define a custom Symfony form theme. This allows us to overwrite the way symfony/form will render out the form. Since our input atom can handle multiple input types (text, email, phone, ...), we can overwrite the 'simple' form widget:
{# templates/form/theme.html.twig #}
{% extends 'form_div_layout.html.twig' %}
{%- block form_widget_simple -%}
{% set attributes = attr|merge({
type: type ?? 'text',
required: required,
value: value ?? '',
id: id,
name: full_name,
erroneous: (errors|length > 0),
view: form,
}) %}
{{ component('atoms:form:input', attributes) }}
{%- endblock form_widget_simple -%}
In this code, you can see that the attributes that are being passed down from symfony/form are being proxied to our input atom. These attributes can be used by the input atom to customize the look and feel of the form. You could e.g. render a red border around the form if erroneous
is marked as true inside the input atom.
For every Twig form component you create, you can overwrite the way symfony/form renders out the matching form type. You can specify how to render labels, form rows, errors and much much more. This allows you to use the atoms as standalone components and even link them to form types.
Advanced form types
The example above is quite basic: it just defines how twig renders the input. However, the same technique can be applied for more advanced form types as well.
Imagine you want to optionally ask for a persons name in your form and you don't want to display too many optional fields. You could, in that case, render an optional firstName field. Depending of wether the user fills in the field or not, you could display or hide the optional lastName field as well.
The form type could look like this:
class OptionalNameType extends AbstractType
{
public function buildForm(FormBuilderInterface $builder, array $options): void
{
$builder
->add('firstName', TextType::class, [
'required' => false,
])
->add('lastName', TextType::class, [
'required' => false,
]);
}
}
Next up, you can gain control about how this form components will be rendered by specifying a form theme for this component:
{% block optional_name_row %}
{{ component('molecules:search-or-create', {
form,
}|merge(component_attr ?? {})) }}
{% endblock %}
The Twig component could look like this:
#[AsTwigComponent('molecules:optional-name', 'components/molecules/optional-name.html.twig')]
class OptionalNameComponent
{
public FormView $form;
}
By passing down the form view, you can use symfony's form helper functions inside your twig component. These will be rendered using your custom form theme, which yout linked to your custom twig components. The Twig template of this component could look like this:
<div {{ attributes }} {{ stimulus_controller('optional-name', {}) }}>
{{ form_row(form.firstName, {
widget_attr: stimulus_target('optional-name', 'firstNameInput').toArray(),
}) }}
{{ form_row(form.lastName, {
attr: stimulus_target('optional-name', 'lastNameRow').toArray()
}) }}
</div>
There are some things to note about this template:
By passing down the attributes, you can configure additional configurations of the component from the parent form type. For example: if you want to give it some padding or margin.
The template contains Stimulus controllers and targets. They are added through Twig helpers that are made available by Symfony UX. These are added in order to make the form type interactive based on Javascript.
Now that we have the rendered HTML of the form type, the only remaining part is to make it interactive with Javascript. Since Symfony UX uses stimulus to achieve these kind of interactions, we will be adding a small Stimulus controller:
import { Controller } from '@hotwired/stimulus';
export default class extends Controller<HTMLDivElement> {
static targets = ["firstNameInput", "lastNameRow"];
declare readonly firstNameInputTarget: HTMLInputElement;
declare readonly lastNameRowTarget: HTMLDivElement;
connect() {
this.updateUI();
this.firstNameInputTarget.addEventListener('change', () => this.updateUI());
}
updateUI() {
const hasFirstName = Boolean(this.firstNameInputTarget.value);
this.lastNameRowTarget.classList.toggle('hidden', !hasFirstName)
}
}
In a next article, we'll dive in a bit deeper into Stimulus. Here is a high-level explanation how it works: The controller and targets are being linked by Stimulus, based on the attributes we've added in the template of the optional name molecule.
In the controller above, the lastName row will only be shown if the firstName field contains a non-empty value. When the value of the firstName input field changes, the decision of displaying the lastName's row is made once more.
Even though this component is starting to get a little bit more complex than the first example, it's usage is very straight forward: You can add the OptionalNameType
form type to any of your other Symfony forms. The form will render this complex component and apply the stimulus controller to that specific part of the form.
This means that you can reuse very complex form types - and only write and maintain the logic for them in one place.
That's powerfull!