Repository: symfony/ux
Pull request #2196 (2024-09-23 10:07:10)

[Turbo] Add Helper/TurboStream::append() et al. methods
| Q             | A
| ------------- | ---
| Bug fix?      | no
| New feature?  | yes
| Issues        | -
| License       | MIT

For thoughts, this might help, WDYT?

```php
return new TurboResponse(TurboStream::append($target, $html));
```

(Note that I'm also wondering about having a twig component to render streams from the twig side, stay tuned)

nicolas-grekas: Updated
rvmourik: Great addition imho, but what about multiple streams in a single response? Or is that the Twig solution you are talking about @nicolas-grekas?

```php
return new TurboResponse(TurboStream::append($target, $html));
```

Regarding DX it is not optimal to have this I guess:

```php
return new TurboResponse(TurboStream::append($target, $html) . TurboStream::replace($target2, $html2));
```

Is this something to consider?

smnandre: Thanks a lot @nicolas-grekas! More comments tomorrow :) 
nicolas-grekas: @rvmourik concatenation FTW indeed. I don't see a better option. In the end, any alternative would just be a fancy way to do concatenation.
rvmourik: > @rvmourik concatenation FTW indeed. I don't see a better option. In the end, any alternative would just be a fancy way to do concatenation.

@nicolas-grekas thanks for the quick reply.

I know the TurboResponse is extending the base Response class but if we take a look at the JsonResponse there is also an option to pass an array or a mixed value, when it is mixed in the end is converts it to an ArrayObject. Could the TurboResponse do something similar, always convert the passed stream(s) to be a collection of streams and processing them before passing it to the base Response?

Maybe I am overthinking it? 😇 
javiereguiluz: If we decide to add some helpers to make using Turbo more user-friendly, what if we all DX and provide a fluent interface to not deal with any internal details?

```php
return TurboResponse()
    ->append($targetId, $html)
    ->prepend($targetId, $html)
    ->replace($targetId, $html, $method = 'morph')
    ->update($targetId, $html, $method = 'morph')
    ->remove($targetId, $html)
    ->before($targetId, $html)
    ->after($targetId, $html)
    // useful if the action (append, prepend, etc.) is a PHP variable:
    ->stream($action, $targetId, $html)
;
```

@rvmourik your example now would look like this:

```php
// before
return new TurboResponse(TurboStream::append($target, $html) . TurboStream::replace($target2, $html2));

// after
return new TurboResponse()
    ->append($target, $html)
    ->append($target2, $html2)
;
```

There's a final consideration, which is not covered either in the current proposal of this PR. In addition to `target` (the targetID), you can use `targets` (a CSS selector) to apply the stream to multiple targets (See https://turbo.hotwired.dev/handbook/streams#actions-with-multiple-targets).

Adding an optional `targets` param would look ugly in my opinion:

```php
return TurboResponse()
    ->append(string|null $targetId, string|null $targets, $html)
    ->prepend(string|null $targetId, string|null $targets, $html)
    // ...
```

We could add a `*All()` series of methods:

```php
return TurboResponse()
    ->append(string $targetId, $html)
    ->appendAll(string $targets, $html)

    ->prepend(string $targetId, string $html)
    ->prependAll(string $targets, $html)
    // ...
```

What do you think?
rvmourik: > If we decide to add some helpers to make using Turbo more user-friendly, what if we all DX and provide a fluent interface to not deal with any internal details?
> 
> ```
> return TurboResponse()
>     ->append($targetId, $html)
>     ->prepend($targetId, $html)
>     ->replace($targetId, $html, $method = 'morph')
>     ->update($targetId, $html, $method = 'morph')
>     ->remove($targetId, $html)
>     ->before($targetId, $html)
>     ->after($targetId, $html)
>     // useful if the action (append, prepend, etc.) is a PHP variable:
>     ->stream($action, $targetId, $html)
> ;
> ```
> 
> @rvmourik your example now would look like this:
> 
> ```
> // before
> return new TurboResponse(TurboStream::append($target, $html) . TurboStream::replace($target2, $html2));
> 
> // after
> return new TurboResponse()
>     ->append($target, $html)
>     ->append($target2, $html2)
> ;
> ```
> 
> There's a final consideration, which is not covered either in the current proposal of this PR. In addition to `target` (the targetID), you can use `targets` (a CSS selector) to apply the stream to multiple targets (See https://turbo.hotwired.dev/handbook/streams#actions-with-multiple-targets).
> 
> Adding an optional `targets` param would look ugly in my opinion:
> 
> ```
> return TurboResponse()
>     ->append(string|null $targetId, string|null $targets, $html)
>     ->prepend(string|null $targetId, string|null $targets, $html)
>     // ...
> ```
> 
> We could add a `*All()` series of methods:
> 
> ```
> return TurboResponse()
>     ->append(string $targetId, $html)
>     ->appendAll(string $targets, $html)
> 
>     ->prepend(string $targetId, string $html)
>     ->prependAll(string $targets, $html)
>     // ...
> ```
> 
> What do you think?

