Skip to content

Commit b9dd9b7

Browse files
committed
feature #1114 [LiveComponents] OnUpdate hook for LiveProp (bocharsky-bw)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [LiveComponents] OnUpdate hook for LiveProp | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes <!-- please update src/**/CHANGELOG.md files --> | Tickets | | License | MIT <!-- Replace this notice by a short README for your feature/bugfix. This will help people understand your PR and can be used as a start for the documentation. Additionally (see https://symfony.com/releases): - Always add tests and ensure they pass. - Never break backward compatibility (see https://symfony.com/bc). - Features and deprecations must be submitted against branch main. --> Feature allows to add a hook on LiveProp changed, providing the "previous value" for convenience: ```php #[LiveProp(writable: true, onUpdated: 'onTitleUpdated')] public string $title = 'Test'; #[LiveProp(writable: ['customerName', 'customerEmail', 'taxRate'], onUpdated: ['customerName' => 'onCustomerNameUpdated'])] public Invoice $invoice; #[LiveProp(writable: LiveProp::IDENTITY, onUpdated: [LiveProp::IDENTITY => 'onEntireInvoiceChange'])] public Invoice $invoice; public function onTitleUpdated($oldValue) { // ... } public function onCustomerNameUpdated($oldValue) { // ... } public function onEntireInvoiceChange($oldValue) { // ... } ``` Commits ------- 96bdeb7 [LiveComponents] OnUpdate hook for LiveProp
2 parents eb746b6 + 96bdeb7 commit b9dd9b7

File tree

6 files changed

+219
-1
lines changed

6 files changed

+219
-1
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.12.0
4+
5+
- Add `onUpdated` hook for `LiveProp`
6+
37
## 2.11.0
48

59
- Add helper for testing live components.

src/LiveComponent/doc/index.rst

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3063,6 +3063,62 @@ Then specify this new route on your component:
30633063
use DefaultActionTrait;
30643064
}
30653065
3066+
Add a Hook on LiveProp Update
3067+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
3068+
3069+
.. versionadded:: 2.12
3070+
3071+
The ``onUpdated`` option was added in LiveComponents 2.12.
3072+
3073+
If you want to run custom code after a specific LiveProp is updated,
3074+
you can do it by adding an ``onUpdated`` option set to a public method name
3075+
on the component:
3076+
3077+
.. code-block:: diff
3078+
3079+
// ...
3080+
3081+
#[AsLiveComponent]
3082+
class ProductSearch
3083+
{
3084+
- #[LiveProp(writable: true)]
3085+
+ #[LiveProp(writable: true, onUpdated: 'onQueryUpdated')]
3086+
public string $query = '';
3087+
3088+
// ...
3089+
3090+
public function onQueryUpdated($previousValue): void
3091+
{
3092+
// $this->query already contains a new value
3093+
// and its previous value is passed as an argument
3094+
}
3095+
}
3096+
}
3097+
3098+
As soon as the `query` LiveProp is updated, the ``onQueryUpdated()`` method
3099+
will be called. The previous value is passed there as the first argument.
3100+
3101+
If you're allowing object properties to be writable, you can also listen to
3102+
the change of one specific key:
3103+
3104+
.. code-block::
3105+
3106+
// ...
3107+
3108+
#[AsLiveComponent]
3109+
class EditPost
3110+
{
3111+
#[LiveProp(writable: ['title', 'content'], onUpdated: ['title' => 'onTitleUpdated'])]
3112+
public Post $post;
3113+
3114+
// ...
3115+
3116+
public function onTitleUpdated($previousValue): void
3117+
{
3118+
// ...
3119+
}
3120+
}
3121+
30663122
Test Helper
30673123
-----------
30683124

src/LiveComponent/src/Attribute/LiveProp.php

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,15 @@ final class LiveProp
4949

5050
private bool $acceptUpdatesFromParent;
5151

