Skip to content

[LiveComponent] Add modifier option to LiveProp #1507

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
Mar 5, 2024
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
4 changes: 4 additions & 0 deletions src/LiveComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# CHANGELOG

## 2.17.0

- Add `modifier` option in `LiveProp` so options can be modified at runtime.

## 2.16.0

- LiveComponents is now stable and no longer experimental 🥳
Expand Down
47 changes: 47 additions & 0 deletions src/LiveComponent/doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -3450,6 +3450,53 @@ the change of one specific key::
}
}

Set LiveProp Options Dynamically
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. versionadded:: 2.17

The ``modifier`` option was added in LiveComponents 2.17.


If you need to configure a LiveProp's options dynamically, you can use the ``modifier`` option to use a custom
method in your component that returns a modified version of your LiveProp::


#[AsLiveComponent]
class ProductSearch
{
#[LiveProp(writable: true, modifier: 'modifyAddedDate')]
public ?\DateTimeImmutable $addedDate = null;

#[LiveProp]
public string $dateFormat = 'Y-m-d';

// ...

public function modifyAddedDate(LiveProp $prop): LiveProp
{
return $prop->withFormat($this->dateFormat);
}
}

Then, when using your component in a template, you can change the date format used for ``$addedDate``:

.. code-block:: twig

{{ component('ProductSearch', {
dateFormat: 'd/m/Y'
}) }}


All ``LiveProp::with*`` methods are immutable, so you need to use their return value as your new LiveProp.

.. caution::

Avoid relying on props that also use a modifier in other modifiers methods. For example, if the ``$dateFormat``
property above also had a ``modifier`` option, then it wouldn't be safe to reference it from the ``modifyAddedDate``
modifier method. This is because the ``$dateFormat`` property may not have been hydrated by this point.


Debugging Components
--------------------

Expand Down
107 changes: 104 additions & 3 deletions src/LiveComponent/src/Attribute/LiveProp.php
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,19 @@ public function __construct(
* in the URL.
*/
private bool $url = false,

/**
* A hook that will be called when this LiveProp is used.
*
* Allows to modify the LiveProp options depending on the context. The
* original LiveProp attribute instance will be passed as an argument to
* it.
*
* @var string|null
*/
private string|null $modifier = null,
) {
if ($this->useSerializerForHydration && ($this->hydrateWith || $this->dehydrateWith)) {
throw new \InvalidArgumentException('Cannot use useSerializerForHydration with hydrateWith or dehydrateWith.');
}
self::validateHydrationStrategy($this);
}

/**
Expand All @@ -119,6 +128,14 @@ public function isIdentityWritable(): bool
return \in_array(self::IDENTITY, $this->writable, true);
}

public function withWritable(bool|array $writable): self
{
$clone = clone $this;
$clone->writable = $writable;

return $clone;
}

/**
* @internal
*/
Expand All @@ -141,6 +158,16 @@ public function hydrateMethod(): ?string
return $this->hydrateWith ? trim($this->hydrateWith, '()') : null;
}

public function withHydrateWith(?string $hydrateWith): self
{
$clone = clone $this;
$clone->hydrateWith = $hydrateWith;

self::validateHydrationStrategy($clone);

return $clone;
}

/**
* @internal
*/
Expand All @@ -149,6 +176,16 @@ public function dehydrateMethod(): ?string
return $this->dehydrateWith ? trim($this->dehydrateWith, '()') : null;
}

public function withDehydrateWith(?string $dehydrateWith): self
{
$clone = clone $this;
$clone->dehydrateWith = $dehydrateWith;

self::validateHydrationStrategy($clone);

return $clone;
}

/**
* @internal
*/
Expand All @@ -157,6 +194,16 @@ public function useSerializerForHydration(): bool
return $this->useSerializerForHydration;
}

public function withUseSerializerForHydration(bool $userSerializerForHydration): self
{
$clone = clone $this;
$clone->useSerializerForHydration = $userSerializerForHydration;

self::validateHydrationStrategy($clone);

return $clone;
}

/**
* @internal
*/
Expand All @@ -165,6 +212,16 @@ public function serializationContext(): array
return $this->serializationContext;
}

public function withSerializationContext(array $serializationContext): self
{
$clone = clone $this;
$clone->serializationContext = $serializationContext;

self::validateHydrationStrategy($clone);

return $clone;
}

/**
* @internal
*/
Expand All @@ -181,11 +238,27 @@ public function calculateFieldName(object $component, string $fallback): string
return $this->fieldName;
}

public function withFieldName(?string $fieldName): self
{
$clone = clone $this;
$clone->fieldName = $fieldName;

return $clone;
}

public function format(): ?string
{
return $this->format;
}

public function withFormat(?string $format): self
{
$clone = clone $this;
$clone->format = $format;

return $clone;
}

public function acceptUpdatesFromParent(): bool
{
return $this->updateFromParent;
Expand All @@ -196,8 +269,36 @@ public function onUpdated(): string|array|null
return $this->onUpdated;
}

public function withOnUpdated(string|array|null $onUpdated): self
{
$clone = clone $this;
$clone->onUpdated = $onUpdated;

return $clone;
}

public function url(): bool
{
return $this->url;
}

public function withUrl(bool $url): self
{
$clone = clone $this;
$clone->url = $url;

return $clone;
}

public function modifier(): string|null
{
return $this->modifier;
}