I think this is a good suggestion, maybe in a future PR the same can be done with adding a helper for a regular turbo frame. I find my self often doing one of the following:

a) creating a twig file only with the frame and HTML in it
b) manually building a response like in the helper in this PR en load the twig file between the <turbo-frame> tag in the response.

Thanks all.
nicolas-grekas: > what if we all DX and provide a fluent interface

Works for me, I added the fluent methods to the TurboStreamResponse class.
Note that I don't know yet if that should be the recommended way to generate turbo stream: the drawback of this approach is that it splits targets from actual HTML, so that it can be easy to forget to update a target after updating a template. Putting the stream tags in the template that they target would improve this by providing locality.

> In addition to target (the targetID), you can use targets (a CSS selector) to apply the stream to multiple targets

You might have missed this in the attached patch: this PR uses only targets, with an "s". That means you have to use `#foo` to target id foo. To me, this is the most empowering and DX-friendly way to express the target. Using target (no "s") provides zero benefits.
nicolas-grekas: PR ready IMHO (failure unrelated IIUC)

Patch:
diff --git a/src/Turbo/CHANGELOG.md b/src/Turbo/CHANGELOG.md
index b5ad566c..d3e4d1c9 100644
--- a/src/Turbo/CHANGELOG.md
+++ b/src/Turbo/CHANGELOG.md
@@ -1,5 +1,10 @@
 # CHANGELOG
 
+## 2.21.0
+
+-   Add `Helper/TurboStream::append()` et al. methods
+-   Add `TurboStreamResponse`
+
 ## 2.19.0
 
 -   Fix Doctrine proxies are not Broadcasted #3139
diff --git a/src/Turbo/doc/index.rst b/src/Turbo/doc/index.rst
index d20f6c73..336145ca 100644
--- a/src/Turbo/doc/index.rst
+++ b/src/Turbo/doc/index.rst
@@ -374,7 +374,7 @@ Let's discover how to use Turbo Streams to enhance your `Symfony forms`_::
     {% endblock %}
 
 Supported actions are ``append``, ``prepend``, ``replace``, ``update``,
-``remove``, ``before`` and ``after``.
+``remove``, ``before``, ``after`` and ``refresh``.
 `Read the Turbo Streams documentation for more details`_.
 
 Resetting the Form
diff --git a/src/Turbo/phpstan.neon.dist b/src/Turbo/phpstan.neon.dist
index 62d57c07..74ef7289 100644
--- a/src/Turbo/phpstan.neon.dist
+++ b/src/Turbo/phpstan.neon.dist
@@ -45,3 +45,8 @@ parameters:
 			message: "#^Call to an undefined method Doctrine\\\\ORM\\\\Event\\\\PostFlushEventArgs\\:\\:getEntityManager\\(\\)\\.$#"
 			count: 1
 			path: src/Doctrine/BroadcastListener.php
