Skip to content

Commit 2be6787

Browse files
committed
feature #38307 [Form] Implement Twig helpers to get field variables (tgalopin)
This PR was merged into the 5.2-dev branch. Discussion ---------- [Form] Implement Twig helpers to get field variables | Q | A | ------------- | --- | Branch? | master | Bug fix? | no | New feature? | yes | Deprecations? | no | Tickets | - | License | MIT | Doc PR | symfony/symfony-docs#14308 Designing Symfony Forms has always been difficult, especially for developers not comfortable with Symfony or Twig. The reason behind this difficulty is that the current `form_*` helper functions, while providing a way to quickly render a form, are hiding the generated HTML behind a notation specific to Symfony. HTML standards introduced many new attributes since the Form component was created, from new constraints to how should inputs be displayed, treated by screen readers, etc. I propose to introduce a series of new Twig functions to help create more flexible forms without the hurdle of having to use `form_*` functions. I called these methods `field_*` because they aim at rendering only the tiny bits of strings necessary to map forms to the Symfony backend. The functions introduced are: * `field_name` returns the name of the given field * `field_value` returns the current value of the given field * `field_label` returns the label of the given field, translated if possible * `field_help` returns the help of the given field, translated if possible * `field_errors` returns an iterator of strings for each of the errors of the given field * `field_choices` returns an iterator of choices (the structure depending on whether the field uses or doesn't use optgroup) with translated labels if possible as keys and values as values A quick example of usage of these functions could be the following: ``` twig <input name="{{ field_name(form.username) }}" value="{{ field_value(form.username) }}" placeholder="{{ field_label(form.username) }}" class="form-control" /> <select name="{{ field_name(form.country) }}" class="form-control"> <option value="">{{ field_label(form.country) }}</option> {% for label, value in field_choices(form.country) %} <option value="{{ value }}">{{ label }}</option> {% endfor %} </select> <select name="{{ field_name(form.stockStatus) }}" class="form-control"> <option value="">{{ field_label(form.stockStatus) }}</option> {% for groupLabel, groupChoices in field_choices(form.stockStatus) %} <optgroup label="{{ groupLabel }}"> {% for label, value in groupChoices %} <option value="{{ value }}">{{ label }}</option> {% endfor %} </optgroup> {% endfor %} </select> {% for error in field_errors(form.country) %} <div class="text-danger mb-2"> {{ error }} </div> {% endfor %} ``` There are several advantages to using these functions instead of their `form_*` equivalents: * they are much easier to use for developers not knowing Symfony: they rely on native HTML with bits of logic inside, instead of relying on specific tools needing to be configured to display proper HTML * they allow for better integration with CSS frameworks or Javascript libraries as adding a new HTML attribute is trivial (no need to look at the documentation) * they are easier to use in contexts where one would like to customize the rendering of a input in details: having the label as placeholder, displaying a select empty field, ... The `form_*` functions are still usable of course, but I'd argue this technique is actually easier to read and understand. Commits ------- 3941d70 [Form] Implement Twig helpers to get field variables
2 parents 534466d + 3941d70 commit 2be6787

File tree

4 files changed

+329
-0
lines changed

4 files changed

+329
-0
lines changed

src/Symfony/Bridge/Twig/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ CHANGELOG
99
* added support for translating `TranslatableInterface` objects
1010
* added the `t()` function to easily create `Translatable` objects
1111
* Added support for extracting messages from the `t()` function
12+
* Added `field_*` Twig functions to access string values from Form fields
1213

1314
5.0.0
1415
-----

src/Symfony/Bridge/Twig/Extension/FormExtension.php

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@
1212
namespace Symfony\Bridge\Twig\Extension;
1313

