Skip to content

Commit

Permalink
[Turbo] Add support for providing multiple mercure topics to `turbo_s…
Browse files Browse the repository at this point in the history
…tream_listen`
  • Loading branch information
norkunas committed Nov 27, 2024
1 parent 3ea19c1 commit 57c6fcb
Show file tree
Hide file tree
Showing 8 changed files with 153 additions and 16 deletions.
1 change: 1 addition & 0 deletions src/Turbo/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
- Add `<twig:Turbo:Stream>` component
- Add `<twig:Turbo:Frame>` component
- Add support for custom actions in `TurboStream` and `TurboStreamResponse`
- Add support for providing multiple mercure topics to `turbo_stream_listen`

## 2.21.0

Expand Down
3 changes: 3 additions & 0 deletions src/Turbo/assets/dist/turbo_stream_controller.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
14 changes: 11 additions & 3 deletions src/Turbo/assets/dist/turbo_stream_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -29,6 +36,7 @@ class default_1 extends Controller {
}
default_1.values = {
topic: String,
topics: Array,
hub: String,
};

Expand Down
14 changes: 12 additions & 2 deletions src/Turbo/assets/src/turbo_stream_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down
34 changes: 34 additions & 0 deletions src/Turbo/src/Bridge/Mercure/TopicSet.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
<?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\Bridge\Mercure;

/**
* @internal
*/
final class TopicSet
{
/**
* @param array<string|object> $topics
*/
public function __construct(
private array $topics,
) {
}

/**
* @return array<string|object>
*/
public function getTopics(): array
{
return $this->topics;
}
}
40 changes: 30 additions & 10 deletions src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
}
7 changes: 6 additions & 1 deletion src/Turbo/src/Twig/TwigExtension.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -35,7 +36,7 @@ public function getFunctions(): array
}

/**
* @param object|string $topic
* @param object|string|array<object|string> $topic
*/
public function turboStreamListen(Environment $env, $topic, ?string $transport = null): string
{
Expand All @@ -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);
}
}
56 changes: 56 additions & 0 deletions src/Turbo/tests/Bridge/Mercure/TurboStreamListenRendererTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?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\Bridge\Mercure;

use App\Entity\Book;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;

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
{
$book = new Book();
$book->id = 123;

yield [
"{{ turbo_stream_listen('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') }}",
[],
'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],
'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],
'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;]"',
];
}
}

0 comments on commit 57c6fcb

Please sign in to comment.