Skip to content

Commit 63efb1d

Browse files
authored
Merge pull request #3 from nicolas-grekas/turbo
Add generic TwigBroadcaster
2 parents fdcefe7 + 573ef7d commit 63efb1d

File tree

9 files changed

+185
-131
lines changed

9 files changed

+185
-131
lines changed

src/Turbo/Broadcaster/BroadcasterInterface.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
interface BroadcasterInterface
2222
{
2323
/**
24-
* @param array{id?: string|string[], transports?: string[], topics?: string[], template?: string} $options
24+
* @param array{id?: string|string[], transports?: string|string[], topics?: string|string[], template?: string, rendered_action?: string} $options
2525
*/
2626
public function broadcast(object $entity, string $action, array $options): void;
2727
}

src/Turbo/Broadcaster/IdAccessor.php

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\Broadcaster;
13+
14+
use Doctrine\Persistence\ManagerRegistry;
15+
use Symfony\Component\PropertyAccess\PropertyAccess;
16+
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
17+
18+
class IdAccessor
19+
{
20+
private $propertyAccessor;
21+
private $doctrine;
22+
23+
public function __construct(PropertyAccessorInterface $propertyAccessor = null, ManagerRegistry $doctrine = null)
24+
{
25+
$this->propertyAccessor = $propertyAccessor ?? (class_exists(PropertyAccess::class) ? PropertyAccess::createPropertyAccessor() : null);
26+
$this->doctrine = $doctrine;
27+
}
28+
29+
/**
30+
* @return string[]
31+
*/
32+
public function getEntityId(object $entity): ?array
33+
{
34+
$entityClass = \get_class($entity);
35+
36+
if ($this->doctrine && $em = $this->doctrine->getManagerForClass($entityClass)) {
37+
return $em->getClassMetadata($entityClass)->getIdentifierValues($entity);
38+
}
39+
40+
if ($this->propertyAccessor) {
41+
return (array) $this->propertyAccessor->getValue($entity, 'id');
42+
}
43+
44+
return null;
45+
}
46+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
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\Broadcaster;
13+
14+
use Twig\Environment;
15+
16+
/**
17+
* Renders the incoming entity using Twig before passing it to a broadcaster.
18+
*
19+
* @author Kévin Dunglas <[email protected]>
20+
*/
21+
final class TwigBroadcaster implements BroadcasterInterface
22+
{
23+
private $broadcaster;
24+
private $twig;
25+
private $templatePrefixes;
26+
private $idAccessor;
27+
28+
/**
29+
* @param array<string, string> $templatePrefixes
30+
*/
31+
public function __construct(BroadcasterInterface $broadcaster, Environment $twig, array $templatePrefixes = [], IdAccessor $idAccessor = null)
32+
{
33+
$this->broadcaster = $broadcaster;
34+
$this->twig = $twig;
35+
$this->templatePrefixes = $templatePrefixes;
36+
$this->idAccessor = $idAccessor ?? new IdAccessor();
37+
}
38+
39+
/**
40+
* {@inheritdoc}
41+
*/
42+
public function broadcast(object $entity, string $action, array $options): void
43+
{
44+
if (!isset($options['id']) && null !== $id = $this->idAccessor->getEntityId($entity)) {
45+
$options['id'] = $id;
46+
}
47+
48+
if (null === $template = $options['template'] ?? null) {
49+
$template = \get_class($entity);
50+
foreach ($this->templatePrefixes as $namespace => $prefix) {
51+
if (0 === strpos($template, $namespace)) {
52+
$template = substr_replace($template, $prefix, 0, \strlen($namespace));
53+
break;
54+
}
55+
}
56+
57+
$template = str_replace('\\', '/', $template).'.stream.html.twig';
58+
}
59+
60+
// Will throw if the template or the block doesn't exist
61+
$options['rendered_action'] = $this->twig
62+
->load($template)
63+
->renderBlock($action, [
64+
'entity' => $entity,
65+
'action' => $action,
66+
'id' => implode('-', (array) ($options['id'] ?? [])),
67+
] + $options);
68+
69+
$this->broadcaster->broadcast($entity, $action, $options);
70+
}
71+
}

src/Turbo/DependencyInjection/TurboExtension.php

Lines changed: 16 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -25,11 +25,7 @@
2525
use Symfony\Component\HttpKernel\DependencyInjection\Extension;
2626
use Symfony\Component\Mercure\HubInterface;
2727
use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface;
28-
use Symfony\UX\Turbo\Doctrine\BroadcastListener;
29-
use Symfony\UX\Turbo\Mercure\Broadcaster;
30-
use Symfony\UX\Turbo\Mercure\TurboStreamListenRenderer;
3128
use Symfony\UX\Turbo\Twig\TurboStreamListenRendererInterface;
32-
use Symfony\UX\Turbo\Twig\TwigExtension;
3329

3430
/**
3531
* @author Kévin Dunglas <[email protected]>
@@ -48,21 +44,25 @@ public function load(array $configs, ContainerBuilder $container): void
4844

4945
$loader = (new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/Resources/config')));
5046
$loader->load('services.php');
51-
$container->getDefinition(TwigExtension::class)->replaceArgument(1, $config['default_transport']);
47+
$container->getDefinition('turbo.twig.extension')->replaceArgument(1, $config['default_transport']);
5248

53-
$this->registerTwig($container);
49+
$this->registerTwig($config, $container);
5450
$this->registerBroadcast($config, $container, $loader);
5551
$this->registerMercureTransports($config, $container, $loader);
5652
}
5753

58-
private function registerTwig(ContainerBuilder $container): void
54+
/**
55+
* @param array<string, mixed> $config
56+
*/
57+
private function registerTwig(array $config, ContainerBuilder $container): void
5958
{
6059
if (!class_exists(TwigBundle::class)) {
61-
$container->removeDefinition(TwigExtension::class);
62-
6360
return;
6461
}
6562

63+
$container->getDefinition('turbo.broadcaster.action_renderer')
64+
->replaceArgument(2, $config['broadcast']['entity_template_prefixes']);
65+
6666
$container
6767
->registerForAutoconfiguration(TurboStreamListenRendererInterface::class)
6868
->addTag('turbo.renderer.stream_listen');
@@ -74,7 +74,8 @@ private function registerTwig(ContainerBuilder $container): void
7474
private function registerBroadcast(array $config, ContainerBuilder $container, LoaderInterface $loader): void
7575
{
7676
if (!$config['broadcast']['enabled']) {
77-
$container->removeDefinition(BroadcasterInterface::class);
77+
$container->removeDefinition('turbo.twig.extension');
78+
$container->removeDefinition('turbo.doctrine.event_listener');
7879

7980
return;
8081
}
@@ -89,7 +90,7 @@ private function registerBroadcast(array $config, ContainerBuilder $container, L
8990
;
9091

9192
if (!$config['broadcast']['doctrine_orm']['enabled']) {
92-
$container->removeDefinition(BroadcastListener::class);
93+
$container->removeDefinition('turbo.doctrine.event_listener');
9394

9495
return;
9596
}
@@ -136,18 +137,17 @@ private function registerMercureTransports(array $config, ContainerBuilder $cont
136137
*/
137138
private function registerMercureTransport(ContainerBuilder $container, array $config, string $name, string $hubId): void
138139
{
139-
$renderer = $container->setDefinition("turbo.mercure.{$name}.renderer", new ChildDefinition(TurboStreamListenRenderer::class));
140+
$renderer = $container->setDefinition("turbo.mercure.{$name}.renderer", new ChildDefinition('turbo.stream_listen_renderer.mercure'));
140141
$renderer->replaceArgument(0, new Reference($hubId));
141-
$renderer->addTag('turbo.renderer.stream_listen', ['index' => $name]);
142+
$renderer->addTag('turbo.renderer.stream_listen', ['transport' => $name]);
142143

143144
if (!$config['broadcast']['enabled']) {
144145
return;
145146
}
146147

147-
$broadcaster = $container->setDefinition("turbo.mercure.{$name}.broadcaster", new ChildDefinition(Broadcaster::class));
148+
$broadcaster = $container->setDefinition("turbo.mercure.{$name}.broadcaster", new ChildDefinition('turbo.broadcaster.mercure'));
148149
$broadcaster->replaceArgument(0, $name);
149-
$broadcaster->replaceArgument(2, new Reference($hubId));
150-
$broadcaster->replaceArgument(3, $config['broadcast']['entity_template_prefixes']);
150+
$broadcaster->replaceArgument(1, new Reference($hubId));
151151
$broadcaster->addTag('turbo.broadcaster');
152152
}
153153
}

src/Turbo/Mercure/Broadcaster.php

Lines changed: 15 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,8 @@
1313

1414
use Symfony\Component\Mercure\HubInterface;
1515
use Symfony\Component\Mercure\Update;
16-
use Symfony\Component\PropertyAccess\PropertyAccess;
17-
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
1816
use Symfony\UX\Turbo\Attribute\Broadcast;
1917
use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface;
20-
use Twig\Environment;
2118

2219
/**
2320
* Broadcasts updates rendered using Twig with Mercure.
@@ -27,7 +24,7 @@
2724
* * id (string[]) The (potentially composite) identifier of the broadcasted entity
2825
* * transports (string[]) The name of the transports to broadcast to
2926
* * topics (string[]) The topics to use; the default topic is derived from the FQCN of the entity and from its id
30-
* * template (string) The Twig template to render when a new object is created, updated or removed
27+
* * rendered_action (string) The turbo-stream action rendered as HTML
3128
* * private (bool) Marks Mercure updates as private
3229
* * sse_id (string) ID field of the SSE
3330
* * sse_type (string) type field of the SSE
@@ -45,58 +42,38 @@ final class Broadcaster implements BroadcasterInterface
4542
public const TOPIC_PATTERN = 'https://symfony.com/ux-turbo/%s/%s';
4643

4744
private $name;
48-
private $twig;
4945
private $hub;
50-
private $propertyAccessor;
51-
private $templatePrefixes;
5246

53-
private const OPTIONS = [
54-
// Generic options
55-
'id',
56-
'transports',
57-
// Twig options
58-
'template',
59-
// Mercure options
60-
'topics',
61-
'private',
62-
'sse_id',
63-
'sse_type',
64-
'sse_retry',
65-
];
66-
67-
/**
68-
* @param array<string, string> $templatePrefixes
69-
*/
70-
public function __construct(string $name, Environment $twig, HubInterface $hub, array $templatePrefixes = [], PropertyAccessorInterface $propertyAccessor = null)
47+
public function __construct(string $name, HubInterface $hub)
7148
{
72-
if (80000 > \PHP_VERSION_ID) {
73-
throw new \LogicException('The broadcast feature requires PHP 8.0 or greater, you must either upgrade to PHP 8 or disable it.');
74-
}
75-
7649
$this->name = $name;
77-
$this->twig = $twig;
7850
$this->hub = $hub;
79-
$this->templatePrefixes = $templatePrefixes;
80-
$this->propertyAccessor = $propertyAccessor ?? (class_exists(PropertyAccess::class) ? PropertyAccess::createPropertyAccessor() : null);
8151
}
8252

8353
/**
8454
* {@inheritdoc}
8555
*/
8656
public function broadcast(object $entity, string $action, array $options): void
8757
{
88-
$options = $this->normalizeOptions($entity, $action, $options);
89-
90-
if (isset($options['transports']) && !\in_array($this->name, $options['transports'], true)) {
58+
if (isset($options['transports']) && !\in_array($this->name, (array) $options['transports'], true)) {
9159
return;
9260
}
9361

94-
// Will throw if the template or the block doesn't exist
95-
$data = $this->twig->load($options['template'])->renderBlock($action, ['entity' => $entity, 'action' => $action] + $options);
62+
$entityClass = \get_class($entity);
63+
64+
if (!isset($options['rendered_action'])) {
65+
throw new \InvalidArgumentException(sprintf('Cannot broadcast entity of class "%s" as option "rendered_action" is missing.', $entityClass));
66+
}
67+
68+
if (!isset($options['topic']) && !isset($options['id'])) {
69+
throw new \InvalidArgumentException(sprintf('Cannot broadcast entity of class "%s": either option "topics" or "id" is missing, or the PropertyAccess component is not installed. Try running "composer require property-access".', $entityClass));
70+
}
71+
72+
$options['topics'] = (array) ($options['topics'] ?? sprintf(self::TOPIC_PATTERN, rawurlencode($entityClass), rawurlencode(implode('-', (array) $options['id']))));
9673

9774
$update = new Update(
9875
$options['topics'],
99-
$data,
76+
$options['rendered_action'],
10077
$options['private'] ?? false,
10178
$options['sse_id'] ?? null,
10279
$options['sse_type'] ?? null,
@@ -105,49 +82,4 @@ public function broadcast(object $entity, string $action, array $options): void
10582

10683
$this->hub->publish($update);
10784
}
108-
109-
/**
110-
* @param mixed[] $options
111-
*
112-
* @return mixed[]
113-
*/
114-
private function normalizeOptions(object $entity, string $action, array $options): array
115-
{
116-
if (isset($options['transports'])) {
117-
$options['transports'] = (array) $options['transports'];
118-
}
119-
120-
$entityClass = \get_class($entity);
121-
122-
if ($extraKeys = array_diff(array_keys($options), self::OPTIONS)) {
123-
throw new \InvalidArgumentException(sprintf('Unknown broadcast options "%s" on class "%s". Valid options are: "%s"', implode('", "', $extraKeys), $entityClass, implode('", "', self::OPTIONS)));
124-
}
125-
126-
if (isset($options['id'])) {
127-
$options['id'] = \is_array($options['id']) ? implode('-', $options['id']) : $options['id'];
128-
} elseif (!isset($options['topics'])) {
129-
if (!$this->propertyAccessor) {
130-
throw new \InvalidArgumentException(sprintf('Cannot broadcast entity of class "%s": either option "topics" or "id" is missing, or the PropertyAccess component is not installed. Try running "composer require property-access".', $entityClass));
131-
}
132-
133-
$options['id'] = $this->propertyAccessor->getValue($entity, 'id');
134-
}
135-
136-
$options['topics'] = (array) ($options['topics'] ?? sprintf(self::TOPIC_PATTERN, rawurlencode($entityClass), rawurlencode($options['id'])));
137-
if (isset($options['template'])) {
138-
return $options;
139-
}
140-
141-
$file = $entityClass;
142-
foreach ($this->templatePrefixes as $namespace => $prefix) {
143-
if (0 === strpos($entityClass, $namespace)) {
144-
$file = substr_replace($entityClass, $prefix, 0, \strlen($namespace));
145-
break;
146-
}
147-
}
148-
149-
$options['template'] = str_replace('\\', '/', $file).'.stream.html.twig';
150-
151-
return $options;
152-
}
15385
}

0 commit comments

Comments
 (0)