Skip to content

Commit 4532537

Browse files
committed
Add alias option to url query mapping
1 parent 8e3cabe commit 4532537

15 files changed

+163
-35
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- Add `modifier` option in `LiveProp` so options can be modified at runtime.
66
- Fix collections hydration with serializer in LiveComponents
7+
- Add `UrlMapping` configuration object for URL bindings in LiveComponents
78

89
## 2.16.0
910

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

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

2441-
.. warning::
2442-
2443-
You can use multiple components with URL bindings in the same page, as long as bound field names don't collide.
2444-
Otherwise, you will observe unexpected behaviors.
2445-
24462441
Supported Data Types
24472442
~~~~~~~~~~~~~~~~~~~~
24482443

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

2484+
Controlling the Query Parameter Name
2485+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
2486+
2487+
.. versionadded:: 2.17
2488+
2489+
The ``as`` option was added in LiveComponents 2.17.
2490+
2491+
2492+
Instead of using the prop's field name as the query parameter name, you can use the ``as`` option in your ``LiveProp``
2493+
definition::
2494+
2495+
// ...
2496+
use Symfony\UX\LiveComponent\Metadata\UrlMapping;
2497+
2498+
#[AsLiveComponent]
2499+
class SearchModule
2500+
{
2501+
#[LiveProp(writable: true, url: new UrlMapping(as: 'q')]
2502+
public string $query = '';
2503+
2504+
// ...
2505+
}
2506+
2507+
Then the ``query`` value will appear in the URL like ``https://my.domain/search?q=my+query+string``.
2508+
2509+
If you need to change the parameter name on a specific page, you can leverage the :ref:`modifier <modifier>` option::
2510+
2511+
// ...
2512+
use Symfony\UX\LiveComponent\Metadata\UrlMapping;
2513+
2514+
#[AsLiveComponent]
2515+
class SearchModule
2516+
{
2517+
#[LiveProp(writable: true, url: true, modifier: 'modifyQueryProp')]
2518+
public string $query = '';
2519+
2520+
#[LiveProp]
2521+
public ?string $alias = null;
2522+
2523+
public function modifyQueryProp(LiveProp $liveProp): LiveProp
2524+
{
2525+
if ($this->alias) {
2526+
$liveProp = $liveProp->withUrl(new UrlMapping(as: $this->alias));
2527+
}
2528+
return $liveProp;
2529+
}
2530+
}
2531+
2532+
.. code-block:: html+twig
2533+
2534+
<twig:SearchModule alias="q" />
2535+
2536+
This way you can also use the component multiple times in the same page and avoid collisions in parameter names:
2537+
2538+
.. code-block:: html+twig
2539+
2540+
<twig:SearchModule alias="q1" />
2541+
<twig:SearchModule alias="q2" />
2542+
24892543
Validating the Query Parameter Values
24902544
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
24912545

@@ -2513,8 +2567,8 @@ validated. To validate it, you have to set up a `PostMount hook`_::
25132567
#[PostMount]
25142568
public function postMount(): void
25152569
{
2516-
// Validate 'mode' field without throwing an exception, so the component can be mounted anyway and a
2517-
// validation error can be shown to the user
2570+
// Validate 'mode' field without throwing an exception, so the component can
2571+
// be mounted anyway and a validation error can be shown to the user
25182572
if (!$this->validateField('mode', false)) {
25192573
// Do something when validation fails
25202574
}
@@ -3450,6 +3504,8 @@ the change of one specific key::
34503504
}
34513505
}
34523506

3507+
.. _modifier:
3508+
34533509
Set LiveProp Options Dynamically
34543510
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
34553511

src/LiveComponent/src/Attribute/LiveProp.php

Lines changed: 15 additions & 8 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.
@@ -111,9 +114,13 @@ public function __construct(
111114
*
112115
* @var string|null
113116
*/
114-
private string|null $modifier = null,
117+
private ?string $modifier = null,
115118
) {
116119
self::validateHydrationStrategy($this);
120+
121+
if (true === $url) {
122+
$this->url = new UrlMapping();
123+
}
117124
}
118125

119126
/**
@@ -277,20 +284,20 @@ 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
}
292299

293-
public function modifier(): string|null
300+
public function modifier(): ?string
294301
{
295302
return $this->modifier;
296303
}

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)