Repository: symfony/ux
Pull request #2298 (2024-10-24 08:31:21)

[Turbo] Support custom TurboStreamResponse actions
| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes
| Issues        | -
| License       | MIT

The current implementation of TurboStream & TurboStreamResponse has no generic action (only the predefined turbo actions) and what's more the class is marked as final, which does not allow adding support for more actions. I think the wrap function should be public or have a public function "custom" that exposes the functionality (I've opted for the latter)

I really like this library: https://github.com/marcoroth/turbo_power
It adds additional simple actions. For example I use set/delete attributes to enable/disable UI elements

This change will allow people to use this or similar libraries, or even build their own custom actions if they want to.

There is no impact to the rest of the functionality or DX


There is a minor change to the wrap function, it now accepts an array of attributes instead of a string. It builds the string from the array of attributes, and adds a leading space. This aims to prevent errors, as the previous implementation needs the $attr argument to start with a blank space otherwise it would produce invalid html

setineos: I think it should also work for `<twig:Turbo:Stream:*>` components.
nicolas-grekas: > I think it should also work for <twig:Turbo:Stream:*> components.

This can be done by adding a generic `<twig:Turbo:Stream>` component maybe.
DRaichev: @nicolas-grekas  
I just realised if you pass "action" or "targets" into the attributes array those will conflict with the existing attributes.
I think we should throw an exception if those are present
smnandre: With the incoming [`<twig:Turbo:Frame>`](https://github.com/symfony/ux/pull/2303) and [`<twig:Turbo:Stream>`](https://github.com/symfony/ux/pull/2302), do you think we still need this features ?
DRaichev: > With the incoming [`<twig:Turbo:Frame>`](https://github.com/symfony/ux/pull/2303) and [`<twig:Turbo:Stream>`](https://github.com/symfony/ux/pull/2302), do you think we still need this features ?

I think they are complementary. I'd rather use the twig component syntax only in templates and stick to this helper on the php side, plus it's better to have consistent functionality in both, especially for people who dislike the twig component syntax.
smnandre: Looks good to me!

Could you check the type of values and throw if not expected (a non-stringable object) ?
DRaichev: > Looks good to me!
> 
> Could you check the type of values and throw if not expected (a non-stringable object) ?

I believe this is going to throw:
```
foreach ($attr as $key => $value) {
    $key = htmlspecialchars($key);
    if (null === $value) {
        $attrString .= \sprintf(' %s', $key);
    } elseif (\is_int($value) || \is_float($value)) {
        $attrString .= \sprintf(' %s="%s"', $key, $value);
    } else {
        $attrString .= \sprintf(' %s="%s"', $key, htmlspecialchars($value)); // htmlspecialchars expect string so will throw here
    }
}
```

Or do you mean somewhere else ?
DRaichev: > > Looks good to me!
> > Could you check the type of values and throw if not expected (a non-stringable object) ?
> 
> I believe this is going to throw:
> 
> ```
> foreach ($attr as $key => $value) {
>     $key = htmlspecialchars($key);
>     if (null === $value) {
>         $attrString .= \sprintf(' %s', $key);
>     } elseif (\is_int($value) || \is_float($value)) {
>         $attrString .= \sprintf(' %s="%s"', $key, $value);
>     } else {
>         $attrString .= \sprintf(' %s="%s"', $key, htmlspecialchars($value)); // htmlspecialchars expect string so will throw here
>     }
> }
> ```
> 
> Or do you mean somewhere else ?

So this is ready for merge then, right
smnandre: After some thinking, why don't we rename the `custom($action)`  method in `action($name, .... ... ... .. )`  ?
DRaichev: > After some thinking, why don't we rename the `custom($action)` method in `action($name, .... ... ... .. )` ?

Yeah, I was thinking about this when I renamed the one in TurboStreamResponse
It should be either custom in both or action in both.

I don't really have a preference, both work, so I leave the choice to you
smnandre: Let's use action in both then (sorry for the late comment)
DRaichev: Ready to merge
DRaichev: Updated the changelog, now ready to merge
Kocal: Hey, I rebasing your PR and will merge it in the next minutes.

Patch:
diff --git a/src/Turbo/CHANGELOG.md b/src/Turbo/CHANGELOG.md
index 872b9430..012b15f2 100644
--- a/src/Turbo/CHANGELOG.md
+++ b/src/Turbo/CHANGELOG.md
@@ -4,6 +4,7 @@
 
 -   Add `<twig:Turbo:Stream>` component
 -   Add `<twig:Turbo:Frame>` component
+-   Add support for custom actions in `TurboStream` and `TurboStreamResponse`
 
 ## 2.21.0
 
diff --git a/src/Turbo/src/Helper/TurboStream.php b/src/Turbo/src/Helper/TurboStream.php
index daea084e..d3ecdc5b 100644
--- a/src/Turbo/src/Helper/TurboStream.php
+++ b/src/Turbo/src/Helper/TurboStream.php
@@ -86,6 +86,34 @@ final class TurboStream
         return \sprintf('<turbo-stream action="refresh" request-id="%s"></turbo-stream>', htmlspecialchars($requestId));
     }
 
+    /**
+     * Custom action and attributes.
+     *
+     * Set boolean attributes (e.g., `disabled`) by providing the attribute name as key with `null` as value.
+     *
+     * @param array<string, string|int|float|null> $attr
+     */
+    public static function action(string $action, string $target, string $html, array $attr = []): string
+    {
+        if (\array_key_exists('action', $attr) || \array_key_exists('targets', $attr)) {
+            throw new \InvalidArgumentException('The "action" and "targets" attributes are reserved and cannot be used.');
+        }
+
+        $attrString = '';
+        foreach ($attr as $key => $value) {
+            $key = htmlspecialchars($key);
+            if (null === $value) {
+                $attrString .= \sprintf(' %s', $key);
+            } elseif (\is_int($value) || \is_float($value)) {
+                $attrString .= \sprintf(' %s="%s"', $key, $value);
+            } else {
+                $attrString .= \sprintf(' %s="%s"', $key, htmlspecialchars($value));
+            }
+        }
+
+        return self::wrap(htmlspecialchars($action), $target, $html, $attrString);
+    }
+
     private static function wrap(string $action, string $target, string $html, string $attr = ''): string
     {
         return \sprintf(<<<EOHTML
diff --git a/src/Turbo/src/TurboStreamResponse.php b/src/Turbo/src/TurboStreamResponse.php
index e597ed30..377d630c 100644
--- a/src/Turbo/src/TurboStreamResponse.php
+++ b/src/Turbo/src/TurboStreamResponse.php
@@ -104,4 +104,20 @@ class TurboStreamResponse extends Response
 
         return $this;
     }
+
+    /**
+     * Custom action and attributes.
+     *
+     * Set boolean attributes (e.g., `disabled`) by providing the attribute name as key with `null` as value.
+     *
+     * @param array<string, string|int|float|null> $attr
+     *
+     * @return $this
+     */
+    public function action(string $action, string $target, string $html, array $attr = []): static
+    {
+        $this->setContent($this->getContent().TurboStream::action($action, $target, $html, $attr));
+
+        return $this;
+    }
 }


Test patch:
diff --git a/src/Turbo/tests/Helper/TurboStreamTest.php b/src/Turbo/tests/Helper/TurboStreamTest.php
index 263e454e..b404dca2 100644
--- a/src/Turbo/tests/Helper/TurboStreamTest.php
+++ b/src/Turbo/tests/Helper/TurboStreamTest.php
@@ -76,4 +76,32 @@ class TurboStreamTest extends TestCase
             TurboStream::refresh('a"b')
         );
     }
+
+    public function testCustom(): void
+    {
+        $this->assertSame(<<<EOHTML
+            <turbo-stream action="customAction" targets="some[&quot;selector&quot;]" someAttr="someValue" boolAttr intAttr="0" floatAttr="3.14">
+                <template><div>content</div></template>
+            </turbo-stream>
+            EOHTML,
+            TurboStream::action('customAction', 'some["selector"]', '<div>content</div>', ['someAttr' => 'someValue', 'boolAttr' => null, 'intAttr' => 0, 'floatAttr' => 3.14])
+        );
+    }
+
+    /**
+     * @dataProvider customThrowsExceptionDataProvider
+     *
+     * @param array<string, string|int|float|null> $attr
+     */
+    public function testCustomThrowsException(string $action, string $target, string $html, array $attr): void
+    {
+        $this->expectException(\InvalidArgumentException::class);
+        TurboStream::action($action, $target, $html, $attr);
+    }
+
+    public static function customThrowsExceptionDataProvider(): \Generator
+    {
+        yield ['customAction', 'some["selector"]', '<div>content</div>', ['action' => 'someAction']];
+        yield ['customAction', 'some["selector"]', '<div>content</div>', ['targets' => 'someTargets']];
+    }
 }