Skip to content

[Twig][Live] Native attributes #255

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Feb 4, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
use Symfony\Contracts\Service\ServiceSubscriberInterface;
use Symfony\UX\LiveComponent\LiveComponentHydrator;
use Symfony\UX\TwigComponent\ComponentAttributes;
use Symfony\UX\TwigComponent\ComponentMetadata;
use Symfony\UX\TwigComponent\EventListener\PreRenderEvent;
use Symfony\UX\TwigComponent\MountedComponent;
use Twig\Environment;

/**
Expand All @@ -29,7 +29,7 @@ public function onPreRender(PreRenderEvent $event): void
return;
}

$attributes = $this->getLiveAttributes($event->getComponent(), $event->getMetadata());
$attributes = $this->getLiveAttributes($event->getMountedComponent());
$variables = $event->getVariables();

if (isset($variables['attributes']) && $variables['attributes'] instanceof ComponentAttributes) {
Expand Down Expand Up @@ -57,12 +57,11 @@ public static function getSubscribedServices(): array
];
}

private function getLiveAttributes(object $component, ComponentMetadata $metadata): ComponentAttributes
private function getLiveAttributes(MountedComponent $mounted): ComponentAttributes
{
$url = $this->container->get(UrlGeneratorInterface::class)
->generate('live_component', ['component' => $metadata->getName()])
;
$data = $this->container->get(LiveComponentHydrator::class)->dehydrate($component);
$name = $mounted->getName();
$url = $this->container->get(UrlGeneratorInterface::class)->generate('live_component', ['component' => $name]);
$data = $this->container->get(LiveComponentHydrator::class)->dehydrate($mounted);
$twig = $this->container->get(Environment::class);

$attributes = [
Expand All @@ -73,7 +72,7 @@ private function getLiveAttributes(object $component, ComponentMetadata $metadat

if ($this->container->has(CsrfTokenManagerInterface::class)) {
$attributes['data-live-csrf-value'] = $this->container->get(CsrfTokenManagerInterface::class)
->getToken($metadata->getName())->getValue()
->getToken($name)->getValue()
;
}

Expand Down
28 changes: 17 additions & 11 deletions src/LiveComponent/src/EventListener/LiveComponentSubscriber.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
use Symfony\UX\TwigComponent\ComponentFactory;
use Symfony\UX\TwigComponent\ComponentMetadata;
use Symfony\UX\TwigComponent\ComponentRenderer;
use Symfony\UX\TwigComponent\MountedComponent;

/**
* @author Kevin Bond <[email protected]>
Expand Down Expand Up @@ -73,6 +74,8 @@ public function onKernelRequest(RequestEvent $event): void
$action = $request->get('action', 'get');
$componentName = (string) $request->get('component');

$request->attributes->set('_component_name', $componentName);

try {
/** @var ComponentMetadata $metadata */
$metadata = $this->container->get(ComponentFactory::class)->metadataFor($componentName);
Expand All @@ -84,8 +87,6 @@ public function onKernelRequest(RequestEvent $event): void
throw new NotFoundHttpException(sprintf('"%s" (%s) is not a Live Component.', $metadata->getClass(), $componentName));
}

$request->attributes->set('_component_metadata', $metadata);

if ('get' === $action) {
$defaultAction = trim($metadata->get('default_action', '__invoke'), '()');

Expand Down Expand Up @@ -144,9 +145,13 @@ public function onKernelController(ControllerEvent $event): void
throw new NotFoundHttpException(sprintf('The action "%s" either doesn\'t exist or is not allowed in "%s". Make sure it exist and has the LiveAction attribute above it.', $action, \get_class($component)));
}

$this->container->get(LiveComponentHydrator::class)->hydrate($component, $data);
$mounted = $this->container->get(LiveComponentHydrator::class)->hydrate(
$component,
$data,
$request->attributes->get('_component_name')
);

$request->attributes->set('_component', $component);
$request->attributes->set('_mounted_component', $mounted);

if (!\is_string($queryString = $request->query->get('args'))) {
return;
Expand All @@ -170,7 +175,7 @@ public function onKernelView(ViewEvent $event): void
return;
}

$response = $this->createResponse($request->attributes->get('_component'), $request);
$response = $this->createResponse($request->attributes->get('_mounted_component'), $request);

$event->setResponse($response);
}
Expand All @@ -187,14 +192,14 @@ public function onKernelException(ExceptionEvent $event): void
return;
}

$component = $request->attributes->get('_component');
$mounted = $request->attributes->get('_mounted_component');

