Skip to content

Commit ba6c670

Browse files
committed
feature #109 Introduce stimulus_controller to ease Stimulus Values API usage (tgalopin)
This PR was merged into the main branch. Discussion ---------- Introduce stimulus_controller to ease Stimulus Values API usage This PR introduces a `stimulus_controller` helper to ease the usage of [Stimulus Values API](https://stimulus.hotwire.dev/reference/values). The Values API is very useful as it allows developers to store state on the DOM node and thus persist it, share it across controllers and build reusable controllers. Using the Values API as a way to configure a controller using data attributes is a very common pattern that we should encourage ("build primitive reusable and composable controllers instead of building a controller per page"). However it has a few drawbacks: 1/ It introduces a duplication of the scoped attributes: ```html <div data-controller="symfony--ux-chartjs-chart" data-symfony--ux-chartjs-chart-data-value="..." data-symfony--ux-chartjs-chart-options-value="..." ....> </div> ``` This can quickly become cumbersome and difficult to track. 2/ It creates its own convention on naming, if you declare a value using camel case, it will need to be passed using kebab case in the HTML: ```javascript export default class extends Controller { static values = { myMessage: String, } connect() { this.element.textContent = this.myMessageValue; } } ``` ```html <div data-controller="hello" data-hello-my-message-value="Hello"></div> ``` 3/ It requires to manually encode nested structure in JSON in order to pass them to the controller This PR improves upon these drawbacks: it introduces a helper you can use in your DOM node to render the controller to use, with optional values for this controller. These values will be encoded properly to be read by Stimulus natively (https://stimulus.hotwire.dev/reference/values#properties-and-attributes). With a single controller: ```twig <div {{ stimulus_controller({ 'chart': { 'data': [1, 2, 3, 4] } }) }}> Hello </div> ``` With multiple controllers: ```twig <div {{ stimulus_controller({ 'chart': { 'data': [1, 2, 3, 4], 'labels': ['January', 'February', 'March', 'April'], }, 'another-controller': {} }) }}> Hello </div> ``` It introduces a "standard" way to transfer data to Stimulus controllers that map well with the Values API and thus work out of the box. Commits ------- cfc0e50 Introduce stimulus_controller to ease Stimulus Values API usage
2 parents 4dc8ecb + cfc0e50 commit ba6c670

File tree

3 files changed

+160
-0
lines changed

3 files changed

+160
-0
lines changed

src/Resources/config/services.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@
3636
</argument>
3737
</service>
3838

39+
<service id="webpack_encore.twig_stimulus_extension" class="Symfony\WebpackEncoreBundle\Twig\StimulusTwigExtension">
40+
<tag name="twig.extension" />
41+
</service>
42+
3943
<service id="webpack_encore.entrypoint_lookup.cache_warmer" class="Symfony\WebpackEncoreBundle\CacheWarmer\EntrypointCacheWarmer">
4044
<tag name="kernel.cache_warmer" />
4145
<argument /> <!-- build list of entrypoint paths -->

src/Twig/StimulusTwigExtension.php

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony WebpackEncoreBundle package.
5+
* (c) Fabien Potencier <[email protected]>
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
namespace Symfony\WebpackEncoreBundle\Twig;
11+
12+
use Twig\Environment;
13+
use Twig\Extension\AbstractExtension;
14+
use Twig\TwigFunction;
15+
16+
final class StimulusTwigExtension extends AbstractExtension
17+
{
18+
public function getFunctions()
19+
{
20+
return [
21+
new TwigFunction('stimulus_controller', [$this, 'renderStimulusController'], ['needs_environment' => true, 'is_safe' => ['all']]),
22+
];
23+
}
24+
25+
public function renderStimulusController(Environment $env, array $data): string
26+
{
27+
if (!$data) {
28+
return '';
29+
}
30+
31+
$controllers = [];
32+
$values = [];
33+
34+
foreach ($data as $controllerName => $controllerValues) {
35+
$controllerName = twig_escape_filter($env, $this->normalizeControllerName($controllerName), 'html_attr');
36+
$controllers[] = $controllerName;
37+
38+
foreach ($controllerValues as $key => $value) {
39+
if (!is_scalar($value)) {
40+
$value = json_encode($value);
41+
}
42+
43+
$key = twig_escape_filter($env, $this->normalizeKeyName($key), 'html_attr');
44+
$value = twig_escape_filter($env, $value, 'html_attr');
45+
46+
$values[] = 'data-'.$controllerName.'-'.$key.'-value="'.$value.'"';
47+
}
48+
}
49+
50+
return rtrim('data-controller="'.implode(' ', $controllers).'" '.implode(' ', $values));
51+
}
52+
53+
/**
54+
* Normalize a Stimulus controller name into its HTML equivalent (no special character and / becomes --).
55+
*
56+
* @see https://stimulus.hotwire.dev/reference/controllers
57+
*/
58+
private function normalizeControllerName(string $str): string
59+
{
60+
return preg_replace('/^@/', '', str_replace('_', '-', str_replace('/', '--', $str)));
61+
}
62+
63+
/**
64+
* Normalize a Stimulus Value API key into its HTML equivalent ("kebab case").
65+
* Backport features from symfony/string.
66+
*
67+
* @see https://stimulus.hotwire.dev/reference/values
68+
*/
69+
private function normalizeKeyName(string $str): string
70+
{
71+
// Adapted from ByteString::camel
72+
$str = ucfirst(str_replace(' ', '', ucwords(preg_replace('/[^a-zA-Z0-9\x7f-\xff]++/', ' ', $str))));
73+
74+
// Adapted from ByteString::snake
75+
return strtolower(preg_replace(['/([A-Z]+)([A-Z][a-z])/', '/([a-z\d])([A-Z])/'], '\1-\2', $str));
76+
}
77+
}

tests/IntegrationTest.php

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
use Symfony\WebpackEncoreBundle\Asset\EntrypointLookupInterface;
2929
use Symfony\WebpackEncoreBundle\Asset\TagRenderer;
3030
use Symfony\WebpackEncoreBundle\CacheWarmer\EntrypointCacheWarmer;
31+
use Symfony\WebpackEncoreBundle\Twig\StimulusTwigExtension;
3132
use Symfony\WebpackEncoreBundle\WebpackEncoreBundle;
3233

3334
class IntegrationTest extends TestCase
@@ -178,6 +179,84 @@ public function testAutowireDefaultBuildArgument()
178179
$this->assertTrue(true);
179180
}
180181

