Skip to content

Commit 5faf43e

Browse files
committed
Add alias option to url query mapping
1 parent 2562a6c commit 5faf43e

15 files changed

+161
-33
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
@@ -2489,11 +2489,6 @@ If you load this URL in your browser, the ``LiveProp`` value will be initialized
24892489

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

2492-
.. warning::
2493-
2494-
You can use multiple components with URL bindings in the same page, as long as bound field names don't collide.
2495-
Otherwise, you will observe unexpected behaviors.
2496-
24972492
Supported Data Types
24982493
~~~~~~~~~~~~~~~~~~~~
24992494

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

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

@@ -2564,8 +2618,8 @@ validated. To validate it, you have to set up a `PostMount hook`_::
25642618
#[PostMount]
25652619
public function postMount(): void
25662620
{
2567-
// Validate 'mode' field without throwing an exception, so the component can be mounted anyway and a
2568-
// validation error can be shown to the user
2621+
// Validate 'mode' field without throwing an exception, so the component can
2622+
// be mounted anyway and a validation error can be shown to the user
25692623
if (!$this->validateField('mode', false)) {
25702624
// Do something when validation fails
25712625
}
@@ -3501,6 +3555,8 @@ the change of one specific key::
35013555
}
35023556
}
35033557

3558+
.. _modifier:
3559+
35043560
Set LiveProp Options Dynamically
35053561
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
35063562

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: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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+
private ?string $as = null,
26+
) {
27+
}
28+
29+
public function getAs(): ?string
30+
{
31+
return $this->as;
32+
}
33+
}

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->getAs() ?? $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->getAs() ?? $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: 4 additions & 0 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')]
@@ -57,4 +58,7 @@ public function modifyProp8(LiveProp $prop): LiveProp
5758
{
5859
return $prop->withUrl($this->prop8InUrl);
5960
}
61+
62+
#[LiveProp(url: new UrlMapping('q'))]
63+
public ?string $prop9 = null;
6064
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,5 @@
77
Prop6: {{ prop6 }}
88
Prop7: {{ prop7 }}
99
Prop8: {{ prop8 }}
10+
Prop9: {{ prop9 }}
1011
</div>

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,7 @@ public function testQueryStringMappingAttribute()
153153
'field6' => ['name' => 'field6'],
154154
'field7' => ['name' => 'field7'],
155155
'prop8' => ['name' => 'prop8'],
156+
'prop9' => ['name' => 'q'],
156157
];
157158

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

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@ class QueryStringInitializerSubscriberTest extends KernelTestCase
2121
public function testQueryStringPropsInitialization()
2222
{
2323
$this->browser()
24-
->throwExceptions()
25-
->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&field7=foo&prop8=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&field7=foo&prop8=foo&q=foo')
2625
->assertSuccessful()
2726
->assertContains('Prop1: foo')
2827
->assertContains('Prop2: 42')
@@ -32,6 +31,7 @@ public function testQueryStringPropsInitialization()
3231
->assertContains('Prop6: foo')
3332
->assertContains('Prop7: foo')
3433
->assertContains('Prop8: foo')
34+
->assertContains('Prop9: foo')
3535
;
3636
}
3737
}

src/LiveComponent/tests/Functional/Metadata/LiveComponentMetadataFactoryTest.php

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

1414
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
1515
use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory;
16+
use Symfony\UX\LiveComponent\Metadata\UrlMapping;
1617
use Symfony\UX\LiveComponent\Tests\Fixtures\Component\ComponentWithUrlBoundProps;
1718

1819
class LiveComponentMetadataFactoryTest extends KernelTestCase
@@ -30,20 +31,22 @@ public function testQueryStringMapping()
3031
$propsMetadataByName[$propMetadata->getName()] = $propMetadata;
3132
}
3233

33-
$this->assertTrue($propsMetadataByName['prop1']->queryStringMapping());
34+
$this->assertNotNull($propsMetadataByName['prop1']->urlMapping());
3435

35-
$this->assertTrue($propsMetadataByName['prop2']->queryStringMapping());
36+
$this->assertNotNull($propsMetadataByName['prop2']->urlMapping());
3637

37-
$this->assertTrue($propsMetadataByName['prop3']->queryStringMapping());
38+
$this->assertNotNull($propsMetadataByName['prop3']->urlMapping());
3839

39-
$this->assertFalse($propsMetadataByName['prop4']->queryStringMapping());
40+
$this->assertNull($propsMetadataByName['prop4']->urlMapping());
4041

41-
$this->assertTrue($propsMetadataByName['prop5']->queryStringMapping());
42+
$this->assertNotNull($propsMetadataByName['prop5']->urlMapping());
4243

43-
$this->assertTrue($propsMetadataByName['prop6']->queryStringMapping());
44+
$this->assertNotNull($propsMetadataByName['prop6']->urlMapping());
4445

45-
$this->assertTrue($propsMetadataByName['prop7']->queryStringMapping());
46+
$this->assertNotNull($propsMetadataByName['prop7']->urlMapping());
4647

47-
$this->assertFalse($propsMetadataByName['prop8']->queryStringMapping());
48+
$this->assertNull($propsMetadataByName['prop8']->urlMapping());
49+
50+
$this->assertEquals(new UrlMapping(as: 'q'), $propsMetadataByName['prop9']->urlMapping());
4851
}
4952
}

src/LiveComponent/tests/Functional/Util/QueryStringPropsExtractorTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ public function getQueryStringTests(): iterable
6464
'invalid scalar value' => ['prop1[]=foo&prop1[]=bar', []],
6565
'invalid array value' => ['prop3=foo', []],
6666
'invalid object value' => ['prop5=foo', []],
67+
'aliased prop' => ['q=foo', ['prop9' => 'foo']],
6768
];
6869
}
6970
}

0 commit comments

Comments
 (0)