Skip to content

Commit 16d6f67

Browse files
committed
feature #124 feat(twig): implements stimulus_action() and stimulus_target() Twig functions, close #119 (Kocal)
This PR was squashed before being merged into the main branch. Discussion ---------- feat(twig): implements stimulus_action() and stimulus_target() Twig functions, close #119 Hi 👋 This PR is a proposal for #119, which adds `stimulus_action()` and `stimulus_target()` Twig functions. `stimulus_action()` can be a bit hard to use because it supports many syntaxes, since Stimulus allows to use multiple actions in a single `data-action` attribute and we should takes care of the default event: - `stimulus_action('controller', 'method')` - a custom event: `stimulus_action('controller', 'method', 'event)` - multiple controllers/actions: ```twig {{ stimulus_action({ 'controller': 'method', 'controller-2': ['method', 'method2'], 'controller-3': {'click': 'onClick'}, 'controller-4': ['method', {'click': 'onClick'}, {'window@resize': 'onWindowResize'}], }) }} ``` For `stimulus_target`: - `stimulus_target('controller', 'target')` - `stimulus_target('controller', 'target1 target2')`, Stimulus allows multiple targets - `stimulus_target({ 'controller1': 'target', 'controller2': 'target2 target3' })`, array syntax Usage: ```twig <div {{ stimulus_controller('foo') }}> <div {{ stimulus_target('foo', 'myTarget') }}></div> <!-- <div data-foo-target="myTarget"></div> --> <div {{ stimulus_target('foo', 'myTarget mySecondTarget') }}></div> <!-- <div data-foo-target="myTarget mySecondTarget"></div> --> <div {{ stimulus_target({ 'foo': 'myTarget', 'bar': 'anotherTarget' }) }}></div> <!-- <div data-foo-target="myTarget" data-bar-target="anotherTarget"></div> --> {# Listen to default event and call "onClick" #} <a {{ stimulus_action('foo', 'onClick') }}>A link</a> <!-- <div data-action="foo#onClick">A link</a> --> {# Listen to event "click" and call "onClick" #} <a {{ stimulus_action('foo', 'onClick', 'click') }}>A Link</a> <!-- <div data-action="click->foo#onClick">A link</a> --> {# Listen to default event, call "onClick" from "foo" controller and "onClick" + "onSomethingElse" from "bar" controller #} <a {{ stimulus_action({ 'foo': 'onClick', 'bar': ['onClick', 'onSomethingElse'] }) }}>A link</a> <!-- <div data-action="click->foo#onClick bar#onClick bar#onSomethingElse">A div</div> --> {# It is possible to pass a map, here "bar#onWindowResize" will be called on "window@resize" #} <div {{ stimulus_action({ 'foo': 'onClick', 'bar': ['onClick', {'window@resize': 'onWindowResize'}] }) }}>A div</a> <!-- <div data-action="foo#onClick bar#onClick window@resize->bar#onWindowResize">A div</div> --> </div> ``` WDYT? Thanks! --- For #119 (comment), I think it should belongs to another PR. Commits ------- c022b08 feat(twig): implements stimulus_action() and stimulus_target() Twig functions, close #119
2 parents f282fb1 + c022b08 commit 16d6f67

File tree

2 files changed

+216
-1
lines changed

2 files changed

+216
-1
lines changed

src/Twig/StimulusTwigExtension.php

Lines changed: 100 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ public function getFunctions()
1919
{
2020
return [
2121
new TwigFunction('stimulus_controller', [$this, 'renderStimulusController'], ['needs_environment' => true, 'is_safe' => ['html_attr']]),
22+
new TwigFunction('stimulus_action', [$this, 'renderStimulusAction'], ['needs_environment' => true, 'is_safe' => ['html_attr']]),
23+
new TwigFunction('stimulus_target', [$this, 'renderStimulusTarget'], ['needs_environment' => true, 'is_safe' => ['html_attr']]),
2224
];
2325
}
2426

@@ -27,7 +29,7 @@ public function getFunctions()
2729
* as keys set to their "values". Or this
2830
* can be a string controller name and data
2931
* is passed as the 2nd argument.
30-
* @param array $controllerValues Array of data if a string is passed to the first argument.
32+
* @param array $controllerValues Array of data if a string is passed to the 1st argument.
3133
* @return string
3234
* @throws \Twig\Error\RuntimeError
3335
*/
@@ -73,6 +75,103 @@ public function renderStimulusController(Environment $env, $dataOrControllerName
7375
return rtrim('data-controller="'.implode(' ', $controllers).'" '.implode(' ', $values));
7476
}
7577

