Skip to content

Commit cae0f0e

Browse files
committed
feature #1507 [LiveComponent] Add modifier option to LiveProp (squrious)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [LiveComponent] Add modifier option to LiveProp | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Issues | N/A | License | MIT Hi! Following discussions in [#1396](#1396 (comment)), here is a proposal to allow a `LiveProp` to be modified at runtime. The feature adds a `modifier` option to `LiveProp`, so users can use a custom method to modify the `LiveProp`'s options depending on the context. This context could be a service, or even another prop within the component. ### Usage Here is the example given in the doc: ```php #[AsLiveComponent] class ProductSearch { #[LiveProp(writable: true, modifier: 'modifyAddedDate')] public ?\DateTimeImmutable $addedDate; #[LiveProp] public string $dateFormat = 'Y-m-d'; // ... public function modifyAddedDate(LiveProp $prop): LiveProp { return $prop->withFormat($this->dateFormat); } } ``` ```twig {{ component('ProductSearch', { dateFormat: 'd/m/Y' }) }} ``` ### Changes overview First, we have to make the `LiveProp` attribute editable in some way. I opted out for immutable methods, so caching is preserved when applicable. Then add a `LivePropMetadata::withModifier(object $component)` method that returns a new instance modified by the user-defined method. This is called in a few places, similarly to `LiveComponentMetadata::calculateFieldName()`. The tricky part is component hydration. If we use props in modifiers to change other `LiveProp` options, they have to be hydrated before the modifier is applied. This is only useful for options related to hydration, but it concerns most of them... The trick is to sort metadata, so modified ones are hydrated at the end. Commits ------- 3bcc5c2 [LiveComponent] Add modifier option to LiveProp
2 parents 8af8b3d + 3bcc5c2 commit cae0f0e

20 files changed

+378
-29
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# CHANGELOG
22

3+
## 2.17.0
4+
5+
- Add `modifier` option in `LiveProp` so options can be modified at runtime.
6+
37
## 2.16.0
48

59
- LiveComponents is now stable and no longer experimental 🥳

src/LiveComponent/doc/index.rst

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3450,6 +3450,53 @@ the change of one specific key::
34503450
}
34513451
}
34523452

3453+
Set LiveProp Options Dynamically
3454+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3455+
3456+
.. versionadded:: 2.17
3457+
3458+
The ``modifier`` option was added in LiveComponents 2.17.
3459+
3460+
3461+
If you need to configure a LiveProp's options dynamically, you can use the ``modifier`` option to use a custom
3462+
method in your component that returns a modified version of your LiveProp::
3463+
3464+
3465+
#[AsLiveComponent]
3466+
class ProductSearch
3467+
{
3468+
#[LiveProp(writable: true, modifier: 'modifyAddedDate')]
3469+
public ?\DateTimeImmutable $addedDate = null;
3470+
3471+
#[LiveProp]
3472+
public string $dateFormat = 'Y-m-d';
3473+
3474+
// ...
3475+
3476+
public function modifyAddedDate(LiveProp $prop): LiveProp
3477+
{
3478+
return $prop->withFormat($this->dateFormat);
3479+
}
3480+
}
3481+
3482+
Then, when using your component in a template, you can change the date format used for ``$addedDate``:
3483+
3484+
.. code-block:: twig
3485+
3486+
{{ component('ProductSearch', {
3487+
dateFormat: 'd/m/Y'
3488+
}) }}
3489+
3490+
3491+
All ``LiveProp::with*`` methods are immutable, so you need to use their return value as your new LiveProp.
3492+
3493+
.. caution::
3494+
3495+
Avoid relying on props that also use a modifier in other modifiers methods. For example, if the ``$dateFormat``
3496+
property above also had a ``modifier`` option, then it wouldn't be safe to reference it from the ``modifyAddedDate``
3497+
modifier method. This is because the ``$dateFormat`` property may not have been hydrated by this point.
3498+
3499+
34533500
Debugging Components
34543501
--------------------
34553502

src/LiveComponent/src/Attribute/LiveProp.php

Lines changed: 104 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,19 @@ public function __construct(
101101
* in the URL.
102102
*/
103103
private bool $url = false,
104+
105+
/**
106+
* A hook that will be called when this LiveProp is used.
107+
*
108+
* Allows to modify the LiveProp options depending on the context. The
109+
* original LiveProp attribute instance will be passed as an argument to
110+
* it.
111+
*
112+
* @var string|null
113+
*/
114+
private string|null $modifier = null,
104115
) {
105-
if ($this->useSerializerForHydration && ($this->hydrateWith || $this->dehydrateWith)) {
106-
throw new \InvalidArgumentException('Cannot use useSerializerForHydration with hydrateWith or dehydrateWith.');
107-
}
116+
self::validateHydrationStrategy($this);
108117
}
109118

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

