Skip to content

Commit c4003f0

Browse files
committed
Add modifier option to LiveProp
1 parent 70f8ca4 commit c4003f0

15 files changed

+338
-14
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
new morphing library in 2.14.0 #1484
88
- Fix bug where the active input would maintain its value, but lose its cursor position #1501
99
- Restrict Twig 3.9 for now #1486
10+
- Add `modifier` option in `LiveProp` so options can be modified at runtime.
1011

1112
## 2.14.1
1213

src/LiveComponent/doc/index.rst

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3348,6 +3348,52 @@ the change of one specific key::
33483348
}
33493349
}
33503350

3351+
Set LiveProp Options Dynamically
3352+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3353+
3354+
.. versionadded:: 2.15
3355+
3356+
The ``modifier`` option was added in LiveComponents 2.15.
3357+
3358+
3359+
If you need to configure a LiveProp's options dynamically, you can use the ``modifier`` option to use a custom
3360+
method in your component that returns a modified version of your LiveProp::
3361+
3362+
3363+
#[AsLiveComponent]
3364+
class ProductSearch
3365+
{
3366+
#[LiveProp(writable: true, modifier: 'modifyAddedDate')]
3367+
public ?\DateTimeImmutable $addedDate;
3368+
3369+
#[LiveProp]
3370+
public string $dateFormat = 'Y-m-d';
3371+
3372+
// ...
3373+
3374+
public function modifyAddedDate(LiveProp $prop): LiveProp
3375+
{
3376+
return $prop->withFormat($this->dateFormat);
3377+
}
3378+
}
3379+
3380+
Then, when using your component in a template, you can change the date format used for ``$addedDate``:
3381+
3382+
.. code-block:: twig
3383+
3384+
{{ component('ProductSearch', {
3385+
dateFormat: 'd/m/Y'
3386+
}) }}
3387+
3388+
3389+
All ``LiveProp::with*`` methods are immutable, so you need to use their return value as your new LiveProp.
3390+
3391+
.. caution::
3392+
3393+
You must not rely on props that also use a modifier in other modifiers methods, as their modifiers will not
3394+
necessarily be applied in the correct order when hydrating components.
3395+
3396+
33513397
Debugging Components
33523398
--------------------
33533399

src/LiveComponent/src/Attribute/LiveProp.php

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

112121
/**
@@ -121,6 +130,14 @@ public function isIdentityWritable(): bool
121130
return \in_array(self::IDENTITY, $this->writable, true);
122131
}
123132

133+
public function withWritable(bool|array $writable): self
134+
{
135+
$clone = clone $this;
136+
$clone->writable = $writable;
137+
138+
return $clone;
139+
}
140+
124141
/**
125142
* @internal
126143
*/
@@ -143,6 +160,16 @@ public function hydrateMethod(): ?string
143160
return $this->hydrateWith ? trim($this->hydrateWith, '()') : null;
144161
}
145162

163+
public function withHydrateWith(?string $hydrateWith): self
164+
{
165+
$clone = clone $this;
166+
$clone->hydrateWith = $hydrateWith;
167+
168+
self::validateHydrationStrategy($clone);
169+
170+
return $clone;
171+
}
172+
146173
/**
147174
* @internal
148175
*/
@@ -151,6 +178,16 @@ public function dehydrateMethod(): ?string
151178
return $this->dehydrateWith ? trim($this->dehydrateWith, '()') : null;
152179
}
153180

181+
public function withDehydrateWith(?string $dehydrateWith): self
182+
{
183+
$clone = clone $this;
184+
$clone->dehydrateWith = $dehydrateWith;
185+
186+
self::validateHydrationStrategy($clone);
187+
188+
return $clone;
189+
}
190+
154191
/**
155192
* @internal
156193
*/
@@ -159,6 +196,16 @@ public function useSerializerForHydration(): bool
159196
return $this->useSerializerForHydration;
160197
}
161198

199+
public function withUseSerializerForHydration(bool $userSerializerForHydration): self
200+
{
201+
$clone = clone $this;
202+
$clone->useSerializerForHydration = $userSerializerForHydration;
203+
204+
self::validateHydrationStrategy($clone);
205+
206+
return $clone;
207+
}
208+
162209
/**
163210
* @internal
164211
*/
@@ -167,6 +214,16 @@ public function serializationContext(): array
167214
return $this->serializationContext;
168215
}
169216

