eggmatters eggmatters - 10 days ago 7
Twig Question

Symfony 3 override default form rendering

Symfony's documentation on "custom form rendering" seems vague and misleading. I am attempting to render a custom element on a form, but I'm seeing inexplicable behaviors when attempting to do so.

The Problem:

I wish to render a multi column select box. A functional equivalent is described accurately here:
jQuery Get Selected Option From Dropdown

Context


  • Model - I have a users Entity. A user can belong to zero, one or many Organizations.

  • Controller - My controller makes an API call which fetches a User and the Organizations, if any that user belongs to, and a list of Organizations that user can add.

  • View - I am rendering a create / edit form Twig template displaying all user fields plus a list of organizations the user may subscribe. There are two select boxes side-by-side. One displays the organizations a user can belong to, the other is the list of possible organizations a user may add:





<select "user_orgs">
<option value="1">Organization 1</option>
</select>
<button id="add"> << </button>
<button id="remove"> << </button>
<select "available_orgs">
<option value="1">Organization 1</option>
<option value="2">Organization 1</option>
</select>





I am using the "bootstrap3_form_horizontal.html.twig template for form rendering. In order to capture the above functionality, I need to provide a way to add a variant of the above html to my form.

Solution 1

Add a method to a Twig Extension that can be callable from the form and render the html.


  • (positive) Easiest and most effective

  • (negative) Doesn't utilize twig. Duck types needed form values.

  • (negative) Not very DRY.



For all Solutions, I have created an AbstractType providing controls for Form rendering:

//Predefined form types:
use Symfony\Component\Form\Extension\Core\Type;

class UserType extends AbstractType {

public function buildForm(FormBuilderInterface $builder, array $options) {
$builder->add( 'email', Type\EmailType::class, [
'label' => 'Email / Username',
])
. . .
->add('organizations', Type\ChoiceType::class, [
'label' => 'Organizations',
'selections' => $options['organizations'],
'multiple' => true,

])
->add('submit', Type\SubmitType::class, [
'attr' => ['class' => 'btn-success btn-outline'],
]);
}
}


user_create.html.twig:

{% form_theme form _self %}
{% block body %}
{{ form_start(form) }}
{{ form_errors(form) }}
{{ form_row(form.email) }}
{{ multi_select(form.organizations, selected_organizations)|raw }}
{{ form_end(form) }}
{% endblock %}


The "multi select" is a custom Twig extension:

