Skip to content

Commit

Permalink
feature #1396 [LiveComponent] Alias URL bound props (squrious)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
kbond committed Apr 16, 2024
2 parents 09797ee + 828e34e commit d4df614
Show file tree
Hide file tree
Showing 16 changed files with 240 additions and 91 deletions.
1 change: 1 addition & 0 deletions src/LiveComponent/CHANGELOG.md
Expand Up @@ -8,6 +8,7 @@
page is rendered, either when the page loads (`loading="defer"`) or when
the component becomes visible in the viewport (`loading="lazy"`).
- Deprecate the `defer` attribute.
- Add `UrlMapping` configuration object for URL bindings in LiveComponents

## 2.16.0

Expand Down
23 changes: 22 additions & 1 deletion src/LiveComponent/assets/test/controller/query-binding.test.ts
Expand Up @@ -144,7 +144,6 @@ describe('LiveController query string binding', () => {
expectCurrentSearch().toEqual('?prop=');
});


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

expectCurrentSearch().toEqual('?prop=foo');
});

it('uses custom name instead of prop name in the URL', async () => {
const test = await createTest({ prop1: ''}, (data: any) => `
<div ${initComponent(data, { queryMapping: {prop1: {name: 'alias1'} }})}></div>
`)

// Set value
test.expectsAjaxCall()
.expectUpdatedData({prop1: 'foo'});

await test.component.set('prop1', 'foo', true);

expectCurrentSearch().toEqual('?alias1=foo');

// Remove value
test.expectsAjaxCall()
.expectUpdatedData({prop1: ''});

await test.component.set('prop1', '', true);

expectCurrentSearch().toEqual('?alias1=');
});
})
70 changes: 63 additions & 7 deletions src/LiveComponent/doc/index.rst
Expand Up @@ -2495,11 +2495,6 @@ If you load this URL in your browser, the ``LiveProp`` value will be initialized

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

.. warning::

You can use multiple components with URL bindings in the same page, as long as bound field names don't collide.
Otherwise, you will observe unexpected behaviors.

Supported Data Types
~~~~~~~~~~~~~~~~~~~~

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

Controlling the Query Parameter Name
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

.. versionadded:: 2.17

The ``as`` option was added in LiveComponents 2.17.


Instead of using the prop's field name as the query parameter name, you can use the ``as`` option in your ``LiveProp``
definition::

// ...
use Symfony\UX\LiveComponent\Metadata\UrlMapping;

#[AsLiveComponent]
class SearchModule
{
#[LiveProp(writable: true, url: new UrlMapping(as: 'q')]
public string $query = '';

// ...
}

Then the ``query`` value will appear in the URL like ``https://my.domain/search?q=my+query+string``.

If you need to change the parameter name on a specific page, you can leverage the :ref:`modifier <modifier>` option::

// ...
use Symfony\UX\LiveComponent\Metadata\UrlMapping;

#[AsLiveComponent]
class SearchModule
{
#[LiveProp(writable: true, url: true, modifier: 'modifyQueryProp')]
public string $query = '';

#[LiveProp]
public ?string $alias = null;

public function modifyQueryProp(LiveProp $liveProp): LiveProp
{
if ($this->alias) {
$liveProp = $liveProp->withUrl(new UrlMapping(as: $this->alias));
}
return $liveProp;
}
}

.. code-block:: html+twig

<twig:SearchModule alias="q" />

This way you can also use the component multiple times in the same page and avoid collisions in parameter names:

.. code-block:: html+twig

<twig:SearchModule alias="q1" />
<twig:SearchModule alias="q2" />

Validating the Query Parameter Values
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

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

.. _modifier:

Set LiveProp Options Dynamically
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down
19 changes: 13 additions & 6 deletions src/LiveComponent/src/Attribute/LiveProp.php
Expand Up @@ -11,6 +11,8 @@

namespace Symfony\UX\LiveComponent\Attribute;

use Symfony\UX\LiveComponent\Metadata\UrlMapping;

