Skip to content
Open
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
2 changes: 2 additions & 0 deletions src/TwigHooks/composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,9 @@
"webmozart/assert": "^1.9"
},
"require-dev": {
"symfony/browser-kit": "^6.4 || ^7.4 || ^8.0",
"symfony/console": "^6.4 || ^7.4 || ^8.0",
"symfony/css-selector": "^6.4 || ^7.4 || ^8.0",
"symfony/dom-crawler": "^6.4 || ^7.4 || ^8.0",
"symfony/dotenv": "^6.4 || ^7.4 || ^8.0",
"symfony/framework-bundle": "^6.4 || ^7.4 || ^8.0",
Expand Down
5 changes: 5 additions & 0 deletions src/TwigHooks/src/Hookable/Metadata/HookableMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,9 @@ public function withConfiguration(ScalarDataBagInterface $configuration): self
{
return new self($this->renderedBy, $this->context, $configuration, $this->prefixes);
}

public function withContext(DataBagInterface $context): self
{
return new self($this->renderedBy, $context, $this->configuration, $this->prefixes);
}
}
120 changes: 115 additions & 5 deletions src/TwigHooks/src/LiveComponent/HookableLiveComponentTrait.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,36 +20,146 @@
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\TwigComponent\Attribute\ExposeInTemplate;