+
+		-
+			message: "#^Method Symfony\\\\UX\\\\Turbo\\\\TurboStreamResponse::__construct\\(\\) has parameter \\$headers with no value type specified in iterable type array\\.$#"
+			count: 1
+			path: src/TurboStreamResponse.php
diff --git a/src/Turbo/src/Helper/TurboStream.php b/src/Turbo/src/Helper/TurboStream.php
new file mode 100644
index 00000000..daea084e
--- /dev/null
+++ b/src/Turbo/src/Helper/TurboStream.php
@@ -0,0 +1,97 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Turbo\Helper;
+
+/**
+ * @see https://turbo.hotwired.dev/reference/streams
+ */
+final class TurboStream
+{
+    /**
+     * Appends to the element(s) designated by the target CSS selector.
+     */
+    public static function append(string $target, string $html): string
+    {
+        return self::wrap('append', $target, $html);
+    }
+
+    /**
+     * Prepends to the element(s) designated by the target CSS selector.
+     */
+    public static function prepend(string $target, string $html): string
+    {
+        return self::wrap('prepend', $target, $html);
+    }
+
+    /**
+     * Replaces the element(s) designated by the target CSS selector.
+     */
+    public static function replace(string $target, string $html, bool $morph = false): string
+    {
+        return self::wrap('replace', $target, $html, $morph ? ' method="morph"' : '');
+    }
+
+    /**
+     * Updates the content of the element(s) designated by the target CSS selector.
+     */
+    public static function update(string $target, string $html, bool $morph = false): string
+    {
+        return self::wrap('update', $target, $html, $morph ? ' method="morph"' : '');
+    }
+
+    /**
+     * Removes the element(s) designated by the target CSS selector.
+     */
+    public static function remove(string $target): string
+    {
+        return \sprintf('<turbo-stream action="remove" targets="%s"></turbo-stream>', htmlspecialchars($target));
+    }
+
+    /**
+     * Inserts before the element(s) designated by the target CSS selector.
+     */
+    public static function before(string $target, string $html): string
+    {
+        return self::wrap('before', $target, $html);
+    }
+
+    /**
+     * Inserts after the element(s) designated by the target CSS selector.
+     */
+    public static function after(string $target, string $html): string
+    {
+        return self::wrap('after', $target, $html);
+    }
+
+    /**
+     * Initiates a Page Refresh to render new content with morphing.
+     *
+     * @see Initiates a Page Refresh to render new content with morphing.
+     */
+    public static function refresh(?string $requestId = null): string
+    {
+        if (null === $requestId) {
+            return '<turbo-stream action="refresh"></turbo-stream>';
+        }
+
+        return \sprintf('<turbo-stream action="refresh" request-id="%s"></turbo-stream>', htmlspecialchars($requestId));
+    }
+
+    private static function wrap(string $action, string $target, string $html, string $attr = ''): string
+    {
+        return \sprintf(<<<EOHTML
+            <turbo-stream action="%s" targets="%s"%s>
+                <template>%s</template>
+            </turbo-stream>
+            EOHTML, $action, htmlspecialchars($target), $attr, $html);
+    }
+}
diff --git a/src/Turbo/src/TurboStreamResponse.php b/src/Turbo/src/TurboStreamResponse.php
new file mode 100644
index 00000000..e597ed30
--- /dev/null
+++ b/src/Turbo/src/TurboStreamResponse.php
@@ -0,0 +1,107 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Turbo;
+
+use Symfony\Component\HttpFoundation\Response;
+use Symfony\UX\Turbo\Helper\TurboStream;
+
+class TurboStreamResponse extends Response
+{
+    public function __construct(?string $content = '', int $status = 200, array $headers = [])
+    {
+        parent::__construct($content, $status, $headers);
+
+        if (!$this->headers->has('Content-Type')) {
+            $this->headers->set('Content-Type', TurboBundle::STREAM_MEDIA_TYPE);
+        }
+    }
+
+    /**
+     * @return $this
+     */
+    public function append(string $target, string $html): static
+    {
+        $this->setContent($this->getContent().TurboStream::append($target, $html));
+
+        return $this;
+    }
+
+    /**
+     * @return $this
+     */
+    public function prepend(string $target, string $html): static
+    {
+        $this->setContent($this->getContent().TurboStream::prepend($target, $html));
+
+        return $this;
+    }
+
+    /**
+     * @return $this
+     */
+    public function replace(string $target, string $html, bool $morph = false): static
+    {
+        $this->setContent($this->getContent().TurboStream::replace($target, $html, $morph));
+
+        return $this;
+    }
+
+    /**
+     * @return $this
+     */
+    public function update(string $target, string $html, bool $morph = false): static
+    {
+        $this->setContent($this->getContent().TurboStream::update($target, $html, $morph));
+
+        return $this;
+    }
+
+    /**
+     * @return $this
+     */
+    public function remove(string $target): static
+    {
+        $this->setContent($this->getContent().TurboStream::remove($target));
+
+        return $this;
+    }
+
+    /**
+     * @return $this
+     */
+    public function before(string $target, string $html): static
+    {
+        $this->setContent($this->getContent().TurboStream::before($target, $html));
+
+        return $this;
+    }
+
+    /**
+     * @return $this
+     */
+    public function after(string $target, string $html): static
+    {
+        $this->setContent($this->getContent().TurboStream::after($target, $html));
+
+        return $this;
+    }
+
+    /**
+     * @return $this
+     */
+    public function refresh(?string $requestId = null): static
+    {
+        $this->setContent($this->getContent().TurboStream::refresh($requestId));
+
+        return $this;
+    }
+}


