From e3b72c18939f8ae14d9cf142c3e7e091d750cd4c Mon Sep 17 00:00:00 2001 From: Tomas Date: Tue, 26 Nov 2024 20:17:03 +0200 Subject: [PATCH 1/3] [Turbo] Add support for providing multiple mercure topics to `turbo_stream_listen` --- src/Turbo/CHANGELOG.md | 1 + .../assets/dist/turbo_stream_controller.d.ts | 3 + .../assets/dist/turbo_stream_controller.js | 14 +++- .../assets/src/turbo_stream_controller.ts | 14 +++- src/Turbo/src/Bridge/Mercure/TopicSet.php | 34 ++++++++++ .../Mercure/TurboStreamListenRenderer.php | 40 ++++++++--- src/Turbo/src/Twig/TwigExtension.php | 7 +- .../Mercure/TurboStreamListenRendererTest.php | 67 +++++++++++++++++++ 8 files changed, 164 insertions(+), 16 deletions(-) create mode 100644 src/Turbo/src/Bridge/Mercure/TopicSet.php create mode 100644 src/Turbo/tests/Bridge/Mercure/TurboStreamListenRendererTest.php 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..23c064ed388 100644 --- a/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php +++ b/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php @@ -43,6 +43,30 @@ public function __construct( } public function renderTurboStreamListen(Environment $env, $topic): string + { + if ($topic instanceof TopicSet) { + $topics = array_map(\Closure::fromCallable([$this, 'resolveTopic']), $topic->getTopics()); + } else { + $topics = [$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 +75,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..549ab2f4f73 --- /dev/null +++ b/src/Turbo/tests/Bridge/Mercure/TurboStreamListenRendererTest.php @@ -0,0 +1,67 @@ + + * + * 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 + */ + public function testRenderTurboStreamListen(string $template, array $context, string $expectedResult) + { + $this->assertSame($expectedResult, self::getContainer()->get('twig')->createTemplate($template)->render($context)); + } + + 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"]"', + ]; + } +} From 47347570d0e4de01b6ba4c8f11738f984425a5e4 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Fri, 29 Nov 2024 16:16:57 +0100 Subject: [PATCH 2/3] [Turbo] Simplify $topics --- .../src/Bridge/Mercure/TurboStreamListenRenderer.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php b/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php index 23c064ed388..68eadd82079 100644 --- a/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php +++ b/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php @@ -44,11 +44,9 @@ public function __construct( public function renderTurboStreamListen(Environment $env, $topic): string { - if ($topic instanceof TopicSet) { - $topics = array_map(\Closure::fromCallable([$this, 'resolveTopic']), $topic->getTopics()); - } else { - $topics = [$this->resolveTopic($topic)]; - } + $topics = $topic instanceof TopicSet + ? array_map($this->resolveTopic(...), $topic->getTopics()) + : [$this->resolveTopic($topic)]; $controllerAttributes = ['hub' => $this->hub->getPublicUrl()]; if (1 < \count($topics)) { From 24690123ff3e6d52072fe57b2f936dd65c847672 Mon Sep 17 00:00:00 2001 From: Hugo Alliaume Date: Fri, 29 Nov 2024 16:22:04 +0100 Subject: [PATCH 3/3] [Turbo] PHPStan --- .../Bridge/Mercure/TurboStreamListenRendererTest.php | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/Turbo/tests/Bridge/Mercure/TurboStreamListenRendererTest.php b/src/Turbo/tests/Bridge/Mercure/TurboStreamListenRendererTest.php index 549ab2f4f73..9b19ba4db09 100644 --- a/src/Turbo/tests/Bridge/Mercure/TurboStreamListenRendererTest.php +++ b/src/Turbo/tests/Bridge/Mercure/TurboStreamListenRendererTest.php @@ -19,12 +19,20 @@ final class TurboStreamListenRendererTest extends KernelTestCase { /** * @dataProvider provideTestCases + * + * @param array $context */ - public function testRenderTurboStreamListen(string $template, array $context, string $expectedResult) + public function testRenderTurboStreamListen(string $template, array $context, string $expectedResult): void { - $this->assertSame($expectedResult, self::getContainer()->get('twig')->createTemplate($template)->render($context)); + $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');