// in case the exception was too early somehow
if (!$component) {
if (!$mounted) {
return;
}

$response = $this->createResponse($component, $request);
$response = $this->createResponse($mounted, $request);
$event->setResponse($response);
}

Expand Down Expand Up @@ -232,15 +237,16 @@ public static function getSubscribedEvents(): array
];
}

private function createResponse(object $component, Request $request): Response
private function createResponse(MountedComponent $mounted, Request $request): Response
{
$component = $mounted->getComponent();

foreach (AsLiveComponent::beforeReRenderMethods($component) as $method) {
$component->{$method->name}();
}

$html = $this->container->get(ComponentRenderer::class)->render(
$component,
$request->attributes->get('_component_metadata')
$mounted,
);

return new Response($html);
Expand Down
24 changes: 21 additions & 3 deletions src/LiveComponent/src/LiveComponentHydrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LivePropContext;
use Symfony\UX\LiveComponent\Exception\UnsupportedHydrationException;
use Symfony\UX\TwigComponent\ComponentAttributes;
use Symfony\UX\TwigComponent\MountedComponent;

/**
* @author Kevin Bond <[email protected]>
Expand All @@ -29,6 +31,7 @@ final class LiveComponentHydrator
{
private const CHECKSUM_KEY = '_checksum';
private const EXPOSED_PROP_KEY = '_id';
private const ATTRIBUTES_KEY = '_attributes';

/** @var PropertyHydratorInterface[] */
private iterable $propertyHydrators;
Expand All @@ -45,8 +48,10 @@ public function __construct(iterable $propertyHydrators, PropertyAccessorInterfa
$this->secret = $secret;
}

public function dehydrate(object $component): array
public function dehydrate(MountedComponent $mounted): array
{
$component = $mounted->getComponent();

foreach (AsLiveComponent::preDehydrateMethods($component) as $method) {
$component->{$method->name}();
}
Expand Down Expand Up @@ -100,15 +105,24 @@ public function dehydrate(object $component): array
}
}

if ($attributes = $mounted->getAttributes()->all()) {
$data[self::ATTRIBUTES_KEY] = $attributes;
$readonlyProperties[] = self::ATTRIBUTES_KEY;
}

$data[self::CHECKSUM_KEY] = $this->computeChecksum($data, $readonlyProperties);

return $data;
}