1414
use Symfony\Bridge\Twig\TokenParser\FormThemeTokenParser;
15+
use Symfony\Component\Form\ChoiceList\View\ChoiceGroupView;
1516
use Symfony\Component\Form\ChoiceList\View\ChoiceView;
17+
use Symfony\Component\Form\FormError;
1618
use Symfony\Component\Form\FormView;
19+
use Symfony\Contracts\Translation\TranslatorInterface;
1720
use Twig\Extension\AbstractExtension;
1821
use Twig\TwigFilter;
1922
use Twig\TwigFunction;
@@ -27,6 +30,13 @@
2730
*/
2831
final class FormExtension extends AbstractExtension
2932
{
33+
private $translator;
34+
35+
public function __construct(TranslatorInterface $translator = null)
36+
{
37+
$this->translator = $translator;
38+
}
39+
3040
/**
3141
* {@inheritdoc}
3242
*/
@@ -55,6 +65,12 @@ public function getFunctions(): array
5565
new TwigFunction('form_end', null, ['node_class' => 'Symfony\Bridge\Twig\Node\RenderBlockNode', 'is_safe' => ['html']]),
5666
new TwigFunction('csrf_token', ['Symfony\Component\Form\FormRenderer', 'renderCsrfToken']),
5767
new TwigFunction('form_parent', 'Symfony\Bridge\Twig\Extension\twig_get_form_parent'),
68+
new TwigFunction('field_name', [$this, 'getFieldName']),
69+
new TwigFunction('field_value', [$this, 'getFieldValue']),
70+
new TwigFunction('field_label', [$this, 'getFieldLabel']),
71+
new TwigFunction('field_help', [$this, 'getFieldHelp']),
72+
new TwigFunction('field_errors', [$this, 'getFieldErrors']),
73+
new TwigFunction('field_choices', [$this, 'getFieldChoices']),
5874
];
5975
}
6076

@@ -79,6 +95,80 @@ public function getTests(): array
7995
new TwigTest('rootform', 'Symfony\Bridge\Twig\Extension\twig_is_root_form'),
8096
];
8197
}
98+
99+
public function getFieldName(FormView $view): string
100+
{
101+
$view->setRendered();
102+
103+
return $view->vars['full_name'];
104+
}
105+
106+
public function getFieldValue(FormView $view): string
107+
{
108+
return $view->vars['value'];
109+
}
110+
111+
public function getFieldLabel(FormView $view): string
112+
{
113+
return $this->createFieldTranslation(
114+
$view->vars['label'],
115+
$view->vars['label_translation_parameters'] ?: [],
116+
$view->vars['translation_domain']
117+
);
118+
}
119+
120+
public function getFieldHelp(FormView $view): string
121+
{
122+
return $this->createFieldTranslation(
123+
$view->vars['help'],
124+
$view->vars['help_translation_parameters'] ?: [],
125+
$view->vars['translation_domain']
126+
);
127+
}
128+
129+
/**
130+
* @return string[]
131+
*/
132+
public function getFieldErrors(FormView $view): iterable
133+
{
134+
/** @var FormError $error */
135+
foreach ($view->vars['errors'] as $error) {
136+
yield $error->getMessage();
137+
}
138+
}
139+
140+
/**
141+
* @return string[]|string[][]
142+
*/
143+
public function getFieldChoices(FormView $view): iterable
144+
{
145+
yield from $this->createFieldChoicesList($view->vars['choices'], $view->vars['choice_translation_domain']);
146+
}
147+
148+
private function createFieldChoicesList(iterable $choices, $translationDomain): iterable
149+
{
150+
foreach ($choices as $choice) {
151+
$translatableLabel = $this->createFieldTranslation($choice->label, [], $translationDomain);
152+
153+
if ($choice instanceof ChoiceGroupView) {
154+
yield $translatableLabel => $this->createFieldChoicesList($choice, $translationDomain);
155+
156+
continue;
157+
}
158+
159+
/* @var ChoiceView $choice */
160+
yield $translatableLabel => $choice->value;
161+
}
162+
}
163+
164+
private function createFieldTranslation(?string $value, array $parameters, $domain): string
165+
{
166+
if (!$this->translator || !$value || false === $domain) {
167+
return $value;
168+
}
169+
170+
return $this->translator->trans($value, $parameters, $domain);
171+
}
82172
}
83173

