diff --git a/src/Turbo/CHANGELOG.md b/src/Turbo/CHANGELOG.md index 012b15f241e..5a0f7d0f4f5 100644 --- a/src/Turbo/CHANGELOG.md +++ b/src/Turbo/CHANGELOG.md @@ -5,6 +5,7 @@ - Add `` component - Add `` component - Add support for custom actions in `TurboStream` and `TurboStreamResponse` +- Add support for providing multiple mercure topics to `turbo_stream_listen` ## 2.21.0 diff --git a/src/Turbo/assets/dist/turbo_stream_controller.d.ts b/src/Turbo/assets/dist/turbo_stream_controller.d.ts index a86c6796863..2806afea3cc 100644 --- a/src/Turbo/assets/dist/turbo_stream_controller.d.ts +++ b/src/Turbo/assets/dist/turbo_stream_controller.d.ts @@ -2,14 +2,17 @@ import { Controller } from '@hotwired/stimulus'; export default class extends Controller { static values: { topic: StringConstructor; + topics: ArrayConstructor; hub: StringConstructor; }; es: EventSource | undefined; url: string | undefined; readonly topicValue: string; + readonly topicsValue: string[]; readonly hubValue: string; readonly hasHubValue: boolean; readonly hasTopicValue: boolean; + readonly hasTopicsValue: boolean; initialize(): void; connect(): void; disconnect(): void; diff --git a/src/Turbo/assets/dist/turbo_stream_controller.js b/src/Turbo/assets/dist/turbo_stream_controller.js index 287dbbf7719..3d55567c772 100644 --- a/src/Turbo/assets/dist/turbo_stream_controller.js +++ b/src/Turbo/assets/dist/turbo_stream_controller.js @@ -6,12 +6,19 @@ class default_1 extends Controller { const errorMessages = []; if (!this.hasHubValue) errorMessages.push('A "hub" value pointing to the Mercure hub must be provided.'); - if (!this.hasTopicValue) - errorMessages.push('A "topic" value must be provided.'); + if (!this.hasTopicValue && !this.hasTopicsValue) + errorMessages.push('Either "topic" or "topics" value must be provided.'); if (errorMessages.length) throw new Error(errorMessages.join(' ')); const u = new URL(this.hubValue); - u.searchParams.append('topic', this.topicValue); + if (this.hasTopicValue) { + u.searchParams.append('topic', this.topicValue); + } + else { + this.topicsValue.forEach((topic) => { + u.searchParams.append('topic', topic); + }); + } this.url = u.toString(); } connect() { @@ -29,6 +36,7 @@ class default_1 extends Controller { } default_1.values = { topic: String, + topics: Array, hub: String, }; diff --git a/src/Turbo/assets/src/turbo_stream_controller.ts b/src/Turbo/assets/src/turbo_stream_controller.ts index c408dcfb099..4c8fd4d915a 100644 --- a/src/Turbo/assets/src/turbo_stream_controller.ts +++ b/src/Turbo/assets/src/turbo_stream_controller.ts @@ -16,24 +16,34 @@ import { connectStreamSource, disconnectStreamSource } from '@hotwired/turbo'; export default class extends Controller { static values = { topic: String, + topics: Array, hub: String, }; es: EventSource | undefined; url: string | undefined; declare readonly topicValue: string; + declare readonly topicsValue: string[]; declare readonly hubValue: string; declare readonly hasHubValue: boolean; declare readonly hasTopicValue: boolean; + declare readonly hasTopicsValue: boolean; initialize() { const errorMessages: string[] = []; if (!this.hasHubValue) errorMessages.push('A "hub" value pointing to the Mercure hub must be provided.'); - if (!this.hasTopicValue) errorMessages.push('A "topic" value must be provided.'); + if (!this.hasTopicValue && !this.hasTopicsValue) + errorMessages.push('Either "topic" or "topics" value must be provided.'); if (errorMessages.length) throw new Error(errorMessages.join(' ')); const u = new URL(this.hubValue); - u.searchParams.append('topic', this.topicValue); + if (this.hasTopicValue) { + u.searchParams.append('topic', this.topicValue); + } else { + this.topicsValue.forEach((topic) => { + u.searchParams.append('topic', topic); + }); + } this.url = u.toString(); } diff --git a/src/Turbo/src/Bridge/Mercure/TopicSet.php b/src/Turbo/src/Bridge/Mercure/TopicSet.php new file mode 100644 index 00000000000..42a6ffe9758 --- /dev/null +++ b/src/Turbo/src/Bridge/Mercure/TopicSet.php @@ -0,0 +1,34 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Turbo\Bridge\Mercure; + +/** + * @internal + */ +final class TopicSet +{ + /** + * @param array $topics + */ + public function __construct( + private array $topics, + ) { + } + + /** + * @return array + */ + public function getTopics(): array + { + return $this->topics; + } +} diff --git a/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php b/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php index 89f24c29e9e..68eadd82079 100644 --- a/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php +++ b/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php @@ -43,6 +43,28 @@ public function __construct( } public function renderTurboStreamListen(Environment $env, $topic): string + { + $topics = $topic instanceof TopicSet + ? array_map($this->resolveTopic(...), $topic->getTopics()) + : [$this->resolveTopic($topic)]; + + $controllerAttributes = ['hub' => $this->hub->getPublicUrl()]; + if (1 < \count($topics)) { + $controllerAttributes['topics'] = $topics; + } else { + $controllerAttributes['topic'] = current($topics); + } + + $stimulusAttributes = $this->stimulusHelper->createStimulusAttributes(); + $stimulusAttributes->addController( + 'symfony/ux-turbo/mercure-turbo-stream', + $controllerAttributes, + ); + + return (string) $stimulusAttributes; + } + + private function resolveTopic(object|string $topic): string { if (\is_object($topic)) { $class = $topic::class; @@ -51,18 +73,14 @@ public function renderTurboStreamListen(Environment $env, $topic): string throw new \LogicException(\sprintf('Cannot listen to entity of class "%s" as the PropertyAccess component is not installed. Try running "composer require symfony/property-access".', $class)); } - $topic = \sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode($class), rawurlencode(implode('-', $id))); - } elseif (!preg_match('/[^a-zA-Z0-9_\x7f-\xff\\\\]/', $topic) && class_exists($topic)) { - // Generate a URI template to subscribe to updates for all objects of this class - $topic = \sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode($topic), '{id}'); + return \sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode($class), rawurlencode(implode('-', $id))); } - $stimulusAttributes = $this->stimulusHelper->createStimulusAttributes(); - $stimulusAttributes->addController( - 'symfony/ux-turbo/mercure-turbo-stream', - ['topic' => $topic, 'hub' => $this->hub->getPublicUrl()] - ); + if (!preg_match('/[^a-zA-Z0-9_\x7f-\xff\\\\]/', $topic) && class_exists($topic)) { + // Generate a URI template to subscribe to updates for all objects of this class + return \sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode($topic), '{id}'); + } - return (string) $stimulusAttributes; + return $topic; } } diff --git a/src/Turbo/src/Twig/TwigExtension.php b/src/Turbo/src/Twig/TwigExtension.php index 87dc3fe58fc..b44d993139f 100644 --- a/src/Turbo/src/Twig/TwigExtension.php +++ b/src/Turbo/src/Twig/TwigExtension.php @@ -12,6 +12,7 @@ namespace Symfony\UX\Turbo\Twig; use Psr\Container\ContainerInterface; +use Symfony\UX\Turbo\Bridge\Mercure\TopicSet; use Twig\Environment; use Twig\Extension\AbstractExtension; use Twig\TwigFunction; @@ -35,7 +36,7 @@ public function getFunctions(): array } /** - * @param object|string $topic + * @param object|string|array $topic */ public function turboStreamListen(Environment $env, $topic, ?string $transport = null): string { @@ -45,6 +46,10 @@ public function turboStreamListen(Environment $env, $topic, ?string $transport = throw new \InvalidArgumentException(\sprintf('The Turbo stream transport "%s" does not exist.', $transport)); } + if (\is_array($topic)) { + $topic = new TopicSet($topic); + } + return $this->turboStreamListenRenderers->get($transport)->renderTurboStreamListen($env, $topic); } } diff --git a/src/Turbo/tests/Bridge/Mercure/TurboStreamListenRendererTest.php b/src/Turbo/tests/Bridge/Mercure/TurboStreamListenRendererTest.php new file mode 100644 index 00000000000..9b19ba4db09 --- /dev/null +++ b/src/Turbo/tests/Bridge/Mercure/TurboStreamListenRendererTest.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Turbo\Tests\Bridge\Mercure; + +use App\Entity\Book; +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\StimulusBundle\Dto\StimulusAttributes; + +final class TurboStreamListenRendererTest extends KernelTestCase +{ + /** + * @dataProvider provideTestCases + * + * @param array $context + */ + public function testRenderTurboStreamListen(string $template, array $context, string $expectedResult): void + { + $twig = self::getContainer()->get('twig'); + self::assertInstanceOf(\Twig\Environment::class, $twig); + + $this->assertSame($expectedResult, $twig->createTemplate($template)->render($context)); + } + + /** + * @return iterable, 2: string}> + */ + public static function provideTestCases(): iterable + { + $newEscape = (new \ReflectionClass(StimulusAttributes::class))->hasMethod('escape'); + + $book = new Book(); + $book->id = 123; + + yield [ + "{{ turbo_stream_listen('a_topic') }}", + [], + $newEscape + ? 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http://127.0.0.1:3000/.well-known/mercure" data-symfony--ux-turbo--mercure-turbo-stream-topic-value="a_topic"' + : 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http://127.0.0.1:3000/.well-known/mercure" data-symfony--ux-turbo--mercure-turbo-stream-topic-value="a_topic"', + ]; + + yield [ + "{{ turbo_stream_listen('App\\Entity\\Book') }}", + [], + $newEscape + ? 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http://127.0.0.1:3000/.well-known/mercure" data-symfony--ux-turbo--mercure-turbo-stream-topic-value="AppEntityBook"' + : 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http://127.0.0.1:3000/.well-known/mercure" data-symfony--ux-turbo--mercure-turbo-stream-topic-value="AppEntityBook"', + ]; + + yield [ + '{{ turbo_stream_listen(book) }}', + ['book' => $book], + $newEscape + ? 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http://127.0.0.1:3000/.well-known/mercure" data-symfony--ux-turbo--mercure-turbo-stream-topic-value="https://symfony.com/ux-turbo/App%5CEntity%5CBook/123"' + : 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http://127.0.0.1:3000/.well-known/mercure" data-symfony--ux-turbo--mercure-turbo-stream-topic-value="https://symfony.com/ux-turbo/App%5CEntity%5CBook/123"', + ]; + + yield [ + "{{ turbo_stream_listen(['a_topic', 'App\\Entity\\Book', book]) }}", + ['book' => $book], + $newEscape + ? 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http://127.0.0.1:3000/.well-known/mercure" data-symfony--ux-turbo--mercure-turbo-stream-topics-value="["a_topic","AppEntityBook","https:\/\/symfony.com\/ux-turbo\/App%5CEntity%5CBook\/123"]"' + : 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http://127.0.0.1:3000/.well-known/mercure" data-symfony--ux-turbo--mercure-turbo-stream-topics-value="["a_topic","AppEntityBook","https:\/\/symfony.com\/ux-turbo\/App%5CEntity%5CBook\/123"]"', + ]; + } +}