Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 100 additions & 1 deletion src/Twig/StimulusTwigExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ public function getFunctions()
{
return [
new TwigFunction('stimulus_controller', [$this, 'renderStimulusController'], ['needs_environment' => true, 'is_safe' => ['html_attr']]),
new TwigFunction('stimulus_action', [$this, 'renderStimulusAction'], ['needs_environment' => true, 'is_safe' => ['html_attr']]),
new TwigFunction('stimulus_target', [$this, 'renderStimulusTarget'], ['needs_environment' => true, 'is_safe' => ['html_attr']]),
];
}

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

/**
* @param string|array $dataOrControllerName This can either be a map of controller names
* as keys set to their "actions" and "events".
* Or this can be a string controller name and
* action and event are passed as the 2nd and 3rd arguments.
* @param string|null $actionName The action to trigger if a string is passed to the 1st argument. Optional.
* @param string|null $eventName The event to listen to trigger if a string is passed to the 1st argument. Optional.
*
* @return string
* @throws \Twig\Error\RuntimeError
*/
public function renderStimulusAction(Environment $env, $dataOrControllerName, string $actionName = null, string $eventName = null): string
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't forget to expose these new functions up in getFunctions() ;)

Copy link
Member Author

@Kocal Kocal May 6, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OMG yes 😱

I totally forgot about them, usually I write tests for Twig Extensions by creating a fake loader and doing assertions on $twig->render() This way I can test the extension and its integration with Twig.

Thanks! 😄

{
if (is_string($dataOrControllerName)) {
$data = [$dataOrControllerName => $eventName === null ? [[$actionName]] : [[$eventName => $actionName]]];
} else {
if ($actionName || $eventName) {
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.');
}

$data = $dataOrControllerName;

if (!$data) {
return '';
}
}

$actions = [];

foreach ($data as $controllerName => $controllerActions) {
$controllerName = twig_escape_filter($env, $this->normalizeControllerName($controllerName), 'html_attr');

if (is_string($controllerActions)) {
$controllerActions = [[$controllerActions]];
}

foreach ($controllerActions as $possibleEventName => $controllerAction) {
if (is_string($possibleEventName) && is_string($controllerAction)) {
$controllerAction = [$possibleEventName => $controllerAction];
} else if (is_string($controllerAction)) {
$controllerAction = [$controllerAction];
}

foreach ($controllerAction as $eventName => $actionName) {
$action = $controllerName.'#'.twig_escape_filter($env, $actionName, 'html_attr');

if (is_string($eventName)) {
$action = $eventName.'->'.$action;
}

$actions[] = $action;
}
}
}

return 'data-action="'.implode(' ', $actions).'"';
}

/**
* @param string|array $dataOrControllerName This can either be a map of controller names
* as keys set to their "targets". Or this can
* be a string controller name and targets are
* passed as the 2nd argument.
* @param string|null $targetNames The space-separated list of target names if a string is passed to the 1st argument. Optional.
*
* @return string
* @throws \Twig\Error\RuntimeError
*/
public function renderStimulusTarget(Environment $env, $dataOrControllerName, string $targetNames = null): string
{
if (is_string($dataOrControllerName)) {
$data = [$dataOrControllerName => $targetNames];
} else {
if ($targetNames) {
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.');
}

$data = $dataOrControllerName;

if (!$data) {
return '';
}
}

$targets = [];

foreach ($data as $controllerName => $targetNames) {
$controllerName = twig_escape_filter($env, $this->normalizeControllerName($controllerName), 'html_attr');

$targets['data-'.$controllerName.'-target'] = twig_escape_filter($env, $targetNames, 'html_attr');
}

return implode(' ', array_map(function(string $attribute, string $value) {
return $attribute.'="'.$value.'"';
}, array_keys($targets), $targets));
}

