diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index ab3bf061ec4..fadf298ec14 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2.13.0 +- Add support for URL binding in `LiveProp` - Add deferred rendering of Live Components ## 2.12.0 diff --git a/src/LiveComponent/assets/dist/Component/plugins/QueryStringPlugin.d.ts b/src/LiveComponent/assets/dist/Component/plugins/QueryStringPlugin.d.ts new file mode 100644 index 00000000000..6f9feeeda3b --- /dev/null +++ b/src/LiveComponent/assets/dist/Component/plugins/QueryStringPlugin.d.ts @@ -0,0 +1,9 @@ +import Component from '../index'; +import { PluginInterface } from './PluginInterface'; +export default class implements PluginInterface { + private element; + private mapping; + attachToComponent(component: Component): void; + private registerBindings; + private updateUrl; +} diff --git a/src/LiveComponent/assets/dist/Component/plugins/QueryStringPluging.d.ts b/src/LiveComponent/assets/dist/Component/plugins/QueryStringPluging.d.ts new file mode 100644 index 00000000000..6f9feeeda3b --- /dev/null +++ b/src/LiveComponent/assets/dist/Component/plugins/QueryStringPluging.d.ts @@ -0,0 +1,9 @@ +import Component from '../index'; +import { PluginInterface } from './PluginInterface'; +export default class implements PluginInterface { + private element; + private mapping; + attachToComponent(component: Component): void; + private registerBindings; + private updateUrl; +} diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 425b2e6a90b..7ea61ff2b1c 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -2696,6 +2696,92 @@ class ComponentRegistry { } } +class AdvancedURLSearchParams extends URLSearchParams { + set(name, value) { + if (typeof value !== 'object') { + super.set(name, value); + } + else { + this.delete(name); + if (Array.isArray(value)) { + value.forEach((v) => { + this.append(`${name}[]`, v); + }); + } + else { + Object.entries(value).forEach(([index, v]) => { + this.append(`${name}[${index}]`, v); + }); + } + } + } + delete(name) { + super.delete(name); + const pattern = new RegExp(`^${name}(\\[.*])?$`); + for (const key of Array.from(this.keys())) { + if (key.match(pattern)) { + super.delete(key); + } + } + } +} +function setQueryParam(param, value) { + const queryParams = new AdvancedURLSearchParams(window.location.search); + queryParams.set(param, value); + const url = urlFromQueryParams(queryParams); + history.replaceState(history.state, '', url); +} +function removeQueryParam(param) { + const queryParams = new AdvancedURLSearchParams(window.location.search); + queryParams.delete(param); + const url = urlFromQueryParams(queryParams); + history.replaceState(history.state, '', url); +} +function urlFromQueryParams(queryParams) { + let queryString = ''; + if (Array.from(queryParams.entries()).length > 0) { + queryString += '?' + queryParams.toString(); + } + return window.location.origin + window.location.pathname + queryString + window.location.hash; +} + +class QueryStringPlugin { + constructor() { + this.mapping = new Map; + } + attachToComponent(component) { + this.element = component.element; + this.registerBindings(); + component.on('connect', (component) => { + this.updateUrl(component); + }); + component.on('render:finished', (component) => { + this.updateUrl(component); + }); + } + registerBindings() { + const rawQueryMapping = this.element.dataset.liveQueryMapping; + if (rawQueryMapping === undefined) { + return; + } + const mapping = JSON.parse(rawQueryMapping); + Object.entries(mapping).forEach(([key, config]) => { + this.mapping.set(key, config); + }); + } + updateUrl(component) { + this.mapping.forEach((mapping, propName) => { + const value = component.valueStore.get(propName); + if (value === '' || value === null || value === undefined) { + removeQueryParam(mapping.name); + } + else { + setQueryParam(mapping.name, value); + } + }); + } +} + const getComponent = (element) => LiveControllerDefault.componentRegistry.getComponent(element); class LiveControllerDefault extends Controller { constructor() { @@ -2723,6 +2809,7 @@ class LiveControllerDefault extends Controller { new PageUnloadingPlugin(), new PollingPlugin(), new SetValueOntoModelFieldsPlugin(), + new QueryStringPlugin(), ]; plugins.forEach((plugin) => { this.component.addPlugin(plugin); diff --git a/src/LiveComponent/assets/dist/url_utils.d.ts b/src/LiveComponent/assets/dist/url_utils.d.ts new file mode 100644 index 00000000000..e5938390069 --- /dev/null +++ b/src/LiveComponent/assets/dist/url_utils.d.ts @@ -0,0 +1,2 @@ +export declare function setQueryParam(param: string, value: any): void; +export declare function removeQueryParam(param: string): void; diff --git a/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts b/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts new file mode 100644 index 00000000000..855a314a27a --- /dev/null +++ b/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts @@ -0,0 +1,52 @@ +import Component from '../index'; +import { PluginInterface } from './PluginInterface'; +import { + setQueryParam, removeQueryParam, +} from '../../url_utils'; + +type QueryMapping = { + name: string, +} + +export default class implements PluginInterface { + private element: Element; + private mapping: Map = new Map; + + attachToComponent(component: Component): void { + this.element = component.element; + this.registerBindings(); + + component.on('connect', (component: Component) => { + this.updateUrl(component); + }); + + component.on('render:finished', (component: Component)=> { + this.updateUrl(component); + }); + } + + private registerBindings(): void { + const rawQueryMapping = (this.element as HTMLElement).dataset.liveQueryMapping; + if (rawQueryMapping === undefined) { + return; + } + + const mapping = JSON.parse(rawQueryMapping) as {[p: string]: QueryMapping}; + + Object.entries(mapping).forEach(([key, config]) => { + this.mapping.set(key, config); + }) + } + + private updateUrl(component: Component){ + this.mapping.forEach((mapping, propName) => { + const value = component.valueStore.get(propName); + if (value === '' || value === null || value === undefined) { + removeQueryParam(mapping.name); + } else { + setQueryParam(mapping.name, value); + } + + }); + } +} diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index 227a14b00c7..a1e65883c83 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -18,6 +18,7 @@ import SetValueOntoModelFieldsPlugin from './Component/plugins/SetValueOntoModel import { PluginInterface } from './Component/plugins/PluginInterface'; import getModelBinding from './Directive/get_model_binding'; import ComponentRegistry from './ComponentRegistry'; +import QueryStringPlugin from './Component/plugins/QueryStringPlugin'; export { Component }; export const getComponent = (element: HTMLElement): Promise => @@ -102,6 +103,7 @@ export default class LiveControllerDefault extends Controller imple new PageUnloadingPlugin(), new PollingPlugin(), new SetValueOntoModelFieldsPlugin(), + new QueryStringPlugin(), ]; plugins.forEach((plugin) => { this.component.addPlugin(plugin); diff --git a/src/LiveComponent/assets/src/url_utils.ts b/src/LiveComponent/assets/src/url_utils.ts new file mode 100644 index 00000000000..3b74f4314dc --- /dev/null +++ b/src/LiveComponent/assets/src/url_utils.ts @@ -0,0 +1,57 @@ +class AdvancedURLSearchParams extends URLSearchParams { + set(name: string, value: any) { + if (typeof value !== 'object') { + super.set(name, value); + } else { + this.delete(name); + if (Array.isArray(value)) { + value.forEach((v) => { + this.append(`${name}[]`, v); + }); + } else { + Object.entries(value).forEach(([index, v]) => { + this.append(`${name}[${index}]`, v as string); + }); + } + } + } + + delete(name: string) { + super.delete(name); + const pattern = new RegExp(`^${name}(\\[.*])?$`); + for (const key of Array.from(this.keys())) { + if (key.match(pattern)) { + super.delete(key); + } + } + } +} + +export function setQueryParam(param: string, value: any) { + const queryParams = new AdvancedURLSearchParams(window.location.search); + + queryParams.set(param, value); + + const url = urlFromQueryParams(queryParams); + + history.replaceState(history.state, '', url); +} + +export function removeQueryParam(param: string) { + const queryParams = new AdvancedURLSearchParams(window.location.search); + + queryParams.delete(param); + + const url = urlFromQueryParams(queryParams); + + history.replaceState(history.state, '', url); +} + +function urlFromQueryParams(queryParams: URLSearchParams) { + let queryString = ''; + if (Array.from(queryParams.entries()).length > 0) { + queryString += '?' + queryParams.toString(); + } + + return window.location.origin + window.location.pathname + queryString + window.location.hash; +} diff --git a/src/LiveComponent/assets/test/controller/query-binding.test.ts b/src/LiveComponent/assets/test/controller/query-binding.test.ts new file mode 100644 index 00000000000..dd05580d619 --- /dev/null +++ b/src/LiveComponent/assets/test/controller/query-binding.test.ts @@ -0,0 +1,76 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +import {createTest, initComponent, shutdownTests} from '../tools'; +import { getByText, waitFor } from '@testing-library/dom'; + +describe('LiveController query string binding', () => { + afterEach(() => { + shutdownTests(); + }); + + it('doesn\'t initialize URL if props are not defined', async () => { + await createTest({ prop: ''}, (data: any) => ` +
+ `) + + expect(window.location.search).toEqual(''); + }) + + it('initializes URL with defined props values', async () => { + await createTest({ prop: 'foo'}, (data: any) => ` +
+ `) + + expect(window.location.search).toEqual('?prop=foo'); + }); + + it('properly handles array props in the URL', async () => { + await createTest({ prop: ['foo', 'bar']}, (data: any) => ` +
+ `) + expect(decodeURIComponent(window.location.search)).toEqual('?prop[]=foo&prop[]=bar'); + }); + + it('updates the URL when the props changed', async () => { + const test = await createTest({ prop: ''}, (data: any) => ` +
+ `) + + test.expectsAjaxCall() + .expectUpdatedData({prop: 'foo'}); + + await test.component.set('prop', 'foo', true); + + expect(window.location.search).toEqual('?prop=foo'); + }); + + it('updates the URL with props changed by the server', async () => { + const test = await createTest({ prop: ''}, (data: any) => ` +
+ Prop: ${data.prop} + +
+ `); + + test.expectsAjaxCall() + .expectActionCalled('changeProp') + .serverWillChangeProps((data: any) => { + data.prop = 'foo'; + }); + + getByText(test.element, 'Change prop').click(); + + await waitFor(() => expect(test.element).toHaveTextContent('Prop: foo')); + + expect(window.location.search).toEqual('?prop=foo'); + }); +}) \ No newline at end of file diff --git a/src/LiveComponent/assets/test/tools.ts b/src/LiveComponent/assets/test/tools.ts index d50d1678a82..362e9c583cc 100644 --- a/src/LiveComponent/assets/test/tools.ts +++ b/src/LiveComponent/assets/test/tools.ts @@ -434,6 +434,7 @@ export function initComponent(props: any = {}, controllerValues: any = {}) { ${controllerValues.fingerprint ? `data-live-fingerprint-value="${controllerValues.fingerprint}"` : ''} ${controllerValues.listeners ? `data-live-listeners-value="${dataToJsonAttribute(controllerValues.listeners)}"` : ''} ${controllerValues.browserDispatch ? `data-live-browser-dispatch="${dataToJsonAttribute(controllerValues.browserDispatch)}"` : ''} + ${controllerValues.queryMapping ? `data-live-query-mapping="${dataToJsonAttribute(controllerValues.queryMapping)}"` : ''} `; } diff --git a/src/LiveComponent/assets/test/url_utils.test.ts b/src/LiveComponent/assets/test/url_utils.test.ts new file mode 100644 index 00000000000..8e8bc4b1fab --- /dev/null +++ b/src/LiveComponent/assets/test/url_utils.test.ts @@ -0,0 +1,92 @@ +/* + * This file is part of the Symfony package. + * + * (c) Fabien Potencier + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +'use strict'; + +import {setQueryParam, removeQueryParam} from '../src/url_utils'; + +const setCurrentSearch = (search: string): void => +{ + history.replaceState(history.state, '', window.location.origin + window.location.pathname + search); +} + +const expectCurrentSearch = () => { + return expect(decodeURIComponent(window.location.search)); +} + +describe('setQueryParam', () => { + it('set the param if it does not exist', () => { + setCurrentSearch(''); + + setQueryParam('param', 'foo'); + + expectCurrentSearch().toEqual('?param=foo'); + }); + + it('override the param if it exists', () => { + setCurrentSearch('?param=foo'); + + setQueryParam('param', 'bar'); + + expectCurrentSearch().toEqual('?param=bar'); + }); + + it('expand arrays in the URL', () => { + setCurrentSearch(''); + + setQueryParam('param', ['foo', 'bar']); + + expectCurrentSearch().toEqual('?param[]=foo¶m[]=bar'); + }); + + it('expand objects in the URL', () => { + setCurrentSearch(''); + + setQueryParam('param', { + foo: 1, + bar: 'baz', + }); + + expectCurrentSearch().toEqual('?param[foo]=1¶m[bar]=baz'); + }) +}) + +describe('removeQueryParam', () => { + it('remove the param if it exists', () => { + setCurrentSearch('?param=foo'); + + removeQueryParam('param'); + + expectCurrentSearch().toEqual(''); + }); + + it('keep other params unchanged', () => { + setCurrentSearch('?param=foo&otherParam=bar'); + + removeQueryParam('param'); + + expectCurrentSearch().toEqual('?otherParam=bar'); + }); + + it('remove all occurrences of an array param', () => { + setCurrentSearch('?param[]=foo¶m[]=bar'); + + removeQueryParam('param'); + + expectCurrentSearch().toEqual(''); + }); + + it ('remove all occurrences of an object param', () => { + setCurrentSearch('?param[foo]=1¶m[bar]=baz'); + + removeQueryParam('param'); + + expectCurrentSearch().toEqual(''); + }); +}) \ No newline at end of file diff --git a/src/LiveComponent/src/Attribute/LiveProp.php b/src/LiveComponent/src/Attribute/LiveProp.php index cb78eee2698..e08eeb51a07 100644 --- a/src/LiveComponent/src/Attribute/LiveProp.php +++ b/src/LiveComponent/src/Attribute/LiveProp.php @@ -58,6 +58,13 @@ final class LiveProp */ private null|string|array $onUpdated; + /** + * @var bool + * + * Tells if this property should be bound to the URL + */ + private bool $url; + /** * @param bool|array $writable If true, this property can be changed by the frontend. * Or set to an array of paths within this object/array @@ -73,6 +80,8 @@ final class LiveProp * from the value used when originally rendering * this child, the value in the child will be updated * to match the new value and the child will be re-rendered + * @param bool $url if true, this property will be synchronized with a query parameter + * in the URL */ public function __construct( bool|array $writable = false, @@ -84,6 +93,7 @@ public function __construct( string $format = null, bool $updateFromParent = false, string|array $onUpdated = null, + bool $url = false, ) { $this->writable = $writable; $this->hydrateWith = $hydrateWith; @@ -94,6 +104,7 @@ public function __construct( $this->format = $format; $this->acceptUpdatesFromParent = $updateFromParent; $this->onUpdated = $onUpdated; + $this->url = $url; if ($this->useSerializerForHydration && ($this->hydrateWith || $this->dehydrateWith)) { throw new \InvalidArgumentException('Cannot use useSerializerForHydration with hydrateWith or dehydrateWith.'); @@ -188,4 +199,9 @@ public function onUpdated(): null|string|array { return $this->onUpdated; } + + public function url(): bool + { + return $this->url; + } } diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index cef94a233ec..5a14ebd60af 100644 --- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php +++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php @@ -28,6 +28,7 @@ use Symfony\UX\LiveComponent\EventListener\DeferLiveComponentSubscriber; use Symfony\UX\LiveComponent\EventListener\InterceptChildComponentRenderSubscriber; use Symfony\UX\LiveComponent\EventListener\LiveComponentSubscriber; +use Symfony\UX\LiveComponent\EventListener\QueryStringInitializeSubscriber; use Symfony\UX\LiveComponent\EventListener\ResetDeterministicIdSubscriber; use Symfony\UX\LiveComponent\Form\Type\LiveCollectionType; use Symfony\UX\LiveComponent\Hydration\HydrationExtensionInterface; @@ -44,6 +45,7 @@ use Symfony\UX\LiveComponent\Util\FingerprintCalculator; use Symfony\UX\LiveComponent\Util\LiveComponentStack; use Symfony\UX\LiveComponent\Util\LiveControllerAttributesCreator; +use Symfony\UX\LiveComponent\Util\QueryStringPropsExtractor; use Symfony\UX\LiveComponent\Util\TwigAttributeHelperFactory; use Symfony\UX\TwigComponent\ComponentFactory; use Symfony\UX\TwigComponent\ComponentRenderer; @@ -216,6 +218,16 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) { ->addTag('container.service_subscriber', ['key' => LiveControllerAttributesCreator::class, 'id' => 'ux.live_component.live_controller_attributes_creator']) ; + $container->register('ux.live_component.query_string_props_extractor', QueryStringPropsExtractor::class); + + $container->register('ux.live_component.query_string_initializer_subscriber', QueryStringInitializeSubscriber::class) + ->setArguments([ + new Reference('request_stack'), + new Reference('ux.live_component.metadata_factory'), + new Reference('ux.live_component.query_string_props_extractor'), + ]) + ->addTag('kernel.event_subscriber'); + $container->register('ux.live_component.defer_live_component_subscriber', DeferLiveComponentSubscriber::class) ->setArguments([ new Reference('ux.twig_component.component_stack'), diff --git a/src/LiveComponent/src/EventListener/QueryStringInitializeSubscriber.php b/src/LiveComponent/src/EventListener/QueryStringInitializeSubscriber.php new file mode 100644 index 00000000000..91d16d9929d --- /dev/null +++ b/src/LiveComponent/src/EventListener/QueryStringInitializeSubscriber.php @@ -0,0 +1,75 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadata; +use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory; +use Symfony\UX\LiveComponent\Util\QueryStringPropsExtractor; +use Symfony\UX\TwigComponent\Event\PreCreateForRenderEvent; +use Symfony\UX\TwigComponent\Event\PreMountEvent; + +/** + * @author Nicolas Rigaud + * + * @experimental + * + * @internal + */ +class QueryStringInitializeSubscriber implements EventSubscriberInterface +{ + /** + * @var array + */ + private array $registered = []; + + public function __construct( + private readonly RequestStack $requestStack, + private readonly LiveComponentMetadataFactory $metadataFactory, + private readonly QueryStringPropsExtractor $queryStringPropsExtractor, + ) { + } + + public static function getSubscribedEvents(): array + { + return [ + PreCreateForRenderEvent::class => 'onPreCreateForRenderEvent', + PreMountEvent::class => 'onPreMount', + ]; + } + + public function onPreMount(PreMountEvent $event): void + { + $component = $event->getComponent(); + if (!($metadata = $this->registered[$component::class] ?? null)) { + return; + } + + $data = $event->getData(); + + $request = $this->requestStack->getCurrentRequest(); + + $queryStringData = $this->queryStringPropsExtractor->extract($request->getQueryString(), $metadata); + + $event->setData(array_merge($data, $queryStringData)); + } + + public function onPreCreateForRenderEvent(PreCreateForRenderEvent $event): void + { + $componentName = $event->getName(); + $metadata = $this->metadataFactory->getMetadata($componentName); + if ($metadata->hasQueryStringBindings()) { + $this->registered[$metadata->getComponentMetadata()->getClass()] = $metadata; + } + } +} diff --git a/src/LiveComponent/src/Metadata/LiveComponentMetadata.php b/src/LiveComponent/src/Metadata/LiveComponentMetadata.php index 2c5be6796b1..9d61418f7d0 100644 --- a/src/LiveComponent/src/Metadata/LiveComponentMetadata.php +++ b/src/LiveComponent/src/Metadata/LiveComponentMetadata.php @@ -61,4 +61,15 @@ public function getOnlyPropsThatAcceptUpdatesFromParent(array $inputProps): arra return array_intersect_key($inputProps, array_flip($propNames)); } + + public function hasQueryStringBindings(): bool + { + foreach ($this->getAllLivePropsMetadata() as $livePropMetadata) { + if ([] !== $livePropMetadata->getQueryStringMapping()) { + return true; + } + } + + return false; + } } diff --git a/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php b/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php index a0176ecf449..bada6b8514e 100644 --- a/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php +++ b/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php @@ -87,24 +87,24 @@ public function createLivePropMetadata(string $className, string $propertyName, if (null === $type && null === $collectionValueType && isset($infoTypes[0])) { $infoType = Type::BUILTIN_TYPE_OBJECT === $infoTypes[0]->getBuiltinType() ? $infoTypes[0]->getClassName() : $infoTypes[0]->getBuiltinType(); - - return new LivePropMetadata( - $property->getName(), - $liveProp, - $infoType, - null === $infoTypes[0]->getClassName(), - $infoTypes[0]->isNullable(), - null, - ); + $isTypeBuiltIn = null === $infoTypes[0]->getClassName(); + $isTypeNullable = $infoTypes[0]->isNullable(); + } else { + $infoType = $type?->getName(); + $isTypeBuiltIn = $type?->isBuiltin() ?? false; + $isTypeNullable = $type?->allowsNull() ?? true; } + $queryStringBinding = $this->createQueryStringMapping($propertyName, $liveProp, $isTypeBuiltIn, $infoType, $collectionValueType); + return new LivePropMetadata( $property->getName(), $liveProp, - $type?->getName(), - $type && $type->isBuiltin(), - !$type || $type->allowsNull(), + $infoType, + $isTypeBuiltIn, + $isTypeNullable, $collectionValueType, + $queryStringBinding ); } @@ -121,4 +121,55 @@ private static function propertiesFor(\ReflectionClass $class): iterable yield from self::propertiesFor($parent); } } + + private function createQueryStringMapping(string $propertyName, LiveProp $liveProp, bool $isTypeBuiltIn, ?string $infoType, ?Type $collectionValueType): array + { + if (false === $liveProp->url()) { + return []; + } + + $queryStringMapping = []; + $parameters = []; + + if ($isTypeBuiltIn || null === $infoType) { + // Built-in or unknown type + $parameters[$propertyName] = [ + 'property' => $propertyName, + 'type' => $infoType ?? 'string', + 'collectionType' => $collectionValueType?->getBuiltinType(), + ]; + } else { + // Custom class type + $subProps = $liveProp->writablePaths(); + + if (empty($subProps)) { + return []; + } + + foreach ($subProps as $subProp) { + $subPropTypes = $this->propertyTypeExtractor->getTypes($infoType, $subProp) ?? []; + foreach ($subPropTypes as $subPropType) { + if ($subPropType->isCollection()) { + $collectionValueType = $subPropType->getCollectionValueTypes()[0] ?? null; + } + } + $subPropType = $subPropTypes[0] ?? null; + + if (Type::BUILTIN_TYPE_OBJECT === $subPropType?->getBuiltinType()) { + // TODO allow deep object binding later? + throw new \InvalidArgumentException(sprintf('Cannot configure URL mapping for nested property %s::%s: only scalar or arrays are supported for nested properties in query string mapping.', $infoType, $subProp)); + } + + $parameters[sprintf('%s_%s', $propertyName, $subProp)] = [ + 'property' => sprintf('%s.%s', $propertyName, $subProp), + 'type' => $subPropType?->getBuiltinType() ?? 'string', + 'collectionType' => $collectionValueType?->getBuiltinType(), + ]; + } + } + + $queryStringMapping['parameters'] = $parameters; + + return $queryStringMapping; + } } diff --git a/src/LiveComponent/src/Metadata/LivePropMetadata.php b/src/LiveComponent/src/Metadata/LivePropMetadata.php index 7e56f982f07..bb7f17244d8 100644 --- a/src/LiveComponent/src/Metadata/LivePropMetadata.php +++ b/src/LiveComponent/src/Metadata/LivePropMetadata.php @@ -30,6 +30,7 @@ public function __construct( private bool $isBuiltIn, private bool $allowsNull, private ?Type $collectionValueType, + private array $queryStringMapping = [], ) { } @@ -53,6 +54,11 @@ public function allowsNull(): bool return $this->allowsNull; } + public function getQueryStringMapping(): array + { + return $this->queryStringMapping; + } + public function calculateFieldName(object $component, string $fallback): string { return $this->liveProp->calculateFieldName($component, $fallback); diff --git a/src/LiveComponent/src/Util/LiveAttributesCollection.php b/src/LiveComponent/src/Util/LiveAttributesCollection.php index 7c99a19f8e7..0f7dba2d41f 100644 --- a/src/LiveComponent/src/Util/LiveAttributesCollection.php +++ b/src/LiveComponent/src/Util/LiveAttributesCollection.php @@ -97,6 +97,11 @@ public function setBrowserEventsToDispatch(array $browserEventsToDispatch): void $this->attributes['data-live-browser-dispatch'] = $browserEventsToDispatch; } + public function setQueryUrlMapping(array $queryUrlMapping): void + { + $this->attributes['data-live-query-mapping'] = $queryUrlMapping; + } + private function escapeAttribute(string $value): string { return twig_escape_filter($this->twig, $value, 'html_attr'); diff --git a/src/LiveComponent/src/Util/LiveControllerAttributesCreator.php b/src/LiveComponent/src/Util/LiveControllerAttributesCreator.php index c0865e0e6f7..14123745df6 100644 --- a/src/LiveComponent/src/Util/LiveControllerAttributesCreator.php +++ b/src/LiveComponent/src/Util/LiveControllerAttributesCreator.php @@ -121,6 +121,21 @@ public function attributesForRendering(MountedComponent $mounted, ComponentMetad ); } + $liveMetadata = $this->metadataFactory->getMetadata($mounted->getName()); + if ($liveMetadata->hasQueryStringBindings()) { + $queryMapping = []; + foreach ($liveMetadata->getAllLivePropsMetadata() as $livePropMetadata) { + if ($mapping = $livePropMetadata->getQueryStringMapping()) { + foreach ($mapping['parameters'] as $parameter => $config) { + $queryMapping[$config['property']] = [ + 'name' => $parameter, + ]; + } + } + } + $attributesCollection->setQueryUrlMapping($queryMapping); + } + return $attributesCollection; } diff --git a/src/LiveComponent/src/Util/QueryStringPropsExtractor.php b/src/LiveComponent/src/Util/QueryStringPropsExtractor.php new file mode 100644 index 00000000000..591be7056ea --- /dev/null +++ b/src/LiveComponent/src/Util/QueryStringPropsExtractor.php @@ -0,0 +1,66 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Util; + +use Symfony\Component\HttpFoundation\HeaderUtils; +use Symfony\Component\PropertyInfo\Type; +use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadata; + +/** + * @author Nicolas Rigaud + * + * @experimental + * + * @internal + */ +class QueryStringPropsExtractor +{ + public function extract(?string $queryString, LiveComponentMetadata $metadata): array + { + if (empty($queryString)) { + return []; + } + + $query = HeaderUtils::parseQuery($queryString); + + $data = []; + + foreach ($metadata->getAllLivePropsMetadata() as $livePropMetadata) { + $queryStringBinding = $livePropMetadata->getQueryStringMapping(); + foreach ($queryStringBinding['parameters'] ?? [] as $parameterName => $paramConfig) { + if (isset($query[$parameterName])) { + $data[$paramConfig['property']] = $this->normalizeValue($query[$parameterName], $paramConfig); + } + } + } + + return $data; + } + + private function normalizeValue(mixed $value, array $config): mixed + { + $allowedTypes = [Type::BUILTIN_TYPE_BOOL, Type::BUILTIN_TYPE_FLOAT, Type::BUILTIN_TYPE_INT, Type::BUILTIN_TYPE_STRING, Type::BUILTIN_TYPE_ARRAY]; + if (!\in_array($config['type'], $allowedTypes)) { + throw new \LogicException(sprintf('Invalid type "%s" for property "%s". Valid types are: %s.', $config['type'], $config['property'], implode(', ', $allowedTypes))); + } + + if (Type::BUILTIN_TYPE_ARRAY === $config['type'] && isset($config['collectionType'])) { + foreach ($value as &$v) { + settype($v, $config['collectionType']); + } + } else { + settype($value, $config['type']); + } + + return $value; + } +} diff --git a/src/LiveComponent/tests/Fixtures/Component/ComponentWithUrlBoundProps.php b/src/LiveComponent/tests/Fixtures/Component/ComponentWithUrlBoundProps.php new file mode 100644 index 00000000000..95c902fa660 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/Component/ComponentWithUrlBoundProps.php @@ -0,0 +1,26 @@ + + Prop1: {{ prop1 }} + Prop2: {{ prop2 }} + Prop3: {{ prop3|join(',') }} + Prop4: {{ prop4 }} + \ No newline at end of file diff --git a/src/LiveComponent/tests/Fixtures/templates/render_component_with_url_bound_props.html.twig b/src/LiveComponent/tests/Fixtures/templates/render_component_with_url_bound_props.html.twig new file mode 100644 index 00000000000..06800a2e53c --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/templates/render_component_with_url_bound_props.html.twig @@ -0,0 +1 @@ +{{ component('component_with_url_bound_props') }} diff --git a/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php index b478be74034..cbe64305da6 100644 --- a/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php +++ b/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php @@ -133,4 +133,23 @@ public function testItDoesNotOverrideDataLiveIdIfSpecified(): void $this->assertSame('todo-item-1', $lis->first()->attr('data-live-id')); $this->assertSame('todo-item-3', $lis->last()->attr('data-live-id')); } + + public function testQueryStringMappingAttribute() + { + $div = $this->browser() + ->visit('/render-template/render_component_with_url_bound_props') + ->assertSuccessful() + ->crawler() + ->filter('div') + ; + + $queryMapping = json_decode($div->attr('data-live-query-mapping'), true); + $expected = [ + 'prop1' => ['name' => 'prop1'], + 'prop2' => ['name' => 'prop2'], + 'prop3' => ['name' => 'prop3'], + ]; + + $this->assertEquals($expected, $queryMapping); + } } diff --git a/src/LiveComponent/tests/Functional/EventListener/QueryStringInitializerSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/QueryStringInitializerSubscriberTest.php new file mode 100644 index 00000000000..0c5fd0fc18f --- /dev/null +++ b/src/LiveComponent/tests/Functional/EventListener/QueryStringInitializerSubscriberTest.php @@ -0,0 +1,32 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Functional\EventListener; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Zenstruck\Browser\Test\HasBrowser; + +class QueryStringInitializerSubscriberTest extends KernelTestCase +{ + use HasBrowser; + + public function testQueryStringPropsInitialization() + { + $this->browser() + ->get('/render-template/render_component_with_url_bound_props?prop1=foo&prop2=42&prop3[]=foo&prop3[]=bar&prop4=unbound') + ->assertSuccessful() + ->assertContains('Prop1: foo') + ->assertContains('Prop2: 42') + ->assertContains('Prop3: foo,bar') + ->assertContains('Prop4:') + ; + } +} diff --git a/src/LiveComponent/tests/Functional/Metadata/LiveComponentMetadataFactoryTest.php b/src/LiveComponent/tests/Functional/Metadata/LiveComponentMetadataFactoryTest.php new file mode 100644 index 00000000000..14759dc0ee9 --- /dev/null +++ b/src/LiveComponent/tests/Functional/Metadata/LiveComponentMetadataFactoryTest.php @@ -0,0 +1,53 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Functional\Metadata; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory; +use Symfony\UX\LiveComponent\Tests\Fixtures\Component\ComponentWithUrlBoundProps; + +class LiveComponentMetadataFactoryTest extends KernelTestCase +{ + public function testQueryStringMapping() + { + /** @var LiveComponentMetadataFactory $metadataFactory */ + $metadataFactory = self::getContainer()->get('ux.live_component.metadata_factory'); + + $class = new \ReflectionClass(ComponentWithUrlBoundProps::class); + $propsMetadata = $metadataFactory->createPropMetadatas($class); + + $propsMetadataByName = []; + foreach ($propsMetadata as $propMetadata) { + $propsMetadataByName[$propMetadata->getName()] = $propMetadata; + } + + $this->assertEquals([ + 'parameters' => [ + 'prop1' => ['property' => 'prop1', 'type' => 'string', 'collectionType' => null], + ], + ], $propsMetadataByName['prop1']->getQueryStringMapping()); + + $this->assertEquals([ + 'parameters' => [ + 'prop2' => ['property' => 'prop2', 'type' => 'int', 'collectionType' => null], + ], + ], $propsMetadataByName['prop2']->getQueryStringMapping()); + + $this->assertEquals([ + 'parameters' => [ + 'prop3' => ['property' => 'prop3', 'type' => 'array', 'collectionType' => null], + ], + ], $propsMetadataByName['prop3']->getQueryStringMapping()); + + $this->assertEquals([], $propsMetadataByName['prop4']->getQueryStringMapping()); + } +} diff --git a/src/LiveComponent/tests/Unit/Util/LiveAttributesCollectionTest.php b/src/LiveComponent/tests/Unit/Util/LiveAttributesCollectionTest.php index 84ec68a19a3..534f5f36fc2 100644 --- a/src/LiveComponent/tests/Unit/Util/LiveAttributesCollectionTest.php +++ b/src/LiveComponent/tests/Unit/Util/LiveAttributesCollectionTest.php @@ -43,6 +43,10 @@ public function testToEscapedArray(): void 'componentName' => null, ], ]); + $collection->setQueryUrlMapping([ + 'foo' => ['name' => 'foo'], + 'bar' => ['name' => 'bar'], + ]); $expected = [ 'data-controller' => 'live', @@ -54,6 +58,7 @@ public function testToEscapedArray(): void 'data-live-csrf-value' => 'the-csrf-token', 'data-live-listeners-value' => '{"event_name":"theActionName"}', 'data-live-emit' => '[{"event":"event_name1","data":{"the":"data"},"target":"up","componentName":"the-component"},{"event":"event_name2","data":{"the":"data"},"target":null,"componentName":null}]', + 'data-live-query-mapping' => '{"foo":{"name":"foo"},"bar":{"name":"bar"}}', ]; $this->assertSame($expected, $collection->toEscapedArray()); diff --git a/src/LiveComponent/tests/Unit/Util/QueryStringPropsExtractorTest.php b/src/LiveComponent/tests/Unit/Util/QueryStringPropsExtractorTest.php new file mode 100644 index 00000000000..27c39475042 --- /dev/null +++ b/src/LiveComponent/tests/Unit/Util/QueryStringPropsExtractorTest.php @@ -0,0 +1,97 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Unit\Util; + +use PHPUnit\Framework\TestCase; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadata; +use Symfony\UX\LiveComponent\Metadata\LivePropMetadata; +use Symfony\UX\LiveComponent\Util\QueryStringPropsExtractor; +use Symfony\UX\TwigComponent\ComponentMetadata; + +class QueryStringPropsExtractorTest extends TestCase +{ + /** + * @dataProvider getQueryStringTests + */ + public function testExtract(string $queryString, array|string $expected) + { + $metadata = new LiveComponentMetadata(new ComponentMetadata([]), [ + new LivePropMetadata('string_prop', new LiveProp(), null, true, true, null, + queryStringMapping: ['parameters' => [ + 'string_prop' => [ + 'property' => 'string_prop', + 'type' => 'string', + ], + ]], + ), + new LivePropMetadata('int_prop', new LiveProp(), null, true, true, null, + queryStringMapping: ['parameters' => [ + 'int_prop' => [ + 'property' => 'int_prop', + 'type' => 'int', + ], + ]], + ), + new LivePropMetadata('array_prop', new LiveProp(), null, true, true, null, + queryStringMapping: ['parameters' => [ + 'array_prop' => [ + 'property' => 'array_prop', + 'type' => 'array', + ], + ]], + ), + new LivePropMetadata('object_prop', new LiveProp(), null, false, true, null, + queryStringMapping: ['parameters' => [ + 'object_prop_foo' => [ + 'property' => 'object_prop.foo', + 'type' => 'string', + ], + 'object_prop_bar' => [ + 'property' => 'object_prop.bar', + 'type' => 'int', + ], + ]], + ), + new LivePropMetadata('invalid_prop', new LiveProp(), null, false, true, null, + queryStringMapping: ['parameters' => [ + 'invalid_prop' => [ + 'property' => 'invalid_prop', + 'type' => 'object', // Object type is invalid + ], + ]], + ), + ]); + + $extractor = new QueryStringPropsExtractor(); + + if (\is_string($expected)) { + $this->expectException($expected); + } + + $data = $extractor->extract($queryString, $metadata); + + $this->assertEquals($expected, $data); + } + + public function getQueryStringTests(): iterable + { + yield from [ + ['', []], + ['string_prop=foo', ['string_prop' => 'foo']], + ['int_prop=42', ['int_prop' => 42]], + ['array_prop[]=foo&array_prop[]=bar', ['array_prop' => ['foo', 'bar']]], + ['object_prop_foo=bar&object_prop_bar=42', ['object_prop.foo' => 'bar', 'object_prop.bar' => 42]], + ['invalid_prop={"foo": "bar}', \LogicException::class], + ]; + } +}