52+
/**
53+
* @var string|string[]|null
54+
*
55+
* A hook that will be called after the property is updated.
56+
* Set it to a method name on the Live Component that should be called.
57+
* The old value of the property will be passed as an argument to it.
58+
*/
59+
private null|string|array $onUpdated;
60+
5261
/**
5362
* @param bool|array $writable If true, this property can be changed by the frontend.
5463
* Or set to an array of paths within this object/array
@@ -73,7 +82,8 @@ public function __construct(
7382
array $serializationContext = [],
7483
string $fieldName = null,
7584
string $format = null,
76-
bool $updateFromParent = false
85+
bool $updateFromParent = false,
86+
string|array $onUpdated = null,
7787
) {
7888
$this->writable = $writable;
7989
$this->hydrateWith = $hydrateWith;
@@ -83,6 +93,7 @@ public function __construct(
8393
$this->fieldName = $fieldName;
8494
$this->format = $format;
8595
$this->acceptUpdatesFromParent = $updateFromParent;
96+
$this->onUpdated = $onUpdated;
8697

8798
if ($this->useSerializerForHydration && ($this->hydrateWith || $this->dehydrateWith)) {
8899
throw new \InvalidArgumentException('Cannot use useSerializerForHydration with hydrateWith or dehydrateWith.');
@@ -172,4 +183,9 @@ public function acceptUpdatesFromParent(): bool
172183
{
173184
return $this->acceptUpdatesFromParent;
174185
}
186+
187+
public function onUpdated(): null|string|array
188+
{
189+
return $this->onUpdated;
190+
}
175191
}

src/LiveComponent/src/LiveComponentHydrator.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use Symfony\Component\Serializer\Normalizer\DenormalizerInterface;
2121
use Symfony\Component\Serializer\Normalizer\NormalizerInterface;
2222
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
23+
use Symfony\UX\LiveComponent\Attribute\LiveProp;
2324
use Symfony\UX\LiveComponent\Exception\HydrationException;
2425
use Symfony\UX\LiveComponent\Hydration\HydrationExtensionInterface;
2526
use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadata;
@@ -208,6 +209,10 @@ public function hydrate(object $component, array $props, array $updatedProps, Li
208209
// unexpected and can't be set - e.g. a string field for an `int` property.
209210
// We ignore this, and allow the original value to remain set.
210211
}
212+
213+
if ($propMetadata->onUpdated()) {
214+
$this->processOnUpdatedHook($component, $frontendName, $propMetadata, $dehydratedUpdatedProps, $dehydratedOriginalProps);
215+
}
211216
}
212217

213218
foreach (AsLiveComponent::postHydrateMethods($component) as $method) {
@@ -559,4 +564,52 @@ private function recursiveKeySort(array &$data): void
559564
}
560565
ksort($data);
561566
}
567+
568+
private function ensureOnUpdatedMethodExists(object $component, string $methodName): void
569+
{
570+
if (method_exists($component, $methodName)) {
571+
return;
572+
}
573+
574+
throw new \Exception(sprintf('Method "%s:%s()" specified as LiveProp "onUpdated" hook does not exist.', $component::class, $methodName));
575+
}
576+
577+
/**
578+
* A special hook that will be called if the LiveProp was changed
579+
* and $onUpdated argument is set on its attribute.
580+
*/
581+
private function processOnUpdatedHook(object $component, string $frontendName, LivePropMetadata $propMetadata, DehydratedProps $dehydratedUpdatedProps, DehydratedProps $dehydratedOriginalProps): void
582+
{
583+
$onUpdated = $propMetadata->onUpdated();
584+
if (\is_string($onUpdated)) {
585+
$onUpdated = [LiveProp::IDENTITY => $onUpdated];
586+
}
587+
588+
foreach ($onUpdated as $propName => $funcName) {
589+
if (LiveProp::IDENTITY === $propName) {
590+
if (!$dehydratedUpdatedProps->hasPropValue($frontendName)) {
591+
continue;
592+
}
593+
594+
$this->ensureOnUpdatedMethodExists($component, $funcName);
595+
$propertyOldValue = $this->hydrateValue(
596+
$dehydratedOriginalProps->getPropValue($frontendName),
597+
$propMetadata,
598+
$component,
599+
);
600+
$component->{$funcName}($propertyOldValue);
601+
602+
continue;
603+
}
604+
605+
$key = sprintf('%s.%s', $frontendName, $propName);
606+
if (!$dehydratedUpdatedProps->hasPropValue($key)) {
607+
continue;
608+
}
609+
610+
$this->ensureOnUpdatedMethodExists($component, $funcName);
611+
$propertyOldValue = $dehydratedOriginalProps->getPropValue($key);
612+
$component->{$funcName}($propertyOldValue);
613+
}
614+
}
562615
}