/**
* Normalize a Stimulus controller name into its HTML equivalent (no special character and / becomes --).
*
Expand Down
116 changes: 116 additions & 0 deletions tests/IntegrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,122 @@ public function testRenderStimulusController($dataOrControllerName, array $contr
$this->assertSame($expected, $extension->renderStimulusController($twig, $dataOrControllerName, $controllerValues));
}

public function provideRenderStimulusAction()
{
yield 'with default event' => [
'dataOrControllerName' => 'my-controller',
'actionName' => 'onClick',
'eventName' => null,
'expected' => 'data-action="my-controller#onClick"',
];

yield 'with custom event' => [
'dataOrControllerName' => 'my-controller',
'actionName' => 'onClick',
'eventName' => 'click',
'expected' => 'data-action="click->my-controller#onClick"',
];

yield 'multiple actions, with default event' => [
'dataOrControllerName' => [
'my-controller' => 'onClick',
'my-second-controller' => ['onClick', 'onSomethingElse'],
'foo/bar-controller' => 'onClick'
],
'actionName' => null,
'eventName' => null,
'expected' => 'data-action="my-controller#onClick my-second-controller#onClick my-second-controller#onSomethingElse foo--bar-controller#onClick"',
];

yield 'multiple actions, with custom event' => [
'dataOrControllerName' => [
'my-controller' => ['click' => 'onClick'],
'my-second-controller' => [['click' => 'onClick'], ['change' => 'onSomethingElse']],
'resize-controller' => ['resize@window' => 'onWindowResize'],
'foo/bar-controller' => ['click' => 'onClick']
],
'actionName' => null,
'eventName' => null,
'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"',
];

yield 'multiple actions, with default and custom event' => [
'dataOrControllerName' => [
'my-controller' => ['click' => 'onClick'],
'my-second-controller' => ['onClick', ['click' => 'onAnotherClick'], ['change' => 'onSomethingElse']],
'resize-controller' => ['resize@window' => 'onWindowResize'],
'foo/bar-controller' => ['click' => 'onClick']
],
'actionName' => null,
'eventName' => null,
'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"',
];

yield 'normalize-name, with default event' => [
'dataOrControllerName' => '@symfony/ux-dropzone/dropzone',
'actionName' => 'onClick',
'eventName' => null,
'expected' => 'data-action="symfony--ux-dropzone--dropzone#onClick"',
];

yield 'normalize-name, with custom event' => [
'dataOrControllerName' => '@symfony/ux-dropzone/dropzone',
'actionName' => 'onClick',
'eventName' => 'click',
'expected' => 'data-action="click->symfony--ux-dropzone--dropzone#onClick"',
];
}

/**
* @dataProvider provideRenderStimulusAction
*/
public function testRenderStimulusAction($dataOrControllerName, ?string $actionName, ?string $eventName, string $expected)
{
$kernel = new WebpackEncoreIntegrationTestKernel(true);
$kernel->boot();
$twig = $this->getTwigEnvironmentFromBootedKernel($kernel);

$extension = new StimulusTwigExtension();
$this->assertSame($expected, $extension->renderStimulusAction($twig, $dataOrControllerName, $actionName, $eventName));
}

public function provideRenderStimulusTarget()
{
yield 'simple' => [
'dataOrControllerName' => 'my-controller',
'targetName' => 'myTarget',
'expected' => 'data-my-controller-target="myTarget"',
];

yield 'normalize-name' => [
'dataOrControllerName' => '@symfony/ux-dropzone/dropzone',
'targetName' => 'myTarget',
'expected' => 'data-symfony--ux-dropzone--dropzone-target="myTarget"',
];

yield 'multiple' => [
'dataOrControllerName' => [
'my-controller' => 'myTarget',
'@symfony/ux-dropzone/dropzone' => 'anotherTarget fooTarget',
],
'targetName' => null,
'expected' => 'data-my-controller-target="myTarget" data-symfony--ux-dropzone--dropzone-target="anotherTarget fooTarget"',
];
}

/**
* @dataProvider provideRenderStimulusTarget
*/
public function testRenderStimulusTarget($dataOrControllerName, ?string $targetName, string $expected)
{
$kernel = new WebpackEncoreIntegrationTestKernel(true);
$kernel->boot();
$twig = $this->getTwigEnvironmentFromBootedKernel($kernel);

$extension = new StimulusTwigExtension();
$this->assertSame($expected, $extension->renderStimulusTarget($twig, $dataOrControllerName, $targetName));
}

private function getContainerFromBootedKernel(WebpackEncoreIntegrationTestKernel $kernel)
{
if ($kernel::VERSION_ID >= 40100) {
Expand Down