/**
* The hookable context is not sent to the client, as it usually contains objects (grid views, forms, entities...),
* so it is gone during live re-renders. To keep it available in the component template and in its nested hooks:
* - list the scalar context values to keep in the LiveProp with {@see getDehydratedHookableContextKeys()};
* - give back the other values (built from the component's own LiveProps) with {@see provideHookableContext()}.
*/
trait HookableLiveComponentTrait
{
#[LiveProp(hydrateWith: 'hydrateHookableMetadata', dehydrateWith: 'dehydrateHookableMetadata')]
#[ExposeInTemplate('hookable_metadata')]
#[ExposeInTemplate(name: 'hookable_metadata', getter: 'getHookableMetadataForTemplate')]
public ?HookableMetadata $hookableMetadata = null;

/**
* @param array{renderedBy: string, configuration: string, prefixes?: array<string>, context?: array<string, mixed>}|null $data
*/
public function hydrateHookableMetadata($data): ?HookableMetadata
{
if (null === $data) {
return null;
}

$context = new DataBag($data['context'] ?? []);

return new HookableMetadata(
new HookMetadata($data['renderedBy'], new DataBag()),
new DataBag(),
new HookMetadata($data['renderedBy'], $context),
$context,
new ScalarDataBag(json_decode($data['configuration'], true)),
$data['prefixes'] ?? [],
);
}

/**
* @return array{renderedBy: string, configuration: string, prefixes?: array<string>, context?: array<string, mixed>}|null
*/
public function dehydrateHookableMetadata(?HookableMetadata $metadata = null): ?array
{
if (null === $metadata) {
return null;
}

return [
$data = [
'renderedBy' => $metadata->renderedBy->name,
'configuration' => json_encode($metadata->configuration->all()),
'configuration' => json_encode($metadata->configuration->all(), \JSON_THROW_ON_ERROR),
'prefixes' => $metadata->prefixes,
];

$context = $this->dehydrateHookableContext($metadata);
if ([] !== $context) {
$data['context'] = $context;
}

return $data;
}

/**
* The hookable metadata exposed as "hookable_metadata", with the context provided by the component
* on top of the hookable context. Called at render time, on the first render and on every re-render.
*/
public function getHookableMetadataForTemplate(): ?HookableMetadata
{
if (null === $this->hookableMetadata) {
return null;
}

$providedContext = $this->provideHookableContext();
if ([] === $providedContext) {
return $this->hookableMetadata;
}

return $this->hookableMetadata->withContext(
new DataBag(array_merge($this->hookableMetadata->context->all(), $providedContext)),
);
}

/**
* Keys of the hookable context to keep across re-renders. Their values must be scalars, nulls
* or arrays of them. They are sent to the client: never list secrets here.
*
* @return array<string>
*/
protected function getDehydratedHookableContextKeys(): array
{
return [];
}

/**
* Context values merged on top of the hookable context at render time, on the first render and on every
* re-render. Use it to give back the objects that cannot be sent to the client, from the component's LiveProps.
*
* @return array<string, mixed>
*/
protected function provideHookableContext(): array
{
return [];
}

/**
* @return array<string, mixed>
*/
private function dehydrateHookableContext(HookableMetadata $metadata): array
{
$hookableContext = $metadata->context->all();
$context = [];

foreach ($this->getDehydratedHookableContextKeys() as $key) {
if (!array_key_exists($key, $hookableContext)) {
continue;
}

$value = $hookableContext[$key];
if (!self::isDehydratableHookableContextValue($value)) {
throw new \LogicException(sprintf(
'The "%s" hookable context value of the "%s" component cannot be dehydrated, only scalars, nulls and arrays of them can. Provide it with "provideHookableContext()" instead.',
$key,
static::class,
));
}

$context[$key] = $value;
}

return $context;
}

private static function isDehydratableHookableContextValue(mixed $value): bool
{
if (null === $value || is_scalar($value)) {
return true;
}

if (!is_array($value)) {
return false;
}

foreach ($value as $item) {
if (!self::isDehydratableHookableContextValue($item)) {
return false;
}
}

return true;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,17 @@ sylius_twig_hooks:
dummy:
component: 'app:dummy_live'

'hookable_live_component_with_context':
context_aware:
component: 'app:context_aware_live'
props:
productName: '@=_context.product.name'

'hookable_live_component_with_context.nested':
flagged:
template: 'hookable_live_component/nested.html.twig'
condition: '@=_context.flag'

'hookable_twig_component':
dummy:
component: 'app:dummy'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<?php

/*
* This file is part of the Sylius package.
*
* (c) Sylius Sp. z o.o.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

declare(strict_types=1);

namespace TestApplication\Sylius\TwigHooks\Component;

use Sylius\TwigHooks\LiveComponent\HookableLiveComponentTrait;
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;

#[AsLiveComponent('app:context_aware_live', template: '_component/context_aware_live.html.twig')]
final class ContextAwareLiveComponent
{
use DefaultActionTrait;
use HookableLiveComponentTrait;

#[LiveProp]
public string $productName = '';

/**
* @return array<string>
*/
protected function getDehydratedHookableContextKeys(): array
{
return ['flag', 'not_in_context'];
}

/**
* @return array<string, mixed>
*/
protected function provideHookableContext(): array
{
return ['product' => (object) ['name' => $this->productName]];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
<div {{ attributes }}>
flag: {{ hookable_metadata.context.flag ? 'yes' : 'no' }}
product: {{ hookable_metadata.context.product.name }}
secret: {{ hookable_metadata.context.secret ?? 'none' }}
{% hook 'nested' %}
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{% hook 'hookable_live_component_with_context' with { flag: true, product, secret: 's3cr3t' } %}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nested product: {{ get_hookable_context().product.name }}
46 changes: 44 additions & 2 deletions src/TwigHooks/tests/Functional/Twig/HookableLiveComponentTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,21 @@

namespace Tests\Sylius\TwigHooks\Functional\Twig;

use Sylius\TwigHooks\Bag\DataBag;
use Sylius\TwigHooks\Bag\ScalarDataBag;
use Sylius\TwigHooks\Hook\Metadata\HookMetadata;
use Sylius\TwigHooks\Hookable\Metadata\HookableMetadata;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
use Symfony\UX\LiveComponent\Test\InteractsWithLiveComponents;
use Twig\Environment as Twig;

/**
* @group kernel-required
*/
final class HookableLiveComponentTest extends KernelTestCase
{
use InteractsWithLiveComponents;

public function testItRendersHookableLiveComponentWithUsingTwigHooks(): void
{
$this->assertSame(
Expand All @@ -43,11 +50,46 @@ public function testItRendersHookableLiveComponentWithoutUsingTwigHooks(): void
);
}

private function render(string $path): string
public function testItRendersHookableLiveComponentWithTheHookableContext(): void
{
$rendered = $this->render(
'hookable_live_component/hookable_component_rendered_with_context.html.twig',
['product' => (object) ['name' => 'Mug']],
);

$this->assertStringContainsString('flag: yes', $rendered);
$this->assertStringContainsString('product: Mug', $rendered);
$this->assertStringContainsString('secret: s3cr3t', $rendered);
$this->assertStringContainsString('nested product: Mug', $rendered);
}

public function testItKeepsTheHookableContextOnReRender(): void
{
$hookName = 'hookable_live_component_with_context';
$context = new DataBag(['flag' => true, 'product' => (object) ['name' => 'Mug'], 'secret' => 's3cr3t']);

$component = $this->createLiveComponent('app:context_aware_live', [
'hookableMetadata' => new HookableMetadata(new HookMetadata($hookName, $context), $context, new ScalarDataBag([]), [$hookName]),
'productName' => 'Mug',
]);

$rendered = $component->refresh()->render()->toString();

$this->assertStringContainsString('flag: yes', $rendered);
$this->assertStringContainsString('product: Mug', $rendered);
$this->assertStringContainsString('secret: none', $rendered);
$this->assertStringContainsString('nested product: Mug', $rendered);
$this->assertStringNotContainsString('s3cr3t', $rendered);
}

/**
* @param array<string, mixed> $context
*/
private function render(string $path, array $context = []): string
{
/** @var Twig $twig */
$twig = $this->getContainer()->get('twig');

return $twig->render($path);
return $twig->render($path, $context);
}
}
Loading
Loading