Test patch:
diff --git a/src/Turbo/tests/Helper/TurboStreamTest.php b/src/Turbo/tests/Helper/TurboStreamTest.php
new file mode 100644
index 00000000..263e454e
--- /dev/null
+++ b/src/Turbo/tests/Helper/TurboStreamTest.php
@@ -0,0 +1,79 @@
+<?php
+
+/*
+ * This file is part of the Symfony package.
+ *
+ * (c) Fabien Potencier <fabien@symfony.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Symfony\UX\Turbo\Tests\Helper;
+
+use PHPUnit\Framework\TestCase;
+use Symfony\UX\Turbo\Helper\TurboStream;
+
+class TurboStreamTest extends TestCase
+{
+    /**
+     * @testWith ["append"]
+     *           ["prepend"]
+     *           ["replace"]
+     *           ["update"]
+     *           ["before"]
+     *           ["after"]
+     */
+    public function testStream(string $action): void
+    {
+        $this->assertSame(<<<EOHTML
+            <turbo-stream action="{$action}" targets="some[&quot;selector&quot;]">
+                <template><div>content</div></template>
+            </turbo-stream>
+            EOHTML,
+            TurboStream::$action('some["selector"]', '<div>content</div>')
+        );
+    }
+
+    /**
+     * @testWith ["replace"]
+     *           ["update"]
+     */
+    public function testStreamMorph(string $action): void
+    {
+        $this->assertSame(<<<EOHTML
+            <turbo-stream action="{$action}" targets="some[&quot;selector&quot;]" method="morph">
+                <template><div>content</div></template>
+            </turbo-stream>
+            EOHTML,
+            TurboStream::$action('some["selector"]', '<div>content</div>', morph: true)
+        );
+    }
+
+    public function testRemove(): void
+    {
+        $this->assertSame(<<<EOHTML
+            <turbo-stream action="remove" targets="some[&quot;selector&quot;]"></turbo-stream>
+            EOHTML,
+            TurboStream::remove('some["selector"]')
+        );
+    }
+
+    public function testRefreshWithoutId(): void
+    {
+        $this->assertSame(<<<EOHTML
+            <turbo-stream action="refresh"></turbo-stream>
+            EOHTML,
+            TurboStream::refresh()
+        );
+    }
+
+    public function testRefreshWithId(): void
+    {
+        $this->assertSame(<<<EOHTML
+            <turbo-stream action="refresh" request-id="a&quot;b"></turbo-stream>
+            EOHTML,
+            TurboStream::refresh('a"b')
+        );
+    }
+}
diff --git a/src/Turbo/tests/app/Kernel.php b/src/Turbo/tests/app/Kernel.php
index 078ba8da..f61de905 100644
--- a/src/Turbo/tests/app/Kernel.php
+++ b/src/Turbo/tests/app/Kernel.php
@@ -220,7 +220,7 @@ class Kernel extends BaseKernel
                     $song->artist = $doctrine->find(Artist::class, $artistId);
                 }
             }
-            if ($remove = $request->get('remove')) {
+            if ($request->get('remove')) {
                 $doctrine->remove($song);
             } else {
                 $doctrine->persist($song);