Skip to content

Commit d4df614

Browse files
committed
feature #1396 [LiveComponent] Alias URL bound props (squrious)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [LiveComponent] Alias URL bound props | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Issues | N/A | License | MIT Following #1230. Allow custom parameter names for URL bound props, and mapping specification from Twig templates. ## Usage From PHP definition: ```php #[AsLiveComponent()] final class MyComponent { // ... #[LiveProp(writable: true, url: new QueryMapping(alias: 'q')) public ?string $search = null; } ``` From templates: ```twig {{ component('MyComponent', { 'data-live-url-mapping-search': { 'alias': 'q' } }) }} {{ component('MyComponent', { 'data-live-url-mapping-search-alias': 'q' }) }} ``` HTML syntax also works: ```twig <twig:MyComponent :data-live-url-mapping-search="{ alias: 'q'}" /> <twig:MyComponent data-live-url-mapping-search-alias="q" /> ``` ## Result Changing the value of `search` will update the url to `https://my.domain?q=my+search+string`. Mappings provided in Twig templates are merged into those provided in PHP. Thus, query mappings in PHP act as defaults, and we can override them in templates (e.g. for specific page requirements). So a page with: ```twig <twig:MyComponent/> <twig:MyComponent data-live-url-mapping-search-alias="q" /> ``` will update its URL to `http://my.domain?search=foo&q=bar`. Commits ------- 828e34e [LiveComponent] Alias URL bound props
2 parents 09797ee + 828e34e commit d4df614

16 files changed

+240
-91
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
page is rendered, either when the page loads (`loading="defer"`) or when
99
the component becomes visible in the viewport (`loading="lazy"`).
1010
- Deprecate the `defer` attribute.
11+
- Add `UrlMapping` configuration object for URL bindings in LiveComponents
1112

1213
## 2.16.0
1314

src/LiveComponent/assets/test/controller/query-binding.test.ts

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,6 @@ describe('LiveController query string binding', () => {
144144
expectCurrentSearch().toEqual('?prop=');
145145
});
146146

