SmartAdmin layout for Symfony forms

SmartAdmin layout for Symfony forms

I will not surprise you by saying that Form component is the toughest part for almost each Symfony developer. Even if it seems to you that you mastered the form building and handling, you can still find yourself run out of patience when it comes to changing form’s appearance. Especially beginners will fully agree with me.

This article aims to teach you how to tailor every part of Symfony’s form to your preferences and demands. We are going to alter default form layout so that it meets the requirements of the SmartAdmin Bootstrap theme. As well as popular front-end frameworks like Bootstrap or Foundation, SmartAdmin needs appropriate CSS classes and HTML tags structure. Despite that layouts for Bootstrap and Foundation do already exist, SmartAdmin still lacks it. The tutorial is going to fill this gap and teach you step by step how to act in similar situations.

I hope that using such a great component as Symfony’s forms will be less painful after reading this article.

What to create and how to apply

Official documentation says:

In Symfony, every part of a form that is rendered - HTML form elements, errors, labels, etc. - is defined in a base theme, which is a collection of blocks in Twig and a collection of template files in PHP.

In Twig, each form “fragment” is represented by a Twig block. To customize any part of how a form renders, you just need to override the appropriate block.

So a form theme is just a single file (twig template) containing blocks which define form’s appearance. In our case, it will be smart_admin_layout.html.twig. There are two options to apply the theme:

  1. globally in config.yml:
# app/config/config.yml

twig:

    form_themes:

        - 'form/fields.html.twig'
  1. for each form separately:
{% form_theme form 'smart_admin_layout.html.twig' %}

I recommend you to have a closer look at the part of documentation above, especially at the form fragment naming and at the template fragment inheritance. In short, in order to customize a part of your form you should override corresponding twig block. To get the name of the block which have to be overridden you must combine the name of a type (form, text, *entity, date, …) with four main form fragments (label, widget, errors, row). E.g.:

  • form_row for label, widget and errors of the FormType,
  • text_row for label of a TextType,
  • entity_widget for widget of an EntityType,
  • date_errors for errors of a DateType.

The theme will be complete as soon as every base type gets corresponding block overridden to meet front-end requirements. But don’t worry, you’re not supposed to write all possible combinations of type_fragment, although you can do it. Pay attention that every form type inherit from other type, e.g. CurrencyTypeChoiceTypeFormType. If the necessary combination (like currency_widget) does not exist then the parent combination is used (choice_widget). This inheritance tree ultimately goes to FormType, that is why form_row, form_widget, form_errors, form_label must always be defined.

Let us begin to create a new theme. In order to reduce amount of code it is a good idea to inherit existing one. So smart_admin_layout.html.twig will reuse form_div_layout.html.twig:

{% use "form_div_layout.html.twig" %}

form_start

SmartAdmin expects the <form> tag to have smart-form CSS class. To add it we need to override form_start block in the following way:

{%- block form_start -%}
    {% set attr = attr|merge({class: (attr.class|default('') ~ ' smart-form')|trim}) %}
    {{- parent() -}}
{%- endblock form_start -%}

attr is a form view variable namely “a key-value array that will be rendered as HTML attributes on the field”. Do not hesitate to check what set, default, merge are in Twig.

form_row

In SmartAdmin, each form input alongside its label and errors is wrapped in <section> tag:

<section> <!-- form_row -->
    <label class="label">Text input</label> <!-- form_label -->
    <label class="input state-error"> <!-- form_widget -->
        <input type="text">
    </label>
    <div class="note note-error">This is a required field.</div> <!-- form_errors -->
</section>

The code above suggests overriding form_row block that contains widget, label and errors:

{%- block form_row -%}
    <section>
        {{- form_label(form) -}}
        {{- form_widget(form) -}}
        {{- form_errors(form) -}}
    </section>
{%- endblock form_row -%}

form_label

Each form label should have label CSS class. We add this class using the same approach as for form_start block:

{%- block form_label -%}
    {% set label_attr = label_attr|merge({class: (label_attr.class|default('') ~ ' label')|trim}) %}
    {{- parent() -}}
{%- endblock form_label -%}

form_errors

{%- block form_errors -%}
    <em class="invalid">
        {%- for error in errors -%}
            <div>{{ error.message }}</div>
        {%- endfor -%}
    </em>
{%- endblock form_errors -%}

form_widget_simple

Text input should be wrapped in <label> tag with input and state-error CSS classes:

<label class="input state-error">
    <input type="text">
</label>

Overriden block looks as following:

{%- block form_widget_simple -%}
    <label class="input{% if not valid %} state-error{% endif %}">
        {{- parent() -}}
    </label>
{%- endblock form_widget_simple -%}

Pay attention that state-error class is added only in case the current form element is invalid. By calling parent() we leverage the default block form_widget_simple defined in form_div_layout.html.twig.

Afterwards the default form_widget_simple will come in handy for other blocks but we will not be able to reference it by now: if we call {% block ('form_widget_simple') %} the new form_widget_simple will be used in place of default one. To differentiate the old block and new one it is suggested to use a label for default form_widget_simple.

The first line of our new theme will change to:

{% use "form_div_layout.html.twig" with form_widget_simple as base_form_widget_simple %}

form_widget_simple will not have any parent so parent() is replaced by block('base_form_widget_simple'):

{%- block form_widget_simple -%}
    <label class="input{% if not valid %} state-error{% endif %}">
        {{- block('base_form_widget_simple') -}}
    </label>
{%- endblock form_widget_simple -%}

