Skip to content

Commit 8b6090f

Browse files
committed
feature #2407 [Turbo] Add support for providing multiple mercure topics to turbo_stream_listen (norkunas, Kocal)
This PR was merged into the 2.x branch. Discussion ---------- [Turbo] Add support for providing multiple mercure topics to `turbo_stream_listen` | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Issues | Fix #213 | License | MIT Commits ------- 2469012 [Turbo] PHPStan 4734757 [Turbo] Simplify $topics e3b72c1 [Turbo] Add support for providing multiple mercure topics to `turbo_stream_listen`
2 parents 885495a + 2469012 commit 8b6090f

8 files changed

+170
-16
lines changed

src/Turbo/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- Add `<twig:Turbo:Stream>` component
66
- Add `<twig:Turbo:Frame>` component
77
- Add support for custom actions in `TurboStream` and `TurboStreamResponse`
8+
- Add support for providing multiple mercure topics to `turbo_stream_listen`
89

910
## 2.21.0
1011

src/Turbo/assets/dist/turbo_stream_controller.d.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,17 @@ import { Controller } from '@hotwired/stimulus';
22
export default class extends Controller {
33
static values: {
44
topic: StringConstructor;
5+
topics: ArrayConstructor;
56
hub: StringConstructor;
67
};
78
es: EventSource | undefined;
89
url: string | undefined;
910
readonly topicValue: string;
11+
readonly topicsValue: string[];
1012
readonly hubValue: string;
1113
readonly hasHubValue: boolean;
1214
readonly hasTopicValue: boolean;
15+
readonly hasTopicsValue: boolean;
1316
initialize(): void;
1417
connect(): void;
1518
disconnect(): void;

src/Turbo/assets/dist/turbo_stream_controller.js

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,19 @@ class default_1 extends Controller {
66
const errorMessages = [];
77
if (!this.hasHubValue)
88
errorMessages.push('A "hub" value pointing to the Mercure hub must be provided.');
9-
if (!this.hasTopicValue)
10-
errorMessages.push('A "topic" value must be provided.');
9+
if (!this.hasTopicValue && !this.hasTopicsValue)
10+
errorMessages.push('Either "topic" or "topics" value must be provided.');
1111
if (errorMessages.length)
1212
throw new Error(errorMessages.join(' '));
1313
const u = new URL(this.hubValue);
14-
u.searchParams.append('topic', this.topicValue);
14+
if (this.hasTopicValue) {
15+
u.searchParams.append('topic', this.topicValue);
16+
}
17+
else {
18+
this.topicsValue.forEach((topic) => {
19+
u.searchParams.append('topic', topic);
20+
});
21+
}
1522
this.url = u.toString();
1623
}
1724
connect() {
@@ -29,6 +36,7 @@ class default_1 extends Controller {
2936
}
3037
default_1.values = {
3138
topic: String,
39+
topics: Array,
3240
hub: String,
3341
};
3442

src/Turbo/assets/src/turbo_stream_controller.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,24 +16,34 @@ import { connectStreamSource, disconnectStreamSource } from '@hotwired/turbo';
1616
export default class extends Controller {
1717
static values = {
1818
topic: String,
19+
topics: Array,
1920
hub: String,
2021
};
2122
es: EventSource | undefined;
2223
url: string | undefined;
2324

2425
declare readonly topicValue: string;
26+
declare readonly topicsValue: string[];
2527
declare readonly hubValue: string;
2628
declare readonly hasHubValue: boolean;
2729
declare readonly hasTopicValue: boolean;
30+
declare readonly hasTopicsValue: boolean;
2831

2932
initialize() {
3033
const errorMessages: string[] = [];
3134
if (!this.hasHubValue) errorMessages.push('A "hub" value pointing to the Mercure hub must be provided.');
32-
if (!this.hasTopicValue) errorMessages.push('A "topic" value must be provided.');
35+
if (!this.hasTopicValue && !this.hasTopicsValue)
36+
errorMessages.push('Either "topic" or "topics" value must be provided.');
3337
if (errorMessages.length) throw new Error(errorMessages.join(' '));
3438

3539
const u = new URL(this.hubValue);
36-
u.searchParams.append('topic', this.topicValue);
40+
if (this.hasTopicValue) {
41+
u.searchParams.append('topic', this.topicValue);
42+
} else {
43+
this.topicsValue.forEach((topic) => {
44+
u.searchParams.append('topic', topic);
45+
});
46+
}
3747

3848
this.url = u.toString();
3949
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\Turbo\Bridge\Mercure;
13+
14+
/**
15+
* @internal
16+
*/
17+
final class TopicSet
18+
{
19+
/**
20+
* @param array<string|object> $topics
21+
*/
22+
public function __construct(
23+
private array $topics,
24+
) {
25+
}
26+
27+
/**
28+
* @return array<string|object>
29+
*/
30+
public function getTopics(): array
31+
{
32+
return $this->topics;
33+
}
34+
}

src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,28 @@ public function __construct(
4343
}
4444

4545
public function renderTurboStreamListen(Environment $env, $topic): string
46+
{
47+
$topics = $topic instanceof TopicSet
48+
? array_map($this->resolveTopic(...), $topic->getTopics())
49+
: [$this->resolveTopic($topic)];
50+
51+
$controllerAttributes = ['hub' => $this->hub->getPublicUrl()];
52+
if (1 < \count($topics)) {
53+
$controllerAttributes['topics'] = $topics;
54+
} else {
55+
$controllerAttributes['topic'] = current($topics);
56+
}
57+
58+
$stimulusAttributes = $this->stimulusHelper->createStimulusAttributes();
59+
$stimulusAttributes->addController(
60+
'symfony/ux-turbo/mercure-turbo-stream',
61+
$controllerAttributes,
62+
);
63+
64+
return (string) $stimulusAttributes;
65+
}
66+
67+
private function resolveTopic(object|string $topic): string
4668
{
4769
if (\is_object($topic)) {
4870
$class = $topic::class;
@@ -51,18 +73,14 @@ public function renderTurboStreamListen(Environment $env, $topic): string
5173
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));
5274
}
5375

54-
$topic = \sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode($class), rawurlencode(implode('-', $id)));
55-
} elseif (!preg_match('/[^a-zA-Z0-9_\x7f-\xff\\\\]/', $topic) && class_exists($topic)) {
56-
// Generate a URI template to subscribe to updates for all objects of this class
57-
$topic = \sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode($topic), '{id}');
76+
return \sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode($class), rawurlencode(implode('-', $id)));
5877
}
5978

