diff --git a/src/Turbo/CONTRIBUTING.md b/src/Turbo/CONTRIBUTING.md index ba533f39cb2..6080d68fa7d 100644 --- a/src/Turbo/CONTRIBUTING.md +++ b/src/Turbo/CONTRIBUTING.md @@ -7,7 +7,7 @@ Start a Mercure Hub: -e MERCURE_PUBLISHER_JWT_KEY='!ChangeMe!' \ -e MERCURE_SUBSCRIBER_JWT_KEY='!ChangeMe!' \ -p 3000:3000 \ - dunglas/mercure caddy run -config /etc/caddy/Caddyfile.dev + dunglas/mercure caddy run --config /etc/caddy/Caddyfile.dev Install the test app: @@ -29,6 +29,7 @@ Start the test app: - `http://localhost:8000/authors`: broadcast - `http://localhost:8000/artists`: broadcast - `http://localhost:8000/songs`: broadcast +- `http://localhost:8000/cart_products`: broadcast ## Run tests diff --git a/src/Turbo/config/services.php b/src/Turbo/config/services.php index 0cd1e6a1d5f..2c3a95e7e3f 100644 --- a/src/Turbo/config/services.php +++ b/src/Turbo/config/services.php @@ -13,9 +13,12 @@ use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; use Symfony\UX\Turbo\Broadcaster\IdAccessor; +use Symfony\UX\Turbo\Broadcaster\IdFormatter; use Symfony\UX\Turbo\Broadcaster\ImuxBroadcaster; use Symfony\UX\Turbo\Broadcaster\TwigBroadcaster; use Symfony\UX\Turbo\Doctrine\BroadcastListener; +use Symfony\UX\Turbo\Doctrine\DoctrineClassResolver; +use Symfony\UX\Turbo\Doctrine\DoctrineIdAccessor; use Symfony\UX\Turbo\Twig\TwigExtension; /* @@ -29,10 +32,22 @@ ->alias(BroadcasterInterface::class, 'turbo.broadcaster.imux') + ->set('turbo.doctrine_class_resolver', DoctrineClassResolver::class) + ->args([ + service('doctrine')->nullOnInvalid(), + ]) + + ->set('turbo.id_formatter', IdFormatter::class) + + ->set('turbo.doctrine_id_accessor', DoctrineIdAccessor::class) + ->args([ + service('doctrine')->nullOnInvalid(), + ]) + ->set('turbo.id_accessor', IdAccessor::class) ->args([ service('property_accessor')->nullOnInvalid(), - service('doctrine')->nullOnInvalid(), + service('turbo.doctrine_id_accessor'), ]) ->set('turbo.broadcaster.action_renderer', TwigBroadcaster::class) @@ -41,6 +56,8 @@ service('twig'), abstract_arg('entity template prefixes'), service('turbo.id_accessor'), + service('turbo.id_formatter'), + service('turbo.doctrine_class_resolver'), ]) ->decorate('turbo.broadcaster.imux') @@ -52,6 +69,8 @@ ->args([ service('turbo.broadcaster.imux'), service('annotation_reader')->nullOnInvalid(), + service('turbo.doctrine_id_accessor'), + service('turbo.doctrine_class_resolver'), ]) ->tag('doctrine.event_listener', ['event' => 'onFlush']) ->tag('doctrine.event_listener', ['event' => 'postFlush']) diff --git a/src/Turbo/src/Bridge/Mercure/Broadcaster.php b/src/Turbo/src/Bridge/Mercure/Broadcaster.php index e727a1276db..924a82c51c8 100644 --- a/src/Turbo/src/Bridge/Mercure/Broadcaster.php +++ b/src/Turbo/src/Bridge/Mercure/Broadcaster.php @@ -15,7 +15,8 @@ use Symfony\Component\Mercure\HubInterface; use Symfony\Component\Mercure\Update; use Symfony\UX\Turbo\Broadcaster\BroadcasterInterface; -use Symfony\UX\Turbo\Doctrine\ClassUtil; +use Symfony\UX\Turbo\Broadcaster\IdFormatter; +use Symfony\UX\Turbo\Doctrine\DoctrineClassResolver; /** * Broadcasts updates rendered using Twig with Mercure. @@ -42,14 +43,18 @@ final class Broadcaster implements BroadcasterInterface private $name; private $hub; + private $idFormatter; + private $doctrineClassResolver; /** @var ExpressionLanguage|null */ private $expressionLanguage; - public function __construct(string $name, HubInterface $hub) + public function __construct(string $name, HubInterface $hub, ?IdFormatter $idFormatter = null, ?DoctrineClassResolver $doctrineClassResolver = null) { $this->name = $name; $this->hub = $hub; + $this->idFormatter = $idFormatter ?? new IdFormatter(); + $this->doctrineClassResolver = $doctrineClassResolver ?? new DoctrineClassResolver(); if (class_exists(ExpressionLanguage::class)) { $this->expressionLanguage = new ExpressionLanguage(); @@ -62,7 +67,7 @@ public function broadcast(object $entity, string $action, array $options): void return; } - $entityClass = ClassUtil::getEntityClass($entity); + $entityClass = $this->doctrineClassResolver->resolve($entity); if (!isset($options['rendered_action'])) { throw new \InvalidArgumentException(sprintf('Cannot broadcast entity of class "%s" as option "rendered_action" is missing.', $entityClass)); @@ -99,7 +104,9 @@ public function broadcast(object $entity, string $action, array $options): void throw new \InvalidArgumentException(sprintf('Cannot broadcast entity of class "%s": the option "topics" is empty and "id" is missing.', $entityClass)); } - $options['topics'] = (array) sprintf(self::TOPIC_PATTERN, rawurlencode($entityClass), rawurlencode(implode('-', (array) $options['id']))); + $id = $this->idFormatter->format($options['id']); + + $options['topics'] = (array) sprintf(self::TOPIC_PATTERN, rawurlencode($entityClass), rawurlencode($id)); } $update = new Update( diff --git a/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php b/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php index 97dd22b76e7..d86e7032434 100644 --- a/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php +++ b/src/Turbo/src/Bridge/Mercure/TurboStreamListenRenderer.php @@ -14,6 +14,8 @@ use Symfony\Component\Mercure\HubInterface; use Symfony\UX\StimulusBundle\Helper\StimulusHelper; use Symfony\UX\Turbo\Broadcaster\IdAccessor; +use Symfony\UX\Turbo\Broadcaster\IdFormatter; +use Symfony\UX\Turbo\Doctrine\DoctrineClassResolver; use Symfony\UX\Turbo\Twig\TurboStreamListenRendererInterface; use Symfony\WebpackEncoreBundle\Twig\StimulusTwigExtension; use Twig\Environment; @@ -28,14 +30,18 @@ final class TurboStreamListenRenderer implements TurboStreamListenRendererInterf private HubInterface $hub; private StimulusHelper $stimulusHelper; private IdAccessor $idAccessor; + private IdFormatter $idFormatter; + private DoctrineClassResolver $doctrineClassResolver; /** * @param $stimulus StimulusHelper */ - public function __construct(HubInterface $hub, StimulusHelper|StimulusTwigExtension $stimulus, IdAccessor $idAccessor) + public function __construct(HubInterface $hub, StimulusHelper|StimulusTwigExtension $stimulus, IdAccessor $idAccessor, ?IdFormatter $idFormatter = null, ?DoctrineClassResolver $doctrineClassResolver = null) { $this->hub = $hub; $this->idAccessor = $idAccessor; + $this->idFormatter = $idFormatter ?? new IdFormatter(); + $this->doctrineClassResolver = $doctrineClassResolver ?? new DoctrineClassResolver(); if ($stimulus instanceof StimulusTwigExtension) { trigger_deprecation('symfony/ux-turbo', '2.9', 'Passing an instance of "%s" as second argument of "%s" is deprecated, pass an instance of "%s" instead.', StimulusTwigExtension::class, __CLASS__, StimulusHelper::class); @@ -49,13 +55,15 @@ public function __construct(HubInterface $hub, StimulusHelper|StimulusTwigExtens public function renderTurboStreamListen(Environment $env, $topic): string { if (\is_object($topic)) { - $class = $topic::class; + $class = $this->doctrineClassResolver->resolve($topic); if (!$id = $this->idAccessor->getEntityId($topic)) { 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))); + $formattedId = $this->idFormatter->format($id); + + $topic = sprintf(Broadcaster::TOPIC_PATTERN, rawurlencode($class), rawurlencode($formattedId)); } 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/src/Broadcaster/BroadcasterInterface.php b/src/Turbo/src/Broadcaster/BroadcasterInterface.php index 7bff8a1e20f..fb89534c757 100644 --- a/src/Turbo/src/Broadcaster/BroadcasterInterface.php +++ b/src/Turbo/src/Broadcaster/BroadcasterInterface.php @@ -19,7 +19,7 @@ interface BroadcasterInterface { /** - * @param array{id?: string|string[], transports?: string|string[], topics?: string|string[], template?: string, rendered_action?: string, private?: bool, sse_id?: string, sse_type?: string, sse_retry?: int} $options + * @param array{id?: array|array>, transports?: string|string[], topics?: string|string[], template?: string, rendered_action?: string, private?: bool, sse_id?: string, sse_type?: string, sse_retry?: int} $options */ public function broadcast(object $entity, string $action, array $options): void; } diff --git a/src/Turbo/src/Broadcaster/IdAccessor.php b/src/Turbo/src/Broadcaster/IdAccessor.php index 64606947fdf..27087eff625 100644 --- a/src/Turbo/src/Broadcaster/IdAccessor.php +++ b/src/Turbo/src/Broadcaster/IdAccessor.php @@ -11,30 +11,28 @@ namespace Symfony\UX\Turbo\Broadcaster; -use Doctrine\Persistence\ManagerRegistry; use Symfony\Component\PropertyAccess\PropertyAccess; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; +use Symfony\UX\Turbo\Doctrine\DoctrineIdAccessor; class IdAccessor { private $propertyAccessor; - private $doctrine; + private $doctrineIdAccessor; - public function __construct(?PropertyAccessorInterface $propertyAccessor = null, ?ManagerRegistry $doctrine = null) + public function __construct(?PropertyAccessorInterface $propertyAccessor = null, ?DoctrineIdAccessor $doctrineIdAccessor = null) { $this->propertyAccessor = $propertyAccessor ?? (class_exists(PropertyAccess::class) ? PropertyAccess::createPropertyAccessor() : null); - $this->doctrine = $doctrine; + $this->doctrineIdAccessor = $doctrineIdAccessor ?? new DoctrineIdAccessor(); } /** - * @return string[] + * @return array>|array|null */ public function getEntityId(object $entity): ?array { - $entityClass = $entity::class; - - if ($this->doctrine && $em = $this->doctrine->getManagerForClass($entityClass)) { - return $em->getClassMetadata($entityClass)->getIdentifierValues($entity); + if (null !== ($id = $this->doctrineIdAccessor->getEntityId($entity))) { + return $id; } if ($this->propertyAccessor) { diff --git a/src/Turbo/src/Broadcaster/IdFormatter.php b/src/Turbo/src/Broadcaster/IdFormatter.php new file mode 100644 index 00000000000..04834fc5143 --- /dev/null +++ b/src/Turbo/src/Broadcaster/IdFormatter.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Turbo\Broadcaster; + +/** + * Formats an id array to a string. + * + * In defaults the id array is something like `['id' => 1]` or `['uuid' => '00000000-0000-0000-0000-000000000000']`. + * For a composite key it could be something like `['cart' => ['id' => 1], 'product' => ['id' => 1]]`. + * + * To create a string representation of the id, the values of the array are flattened and concatenated with a dash. + * + * @author Jason Schilling + */ +class IdFormatter +{ + /** + * @param array>|array|string $id + */ + public function format(array|string $id): string + { + if (\is_string($id)) { + return $id; + } + + $flatten = []; + + array_walk_recursive($id, static function ($item) use (&$flatten) { $flatten[] = $item; }); + + return implode('-', $flatten); + } +} diff --git a/src/Turbo/src/Broadcaster/TwigBroadcaster.php b/src/Turbo/src/Broadcaster/TwigBroadcaster.php index 05a428cd53f..f79d7428bcf 100644 --- a/src/Turbo/src/Broadcaster/TwigBroadcaster.php +++ b/src/Turbo/src/Broadcaster/TwigBroadcaster.php @@ -11,7 +11,7 @@ namespace Symfony\UX\Turbo\Broadcaster; -use Symfony\UX\Turbo\Doctrine\ClassUtil; +use Symfony\UX\Turbo\Doctrine\DoctrineClassResolver; use Twig\Environment; /** @@ -25,16 +25,20 @@ final class TwigBroadcaster implements BroadcasterInterface private $twig; private $templatePrefixes; private $idAccessor; + private $idFormatter; + private $doctrineClassResolver; /** * @param array $templatePrefixes */ - public function __construct(BroadcasterInterface $broadcaster, Environment $twig, array $templatePrefixes = [], ?IdAccessor $idAccessor = null) + public function __construct(BroadcasterInterface $broadcaster, Environment $twig, array $templatePrefixes = [], ?IdAccessor $idAccessor = null, ?IdFormatter $idFormatter = null, ?DoctrineClassResolver $doctrineClassResolver = null) { $this->broadcaster = $broadcaster; $this->twig = $twig; $this->templatePrefixes = $templatePrefixes; $this->idAccessor = $idAccessor ?? new IdAccessor(); + $this->idFormatter = $idFormatter ?? new IdFormatter(); + $this->doctrineClassResolver = $doctrineClassResolver ?? new DoctrineClassResolver(); } public function broadcast(object $entity, string $action, array $options): void @@ -43,10 +47,9 @@ public function broadcast(object $entity, string $action, array $options): void $options['id'] = $id; } - $class = ClassUtil::getEntityClass($entity); - if (null === $template = $options['template'] ?? null) { - $template = $class; + $template = $this->doctrineClassResolver->resolve($entity); + foreach ($this->templatePrefixes as $namespace => $prefix) { if (str_starts_with($template, $namespace)) { $template = substr_replace($template, $prefix, 0, \strlen($namespace)); @@ -63,7 +66,7 @@ public function broadcast(object $entity, string $action, array $options): void ->renderBlock($action, [ 'entity' => $entity, 'action' => $action, - 'id' => implode('-', (array) ($options['id'] ?? [])), + 'id' => $this->idFormatter->format($options['id'] ?? []), ] + $options); $this->broadcaster->broadcast($entity, $action, $options); diff --git a/src/Turbo/src/Doctrine/BroadcastListener.php b/src/Turbo/src/Doctrine/BroadcastListener.php index c2c07eb0c9d..44009237d4f 100644 --- a/src/Turbo/src/Doctrine/BroadcastListener.php +++ b/src/Turbo/src/Doctrine/BroadcastListener.php @@ -29,6 +29,8 @@ final class BroadcastListener implements ResetInterface { private $broadcaster; private $annotationReader; + private $doctrineIdAccessor; + private $doctrineClassResolver; /** * @var array> @@ -48,12 +50,14 @@ final class BroadcastListener implements ResetInterface */ private $removedEntities; - public function __construct(BroadcasterInterface $broadcaster, ?Reader $annotationReader = null) + public function __construct(BroadcasterInterface $broadcaster, ?Reader $annotationReader = null, ?DoctrineIdAccessor $doctrineIdAccessor = null, ?DoctrineClassResolver $doctrineClassResolver = null) { $this->reset(); $this->broadcaster = $broadcaster; $this->annotationReader = $annotationReader; + $this->doctrineIdAccessor = $doctrineIdAccessor ?? new DoctrineIdAccessor(); + $this->doctrineClassResolver = $doctrineClassResolver ?? new DoctrineClassResolver(); } /** @@ -94,7 +98,7 @@ public function postFlush(EventArgs $eventArgs): void try { foreach ($this->createdEntities as $entity) { $options = $this->createdEntities[$entity]; - $id = $em->getClassMetadata($entity::class)->getIdentifierValues($entity); + $id = $this->doctrineIdAccessor->getEntityId($entity, $em); foreach ($options as $option) { $option['id'] = $id; $this->broadcaster->broadcast($entity, Broadcast::ACTION_CREATE, $option); @@ -126,28 +130,28 @@ public function reset(): void private function storeEntitiesToPublish(EntityManagerInterface $em, object $entity, string $property): void { - $class = ClassUtil::getEntityClass($entity); + $entityClass = $this->doctrineClassResolver->resolve($entity, $em); - if (!isset($this->broadcastedClasses[$class])) { - $this->broadcastedClasses[$class] = []; - $r = new \ReflectionClass($class); + if (!isset($this->broadcastedClasses[$entityClass])) { + $this->broadcastedClasses[$entityClass] = []; + $r = new \ReflectionClass($entityClass); if ($options = $r->getAttributes(Broadcast::class)) { foreach ($options as $option) { - $this->broadcastedClasses[$class][] = $option->newInstance()->options; + $this->broadcastedClasses[$entityClass][] = $option->newInstance()->options; } } elseif ($this->annotationReader && $options = $this->annotationReader->getClassAnnotations($r)) { foreach ($options as $option) { if ($option instanceof Broadcast) { - $this->broadcastedClasses[$class][] = $option->options; + $this->broadcastedClasses[$entityClass][] = $option->options; } } } } - if ($options = $this->broadcastedClasses[$class]) { + if ($options = $this->broadcastedClasses[$entityClass]) { if ('createdEntities' !== $property) { - $id = $em->getClassMetadata($class)->getIdentifierValues($entity); + $id = $this->doctrineIdAccessor->getEntityId($entity, $em); foreach ($options as $k => $option) { $options[$k]['id'] = $id; } diff --git a/src/Turbo/src/Doctrine/DoctrineClassResolver.php b/src/Turbo/src/Doctrine/DoctrineClassResolver.php new file mode 100644 index 00000000000..f5869f4b8c4 --- /dev/null +++ b/src/Turbo/src/Doctrine/DoctrineClassResolver.php @@ -0,0 +1,55 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Turbo\Doctrine; + +use Doctrine\ORM\Mapping\ClassMetadata; +use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\ObjectManager; + +/** + * @author Jason Schilling + */ +class DoctrineClassResolver +{ + private $doctrine; + + public function __construct(?ManagerRegistry $doctrine = null) + { + $this->doctrine = $doctrine; + } + + /** + * @return class-string + */ + public function resolve(object $entity, ?ObjectManager $em = null): string + { + $class = ClassUtil::getEntityClass($entity); + + if (!$this->doctrine) { + return $class; + } + + $em = $em ?? $this->doctrine->getManagerForClass($class); + + if (!$em) { + return $class; + } + + $classMetadata = $em->getClassMetadata($class); + + if ($classMetadata instanceof ClassMetadata) { + return $classMetadata->rootEntityName; + } + + return $class; + } +} diff --git a/src/Turbo/src/Doctrine/DoctrineIdAccessor.php b/src/Turbo/src/Doctrine/DoctrineIdAccessor.php new file mode 100644 index 00000000000..b1a5edbd03f --- /dev/null +++ b/src/Turbo/src/Doctrine/DoctrineIdAccessor.php @@ -0,0 +1,58 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\Turbo\Doctrine; + +use Doctrine\Persistence\ManagerRegistry; +use Doctrine\Persistence\ObjectManager; + +/** + * @author Jason Schilling + */ +class DoctrineIdAccessor +{ + private $doctrine; + + public function __construct(?ManagerRegistry $doctrine = null) + { + $this->doctrine = $doctrine; + } + + /** + * @return array>|array|null + */ + public function getEntityId(object $entity, ?ObjectManager $em = null): ?array + { + $em = $em ?? $this->doctrine?->getManagerForClass($entity::class); + + if ($em) { + return $this->getIdentifierValues($em, $entity); + } + + return null; + } + + /** + * @return array|array> + */ + private function getIdentifierValues(ObjectManager $em, object $entity): array + { + $values = $em->getClassMetadata($entity::class)->getIdentifierValues($entity); + + foreach ($values as $key => $value) { + if (\is_object($value)) { + $values[$key] = $this->getIdentifierValues($em, $value); + } + } + + return $values; + } +} diff --git a/src/Turbo/src/TurboBundle.php b/src/Turbo/src/TurboBundle.php index 61542a4bab9..99cbf74a6ad 100644 --- a/src/Turbo/src/TurboBundle.php +++ b/src/Turbo/src/TurboBundle.php @@ -14,6 +14,7 @@ use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\Compiler\PassConfig; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Bundle\Bundle; @@ -45,6 +46,24 @@ public function process(ContainerBuilder $container): void } } }, PassConfig::TYPE_BEFORE_REMOVING); + + $container->addCompilerPass(new class() implements CompilerPassInterface { + public function process(ContainerBuilder $container): void + { + $serviceIds = [ + ...$container->findTaggedServiceIds('turbo.broadcaster'), + ...$container->findTaggedServiceIds('turbo.renderer.stream_listen'), + ]; + + foreach ($serviceIds as $serviceId => $tags) { + $definition = $container->getDefinition($serviceId); + + $definition + ->addArgument(new Reference('turbo.id_formatter')) + ->addArgument(new Reference('turbo.doctrine_class_resolver')); + } + } + }); } public function getPath(): string diff --git a/src/Turbo/tests/BroadcastTest.php b/src/Turbo/tests/BroadcastTest.php index ea57faf7628..53cc43317a0 100644 --- a/src/Turbo/tests/BroadcastTest.php +++ b/src/Turbo/tests/BroadcastTest.php @@ -27,7 +27,7 @@ class BroadcastTest extends PantherTestCase protected function setUp(): void { if (!file_exists(__DIR__.'/app/public/build')) { - throw new \Exception(sprintf('Move into %s and execute Encore before running this test.', realpath(__DIR__.'/app'))); + throw new \Exception(sprintf('Move into "%s" and execute Encore before running this test.', realpath(__DIR__.'/app'))); } parent::setUp(); @@ -104,4 +104,19 @@ public function testBroadcastWithProxy(): void // this part is from the stream template $this->assertSelectorWillContain('#artists', ', updated)'); } + + public function testBroadcastWithCompositePrimaryKey(): void + { + ($client = self::createPantherClient())->request('GET', '/cartProducts'); + + // submit first to create the entities + $client->submitForm('Submit', ['title' => 'product 1']); + $this->assertSelectorWillContain('#cart_products', 'product 1'); + + // submit again to update the quantity + $client->submitForm('Submit'); + $this->assertSelectorWillContain('#cart_products', '2x product 1'); + // this part is from the stream template + $this->assertSelectorWillContain('#cart_products', ', updated)'); + } } diff --git a/src/Turbo/tests/app/Entity/Cart.php b/src/Turbo/tests/app/Entity/Cart.php new file mode 100644 index 00000000000..f3741082d4d --- /dev/null +++ b/src/Turbo/tests/app/Entity/Cart.php @@ -0,0 +1,27 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Entity; + +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; + +/** + * @author Jason Schilling + */ +#[ORM\Entity] +class Cart +{ + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + #[ORM\Column(type: Types::INTEGER)] + public ?int $id = null; +} diff --git a/src/Turbo/tests/app/Entity/CartProduct.php b/src/Turbo/tests/app/Entity/CartProduct.php new file mode 100644 index 00000000000..dbd8b283630 --- /dev/null +++ b/src/Turbo/tests/app/Entity/CartProduct.php @@ -0,0 +1,37 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Entity; + +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; +use Symfony\UX\Turbo\Attribute\Broadcast; + +/** + * @author Jason Schilling + */ +#[Broadcast] +#[ORM\Entity] +class CartProduct +{ + #[ORM\Id] + #[ORM\ManyToOne(targetEntity: Cart::class)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + public ?Cart $cart = null; + + #[ORM\Id] + #[ORM\ManyToOne(targetEntity: Product::class)] + #[ORM\JoinColumn(nullable: false, onDelete: 'CASCADE')] + public ?Product $product = null; + + #[ORM\Column(type: Types::INTEGER)] + public int $quantity = 1; +} diff --git a/src/Turbo/tests/app/Entity/Product.php b/src/Turbo/tests/app/Entity/Product.php new file mode 100644 index 00000000000..599a2cfa903 --- /dev/null +++ b/src/Turbo/tests/app/Entity/Product.php @@ -0,0 +1,30 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace App\Entity; + +use Doctrine\DBAL\Types\Types; +use Doctrine\ORM\Mapping as ORM; + +/** + * @author Jason Schilling + */ +#[ORM\Entity] +class Product +{ + #[ORM\Id] + #[ORM\GeneratedValue(strategy: 'AUTO')] + #[ORM\Column(type: Types::INTEGER)] + public ?int $id = null; + + #[ORM\Column] + public string $title = ''; +} diff --git a/src/Turbo/tests/app/Entity/Song.php b/src/Turbo/tests/app/Entity/Song.php index cdb5d8a5ef8..6600f2121ea 100644 --- a/src/Turbo/tests/app/Entity/Song.php +++ b/src/Turbo/tests/app/Entity/Song.php @@ -24,7 +24,7 @@ class Song #[ORM\Id] #[ORM\GeneratedValue(strategy: 'AUTO')] #[ORM\Column(type: 'integer')] - public ?string $id = null; + public ?int $id = null; #[ORM\Column] public string $title = ''; diff --git a/src/Turbo/tests/app/Kernel.php b/src/Turbo/tests/app/Kernel.php index 078ba8da579..5d99852c3e6 100644 --- a/src/Turbo/tests/app/Kernel.php +++ b/src/Turbo/tests/app/Kernel.php @@ -13,6 +13,9 @@ use App\Entity\Artist; use App\Entity\Book; +use App\Entity\Cart; +use App\Entity\CartProduct; +use App\Entity\Product; use App\Entity\Song; use Composer\InstalledVersions; use Doctrine\Bundle\DoctrineBundle\DoctrineBundle; @@ -137,6 +140,7 @@ protected function configureRoutes(RoutingConfigurator $routes): void $routes->add('artists', '/artists')->controller('kernel::artists'); $routes->add('artist', '/artists/{id}')->controller('kernel::artist'); $routes->add('artist_from_song', '/artistFromSong')->controller('kernel::artistFromSong'); + $routes->add('cart_products', '/cartProducts')->controller('kernel::cartProducts'); } public function getProjectDir(): string @@ -305,4 +309,58 @@ public function artistFromSong(Request $request, EntityManagerInterface $doctrin 'song' => $song, ])); } + + public function cartProducts(Request $request, EntityManagerInterface $doctrine, Environment $twig): Response + { + $cartProduct = null; + if ($request->isMethod('POST')) { + $cartId = $request->get('cartId'); + $productId = $request->get('productId'); + + if (!$cartId || !$productId) { + $cart = new Cart(); + $product = new Product(); + + if ($title = $request->get('title')) { + $product->title = $title; + } + + $cartProduct = new CartProduct(); + $cartProduct->cart = $cart; + $cartProduct->product = $product; + $cartProduct->quantity = 1; + + $doctrine->persist($cart); + $doctrine->persist($product); + $doctrine->persist($cartProduct); + $doctrine->flush(); + } else { + $cartProduct = $doctrine->find(CartProduct::class, ['cart' => $cartId, 'product' => $productId]); + + if (!$cartProduct) { + throw new NotFoundHttpException(); + } + + ++$cartProduct->quantity; + + if ($remove = $request->get('remove')) { + $doctrine->remove($cartProduct); + if ($cartProduct->product) { + $doctrine->remove($cartProduct->product); // for cleanup + } + if ($cartProduct->cart) { + $doctrine->remove($cartProduct->cart); // for cleanup + } + } else { + $doctrine->persist($cartProduct); + } + + $doctrine->flush(); + } + } + + return new Response($twig->render('cart_products.html.twig', [ + 'cartProduct' => $cartProduct, + ])); + } } diff --git a/src/Turbo/tests/app/templates/base.html.twig b/src/Turbo/tests/app/templates/base.html.twig index 0a3c7a37350..96b3a07f3de 100644 --- a/src/Turbo/tests/app/templates/base.html.twig +++ b/src/Turbo/tests/app/templates/base.html.twig @@ -14,6 +14,7 @@
  • Books (broadcast)
  • Songs (broadcast)
  • Artists (broadcast)
  • +
  • CartProducts (broadcast)
  • diff --git a/src/Turbo/tests/app/templates/broadcast/CartProduct.stream.html.twig b/src/Turbo/tests/app/templates/broadcast/CartProduct.stream.html.twig new file mode 100644 index 00000000000..9f285aa3fe0 --- /dev/null +++ b/src/Turbo/tests/app/templates/broadcast/CartProduct.stream.html.twig @@ -0,0 +1,20 @@ + +{% block create %} + + + +{% endblock %} + +{% block update %} + + + +{% endblock %} + +{% block remove %} + +{% endblock %} diff --git a/src/Turbo/tests/app/templates/cart_products.html.twig b/src/Turbo/tests/app/templates/cart_products.html.twig new file mode 100644 index 00000000000..be22c298d5d --- /dev/null +++ b/src/Turbo/tests/app/templates/cart_products.html.twig @@ -0,0 +1,17 @@ +{% extends 'base.html.twig' %} + +{% block body %} +

    Create Cart, Product and CartProduct, increase quantity on update

    + +
    + + +
    + + + + + +
    +
    +{% endblock %}