From d28e5af04fa93fa6cc55a9f1be07a86013c93b82 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Fri, 26 Mar 2021 00:13:46 +0100 Subject: [PATCH 1/2] Tweaks and review --- src/Turbo/{ => Attribute}/Broadcast.php | 4 +- .../DependencyInjection/Configuration.php | 16 ++- .../DependencyInjection/TurboExtension.php | 28 ++-- src/Turbo/Doctrine/BroadcastListener.php | 12 +- src/Turbo/LICENSE | 2 +- src/Turbo/Mercure/Broadcaster.php | 49 +++---- .../Mercure/TurboStreamListenRenderer.php | 2 +- src/Turbo/README.md | 122 ++++++++---------- src/Turbo/Resources/config/doctrine_orm.php | 28 ---- src/Turbo/Resources/config/mercure.php | 8 +- src/Turbo/Resources/config/services.php | 20 ++- .../Stream/AddTurboStreamFormatSubscriber.php | 2 +- src/Turbo/Tests/BroadcastTest.php | 2 +- src/Turbo/Tests/app/Entity/Book.php | 2 +- src/Turbo/Twig/TwigExtension.php | 6 +- src/Turbo/composer.json | 32 ++--- 16 files changed, 147 insertions(+), 188 deletions(-) rename src/Turbo/{ => Attribute}/Broadcast.php (81%) delete mode 100644 src/Turbo/Resources/config/doctrine_orm.php diff --git a/src/Turbo/Broadcast.php b/src/Turbo/Attribute/Broadcast.php similarity index 81% rename from src/Turbo/Broadcast.php rename to src/Turbo/Attribute/Broadcast.php index e581e37c69e..a0fc47d53fe 100644 --- a/src/Turbo/Broadcast.php +++ b/src/Turbo/Attribute/Broadcast.php @@ -9,7 +9,7 @@ * file that was distributed with this source code. */ -namespace Symfony\UX\Turbo; +namespace Symfony\UX\Turbo\Attribute; use Symfony\UX\Turbo\Mercure\Broadcaster; @@ -35,7 +35,7 @@ final class Broadcast /** * Options can be any option supported by the broadcaster. * - * @see Broadcaster for the default options + * @see Broadcaster for the default options // there is some coupling with Mercure here - can't we extract some options as named (generic) arguments? */ public function __construct(mixed ...$options) { diff --git a/src/Turbo/DependencyInjection/Configuration.php b/src/Turbo/DependencyInjection/Configuration.php index 97fe63ec8e1..9a39dd4b987 100644 --- a/src/Turbo/DependencyInjection/Configuration.php +++ b/src/Turbo/DependencyInjection/Configuration.php @@ -12,7 +12,6 @@ namespace Symfony\UX\Turbo\DependencyInjection; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; -use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\MercureBundle\MercureBundle; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; @@ -35,19 +34,26 @@ public function getConfigTreeBuilder(): TreeBuilder ->arrayNode('broadcast') ->{\PHP_VERSION_ID >= 80000 ? 'canBeDisabled' : 'canBeEnabled'}() ->children() - ->scalarNode('entity_namespace')->info('Prefix to strip when looking for broadcast templates')->defaultValue('App\\Entity\\')->end() + ->scalarNode('entity_namespace') + ->info('Prefix to strip when looking for broadcast templates') + ->defaultValue('App\\Entity\\') // we should remove the default and ship it with a recipe instead + ->end() ->arrayNode('doctrine_orm') ->info('Enable the Doctrine ORM integration') - ->{class_exists(DoctrineBundle::class) && interface_exists(EntityManagerInterface::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->{class_exists(DoctrineBundle::class) ? 'canBeDisabled' : 'canBeEnabled'}() ->end() ->end() ->end() ->scalarNode('default_transport')->defaultValue('default')->end() ->arrayNode('mercure') ->{class_exists(MercureBundle::class) ? 'canBeDisabled' : 'canBeEnabled'}() - ->info('If the Mercure hubs to use as transports aren\'t configured explicitly, a transport named "default" using the default Mercure hub will be automatically created.') + ->info('If no Mercure hubs are configured explicitly, the default Mercure hub will be used.') ->children() - ->arrayNode('hubs')->scalarPrototype()->info('The name of the Mercure hubs (configured in MercureBundle) to use as transports')->end() + ->arrayNode('hubs') + ->fixXmlConfig('hub') + ->scalarPrototype() + ->info('The name of the Mercure hubs (configured in MercureBundle) to use as transports') + ->end() ->end() ->end() ->end() diff --git a/src/Turbo/DependencyInjection/TurboExtension.php b/src/Turbo/DependencyInjection/TurboExtension.php index f404d132c60..9d784d17050 100644 --- a/src/Turbo/DependencyInjection/TurboExtension.php +++ b/src/Turbo/DependencyInjection/TurboExtension.php @@ -12,7 +12,6 @@ namespace Symfony\UX\Turbo\DependencyInjection; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; -use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\MercureBundle\MercureBundle; use Symfony\Bundle\TwigBundle\TwigBundle; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; @@ -26,6 +25,7 @@ use Symfony\Component\Mercure\Hub; use Symfony\Component\Mercure\HubInterface; use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; +use Symfony\UX\Turbo\Doctrine\BroadcastListener; use Symfony\UX\Turbo\Mercure\Broadcaster; use Symfony\UX\Turbo\Mercure\TurboStreamListenRenderer; use Symfony\UX\Turbo\Twig\TurboStreamListenRendererInterface; @@ -46,7 +46,7 @@ public function load(array $configs, ContainerBuilder $container): void $configuration = new Configuration(); $config = $this->processConfiguration($configuration, $configs); - $loader = (new PhpFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'))); + $loader = (new PhpFileLoader($container, new FileLocator(\dirname(__DIR__).'/Resources/config'))); $loader->load('services.php'); $container->getDefinition(TwigExtension::class)->replaceArgument(1, $config['default_transport']); @@ -80,7 +80,7 @@ private function registerBroadcast(array $config, ContainerBuilder $container, L } if (\PHP_VERSION_ID < 80000) { - throw new InvalidConfigurationException('Enabling the "broadcast" configuration option requires PHP 8 or higher.'); + throw new InvalidConfigurationException('Enabling the "turbo.broadcast" configuration option requires PHP 8 or higher.'); } $container @@ -89,14 +89,14 @@ private function registerBroadcast(array $config, ContainerBuilder $container, L ; if (!$config['broadcast']['doctrine_orm']['enabled']) { + $container->removeDefinition(BroadcastListener::class); + return; } - if (!class_exists(DoctrineBundle::class) || !interface_exists(EntityManagerInterface::class)) { - throw new InvalidConfigurationException('You cannot use the Doctrine ORM integration as the "doctrine/doctrine-bundle" package is not installed. Try running "composer require symfony/orm-pack".'); + if (!class_exists(DoctrineBundle::class)) { + throw new InvalidConfigurationException('You cannot use the Doctrine ORM integration as "doctrine/doctrine-bundle" is not installed. Try running "composer require symfony/orm-pack".'); } - - $loader->load('doctrine_orm.php'); } /** @@ -104,16 +104,16 @@ private function registerBroadcast(array $config, ContainerBuilder $container, L */ private function registerMercureTransports(array $config, ContainerBuilder $container, LoaderInterface $loader): void { - if (!$config['mercure']) { + if (!$config['mercure']['enabled']) { return; } if (!class_exists(MercureBundle::class)) { - throw new InvalidConfigurationException('You cannot use the Mercure integration as the "symfony/mercure-bundle" package is not installed. Try running "composer require symfony/mercure-bundle".'); + throw new InvalidConfigurationException('You cannot use the Mercure integration as "symfony/mercure-bundle" is not installed. Try running "composer require symfony/mercure-bundle".'); } if (!class_exists(TwigBundle::class)) { - throw new InvalidConfigurationException('You cannot use the Mercure integration as the "symfony/twig-bundle" package is not installed. Try running "composer require symfony/twig-pack".'); + throw new InvalidConfigurationException('You cannot use the Mercure integration as "symfony/twig-bundle" is not installed. Try running "composer require symfony/twig-pack".'); } $loader->load('mercure.php'); @@ -135,11 +135,9 @@ private function registerMercureTransports(array $config, ContainerBuilder $cont */ private function registerMercureTransport(ContainerBuilder $container, array $config, string $name, string $hubId): void { - $hubService = new Reference($hubId); - $renderer = $container->setDefinition("turbo.mercure.{$name}.renderer", new ChildDefinition(TurboStreamListenRenderer::class)); - $renderer->replaceArgument(0, $hubService); - $renderer->addTag('turbo.renderer.stream_listen', ['key' => $name]); + $renderer->replaceArgument(0, new Reference($hubId)); + $renderer->addTag('turbo.renderer.stream_listen', ['index' => $name]); if (!$config['broadcast']['enabled']) { return; @@ -147,7 +145,7 @@ private function registerMercureTransport(ContainerBuilder $container, array $co $broadcaster = $container->setDefinition("turbo.mercure.{$name}.broadcaster", new ChildDefinition(Broadcaster::class)); $broadcaster->replaceArgument(0, $name); - $broadcaster->replaceArgument(2, $hubService); + $broadcaster->replaceArgument(2, new Reference($hubId)); $broadcaster->replaceArgument(5, $config['broadcast']['entity_namespace']); $broadcaster->addTag('turbo.broadcaster'); } diff --git a/src/Turbo/Doctrine/BroadcastListener.php b/src/Turbo/Doctrine/BroadcastListener.php index b7b1a964a11..f051da03743 100644 --- a/src/Turbo/Doctrine/BroadcastListener.php +++ b/src/Turbo/Doctrine/BroadcastListener.php @@ -14,7 +14,7 @@ use Doctrine\Common\EventArgs; use Doctrine\ORM\Event\OnFlushEventArgs; use Symfony\Contracts\Service\ResetInterface; -use Symfony\UX\Turbo\Broadcast; +use Symfony\UX\Turbo\Attribute\Broadcast; use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; /** @@ -22,15 +22,12 @@ * * @author Kévin Dunglas * - * @see https://github.com/api-platform/core/blob/master/src/Bridge/Doctrine/EventListener/PublishMercureUpdatesListener.php Adapted from API Platform. - * - * @todo backport MongoDB support - * * @experimental */ final class BroadcastListener implements ResetInterface { private $broadcaster; + private $broadcastedClasses; /** * @var \SplObjectStorage @@ -110,7 +107,10 @@ public function reset(): void private function storeEntitiesToPublish(object $entity, string $property): void { - if ((new \ReflectionClass($entity))->getAttributes(Broadcast::class)) { + $class = \get_class($entity); + + if ($this->broadcastedClasses[$class] ?? $this->broadcastedClasses[$class] = (bool) (new \ReflectionClass($class))->getAttributes(Broadcast::class)) { + // what happens if we don't clone removed entities here? $this->{$property}->attach('removedEntities' === $property ? clone $entity : $entity); } } diff --git a/src/Turbo/LICENSE b/src/Turbo/LICENSE index 5dcd9af2f97..efb17f98e7d 100644 --- a/src/Turbo/LICENSE +++ b/src/Turbo/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2021 Kévin Dunglas +Copyright (c) 2021 Fabien Potencier Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/src/Turbo/Mercure/Broadcaster.php b/src/Turbo/Mercure/Broadcaster.php index 07c851d558f..cd296f07c10 100644 --- a/src/Turbo/Mercure/Broadcaster.php +++ b/src/Turbo/Mercure/Broadcaster.php @@ -16,7 +16,7 @@ use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\Component\Security\Core\Authorization\ExpressionLanguage; -use Symfony\UX\Turbo\Broadcast; +use Symfony\UX\Turbo\Attribute\Broadcast; use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; use Twig\Environment; @@ -25,14 +25,15 @@ * * Supported options are: * - * * topics (string[]) Mercure topics to use, defaults to an array containing the Fully Qualified Class Name with "\" characters replaced by ":" characters. - * * createTemplate (string) The Twig template to render when a new object is created - * * updateTemplate (string) The Twig template to render when a new object is updated - * * removeTemplate (string) The Twig template to render when a new object is removed - * * private (bool) Marks Mercure updates as private - * * id (string) ID field of the SSE - * * type (string) type field of the SSE - * * retry (int) retry field of the SSE + * * topics (string[]) Mercure topics to use, defaults to an array containing the Fully Qualified Class Name with "\" characters replaced by ":" characters. + * * createTemplate (string) The Twig template to render when a new object is created + * * updateTemplate (string) The Twig template to render when a new object is updated + * * removeTemplate (string) The Twig template to render when a new object is removed + * * private (bool) Marks Mercure updates as private + * * id (string) ID field of the SSE + * * type (string) type field of the SSE + * * retry (int) retry field of the SSE + * * transports (string[]) * * The options can also be generated using the ExpressionLanguage language: if the option is a string, it is evaluated as an expression that must return an array. * @@ -51,12 +52,8 @@ final class Broadcaster implements BroadcasterInterface private $twig; private $hub; private $propertyAccessor; - private $entityNamespace; - - /** - * @var ExpressionLanguage|null - */ private $expressionLanguage; + private $entityNamespace; private const OPTIONS = [ // Generic options @@ -71,14 +68,8 @@ final class Broadcaster implements BroadcasterInterface 'retry', ]; - public function __construct( - string $name, - Environment $twig, - HubInterface $hub, - ?PropertyAccessorInterface $propertyAccessor, - ?ExpressionLanguage $expressionLanguage = null, - ?string $entityNamespace = null - ) { + public function __construct(string $name, Environment $twig, HubInterface $hub, ?PropertyAccessorInterface $propertyAccessor, ExpressionLanguage $expressionLanguage = null, string $entityNamespace = null) + { if (80000 > \PHP_VERSION_ID) { throw new \LogicException('The broadcast feature requires PHP 8.0 or greater, you must either upgrade to PHP 8 or disable it.'); } @@ -108,11 +99,11 @@ public function broadcast(object $entity, string $action): void $broadcast = $attribute->newInstance(); $options = $this->normalizeOptions($entity, $action, $broadcast->options); - if (isset($options['transports']) && !\in_array($this->name, $options['transports'], false)) { + if (isset($options['transports']) && !\in_array($this->name, $options['transports'], true)) { return; } - // What must we do if the template or the block doesn't exist? Throwing for now. + // Will throw if the template or the block doesn't exist $data = $this->twig->load($options['template'])->renderBlock($action, ['entity' => $entity, 'action' => $action, 'options' => $options]); $update = new Update( @@ -134,11 +125,7 @@ public function broadcast(object $entity, string $action): void */ private function normalizeOptions(object $entity, string $action, array $options): array { - if (isset($options['transports'])) { - $options['transports'] = (array) $options['transports']; - } - - if (\is_string($options[0] ?? null)) { + if ([0] === array_keys($options) && \is_string($options[0])) { if (null === $this->expressionLanguage) { throw new \RuntimeException('The Expression Language component is not installed. Try running "composer require symfony/expression-language".'); } @@ -146,6 +133,10 @@ private function normalizeOptions(object $entity, string $action, array $options $options = $this->expressionLanguage->evaluate($options[0], ['entity' => $entity, 'action' => $action]); } + if (isset($options['transports'])) { + $options['transports'] = (array) $options['transports']; + } + $entityClass = \get_class($entity); if ($extraKeys = array_diff(array_keys($options), self::OPTIONS)) { diff --git a/src/Turbo/Mercure/TurboStreamListenRenderer.php b/src/Turbo/Mercure/TurboStreamListenRenderer.php index 140554d3d42..51af0340eeb 100644 --- a/src/Turbo/Mercure/TurboStreamListenRenderer.php +++ b/src/Turbo/Mercure/TurboStreamListenRenderer.php @@ -40,7 +40,7 @@ public function renderTurboStreamListen(Environment $env, $topic): string { if (\is_object($topic)) { $topic = sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode(\get_class($topic)), rawurlencode($this->propertyAccessor->getValue($topic, 'id'))); - } elseif (class_exists($topic)) { + } elseif (!preg_match('[^a-zA-Z0-9_\x7f-\xff\\\\]') && 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}'); } diff --git a/src/Turbo/README.md b/src/Turbo/README.md index b5812d43f4e..32156fa0bd7 100644 --- a/src/Turbo/README.md +++ b/src/Turbo/README.md @@ -6,15 +6,15 @@ library in Symfony applications. It is part of [the Symfony UX initiative](https Symfony UX Turbo allows having the same user experience as with [Single Page Apps](https://en.wikipedia.org/wiki/Single-page_application) but without having to write a single line of JavaScript! -Symfony UX Turbo also integrates with [Symfony Mercure](https://symfony.com/doc/current/mercure.html) to broadcast DOM changes -to all currently connected users! +Symfony UX Turbo also integrates with [Symfony Mercure](https://symfony.com/doc/current/mercure.html) +or any other transports to broadcast DOM changes to all currently connected users! -You're in a hurry? Take a look to [the chat example](#sending-async-changes-using-mercure-a-chat) to discover the full potential -of Symfony UX Turbo. +You're in a hurry? Take a look at [the chat example](#sending-async-changes-using-mercure-a-chat) +to discover the full potential of Symfony UX Turbo. ## Installation -Symfony UX Turbo requires PHP 8+ and Symfony 4.4+. +Symfony UX Turbo requires PHP 7.2+ and Symfony 5.2+. Install this bundle using Composer and Symfony Flex: @@ -23,7 +23,7 @@ composer require symfony/ux-turbo # Don't forget to install the JavaScript dependencies as well and compile yarn install --force -yarn encore dev +yarn encore dev // "./node_modules/@symfony/stimulus-bridge/dist/webpack/loader.js!./assets/controllers.json" contains a reference to the file "@symfony/ux-turbo/dist/turbo_stream_controller.js". ``` ## Usage @@ -74,7 +74,7 @@ The content of a frame can be lazy loaded: {% extends 'base.html.twig' %} {% block body %} - + // can we make it clearer what "block" is? A placeholder. {% endblock %} @@ -133,9 +133,9 @@ class TurboFrameTest extends PantherTestCase ``` Run `bin/phpunit` to execute the test! Symfony Panther automatically starts your application with a web server -and test it using Google Chrome (Firefox is also supported)! +and tests it using Google Chrome or Firefox! -You can even watch changes happening in Chrome by using: `PANTHER_NO_HEADLESS=1 bin/phpunit --debug` +You can even watch changes happening in the browser by using: `PANTHER_NO_HEADLESS=1 bin/phpunit --debug` [Read the Turbo Frames documentation](https://turbo.hotwire.dev/handbook/frames) to learn everything you can do using Turbo Frames. @@ -144,8 +144,9 @@ You can even watch changes happening in Chrome by using: `PANTHER_NO_HEADLESS=1 Turbo Streams are a way for the server to send partial page updates to clients. There are two main ways to receive the updates: -- in response to a user action, for instance when the user submits a form -- asynchronously, by sending updates to clients using [Mercure](https://mercure.rocks), [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) and similar protocols + - in response to a user action, for instance when the user submits a form; + - asynchronously, by sending updates to clients using [Mercure](https://mercure.rocks), + [WebSocket](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) and similar protocols. #### Forms @@ -168,8 +169,8 @@ class TaskController extends AbstractController $task = new Task(); $form = $this->createForm(TaskType::class, $task); - $form->handleRequest($request); + if ($form->isSubmitted() && $form->isValid()) { $task = $form->getData(); // ... perform some action, such as saving the task to the database @@ -209,8 +210,8 @@ Supported actions are `append`, `prepend`, `replace`, `update` and `remove`. #### Sending Async Changes using Mercure: a Chat -Symfony UX Turbo also supports the [Mercure](https://mercure.rocks) protocol. With this feature you can broadcast HTML -updates to all currently connected clients. +Symfony UX Turbo also supports broadcasting HTML updates to all currently connected clients, +using the [Mercure](https://mercure.rocks) protocol or any other. To illustrate this, let's build a chat system with **0 lines of JavaScript**! @@ -227,7 +228,7 @@ Otherwise, configure the URL of the Mercure Hub: # config/packages/turbo.yaml turbo: mercure: - subscribe_url: https://localhost/.well-known/mercure + subscribe_url: https://localhost/.well-known/mercure // config option doesn't exist ``` Let's create our chat: @@ -260,7 +261,7 @@ class ChatController extends AbstractController // 🔥 The magic happens here! 🔥 // The HTML update is pushed to the client using Mercure - // The Mercure topic (here "chat") MUST be the ID of the block that will display the received updates + // The Mercure topic (here "chat") MUST be the ID of the block that will display the received updates // in the twig below; we never see any id="chat", this might be confusing $mercure->publish(new Update( 'chat', $this->renderView('chat/message.stream.html.twig', ['message' => $data['message']]) @@ -288,8 +289,8 @@ class ChatController extends AbstractController
{# The messages will be displayed here. - "turbo_stream_listen()" automatically registers a Stimulus controller that subscribes to the "chat" Mercure topic using EventSource. - The connection to the Mercure Hub is automatically closed when this HTML block is removed. + "turbo_stream_listen()" automatically registers a Stimulus controller that subscribes to the "chat" topic of the transport. + The transport connection is automatically closed when this HTML block is removed. All connected users will receive the new messages! #} @@ -324,14 +325,14 @@ Keep in mind that you can use all features provided by Symfony Mercure, includin Symfony UX Turbo also comes with a convenient integration with Doctrine ORM. -With a single attribute, your clients can subscribe to creation, update and deletion of entities: +With a single attribute, your clients can subscribe to creations, updates and deletions of entities: ```php // src/Entity/Book.php namespace App\Entity; use Doctrine\ORM\Mapping as ORM; -use Symfony\UX\Turbo\Broadcast; +use Symfony\UX\Turbo\Attribute\Broadcast; /** * @ORM\Entity @@ -395,7 +396,7 @@ By convention, Symfony UX Turbo will look for a template named `templates/broadc This template **must** contain at least 3 blocks: `create`, `update` and `remove` (they can be empty, but they must exist). Every time an entity marked with the `Broadcast` attribute changes, Symfony UX Turbo will render the associated template -and will use Mercure to broadcast the changes to all connected clients. +and will broadcast the changes to all connected clients. Each block must contain a list of Turbo Stream actions. These actions will be automatically applied by Turbo to the DOM tree of every connected client. Each template can contain as many actions as needed. @@ -409,11 +410,14 @@ are passed to the template as variables: `entity`, `action` and `options`. ### Broadcast Conventions and Configuration -The entity class **must** have a public property named `id` or a public method named `getId()`. +When using the Mercure transport, the entity class **must** have a public property named `id` or a public method named `getId()`. If your entities aren't in the `App\Entity` namespace, Symfony UX Turbo will look for a template in a directory named after their Fully Qualified Class Names. For instance, if a class marked with the `Broadcast` attribute is named `\App\Foo\Bar`, the corresponding template will be `templates/broadcast/App/Foo/Bar.stream.html.twig`. +// do we really need the above convention? Can't we force such entities to have a template attribute instead? +// The less conventions the better, remember the @Template annotation... +// Here is another convention highlighted in the example config, mapping entities to template directories: It's possible to use the `turbo.broadcast.entity_namespace` configuration parameter to change the default entity namespace: @@ -421,7 +425,8 @@ It's possible to use the `turbo.broadcast.entity_namespace` configuration parame # config/packages/turbo.yaml turbo: broadcast: - entity_namespace: App\Foo + entity_template_dirs: + App\Entity\: broadcast/ ``` Finally, it's also possible to explicitly set the template to use with the `template` parameter of the `Broadcast` attribute: @@ -434,13 +439,15 @@ class Book { /* ... */ } ### Broadcast Options The `Broadcast` attribute comes with a set of handy options: +// These are tightly coupled to Mercure currently: the Broadcast attribute is variadic. Can't we make this more generic and discoverable, with actual named arguments, at least for some of them? -- `template` (`string`): Twig template to render (see above) -- `topics` (`string[]`): Mercure topics to use, defaults to an array containing the Fully Qualified Class Name -- `private` (`bool`): marks Mercure updates as private -- `id` (`string`): `id` field of the SSE -- `type` (`string`): `type` field of the SSE -- `retry` (`int`): `retry` field of the SSE + - `template` (`string`): Twig template to render (see above) + - `topics` (`string[]`): Mercure topics to use, defaults to an array containing the Fully Qualified Class Name + - `private` (`bool`): marks Mercure updates as private + - `id` (`string`): `id` field of the SSE + - `type` (`string`): `type` field of the SSE + - `retry` (`int`): `retry` field of the SSE // what's the use case for these 3 SSE elements? Can't we remove them to keep this generic? + - `transports` is missing Example: @@ -448,7 +455,7 @@ Example: // src/Entity/Book.php namespace App\Entity; -use Symfony\UX\Turbo\Broadcast; +use Symfony\UX\Turbo\Attribute\Broadcast; #[Broadcast(template: 'foo.stream.html.twig', private: true)] class Book @@ -457,7 +464,7 @@ class Book } ``` -### Using an Expression to Generate The Options +### Using an Expression to Generate the Options If the [Symfony ExpressionLanguage Component](https://symfony.com/doc/current/components/expression_language.html) is installed, you can also pass an _expression_ generating the options as parameter of the `Broadcast` attribute: @@ -466,7 +473,7 @@ you can also pass an _expression_ generating the options as parameter of the `Br // src/Entity/Book.php namespace App\Entity; -use Symfony\UX\Turbo\Broadcast; +use Symfony\UX\Turbo\Attribute\Broadcast; #[Broadcast('entity.getOptions()')] class Book @@ -488,18 +495,6 @@ class Book Symfony UX Turbo allows sending Turbo Streams updates using multiple transports. For instance, it's possible to use several Mercure hubs with the following configuration: -```yaml -# config/packages/mercure.yaml -mercure: - hubs: - hub1: - url: https://hub1.example.net/.well-known/mercure - jwt: snip - hub2: - url: https://hub2.example.net/.well-known/mercure - jwt: snip -``` - ```yaml # config/packages/turbo.yaml turbo: @@ -510,13 +505,13 @@ turbo: Use the appropriate Mercure `Publisher` service to send a change using a specific transport: ```php -stimulusTwigExtension->renderStimulusController($env, 'your_stimulus_controller', [/* controller values such as topic */]); - } - - public static function getDefaultIndexName(): string + public function renderTurboStreamListen(Environment $env, $topic): string { - // The transport name, used as key in the service locator - // Alternatively, configure it using the other supported methods: https://symfony.com/doc/current/service_container/service_subscribers_locators.html#indexing-the-collection-of-services - return 'my-transport'; + return $this->stimulusTwigExtension->renderStimulusController( + $env, + 'your_stimulus_controller', + [/* controller values such as topic */] + ); } } ``` The broadcaster must be registered as a service tagged with `turbo.broadcaster` and the renderer must be tagged with `turbo.renderer.stream_listen`. If you enabled [autoconfigure option](https://symfony.com/doc/current/service_container.html#the-autoconfigure-option) (it's the case by default), these tags will be added automatically because these classes implement the `BroadcasterInterface` and `TurboStreamListenRendererInterface` interfaces, -the related services will be . +the related services will be. ## Backward Compatibility promise diff --git a/src/Turbo/Resources/config/doctrine_orm.php b/src/Turbo/Resources/config/doctrine_orm.php deleted file mode 100644 index b86fbf3a1f2..00000000000 --- a/src/Turbo/Resources/config/doctrine_orm.php +++ /dev/null @@ -1,28 +0,0 @@ - - * - * For the full copyright and license information, please view the LICENSE - * file that was distributed with this source code. - */ - -namespace Symfony\Component\DependencyInjection\Loader\Configurator; - -use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; -use Symfony\UX\Turbo\Doctrine\BroadcastListener; - -/* - * @author Kévin Dunglas - */ -return static function (ContainerConfigurator $container): void { - $container - ->services() - ->set(BroadcastListener::class) - ->args([service(BroadcasterInterface::class)]) - ->tag('doctrine.event_listener', ['event' => 'onFlush']) - ->tag('doctrine.event_listener', ['event' => 'postFlush']) - ; -}; diff --git a/src/Turbo/Resources/config/mercure.php b/src/Turbo/Resources/config/mercure.php index 0977576e71d..fb70eff899c 100644 --- a/src/Turbo/Resources/config/mercure.php +++ b/src/Turbo/Resources/config/mercure.php @@ -18,9 +18,9 @@ * @author Kévin Dunglas */ return static function (ContainerConfigurator $container): void { - $container - ->services() - ->set(Broadcaster::class) + $container->services() + + ->set(Broadcaster::class) ->abstract() ->args([ abstract_arg('name'), @@ -31,7 +31,7 @@ abstract_arg('entity namespace'), ]) - ->set(TurboStreamListenRenderer::class) + ->set(TurboStreamListenRenderer::class) ->abstract() ->args([ abstract_arg('hub'), diff --git a/src/Turbo/Resources/config/services.php b/src/Turbo/Resources/config/services.php index ee26aae09b4..0d59f0b8afc 100644 --- a/src/Turbo/Resources/config/services.php +++ b/src/Turbo/Resources/config/services.php @@ -13,6 +13,7 @@ use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; use Symfony\UX\Turbo\Broadcaster\ImuxBroadcaster; +use Symfony\UX\Turbo\Doctrine\BroadcastListener; use Symfony\UX\Turbo\Stream\AddTurboStreamFormatSubscriber; use Symfony\UX\Turbo\Twig\TwigExtension; @@ -20,16 +21,23 @@ * @author Kévin Dunglas */ return static function (ContainerConfigurator $container): void { - $container - ->services() - ->set(AddTurboStreamFormatSubscriber::class) + $container->services() + + ->set(AddTurboStreamFormatSubscriber::class) ->tag('kernel.event_subscriber') - ->set(BroadcasterInterface::class, ImuxBroadcaster::class) + ->set(ImuxBroadcaster::class) ->args([tagged_iterator('turbo.broadcaster')]) - ->set(TwigExtension::class) - ->args([tagged_locator('turbo.renderer.stream_listen', 'key'), abstract_arg('default')]) + ->alias(BroadcasterInterface::class, ImuxBroadcaster::class) + + ->set(TwigExtension::class) + ->args([tagged_locator('turbo.renderer.stream_listen', 'index'), abstract_arg('default')]) ->tag('twig.extension') + + ->set(BroadcastListener::class) + ->args([service(BroadcasterInterface::class)]) + ->tag('doctrine.event_listener', ['event' => 'onFlush']) + ->tag('doctrine.event_listener', ['event' => 'postFlush']) ; }; diff --git a/src/Turbo/Stream/AddTurboStreamFormatSubscriber.php b/src/Turbo/Stream/AddTurboStreamFormatSubscriber.php index 1cbf35e9d94..0d5bb0e9225 100644 --- a/src/Turbo/Stream/AddTurboStreamFormatSubscriber.php +++ b/src/Turbo/Stream/AddTurboStreamFormatSubscriber.php @@ -17,7 +17,7 @@ use Symfony\Component\HttpKernel\KernelEvents; /** - * Detects if it's a Turbo Stream request and set the format accordingly. + * Detects if it's a Turbo Stream request and sets the format accordingly. * * @author Kévin Dunglas * diff --git a/src/Turbo/Tests/BroadcastTest.php b/src/Turbo/Tests/BroadcastTest.php index a1228f9f171..2eee3de4a1a 100644 --- a/src/Turbo/Tests/BroadcastTest.php +++ b/src/Turbo/Tests/BroadcastTest.php @@ -18,7 +18,7 @@ * * @author Kévin Dunglas * - * @requires PHP >= 8.0 + * @requires PHP 8.0 */ class BroadcastTest extends PantherTestCase { diff --git a/src/Turbo/Tests/app/Entity/Book.php b/src/Turbo/Tests/app/Entity/Book.php index 4bceec99520..d34a0379ad3 100644 --- a/src/Turbo/Tests/app/Entity/Book.php +++ b/src/Turbo/Tests/app/Entity/Book.php @@ -12,7 +12,7 @@ namespace App\Entity; use Doctrine\ORM\Mapping as ORM; -use Symfony\UX\Turbo\Broadcast; +use Symfony\UX\Turbo\Attribute\Broadcast; /** * @ORM\Entity diff --git a/src/Turbo/Twig/TwigExtension.php b/src/Turbo/Twig/TwigExtension.php index 87fa8028981..29583583186 100644 --- a/src/Turbo/Twig/TwigExtension.php +++ b/src/Turbo/Twig/TwigExtension.php @@ -42,12 +42,10 @@ public function getFunctions(): iterable */ public function turboStreamListen(Environment $env, $topic, ?string $transport = null): string { - if (null === $transport) { - $transport = $this->default; - } + $transport ?? $transport = $this->default; if (!$this->turboStreamListenRenderers->has($transport)) { - throw new \InvalidArgumentException(sprintf('The transport "%s" doesn\'t exist.', $transport)); + throw new \InvalidArgumentException(sprintf('The Turbo stream transport "%s" doesn\'t exist.', $transport)); } return $this->turboStreamListenRenderers->get($transport)->renderTurboStreamListen($env, $topic); diff --git a/src/Turbo/composer.json b/src/Turbo/composer.json index 1eefcf9c570..417953dbf80 100644 --- a/src/Turbo/composer.json +++ b/src/Turbo/composer.json @@ -22,14 +22,6 @@ "homepage": "https://symfony.com/contributors" } ], - "require": { - "php": ">=7.2.5", - "symfony/config": "^5.2", - "symfony/dependency-injection": "^5.2", - "symfony/http-kernel": "^5.2", - "symfony/property-access": "^5.2", - "symfony/webpack-encore-bundle": "^1.11" - }, "autoload": { "psr-4": { "Symfony\\UX\\Turbo\\": "" @@ -43,17 +35,9 @@ "App\\": "Tests/app/" } }, - "extra": { - "branch-alias": { - "dev-main": "1.1-dev" - }, - "thanks": { - "name": "symfony/ux", - "url": "https://github.com/symfony/ux" - } - }, - "config": { - "sort-packages": true + "require": { + "php": ">=7.2.5", + "symfony/webpack-encore-bundle": "^1.11" }, "require-dev": { "doctrine/doctrine-bundle": "^2.2", @@ -67,10 +51,20 @@ "symfony/messenger": "^5.2", "symfony/panther": "^1.0", "symfony/phpunit-bridge": "^5.2.1", + "symfony/property-access": "^5.2", "symfony/security-core": "^5.2", "symfony/stopwatch": "^5.2", "symfony/twig-bundle": "^5.2", "symfony/web-profiler-bundle": "^5.2" }, + "extra": { + "branch-alias": { + "dev-main": "1.1-dev" + }, + "thanks": { + "name": "symfony/ux", + "url": "https://github.com/symfony/ux" + } + }, "minimum-stability": "dev" } From a1dffea469eb86668628bca356e962e4f8e0523f Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Fri, 26 Mar 2021 10:26:43 +0100 Subject: [PATCH 2/2] - --- src/Turbo/Attribute/Broadcast.php | 2 +- .../DependencyInjection/Configuration.php | 12 +-- .../DependencyInjection/TurboExtension.php | 20 +++-- src/Turbo/Doctrine/BroadcastListener.php | 15 ++-- src/Turbo/Mercure/Broadcaster.php | 46 ++++------- .../Mercure/TurboStreamListenRenderer.php | 2 +- src/Turbo/README.md | 81 +++++++------------ src/Turbo/Resources/config/mercure.php | 3 +- src/Turbo/Tests/app/Kernel.php | 6 +- src/Turbo/Twig/TwigExtension.php | 2 +- src/Turbo/composer.json | 1 - 11 files changed, 80 insertions(+), 110 deletions(-) diff --git a/src/Turbo/Attribute/Broadcast.php b/src/Turbo/Attribute/Broadcast.php index a0fc47d53fe..32320fb2cc9 100644 --- a/src/Turbo/Attribute/Broadcast.php +++ b/src/Turbo/Attribute/Broadcast.php @@ -35,7 +35,7 @@ final class Broadcast /** * Options can be any option supported by the broadcaster. * - * @see Broadcaster for the default options // there is some coupling with Mercure here - can't we extract some options as named (generic) arguments? + * @see Broadcaster for the default options when using Mercure */ public function __construct(mixed ...$options) { diff --git a/src/Turbo/DependencyInjection/Configuration.php b/src/Turbo/DependencyInjection/Configuration.php index 9a39dd4b987..de9f0a9f168 100644 --- a/src/Turbo/DependencyInjection/Configuration.php +++ b/src/Turbo/DependencyInjection/Configuration.php @@ -12,6 +12,7 @@ namespace Symfony\UX\Turbo\DependencyInjection; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\MercureBundle\MercureBundle; use Symfony\Component\Config\Definition\Builder\TreeBuilder; use Symfony\Component\Config\Definition\ConfigurationInterface; @@ -34,13 +35,14 @@ public function getConfigTreeBuilder(): TreeBuilder ->arrayNode('broadcast') ->{\PHP_VERSION_ID >= 80000 ? 'canBeDisabled' : 'canBeEnabled'}() ->children() - ->scalarNode('entity_namespace') - ->info('Prefix to strip when looking for broadcast templates') - ->defaultValue('App\\Entity\\') // we should remove the default and ship it with a recipe instead + ->arrayNode('entity_template_prefixes') + ->fixXmlConfig('entity_template_prefix') + ->defaultValue(['App\Entity\\' => 'broadcast/']) + ->scalarPrototype()->end() ->end() ->arrayNode('doctrine_orm') ->info('Enable the Doctrine ORM integration') - ->{class_exists(DoctrineBundle::class) ? 'canBeDisabled' : 'canBeEnabled'}() + ->{class_exists(DoctrineBundle::class) && interface_exists(EntityManagerInterface::class) ? 'canBeDisabled' : 'canBeEnabled'}() ->end() ->end() ->end() @@ -51,8 +53,8 @@ public function getConfigTreeBuilder(): TreeBuilder ->children() ->arrayNode('hubs') ->fixXmlConfig('hub') - ->scalarPrototype() ->info('The name of the Mercure hubs (configured in MercureBundle) to use as transports') + ->scalarPrototype()->end() ->end() ->end() ->end() diff --git a/src/Turbo/DependencyInjection/TurboExtension.php b/src/Turbo/DependencyInjection/TurboExtension.php index 9d784d17050..d84e19b69e2 100644 --- a/src/Turbo/DependencyInjection/TurboExtension.php +++ b/src/Turbo/DependencyInjection/TurboExtension.php @@ -12,6 +12,7 @@ namespace Symfony\UX\Turbo\DependencyInjection; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; +use Doctrine\ORM\EntityManagerInterface; use Symfony\Bundle\MercureBundle\MercureBundle; use Symfony\Bundle\TwigBundle\TwigBundle; use Symfony\Component\Config\Definition\Exception\InvalidConfigurationException; @@ -24,6 +25,7 @@ use Symfony\Component\HttpKernel\DependencyInjection\Extension; use Symfony\Component\Mercure\Hub; use Symfony\Component\Mercure\HubInterface; +use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; use Symfony\UX\Turbo\Doctrine\BroadcastListener; use Symfony\UX\Turbo\Mercure\Broadcaster; @@ -94,8 +96,8 @@ private function registerBroadcast(array $config, ContainerBuilder $container, L return; } - if (!class_exists(DoctrineBundle::class)) { - throw new InvalidConfigurationException('You cannot use the Doctrine ORM integration as "doctrine/doctrine-bundle" is not installed. Try running "composer require symfony/orm-pack".'); + if (!class_exists(DoctrineBundle::class) || !interface_exists(EntityManagerInterface::class)) { + throw new InvalidConfigurationException('You cannot use the Doctrine ORM integration as the Doctrine bundle is not installed. Try running "composer require symfony/orm-pack".'); } } @@ -108,12 +110,14 @@ private function registerMercureTransports(array $config, ContainerBuilder $cont return; } - if (!class_exists(MercureBundle::class)) { - throw new InvalidConfigurationException('You cannot use the Mercure integration as "symfony/mercure-bundle" is not installed. Try running "composer require symfony/mercure-bundle".'); - } + $missingDeps = array_filter([ + 'symfony/mercure-bundle' => !class_exists(MercureBundle::class), + 'symfony/twig-pack' => !class_exists(TwigBundle::class), + 'symfony/property-access' => !interface_exists(PropertyAccessorInterface::class), + ]); - if (!class_exists(TwigBundle::class)) { - throw new InvalidConfigurationException('You cannot use the Mercure integration as "symfony/twig-bundle" is not installed. Try running "composer require symfony/twig-pack".'); + if ($missingDeps) { + throw new InvalidConfigurationException(sprintf('You cannot use the Mercure integration as some required dependencies are missing. Try running "composer require %s".', implode(' ', $missingDeps))); } $loader->load('mercure.php'); @@ -146,7 +150,7 @@ private function registerMercureTransport(ContainerBuilder $container, array $co $broadcaster = $container->setDefinition("turbo.mercure.{$name}.broadcaster", new ChildDefinition(Broadcaster::class)); $broadcaster->replaceArgument(0, $name); $broadcaster->replaceArgument(2, new Reference($hubId)); - $broadcaster->replaceArgument(5, $config['broadcast']['entity_namespace']); + $broadcaster->replaceArgument(4, $config['broadcast']['entity_template_prefixes']); $broadcaster->addTag('turbo.broadcaster'); } } diff --git a/src/Turbo/Doctrine/BroadcastListener.php b/src/Turbo/Doctrine/BroadcastListener.php index f051da03743..86a6779f40b 100644 --- a/src/Turbo/Doctrine/BroadcastListener.php +++ b/src/Turbo/Doctrine/BroadcastListener.php @@ -27,20 +27,24 @@ final class BroadcastListener implements ResetInterface { private $broadcaster; + + /** + * @var array + */ private $broadcastedClasses; /** * @var \SplObjectStorage */ - private \SplObjectStorage $createdEntities; + private $createdEntities; /** * @var \SplObjectStorage */ - private \SplObjectStorage $updatedEntities; + private $updatedEntities; /** * @var \SplObjectStorage */ - private \SplObjectStorage $removedEntities; + private $removedEntities; public function __construct(BroadcasterInterface $broadcaster) { @@ -109,8 +113,9 @@ private function storeEntitiesToPublish(object $entity, string $property): void { $class = \get_class($entity); - if ($this->broadcastedClasses[$class] ?? $this->broadcastedClasses[$class] = (bool) (new \ReflectionClass($class))->getAttributes(Broadcast::class)) { - // what happens if we don't clone removed entities here? + $this->broadcastedClasses[$class] ?? $this->broadcastedClasses[$class] = (new \ReflectionClass($class))->getAttributes(Broadcast::class); + + if ($this->broadcastedClasses[$class]) { $this->{$property}->attach('removedEntities' === $property ? clone $entity : $entity); } } diff --git a/src/Turbo/Mercure/Broadcaster.php b/src/Turbo/Mercure/Broadcaster.php index cd296f07c10..311994e45e0 100644 --- a/src/Turbo/Mercure/Broadcaster.php +++ b/src/Turbo/Mercure/Broadcaster.php @@ -15,7 +15,6 @@ use Symfony\Component\Mercure\Update; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; -use Symfony\Component\Security\Core\Authorization\ExpressionLanguage; use Symfony\UX\Turbo\Attribute\Broadcast; use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; use Twig\Environment; @@ -25,17 +24,13 @@ * * Supported options are: * - * * topics (string[]) Mercure topics to use, defaults to an array containing the Fully Qualified Class Name with "\" characters replaced by ":" characters. - * * createTemplate (string) The Twig template to render when a new object is created - * * updateTemplate (string) The Twig template to render when a new object is updated - * * removeTemplate (string) The Twig template to render when a new object is removed + * * transports (string[]) The name of the transports to broadcast to + * * topics (string[]) The topics to use; the default topic is derived from the FQCN of the entity and from its id + * * template (string) The Twig template to render when a new object is created, updated or removed * * private (bool) Marks Mercure updates as private * * id (string) ID field of the SSE * * type (string) type field of the SSE * * retry (int) retry field of the SSE - * * transports (string[]) - * - * The options can also be generated using the ExpressionLanguage language: if the option is a string, it is evaluated as an expression that must return an array. * * @author Kévin Dunglas * @@ -52,8 +47,7 @@ final class Broadcaster implements BroadcasterInterface private $twig; private $hub; private $propertyAccessor; - private $expressionLanguage; - private $entityNamespace; + private $templatePrefixes; private const OPTIONS = [ // Generic options @@ -68,7 +62,10 @@ final class Broadcaster implements BroadcasterInterface 'retry', ]; - public function __construct(string $name, Environment $twig, HubInterface $hub, ?PropertyAccessorInterface $propertyAccessor, ExpressionLanguage $expressionLanguage = null, string $entityNamespace = null) + /** + * @param array $templatePrefixes + */ + public function __construct(string $name, Environment $twig, HubInterface $hub, ?PropertyAccessorInterface $propertyAccessor, array $templatePrefixes = []) { if (80000 > \PHP_VERSION_ID) { throw new \LogicException('The broadcast feature requires PHP 8.0 or greater, you must either upgrade to PHP 8 or disable it.'); @@ -78,13 +75,7 @@ public function __construct(string $name, Environment $twig, HubInterface $hub, $this->twig = $twig; $this->propertyAccessor = $propertyAccessor ?? PropertyAccess::createPropertyAccessor(); $this->hub = $hub; - $this->entityNamespace = $entityNamespace; - - if ($expressionLanguage) { - $this->expressionLanguage = $expressionLanguage; - } elseif (class_exists(ExpressionLanguage::class)) { - $this->expressionLanguage = new ExpressionLanguage(); - } + $this->templatePrefixes = $templatePrefixes; } public function broadcast(object $entity, string $action): void @@ -125,14 +116,6 @@ public function broadcast(object $entity, string $action): void */ private function normalizeOptions(object $entity, string $action, array $options): array { - if ([0] === array_keys($options) && \is_string($options[0])) { - if (null === $this->expressionLanguage) { - throw new \RuntimeException('The Expression Language component is not installed. Try running "composer require symfony/expression-language".'); - } - - $options = $this->expressionLanguage->evaluate($options[0], ['entity' => $entity, 'action' => $action]); - } - if (isset($options['transports'])) { $options['transports'] = (array) $options['transports']; } @@ -148,12 +131,15 @@ private function normalizeOptions(object $entity, string $action, array $options return $options; } - $dir = $entityClass; - if ($this->entityNamespace && 0 === strpos($entityClass, $this->entityNamespace)) { - $dir = substr($entityClass, \strlen($this->entityNamespace)); + $file = $entityClass; + foreach ($this->templatePrefixes as $namespace => $prefix) { + if (0 === strpos($entityClass, $namespace)) { + $file = substr_replace($entityClass, $prefix, 0, \strlen($namespace)); + break; + } } - $options['template'] = sprintf('broadcast/%s.stream.html.twig', str_replace('\\', '/', $dir)); + $options['template'] = str_replace('\\', '/', $file).'.stream.html.twig'; return $options; } diff --git a/src/Turbo/Mercure/TurboStreamListenRenderer.php b/src/Turbo/Mercure/TurboStreamListenRenderer.php index 51af0340eeb..32f8a004312 100644 --- a/src/Turbo/Mercure/TurboStreamListenRenderer.php +++ b/src/Turbo/Mercure/TurboStreamListenRenderer.php @@ -40,7 +40,7 @@ public function renderTurboStreamListen(Environment $env, $topic): string { if (\is_object($topic)) { $topic = sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode(\get_class($topic)), rawurlencode($this->propertyAccessor->getValue($topic, 'id'))); - } elseif (!preg_match('[^a-zA-Z0-9_\x7f-\xff\\\\]') && class_exists($topic)) { + } 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}'); } diff --git a/src/Turbo/README.md b/src/Turbo/README.md index 32156fa0bd7..a07d52baf2e 100644 --- a/src/Turbo/README.md +++ b/src/Turbo/README.md @@ -23,7 +23,7 @@ composer require symfony/ux-turbo # Don't forget to install the JavaScript dependencies as well and compile yarn install --force -yarn encore dev // "./node_modules/@symfony/stimulus-bridge/dist/webpack/loader.js!./assets/controllers.json" contains a reference to the file "@symfony/ux-turbo/dist/turbo_stream_controller.js". +yarn encore dev ``` ## Usage @@ -74,7 +74,7 @@ The content of a frame can be lazy loaded: {% extends 'base.html.twig' %} {% block body %} - // can we make it clearer what "block" is? + A placeholder. {% endblock %} @@ -222,15 +222,6 @@ which comes with a Mercure hub integrated in the web server. If you use Symfony Flex, the configuration has been generated for you, be sure to update the `MERCURE_SUBSCRIBE_URL` in the `.env` file to point to a Mercure Hub (it's not necessary if you are using Symfony Docker). -Otherwise, configure the URL of the Mercure Hub: - -```yaml -# config/packages/turbo.yaml -turbo: - mercure: - subscribe_url: https://localhost/.well-known/mercure // config option doesn't exist -``` - Let's create our chat: ```php @@ -261,7 +252,6 @@ class ChatController extends AbstractController // 🔥 The magic happens here! 🔥 // The HTML update is pushed to the client using Mercure - // The Mercure topic (here "chat") MUST be the ID of the block that will display the received updates // in the twig below; we never see any id="chat", this might be confusing $mercure->publish(new Update( 'chat', $this->renderView('chat/message.stream.html.twig', ['message' => $data['message']]) @@ -289,9 +279,7 @@ class ChatController extends AbstractController
{# The messages will be displayed here. - "turbo_stream_listen()" automatically registers a Stimulus controller that subscribes to the "chat" topic of the transport. - The transport connection is automatically closed when this HTML block is removed. - + "turbo_stream_listen()" automatically registers a Stimulus controller that subscribes to the "chat" topic as managed by the transport. All connected users will receive the new messages! #}
@@ -412,20 +400,18 @@ are passed to the template as variables: `entity`, `action` and `options`. When using the Mercure transport, the entity class **must** have a public property named `id` or a public method named `getId()`. -If your entities aren't in the `App\Entity` namespace, Symfony UX Turbo will look for a template in a directory named after -their Fully Qualified Class Names. For instance, if a class marked with the `Broadcast` attribute is named `\App\Foo\Bar`, -the corresponding template will be `templates/broadcast/App/Foo/Bar.stream.html.twig`. -// do we really need the above convention? Can't we force such entities to have a template attribute instead? -// The less conventions the better, remember the @Template annotation... -// Here is another convention highlighted in the example config, mapping entities to template directories: +Symfony UX Turbo will look for a template named after mapping their Fully Qualified Class Names. +For example and by default, if a class marked with the `Broadcast` attribute is named `App\Entity\Foo`, +the corresponding template will be found in `templates/broadcast/Foo.stream.html.twig`. -It's possible to use the `turbo.broadcast.entity_namespace` configuration parameter to change the default entity namespace: +It's possible to configure own namespaces are mapped to templates by using the `turbo.broadcast.entity_template_prefixes` configuration options. +The default is defined as such: ```yaml # config/packages/turbo.yaml turbo: broadcast: - entity_template_dirs: + entity_template_prefixes: App\Entity\: broadcast/ ``` @@ -439,15 +425,18 @@ class Book { /* ... */ } ### Broadcast Options The `Broadcast` attribute comes with a set of handy options: -// These are tightly coupled to Mercure currently: the Broadcast attribute is variadic. Can't we make this more generic and discoverable, with actual named arguments, at least for some of them? + - `transports` (`string[]`): a list of transports to broadcast to + - `topics` (`string[]`): a list of topics to use, the default topic is derived from the FQCN of the entity and from its id - `template` (`string`): Twig template to render (see above) - - `topics` (`string[]`): Mercure topics to use, defaults to an array containing the Fully Qualified Class Name + +Options are transport-sepcific. +When using Mercure, some extra options are supported: + - `private` (`bool`): marks Mercure updates as private - `id` (`string`): `id` field of the SSE - `type` (`string`): `type` field of the SSE - - `retry` (`int`): `retry` field of the SSE // what's the use case for these 3 SSE elements? Can't we remove them to keep this generic? - - `transports` is missing + - `retry` (`int`): `retry` field of the SSE Example: @@ -464,37 +453,23 @@ class Book } ``` -### Using an Expression to Generate the Options - -If the [Symfony ExpressionLanguage Component](https://symfony.com/doc/current/components/expression_language.html) is installed, -you can also pass an _expression_ generating the options as parameter of the `Broadcast` attribute: - -```php -// src/Entity/Book.php -namespace App\Entity; - -use Symfony\UX\Turbo\Attribute\Broadcast; - -#[Broadcast('entity.getOptions()')] -class Book -{ - // ... - - public function getOptions(): array - { - return [ - 'topics' => [$this->id], - 'private' => $this->private, - ]; - } -} -``` - ### Using Multiple Transports Symfony UX Turbo allows sending Turbo Streams updates using multiple transports. For instance, it's possible to use several Mercure hubs with the following configuration: +```yaml +# config/packages/mercure.yaml +mercure: + hubs: + hub1: + url: https://hub1.example.net/.well-known/mercure + jwt: snip + hub2: + url: https://hub2.example.net/.well-known/mercure + jwt: snip +``` + ```yaml # config/packages/turbo.yaml turbo: diff --git a/src/Turbo/Resources/config/mercure.php b/src/Turbo/Resources/config/mercure.php index fb70eff899c..8b4bae5b1ef 100644 --- a/src/Turbo/Resources/config/mercure.php +++ b/src/Turbo/Resources/config/mercure.php @@ -27,8 +27,7 @@ service('twig'), abstract_arg('publisher'), service('property_accessor'), - null, - abstract_arg('entity namespace'), + abstract_arg('entity template prefixes'), ]) ->set(TurboStreamListenRenderer::class) diff --git a/src/Turbo/Tests/app/Kernel.php b/src/Turbo/Tests/app/Kernel.php index c856c33fc86..505eefe9164 100644 --- a/src/Turbo/Tests/app/Kernel.php +++ b/src/Turbo/Tests/app/Kernel.php @@ -122,9 +122,9 @@ protected function configureRoutes(RoutingConfigurator $routes): void $routes->add('home', '/')->controller(TemplateController::class)->defaults(['template' => 'home.html.twig']); $routes->add('block', '/block')->controller(TemplateController::class)->defaults(['template' => 'block.html.twig']); $routes->add('lazy', '/lazy')->controller(TemplateController::class)->defaults(['template' => 'lazy.html.twig']); - $routes->add('form', '/form')->controller('kernel:form'); - $routes->add('chat', '/chat')->controller('kernel:chat'); - $routes->add('books', '/books')->controller('kernel:books'); + $routes->add('form', '/form')->controller('kernel::form'); + $routes->add('chat', '/chat')->controller('kernel::chat'); + $routes->add('books', '/books')->controller('kernel::books'); } public function getProjectDir(): string diff --git a/src/Turbo/Twig/TwigExtension.php b/src/Turbo/Twig/TwigExtension.php index 29583583186..a7dd87690bf 100644 --- a/src/Turbo/Twig/TwigExtension.php +++ b/src/Turbo/Twig/TwigExtension.php @@ -42,7 +42,7 @@ public function getFunctions(): iterable */ public function turboStreamListen(Environment $env, $topic, ?string $transport = null): string { - $transport ?? $transport = $this->default; + $transport = $transport ?? $this->default; if (!$this->turboStreamListenRenderers->has($transport)) { throw new \InvalidArgumentException(sprintf('The Turbo stream transport "%s" doesn\'t exist.', $transport)); diff --git a/src/Turbo/composer.json b/src/Turbo/composer.json index 417953dbf80..caa328a3abd 100644 --- a/src/Turbo/composer.json +++ b/src/Turbo/composer.json @@ -44,7 +44,6 @@ "doctrine/orm": "^2.8", "phpstan/phpstan": "^0.12", "symfony/debug-bundle": "^5.2", - "symfony/expression-language": "^5.2", "symfony/form": "^5.2", "symfony/framework-bundle": "^5.2", "symfony/mercure-bundle": "^0.3",