Skip to content

Commit cd5f46d

Browse files
authored
Merge branch 'symfony:2.x' into safe-classes-configurator
2 parents 6a7f50f + 8e3cabe commit cd5f46d

File tree

55 files changed

+775
-299
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

55 files changed

+775
-299
lines changed

src/LiveComponent/CHANGELOG.md

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

3+
## 2.17.0
4+
5+
- Add `modifier` option in `LiveProp` so options can be modified at runtime.
6+
- Fix collections hydration with serializer in LiveComponents
7+
38
## 2.16.0
49

510
- LiveComponents is now stable and no longer experimental 🥳

src/LiveComponent/doc/index.rst

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -370,6 +370,29 @@ it won't, yet, make an Ajax call to re-render the component. Whenever
370370
the next re-render *does* happen, the updated ``max`` value will be
371371
used.
372372

373+
This can be useful along with a button that triggers a render on click:
374+
375+
.. code-block:: html+twig
376+
377+
<input data-model="norender|coupon">
378+
<button data-action="live#$render">Apply</button>
379+
380+
Forcing a Re-Render Explicitly
381+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
382+
383+
In some cases, you might want to force a component re-render explicitly. For
384+
example, consider a checkout component that provides a coupon input that must
385+
only be used when clicking on the associated "Apply coupon" button:
386+
387+
.. code-block:: html+twig
388+
389+
<input data-model="norender|coupon">
390+
<button data-action="live#$render">Apply coupon</button>
391+
392+
The ``norender`` option on the input ensures that the component won't re-render
393+
when this input changes. The ``live#$render`` action is a special built-in action
394+
that triggers a re-render.
395+
373396
.. _name-attribute-model:
374397

375398
Using name="" instead of data-model
@@ -3427,6 +3450,53 @@ the change of one specific key::
34273450
}
34283451
}
34293452

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+
34303500
Debugging Components
34313501
--------------------
34323502

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 <squrious@protonmail.com>
@@ -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: 16 additions & 4 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,
@@ -255,11 +256,22 @@ public function hydrateValue(mixed $value, LivePropMetadata $propMetadata, objec
255256
throw new \LogicException(sprintf('The LiveProp "%s" on component "%s" has "useSerializerForHydration: true", but the given serializer does not implement DenormalizerInterface.', $propMetadata->getName(), $parentObject::class));
256257
}
257258

258-
if (null === $propMetadata->getType()) {
259+
if ($propMetadata->collectionValueType()) {
260+
$builtInType = $propMetadata->collectionValueType()->getBuiltinType();
261+
if (Type::BUILTIN_TYPE_OBJECT === $builtInType) {
262+
$type = $propMetadata->collectionValueType()->getClassName().'[]';
263+
} else {
264+
$type = $builtInType.'[]';
265+
}
266+
} else {
267+
$type = $propMetadata->getType();
268+
}
269+
270+
if (null === $type) {
259271
throw new \LogicException(sprintf('The "%s::%s" object should be hydrated with the Serializer, but no type could be guessed.', $parentObject::class, $propMetadata->getName()));
260272
}
261273

262-
return $this->serializer->denormalize($value, $propMetadata->getType(), 'json', $propMetadata->serializationContext());
274+
return $this->serializer->denormalize($value, $type, 'json', $propMetadata->serializationContext());
263275
}
264276

265277
if ($propMetadata->collectionValueType() && Type::BUILTIN_TYPE_OBJECT === $propMetadata->collectionValueType()->getBuiltinType()) {

0 commit comments

Comments
 (0)