147-
148147
it('updates the URL with props changed by the server', async () => {
149148
const test = await createTest({ prop: ''}, (data: any) => `
150149
<div ${initComponent(data, {queryMapping: {prop: {name: 'prop'}}})}>
@@ -165,4 +164,26 @@ describe('LiveController query string binding', () => {
165164

166165
expectCurrentSearch().toEqual('?prop=foo');
167166
});
167+
168+
it('uses custom name instead of prop name in the URL', async () => {
169+
const test = await createTest({ prop1: ''}, (data: any) => `
170+
<div ${initComponent(data, { queryMapping: {prop1: {name: 'alias1'} }})}></div>
171+
`)
172+
173+
// Set value
174+
test.expectsAjaxCall()
175+
.expectUpdatedData({prop1: 'foo'});
176+
177+
await test.component.set('prop1', 'foo', true);
178+
179+
expectCurrentSearch().toEqual('?alias1=foo');
180+
181+
// Remove value
182+
test.expectsAjaxCall()
183+
.expectUpdatedData({prop1: ''});
184+
185+
await test.component.set('prop1', '', true);
186+
187+
expectCurrentSearch().toEqual('?alias1=');
188+
});
168189
})

src/LiveComponent/doc/index.rst

Lines changed: 63 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2495,11 +2495,6 @@ If you load this URL in your browser, the ``LiveProp`` value will be initialized
24952495

24962496
The URL is changed via ``history.replaceState()``. So no new entry is added.
24972497

2498-
.. warning::
2499-
2500-
You can use multiple components with URL bindings in the same page, as long as bound field names don't collide.
2501-
Otherwise, you will observe unexpected behaviors.
2502-
25032498
Supported Data Types
25042499
~~~~~~~~~~~~~~~~~~~~
25052500

@@ -2543,6 +2538,65 @@ For example, if you declare the following bindings::
25432538
And you only set the ``query`` value, then your URL will be updated to
25442539
``https://my.domain/search?query=my+query+string&mode=fulltext``.
25452540

2541+
Controlling the Query Parameter Name
2542+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2543+
2544+
.. versionadded:: 2.17
2545+
2546+
The ``as`` option was added in LiveComponents 2.17.
2547+
2548+
2549+
Instead of using the prop's field name as the query parameter name, you can use the ``as`` option in your ``LiveProp``
2550+
definition::
2551+
2552+
// ...
2553+
use Symfony\UX\LiveComponent\Metadata\UrlMapping;
2554+
2555+
#[AsLiveComponent]
2556+
class SearchModule
2557+
{
2558+
#[LiveProp(writable: true, url: new UrlMapping(as: 'q')]
2559+
public string $query = '';
2560+
2561+
// ...
2562+
}
2563+
2564+
Then the ``query`` value will appear in the URL like ``https://my.domain/search?q=my+query+string``.
2565+
2566+
If you need to change the parameter name on a specific page, you can leverage the :ref:`modifier <modifier>` option::
2567+
2568+
// ...
2569+
use Symfony\UX\LiveComponent\Metadata\UrlMapping;
2570+
2571+
#[AsLiveComponent]
2572+
class SearchModule
2573+
{
2574+
#[LiveProp(writable: true, url: true, modifier: 'modifyQueryProp')]
2575+
public string $query = '';
2576+
2577+
#[LiveProp]
2578+
public ?string $alias = null;
2579+
2580+
public function modifyQueryProp(LiveProp $liveProp): LiveProp
2581+
{
2582+
if ($this->alias) {
2583+
$liveProp = $liveProp->withUrl(new UrlMapping(as: $this->alias));
2584+
}
2585+
return $liveProp;
2586+
}
2587+
}
2588+
2589+
.. code-block:: html+twig
2590+
2591+
<twig:SearchModule alias="q" />
2592+
2593+
This way you can also use the component multiple times in the same page and avoid collisions in parameter names:
2594+
2595+
.. code-block:: html+twig
2596+
2597+
<twig:SearchModule alias="q1" />
2598+
<twig:SearchModule alias="q2" />
2599+
25462600
Validating the Query Parameter Values
25472601
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
25482602

@@ -2570,8 +2624,8 @@ validated. To validate it, you have to set up a `PostMount hook`_::
25702624
#[PostMount]
25712625
public function postMount(): void
25722626
{
2573-
// Validate 'mode' field without throwing an exception, so the component can be mounted anyway and a
2574-
// validation error can be shown to the user
2627+
// Validate 'mode' field without throwing an exception, so the component can
2628+
// be mounted anyway and a validation error can be shown to the user
25752629
if (!$this->validateField('mode', false)) {
25762630
// Do something when validation fails
25772631
}
@@ -3508,6 +3562,8 @@ the change of one specific key::
35083562
}
35093563
}
35103564

3565+
.. _modifier:
3566+
35113567
Set LiveProp Options Dynamically
35123568
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
35133569

src/LiveComponent/src/Attribute/LiveProp.php

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@
1111

1212
namespace Symfony\UX\LiveComponent\Attribute;
1313

14+
use Symfony\UX\LiveComponent\Metadata\UrlMapping;
15+
1416
/**
1517
* An attribute to mark a property as a "LiveProp".
1618
*
@@ -97,10 +99,11 @@ public function __construct(
9799
private string|array|null $onUpdated = null,
98100

99101
/**
100-
* If true, this property will be synchronized with a query parameter
101-
* in the URL.
102+
* Whether to synchronize this property with a query parameter
103+
* in the URL. Pass true to configure the mapping automatically, or a
104+
* {@see UrlMapping} instance to configure the mapping.
102105
*/
103-
private bool $url = false,
106+
private bool|UrlMapping $url = false,
104107

105108
/**
106109
* A hook that will be called when this LiveProp is used.
@@ -114,6 +117,10 @@ public function __construct(
114117
private ?string $modifier = null,
115118
) {
116119
self::validateHydrationStrategy($this);
120+
121+
if (true === $url) {
122+
$this->url = new UrlMapping();
123+
}
117124
}
118125

119126
/**
@@ -277,15 +284,15 @@ public function withOnUpdated(string|array|null $onUpdated): self
277284
return $clone;
278285
}
279286

280-
public function url(): bool
287+
public function url(): UrlMapping|false
281288
{
282289
return $this->url;
283290
}
284291

285-
public function withUrl(bool $url): self
292+
public function withUrl(bool|UrlMapping $url): self
286293
{
287294
$clone = clone $this;
288-
$clone->url = $url;
295+
$clone->url = (true === $url) ? new UrlMapping() : $url;
289296

290297
return $clone;
291298
}

src/LiveComponent/src/Metadata/LiveComponentMetadata.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ public function getOnlyPropsThatAcceptUpdatesFromParent(array $inputProps): arra
6969
public function hasQueryStringBindings($component): bool
7070
{
7171
foreach ($this->getAllLivePropsMetadata($component) as $livePropMetadata) {
72-
if ($livePropMetadata->queryStringMapping()) {
72+
if ($livePropMetadata->urlMapping()) {
7373
return true;
7474
}
7575
}

src/LiveComponent/src/Metadata/LivePropMetadata.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,9 @@ public function allowsNull(): bool
5151
return $this->allowsNull;
5252
}
5353

54-
public function queryStringMapping(): bool
54+
public function urlMapping(): ?UrlMapping
5555
{
56-
return $this->liveProp->url();
56+
return $this->liveProp->url() ?: null;
5757
}
5858

5959
public function calculateFieldName(object $component, string $fallback): string
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <[email protected]>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\UX\LiveComponent\Metadata;
13+
14+
/**
15+
* Mapping configuration to bind a LiveProp to a URL query parameter.
16+
*
17+
* @author Nicolas Rigaud <[email protected]>
18+
*/
19+
final class UrlMapping
20+
{
21+
public function __construct(
22+
/**
23+
* The name of the prop that appears in the URL. If null, the LiveProp's field name is used.
24+
*/
25+
public readonly ?string $as = null,
26+
) {
27+
}
28+
}

src/LiveComponent/src/Util/LiveControllerAttributesCreator.php

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,14 +104,14 @@ public function attributesForRendering(MountedComponent $mounted, ComponentMetad
104104
}
105105

106106
if ($liveMetadata->hasQueryStringBindings($mounted->getComponent())) {
107-
$queryMapping = [];
107+
$mappings = [];
108108
foreach ($liveMetadata->getAllLivePropsMetadata($mounted->getComponent()) as $livePropMetadata) {
109-
if ($livePropMetadata->queryStringMapping()) {
109+
if ($urlMapping = $livePropMetadata->urlMapping()) {
110110
$frontendName = $livePropMetadata->calculateFieldName($mounted->getComponent(), $livePropMetadata->getName());
111-
$queryMapping[$frontendName] = ['name' => $frontendName];
111+
$mappings[$frontendName] = ['name' => $urlMapping->as ?? $frontendName];
112112
}
113113
}
114-
$attributesCollection->setQueryUrlMapping($queryMapping);
114+
$attributesCollection->setQueryUrlMapping($mappings);
115115
}
116116

117117
if ($isChildComponent) {

src/LiveComponent/src/Util/QueryStringPropsExtractor.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,9 +41,9 @@ public function extract(Request $request, LiveComponentMetadata $metadata, objec
4141
$data = [];
4242

4343
foreach ($metadata->getAllLivePropsMetadata($component) as $livePropMetadata) {
44-
if ($livePropMetadata->queryStringMapping()) {
44+
if ($queryMapping = $livePropMetadata->urlMapping()) {
4545
$frontendName = $livePropMetadata->calculateFieldName($component, $livePropMetadata->getName());
46-
if (null !== ($value = $query[$frontendName] ?? null)) {
46+
if (null !== ($value = $query[$queryMapping->as ?? $frontendName] ?? null)) {
4747
if ('' === $value && null !== $livePropMetadata->getType() && (!$livePropMetadata->isBuiltIn() || 'array' === $livePropMetadata->getType())) {
4848
// Cast empty string to empty array for objects and arrays
4949
$value = [];

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

Lines changed: 37 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
1515
use Symfony\UX\LiveComponent\Attribute\LiveProp;
1616
use Symfony\UX\LiveComponent\DefaultActionTrait;
17+
use Symfony\UX\LiveComponent\Metadata\UrlMapping;
1718
use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\Address;
1819

1920
#[AsLiveComponent('component_with_url_bound_props')]
@@ -22,39 +23,59 @@ class ComponentWithUrlBoundProps
2223
use DefaultActionTrait;
2324

2425
#[LiveProp(url: true)]
25-
public ?string $prop1 = null;
26+
public ?string $stringProp = null;
2627

2728
#[LiveProp(url: true)]
28-
public ?int $prop2 = null;
29+
public ?int $intProp = null;
2930

3031
#[LiveProp(url: true)]
31-
public array $prop3 = [];
32+
public array $arrayProp = [];
3233

3334
#[LiveProp]
34-
public ?string $prop4 = null;
35+
public ?string $unboundProp = null;
3536

3637
#[LiveProp(url: true)]
37-
public ?Address $prop5 = null;
38+
public ?Address $objectProp = null;
3839

39-
#[LiveProp(fieldName: 'field6', url: true)]
40-
public ?string $prop6 = null;
40+
#[LiveProp(fieldName: 'field1', url: true)]
41+
public ?string $propWithField1 = null;
4142

42-
#[LiveProp(fieldName: 'getProp7Name()', url: true)]
43-
public ?string $prop7 = null;
43+
#[LiveProp(fieldName: 'getField2()', url: true)]
44+
public ?string $propWithField2 = null;
4445

45-
#[LiveProp(modifier: 'modifyProp8')]
46-
public ?string $prop8 = null;
46+
#[LiveProp(modifier: 'modifyMaybeBoundProp')]
47+
public ?string $maybeBoundProp = null;
4748

4849
#[LiveProp]
49-
public ?bool $prop8InUrl = false;
50+
public ?bool $maybeBoundPropInUrl = false;
5051

51-
public function getProp7Name(): string
52+
public function getField2(): string
5253
{
53-
return 'field7';
54+
return 'field2';
5455
}
5556

56-
public function modifyProp8(LiveProp $prop): LiveProp
57+
public function modifyMaybeBoundProp(LiveProp $prop): LiveProp
5758
{
58-
return $prop->withUrl($this->prop8InUrl);
59+
return $prop->withUrl($this->maybeBoundPropInUrl);
5960
}
61+
62+
#[LiveProp(url: new UrlMapping(as: 'q'))]
63+
public ?string $boundPropWithAlias = null;
64+
65+
#[LiveProp(url: true, modifier: 'modifyBoundPropWithCustomAlias')]
66+
public ?string $boundPropWithCustomAlias = null;
67+
68+
#[LiveProp]
69+
public ?string $customAlias = null;
70+
71+
public function modifyBoundPropWithCustomAlias(LiveProp $liveProp): LiveProp
72+
{
73+
if ($this->customAlias) {
74+
$liveProp = $liveProp->withUrl(new UrlMapping(as: $this->customAlias));
75+
}
76+
77+
return $liveProp;
78+
}
79+
80+
6081
}
Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
<div {{ attributes }}>
2-
Prop1: {{ prop1 }}
3-
Prop2: {{ prop2 }}
4-
Prop3: {{ prop3|join(',') }}
5-
Prop4: {{ prop4 }}
6-
Prop5: address: {{ prop5.address ?? '' }} city: {{ prop5.city ?? '' }}
7-
Prop6: {{ prop6 }}
8-
Prop7: {{ prop7 }}
9-
Prop8: {{ prop8 }}
2+
StringProp: {{ stringProp }}
3+
IntProp: {{ intProp }}
4+
ArrayProp: {{ arrayProp|join(',') }}
5+
UnboundProp: {{ unboundProp }}
6+
ObjectProp: address: {{ objectProp.address ?? '' }} city: {{ objectProp.city ?? '' }}
7+
PropWithField1: {{ propWithField1 }}
8+
PropWithField2: {{ propWithField2 }}
9+
MaybeBoundProp: {{ maybeBoundProp }}
10+
BoundPropWithAlias: {{ boundPropWithAlias }}
11+
BoundPropWithCustomAlias: {{ boundPropWithCustomAlias }}
1012
</div>
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
{{ component('component_with_url_bound_props', {
2-
prop8InUrl: true
2+
maybeBoundPropInUrl: true,
3+
customAlias: 'customAlias',
34
}) }}

0 commit comments

Comments
 (0)