TextareaType

Add custom-scroll CSS class and default value of rows attribute:

{%- block textarea_widget -%}
    <label class="textarea{% if not valid %} state-error{% endif %}">
        {%- set attr = attr|merge({class: (attr.class|default('') ~ ' custom-scroll')|trim, rows: (attr.rows|default(3))}) -%}
        <textarea {{ block('widget_attributes') }}>{{ value }}</textarea>
    </label>
{%- endblock textarea_widget -%}

MoneyType

In the form_div_layout.html.twig MoneyType is rendered differently depending on current locale (could be $ <input ...> or <input ...> $):

{%- block money_widget -%}
    {{ money_pattern|replace({ '{{ widget }}': block('form_widget_simple') })|raw }}
{%- endblock money_widget -%}

Let us keep this feature. Pay attention that base_form_widget_simple is used here:

{%- block money_widget -%}
    <label class="input{% if not valid %} state-error{% endif %}">
        {%- set symbol = money_pattern|replace({ '{{ widget }}': '' }) -%}
        {% if money_pattern ends with '{{ widget }}' %}<i class="icon-prepend">{{ symbol }}</i>{% endif -%}
        {{ block('base_form_widget_simple') -}}
        {% if money_pattern starts with '{{ widget }}' %}<i class="icon-append">{{ symbol }}</i>{% endif -%}
    </label>
{%- endblock money_widget -%}

PercentType

This is a piece of cake after all you’ve seen - just add <i class="icon-append">%</i>.

{%- block percent_widget -%}
    <label class="input{% if not valid %} state-error{% endif %}">
        {{- block('base_form_widget_simple') -}}
        <i class="icon-append">%</i>
    </label>
{%- endblock percent_widget -%}

RangeType

SmartAdmin makes use of jQueryUI slider to make range inputs more elegant. You only have to add some CSS classes and attributes to turn basic input into slider:

{%- block range_widget -%}
    {% set attr = attr|merge({class: (attr.class|default('') ~ ' slider slider-primary')|trim,
        'data-slider-min': attr.min|default,
        'data-slider-max': attr.max|default,
        'data-slider-value': value}) -%}
    {{ block('base_form_widget_simple') }}
{%- endblock range_widget %}

ChoiceType

Choice type can appear in four modes depending on multiple and expanded options: radios, checkboxes, select and multiple select. Generally, ChoiceType does not require customization (neither choice_row nor choice_widget) because it delegates its rendering to choice_widget_expanded and choice_widget_collapsed. The fist is used when 'expanded' => true, otherwise the second is used. Thereby there are two main modes: expanded (radios, checkboxes) and collapsed (select, selece multiple).

Expanded ChoiceType generates a compound FormView that contains a collection of several radios or checkboxes. The customization takes place at radio_row and checkbox_row:

The block for a single checkbox:

{%- block checkbox_row -%}
    {% set multiple = true -%}
    {{ block('single_choice_row_expanded') }}
{%- endblock checkbox_row -%}

The block for a single radio:

{%- block radio_row -%}
    {% set multiple = false -%}
    {{ block('single_choice_row_expanded') }}
{%- endblock radio_row -%}

A new block that contains a common code for checkboxes and radios:

{%- block single_choice_row_expanded -%}
    <section>
        {{- form_errors(form) -}}
        <label class="{{ multiple ? 'checkbox' : 'radio'}}{% if not valid %} state-error{% endif %}">
            {{- block(multiple ? 'checkbox_widget' : 'radio_widget') -}}
            <i></i>
            {{- translation_domain is same as(false) ? label : label|trans({}, translation_domain) -}}
        </label>
    </section>
{%- endblock single_choice_row_expanded -%}

Note that multiple variable is set in two latest snippets to differentiate between checkboxes and radios.

Here is choice_widget_expanded which renders all children (radios or checkboxes) of ChoiceType field:

{%- block choice_widget_expanded -%}
    <div {{ block('widget_container_attributes') }}>
        {%- for child in form -%}
            {{ form_row(child) }} {# radio_row or checkbox_row #}
        {%- endfor -%}
    </div>
{%- endblock choice_widget_expanded -%}

In order to adapt <select> tag for SmartAdmin we have to add some CSS classes and HTML tags taking into account multiple option:

{%- block choice_widget_collapsed -%}
    {% set attr = multiple ? attr|merge({class: (attr.class|default ~ ' custom-scroll')|trim}) : attr -%}
    <label class="select{% if multiple %} select-multiple{% endif %}{% if not valid %} state-error{% endif %}">
        {{- parent() -}}
        {% if not multiple %}<i></i>{% endif -%}
    </label>
{%- endblock choice_widget_collapsed -%}

FileType

Rearrange tags, add CSS classes for FileType widget:

{% block file_widget -%}
    <div class="input input-file{% if not valid %} state-error{% endif %}">
        <span class="button">
            {{- block('base_form_widget_simple') -}}
            {{ 'browse'|trans -}}
        </span>
        <input type="text" placeholder="{{ 'include_some_files'|trans }}" readonly="">
    </div>
{%- endblock file_widget %}

That is all for now. In conclusion, I would like to mention that Symfony’s Form component is easily customisable and extendable at any level. As you have seen, the modification of rendering layer isn’t very tough job. The complete smart_admin_layout.html.twig theme can be found here.