public function hydrate(object $component, array $data): void
public function hydrate(object $component, array $data, string $componentName): MountedComponent
{
$readonlyProperties = [];

if (isset($data[self::ATTRIBUTES_KEY])) {
$readonlyProperties[] = self::ATTRIBUTES_KEY;
}

/** @var LivePropContext[] $propertyContexts */
$propertyContexts = iterator_to_array(AsLiveComponent::liveProps($component));

Expand All @@ -129,7 +143,9 @@ public function hydrate(object $component, array $data): void

$this->verifyChecksum($data, $readonlyProperties);

unset($data[self::CHECKSUM_KEY]);
$attributes = new ComponentAttributes($data[self::ATTRIBUTES_KEY] ?? []);

unset($data[self::CHECKSUM_KEY], $data[self::ATTRIBUTES_KEY]);

foreach ($propertyContexts as $context) {
$property = $context->reflectionProperty();
Expand Down Expand Up @@ -187,6 +203,8 @@ public function hydrate(object $component, array $data): void
foreach (AsLiveComponent::postHydrateMethods($component) as $method) {
$component->{$method->name}();
}

return new MountedComponent($componentName, $component, $attributes);
}

private function computeChecksum(array $data, array $readonlyProperties): string
Expand Down
23 changes: 4 additions & 19 deletions src/LiveComponent/src/Resources/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -249,30 +249,15 @@ Component Attributes

.. versionadded:: 2.1

The ``HasAttributes`` trait was added in TwigComponents 2.1.
Component attributes were added in TwigComponents 2.1.

`Component attributes`_ allows you to render your components with extra
props that are are converted to html attributes and made available in
your component's template as an ``attributes`` variable. When used on
live components, these props are persisted between renders. You can enable
this feature by having your live component use the ``HasAttributesTrait``:
live components, these props are persisted between renders.

.. code-block:: diff

// ...
use Symfony\UX\LiveComponent\Attribute\LiveProp;
+ use Symfony\UX\TwigComponent\HasAttributesTrait;

#[AsLiveComponent('random_number')]
class RandomNumberComponent
{
+ use HasAttributesTrait;

#[LiveProp]
public int $min = 0;

Now, when rendering your component, you can pass html attributes
as props and these will be added to ``attributes``:
When rendering your component, you can pass html attributes as props and
these will be added to ``attributes``:

.. code-block:: twig

Expand Down
4 changes: 2 additions & 2 deletions src/LiveComponent/src/Twig/LiveComponentRuntime.php
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@ public function __construct(

public function getComponentUrl(string $name, array $props = []): string
{
$component = $this->factory->create($name, $props);
$params = ['component' => $name] + $this->hydrator->dehydrate($component);
$mounted = $this->factory->create($name, $props);
$params = ['component' => $name] + $this->hydrator->dehydrate($mounted);

return $this->urlGenerator->generate('live_component', $params);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\TwigComponent\HasAttributesTrait;

/**
* @author Kevin Bond <[email protected]>
Expand All @@ -13,5 +12,4 @@
final class ComponentWithAttributes
{
use DefaultActionTrait;
use HasAttributesTrait;
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,9 +14,6 @@
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\UX\LiveComponent\LiveComponentHydrator;
use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component1;
use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component2;
use Symfony\UX\LiveComponent\Tests\Fixtures\Component\Component6;
use Symfony\UX\LiveComponent\Tests\Fixtures\Entity\Entity1;
use Symfony\UX\TwigComponent\ComponentFactory;
use Zenstruck\Browser\Response\HtmlResponse;
Expand All @@ -42,7 +39,6 @@ public function testCanRenderComponentAsHtml(): void
/** @var ComponentFactory $factory */
$factory = self::getContainer()->get('ux.twig_component.component_factory');

/** @var Component1 $component */
$component = $factory->create('component1', [
'prop1' => $entity = create(Entity1::class)->object(),
'prop2' => $date = new \DateTime('2021-03-05 9:23'),
Expand Down Expand Up @@ -72,10 +68,7 @@ public function testCanExecuteComponentAction(): void
/** @var ComponentFactory $factory */
$factory = self::getContainer()->get('ux.twig_component.component_factory');

/** @var Component2 $component */
$component = $factory->create('component2');

$dehydrated = $hydrator->dehydrate($component);
$dehydrated = $hydrator->dehydrate($factory->create('component2'));
$token = null;

$this->browser()
Expand Down Expand Up @@ -160,10 +153,7 @@ public function testBeforeReRenderHookOnlyExecutedDuringAjax(): void
/** @var ComponentFactory $factory */
$factory = self::getContainer()->get('ux.twig_component.component_factory');

/** @var Component2 $component */
$component = $factory->create('component2');

$dehydrated = $hydrator->dehydrate($component);
$dehydrated = $hydrator->dehydrate($factory->create('component2'));

$this->browser()
->visit('/render-template/template1')
Expand All @@ -183,10 +173,7 @@ public function testCanRedirectFromComponentAction(): void
/** @var ComponentFactory $factory */
$factory = self::getContainer()->get('ux.twig_component.component_factory');

/** @var Component2 $component */
$component = $factory->create('component2');

$dehydrated = $hydrator->dehydrate($component);
$dehydrated = $hydrator->dehydrate($factory->create('component2'));
$token = null;

$this->browser()
Expand Down Expand Up @@ -226,10 +213,7 @@ public function testInjectsLiveArgs(): void
/** @var ComponentFactory $factory */
$factory = self::getContainer()->get('ux.twig_component.component_factory');

/** @var Component6 $component */
$component = $factory->create('component6');

$dehydrated = $hydrator->dehydrate($component);
$dehydrated = $hydrator->dehydrate($factory->create('component6'));
$token = null;

$argsQueryParams = http_build_query(['args' => http_build_query(['arg1' => 'hello', 'arg2' => 666, 'custom' => '33.3'])]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,15 +123,18 @@ public function testFormRemembersValidationFromInitialForm(): void

$form = $formFactory->create(BlogPostFormType::class);
$form->submit(['title' => '', 'content' => '']);
/** @var FormWithCollectionTypeComponent $component */
$component = $factory->create('form_with_collection_type', [

$mounted = $factory->create('form_with_collection_type', [
'form' => $form->createView(),
]);

/** @var FormWithCollectionTypeComponent $component */
$component = $mounted->getComponent();

// component should recognize that it is already submitted
$this->assertTrue($component->isValidated);

$dehydrated = $hydrator->dehydrate($component);
$dehydrated = $hydrator->dehydrate($mounted);
$dehydrated['blog_post_form']['content'] = 'changed description';
$dehydrated['validatedFields'][] = 'blog_post_form.content';

Expand Down
Loading