60-
$stimulusAttributes = $this->stimulusHelper->createStimulusAttributes();
61-
$stimulusAttributes->addController(
62-
'symfony/ux-turbo/mercure-turbo-stream',
63-
['topic' => $topic, 'hub' => $this->hub->getPublicUrl()]
64-
);
79+
if (!preg_match('/[^a-zA-Z0-9_\x7f-\xff\\\\]/', $topic) && class_exists($topic)) {
80+
// Generate a URI template to subscribe to updates for all objects of this class
81+
return \sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode($topic), '{id}');
82+
}
6583

66-
return (string) $stimulusAttributes;
84+
return $topic;
6785
}
6886
}

src/Turbo/src/Twig/TwigExtension.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
namespace Symfony\UX\Turbo\Twig;
1313

1414
use Psr\Container\ContainerInterface;
15+
use Symfony\UX\Turbo\Bridge\Mercure\TopicSet;
1516
use Twig\Environment;
1617
use Twig\Extension\AbstractExtension;
1718
use Twig\TwigFunction;
@@ -35,7 +36,7 @@ public function getFunctions(): array
3536
}
3637

3738
/**
38-
* @param object|string $topic
39+
* @param object|string|array<object|string> $topic
3940
*/
4041
public function turboStreamListen(Environment $env, $topic, ?string $transport = null): string
4142
{
@@ -45,6 +46,10 @@ public function turboStreamListen(Environment $env, $topic, ?string $transport =
4546
throw new \InvalidArgumentException(\sprintf('The Turbo stream transport "%s" does not exist.', $transport));
4647
}
4748

49+
if (\is_array($topic)) {
50+
$topic = new TopicSet($topic);
51+
}
52+
4853
return $this->turboStreamListenRenderers->get($transport)->renderTurboStreamListen($env, $topic);
4954
}
5055
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\Turbo\Tests\Bridge\Mercure;
13+
14+
use App\Entity\Book;
15+
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
16+
use Symfony\UX\StimulusBundle\Dto\StimulusAttributes;
17+
18+
final class TurboStreamListenRendererTest extends KernelTestCase
19+
{
20+
/**
21+
* @dataProvider provideTestCases
22+
*
23+
* @param array<mixed> $context
24+
*/
25+
public function testRenderTurboStreamListen(string $template, array $context, string $expectedResult): void
26+
{
27+
$twig = self::getContainer()->get('twig');
28+
self::assertInstanceOf(\Twig\Environment::class, $twig);
29+
30+
$this->assertSame($expectedResult, $twig->createTemplate($template)->render($context));
31+
}
32+
33+
/**
34+
* @return iterable<array{0: string, 1: array<mixed>, 2: string}>
35+
*/
36+
public static function provideTestCases(): iterable
37+
{
38+
$newEscape = (new \ReflectionClass(StimulusAttributes::class))->hasMethod('escape');
39+
40+
$book = new Book();
41+
$book->id = 123;
42+
43+
yield [
44+
"{{ turbo_stream_listen('a_topic') }}",
45+
[],
46+
$newEscape
47+
? '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"'
48+
: 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http&#x3A;&#x2F;&#x2F;127.0.0.1&#x3A;3000&#x2F;.well-known&#x2F;mercure" data-symfony--ux-turbo--mercure-turbo-stream-topic-value="a_topic"',
49+
];
50+
51+
yield [
52+
"{{ turbo_stream_listen('App\\Entity\\Book') }}",
53+
[],
54+
$newEscape
55+
? '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"'
56+
: 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http&#x3A;&#x2F;&#x2F;127.0.0.1&#x3A;3000&#x2F;.well-known&#x2F;mercure" data-symfony--ux-turbo--mercure-turbo-stream-topic-value="AppEntityBook"',
57+
];
58+
59+
yield [
60+
'{{ turbo_stream_listen(book) }}',
61+
['book' => $book],
62+
$newEscape
63+
? '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"'
64+
: 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http&#x3A;&#x2F;&#x2F;127.0.0.1&#x3A;3000&#x2F;.well-known&#x2F;mercure" data-symfony--ux-turbo--mercure-turbo-stream-topic-value="https&#x3A;&#x2F;&#x2F;symfony.com&#x2F;ux-turbo&#x2F;App&#x25;5CEntity&#x25;5CBook&#x2F;123"',
65+
];
66+
67+
yield [
68+
"{{ turbo_stream_listen(['a_topic', 'App\\Entity\\Book', book]) }}",
69+
['book' => $book],
70+
$newEscape
71+
? '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="[&quot;a_topic&quot;,&quot;AppEntityBook&quot;,&quot;https:\/\/symfony.com\/ux-turbo\/App%5CEntity%5CBook\/123&quot;]"'
72+
: 'data-controller="symfony--ux-turbo--mercure-turbo-stream" data-symfony--ux-turbo--mercure-turbo-stream-hub-value="http&#x3A;&#x2F;&#x2F;127.0.0.1&#x3A;3000&#x2F;.well-known&#x2F;mercure" data-symfony--ux-turbo--mercure-turbo-stream-topics-value="&#x5B;&quot;a_topic&quot;,&quot;AppEntityBook&quot;,&quot;https&#x3A;&#x5C;&#x2F;&#x5C;&#x2F;symfony.com&#x5C;&#x2F;ux-turbo&#x5C;&#x2F;App&#x25;5CEntity&#x25;5CBook&#x5C;&#x2F;123&quot;&#x5D;"',
73+
];
74+
}
75+
}

0 commit comments

Comments
 (0)