182+
public function provideRenderStimulusController()
183+
{
184+
yield 'empty' => [
185+
'data' => [],
186+
'expected' => '',
187+
];
188+
189+
yield 'single-controller-no-data' => [
190+
'data' => [
191+
'my-controller' => [],
192+
],
193+
'expected' => 'data-controller="my-controller"',
194+
];
195+
196+
yield 'single-controller-scalar-data' => [
197+
'data' => [
198+
'my-controller' => [
199+
'myValue' => 'scalar-value',
200+
],
201+
],
202+
'expected' => 'data-controller="my-controller" data-my-controller-my-value-value="scalar-value"',
203+
];
204+
205+
yield 'single-controller-typed-data' => [
206+
'data' => [
207+
'my-controller' => [
208+
'boolean' => true,
209+
'number' => 4,
210+
'string' => 'str',
211+
],
212+
],
213+
'expected' => 'data-controller="my-controller" data-my-controller-boolean-value="1" data-my-controller-number-value="4" data-my-controller-string-value="str"',
214+
];
215+
216+
yield 'single-controller-nested-data' => [
217+
'data' => [
218+
'my-controller' => [
219+
'myValue' => ['nested' => 'array'],
220+
],
221+
],
222+
'expected' => 'data-controller="my-controller" data-my-controller-my-value-value="&#x7B;&quot;nested&quot;&#x3A;&quot;array&quot;&#x7D;"',
223+
];
224+
225+
yield 'multiple-controllers-scalar-data' => [
226+
'data' => [
227+
'my-controller' => [
228+
'myValue' => 'scalar-value',
229+
],
230+
'another-controller' => [
231+
'anotherValue' => 'scalar-value 2',
232+
],
233+
],
234+
'expected' => 'data-controller="my-controller another-controller" data-my-controller-my-value-value="scalar-value" data-another-controller-another-value-value="scalar-value&#x20;2"',
235+
];
236+
237+
yield 'normalize-names' => [
238+
'data' => [
239+
'@symfony/ux-dropzone/dropzone' => [
240+
'my"Key"' => true,
241+
],
242+
],
243+
'expected' => 'data-controller="symfony--ux-dropzone--dropzone" data-symfony--ux-dropzone--dropzone-my-key-value="1"',
244+
];
245+
}
246+
247+
/**
248+
* @dataProvider provideRenderStimulusController
249+
*/
250+
public function testRenderStimulusController(array $data, string $expected)
251+
{
252+
$kernel = new WebpackEncoreIntegrationTestKernel(true);
253+
$kernel->boot();
254+
$twig = $this->getTwigEnvironmentFromBootedKernel($kernel);
255+
256+
$extension = new StimulusTwigExtension();
257+
$this->assertSame($expected, $extension->renderStimulusController($twig, $data));
258+
}
259+
181260
private function getContainerFromBootedKernel(WebpackEncoreIntegrationTestKernel $kernel)
182261
{
183262
if ($kernel::VERSION_ID >= 40100) {

0 commit comments

Comments
 (0)