/**
* An attribute to mark a property as a "LiveProp".
*
Expand Down Expand Up @@ -97,10 +99,11 @@ public function __construct(
private string|array|null $onUpdated = null,

/**
* If true, this property will be synchronized with a query parameter
* in the URL.
* Whether to synchronize this property with a query parameter
* in the URL. Pass true to configure the mapping automatically, or a
* {@see UrlMapping} instance to configure the mapping.
*/
private bool $url = false,
private bool|UrlMapping $url = false,

/**
* A hook that will be called when this LiveProp is used.
Expand All @@ -114,6 +117,10 @@ public function __construct(
private ?string $modifier = null,
) {
self::validateHydrationStrategy($this);

if (true === $url) {
$this->url = new UrlMapping();
}
}

/**
Expand Down Expand Up @@ -277,15 +284,15 @@ public function withOnUpdated(string|array|null $onUpdated): self
return $clone;
}

public function url(): bool
public function url(): UrlMapping|false
{
return $this->url;
}

public function withUrl(bool $url): self
public function withUrl(bool|UrlMapping $url): self
{
$clone = clone $this;
$clone->url = $url;
$clone->url = (true === $url) ? new UrlMapping() : $url;

return $clone;
}
Expand Down
2 changes: 1 addition & 1 deletion src/LiveComponent/src/Metadata/LiveComponentMetadata.php
Expand Up @@ -69,7 +69,7 @@ public function getOnlyPropsThatAcceptUpdatesFromParent(array $inputProps): arra
public function hasQueryStringBindings($component): bool
{
foreach ($this->getAllLivePropsMetadata($component) as $livePropMetadata) {
if ($livePropMetadata->queryStringMapping()) {
if ($livePropMetadata->urlMapping()) {
return true;
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/LiveComponent/src/Metadata/LivePropMetadata.php
Expand Up @@ -51,9 +51,9 @@ public function allowsNull(): bool
return $this->allowsNull;
}

public function queryStringMapping(): bool
public function urlMapping(): ?UrlMapping
{
return $this->liveProp->url();
return $this->liveProp->url() ?: null;
}

public function calculateFieldName(object $component, string $fallback): string
Expand Down
28 changes: 28 additions & 0 deletions src/LiveComponent/src/Metadata/UrlMapping.php
@@ -0,0 +1,28 @@
<?php

/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace Symfony\UX\LiveComponent\Metadata;

/**
* Mapping configuration to bind a LiveProp to a URL query parameter.
*
* @author Nicolas Rigaud <squrious@protonmail.com>
*/
final class UrlMapping
{
public function __construct(
/**
* The name of the prop that appears in the URL. If null, the LiveProp's field name is used.
*/
public readonly ?string $as = null,
) {
}
}
Expand Up @@ -104,14 +104,14 @@ public function attributesForRendering(MountedComponent $mounted, ComponentMetad
}