private static function validateHydrationStrategy(self $liveProp): void
{
if ($liveProp->useSerializerForHydration && ($liveProp->hydrateWith || $liveProp->dehydrateWith)) {
throw new \InvalidArgumentException('Cannot use useSerializerForHydration with hydrateWith or dehydrateWith.');
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,7 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
new Reference('request_stack'),
new Reference('ux.live_component.metadata_factory'),
new Reference('ux.live_component.query_string_props_extractor'),
new Reference('property_accessor'),
])
->addTag('kernel.event_subscriber');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,11 @@

use Symfony\Component\EventDispatcher\EventSubscriberInterface;
use Symfony\Component\HttpFoundation\RequestStack;
use Symfony\Component\PropertyAccess\Exception\ExceptionInterface as PropertyAccessExceptionInterface;
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory;
use Symfony\UX\LiveComponent\Util\QueryStringPropsExtractor;
use Symfony\UX\TwigComponent\Event\PreMountEvent;
use Symfony\UX\TwigComponent\Event\PostMountEvent;

/**
* @author Nicolas Rigaud <squrious@protonmail.com>
Expand All @@ -28,17 +30,18 @@ public function __construct(
private readonly RequestStack $requestStack,
private readonly LiveComponentMetadataFactory $metadataFactory,
private readonly QueryStringPropsExtractor $queryStringPropsExtractor,
private readonly PropertyAccessorInterface $propertyAccessor,
) {
}

public static function getSubscribedEvents(): array
{
return [
PreMountEvent::class => 'onPreMount',
PostMountEvent::class => ['onPostMount', 256],
];
}

public function onPreMount(PreMountEvent $event): void
public function onPostMount(PostMountEvent $event): void
{
if (!$event->getMetadata()->get('live', false)) {
// Not a live component
Expand All @@ -53,12 +56,20 @@ public function onPreMount(PreMountEvent $event): void

$metadata = $this->metadataFactory->getMetadata($event->getMetadata()->getName());

if (!$metadata->hasQueryStringBindings()) {
if (!$metadata->hasQueryStringBindings($event->getComponent())) {
return;
}

$queryStringData = $this->queryStringPropsExtractor->extract($request, $metadata, $event->getComponent());

$event->setData(array_merge($event->getData(), $queryStringData));
$component = $event->getComponent();

foreach ($queryStringData as $name => $value) {
try {
$this->propertyAccessor->setValue($component, $name, $value);
} catch (PropertyAccessExceptionInterface $exception) {
// Ignore errors
}
}
}
}
5 changes: 3 additions & 2 deletions src/LiveComponent/src/LiveComponentHydrator.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public function dehydrate(object $component, ComponentAttributes $attributes, Li
$takenFrontendPropertyNames = [];

$dehydratedProps = new DehydratedProps();
foreach ($componentMetadata->getAllLivePropsMetadata() as $propMetadata) {
foreach ($componentMetadata->getAllLivePropsMetadata($component) as $propMetadata) {
$propertyName = $propMetadata->getName();
$frontendName = $propMetadata->calculateFieldName($component, $propertyName);

Expand Down Expand Up @@ -143,8 +143,9 @@ public function hydrate(object $component, array $props, array $updatedProps, Li
$attributes = new ComponentAttributes($dehydratedOriginalProps->getPropValue(self::ATTRIBUTES_KEY, []));
$dehydratedOriginalProps->removePropValue(self::ATTRIBUTES_KEY);

foreach ($componentMetadata->getAllLivePropsMetadata() as $propMetadata) {
foreach ($componentMetadata->getAllLivePropsMetadata($component) as $propMetadata) {
$frontendName = $propMetadata->calculateFieldName($component, $propMetadata->getName());

if (!$dehydratedOriginalProps->hasPropValue($frontendName)) {
// this property was not sent, so skip
// even if this has writable paths, if no identity is sent,
Expand Down
14 changes: 10 additions & 4 deletions src/LiveComponent/src/Metadata/LiveComponentMetadata.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ public function __construct(
/** @var LivePropMetadata[] */
private array $livePropsMetadata,
) {
uasort(
$this->livePropsMetadata,
static fn (LivePropMetadata $a, LivePropMetadata $b) => $a->hasModifier() <=> $b->hasModifier()
);
}

public function getComponentMetadata(): ComponentMetadata
Expand All @@ -35,9 +39,11 @@ public function getComponentMetadata(): ComponentMetadata
/**
* @return LivePropMetadata[]
*/
public function getAllLivePropsMetadata(): array
public function getAllLivePropsMetadata(object $component): iterable
{
return $this->livePropsMetadata;
foreach ($this->livePropsMetadata as $livePropMetadata) {
yield $livePropMetadata->withModifier($component);
}
}

/**
Expand All @@ -60,9 +66,9 @@ public function getOnlyPropsThatAcceptUpdatesFromParent(array $inputProps): arra
return array_intersect_key($inputProps, array_flip($propNames));
}

public function hasQueryStringBindings(): bool
public function hasQueryStringBindings($component): bool
{
foreach ($this->getAllLivePropsMetadata() as $livePropMetadata) {
foreach ($this->getAllLivePropsMetadata($component) as $livePropMetadata) {
if ($livePropMetadata->queryStringMapping()) {
return true;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -107,8 +107,7 @@ public function createLivePropMetadata(string $className, string $propertyName,
$infoType,
$isTypeBuiltIn,
$isTypeNullable,
$collectionValueType,
$liveProp->url()
$collectionValueType
);
}

Expand Down
Loading