diff --git a/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts b/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts index a51a6448707..b6fef064a7d 100644 --- a/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts +++ b/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts @@ -1,6 +1,8 @@ export default class { response: Response; private body; + private liveUrl; constructor(response: Response); getBody(): Promise; + getLiveUrl(): Promise; } diff --git a/src/LiveComponent/assets/dist/Component/plugins/QueryStringPlugin.d.ts b/src/LiveComponent/assets/dist/Component/plugins/QueryStringPlugin.d.ts deleted file mode 100644 index f91f5e6c871..00000000000 --- a/src/LiveComponent/assets/dist/Component/plugins/QueryStringPlugin.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type Component from '../index'; -import type { PluginInterface } from './PluginInterface'; -interface QueryMapping { - name: string; -} -export default class implements PluginInterface { - private readonly mapping; - constructor(mapping: { - [p: string]: QueryMapping; - }); - attachToComponent(component: Component): void; -} -export {}; diff --git a/src/LiveComponent/assets/dist/live_controller.d.ts b/src/LiveComponent/assets/dist/live_controller.d.ts index 7e5cff52474..21a6b186ce8 100644 --- a/src/LiveComponent/assets/dist/live_controller.d.ts +++ b/src/LiveComponent/assets/dist/live_controller.d.ts @@ -49,10 +49,6 @@ export default class LiveControllerDefault extends Controller imple type: StringConstructor; default: string; }; - queryMapping: { - type: ObjectConstructor; - default: {}; - }; }; readonly nameValue: string; readonly urlValue: string; @@ -76,11 +72,6 @@ export default class LiveControllerDefault extends Controller imple readonly debounceValue: number; readonly fingerprintValue: string; readonly requestMethodValue: 'get' | 'post'; - readonly queryMappingValue: { - [p: string]: { - name: string; - }; - }; private proxiedComponent; private mutationObserver; component: Component; diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 41177512fdd..49497a9a0de 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -33,6 +33,7 @@ class RequestBuilder { fetchOptions.headers = { Accept: 'application/vnd.live-component+html', 'X-Requested-With': 'XMLHttpRequest', + 'X-Live-Url': window.location.pathname + window.location.search, }; const totalFiles = Object.entries(files).reduce((total, current) => total + current.length, 0); const hasFingerprints = Object.keys(children).length > 0; @@ -111,6 +112,12 @@ class BackendResponse { } return this.body; } + async getLiveUrl() { + if (undefined === this.liveUrl) { + this.liveUrl = await this.response.headers.get('X-Live-Url'); + } + return this.liveUrl; + } } function getElementAsTagText(element) { @@ -1790,6 +1797,110 @@ function executeMorphdom(rootFromElement, rootToElement, modifiedFieldElements, }); } +function isValueEmpty(value) { + if (null === value || value === '' || undefined === value || (Array.isArray(value) && value.length === 0)) { + return true; + } + if (typeof value !== 'object') { + return false; + } + for (const key of Object.keys(value)) { + if (!isValueEmpty(value[key])) { + return false; + } + } + return true; +} +function toQueryString(data) { + const buildQueryStringEntries = (data, entries = {}, baseKey = '') => { + Object.entries(data).forEach(([iKey, iValue]) => { + const key = baseKey === '' ? iKey : `${baseKey}[${iKey}]`; + if ('' === baseKey && isValueEmpty(iValue)) { + entries[key] = ''; + } + else if (null !== iValue) { + if (typeof iValue === 'object') { + entries = { ...entries, ...buildQueryStringEntries(iValue, entries, key) }; + } + else { + entries[key] = encodeURIComponent(iValue) + .replace(/%20/g, '+') + .replace(/%2C/g, ','); + } + } + }); + return entries; + }; + const entries = buildQueryStringEntries(data); + return Object.entries(entries) + .map(([key, value]) => `${key}=${value}`) + .join('&'); +} +function fromQueryString(search) { + search = search.replace('?', ''); + if (search === '') + return {}; + const insertDotNotatedValueIntoData = (key, value, data) => { + const [first, second, ...rest] = key.split('.'); + if (!second) { + data[key] = value; + return value; + } + if (data[first] === undefined) { + data[first] = Number.isNaN(Number.parseInt(second)) ? {} : []; + } + insertDotNotatedValueIntoData([second, ...rest].join('.'), value, data[first]); + }; + const entries = search.split('&').map((i) => i.split('=')); + const data = {}; + entries.forEach(([key, value]) => { + value = decodeURIComponent(value.replace(/\+/g, '%20')); + if (!key.includes('[')) { + data[key] = value; + } + else { + if ('' === value) + return; + const dotNotatedKey = key.replace(/\[/g, '.').replace(/]/g, ''); + insertDotNotatedValueIntoData(dotNotatedKey, value, data); + } + }); + return data; +} +class UrlUtils extends URL { + has(key) { + const data = this.getData(); + return Object.keys(data).includes(key); + } + set(key, value) { + const data = this.getData(); + data[key] = value; + this.setData(data); + } + get(key) { + return this.getData()[key]; + } + remove(key) { + const data = this.getData(); + delete data[key]; + this.setData(data); + } + getData() { + if (!this.search) { + return {}; + } + return fromQueryString(this.search); + } + setData(data) { + this.search = toQueryString(data); + } +} +class HistoryStrategy { + static replace(url) { + history.replaceState(history.state, '', url); + } +} + class UnsyncedInputsTracker { constructor(component, modelElementResolver) { this.elementEventListeners = [ @@ -2137,6 +2248,10 @@ class Component { return response; } this.processRerender(html, backendResponse); + const liveUrl = await backendResponse.getLiveUrl(); + if (liveUrl) { + HistoryStrategy.replace(new UrlUtils(liveUrl + window.location.hash, window.location.origin)); + } this.backendRequest = null; thisPromiseResolve(backendResponse); if (this.isRequestPending) { @@ -2741,129 +2856,6 @@ class PollingPlugin { } } -function isValueEmpty(value) { - if (null === value || value === '' || undefined === value || (Array.isArray(value) && value.length === 0)) { - return true; - } - if (typeof value !== 'object') { - return false; - } - for (const key of Object.keys(value)) { - if (!isValueEmpty(value[key])) { - return false; - } - } - return true; -} -function toQueryString(data) { - const buildQueryStringEntries = (data, entries = {}, baseKey = '') => { - Object.entries(data).forEach(([iKey, iValue]) => { - const key = baseKey === '' ? iKey : `${baseKey}[${iKey}]`; - if ('' === baseKey && isValueEmpty(iValue)) { - entries[key] = ''; - } - else if (null !== iValue) { - if (typeof iValue === 'object') { - entries = { ...entries, ...buildQueryStringEntries(iValue, entries, key) }; - } - else { - entries[key] = encodeURIComponent(iValue) - .replace(/%20/g, '+') - .replace(/%2C/g, ','); - } - } - }); - return entries; - }; - const entries = buildQueryStringEntries(data); - return Object.entries(entries) - .map(([key, value]) => `${key}=${value}`) - .join('&'); -} -function fromQueryString(search) { - search = search.replace('?', ''); - if (search === '') - return {}; - const insertDotNotatedValueIntoData = (key, value, data) => { - const [first, second, ...rest] = key.split('.'); - if (!second) { - data[key] = value; - return value; - } - if (data[first] === undefined) { - data[first] = Number.isNaN(Number.parseInt(second)) ? {} : []; - } - insertDotNotatedValueIntoData([second, ...rest].join('.'), value, data[first]); - }; - const entries = search.split('&').map((i) => i.split('=')); - const data = {}; - entries.forEach(([key, value]) => { - value = decodeURIComponent(value.replace(/\+/g, '%20')); - if (!key.includes('[')) { - data[key] = value; - } - else { - if ('' === value) - return; - const dotNotatedKey = key.replace(/\[/g, '.').replace(/]/g, ''); - insertDotNotatedValueIntoData(dotNotatedKey, value, data); - } - }); - return data; -} -class UrlUtils extends URL { - has(key) { - const data = this.getData(); - return Object.keys(data).includes(key); - } - set(key, value) { - const data = this.getData(); - data[key] = value; - this.setData(data); - } - get(key) { - return this.getData()[key]; - } - remove(key) { - const data = this.getData(); - delete data[key]; - this.setData(data); - } - getData() { - if (!this.search) { - return {}; - } - return fromQueryString(this.search); - } - setData(data) { - this.search = toQueryString(data); - } -} -class HistoryStrategy { - static replace(url) { - history.replaceState(history.state, '', url); - } -} - -class QueryStringPlugin { - constructor(mapping) { - this.mapping = mapping; - } - attachToComponent(component) { - component.on('render:finished', (component) => { - const urlUtils = new UrlUtils(window.location.href); - const currentUrl = urlUtils.toString(); - Object.entries(this.mapping).forEach(([prop, mapping]) => { - const value = component.valueStore.get(prop); - urlUtils.set(mapping.name, value); - }); - if (currentUrl !== urlUtils.toString()) { - HistoryStrategy.replace(urlUtils); - } - }); - } -} - class SetValueOntoModelFieldsPlugin { attachToComponent(component) { this.synchronizeValueOfModelFields(component); @@ -3073,7 +3065,6 @@ class LiveControllerDefault extends Controller { new PageUnloadingPlugin(), new PollingPlugin(), new SetValueOntoModelFieldsPlugin(), - new QueryStringPlugin(this.queryMappingValue), new ChildComponentPlugin(this.component), ]; plugins.forEach((plugin) => { @@ -3183,7 +3174,6 @@ LiveControllerDefault.values = { debounce: { type: Number, default: 150 }, fingerprint: { type: String, default: '' }, requestMethod: { type: String, default: 'post' }, - queryMapping: { type: Object, default: {} }, }; LiveControllerDefault.backendFactory = (controller) => new Backend(controller.urlValue, controller.requestMethodValue); diff --git a/src/LiveComponent/assets/src/Backend/BackendResponse.ts b/src/LiveComponent/assets/src/Backend/BackendResponse.ts index 5b1357bd24e..afd963d2e02 100644 --- a/src/LiveComponent/assets/src/Backend/BackendResponse.ts +++ b/src/LiveComponent/assets/src/Backend/BackendResponse.ts @@ -1,6 +1,7 @@ export default class { response: Response; private body: string; + private liveUrl: string | null; constructor(response: Response) { this.response = response; @@ -13,4 +14,12 @@ export default class { return this.body; } + + async getLiveUrl(): Promise { + if (undefined === this.liveUrl) { + this.liveUrl = await this.response.headers.get('X-Live-Url'); + } + + return this.liveUrl; + } } diff --git a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts index 533e34fece9..12311b6d64a 100644 --- a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts +++ b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts @@ -26,6 +26,7 @@ export default class { fetchOptions.headers = { Accept: 'application/vnd.live-component+html', 'X-Requested-With': 'XMLHttpRequest', + 'X-Live-Url': window.location.pathname + window.location.search, }; const totalFiles = Object.entries(files).reduce((total, current) => total + current.length, 0); diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index 7db1f564a7b..24ff8d8ec2d 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -7,6 +7,7 @@ import ExternalMutationTracker from '../Rendering/ExternalMutationTracker'; import { elementBelongsToThisComponent, getValueFromElement, htmlToElement } from '../dom_utils'; import { executeMorphdom } from '../morphdom'; import { normalizeModelName } from '../string_utils'; +import { HistoryStrategy, UrlUtils } from "../url_utils"; import type { ElementDriver } from './ElementDriver'; import UnsyncedInputsTracker from './UnsyncedInputsTracker'; import ValueStore from './ValueStore'; @@ -328,6 +329,10 @@ export default class Component { } this.processRerender(html, backendResponse); + const liveUrl = await backendResponse.getLiveUrl(); + if (liveUrl) { + HistoryStrategy.replace(new UrlUtils(liveUrl + window.location.hash, window.location.origin)); + } // finally resolve this promise this.backendRequest = null; diff --git a/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts b/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts deleted file mode 100644 index c0ac2f08849..00000000000 --- a/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { HistoryStrategy, UrlUtils } from '../../url_utils'; -import type Component from '../index'; -import type { PluginInterface } from './PluginInterface'; - -interface QueryMapping { - /** - * URL parameter name - */ - name: string; -} - -export default class implements PluginInterface { - constructor(private readonly mapping: { [p: string]: QueryMapping }) {} - - attachToComponent(component: Component): void { - component.on('render:finished', (component: Component) => { - const urlUtils = new UrlUtils(window.location.href); - const currentUrl = urlUtils.toString(); - - Object.entries(this.mapping).forEach(([prop, mapping]) => { - const value = component.valueStore.get(prop); - urlUtils.set(mapping.name, value); - }); - - // Only update URL if it has changed - if (currentUrl !== urlUtils.toString()) { - HistoryStrategy.replace(urlUtils); - } - }); - } -} diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts index a9ea7f115ee..f0638451704 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -8,7 +8,6 @@ import LoadingPlugin from './Component/plugins/LoadingPlugin'; import PageUnloadingPlugin from './Component/plugins/PageUnloadingPlugin'; import type { PluginInterface } from './Component/plugins/PluginInterface'; import PollingPlugin from './Component/plugins/PollingPlugin'; -import QueryStringPlugin from './Component/plugins/QueryStringPlugin'; import SetValueOntoModelFieldsPlugin from './Component/plugins/SetValueOntoModelFieldsPlugin'; import ValidatedFieldsPlugin from './Component/plugins/ValidatedFieldsPlugin'; import { type DirectiveModifier, parseDirectives } from './Directive/directives_parser'; @@ -42,7 +41,6 @@ export default class LiveControllerDefault extends Controller imple debounce: { type: Number, default: 150 }, fingerprint: { type: String, default: '' }, requestMethod: { type: String, default: 'post' }, - queryMapping: { type: Object, default: {} }, }; declare readonly nameValue: string; @@ -61,7 +59,6 @@ export default class LiveControllerDefault extends Controller imple declare readonly debounceValue: number; declare readonly fingerprintValue: string; declare readonly requestMethodValue: 'get' | 'post'; - declare readonly queryMappingValue: { [p: string]: { name: string } }; /** The component, wrapped in the convenience Proxy */ private proxiedComponent: Component; @@ -301,7 +298,6 @@ export default class LiveControllerDefault extends Controller imple new PageUnloadingPlugin(), new PollingPlugin(), new SetValueOntoModelFieldsPlugin(), - new QueryStringPlugin(this.queryMappingValue), new ChildComponentPlugin(this.component), ]; plugins.forEach((plugin) => { diff --git a/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts b/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts index 44521271e80..c61840fde2d 100644 --- a/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts +++ b/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts @@ -18,6 +18,7 @@ describe('buildRequest', () => { expect(fetchOptions.method).toEqual('GET'); expect(fetchOptions.headers).toEqual({ Accept: 'application/vnd.live-component+html', + "X-Live-Url": "/", 'X-Requested-With': 'XMLHttpRequest', }); }); @@ -42,6 +43,7 @@ describe('buildRequest', () => { expect(fetchOptions.method).toEqual('POST'); expect(fetchOptions.headers).toEqual({ Accept: 'application/vnd.live-component+html', + "X-Live-Url": "/", 'X-Requested-With': 'XMLHttpRequest', }); const body = fetchOptions.body; @@ -115,6 +117,7 @@ describe('buildRequest', () => { expect(fetchOptions.headers).toEqual({ // no token Accept: 'application/vnd.live-component+html', + "X-Live-Url": "/", 'X-Requested-With': 'XMLHttpRequest', }); const body = fetchOptions.body; @@ -145,6 +148,7 @@ describe('buildRequest', () => { expect(fetchOptions.headers).toEqual({ // no token Accept: 'application/vnd.live-component+html', + "X-Live-Url": "/", 'X-Requested-With': 'XMLHttpRequest', }); const body = fetchOptions.body; @@ -230,6 +234,7 @@ describe('buildRequest', () => { expect(fetchOptions.method).toEqual('POST'); expect(fetchOptions.headers).toEqual({ Accept: 'application/vnd.live-component+html', + "X-Live-Url": "/", 'X-Requested-With': 'XMLHttpRequest', }); const body = fetchOptions.body; @@ -254,6 +259,7 @@ describe('buildRequest', () => { expect(fetchOptions.method).toEqual('POST'); expect(fetchOptions.headers).toEqual({ Accept: 'application/vnd.live-component+html', + "X-Live-Url": "/", 'X-Requested-With': 'XMLHttpRequest', }); const body = fetchOptions.body; diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index dc04bfb10fa..56891eb3a73 100644 --- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php +++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php @@ -33,6 +33,7 @@ use Symfony\UX\LiveComponent\EventListener\DeferLiveComponentSubscriber; use Symfony\UX\LiveComponent\EventListener\InterceptChildComponentRenderSubscriber; use Symfony\UX\LiveComponent\EventListener\LiveComponentSubscriber; +use Symfony\UX\LiveComponent\EventListener\LiveUrlSubscriber; use Symfony\UX\LiveComponent\EventListener\QueryStringInitializeSubscriber; use Symfony\UX\LiveComponent\EventListener\ResetDeterministicIdSubscriber; use Symfony\UX\LiveComponent\Form\Type\LiveCollectionType; @@ -50,8 +51,9 @@ 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\LiveComponent\Util\UrlFactory; +use Symfony\UX\LiveComponent\Util\UrlPropsExtractor; use Symfony\UX\TwigComponent\ComponentFactory; use Symfony\UX\TwigComponent\ComponentRenderer; @@ -135,6 +137,14 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) { ->addTag('container.service_subscriber', ['key' => LiveComponentMetadataFactory::class, 'id' => 'ux.live_component.metadata_factory']) ; + $container->register('ux.live_component.live_url_subscriber', LiveUrlSubscriber::class) + ->setArguments([ + new Reference('ux.live_component.metadata_factory'), + new Reference('ux.live_component.url_factory'), + ]) + ->addTag('kernel.event_subscriber') + ; + $container->register('ux.live_component.live_responder', LiveResponder::class); $container->setAlias(LiveResponder::class, 'ux.live_component.live_responder'); @@ -200,6 +210,9 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) { $container->register('ux.live_component.attribute_helper_factory', TwigAttributeHelperFactory::class) ->setArguments([new Reference('twig')]); + $container->register('ux.live_component.url_factory', UrlFactory::class) + ->setArguments([new Reference('router')]); + $container->register('ux.live_component.live_controller_attributes_creator', LiveControllerAttributesCreator::class) ->setArguments([ new Reference('ux.live_component.metadata_factory'), @@ -222,7 +235,7 @@ 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_props_extractor', UrlPropsExtractor::class) ->setArguments([ new Reference('ux.live_component.component_hydrator'), ]); diff --git a/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php new file mode 100644 index 00000000000..c2fb6703236 --- /dev/null +++ b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php @@ -0,0 +1,89 @@ + + * + * 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\Request; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Routing\RouterInterface; +use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory; +use Symfony\UX\LiveComponent\Util\UrlFactory; + +class LiveUrlSubscriber implements EventSubscriberInterface +{ + private const URL_HEADER = 'X-Live-Url'; + + public function __construct( + private LiveComponentMetadataFactory $metadataFactory, + private UrlFactory $urlFactory + ) { + } + + public function onKernelResponse(ResponseEvent $event): void + { + $request = $event->getRequest(); + if (!$request->attributes->has('_live_component')) { + return; + } + if (!$event->isMainRequest()) { + return; + } + + if ($previousLocation = $request->headers->get(self::URL_HEADER)) { + $liveProps = $this->getLivePropsToMap($request); + $newUrl = $this->urlFactory->createFromPreviousAndProps( + $previousLocation, + $liveProps['path'], + $liveProps['query'] + ); + if ($newUrl) { + $event->getResponse()->headers->set( + self::URL_HEADER, + $newUrl + ); + } + } + } + + public static function getSubscribedEvents(): array + { + return [ + KernelEvents::RESPONSE => 'onKernelResponse', + ]; + } + + private function getLivePropsToMap(Request $request): array + { + $componentName = $request->attributes->get('_live_component'); + $component = $request->attributes->get('_mounted_component'); + $metadata = $this->metadataFactory->getMetadata($componentName); + + $liveData = $request->attributes->get('_live_request_data') ?? []; + $values = array_merge($liveData['props'] ?? [], $liveData['updated'] ?? []); + + $urlLiveProps = [ + 'path' => [], + 'query' => [], + ]; + foreach ($metadata->getAllLivePropsMetadata($component) as $liveProp) { + $name = $liveProp->getName(); + $urlMapping = $liveProp->urlMapping(); + if (isset($values[$name]) && $urlMapping) { + $urlLiveProps[$urlMapping->mapPath ? 'path' : 'query'][$urlMapping->as ?? $name] = + $values[$name]; + } + } + + return $urlLiveProps; + } +} diff --git a/src/LiveComponent/src/EventListener/QueryStringInitializeSubscriber.php b/src/LiveComponent/src/EventListener/QueryStringInitializeSubscriber.php index 9dc80577f7a..a49d6d2cb5e 100644 --- a/src/LiveComponent/src/EventListener/QueryStringInitializeSubscriber.php +++ b/src/LiveComponent/src/EventListener/QueryStringInitializeSubscriber.php @@ -16,7 +16,7 @@ use Symfony\Component\PropertyAccess\Exception\ExceptionInterface as PropertyAccessExceptionInterface; use Symfony\Component\PropertyAccess\PropertyAccessorInterface; use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory; -use Symfony\UX\LiveComponent\Util\QueryStringPropsExtractor; +use Symfony\UX\LiveComponent\Util\UrlPropsExtractor; use Symfony\UX\TwigComponent\Event\PostMountEvent; /** @@ -29,7 +29,7 @@ class QueryStringInitializeSubscriber implements EventSubscriberInterface public function __construct( private readonly RequestStack $requestStack, private readonly LiveComponentMetadataFactory $metadataFactory, - private readonly QueryStringPropsExtractor $queryStringPropsExtractor, + private readonly UrlPropsExtractor $queryStringPropsExtractor, private readonly PropertyAccessorInterface $propertyAccessor, ) { } diff --git a/src/LiveComponent/src/Metadata/UrlMapping.php b/src/LiveComponent/src/Metadata/UrlMapping.php index be7fd86195e..24150156c9b 100644 --- a/src/LiveComponent/src/Metadata/UrlMapping.php +++ b/src/LiveComponent/src/Metadata/UrlMapping.php @@ -12,7 +12,7 @@ namespace Symfony\UX\LiveComponent\Metadata; /** - * Mapping configuration to bind a LiveProp to a URL query parameter. + * Mapping configuration to bind a LiveProp to a URL path or query parameter. * * @author Nicolas Rigaud */ @@ -23,6 +23,11 @@ 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, + + /** + * True if the prop should be mapped to the path if it matches one of its parameters. Otherwise a query parameter will be used. + */ + public readonly bool $mapPath = false, ) { } } diff --git a/src/LiveComponent/src/Util/UrlFactory.php b/src/LiveComponent/src/Util/UrlFactory.php new file mode 100644 index 00000000000..bb4b33180fb --- /dev/null +++ b/src/LiveComponent/src/Util/UrlFactory.php @@ -0,0 +1,77 @@ +createPath($previousUrl, $pathMappedProps); + + return $this->replaceQueryString( + $newUrl, + array_merge( + $this->getPreviousQueryParameters($parsed['query'] ?? ''), + $this->getRemnantProps($newUrl), + $queryMappedProps, + ) + ); + } + + private function createPath(string $previousUrl, array $props): string + { + return $this->router->generate( + $this->router->match($previousUrl)['_route'], + $props + ); + } + + + private function replaceQueryString($url, array $props): string + { + $queryString = http_build_query($props); + + return preg_replace('/[?#].*/', '', $url). + ('' !== $queryString ? '?' : ''). + $queryString; + } + + // Keep the query parameters of the previous request + private function getPreviousQueryParameters(string $query): array + { + parse_str($query, $previousQueryParams); + + return $previousQueryParams; + } + + // Symfony router will set props in query if they do not match route parameter + private function getRemnantProps(string $newUrl): array + { + parse_str(parse_url($newUrl)['query'] ?? '', $remnantQueryParams); + + return $remnantQueryParams; + } +} diff --git a/src/LiveComponent/src/Util/QueryStringPropsExtractor.php b/src/LiveComponent/src/Util/UrlPropsExtractor.php similarity index 86% rename from src/LiveComponent/src/Util/QueryStringPropsExtractor.php rename to src/LiveComponent/src/Util/UrlPropsExtractor.php index 48e852d70ba..68859440283 100644 --- a/src/LiveComponent/src/Util/QueryStringPropsExtractor.php +++ b/src/LiveComponent/src/Util/UrlPropsExtractor.php @@ -22,20 +22,21 @@ * * @internal */ -final class QueryStringPropsExtractor +final class UrlPropsExtractor { public function __construct(private readonly LiveComponentHydrator $hydrator) { } /** - * Extracts relevant query parameters from the current URL and hydrates them. + * Extracts relevant props parameters from the current URL and hydrates them. */ public function extract(Request $request, LiveComponentMetadata $metadata, object $component): array { - $query = $request->query->all(); + $parameters = array_merge($request->attributes->all(), $request->query->all()); - if (empty($query)) { + // @todo never empty because custom values prefixed with _ ... do something ? + if (empty($parameters)) { return []; } $data = []; @@ -43,7 +44,7 @@ public function extract(Request $request, LiveComponentMetadata $metadata, objec foreach ($metadata->getAllLivePropsMetadata($component) as $livePropMetadata) { if ($queryMapping = $livePropMetadata->urlMapping()) { $frontendName = $livePropMetadata->calculateFieldName($component, $livePropMetadata->getName()); - if (null !== ($value = $query[$queryMapping->as ?? $frontendName] ?? null)) { + if (null !== ($value = $parameters[$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 = []; diff --git a/src/LiveComponent/tests/Functional/Util/QueryStringPropsExtractorTest.php b/src/LiveComponent/tests/Functional/Util/UrlPropsExtractorTest.php similarity index 93% rename from src/LiveComponent/tests/Functional/Util/QueryStringPropsExtractorTest.php rename to src/LiveComponent/tests/Functional/Util/UrlPropsExtractorTest.php index cabfb98e406..b2bac748cb8 100644 --- a/src/LiveComponent/tests/Functional/Util/QueryStringPropsExtractorTest.php +++ b/src/LiveComponent/tests/Functional/Util/UrlPropsExtractorTest.php @@ -16,9 +16,9 @@ use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory; use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\Address; use Symfony\UX\LiveComponent\Tests\LiveComponentTestHelper; -use Symfony\UX\LiveComponent\Util\QueryStringPropsExtractor; +use Symfony\UX\LiveComponent\Util\UrlPropsExtractor; -class QueryStringPropsExtractorTest extends KernelTestCase +class UrlPropsExtractorTest extends KernelTestCase { use LiveComponentTestHelper; @@ -27,7 +27,7 @@ class QueryStringPropsExtractorTest extends KernelTestCase */ public function testExtract(string $queryString, array $expected) { - $extractor = new QueryStringPropsExtractor($this->hydrator()); + $extractor = new UrlPropsExtractor($this->hydrator()); $request = Request::create('/'.!empty($queryString) ? '?'.$queryString : '');