if ($liveMetadata->hasQueryStringBindings($mounted->getComponent())) {
$queryMapping = [];
$mappings = [];
foreach ($liveMetadata->getAllLivePropsMetadata($mounted->getComponent()) as $livePropMetadata) {
if ($livePropMetadata->queryStringMapping()) {
if ($urlMapping = $livePropMetadata->urlMapping()) {
$frontendName = $livePropMetadata->calculateFieldName($mounted->getComponent(), $livePropMetadata->getName());
$queryMapping[$frontendName] = ['name' => $frontendName];
$mappings[$frontendName] = ['name' => $urlMapping->as ?? $frontendName];
}
}
$attributesCollection->setQueryUrlMapping($queryMapping);
$attributesCollection->setQueryUrlMapping($mappings);
}

if ($isChildComponent) {
Expand Down
4 changes: 2 additions & 2 deletions src/LiveComponent/src/Util/QueryStringPropsExtractor.php
Expand Up @@ -41,9 +41,9 @@ public function extract(Request $request, LiveComponentMetadata $metadata, objec
$data = [];

foreach ($metadata->getAllLivePropsMetadata($component) as $livePropMetadata) {
if ($livePropMetadata->queryStringMapping()) {
if ($queryMapping = $livePropMetadata->urlMapping()) {
$frontendName = $livePropMetadata->calculateFieldName($component, $livePropMetadata->getName());
if (null !== ($value = $query[$frontendName] ?? null)) {
if (null !== ($value = $query[$queryMapping->as ?? $frontendName] ?? null)) {
if ('' === $value && null !== $livePropMetadata->getType() && (!$livePropMetadata->isBuiltIn() || 'array' === $livePropMetadata->getType())) {
// Cast empty string to empty array for objects and arrays
$value = [];
Expand Down
Expand Up @@ -14,6 +14,7 @@
use Symfony\UX\LiveComponent\Attribute\AsLiveComponent;
use Symfony\UX\LiveComponent\Attribute\LiveProp;
use Symfony\UX\LiveComponent\DefaultActionTrait;
use Symfony\UX\LiveComponent\Metadata\UrlMapping;
use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\Address;

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

#[LiveProp(url: true)]
public ?string $prop1 = null;
public ?string $stringProp = null;

#[LiveProp(url: true)]
public ?int $prop2 = null;
public ?int $intProp = null;

#[LiveProp(url: true)]
public array $prop3 = [];
public array $arrayProp = [];

#[LiveProp]
public ?string $prop4 = null;
public ?string $unboundProp = null;

#[LiveProp(url: true)]
public ?Address $prop5 = null;
public ?Address $objectProp = null;

#[LiveProp(fieldName: 'field6', url: true)]
public ?string $prop6 = null;
#[LiveProp(fieldName: 'field1', url: true)]
public ?string $propWithField1 = null;

#[LiveProp(fieldName: 'getProp7Name()', url: true)]
public ?string $prop7 = null;
#[LiveProp(fieldName: 'getField2()', url: true)]
public ?string $propWithField2 = null;

#[LiveProp(modifier: 'modifyProp8')]
public ?string $prop8 = null;
#[LiveProp(modifier: 'modifyMaybeBoundProp')]
public ?string $maybeBoundProp = null;

#[LiveProp]
public ?bool $prop8InUrl = false;
public ?bool $maybeBoundPropInUrl = false;

public function getProp7Name(): string
public function getField2(): string
{
return 'field7';
return 'field2';
}

public function modifyProp8(LiveProp $prop): LiveProp
public function modifyMaybeBoundProp(LiveProp $prop): LiveProp
{
return $prop->withUrl($this->prop8InUrl);
return $prop->withUrl($this->maybeBoundPropInUrl);
}

#[LiveProp(url: new UrlMapping(as: 'q'))]
public ?string $boundPropWithAlias = null;

#[LiveProp(url: true, modifier: 'modifyBoundPropWithCustomAlias')]
public ?string $boundPropWithCustomAlias = null;

#[LiveProp]
public ?string $customAlias = null;

public function modifyBoundPropWithCustomAlias(LiveProp $liveProp): LiveProp
{
if ($this->customAlias) {
$liveProp = $liveProp->withUrl(new UrlMapping(as: $this->customAlias));
}

return $liveProp;
}


}
@@ -1,10 +1,12 @@
<div {{ attributes }}>
Prop1: {{ prop1 }}
Prop2: {{ prop2 }}
Prop3: {{ prop3|join(',') }}
Prop4: {{ prop4 }}
Prop5: address: {{ prop5.address ?? '' }} city: {{ prop5.city ?? '' }}
Prop6: {{ prop6 }}
Prop7: {{ prop7 }}
Prop8: {{ prop8 }}
StringProp: {{ stringProp }}
IntProp: {{ intProp }}
ArrayProp: {{ arrayProp|join(',') }}
UnboundProp: {{ unboundProp }}
ObjectProp: address: {{ objectProp.address ?? '' }} city: {{ objectProp.city ?? '' }}
PropWithField1: {{ propWithField1 }}
PropWithField2: {{ propWithField2 }}
MaybeBoundProp: {{ maybeBoundProp }}
BoundPropWithAlias: {{ boundPropWithAlias }}
BoundPropWithCustomAlias: {{ boundPropWithCustomAlias }}
</div>
@@ -1,3 +1,4 @@
{{ component('component_with_url_bound_props', {
prop8InUrl: true
maybeBoundPropInUrl: true,
customAlias: 'customAlias',
}) }}

0 comments on commit d4df614

Please sign in to comment.