class CustomTwigExtension extends \Twig_Extension {
public function getFunctions() {
return array(
new \Twig_SimpleFunction('multi_select', [$this, 'multiSelect']),
}

public function multiSelect(\Symfony\Component\Form\FormView $formView, $selections) {
$formData = $formView->vars;
$formLabel = $formData['label'];
$selectName = $formData['full_name'];
$formId = $formData['id'];
$optionsId = $formId . '_options';
$rhsOptions = "";
$lhsOptions = "";

foreach($selections as $key => $value) {
$lhsOptions .= '<option value="' . $value . ' selected">' . $key . '</option>';
}
foreach($formData['choices'] as $option) {
$rhsOptions .= '<option value="' . $option->value . ' selected">' . $option->label . '</option>';
}

$html = <<<HTML
<div class="form-group">
<label class="col-sm-2 control-label required" for="$formId">$formLabel</label>
<div class="col-sm-2">
<select id="$formId" class="form-control" name="$selectName" multiple>
$lhsOptions
</select>
</div>
<div class="col-sm-1" style="width: 4%;">
<button class="btn btn-default" id="m-select-to">&laquo;</button>
<br />&nbsp;<br />
<button class="btn btn-default" id="m-select-from">&raquo;</button>
</div>
<div class="col-sm-2">
<select id="$optionsId" class="form-control" multiple>
$rhsOptions
</select>
</div>
</div>
HTML;
return $html;
}


Not very elegant, but it renders the desired result. (I included the HEREDOC html to be more precise concerning my end result).

The issue is, the "form_start" Twig method also renders a select box of organizations in addition to the control from above - I have directed it to do that by setting the "ChoiceType" in the User's abstract type.

The form roughly resembles:
(The relevant parts are bracketed by the comments)



<form name="user" method="post" action="/admin/users/save/" class="form-horizontal">
<!-- THIS IS THE CUSTOM GENERATED CONTROL -->
<div class="form-group">
<label class="col-sm-2 control-label required" for="user_organizations">Organizations</label>
<div class="col-sm-2">
<select id="user_organizations" class="form-control" name="user[organizations][]" multiple="">
</select>
</div>
<!-- END CUSTOM GENERATED CONTROL -->
<div class="col-sm-1" style="width: 4%;">
<button class="btn btn-default" id="m-select-to">«</button>
<br>&nbsp;<br>
<button class="btn btn-default" id="m-select-from">»</button>
</div>
<div class="col-sm-2">
<select id="user_organizations_options" class="form-control" multiple="">
<option value="1 selected">Organization1</option>
<option value="2 selected">Organization2</option>
</select>
</div>
</div>
<div class="form-group">
<label class="col-sm-2 control-label required" for="user_email">Email / Username</label>
<div class="col-sm-10">
<input id="user_email" name="user[email]" required="required" class="form-control" type="email">
</div>
<!-- THIS IS THE SYMFONY GENERATED CONTROL -->
<div class="form-group">
<label class="col-sm-2 control-label required" for="user_organizations">Organizations</label>
<div class="col-sm-10">
<select id="user_organizations" name="user[organizations][]" class="form-control" multiple="multiple">
<option value="1 selected">Organization1</option>
<option value="2 selected">Organization2</option>
</select>
</div>
</div>
<!-- END SYMFONY GENERATED CONTROL -->
</div><input id="user__token" name="user[_token]" value="imatoken" type="hidden"></form>





As you can see, there are two different things going on. A generic, "out-of-the-box" select tag and my custom rendered one.

The form_start method appears to be iterating through the children of the UserType and rendering their controls in addition to the one I specified.

Solution 2

Create a custom template to control rendering of the form.


  • (positive) Follows documented best practices for achieving my result

  • (negative) Not documented fully. Doesn't work.



Following the procedures outlined in http://symfony.com/doc/current/form/form_customization.html and clarified in the SO post:
How to create a custom Symfony2 Twig form template block
As well as:
Custom form field template with twig

I did the following:

Created a Custom type to be rendered from a custom template (from the Symfony docs link):

Custom Type:
AppBundle\Form\MultiSelectType:

class MultiSelectType extends AbstractType {

public function buildView(FormView $view, FormInterface $form, array $options) {
$view->vars['selections'] = $this->setOptions($options['selections']);
//The following lines will assign the select box containing
//already defined organizations.
$entity = $view->parent->vars['value'];
$field = $options['entity_field_name'];
$method = 'get' . $field;
$values = call_user_func(array($entity, $method));
$view->vars['choices'] = $values;
}

public function configureOptions(OptionsResolver $resolver)
{
$resolver->setDefaults(array(
'selections' => array(),
'custom_label' => 'slapping juice'
));
}

private function setOptions($options) {
$ar = [];
foreach($options as $k => $v) {
$ar[] = ['value' => $v, 'label' => $k];
}
return $ar;
}
}


I have literally no idea what to specify here. Which methods do I override and how? The Symfony docs urge you to look at a similar instantiation. The Choices Type class is 800 lines long! Do I need 800+ lines of PHP to render 20 of HTML?!

I reference it in my UserType thusly:

class UserType extends AbstractType {

public function buildForm(FormBuilderInterface $builder, array $options) {
. . .
$builder->add('organizations', MultiSelectType::class, [
'label' => 'Organizations',
'selections' => $options['organizations'],
'entity_field_name' => 'organizations',

])
. . .
}
}


In my Twig Extension, I add a reference (by magic since there is no mention of this in the Symfony doc linked above.)

class CBMSHelperExtension extends \Twig_Extension {

public function getFunctions() {
return array(
new \Twig_SimpleFunction('multiselect_widget', null, [
'node_class' => 'Symfony\Bridge\Twig\Node\RenderBlockNode',
'is_safe' => [ 'html' ]])
);
}


Aaaaand Finaly, My near-complete custom template, copied - as instructed - from existing twig templates:

Resources/views/form/fields.html.twig

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

{% block multi_select_widget %}
{% spaceless %}
<div class="form-group">
{{ form_label(form) }}
<div class="{{ block('form_group_class') }}">
<select {{ block('widget_attributes') }} multiple="multiple">
{%- set options = selections -%}
{% for choice in options %}
<option value="{{ choice.value }}" selected="selected">{{ choice.label }}</option>
{% endfor %}
</select>
</div>
<div class="{{ block('form_group_class') }}">
{% for organization in choices %}
<p>{{ organization.name }}</p>
{% endfor %}
</div>
</div>
{% endspaceless %}
{% endblock %}


(The above was added to config.yml)

The problem now is similar to the one above.

There is an extra "Organizations" label being rendered!! I'll spare you the html dump but basically what it's doing is this:

|| form tag ||
| form group: |
| label - email | input box for email |
| form group: |
| label - organizations | form group |
| | label - organizations |
| | custom template |


It's trying to render the organizations on it's own even though I'm doing everything in my power to tell it not to.

Here is a screenshot from the actual application demonstrating this:
enter image description here

The Question

How can I implement custom form rendering to render either a custom form template from HTML following Symfony's "Best Practices" without kludging, or creating a non-scalable solution?

BONUS QUESTION:
Is it just me or is developing in Symfony like trying to build a Rube Goldberg device?

Answer

I can't answer to the reasons why extra content is being rendered. From the base Twig template form_div_layout.html.twig, there is nothing that indicates the behavior that is happening:

{%- block form_start -%}
    {% set method = method|upper %}
    {%- if method in ["GET", "POST"] -%}
        {% set form_method = method %}
    {%- else -%}
        {% set form_method = "POST" %}
    {%- endif -%}
    <form name="{{ name }}" method="{{ form_method|lower }}"{% if action != '' %} action="{{ action }}"{% endif %}{% for attrname, attrvalue in attr %} {{ attrname }}="{{ attrvalue }}"{% endfor %}{% if multipart %} enctype="multipart/form-data"{% endif %}>
    {%- if form_method != method -%}
        <input type="hidden" name="_method" value="{{ method }}" />
    {%- endif -%}
{%- endblock form_start -%}

Something is getting confused in the rendering. But, by removing this block, only specified form elements will be rendered (all the form_row and form_errors blocks).

So why not extend Solution 1 and add overrides to the form_start and form end methods?

class MyTwigExtension extends \Twig_Extension {

    public function getFunctions() {
        return array(
            new \Twig_SimpleFunction('custom_form_start', [$this, 'customFormStart']),
            new \Twig_SimpleFunction('custom_form_end', [$this, 'customFormEnd']),
            new \Twig_SimpleFunction('multi_select', [$this, 'multiSelect']),
            new \Twig_SimpleFunction('multi_select_js', [$this, 'multiSelectJS'])
        );
    }
 . . .
public function customFormStart(\Symfony\Component\Form\FormView $formView) {
    $formData = $formView->vars;

    $html = '<form name="' . $formData['name'] . '" method="' . $formData['method'] . '"'
        . ' action="' . $formData['action'] . '" class="form-horizontal">';
    return $html;
}
public function customFormEnd(\Symfony\Component\Form\FormView $formView) {
    $formData = $formView->vars;
    $token = $formView->children['_token']->vars;
    $html = '<input id="' . $token['id'] .'" name="' . $token['full_name']
        . '" value="' . $token['value'] . '" type="hidden">';
    return $html;
}

}

This will generate the remaining elements required to submit your form. Should be no different than what should have been generated by Symfony.

This may not fill all the checkboxes for scalibility and Best Practices etc, but it does get data back to the controller.

BONUS QUESTION: No, developing Symfony is not like creating a Rube Goldberg contraption. It's more like building an airplane . . . while it's in the air.