Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 2be6787

Browse filesBrowse 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
Copy full SHA for 2be6787

File tree

4 files changed

+329
-0
lines changed
Filter options

4 files changed

+329
-0
lines changed

‎src/Symfony/Bridge/Twig/CHANGELOG.md

Copy file name to clipboardExpand all lines: src/Symfony/Bridge/Twig/CHANGELOG.md
+1Lines 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

Copy file name to clipboardExpand all lines: src/Symfony/Bridge/Twig/Extension/FormExtension.php
+90Lines 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
/**
+237Lines 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

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/TwigBundle/Resources/config/form.php
+1Lines 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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.