http://vria.eu SL

Wellcome to my professional blog dedicated to interesting things in programming, web-development and design patterns.

Developing SmartAdmin layout for Symfony forms

Mar 26, 2016 symfony twig forms smart admin

This article is a tutorial on customization of Symfony forms’ appearence. Using SmartAdmin as a case study, I will highlight key aspects that you need to know for adapting any element of your form to front-end requirements.

sa3.png

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 front-end framework. As well as popular front-end frameworks like Bootstrap and 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.

Generally speaking, you have 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.:

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 (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:


<form class="smart-form">

To add this class 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 with 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

As you can see, text input is wrapped in <label> tag with text CSS class:


<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 if the current form element is not valid. In addition to this, 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 in the following way because depending on current locale it should 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 enhances jQueryUI slider to make range inputs more elegant. You can find different variants of slider widgets on the form plugins page. Now 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, single select and multiple select. Generally, the whole ChoiceType requires customization neither for choice_row nor for choice_widget because it delegates its rendering to choice_widget_expanded or choice_widget_collapsed. choice_widget_expanded is used when 'expanded' => true, otherwise choice_widget_collapsed is used. Thereby there are two main modes: expanded (radios, checkboxes) and collapsed (select, selece multiple).

Expanded ChoiceType is a compound FormView that contains a collection of several radios or checkboxes. The customization takes place at radio_row and checkbox_row. They differ intype attribute and CSS classes.

Mutual row for single field of radio or checkbox:


{%- 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 -%}

Checkbox row:


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

Radio row:


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

Note that multiple is set in two latest snippets. The reason is that multiple variable is set only for ChoiceType which is a parent of radios or checkboxes. checkbox_row and radio_row aren't provided with multiple variable. However, it's obvious that checkbox_row is multiple and radio is not.

Here is choice_widget_expanded which renders all children 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 custimize select 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

Rearranging tags, add CSS classes for FileType.


{% 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.