131+
public function withWritable(bool|array $writable): self
132+
{
133+
$clone = clone $this;
134+
$clone->writable = $writable;
135+
136+
return $clone;
137+
}
138+
122139
/**
123140
* @internal
124141
*/
@@ -141,6 +158,16 @@ public function hydrateMethod(): ?string
141158
return $this->hydrateWith ? trim($this->hydrateWith, '()') : null;
142159
}
143160

161+
public function withHydrateWith(?string $hydrateWith): self
162+
{
163+
$clone = clone $this;
164+
$clone->hydrateWith = $hydrateWith;
165+
166+
self::validateHydrationStrategy($clone);
167+
168+
return $clone;
169+
}
170+
144171
/**
145172
* @internal
146173
*/
@@ -149,6 +176,16 @@ public function dehydrateMethod(): ?string
149176
return $this->dehydrateWith ? trim($this->dehydrateWith, '()') : null;
150177
}
151178

179+
public function withDehydrateWith(?string $dehydrateWith): self
180+
{
181+
$clone = clone $this;
182+
$clone->dehydrateWith = $dehydrateWith;
183+
184+
self::validateHydrationStrategy($clone);
185+
186+
return $clone;
187+
}
188+
152189
/**
153190
* @internal
154191
*/
@@ -157,6 +194,16 @@ public function useSerializerForHydration(): bool
157194
return $this->useSerializerForHydration;
158195
}
159196

197+
public function withUseSerializerForHydration(bool $userSerializerForHydration): self
198+
{
199+
$clone = clone $this;
200+
$clone->useSerializerForHydration = $userSerializerForHydration;
201+
202+
self::validateHydrationStrategy($clone);
203+
204+
return $clone;
205+
}
206+
160207
/**
161208
* @internal
162209
*/
@@ -165,6 +212,16 @@ public function serializationContext(): array
165212
return $this->serializationContext;
166213
}
167214

215+
public function withSerializationContext(array $serializationContext): self
216+
{
217+
$clone = clone $this;
218+
$clone->serializationContext = $serializationContext;
219+
220+
self::validateHydrationStrategy($clone);
221+
222+
return $clone;
223+
}
224+
168225
/**
169226
* @internal
170227
*/
@@ -181,11 +238,27 @@ public function calculateFieldName(object $component, string $fallback): string
181238
return $this->fieldName;
182239
}
183240

241+
public function withFieldName(?string $fieldName): self
242+
{
243+
$clone = clone $this;
244+
$clone->fieldName = $fieldName;
245+
246+
return $clone;
247+
}
248+
184249
public function format(): ?string
185250
{
186251
return $this->format;
187252
}
188253

254+
public function withFormat(?string $format): self
255+
{
256+
$clone = clone $this;
257+
$clone->format = $format;
258+
259+
return $clone;
260+
}
261+
189262
public function acceptUpdatesFromParent(): bool
190263
{
191264
return $this->updateFromParent;
@@ -196,8 +269,36 @@ public function onUpdated(): string|array|null
196269
return $this->onUpdated;
197270
}
198271

272+
public function withOnUpdated(string|array|null $onUpdated): self
273+
{
274+
$clone = clone $this;
275+
$clone->onUpdated = $onUpdated;
276+
277+
return $clone;
278+
}
279+
199280
public function url(): bool
200281
{
201282
return $this->url;
202283
}
284+
285+
public function withUrl(bool $url): self
286+
{
287+
$clone = clone $this;
288+
$clone->url = $url;
289+
290+
return $clone;
291+
}
292+
293+
public function modifier(): string|null
294+
{
295+
return $this->modifier;
296+
}
297+
298+
private static function validateHydrationStrategy(self $liveProp): void
299+
{
300+
if ($liveProp->useSerializerForHydration && ($liveProp->hydrateWith || $liveProp->dehydrateWith)) {
301+
throw new \InvalidArgumentException('Cannot use useSerializerForHydration with hydrateWith or dehydrateWith.');
302+
}
303+
}
203304
}

src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,7 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
227227
new Reference('request_stack'),
228228
new Reference('ux.live_component.metadata_factory'),
229229
new Reference('ux.live_component.query_string_props_extractor'),
230+
new Reference('property_accessor'),
230231
])
231232
->addTag('kernel.event_subscriber');
232233

src/LiveComponent/src/EventListener/QueryStringInitializeSubscriber.php

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,11 @@
1313

