Skip to content

Commit 828e34e

Browse files
squriouskbond
authored andcommitted
[LiveComponent] Alias URL bound props
1 parent 2562a6c commit 828e34e

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
@@ -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: 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)