src/LiveComponent/src/Metadata/LivePropMetadata.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,4 +105,9 @@ public function getFormat(): ?string
105105
{
106106
return $this->liveProp->format();
107107
}
108+
109+
public function onUpdated(): null|string|array
110+
{
111+
return $this->liveProp->onUpdated();
112+
}
108113
}

src/LiveComponent/tests/Integration/LiveComponentHydratorTest.php

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,90 @@ public function testCanDehydrateAndHydrateComponentWithTestCases(callable $testF
144144

145145
public function provideDehydrationHydrationTests(): iterable
146146
{
147+
yield 'onUpdated: exception if method not exists' => [function () {
148+
return HydrationTest::create(new class() {
149+
#[LiveProp(writable: true, onUpdated: 'onFirstNameUpdated')]
150+
public string $firstName;
151+
})
152+
->mountWith(['firstName' => 'Ryan'])
153+
->userUpdatesProps(['firstName' => 'Victor'])
154+
->expectsExceptionDuringHydration(\Exception::class, '/onFirstNameUpdated\(\)" specified as LiveProp "onUpdated" hook does not exist/');
155+
}];
156+
157+
yield 'onUpdated: with scalar value' => [function () {
158+
return HydrationTest::create(new class() {
159+
#[LiveProp(writable: true, onUpdated: 'onFirstNameUpdated')]
160+
public string $firstName;
161+
162+
public function onFirstNameUpdated($oldValue)
163+
{
164+
if ('Victor' === $this->firstName) {
165+
$this->firstName = 'Revert to '.$oldValue;
166+
}
167+
}
168+
})
169+
->mountWith(['firstName' => 'Ryan'])
170+
->userUpdatesProps(['firstName' => 'Victor'])
171+
->assertObjectAfterHydration(function (object $object) {
172+
$this->assertSame('Revert to Ryan', $object->firstName);
173+
});
174+
}];
175+
176+
yield 'onUpdated: set to an array' => [function () {
177+
$product = create(ProductFixtureEntity::class, [
178+
'name' => 'Chicken',
179+
])->object();
180+
181+
return HydrationTest::create(new class() {
182+
#[LiveProp(writable: ['name'], onUpdated: ['name' => 'onNameUpdated'])]
183+
public ProductFixtureEntity $product;
184+
185+
public function onNameUpdated($oldValue)
186+
{
187+
if ('Rabbit' === $this->product->name) {
188+
$this->product->name = 'Revert to '.$oldValue;
189+
}
190+
}
191+
})
192+
->mountWith(['product' => $product])
193+
->userUpdatesProps(['product.name' => 'Rabbit'])
194+
->assertObjectAfterHydration(function (object $object) {
195+
$this->assertSame('Revert to Chicken', $object->product->name);
196+
});
197+
}];
198+
199+
yield 'onUpdated: with IDENTITY' => [function () {
200+
$entityOriginal = create(Entity1::class)->object();
201+
$entityNext = create(Entity1::class)->object();
202+
\assert($entityOriginal instanceof Entity1);
203+
\assert($entityNext instanceof Entity1);
204+
205+
return HydrationTest::create(new class() {
206+
#[LiveProp(writable: [LiveProp::IDENTITY], onUpdated: [LiveProp::IDENTITY => 'onEntireEntityUpdated'])]
207+
public Entity1 $entity1;
208+
209+
public function onEntireEntityUpdated($oldValue)
210+
{
211+
// Sanity check
212+
if ($this->entity1 === $oldValue) {
213+
throw new \Exception('Old value is the same entity?!');
214+
}
215+
if (2 === $this->entity1->id) {
216+
// Revert the value
217+
$this->entity1 = $oldValue;
218+
}
219+
}
220+
})
221+
->mountWith(['entity1' => $entityOriginal])
222+
->userUpdatesProps(['entity1' => $entityNext->id])
223+
->assertObjectAfterHydration(function (object $object) use ($entityOriginal) {
224+
$this->assertSame(
225+
$entityOriginal->id,
226+
$object->entity1->id
227+
);
228+
});
229+
}];
230+
147231
yield 'string: (de)hydrates correctly' => [function () {
148232
return HydrationTest::create(new class() {
149233
#[LiveProp()]

0 commit comments

Comments
 (0)