1414
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
1515
use Symfony\Component\HttpFoundation\RequestStack;
16+
use Symfony\Component\PropertyAccess\Exception\ExceptionInterface as PropertyAccessExceptionInterface;
17+
use Symfony\Component\PropertyAccess\PropertyAccessorInterface;
1618
use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory;
1719
use Symfony\UX\LiveComponent\Util\QueryStringPropsExtractor;
18-
use Symfony\UX\TwigComponent\Event\PreMountEvent;
20+
use Symfony\UX\TwigComponent\Event\PostMountEvent;
1921

2022
/**
2123
* @author Nicolas Rigaud <[email protected]>
@@ -28,17 +30,18 @@ public function __construct(
2830
private readonly RequestStack $requestStack,
2931
private readonly LiveComponentMetadataFactory $metadataFactory,
3032
private readonly QueryStringPropsExtractor $queryStringPropsExtractor,
33+
private readonly PropertyAccessorInterface $propertyAccessor,
3134
) {
3235
}
3336

3437
public static function getSubscribedEvents(): array
3538
{
3639
return [
37-
PreMountEvent::class => 'onPreMount',
40+
PostMountEvent::class => ['onPostMount', 256],
3841
];
3942
}
4043

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

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

56-
if (!$metadata->hasQueryStringBindings()) {
59+
if (!$metadata->hasQueryStringBindings($event->getComponent())) {
5760
return;
5861
}
5962

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

62-
$event->setData(array_merge($event->getData(), $queryStringData));
65+
$component = $event->getComponent();
66+
67+
foreach ($queryStringData as $name => $value) {
68+
try {
69+
$this->propertyAccessor->setValue($component, $name, $value);
70+
} catch (PropertyAccessExceptionInterface $exception) {
71+
// Ignore errors
72+
}
73+
}
6374
}
6475
}

src/LiveComponent/src/LiveComponentHydrator.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ public function dehydrate(object $component, ComponentAttributes $attributes, Li
6363
$takenFrontendPropertyNames = [];
6464

6565
$dehydratedProps = new DehydratedProps();
66-
foreach ($componentMetadata->getAllLivePropsMetadata() as $propMetadata) {
66+
foreach ($componentMetadata->getAllLivePropsMetadata($component) as $propMetadata) {
6767
$propertyName = $propMetadata->getName();
6868
$frontendName = $propMetadata->calculateFieldName($component, $propertyName);
6969

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

146-
foreach ($componentMetadata->getAllLivePropsMetadata() as $propMetadata) {
146+
foreach ($componentMetadata->getAllLivePropsMetadata($component) as $propMetadata) {
147147
$frontendName = $propMetadata->calculateFieldName($component, $propMetadata->getName());
148+
148149
if (!$dehydratedOriginalProps->hasPropValue($frontendName)) {
149150
// this property was not sent, so skip
150151
// even if this has writable paths, if no identity is sent,

src/LiveComponent/src/Metadata/LiveComponentMetadata.php

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ public function __construct(
2525
/** @var LivePropMetadata[] */
2626
private array $livePropsMetadata,
2727
) {
28+
uasort(
29+
$this->livePropsMetadata,
30+
static fn (LivePropMetadata $a, LivePropMetadata $b) => $a->hasModifier() <=> $b->hasModifier()
31+
);
2832
}
2933

3034
public function getComponentMetadata(): ComponentMetadata
@@ -35,9 +39,11 @@ public function getComponentMetadata(): ComponentMetadata
3539
/**
3640
* @return LivePropMetadata[]
3741
*/
38-
public function getAllLivePropsMetadata(): array
42+
public function getAllLivePropsMetadata(object $component): iterable
3943
{
40-
return $this->livePropsMetadata;
44+
foreach ($this->livePropsMetadata as $livePropMetadata) {
45+
yield $livePropMetadata->withModifier($component);
46+
}
4147
}
4248

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

63-
public function hasQueryStringBindings(): bool
69+
public function hasQueryStringBindings($component): bool
6470
{
65-
foreach ($this->getAllLivePropsMetadata() as $livePropMetadata) {
71+
foreach ($this->getAllLivePropsMetadata($component) as $livePropMetadata) {
6672
if ($livePropMetadata->queryStringMapping()) {
6773
return true;
6874
}

src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -107,8 +107,7 @@ public function createLivePropMetadata(string $className, string $propertyName,
107107
$infoType,
108108
$isTypeBuiltIn,
109109
$isTypeNullable,
110-
$collectionValueType,
111-
$liveProp->url()
110+
$collectionValueType
112111
);
113112
}
114113

0 commit comments

Comments
 (0)