78+
/**
79+
* @param string|array $dataOrControllerName This can either be a map of controller names
80+
* as keys set to their "actions" and "events".
81+
* Or this can be a string controller name and
82+
* action and event are passed as the 2nd and 3rd arguments.
83+
* @param string|null $actionName The action to trigger if a string is passed to the 1st argument. Optional.
84+
* @param string|null $eventName The event to listen to trigger if a string is passed to the 1st argument. Optional.
85+
*
86+
* @return string
87+
* @throws \Twig\Error\RuntimeError
88+
*/
89+
public function renderStimulusAction(Environment $env, $dataOrControllerName, string $actionName = null, string $eventName = null): string
90+
{
91+
if (is_string($dataOrControllerName)) {
92+
$data = [$dataOrControllerName => $eventName === null ? [[$actionName]] : [[$eventName => $actionName]]];
93+
} else {
94+
if ($actionName || $eventName) {
95+
throw new \InvalidArgumentException('You cannot pass a string to the second or third argument while passing an array to the first argument of stimulus_action(): check the documentation.');
96+
}
97+
98+
$data = $dataOrControllerName;
99+
100+
if (!$data) {
101+
return '';
102+
}
103+
}
104+
105+
$actions = [];
106+
107+
foreach ($data as $controllerName => $controllerActions) {
108+
$controllerName = twig_escape_filter($env, $this->normalizeControllerName($controllerName), 'html_attr');
109+
110+
if (is_string($controllerActions)) {
111+
$controllerActions = [[$controllerActions]];
112+
}
113+
114+
foreach ($controllerActions as $possibleEventName => $controllerAction) {
115+
if (is_string($possibleEventName) && is_string($controllerAction)) {
116+
$controllerAction = [$possibleEventName => $controllerAction];
117+
} else if (is_string($controllerAction)) {
118+
$controllerAction = [$controllerAction];
119+
}
120+
121+
foreach ($controllerAction as $eventName => $actionName) {
122+
$action = $controllerName.'#'.twig_escape_filter($env, $actionName, 'html_attr');
123+
124+
if (is_string($eventName)) {
125+
$action = $eventName.'->'.$action;
126+
}
127+
128+
$actions[] = $action;
129+
}
130+
}
131+
}
132+
133+
return 'data-action="'.implode(' ', $actions).'"';
134+
}
135+
136+
/**
137+
* @param string|array $dataOrControllerName This can either be a map of controller names
138+
* as keys set to their "targets". Or this can
139+
* be a string controller name and targets are
140+
* passed as the 2nd argument.
141+
* @param string|null $targetNames The space-separated list of target names if a string is passed to the 1st argument. Optional.
142+
*
143+
* @return string
144+
* @throws \Twig\Error\RuntimeError
145+
*/
146+
public function renderStimulusTarget(Environment $env, $dataOrControllerName, string $targetNames = null): string
147+
{
148+
if (is_string($dataOrControllerName)) {
149+
$data = [$dataOrControllerName => $targetNames];
150+
} else {
151+
if ($targetNames) {
152+
throw new \InvalidArgumentException('You cannot pass a string to the second argument while passing an array to the first argument of stimulus_target(): check the documentation.');
153+
}
154+
155+
$data = $dataOrControllerName;
156+
157+
if (!$data) {
158+
return '';
159+
}
160+
}
161+
162+
$targets = [];
163+
164+
foreach ($data as $controllerName => $targetNames) {
165+
$controllerName = twig_escape_filter($env, $this->normalizeControllerName($controllerName), 'html_attr');
166+
167+
$targets['data-'.$controllerName.'-target'] = twig_escape_filter($env, $targetNames, 'html_attr');
168+
}
169+
170+
return implode(' ', array_map(function(string $attribute, string $value) {
171+
return $attribute.'="'.$value.'"';
172+
}, array_keys($targets), $targets));
173+
}
174+
76175
/**
77176
* Normalize a Stimulus controller name into its HTML equivalent (no special character and / becomes --).
78177
*

tests/IntegrationTest.php

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,122 @@ public function testRenderStimulusController($dataOrControllerName, array $contr
288288
$this->assertSame($expected, $extension->renderStimulusController($twig, $dataOrControllerName, $controllerValues));
289289
}
290290

291+
public function provideRenderStimulusAction()
292+
{
293+
yield 'with default event' => [
294+
'dataOrControllerName' => 'my-controller',
295+
'actionName' => 'onClick',
296+
'eventName' => null,
297+
'expected' => 'data-action="my-controller#onClick"',
298+
];
299+
300+
yield 'with custom event' => [
301+
'dataOrControllerName' => 'my-controller',
302+
'actionName' => 'onClick',
303+
'eventName' => 'click',
304+
'expected' => 'data-action="click->my-controller#onClick"',
305+
];
306+
307+
yield 'multiple actions, with default event' => [
308+
'dataOrControllerName' => [
309+
'my-controller' => 'onClick',
310+
'my-second-controller' => ['onClick', 'onSomethingElse'],
311+
'foo/bar-controller' => 'onClick'
312+
],
313+
'actionName' => null,
314+
'eventName' => null,
315+
'expected' => 'data-action="my-controller#onClick my-second-controller#onClick my-second-controller#onSomethingElse foo--bar-controller#onClick"',
316+
];
317+
318+
yield 'multiple actions, with custom event' => [
319+
'dataOrControllerName' => [
320+
'my-controller' => ['click' => 'onClick'],
321+
'my-second-controller' => [['click' => 'onClick'], ['change' => 'onSomethingElse']],
322+
'resize-controller' => ['resize@window' => 'onWindowResize'],
323+
'foo/bar-controller' => ['click' => 'onClick']
324+
],
325+
'actionName' => null,
326+
'eventName' => null,
327+
'expected' => 'data-action="click->my-controller#onClick click->my-second-controller#onClick change->my-second-controller#onSomethingElse resize@window->resize-controller#onWindowResize click->foo--bar-controller#onClick"',
328+
];
329+
330+
yield 'multiple actions, with default and custom event' => [
331+
'dataOrControllerName' => [
332+
'my-controller' => ['click' => 'onClick'],
333+
'my-second-controller' => ['onClick', ['click' => 'onAnotherClick'], ['change' => 'onSomethingElse']],
334+
'resize-controller' => ['resize@window' => 'onWindowResize'],
335+
'foo/bar-controller' => ['click' => 'onClick']
336+
],
337+
'actionName' => null,
338+
'eventName' => null,
339+
'expected' => 'data-action="click->my-controller#onClick my-second-controller#onClick click->my-second-controller#onAnotherClick change->my-second-controller#onSomethingElse resize@window->resize-controller#onWindowResize click->foo--bar-controller#onClick"',
340+
];
341+
342+
yield 'normalize-name, with default event' => [
343+
'dataOrControllerName' => '@symfony/ux-dropzone/dropzone',
344+
'actionName' => 'onClick',
345+
'eventName' => null,
346+
'expected' => 'data-action="symfony--ux-dropzone--dropzone#onClick"',
347+
];
348+
349+
yield 'normalize-name, with custom event' => [
350+
'dataOrControllerName' => '@symfony/ux-dropzone/dropzone',
351+
'actionName' => 'onClick',
352+
'eventName' => 'click',
353+
'expected' => 'data-action="click->symfony--ux-dropzone--dropzone#onClick"',
354+
];
355+
}
356+
357+
/**
358+
* @dataProvider provideRenderStimulusAction
359+
*/
360+
public function testRenderStimulusAction($dataOrControllerName, ?string $actionName, ?string $eventName, string $expected)
361+
{
362+
$kernel = new WebpackEncoreIntegrationTestKernel(true);
363+
$kernel->boot();
364+
$twig = $this->getTwigEnvironmentFromBootedKernel($kernel);
365+
366+
$extension = new StimulusTwigExtension();
367+
$this->assertSame($expected, $extension->renderStimulusAction($twig, $dataOrControllerName, $actionName, $eventName));
368+
}
369+
370+
public function provideRenderStimulusTarget()
371+
{
372+
yield 'simple' => [
373+
'dataOrControllerName' => 'my-controller',
374+
'targetName' => 'myTarget',
375+
'expected' => 'data-my-controller-target="myTarget"',
376+
];
377+
378+
yield 'normalize-name' => [
379+
'dataOrControllerName' => '@symfony/ux-dropzone/dropzone',
380+
'targetName' => 'myTarget',
381+
'expected' => 'data-symfony--ux-dropzone--dropzone-target="myTarget"',
382+
];
383+
384+
yield 'multiple' => [
385+
'dataOrControllerName' => [
386+
'my-controller' => 'myTarget',
387+
'@symfony/ux-dropzone/dropzone' => 'anotherTarget fooTarget',
388+
],
389+
'targetName' => null,
390+
'expected' => 'data-my-controller-target="myTarget" data-symfony--ux-dropzone--dropzone-target="anotherTarget&#x20;fooTarget"',
391+
];
392+
}
393+
394+
/**
395+
* @dataProvider provideRenderStimulusTarget
396+
*/
397+
public function testRenderStimulusTarget($dataOrControllerName, ?string $targetName, string $expected)
398+
{
399+
$kernel = new WebpackEncoreIntegrationTestKernel(true);
400+
$kernel->boot();
401+
$twig = $this->getTwigEnvironmentFromBootedKernel($kernel);
402+
403+
$extension = new StimulusTwigExtension();
404+
$this->assertSame($expected, $extension->renderStimulusTarget($twig, $dataOrControllerName, $targetName));
405+
}
406+
291407
private function getContainerFromBootedKernel(WebpackEncoreIntegrationTestKernel $kernel)
292408
{
293409
if ($kernel::VERSION_ID >= 40100) {

0 commit comments

Comments
 (0)