217+
public function withSerializationContext(array $serializationContext): self
218+
{
219+
$clone = clone $this;
220+
$clone->serializationContext = $serializationContext;
221+
222+
self::validateHydrationStrategy($clone);
223+
224+
return $clone;
225+
}
226+
170227
/**
171228
* @internal
172229
*/
@@ -183,11 +240,27 @@ public function calculateFieldName(object $component, string $fallback): string
183240
return $this->fieldName;
184241
}
185242

243+
public function withFieldName(?string $fieldName): self
244+
{
245+
$clone = clone $this;
246+
$clone->fieldName = $fieldName;
247+
248+
return $clone;
249+
}
250+
186251
public function format(): ?string
187252
{
188253
return $this->format;
189254
}
190255

256+
public function withFormat(?string $format): self
257+
{
258+
$clone = clone $this;
259+
$clone->format = $format;
260+
261+
return $clone;
262+
}
263+
191264
public function acceptUpdatesFromParent(): bool
192265
{
193266
return $this->updateFromParent;
@@ -198,8 +271,36 @@ public function onUpdated(): string|array|null
198271
return $this->onUpdated;
199272
}
200273

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

src/LiveComponent/src/LiveComponentHydrator.php

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ public function dehydrate(object $component, ComponentAttributes $attributes, Li
6666

6767
$dehydratedProps = new DehydratedProps();
6868
foreach ($componentMetadata->getAllLivePropsMetadata() as $propMetadata) {
69+
$propMetadata = $propMetadata->withModifier($component);
6970
$propertyName = $propMetadata->getName();
7071
$frontendName = $propMetadata->calculateFieldName($component, $propertyName);
7172

@@ -145,7 +146,20 @@ public function hydrate(object $component, array $props, array $updatedProps, Li
145146
$attributes = new ComponentAttributes($dehydratedOriginalProps->getPropValue(self::ATTRIBUTES_KEY, []));
146147
$dehydratedOriginalProps->removePropValue(self::ATTRIBUTES_KEY);
147148

148-
foreach ($componentMetadata->getAllLivePropsMetadata() as $propMetadata) {
149+
$allMetadata = $componentMetadata->getAllLivePropsMetadata();
150+
uasort($allMetadata, static function (LivePropMetadata $a, LivePropMetadata $b) {
151+
if ($a->hasModifier() && !$b->hasModifier()) {
152+
return 1;
153+
} elseif (!$a->hasModifier() && $b->hasModifier()) {
154+
return -1;
155+
} else {
156+
return 0;
157+
}
158+
});
159+
160+
foreach ($allMetadata as $propMetadata) {
161+
$propMetadata = $propMetadata->withModifier($component);
162+
// foreach ($componentMetadata->getAllLivePropsMetadata($component) as $propMetadata) {
149163
$frontendName = $propMetadata->calculateFieldName($component, $propMetadata->getName());
150164
if (!$dehydratedOriginalProps->hasPropValue($frontendName)) {
151165
// this property was not sent, so skip

src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,7 @@ public function createLivePropMetadata(string $className, string $propertyName,
109109
$infoType,
110110
$isTypeBuiltIn,
111111
$isTypeNullable,
112-
$collectionValueType,
113-
$liveProp->url()
112+
$collectionValueType
114113
);
115114
}
116115

src/LiveComponent/src/Metadata/LivePropMetadata.php

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ public function __construct(
3030
private bool $isBuiltIn,
3131
private bool $allowsNull,
3232
private ?Type $collectionValueType,
33-
private bool $queryStringMapping,
3433
) {
3534
}
3635

@@ -56,7 +55,7 @@ public function allowsNull(): bool
5655

5756
public function queryStringMapping(): bool
5857
{
59-
return $this->queryStringMapping;
58+
return $this->liveProp->url();
6059
}
6160

6261
public function calculateFieldName(object $component, string $fallback): string
@@ -116,4 +115,36 @@ public function onUpdated(): string|array|null
116115
{
117116
return $this->liveProp->onUpdated();
118117
}
118+
119+
public function hasModifier(): bool
120+
{
121+
return null !== $this->liveProp->modifier();
122+
}
123+
124+
/**
125+
* Applies a modifier specified in LiveProp attribute.
126+
*
127+
* If a modifier is specified, a modified clone is returned.
128+
* Otherwise, the metadata is returned as it is.
129+
*/
130+
public function withModifier(object $component): self
131+
{
132+
if (null === ($modifier = $this->liveProp->modifier())) {
133+
return $this;
134+
}
135+
136+
if (!method_exists($component, $modifier)) {
137+
throw new \LogicException(sprintf('Method "%s::%s()" given in LiveProp "modifier" does not exist.', $component::class, $modifier));
138+
}
139+
140+
$modifiedLiveProp = $component->{$modifier}($this->liveProp);
141+
if (!$modifiedLiveProp instanceof LiveProp) {
142+
throw new \LogicException(sprintf('Method "%s::%s()" should return an instance of "%s" (given: "%s").', $component::class, $modifier, LiveProp::class, get_debug_type($modifiedLiveProp)));
143+
}
144+
145+
$clone = clone $this;
146+
$clone->liveProp = $modifiedLiveProp;
147+
148+
return $clone;
149+
}
119150
}

src/LiveComponent/src/Util/LiveControllerAttributesCreator.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ public function attributesForRendering(MountedComponent $mounted, ComponentMetad
108108
if ($liveMetadata->hasQueryStringBindings()) {
109109
$queryMapping = [];
110110
foreach ($liveMetadata->getAllLivePropsMetadata() as $livePropMetadata) {
111+
$livePropMetadata = $livePropMetadata->withModifier($mounted->getComponent());
111112
if ($livePropMetadata->queryStringMapping()) {
112113
$frontendName = $livePropMetadata->calculateFieldName($mounted, $livePropMetadata->getName());
113114
$queryMapping[$frontendName] = ['name' => $frontendName];

src/LiveComponent/src/Util/QueryStringPropsExtractor.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ public function extract(Request $request, LiveComponentMetadata $metadata, objec
4343
$data = [];
4444

4545
foreach ($metadata->getAllLivePropsMetadata() as $livePropMetadata) {
46+
$livePropMetadata = $livePropMetadata->withModifier($component);
4647
if ($livePropMetadata->queryStringMapping()) {
4748
$frontendName = $livePropMetadata->calculateFieldName($component, $livePropMetadata->getName());
4849
if (null !== ($value = $query[$frontendName] ?? null)) {

src/LiveComponent/tests/Fixtures/Component/ComponentWithUrlBoundProps.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,4 +38,15 @@ class ComponentWithUrlBoundProps
3838

3939
#[LiveProp(fieldName: 'field6', url: true)]
4040
public ?string $prop6 = null;
41+
42+
#[LiveProp(modifier: 'modifyProp7')]
43+
public ?string $prop7 = null;
44+
45+
#[LiveProp]
46+
public ?bool $prop7InUrl = false;
47+
48+
public function modifyProp7(LiveProp $prop): LiveProp
49+
{
50+
return $prop->withUrl(true);
51+
}
4152
}

src/LiveComponent/tests/Fixtures/templates/components/component_with_url_bound_props.html.twig

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@
55
Prop4: {{ prop4 }}
66
Prop5: address: {{ prop5.address ?? '' }} city: {{ prop5.city ?? '' }}
77
Prop6: {{ prop6 }}
8-
</div>
8+
Prop7: {{ prop7 }}
9+
</div>

src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,7 @@ public function testQueryStringMappingAttribute()
151151
'prop3' => ['name' => 'prop3'],
152152
'prop5' => ['name' => 'prop5'],
153153
'field6' => ['name' => 'field6'],
154+
'prop7' => ['name' => 'prop7'],
154155
];
155156

156157
$this->assertEquals($expected, $queryMapping);

src/LiveComponent/tests/Functional/EventListener/QueryStringInitializerSubscriberTest.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,15 @@ class QueryStringInitializerSubscriberTest extends KernelTestCase
2121
public function testQueryStringPropsInitialization()
2222
{
2323
$this->browser()
24-
->get('/render-template/render_component_with_url_bound_props?prop1=foo&prop2=42&prop3[]=foo&prop3[]=bar&prop4=unbound&prop5[address]=foo&prop5[city]=bar&field6=foo')
24+
->get('/render-template/render_component_with_url_bound_props?prop1=foo&prop2=42&prop3[]=foo&prop3[]=bar&prop4=unbound&prop5[address]=foo&prop5[city]=bar&field6=foo&prop7=foo')
2525
->assertSuccessful()
2626
->assertContains('Prop1: foo')
2727
->assertContains('Prop2: 42')
2828
->assertContains('Prop3: foo,bar')
2929
->assertContains('Prop4:')
3030
->assertContains('Prop5: address: foo city: bar')
3131
->assertContains('Prop6: foo')
32+
->assertContains('Prop7: foo')
3233
;
3334
}
3435
}

0 commit comments

Comments
 (0)