84174
/**
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Bridge\Twig\Tests\Extension;
13+
14+
use Symfony\Bridge\Twig\Extension\FormExtension;
15+
use Symfony\Bridge\Twig\Tests\Extension\Fixtures\StubTranslator;
16+
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
17+
use Symfony\Component\Form\Extension\Core\Type\FormType;
18+
use Symfony\Component\Form\Extension\Core\Type\TextType;
19+
use Symfony\Component\Form\FormError;
20+
use Symfony\Component\Form\FormView;
21+
use Symfony\Component\Form\Test\FormIntegrationTestCase;
22+
23+
class FormExtensionFieldHelpersTest extends FormIntegrationTestCase
24+
{
25+
/**
26+
* @var FormExtension
27+
*/
28+
private $rawExtension;
29+
30+
/**
31+
* @var FormExtension
32+
*/
33+
private $translatorExtension;
34+
35+
/**
36+
* @var FormView
37+
*/
38+
private $view;
39+
40+
protected function getTypes()
41+
{
42+
return [new TextType(), new ChoiceType()];
43+
}
44+
45+
protected function setUp(): void
46+
{
47+
parent::setUp();
48+
49+
$this->rawExtension = new FormExtension();
50+
$this->translatorExtension = new FormExtension(new StubTranslator());
51+
52+
$form = $this->factory->createNamedBuilder('register', FormType::class, ['username' => 'tgalopin'])
53+
->add('username', TextType::class, [
54+
'label' => 'base.username',
55+
'label_translation_parameters' => ['%label_brand%' => 'Symfony'],
56+
'help' => 'base.username_help',
57+
'help_translation_parameters' => ['%help_brand%' => 'Symfony'],
58+
'translation_domain' => 'forms',
59+
])
60+
->add('choice_flat', ChoiceType::class, [
61+
'choices' => [
62+
'base.yes' => 'yes',
63+
'base.no' => 'no',
64+
],
65+
'choice_translation_domain' => 'forms',
66+
])
67+
->add('choice_grouped', ChoiceType::class, [
68+
'choices' => [
69+
'base.europe' => [
70+
'base.fr' => 'fr',
71+
'base.de' => 'de',
72+
],
73+
'base.asia' => [
74+
'base.cn' => 'cn',
75+
'base.jp' => 'jp',
76+
],
77+
],
78+
'choice_translation_domain' => 'forms',
79+
])
80+
->getForm()
81+
;
82+
83+
$form->get('username')->addError(new FormError('username.max_length'));
84+
85+
$this->view = $form->createView();
86+
}
87+
88+
public function testFieldName()
89+
{
90+
$this->assertFalse($this->view->children['username']->isRendered());
91+
$this->assertSame('register[username]', $this->rawExtension->getFieldName($this->view->children['username']));
92+
$this->assertTrue($this->view->children['username']->isRendered());
93+
}
94+
95+
public function testFieldValue()
96+
{
97+
$this->assertSame('tgalopin', $this->rawExtension->getFieldValue($this->view->children['username']));
98+
}
99+
100+
public function testFieldLabel()
101+
{
102+
$this->assertSame('base.username', $this->rawExtension->getFieldLabel($this->view->children['username']));
103+
}
104+
105+
public function testFieldTranslatedLabel()
106+
{
107+
$this->assertSame('[trans]base.username[/trans]', $this->translatorExtension->getFieldLabel($this->view->children['username']));
108+
}
109+
110+
public function testFieldHelp()
111+
{
112+
$this->assertSame('base.username_help', $this->rawExtension->getFieldHelp($this->view->children['username']));
113+
}
114+
115+
public function testFieldTranslatedHelp()
116+
{
117+
$this->assertSame('[trans]base.username_help[/trans]', $this->translatorExtension->getFieldHelp($this->view->children['username']));
118+
}
119+
120+
public function testFieldErrors()
121+
{
122+
$errors = $this->rawExtension->getFieldErrors($this->view->children['username']);
123+
$this->assertSame(['username.max_length'], iterator_to_array($errors));
124+
}
125+
126+
public function testFieldTranslatedErrors()
127+
{
128+
$errors = $this->translatorExtension->getFieldErrors($this->view->children['username']);
129+
$this->assertSame(['username.max_length'], iterator_to_array($errors));
130+
}
131+
132+
public function testFieldChoicesFlat()
133+
{
134+
$choices = $this->rawExtension->getFieldChoices($this->view->children['choice_flat']);
135+
136+
$choicesArray = [];
137+
foreach ($choices as $label => $value) {
138+
$choicesArray[] = ['label' => $label, 'value' => $value];
139+
}
140+
141+
$this->assertCount(2, $choicesArray);
142+
143+
$this->assertSame('yes', $choicesArray[0]['value']);
144+
$this->assertSame('base.yes', $choicesArray[0]['label']);
145+
146+
$this->assertSame('no', $choicesArray[1]['value']);
147+
$this->assertSame('base.no', $choicesArray[1]['label']);
148+
}
149+
150+
public function testFieldTranslatedChoicesFlat()
151+
{
152+
$choices = $this->translatorExtension->getFieldChoices($this->view->children['choice_flat']);
153+
154+
$choicesArray = [];
155+
foreach ($choices as $label => $value) {
156+
$choicesArray[] = ['label' => $label, 'value' => $value];
157+
}
158+
159+
$this->assertCount(2, $choicesArray);
160+
161+
$this->assertSame('yes', $choicesArray[0]['value']);
162+
$this->assertSame('[trans]base.yes[/trans]', $choicesArray[0]['label']);
163+
164+
$this->assertSame('no', $choicesArray[1]['value']);
165+
$this->assertSame('[trans]base.no[/trans]', $choicesArray[1]['label']);
166+
}
167+
168+
public function testFieldChoicesGrouped()
169+
{
170+
$choices = $this->rawExtension->getFieldChoices($this->view->children['choice_grouped']);
171+
172+
$choicesArray = [];
173+
foreach ($choices as $groupLabel => $groupChoices) {
174+
$groupChoicesArray = [];
175+
foreach ($groupChoices as $label => $value) {
176+
$groupChoicesArray[] = ['label' => $label, 'value' => $value];
177+
}
178+
179+
$choicesArray[] = ['label' => $groupLabel, 'choices' => $groupChoicesArray];
180+
}
181+
182+
$this->assertCount(2, $choicesArray);
183+
184+
$this->assertCount(2, $choicesArray[0]['choices']);
185+
$this->assertSame('base.europe', $choicesArray[0]['label']);
186+
187+
$this->assertSame('fr', $choicesArray[0]['choices'][0]['value']);
188+
$this->assertSame('base.fr', $choicesArray[0]['choices'][0]['label']);
189+
190+
$this->assertSame('de', $choicesArray[0]['choices'][1]['value']);
191+
$this->assertSame('base.de', $choicesArray[0]['choices'][1]['label']);
192+
193+
$this->assertCount(2, $choicesArray[1]['choices']);
194+
$this->assertSame('base.asia', $choicesArray[1]['label']);
195+
196+
$this->assertSame('cn', $choicesArray[1]['choices'][0]['value']);
197+
$this->assertSame('base.cn', $choicesArray[1]['choices'][0]['label']);
198+
199+
$this->assertSame('jp', $choicesArray[1]['choices'][1]['value']);
200+
$this->assertSame('base.jp', $choicesArray[1]['choices'][1]['label']);
201+
}
202+
203+
public function testFieldTranslatedChoicesGrouped()
204+
{
205+
$choices = $this->translatorExtension->getFieldChoices($this->view->children['choice_grouped']);
206+
207+
$choicesArray = [];
208+
foreach ($choices as $groupLabel => $groupChoices) {
209+
$groupChoicesArray = [];
210+
foreach ($groupChoices as $label => $value) {
211+
$groupChoicesArray[] = ['label' => $label, 'value' => $value];
212+
}
213+
214+
$choicesArray[] = ['label' => $groupLabel, 'choices' => $groupChoicesArray];
215+
}
216+
217+
$this->assertCount(2, $choicesArray);
218+
219+
$this->assertCount(2, $choicesArray[0]['choices']);
220+
$this->assertSame('[trans]base.europe[/trans]', $choicesArray[0]['label']);
221+
222+
$this->assertSame('fr', $choicesArray[0]['choices'][0]['value']);
223+
$this->assertSame('[trans]base.fr[/trans]', $choicesArray[0]['choices'][0]['label']);
224+
225+
$this->assertSame('de', $choicesArray[0]['choices'][1]['value']);
226+
$this->assertSame('[trans]base.de[/trans]', $choicesArray[0]['choices'][1]['label']);
227+
228+
$this->assertCount(2, $choicesArray[1]['choices']);
229+
$this->assertSame('[trans]base.asia[/trans]', $choicesArray[1]['label']);
230+
231+
$this->assertSame('cn', $choicesArray[1]['choices'][0]['value']);
232+
$this->assertSame('[trans]base.cn[/trans]', $choicesArray[1]['choices'][0]['label']);
233+
234+
$this->assertSame('jp', $choicesArray[1]['choices'][1]['value']);
235+
$this->assertSame('[trans]base.jp[/trans]', $choicesArray[1]['choices'][1]['label']);
236+
}
237+
}

src/Symfony/Bundle/TwigBundle/Resources/config/form.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
return static function (ContainerConfigurator $container) {
1919
$container->services()
2020
->set('twig.extension.form', FormExtension::class)
21+
->args([service('translator')->nullOnInvalid()])
2122

2223
->set('twig.form.engine', TwigRendererEngine::class)
2324
->args([param('twig.form.resources'), service('twig')])

0 commit comments

Comments
 (0)