From 1c257731e6881134a38bafabf69a990ec5a89192 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Tue, 1 Apr 2025 20:27:48 +0200 Subject: [PATCH 01/18] [WIP] LiveUrl --- .../assets/dist/Backend/BackendResponse.d.ts | 2 + .../assets/dist/live_controller.js | 11 ++ .../assets/src/Backend/BackendResponse.ts | 9 ++ .../assets/src/Backend/RequestBuilder.ts | 1 + .../assets/src/Component/index.ts | 5 + .../LiveComponentExtension.php | 9 ++ .../src/EventListener/LiveUrlSubscriber.php | 133 ++++++++++++++++++ 7 files changed, 170 insertions(+) create mode 100644 src/LiveComponent/src/EventListener/LiveUrlSubscriber.php 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/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 4902665e1de..69bb1d60358 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.href }; 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) { @@ -2137,6 +2144,10 @@ class Component { return response; } this.processRerender(html, backendResponse); + const liveUrl = await backendResponse.getLiveUrl(); + if (liveUrl) { + HistoryStrategy.replace(new UrlUtils(liveUrl)); + } this.backendRequest = null; thisPromiseResolve(backendResponse); if (this.isRequestPending) { 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..6bd05769ab8 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.href }; 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 b0be5f7b384..8a2949b9fc5 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -11,6 +11,7 @@ import type { ElementDriver } from './ElementDriver'; import type { PluginInterface } from './plugins/PluginInterface'; import UnsyncedInputsTracker from './UnsyncedInputsTracker'; import ValueStore from './ValueStore'; +import {HistoryStrategy, UrlUtils} from "../url_utils"; declare const Turbo: any; @@ -328,6 +329,10 @@ export default class Component { } this.processRerender(html, backendResponse); + const liveUrl = await backendResponse.getLiveUrl(); + if (liveUrl) { + HistoryStrategy.replace(new UrlUtils(liveUrl)); + } // finally resolve this promise this.backendRequest = null; diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index 5fedbf522a0..910177ca6f4 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; @@ -136,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('router'), + new Reference('ux.live_component.metadata_factory'), + ]) + ->addTag('kernel.event_subscriber') + ; + $container->register('ux.live_component.live_responder', LiveResponder::class); $container->setAlias(LiveResponder::class, 'ux.live_component.live_responder'); diff --git a/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php new file mode 100644 index 00000000000..cb4a94bcf81 --- /dev/null +++ b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php @@ -0,0 +1,133 @@ + + * + * 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; + +class LiveUrlSubscriber implements EventSubscriberInterface +{ + private const URL_HEADER = 'X-Live-Url'; + + public function __construct( + private readonly RouterInterface $router, + private readonly LiveComponentMetadataFactory $metadataFactory, + ) { + } + + public function onKernelResponse(ResponseEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + if (!$this->isLiveComponentRequest($request = $event->getRequest())) { + return; + } + + if ($previousLocation = $request->headers->get(self::URL_HEADER)) { + $newUrl = $this->computeNewUrl( + $previousLocation, + $this->getLivePropsToMap($request) + ); + 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 = []; + foreach ($metadata->getAllLivePropsMetadata($component) as $liveProp) { + $name = $liveProp->getName(); + $urlMapping = $liveProp->urlMapping(); + if (isset($values[$name]) && $urlMapping) { + $urlLiveProps[$urlMapping->as ?? $name] = $values[$name]; + } + } + + return $urlLiveProps; + } + + // @todo use requestStack ? + private function computeNewUrl(string $previousUrl, array $newProps): string + { + $parsed = parse_url($previousUrl); + $baseUrl = $parsed['scheme'].'://'; + if (isset($parsed['user'])) { + $baseUrl .= $parsed['user']; + if (isset($parsed['pass'])) { + $baseUrl .= ':'.$parsed['pass']; + } + $baseUrl .= '@'; + } + $baseUrl .= $parsed['host']; + if (isset($parsed['port'])) { + $baseUrl .= ':'.$parsed['port']; + } + + $path = $parsed['path'] ?? ''; + if (isset($parsed['query'])) { + $path .= '?'.$parsed['query']; + } + parse_str($parsed['query'] ?? '', $previousParams); + + $match = $this->router->match($path); + $newUrl = $this->router->generate( + $match['_route'], + array_merge($previousParams, $newProps) + ); + + $fragment = $parsed['fragment'] ?? ''; + + return $baseUrl.$newUrl.$fragment; + } + + /** + * copied from LiveComponentSubscriber. + */ + private function isLiveComponentRequest(Request $request): bool + { + if (!$request->attributes->has('_live_component')) { + return false; + } + + // if ($this->testMode) { + // return true; + // } + + // Except when testing, require the correct content-type in the Accept header. + // This also acts as a CSRF protection since this can only be set in accordance with same-origin/CORS policies. + return \in_array('application/vnd.live-component+html', $request->getAcceptableContentTypes(), true); + } +} From 59614019441ba03d0831f3c69dda4f1612f7b8f3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Sun, 6 Apr 2025 09:00:00 +0200 Subject: [PATCH 02/18] LiveUrlSubscriber should handle only path and query --- .../assets/dist/live_controller.js | 212 +++++++++--------- .../assets/src/Backend/RequestBuilder.ts | 2 +- .../assets/src/Component/index.ts | 2 +- .../Component/plugins/QueryStringPlugin.ts | 1 + .../src/EventListener/LiveUrlSubscriber.php | 30 +-- 5 files changed, 115 insertions(+), 132 deletions(-) diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 69bb1d60358..377e2fc68e3 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -33,7 +33,7 @@ class RequestBuilder { fetchOptions.headers = { Accept: 'application/vnd.live-component+html', 'X-Requested-With': 'XMLHttpRequest', - 'X-Live-Url': window.location.href + '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; @@ -1975,6 +1975,110 @@ class ValueStore { } } +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(String(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 Component { constructor(element, name, props, listeners, id, backend, elementDriver) { this.fingerprint = ''; @@ -2146,7 +2250,7 @@ class Component { this.processRerender(html, backendResponse); const liveUrl = await backendResponse.getLiveUrl(); if (liveUrl) { - HistoryStrategy.replace(new UrlUtils(liveUrl)); + HistoryStrategy.replace(new UrlUtils(liveUrl + window.location.hash, window.location.origin)); } this.backendRequest = null; thisPromiseResolve(backendResponse); @@ -2752,110 +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(String(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; diff --git a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts index 6bd05769ab8..2792d7fad93 100644 --- a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts +++ b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts @@ -26,7 +26,7 @@ export default class { fetchOptions.headers = { Accept: 'application/vnd.live-component+html', 'X-Requested-With': 'XMLHttpRequest', - 'X-Live-Url' : window.location.href + '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 8a2949b9fc5..e7be3e380b5 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -331,7 +331,7 @@ export default class Component { this.processRerender(html, backendResponse); const liveUrl = await backendResponse.getLiveUrl(); if (liveUrl) { - HistoryStrategy.replace(new UrlUtils(liveUrl)); + HistoryStrategy.replace(new UrlUtils(liveUrl + window.location.hash, window.location.origin)); } // finally resolve this promise diff --git a/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts b/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts index c0ac2f08849..26ae489c35d 100644 --- a/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts +++ b/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts @@ -12,6 +12,7 @@ interface QueryMapping { export default class implements PluginInterface { constructor(private readonly mapping: { [p: string]: QueryMapping }) {} + //@todo delete attachToComponent(component: Component): void { component.on('render:finished', (component: Component) => { const urlUtils = new UrlUtils(window.location.href); diff --git a/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php index cb4a94bcf81..33b598ba35e 100644 --- a/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php @@ -79,38 +79,20 @@ private function getLivePropsToMap(Request $request): array return $urlLiveProps; } - // @todo use requestStack ? private function computeNewUrl(string $previousUrl, array $newProps): string { $parsed = parse_url($previousUrl); - $baseUrl = $parsed['scheme'].'://'; - if (isset($parsed['user'])) { - $baseUrl .= $parsed['user']; - if (isset($parsed['pass'])) { - $baseUrl .= ':'.$parsed['pass']; - } - $baseUrl .= '@'; - } - $baseUrl .= $parsed['host']; - if (isset($parsed['port'])) { - $baseUrl .= ':'.$parsed['port']; - } - $path = $parsed['path'] ?? ''; + $url = $parsed['path'] ?? ''; if (isset($parsed['query'])) { - $path .= '?'.$parsed['query']; + $url .= '?'.$parsed['query']; } - parse_str($parsed['query'] ?? '', $previousParams); + parse_str($parsed['query'] ?? '', $previousQueryParams); - $match = $this->router->match($path); - $newUrl = $this->router->generate( - $match['_route'], - array_merge($previousParams, $newProps) + return $this->router->generate( + $this->router->match($url)['_route'], + array_merge($previousQueryParams, $newProps) ); - - $fragment = $parsed['fragment'] ?? ''; - - return $baseUrl.$newUrl.$fragment; } /** From 4acfcac8c58379df5bf699300119c81c24c612a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Sun, 6 Apr 2025 10:00:00 +0200 Subject: [PATCH 03/18] Remove unused QueryStringPlugin --- .../Component/plugins/QueryStringPlugin.d.ts | 13 - .../assets/dist/live_controller.d.ts | 9 - .../assets/dist/live_controller.js | 231 ++++++++---------- .../assets/src/Backend/RequestBuilder.ts | 2 +- .../assets/src/Component/index.ts | 2 +- .../Component/plugins/QueryStringPlugin.ts | 32 --- .../assets/src/live_controller.ts | 4 - .../test/Backend/RequestBuilder.test.ts | 6 + 8 files changed, 112 insertions(+), 187 deletions(-) delete mode 100644 src/LiveComponent/assets/dist/Component/plugins/QueryStringPlugin.d.ts delete mode 100644 src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts 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 377e2fc68e3..f8076428574 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -33,7 +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 + '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; @@ -1796,7 +1796,109 @@ class ExternalMutationTracker { return element.tagName === 'FONT' && element.getAttribute('style') === 'vertical-align: inherit;'; } } - +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(String(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 = [ @@ -1975,110 +2077,6 @@ class ValueStore { } } -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(String(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 Component { constructor(element, name, props, listeners, id, backend, elementDriver) { this.fingerprint = ''; @@ -2856,25 +2854,6 @@ class PollingPlugin { } } -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); @@ -3084,7 +3063,6 @@ class LiveControllerDefault extends Controller { new PageUnloadingPlugin(), new PollingPlugin(), new SetValueOntoModelFieldsPlugin(), - new QueryStringPlugin(this.queryMappingValue), new ChildComponentPlugin(this.component), ]; plugins.forEach((plugin) => { @@ -3194,7 +3172,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/RequestBuilder.ts b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts index 2792d7fad93..12311b6d64a 100644 --- a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts +++ b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts @@ -26,7 +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 + '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 e7be3e380b5..ecfd8b62adf 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -7,11 +7,11 @@ import HookManager from '../HookManager'; import { executeMorphdom } from '../morphdom'; import ExternalMutationTracker from '../Rendering/ExternalMutationTracker'; import { normalizeModelName } from '../string_utils'; +import { HistoryStrategy, UrlUtils } from "../url_utils"; import type { ElementDriver } from './ElementDriver'; import type { PluginInterface } from './plugins/PluginInterface'; import UnsyncedInputsTracker from './UnsyncedInputsTracker'; import ValueStore from './ValueStore'; -import {HistoryStrategy, UrlUtils} from "../url_utils"; declare const Turbo: any; 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 26ae489c35d..00000000000 --- a/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts +++ /dev/null @@ -1,32 +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 }) {} - - //@todo delete - 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 34380a8fa73..7ffccd01ff8 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..6650546f7d3 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; From 19428f6d222baf91315c37a97b3a3a23b04ff650 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Sun, 6 Apr 2025 10:23:53 +0200 Subject: [PATCH 04/18] UrlMapping.mapPath --- .../src/EventListener/LiveUrlSubscriber.php | 20 ++++++++++++++----- src/LiveComponent/src/Metadata/UrlMapping.php | 7 ++++++- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php index 33b598ba35e..9208d60e53d 100644 --- a/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php @@ -67,19 +67,23 @@ private function getLivePropsToMap(Request $request): array $liveData = $request->attributes->get('_live_request_data') ?? []; $values = array_merge($liveData['props'] ?? [], $liveData['updated'] ?? []); - $urlLiveProps = []; + $urlLiveProps = [ + 'path' => [], + 'query' => [], + ]; foreach ($metadata->getAllLivePropsMetadata($component) as $liveProp) { $name = $liveProp->getName(); $urlMapping = $liveProp->urlMapping(); if (isset($values[$name]) && $urlMapping) { - $urlLiveProps[$urlMapping->as ?? $name] = $values[$name]; + $urlLiveProps[$urlMapping->mapPath ? 'path' : 'query'][$urlMapping->as ?? $name] = + $values[$name]; } } return $urlLiveProps; } - private function computeNewUrl(string $previousUrl, array $newProps): string + private function computeNewUrl(string $previousUrl, array $livePropsToMap): string { $parsed = parse_url($previousUrl); @@ -89,10 +93,16 @@ private function computeNewUrl(string $previousUrl, array $newProps): string } parse_str($parsed['query'] ?? '', $previousQueryParams); - return $this->router->generate( + $newUrl = $this->router->generate( $this->router->match($url)['_route'], - array_merge($previousQueryParams, $newProps) + array_merge($previousQueryParams, $livePropsToMap['path']) ); + parse_str(parse_url($newUrl)['query'] ?? '', $queryParams); + $queryString = http_build_query(array_merge($queryParams, $livePropsToMap['query'])); + + return preg_replace('/[?#].*/', '', $newUrl). + ('' !== $queryString ? '?' : ''). + $queryString; } /** 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, ) { } } From 57b7746e09b6019ee7cfa657037b909f0407db0f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Sun, 6 Apr 2025 10:25:02 +0200 Subject: [PATCH 05/18] QueryStringPropsExtractor, renamed to RequestPropsExtractor shall get props also from path. RequestInitializeSubscriber also renamed --- .../DependencyInjection/LiveComponentExtension.php | 13 ++++++++----- ...bscriber.php => RequestInitializeSubscriber.php} | 10 +++++----- ...PropsExtractor.php => RequestPropsExtractor.php} | 10 +++++----- ...est.php => RequestInitializerSubscriberTest.php} | 2 +- ...ractorTest.php => RequestPropsExtractorTest.php} | 13 +++++++++---- 5 files changed, 28 insertions(+), 20 deletions(-) rename src/LiveComponent/src/EventListener/{QueryStringInitializeSubscriber.php => RequestInitializeSubscriber.php} (83%) rename src/LiveComponent/src/Util/{QueryStringPropsExtractor.php => RequestPropsExtractor.php} (92%) rename src/LiveComponent/tests/Functional/EventListener/{QueryStringInitializerSubscriberTest.php => RequestInitializerSubscriberTest.php} (95%) rename src/LiveComponent/tests/Functional/Util/{QueryStringPropsExtractorTest.php => RequestPropsExtractorTest.php} (78%) diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index 910177ca6f4..fa423b1dd31 100644 --- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php +++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php @@ -34,7 +34,7 @@ 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\RequestInitializeSubscriber; use Symfony\UX\LiveComponent\EventListener\ResetDeterministicIdSubscriber; use Symfony\UX\LiveComponent\Form\Type\LiveCollectionType; use Symfony\UX\LiveComponent\Hydration\HydrationExtensionInterface; @@ -51,7 +51,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\RequestPropsExtractor; use Symfony\UX\LiveComponent\Util\TwigAttributeHelperFactory; use Symfony\UX\TwigComponent\ComponentFactory; use Symfony\UX\TwigComponent\ComponentRenderer; @@ -139,8 +139,8 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) { $container->register('ux.live_component.live_url_subscriber', LiveUrlSubscriber::class) ->setArguments([ - new Reference('router'), new Reference('ux.live_component.metadata_factory'), + new Reference('ux.live_component.url_factory'), ]) ->addTag('kernel.event_subscriber') ; @@ -210,6 +210,9 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) { $container->register('ux.live_component.attribute_helper_factory', TwigAttributeHelperFactory::class); + $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'), @@ -233,12 +236,12 @@ 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', RequestPropsExtractor::class) ->setArguments([ new Reference('ux.live_component.component_hydrator'), ]); - $container->register('ux.live_component.query_string_initializer_subscriber', QueryStringInitializeSubscriber::class) + $container->register('ux.live_component.query_string_initializer_subscriber', RequestInitializeSubscriber::class) ->setArguments([ new Reference('request_stack'), new Reference('ux.live_component.metadata_factory'), diff --git a/src/LiveComponent/src/EventListener/QueryStringInitializeSubscriber.php b/src/LiveComponent/src/EventListener/RequestInitializeSubscriber.php similarity index 83% rename from src/LiveComponent/src/EventListener/QueryStringInitializeSubscriber.php rename to src/LiveComponent/src/EventListener/RequestInitializeSubscriber.php index 9dc80577f7a..01893f268c9 100644 --- a/src/LiveComponent/src/EventListener/QueryStringInitializeSubscriber.php +++ b/src/LiveComponent/src/EventListener/RequestInitializeSubscriber.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\RequestPropsExtractor; use Symfony\UX\TwigComponent\Event\PostMountEvent; /** @@ -24,12 +24,12 @@ * * @internal */ -class QueryStringInitializeSubscriber implements EventSubscriberInterface +class RequestInitializeSubscriber implements EventSubscriberInterface { public function __construct( private readonly RequestStack $requestStack, private readonly LiveComponentMetadataFactory $metadataFactory, - private readonly QueryStringPropsExtractor $queryStringPropsExtractor, + private readonly RequestPropsExtractor $requestPropsExtractor, private readonly PropertyAccessorInterface $propertyAccessor, ) { } @@ -60,11 +60,11 @@ public function onPostMount(PostMountEvent $event): void return; } - $queryStringData = $this->queryStringPropsExtractor->extract($request, $metadata, $event->getComponent()); + $requestData = $this->requestPropsExtractor->extract($request, $metadata, $event->getComponent()); $component = $event->getComponent(); - foreach ($queryStringData as $name => $value) { + foreach ($requestData as $name => $value) { try { $this->propertyAccessor->setValue($component, $name, $value); } catch (PropertyAccessExceptionInterface $exception) { diff --git a/src/LiveComponent/src/Util/QueryStringPropsExtractor.php b/src/LiveComponent/src/Util/RequestPropsExtractor.php similarity index 92% rename from src/LiveComponent/src/Util/QueryStringPropsExtractor.php rename to src/LiveComponent/src/Util/RequestPropsExtractor.php index e67741fd966..53f0535ace1 100644 --- a/src/LiveComponent/src/Util/QueryStringPropsExtractor.php +++ b/src/LiveComponent/src/Util/RequestPropsExtractor.php @@ -26,20 +26,20 @@ * * @internal */ -final class QueryStringPropsExtractor +final class RequestPropsExtractor { 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)) { + if (empty($parameters)) { return []; } $data = []; @@ -47,7 +47,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) { // BC layer when "symfony/type-info" is not available if ($livePropMetadata instanceof LegacyLivePropMetadata) { diff --git a/src/LiveComponent/tests/Functional/EventListener/QueryStringInitializerSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/RequestInitializerSubscriberTest.php similarity index 95% rename from src/LiveComponent/tests/Functional/EventListener/QueryStringInitializerSubscriberTest.php rename to src/LiveComponent/tests/Functional/EventListener/RequestInitializerSubscriberTest.php index aa5955c6378..856c264a820 100644 --- a/src/LiveComponent/tests/Functional/EventListener/QueryStringInitializerSubscriberTest.php +++ b/src/LiveComponent/tests/Functional/EventListener/RequestInitializerSubscriberTest.php @@ -14,7 +14,7 @@ use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; use Zenstruck\Browser\Test\HasBrowser; -class QueryStringInitializerSubscriberTest extends KernelTestCase +class RequestInitializerSubscriberTest extends KernelTestCase { use HasBrowser; diff --git a/src/LiveComponent/tests/Functional/Util/QueryStringPropsExtractorTest.php b/src/LiveComponent/tests/Functional/Util/RequestPropsExtractorTest.php similarity index 78% rename from src/LiveComponent/tests/Functional/Util/QueryStringPropsExtractorTest.php rename to src/LiveComponent/tests/Functional/Util/RequestPropsExtractorTest.php index cabfb98e406..7301ad48168 100644 --- a/src/LiveComponent/tests/Functional/Util/QueryStringPropsExtractorTest.php +++ b/src/LiveComponent/tests/Functional/Util/RequestPropsExtractorTest.php @@ -16,20 +16,21 @@ 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\RequestPropsExtractor; -class QueryStringPropsExtractorTest extends KernelTestCase +class RequestPropsExtractorTest extends KernelTestCase { use LiveComponentTestHelper; /** * @dataProvider getQueryStringTests */ - public function testExtract(string $queryString, array $expected) + public function testExtractFromQueryString(string $queryString, array $expected, array $attributes = []): void { - $extractor = new QueryStringPropsExtractor($this->hydrator()); + $extractor = new RequestPropsExtractor($this->hydrator()); $request = Request::create('/'.!empty($queryString) ? '?'.$queryString : ''); + $request->attributes->add($attributes); /** @var LiveComponentMetadataFactory $metadataFactory */ $metadataFactory = self::getContainer()->get('ux.live_component.metadata_factory'); @@ -65,6 +66,10 @@ public function getQueryStringTests(): iterable 'invalid array value' => ['arrayProp=foo', []], 'invalid object value' => ['objectProp=foo', []], 'aliased prop' => ['q=foo', ['boundPropWithAlias' => 'foo']], + 'attribute prop' => ['', ['stringProp' => 'foo'], ['stringProp' => 'foo']], + 'attribute aliased prop' => ['', ['boundPropWithAlias' => 'foo'], ['q' => 'foo']], + 'attribute not bound prop' => ['', [], ['unboundProp' => 'foo']], + 'query priority' => ['stringProp=foo', ['stringProp' => 'foo'], ['stringProp' => 'bar']], ]; } } From 242736b8255361827b7332a583b663048926cbcb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Thu, 10 Apr 2025 20:18:37 +0200 Subject: [PATCH 06/18] UrlFactory --- .../LiveComponentExtension.php | 1 + .../src/EventListener/LiveUrlSubscriber.php | 71 ++----- src/LiveComponent/src/Util/UrlFactory.php | 92 +++++++++ .../tests/Unit/Util/UrlFactoryTest.php | 179 ++++++++++++++++++ 4 files changed, 286 insertions(+), 57 deletions(-) create mode 100644 src/LiveComponent/src/Util/UrlFactory.php create mode 100644 src/LiveComponent/tests/Unit/Util/UrlFactoryTest.php diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index fa423b1dd31..43ebd06fa63 100644 --- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php +++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php @@ -53,6 +53,7 @@ use Symfony\UX\LiveComponent\Util\LiveControllerAttributesCreator; use Symfony\UX\LiveComponent\Util\RequestPropsExtractor; use Symfony\UX\LiveComponent\Util\TwigAttributeHelperFactory; +use Symfony\UX\LiveComponent\Util\UrlFactory; use Symfony\UX\TwigComponent\ComponentFactory; use Symfony\UX\TwigComponent\ComponentRenderer; diff --git a/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php index 9208d60e53d..21e14dcb8ab 100644 --- a/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php @@ -15,16 +15,16 @@ 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 readonly RouterInterface $router, - private readonly LiveComponentMetadataFactory $metadataFactory, + private LiveComponentMetadataFactory $metadataFactory, + private UrlFactory $urlFactory, ) { } @@ -33,22 +33,22 @@ public function onKernelResponse(ResponseEvent $event): void if (!$event->isMainRequest()) { return; } - if (!$this->isLiveComponentRequest($request = $event->getRequest())) { + $request = $event->getRequest(); + if (!$request->attributes->has('_live_component')) { return; } + $newUrl = null; if ($previousLocation = $request->headers->get(self::URL_HEADER)) { - $newUrl = $this->computeNewUrl( - $previousLocation, - $this->getLivePropsToMap($request) - ); - if ($newUrl) { - $event->getResponse()->headers->set( - self::URL_HEADER, - $newUrl - ); + $liveProps = $this->getLivePropsToMap($request); + if (!empty($liveProps)) { + $newUrl = $this->urlFactory->createFromPreviousAndProps($previousLocation, $liveProps['path'], $liveProps['query']); } } + + if ($newUrl) { + $event->getResponse()->headers->set(self::URL_HEADER, $newUrl); + } } public static function getSubscribedEvents(): array @@ -61,7 +61,6 @@ public static function getSubscribedEvents(): array 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') ?? []; @@ -71,9 +70,7 @@ private function getLivePropsToMap(Request $request): array 'path' => [], 'query' => [], ]; - foreach ($metadata->getAllLivePropsMetadata($component) as $liveProp) { - $name = $liveProp->getName(); - $urlMapping = $liveProp->urlMapping(); + foreach ($metadata->getAllUrlMappings() as $name => $urlMapping) { if (isset($values[$name]) && $urlMapping) { $urlLiveProps[$urlMapping->mapPath ? 'path' : 'query'][$urlMapping->as ?? $name] = $values[$name]; @@ -82,44 +79,4 @@ private function getLivePropsToMap(Request $request): array return $urlLiveProps; } - - private function computeNewUrl(string $previousUrl, array $livePropsToMap): string - { - $parsed = parse_url($previousUrl); - - $url = $parsed['path'] ?? ''; - if (isset($parsed['query'])) { - $url .= '?'.$parsed['query']; - } - parse_str($parsed['query'] ?? '', $previousQueryParams); - - $newUrl = $this->router->generate( - $this->router->match($url)['_route'], - array_merge($previousQueryParams, $livePropsToMap['path']) - ); - parse_str(parse_url($newUrl)['query'] ?? '', $queryParams); - $queryString = http_build_query(array_merge($queryParams, $livePropsToMap['query'])); - - return preg_replace('/[?#].*/', '', $newUrl). - ('' !== $queryString ? '?' : ''). - $queryString; - } - - /** - * copied from LiveComponentSubscriber. - */ - private function isLiveComponentRequest(Request $request): bool - { - if (!$request->attributes->has('_live_component')) { - return false; - } - - // if ($this->testMode) { - // return true; - // } - - // Except when testing, require the correct content-type in the Accept header. - // This also acts as a CSRF protection since this can only be set in accordance with same-origin/CORS policies. - return \in_array('application/vnd.live-component+html', $request->getAcceptableContentTypes(), true); - } } diff --git a/src/LiveComponent/src/Util/UrlFactory.php b/src/LiveComponent/src/Util/UrlFactory.php new file mode 100644 index 00000000000..0ad6062754b --- /dev/null +++ b/src/LiveComponent/src/Util/UrlFactory.php @@ -0,0 +1,92 @@ + + * + * 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\Routing\Exception\MissingMandatoryParametersException; +use Symfony\Component\Routing\Exception\ResourceNotFoundException; +use Symfony\Component\Routing\RouterInterface; + +/** + * @internal + */ +class UrlFactory +{ + public function __construct( + private RouterInterface $router, + ) { + } + + public function createFromPreviousAndProps( + string $previousUrl, + array $pathMappedProps, + array $queryMappedProps, + ): ?string { + $parsed = parse_url($previousUrl); + if (false === $parsed) { + return null; + } + + // Make sure to handle only path and query + $previousUrl = $parsed['path'] ?? ''; + if (isset($parsed['query'])) { + $previousUrl .= '?'.$parsed['query']; + } + + try { + $newUrl = $this->createPath($previousUrl, $pathMappedProps); + } catch (ResourceNotFoundException|MissingMandatoryParametersException) { + return null; + } + + 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/tests/Unit/Util/UrlFactoryTest.php b/src/LiveComponent/tests/Unit/Util/UrlFactoryTest.php new file mode 100644 index 00000000000..40203fa4720 --- /dev/null +++ b/src/LiveComponent/tests/Unit/Util/UrlFactoryTest.php @@ -0,0 +1,179 @@ + + * + * 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\Component\Routing\Exception\MissingMandatoryParametersException; +use Symfony\Component\Routing\Exception\ResourceNotFoundException; +use Symfony\Component\Routing\RouterInterface; +use Symfony\UX\LiveComponent\Util\UrlFactory; + +class UrlFactoryTest extends TestCase +{ + public function getData(): \Generator + { + yield 'keep_default_url' => []; + + yield 'keep_relative_url' => [ + 'input' => ['previousUrl' => '/foo/bar'], + 'expectedUrl' => '/foo/bar', + ]; + + yield 'keep_absolute_url' => [ + 'input' => ['previousUrl' => 'https://symfony.com/foo/bar'], + 'expectedUrl' => '/foo/bar', + 'routerStubData' => [ + 'previousUrl' => '/foo/bar', + 'newUrl' => '/foo/bar', + ], + ]; + + yield 'keep_url_with_query_parameters' => [ + 'input' => ['previousUrl' => 'https://symfony.com/foo/bar?prop1=val1&prop2=val2'], + '/foo/bar?prop1=val1&prop2=val2', + 'routerStubData' => [ + 'previousUrl' => '/foo/bar?prop1=val1&prop2=val2', + 'newUrl' => '/foo/bar?prop1=val1&prop2=val2', + ], + ]; + + yield 'add_query_parameters' => [ + 'input' => [ + 'previousUrl' => '/foo/bar', + 'queryMappedProps' => ['prop1' => 'val1', 'prop2' => 'val2'], + ], + 'expectedUrl' => '/foo/bar?prop1=val1&prop2=val2', + ]; + + yield 'override_previous_matching_query_parameters' => [ + 'input' => [ + 'previousUrl' => '/foo/bar?prop1=oldValue&prop3=oldValue', + 'queryMappedProps' => ['prop1' => 'val1', 'prop2' => 'val2'], + ], + 'expectedUrl' => '/foo/bar?prop1=val1&prop3=oldValue&prop2=val2', + ]; + + yield 'add_path_parameters' => [ + 'input' => [ + 'previousUrl' => '/foo/bar', + 'pathMappedProps' => ['value' => 'baz'], + ], + 'expectedUrl' => '/foo/baz', + 'routerStubData' => [ + 'previousUrl' => '/foo/bar', + 'newUrl' => '/foo/baz', + 'props' => ['value' => 'baz'], + ], + ]; + + yield 'add_both_parameters' => [ + 'input' => [ + 'previousUrl' => '/foo/bar', + 'pathMappedProps' => ['value' => 'baz'], + 'queryMappedProps' => ['filter' => 'all'], + ], + 'expectedUrl' => '/foo/baz?filter=all', + 'routerStubData' => [ + 'previousUrl' => '/foo/bar', + 'newUrl' => '/foo/baz', + 'props' => ['value' => 'baz'], + ], + ]; + + yield 'handle_path_parameter_not_recognized' => [ + 'input' => [ + 'previousUrl' => '/foo/bar', + 'pathMappedProps' => ['value' => 'baz'], + ], + 'expectedUrl' => '/foo/bar?value=baz', + 'routerStubData' => [ + 'previousUrl' => '/foo/bar', + 'newUrl' => '/foo/bar?value=baz', + 'props' => ['value' => 'baz'], + ], + ]; + } + + /** + * @dataProvider getData + */ + public function testCreate( + array $input = [], + string $expectedUrl = '', + array $routerStubData = [], + ): void { + $previousUrl = $input['previousUrl'] ?? ''; + $router = $this->createRouterStub( + $routerStubData['previousUrl'] ?? $previousUrl, + $routerStubData['newUrl'] ?? $previousUrl, + $routerStubData['props'] ?? [], + ); + $factory = new UrlFactory($router); + $newUrl = $factory->createFromPreviousAndProps( + $previousUrl, + $input['pathMappedProps'] ?? [], + $input['queryMappedProps'] ?? [] + ); + + $this->assertEquals($expectedUrl, $newUrl); + } + + public function testResourceNotFoundException(): void + { + $previousUrl = '/foo/bar'; + $router = $this->createMock(RouterInterface::class); + $router->expects(self::once()) + ->method('match') + ->with($previousUrl) + ->willThrowException(new ResourceNotFoundException()); + $factory = new UrlFactory($router); + + $this->assertNull($factory->createFromPreviousAndProps($previousUrl, [], [])); + } + + public function testMissingMandatoryParametersException(): void + { + $previousUrl = '/foo/bar'; + $matchedRouteName = 'foo_bar'; + $router = $this->createMock(RouterInterface::class); + $router->expects(self::once()) + ->method('match') + ->with($previousUrl) + ->willReturn(['_route' => $matchedRouteName]); + $router->expects(self::once()) + ->method('generate') + ->with($matchedRouteName, []) + ->willThrowException(new MissingMandatoryParametersException($matchedRouteName, ['baz'])); + $factory = new UrlFactory($router); + + $this->assertNull($factory->createFromPreviousAndProps($previousUrl, [], [])); + } + + private function createRouterStub( + string $previousUrl, + string $newUrl, + array $props = [], + ): RouterInterface { + $matchedRoute = 'default'; + $router = $this->createMock(RouterInterface::class); + $router->expects(self::once()) + ->method('match') + ->with($previousUrl) + ->willReturn(['_route' => $matchedRoute]); + $router->expects(self::once()) + ->method('generate') + ->with($matchedRoute, $props) + ->willReturn($newUrl); + + return $router; + } +} From b0a5cf1d23036b19df3689436424b5d12488fc7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Thu, 10 Apr 2025 20:19:32 +0200 Subject: [PATCH 07/18] remove UrlUtils --- .../assets/dist/live_controller.js | 2 +- src/LiveComponent/assets/dist/url_utils.d.ts | 11 -- .../assets/src/Component/index.ts | 7 +- src/LiveComponent/assets/src/url_utils.ts | 172 ------------------ .../assets/test/url_utils.test.ts | 160 ---------------- 5 files changed, 6 insertions(+), 346 deletions(-) delete mode 100644 src/LiveComponent/assets/dist/url_utils.d.ts delete mode 100644 src/LiveComponent/assets/src/url_utils.ts delete mode 100644 src/LiveComponent/assets/test/url_utils.test.ts diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index f8076428574..680a9b20854 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -2248,7 +2248,7 @@ class Component { this.processRerender(html, backendResponse); const liveUrl = await backendResponse.getLiveUrl(); if (liveUrl) { - HistoryStrategy.replace(new UrlUtils(liveUrl + window.location.hash, window.location.origin)); + history.replaceState(history.state, '', new URL(liveUrl + window.location.hash, window.location.origin)); } this.backendRequest = null; thisPromiseResolve(backendResponse); diff --git a/src/LiveComponent/assets/dist/url_utils.d.ts b/src/LiveComponent/assets/dist/url_utils.d.ts deleted file mode 100644 index c54c70f08ac..00000000000 --- a/src/LiveComponent/assets/dist/url_utils.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -export declare class UrlUtils extends URL { - has(key: string): boolean; - set(key: string, value: any): void; - get(key: string): any | undefined; - remove(key: string): void; - private getData; - private setData; -} -export declare class HistoryStrategy { - static replace(url: URL): void; -} diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index ecfd8b62adf..b5619c3207a 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -7,7 +7,6 @@ import HookManager from '../HookManager'; import { executeMorphdom } from '../morphdom'; import ExternalMutationTracker from '../Rendering/ExternalMutationTracker'; import { normalizeModelName } from '../string_utils'; -import { HistoryStrategy, UrlUtils } from "../url_utils"; import type { ElementDriver } from './ElementDriver'; import type { PluginInterface } from './plugins/PluginInterface'; import UnsyncedInputsTracker from './UnsyncedInputsTracker'; @@ -331,7 +330,11 @@ 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)); + history.replaceState( + history.state, + '', + new URL(liveUrl + window.location.hash, window.location.origin) + ); } // finally resolve this promise diff --git a/src/LiveComponent/assets/src/url_utils.ts b/src/LiveComponent/assets/src/url_utils.ts deleted file mode 100644 index c988d7efdc7..00000000000 --- a/src/LiveComponent/assets/src/url_utils.ts +++ /dev/null @@ -1,172 +0,0 @@ -/** - * Adapted from Livewire's history plugin. - * - * @see https://github.com/livewire/livewire/blob/d4839e3b2c23fc71e615e68bc29ff4de95751810/js/plugins/history/index.js - */ - -/** - * Check if a value is empty. - * - * Empty values are: - * - `null` and `undefined` - * - Empty strings - * - Empty arrays - * - Deeply empty objects - */ -function isValueEmpty(value: any): boolean { - 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; -} - -/** - * Converts JavaScript data to bracketed query string notation. - * - * Input: `{ items: [['foo']] }` - * - * Output: `"items[0][0]=foo"` - */ -function toQueryString(data: any) { - const buildQueryStringEntries = (data: { [p: string]: any }, entries: any = {}, baseKey = '') => { - Object.entries(data).forEach(([iKey, iValue]) => { - const key = baseKey === '' ? iKey : `${baseKey}[${iKey}]`; - - if ('' === baseKey && isValueEmpty(iValue)) { - // Top level empty parameter - entries[key] = ''; - } else if (null !== iValue) { - if (typeof iValue === 'object') { - // Non-empty object/array process - entries = { ...entries, ...buildQueryStringEntries(iValue, entries, key) }; - } else { - // Scalar value - entries[key] = encodeURIComponent(iValue) - .replace(/%20/g, '+') // Conform to RFC1738 - .replace(/%2C/g, ','); - } - } - }); - - return entries; - }; - - const entries = buildQueryStringEntries(data); - - return Object.entries(entries) - .map(([key, value]) => `${key}=${value}`) - .join('&'); -} - -/** - * Converts bracketed query string notation to JavaScript data. - * - * Input: `"items[0][0]=foo"` - * - * Output: `{ items: [['foo']] }` - */ -function fromQueryString(search: string) { - search = search.replace('?', ''); - - if (search === '') return {}; - - const insertDotNotatedValueIntoData = (key: string, value: any, data: any) => { - const [first, second, ...rest] = key.split('.'); - - // We're at a leaf node, let's make the assigment... - if (!second) { - data[key] = value; - return value; - } - - // This is where we fill in empty arrays/objects along the way to the assigment... - if (data[first] === undefined) { - data[first] = Number.isNaN(Number.parseInt(second)) ? {} : []; - } - - // Keep deferring assignment until the full key is built up... - insertDotNotatedValueIntoData([second, ...rest].join('.'), value, data[first]); - }; - - const entries = search.split('&').map((i) => i.split('=')); - - const data: any = {}; - - entries.forEach(([key, value]) => { - value = decodeURIComponent(String(value || '').replace(/\+/g, '%20')); - - if (!key.includes('[')) { - data[key] = value; - } else { - // Skip empty nested data - if ('' === value) return; - - // Convert to dot notation because it's easier... - const dotNotatedKey = key.replace(/\[/g, '.').replace(/]/g, ''); - - insertDotNotatedValueIntoData(dotNotatedKey, value, data); - } - }); - - return data; -} - -/** - * Wraps a URL to manage search parameters with common map functions. - */ -export class UrlUtils extends URL { - has(key: string) { - const data = this.getData(); - - return Object.keys(data).includes(key); - } - - set(key: string, value: any) { - const data = this.getData(); - - data[key] = value; - - this.setData(data); - } - - get(key: string): any | undefined { - return this.getData()[key]; - } - - remove(key: string) { - const data = this.getData(); - - delete data[key]; - - this.setData(data); - } - - private getData() { - if (!this.search) { - return {}; - } - - return fromQueryString(this.search); - } - - private setData(data: any) { - this.search = toQueryString(data); - } -} - -export class HistoryStrategy { - static replace(url: URL) { - history.replaceState(history.state, '', url); - } -} diff --git a/src/LiveComponent/assets/test/url_utils.test.ts b/src/LiveComponent/assets/test/url_utils.test.ts deleted file mode 100644 index bce7f86b88f..00000000000 --- a/src/LiveComponent/assets/test/url_utils.test.ts +++ /dev/null @@ -1,160 +0,0 @@ -/* - * 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. - */ - -import { HistoryStrategy, UrlUtils } from '../src/url_utils'; - -describe('url_utils', () => { - describe('UrlUtils', () => { - describe('set', () => { - const urlUtils: UrlUtils = new UrlUtils(window.location.href); - - beforeEach(() => { - // Reset search before each test - urlUtils.search = ''; - }); - - it('set the param if it does not exist', () => { - urlUtils.set('param', 'foo'); - - expect(urlUtils.search).toEqual('?param=foo'); - }); - - it('override the param if it exists', () => { - urlUtils.search = '?param=foo'; - - urlUtils.set('param', 'bar'); - - expect(urlUtils.search).toEqual('?param=bar'); - }); - - it('preserve empty values if the param is scalar', () => { - urlUtils.set('param', ''); - - expect(urlUtils.search).toEqual('?param='); - }); - - it('expand arrays in the URL', () => { - urlUtils.set('param', ['foo', 'bar']); - - expect(urlUtils.search).toEqual('?param[0]=foo¶m[1]=bar'); - }); - - it('keep empty values if the param is an empty array', () => { - urlUtils.set('param', []); - - expect(urlUtils.search).toEqual('?param='); - }); - - it('expand objects in the URL', () => { - urlUtils.set('param', { - foo: 1, - bar: 'baz', - }); - - expect(urlUtils.search).toEqual('?param[foo]=1¶m[bar]=baz'); - }); - - it('remove empty values in nested object properties', () => { - urlUtils.set('param', { - foo: null, - bar: 'baz', - }); - - expect(urlUtils.search).toEqual('?param[bar]=baz'); - }); - - it('keep empty values if the param is an empty object', () => { - urlUtils.set('param', {}); - - expect(urlUtils.search).toEqual('?param='); - }); - }); - - describe('remove', () => { - const urlUtils: UrlUtils = new UrlUtils(window.location.href); - - beforeEach(() => { - // Reset search before each test - urlUtils.search = ''; - }); - it('remove the param if it exists', () => { - urlUtils.search = '?param=foo'; - - urlUtils.remove('param'); - - expect(urlUtils.search).toEqual(''); - }); - - it('keep other params unchanged', () => { - urlUtils.search = '?param=foo&otherParam=bar'; - - urlUtils.remove('param'); - - expect(urlUtils.search).toEqual('?otherParam=bar'); - }); - - it('remove all occurrences of an array param', () => { - urlUtils.search = '?param[0]=foo¶m[1]=bar'; - - urlUtils.remove('param'); - - expect(urlUtils.search).toEqual(''); - }); - - it('remove all occurrences of an object param', () => { - urlUtils.search = '?param[foo]=1¶m[bar]=baz'; - - urlUtils.remove('param'); - - expect(urlUtils.search).toEqual(''); - }); - }); - - describe('fromQueryString', () => { - const urlUtils: UrlUtils = new UrlUtils(window.location.href); - - beforeEach(() => { - // Reset search before each test - urlUtils.search = ''; - }); - - it('parses a query string with value', () => { - urlUtils.search = '?param1=value1'; - expect(urlUtils.get('param1')).toEqual('value1'); - }); - - it('parses a query string with empty value', () => { - urlUtils.search = '?param1=¶m2=value2'; - expect(urlUtils.get('param1')).toEqual(''); - expect(urlUtils.get('param2')).toEqual('value2'); - }); - - it('parses a query string without equal sign', () => { - urlUtils.search = '?param1¶m2=value2'; - expect(urlUtils.get('param1')).toEqual(''); - expect(urlUtils.get('param2')).toEqual('value2'); - }); - }); - }); - - describe('HistoryStrategy', () => { - let initialUrl: URL; - beforeAll(() => { - initialUrl = new URL(window.location.href); - }); - afterEach(() => { - history.replaceState(history.state, '', initialUrl); - }); - it('replace URL', () => { - const newUrl = new URL(`${window.location.href}/foo/bar`); - HistoryStrategy.replace(newUrl); - expect(window.location.href).toEqual(newUrl.toString()); - }); - }); -}); From 1e581d8417258663d3d54629e1b62ebf71e8bd6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Mon, 12 May 2025 17:54:35 +0200 Subject: [PATCH 08/18] add server-side changed props in request to be handled by LiveUrlSubscriber --- .../src/EventListener/LiveComponentSubscriber.php | 12 +++++++++++- .../src/EventListener/LiveUrlSubscriber.php | 6 +++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index 58b5df6d111..5e6597ae583 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -255,7 +255,17 @@ public function onKernelView(ViewEvent $event): void return; } - $event->setResponse($this->createResponse($request->attributes->get('_mounted_component'))); + $mountedComponent = $request->attributes->get('_mounted_component'); + if (!$request->attributes->get('_component_default_action', false)) { + // On custom action, props may be updated by the server side + // @todo discuss name responseProps + // @todo maybe always set in, including default action and use only this, ignoring `props` and `updated` for UrlFactory ? + $liveRequestData = $request->attributes->get('_live_request_data'); + $liveRequestData['responseProps'] = (array) $mountedComponent->getComponent(); + $request->attributes->set('_live_request_data', $liveRequestData); + } + + $event->setResponse($this->createResponse($mountedComponent)); } public function onKernelException(ExceptionEvent $event): void diff --git a/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php index 21e14dcb8ab..4e57be2bf52 100644 --- a/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php @@ -64,7 +64,11 @@ private function getLivePropsToMap(Request $request): array $metadata = $this->metadataFactory->getMetadata($componentName); $liveData = $request->attributes->get('_live_request_data') ?? []; - $values = array_merge($liveData['props'] ?? [], $liveData['updated'] ?? []); + $values = array_merge( + $liveData['props'] ?? [], + $liveData['updated'] ?? [], + $liveData['responseProps'] ?? [] + ); $urlLiveProps = [ 'path' => [], From 098bef7d1cdc6222087fa8324fa05125a8927de8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Tue, 20 May 2025 21:02:42 +0200 Subject: [PATCH 09/18] LiveUrlSubscriberTest --- .../src/EventListener/LiveUrlSubscriber.php | 15 +- .../EventListener/LiveUrlSubscriberTest.php | 192 ++++++++++++++++++ 2 files changed, 200 insertions(+), 7 deletions(-) create mode 100644 src/LiveComponent/tests/Unit/EventListener/LiveUrlSubscriberTest.php diff --git a/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php index 4e57be2bf52..cae92743c50 100644 --- a/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php @@ -18,6 +18,9 @@ use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory; use Symfony\UX\LiveComponent\Util\UrlFactory; +/** + * @internal + */ class LiveUrlSubscriber implements EventSubscriberInterface { private const URL_HEADER = 'X-Live-Url'; @@ -41,9 +44,7 @@ public function onKernelResponse(ResponseEvent $event): void $newUrl = null; if ($previousLocation = $request->headers->get(self::URL_HEADER)) { $liveProps = $this->getLivePropsToMap($request); - if (!empty($liveProps)) { - $newUrl = $this->urlFactory->createFromPreviousAndProps($previousLocation, $liveProps['path'], $liveProps['query']); - } + $newUrl = $this->urlFactory->createFromPreviousAndProps($previousLocation, $liveProps['path'], $liveProps['query']); } if ($newUrl) { @@ -63,11 +64,11 @@ private function getLivePropsToMap(Request $request): array $componentName = $request->attributes->get('_live_component'); $metadata = $this->metadataFactory->getMetadata($componentName); - $liveData = $request->attributes->get('_live_request_data') ?? []; + $liveRequestData = $request->attributes->get('_live_request_data') ?? []; $values = array_merge( - $liveData['props'] ?? [], - $liveData['updated'] ?? [], - $liveData['responseProps'] ?? [] + $liveRequestData['props'] ?? [], + $liveRequestData['updated'] ?? [], + $liveRequestData['responseProps'] ?? [] ); $urlLiveProps = [ diff --git a/src/LiveComponent/tests/Unit/EventListener/LiveUrlSubscriberTest.php b/src/LiveComponent/tests/Unit/EventListener/LiveUrlSubscriberTest.php new file mode 100644 index 00000000000..20c862c8842 --- /dev/null +++ b/src/LiveComponent/tests/Unit/EventListener/LiveUrlSubscriberTest.php @@ -0,0 +1,192 @@ + + * + * 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\EventListener; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\UX\LiveComponent\EventListener\LiveUrlSubscriber; +use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadata; +use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory; +use Symfony\UX\LiveComponent\Metadata\UrlMapping; +use Symfony\UX\LiveComponent\Util\UrlFactory; + +class LiveUrlSubscriberTest extends TestCase +{ + public function getIgnoreData(): iterable + { + yield 'not_a_live_component' => [ + 'attributes' => [], + 'requestType' => HttpKernelInterface::MAIN_REQUEST, + 'headers' => ['X-Live-Url' => '/foo/bar'], + ]; + yield 'not_main_request' => [ + 'attributes' => ['_live_component' => 'componentName'], + 'requestType' => HttpKernelInterface::SUB_REQUEST, + 'headers' => ['X-Live-Url' => '/foo/bar'], + ]; + yield 'no_previous_url' => [ + 'attributes' => ['_live_component' => 'componentName'], + 'requestType' => HttpKernelInterface::MAIN_REQUEST, + 'headers' => [], + ]; + } + + /** + * @dataProvider getIgnoreData + */ + public function testDoNothing( + array $attributes = ['_live_component' => 'componentName'], + int $requestType = HttpKernelInterface::MAIN_REQUEST, + array $headers = ['X-Live-Url' => '/foo/bar'], + ): void { + $request = new Request(); + $request->attributes->add($attributes); + $request->headers->add($headers); + $response = new Response(); + $event = new ResponseEvent( + $this->createMock(HttpKernelInterface::class), + $request, + $requestType, + $response + ); + + $metadataFactory = $this->createMock(LiveComponentMetadataFactory::class); + $metadataFactory->expects(self::never())->method('getMetadata'); + $urlFactory = $this->createMock(UrlFactory::class); + $urlFactory->expects(self::never())->method('createFromPreviousAndProps'); + $liveUrlSubscriber = new LiveUrlSubscriber($metadataFactory, $urlFactory); + + $liveUrlSubscriber->onKernelResponse($event); + $this->assertNull($response->headers->get('X-Live-Url')); + } + + public function getData(): iterable + { + yield 'prop_without_matching_property' => [ + 'liveRequestData' => [ + 'props' => ['notMatchingProp' => 0], + ], + ]; + yield 'prop_matching_non_mapped_property' => [ + 'liveRequestData' => [ + 'props' => ['nonMappedProp' => 0], + ], + ]; + yield 'props_matching_query_mapped_properties' => [ + 'liveRequestData' => [ + 'props' => ['queryMappedProp1' => 1], + 'updated' => ['queryMappedProp2' => 2], + 'responseProps' => ['queryMappedProp3' => 3], + ], + 'expectedPathProps' => [], + 'expectedQueryProps' => [ + 'queryMappedProp1' => 1, + 'queryMappedProp2' => 2, + 'queryMappedProp3' => 3, + ], + ]; + yield 'props_matching_path_mapped_properties' => [ + 'liveRequestData' => [ + 'props' => ['pathMappedProp1' => 1], + 'updated' => ['pathMappedProp2' => 2], + 'responseProps' => ['pathMappedProp3' => 3], + ], + 'expectedPathProps' => [ + 'pathMappedProp1' => 1, + 'pathMappedProp2' => 2, + 'pathMappedProp3' => 3, + ], + 'expectedQueryProps' => [], + ]; + yield 'props_matching_properties_with_alias' => [ + 'liveRequestData' => [ + 'props' => ['pathMappedPropWithAlias' => 1, 'queryMappedPropWithAlias' => 2], + ], + 'expectedPathProps' => ['pathAlias' => 1], + 'expectedQueryProps' => ['queryAlias' => 2], + ]; + yield 'responseProps_have_highest_priority' => [ + 'liveRequestData' => [ + 'props' => ['queryMappedProp1' => 1], + 'updated' => ['queryMappedProp1' => 2], + 'responseProps' => ['queryMappedProp1' => 3], + ], + 'expectedPathProps' => [], + 'expectedQueryProps' => ['queryMappedProp1' => 3], + ]; + yield 'updated_have_second_priority' => [ + 'liveRequestData' => [ + 'props' => ['queryMappedProp1' => 1], + 'updated' => ['queryMappedProp1' => 2], + ], + 'expectedPathProps' => [], + 'expectedQueryProps' => ['queryMappedProp1' => 2], + ]; + } + + /** + * @dataProvider getData + */ + public function testProps( + array $liveRequestData, + array $expectedPathProps = [], + array $expectedQueryProps = [], + ): void { + $previousLocation = '/foo/bar'; + $newLocation = '/foo/baz'; + $componentName = 'componentName'; + $component = $this->createMock(\stdClass::class); + $metaData = $this->createMock(LiveComponentMetadata::class); + $metaData->expects(self::once()) + ->method('getAllUrlMappings') + ->willReturn([ + 'nonMappedProp' => false, + 'queryMappedProp1' => new UrlMapping(), + 'queryMappedProp2' => new UrlMapping(), + 'queryMappedProp3' => new UrlMapping(), + 'pathMappedProp1' => new UrlMapping(mapPath: true), + 'pathMappedProp2' => new UrlMapping(mapPath: true), + 'pathMappedProp3' => new UrlMapping(mapPath: true), + 'queryMappedPropWithAlias' => new UrlMapping(as: 'queryAlias'), + 'pathMappedPropWithAlias' => new UrlMapping(as: 'pathAlias', mapPath: true), + ]); + $request = new Request(); + $request->attributes->add([ + '_live_component' => $componentName, + '_mounted_component' => $component, + '_live_request_data' => $liveRequestData, + ]); + $request->headers->add(['X-Live-Url' => $previousLocation]); + $response = new Response(); + $event = new ResponseEvent( + $this->createMock(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + $response + ); + + $metadataFactory = $this->createMock(LiveComponentMetadataFactory::class); + $metadataFactory->expects(self::once())->method('getMetadata')->with($componentName)->willReturn($metaData); + $urlFactory = $this->createMock(UrlFactory::class); + $liveUrlSubscriber = new LiveUrlSubscriber($metadataFactory, $urlFactory); + + $urlFactory->expects(self::once()) + ->method('createFromPreviousAndProps') + ->with($previousLocation, $expectedPathProps, $expectedQueryProps) + ->willReturn($newLocation); + $liveUrlSubscriber->onKernelResponse($event); + $this->assertEquals($newLocation, $response->headers->get('X-Live-Url')); + } +} From 1a7e5436c8b6983adef9ed1fb1a5682062bf7c12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Fri, 9 May 2025 17:13:15 +0200 Subject: [PATCH 10/18] fix query-binding tests --- .../test/controller/query-binding.test.ts | 46 ++++++++++++------- src/LiveComponent/assets/test/tools.ts | 15 +++++- 2 files changed, 43 insertions(+), 18 deletions(-) diff --git a/src/LiveComponent/assets/test/controller/query-binding.test.ts b/src/LiveComponent/assets/test/controller/query-binding.test.ts index f0654efe8e9..6aea82fa6ca 100644 --- a/src/LiveComponent/assets/test/controller/query-binding.test.ts +++ b/src/LiveComponent/assets/test/controller/query-binding.test.ts @@ -49,14 +49,14 @@ describe('LiveController query string binding', () => { // String // Set value - test.expectsAjaxCall().expectUpdatedData({ prop1: 'foo' }); + test.expectsAjaxCall().expectUpdatedData({ prop1: 'foo' }).willReturnLiveUrl('?prop1=foo&prop2='); await test.component.set('prop1', 'foo', true); expectCurrentSearch().toEqual('?prop1=foo&prop2='); // Remove value - test.expectsAjaxCall().expectUpdatedData({ prop1: '' }); + test.expectsAjaxCall().expectUpdatedData({ prop1: '' }).willReturnLiveUrl('?prop1=&prop2='); await test.component.set('prop1', '', true); @@ -65,14 +65,14 @@ describe('LiveController query string binding', () => { // Number // Set value - test.expectsAjaxCall().expectUpdatedData({ prop2: 42 }); + test.expectsAjaxCall().expectUpdatedData({ prop2: 42 }).willReturnLiveUrl('?prop1=&prop2=42'); await test.component.set('prop2', 42, true); expectCurrentSearch().toEqual('?prop1=&prop2=42'); // Remove value - test.expectsAjaxCall().expectUpdatedData({ prop2: null }); + test.expectsAjaxCall().expectUpdatedData({ prop2: null }).willReturnLiveUrl('?prop1=&prop2='); await test.component.set('prop2', null, true); @@ -88,21 +88,25 @@ describe('LiveController query string binding', () => { ); // Set value - test.expectsAjaxCall().expectUpdatedData({ prop: ['foo', 'bar'] }); + test.expectsAjaxCall() + .expectUpdatedData({ prop: ['foo', 'bar'] }) + .willReturnLiveUrl('?prop[0]=foo&prop[1]=bar'); await test.component.set('prop', ['foo', 'bar'], true); expectCurrentSearch().toEqual('?prop[0]=foo&prop[1]=bar'); // Remove one value - test.expectsAjaxCall().expectUpdatedData({ prop: ['foo'] }); + test.expectsAjaxCall() + .expectUpdatedData({ prop: ['foo'] }) + .willReturnLiveUrl('?prop[0]=foo'); await test.component.set('prop', ['foo'], true); expectCurrentSearch().toEqual('?prop[0]=foo'); // Remove all remaining values - test.expectsAjaxCall().expectUpdatedData({ prop: [] }); + test.expectsAjaxCall().expectUpdatedData({ prop: [] }).willReturnLiveUrl('?prop='); await test.component.set('prop', [], true); @@ -118,28 +122,34 @@ describe('LiveController query string binding', () => { ); // Set single nested prop - test.expectsAjaxCall().expectUpdatedData({ 'prop.foo': 'dummy' }); + test.expectsAjaxCall().expectUpdatedData({ 'prop.foo': 'dummy' }).willReturnLiveUrl('?prop[foo]=dummy'); await test.component.set('prop.foo', 'dummy', true); expectCurrentSearch().toEqual('?prop[foo]=dummy'); // Set multiple values - test.expectsAjaxCall().expectUpdatedData({ prop: { foo: 'other', bar: 42 } }); + test.expectsAjaxCall() + .expectUpdatedData({ prop: { foo: 'other', bar: 42 } }) + .willReturnLiveUrl('?prop[foo]=other&prop[bar]=42'); await test.component.set('prop', { foo: 'other', bar: 42 }, true); expectCurrentSearch().toEqual('?prop[foo]=other&prop[bar]=42'); // Remove one value - test.expectsAjaxCall().expectUpdatedData({ prop: { foo: 'other', bar: null } }); + test.expectsAjaxCall() + .expectUpdatedData({ prop: { foo: 'other', bar: null } }) + .willReturnLiveUrl('?prop[foo]=other'); await test.component.set('prop', { foo: 'other', bar: null }, true); expectCurrentSearch().toEqual('?prop[foo]=other'); // Remove all values - test.expectsAjaxCall().expectUpdatedData({ prop: { foo: null, bar: null } }); + test.expectsAjaxCall() + .expectUpdatedData({ prop: { foo: null, bar: null } }) + .willReturnLiveUrl('?prop='); await test.component.set('prop', { foo: null, bar: null }, true); @@ -161,13 +171,15 @@ describe('LiveController query string binding', () => { .expectActionCalled('changeProp') .serverWillChangeProps((data: any) => { data.prop = 'foo'; - }); + }) + .willReturnLiveUrl('?prop=foo'); getByText(test.element, 'Change prop').click(); - await waitFor(() => expect(test.element).toHaveTextContent('Prop: foo')); - - expectCurrentSearch().toEqual('?prop=foo'); + await waitFor(() => { + expect(test.element).toHaveTextContent('Prop: foo'); + expectCurrentSearch().toEqual('?prop=foo'); + }); }); it('uses custom name instead of prop name in the URL', async () => { @@ -179,14 +191,14 @@ describe('LiveController query string binding', () => { ); // Set value - test.expectsAjaxCall().expectUpdatedData({ prop1: 'foo' }); + test.expectsAjaxCall().expectUpdatedData({ prop1: 'foo' }).willReturnLiveUrl('?alias1=foo'); await test.component.set('prop1', 'foo', true); expectCurrentSearch().toEqual('?alias1=foo'); // Remove value - test.expectsAjaxCall().expectUpdatedData({ prop1: '' }); + test.expectsAjaxCall().expectUpdatedData({ prop1: '' }).willReturnLiveUrl('?alias1='); await test.component.set('prop1', '', true); diff --git a/src/LiveComponent/assets/test/tools.ts b/src/LiveComponent/assets/test/tools.ts index 41a6b11a29c..228d8254fbf 100644 --- a/src/LiveComponent/assets/test/tools.ts +++ b/src/LiveComponent/assets/test/tools.ts @@ -173,6 +173,7 @@ class MockedAjaxCall { /* Response properties */ private changePropsCallback?: (props: any) => void; private template?: (props: any) => string; + private liveUrl?: string; private delayResponseTime?: number = 0; private customResponseStatusCode?: number; private customResponseHTML?: string; @@ -269,10 +270,16 @@ class MockedAjaxCall { const html = this.customResponseHTML ? this.customResponseHTML : template(newProps); // assume a normal, live-component response unless it's totally custom - const headers = { 'Content-Type': 'application/vnd.live-component+html' }; + const headers = { + 'Content-Type': 'application/vnd.live-component+html', + 'X-Live-Url': '', + }; if (this.customResponseHTML) { headers['Content-Type'] = 'text/html'; } + if (this.liveUrl) { + headers['X-Live-Url'] = this.liveUrl; + } const response = new Response(html, { status: this.customResponseStatusCode || 200, @@ -342,6 +349,12 @@ class MockedAjaxCall { return this; } + willReturnLiveUrl(liveUrl: string): MockedAjaxCall { + this.liveUrl = liveUrl; + + return this; + } + serverWillReturnCustomResponse(statusCode: number, responseHTML: string): MockedAjaxCall { this.customResponseStatusCode = statusCode; this.customResponseHTML = responseHTML; From c055db4baa3ad7f10ee0b12b6c3a9d998597a719 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Thu, 22 May 2025 17:16:23 +0200 Subject: [PATCH 11/18] LiveComponentMetadata.getAllUrlMappings() --- .../src/Metadata/LiveComponentMetadata.php | 15 +++++++++++++++ .../Unit/Metadata/LiveComponentMetadataTest.php | 16 ++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/LiveComponent/src/Metadata/LiveComponentMetadata.php b/src/LiveComponent/src/Metadata/LiveComponentMetadata.php index cae3c580760..fb1ccd07b48 100644 --- a/src/LiveComponent/src/Metadata/LiveComponentMetadata.php +++ b/src/LiveComponent/src/Metadata/LiveComponentMetadata.php @@ -66,6 +66,21 @@ public function getOnlyPropsThatAcceptUpdatesFromParent(array $inputProps): arra return array_intersect_key($inputProps, array_flip($propNames)); } + /** + * @return UrlMapping[] + */ + public function getAllUrlMappings(): iterable + { + $urlMappings = []; + foreach ($this->livePropsMetadata as $livePropMetadata) { + if ($livePropMetadata->urlMapping()) { + $urlMappings[$livePropMetadata->getName()] = $livePropMetadata->urlMapping(); + } + } + + return $urlMappings; + } + public function hasQueryStringBindings($component): bool { foreach ($this->getAllLivePropsMetadata($component) as $livePropMetadata) { diff --git a/src/LiveComponent/tests/Unit/Metadata/LiveComponentMetadataTest.php b/src/LiveComponent/tests/Unit/Metadata/LiveComponentMetadataTest.php index f2bd9e7d446..3c8ff8891d9 100644 --- a/src/LiveComponent/tests/Unit/Metadata/LiveComponentMetadataTest.php +++ b/src/LiveComponent/tests/Unit/Metadata/LiveComponentMetadataTest.php @@ -15,6 +15,7 @@ use Symfony\UX\LiveComponent\Attribute\LiveProp; use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadata; use Symfony\UX\LiveComponent\Metadata\LivePropMetadata; +use Symfony\UX\LiveComponent\Metadata\UrlMapping; use Symfony\UX\TwigComponent\ComponentMetadata; class LiveComponentMetadataTest extends TestCase @@ -37,4 +38,19 @@ public function testGetOnlyPropsThatAcceptUpdatesFromParent() $actual = $liveComponentMetadata->getOnlyPropsThatAcceptUpdatesFromParent($inputProps); $this->assertEquals($expected, $actual); } + + public function testGetAllUrlMappings(): void + { + $aliasUrlMapping = new UrlMapping('alias'); + $propMetadas = [ + new LivePropMetadata('noUrlMapping', new LiveProp(), null, false, false, null), + new LivePropMetadata('basicUrlMapping', new LiveProp(url: true), null, false, false, null), + new LivePropMetadata('aliasUrlMapping', new LiveProp(url: $aliasUrlMapping), null, false, false, null), + ]; + $liveComponentMetadata = new LiveComponentMetadata(new ComponentMetadata([]), $propMetadas); + $urlMappings = $liveComponentMetadata->getAllUrlMappings(); + $this->assertCount(2, $urlMappings); + $this->assertInstanceOf(UrlMapping::class, $urlMappings['basicUrlMapping']); + $this->assertEquals($aliasUrlMapping, $urlMappings['aliasUrlMapping']); + } } From 269acb6a99e8584be4a0a22caf21be90ad526f12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Thu, 22 May 2025 17:19:03 +0200 Subject: [PATCH 12/18] Update and extends functional tests --- .../Component/ComponentWithUrlBoundProps.php | 6 + src/LiveComponent/tests/Fixtures/Kernel.php | 2 + .../component_with_url_bound_props.html.twig | 2 + .../AddLiveAttributesSubscriberTest.php | 2 + .../EventListener/LiveUrlSubscriberTest.php | 104 ++++++++++++++++++ 5 files changed, 116 insertions(+) create mode 100644 src/LiveComponent/tests/Functional/EventListener/LiveUrlSubscriberTest.php diff --git a/src/LiveComponent/tests/Fixtures/Component/ComponentWithUrlBoundProps.php b/src/LiveComponent/tests/Fixtures/Component/ComponentWithUrlBoundProps.php index 24ffdf10614..29495d2ac7c 100644 --- a/src/LiveComponent/tests/Fixtures/Component/ComponentWithUrlBoundProps.php +++ b/src/LiveComponent/tests/Fixtures/Component/ComponentWithUrlBoundProps.php @@ -68,6 +68,12 @@ public function modifyMaybeBoundProp(LiveProp $prop): LiveProp #[LiveProp] public ?string $customAlias = null; + #[LiveProp(writable: true, url: new UrlMapping(mapPath: true))] + public ?string $pathProp = null; + + #[LiveProp(writable: true, url: new UrlMapping(as: 'pathAlias', mapPath: true))] + public ?string $pathPropWithAlias = null; + public function modifyBoundPropWithCustomAlias(LiveProp $liveProp): LiveProp { if ($this->customAlias) { diff --git a/src/LiveComponent/tests/Fixtures/Kernel.php b/src/LiveComponent/tests/Fixtures/Kernel.php index 4b0cd90d143..4598fa0ea2e 100644 --- a/src/LiveComponent/tests/Fixtures/Kernel.php +++ b/src/LiveComponent/tests/Fixtures/Kernel.php @@ -219,5 +219,7 @@ protected function configureRoutes(RoutingConfigurator $routes): void $routes->add('homepage', '/')->controller('kernel::index'); $routes->add('alternate_live_route', '/alt/{_live_component}/{_live_action}')->defaults(['_live_action' => 'get']); $routes->add('localized_route', '/locale/{_locale}/{_live_component}/{_live_action}')->defaults(['_live_action' => 'get']); + $routes->add('route_with_prop', '/route_with_prop/{pathProp}'); + $routes->add('route_with_alias_prop', '/route_with_alias_prop/{pathAlias}'); } } diff --git a/src/LiveComponent/tests/Fixtures/templates/components/component_with_url_bound_props.html.twig b/src/LiveComponent/tests/Fixtures/templates/components/component_with_url_bound_props.html.twig index 21073e218f9..9e6c3750222 100644 --- a/src/LiveComponent/tests/Fixtures/templates/components/component_with_url_bound_props.html.twig +++ b/src/LiveComponent/tests/Fixtures/templates/components/component_with_url_bound_props.html.twig @@ -9,4 +9,6 @@ MaybeBoundProp: {{ maybeBoundProp }} BoundPropWithAlias: {{ boundPropWithAlias }} BoundPropWithCustomAlias: {{ boundPropWithCustomAlias }} + PathProp: {{ pathProp }} + PathPropWithAlias: {{ pathPropWithAlias }} diff --git a/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php index 74d5f975c90..93b310a91e7 100644 --- a/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php +++ b/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php @@ -139,6 +139,8 @@ public function testQueryStringMappingAttribute() 'maybeBoundProp' => ['name' => 'maybeBoundProp'], 'boundPropWithAlias' => ['name' => 'q'], 'boundPropWithCustomAlias' => ['name' => 'customAlias'], + 'pathProp' => ['name' => 'pathProp'], + 'pathPropWithAlias' => ['name' => 'pathAlias'], ]; $this->assertEquals($expected, $queryMapping); diff --git a/src/LiveComponent/tests/Functional/EventListener/LiveUrlSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/LiveUrlSubscriberTest.php new file mode 100644 index 00000000000..a705ffa6ab0 --- /dev/null +++ b/src/LiveComponent/tests/Functional/EventListener/LiveUrlSubscriberTest.php @@ -0,0 +1,104 @@ + + * + * 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 Symfony\UX\LiveComponent\Tests\LiveComponentTestHelper; +use Zenstruck\Browser\Test\HasBrowser; + +class LiveUrlSubscriberTest extends KernelTestCase +{ + use HasBrowser; + use LiveComponentTestHelper; + + public function getTestData(): iterable + { + yield 'missing_header' => [ + 'previousLocation' => null, + 'expectedLocation' => null, + 'props' => [], + ]; + yield 'unknown_previous_location' => [ + 'previousLocation' => 'foo/bar', + 'expectedLocation' => null, + 'props' => [], + ]; + + yield 'no_prop' => [ + 'previousLocation' => '/route_with_prop/foo', + 'expectedLocation' => null, + 'props' => [], + ]; + + yield 'no_change' => [ + 'previousLocation' => '/route_with_prop/foo', + 'expectedLocation' => '/route_with_prop/foo', + 'props' => [ + 'pathProp' => 'foo', + ], + ]; + + yield 'prop_changed' => [ + 'previousLocation' => '/route_with_prop/foo', + 'expectedLocation' => '/route_with_prop/bar', + 'props' => [ + 'pathProp' => 'foo', + ], + 'updated' => [ + 'pathProp' => 'bar', + ], + ]; + + yield 'alias_prop_changed' => [ + 'previousLocation' => '/route_with_alias_prop/foo', + 'expectedLocation' => '/route_with_alias_prop/bar', + 'props' => [ + 'pathPropWithAlias' => 'foo', + ], + 'updated' => [ + 'pathPropWithAlias' => 'bar', + ], + ]; + } + + /** + * @dataProvider getTestData + */ + public function testNoHeader( + ?string $previousLocation, + ?string $expectedLocation, + array $props, + array $updated = [], + ): void { + $component = $this->mountComponent('component_with_url_bound_props', $props); + $dehydrated = $this->dehydrateComponent($component); + + $this->browser() + ->throwExceptions() + ->post( + '/_components/component_with_url_bound_props', + [ + 'body' => [ + 'data' => json_encode([ + 'props' => $dehydrated->getProps(), + 'updated' => $updated, + ]), + ], + 'headers' => [ + 'X-Live-Url' => $previousLocation, + ], + ] + ) + ->assertSuccessful() + ->assertHeaderEquals('X-Live-Url', $expectedLocation); + } +} From 67bdeffb3f031bfb6cc7cb47f6063770533fa378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Thu, 22 May 2025 18:03:08 +0200 Subject: [PATCH 13/18] CHANGELOG --- src/LiveComponent/CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index 84ed16c82dc..09f284b313e 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -1,5 +1,11 @@ # CHANGELOG +## 2.28.0 + +- Add new `mapPath` options (default `false`) to `UrlMapping` of a `LiveProp` + to allow the prop to be mapped to the path instead of the query in the url. + + ## 2.27.0 - Add events assertions in `InteractsWithLiveComponents`: From a8b58d2734c1165d07a65c08ef96662c47c84f04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Fri, 30 May 2025 14:37:55 +0200 Subject: [PATCH 14/18] doc --- src/LiveComponent/doc/index.rst | 40 +++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index b59142e1ddc..004a8a92f68 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -2677,6 +2677,46 @@ It can be used to perform more generic operations inside of the modifier that ca The ``query`` value will appear in the URL like ``/search?query=my+important+query&secondary-query=my+secondary+query``. +Map the parameter to path instead of query +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 2.27 + + The ``mapPath`` option was added in LiveComponents 2.27. + +Instead of setting the LiveProp as a query parameter, you can set it as route parameter. +definition:: + + // ... + use Symfony\UX\LiveComponent\Metadata\UrlMapping; + + #[AsLiveComponent] + class NodeModule + { + #[LiveProp(writable: true, url: new UrlMapping(mapPath: true))] + public string $id = ''; + + // ... + } + + +The if the symfony route is defined like this:: + + // src/Controller/NodeController.php + // ... + + #[Route('/node/{id}', name: 'node')] + public function node(NodeModule $nodeModule): Response + { + // ... + } + +Then the ``id`` value will appear in the URL like ``https://my.domain/node/my-node-id``. + +If the route parameter name is different from the LiveProp name, the ``as`` option can be used to map the LiveProp. + +If the route parameter is not defined, the ``mapPath`` option will be ignored and the LiveProp value will fallback to a query parameter. + Validating the Query Parameter Values ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ From 9a5b9cf495c00e8d140d06686ffd6ce4ff86125e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Fri, 11 Jul 2025 12:40:49 +0200 Subject: [PATCH 15/18] rebuild after rebase --- .../assets/dist/live_controller.js | 104 +----------------- 1 file changed, 1 insertion(+), 103 deletions(-) diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 680a9b20854..04c8a400da5 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -1796,109 +1796,7 @@ class ExternalMutationTracker { return element.tagName === 'FONT' && element.getAttribute('style') === 'vertical-align: inherit;'; } } -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(String(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 = [ From b838eccc42504d83ad33573368a0e2e24f2fb9cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Fri, 11 Jul 2025 15:04:57 +0200 Subject: [PATCH 16/18] returns --- .../assets/dist/Backend/BackendResponse.d.ts | 2 +- src/LiveComponent/assets/dist/live_controller.js | 6 +++--- .../assets/src/Backend/BackendResponse.ts | 4 ++-- src/LiveComponent/assets/src/Component/index.ts | 2 +- src/LiveComponent/doc/index.rst | 10 +++++----- .../src/EventListener/LiveComponentSubscriber.php | 2 -- src/LiveComponent/src/Util/UrlFactory.php | 8 ++++++-- .../tests/Unit/EventListener/LiveUrlSubscriberTest.php | 6 +++--- src/LiveComponent/tests/Unit/Util/UrlFactoryTest.php | 4 ++-- 9 files changed, 23 insertions(+), 21 deletions(-) diff --git a/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts b/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts index b6fef064a7d..71a1b4c0bab 100644 --- a/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts +++ b/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts @@ -4,5 +4,5 @@ export default class { private liveUrl; constructor(response: Response); getBody(): Promise; - getLiveUrl(): Promise; + getLiveUrl(): string | null; } diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 04c8a400da5..1ac46072642 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -112,9 +112,9 @@ class BackendResponse { } return this.body; } - async getLiveUrl() { + getLiveUrl() { if (undefined === this.liveUrl) { - this.liveUrl = await this.response.headers.get('X-Live-Url'); + this.liveUrl = this.response.headers.get('X-Live-Url'); } return this.liveUrl; } @@ -2144,7 +2144,7 @@ class Component { return response; } this.processRerender(html, backendResponse); - const liveUrl = await backendResponse.getLiveUrl(); + const liveUrl = backendResponse.getLiveUrl(); if (liveUrl) { history.replaceState(history.state, '', new URL(liveUrl + window.location.hash, window.location.origin)); } diff --git a/src/LiveComponent/assets/src/Backend/BackendResponse.ts b/src/LiveComponent/assets/src/Backend/BackendResponse.ts index afd963d2e02..c7d6d2cc3b5 100644 --- a/src/LiveComponent/assets/src/Backend/BackendResponse.ts +++ b/src/LiveComponent/assets/src/Backend/BackendResponse.ts @@ -15,9 +15,9 @@ export default class { return this.body; } - async getLiveUrl(): Promise { + getLiveUrl(): string | null { if (undefined === this.liveUrl) { - this.liveUrl = await this.response.headers.get('X-Live-Url'); + this.liveUrl = this.response.headers.get('X-Live-Url'); } return this.liveUrl; diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index b5619c3207a..2a7decb6ae4 100644 --- a/src/LiveComponent/assets/src/Component/index.ts +++ b/src/LiveComponent/assets/src/Component/index.ts @@ -328,7 +328,7 @@ export default class Component { } this.processRerender(html, backendResponse); - const liveUrl = await backendResponse.getLiveUrl(); + const liveUrl = backendResponse.getLiveUrl(); if (liveUrl) { history.replaceState( history.state, diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index 004a8a92f68..d7e15205f2b 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -2680,11 +2680,11 @@ The ``query`` value will appear in the URL like ``/search?query=my+important+que Map the parameter to path instead of query ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ -.. versionadded:: 2.27 +.. versionadded:: 2.28 - The ``mapPath`` option was added in LiveComponents 2.27. + The ``mapPath`` option was added in LiveComponents 2.28. -Instead of setting the LiveProp as a query parameter, you can set it as route parameter. +Instead of setting the ``LiveProp`` as a query parameter, you can set it as route parameter. definition:: // ... @@ -2700,7 +2700,7 @@ definition:: } -The if the symfony route is defined like this:: +If the symfony route is defined like this:: // src/Controller/NodeController.php // ... @@ -2713,7 +2713,7 @@ The if the symfony route is defined like this:: Then the ``id`` value will appear in the URL like ``https://my.domain/node/my-node-id``. -If the route parameter name is different from the LiveProp name, the ``as`` option can be used to map the LiveProp. +If the route parameter name is different from the LiveProp name, the ``as`` option can be used to map the ``LiveProp``. If the route parameter is not defined, the ``mapPath`` option will be ignored and the LiveProp value will fallback to a query parameter. diff --git a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php index 5e6597ae583..acde1db41a2 100644 --- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php @@ -258,8 +258,6 @@ public function onKernelView(ViewEvent $event): void $mountedComponent = $request->attributes->get('_mounted_component'); if (!$request->attributes->get('_component_default_action', false)) { // On custom action, props may be updated by the server side - // @todo discuss name responseProps - // @todo maybe always set in, including default action and use only this, ignoring `props` and `updated` for UrlFactory ? $liveRequestData = $request->attributes->get('_live_request_data'); $liveRequestData['responseProps'] = (array) $mountedComponent->getComponent(); $request->attributes->set('_live_request_data', $liveRequestData); diff --git a/src/LiveComponent/src/Util/UrlFactory.php b/src/LiveComponent/src/Util/UrlFactory.php index 0ad6062754b..f082394eaa9 100644 --- a/src/LiveComponent/src/Util/UrlFactory.php +++ b/src/LiveComponent/src/Util/UrlFactory.php @@ -74,7 +74,9 @@ private function replaceQueryString($url, array $props): string $queryString; } - // Keep the query parameters of the previous request + /** + * Keep the query parameters of the previous request. + */ private function getPreviousQueryParameters(string $query): array { parse_str($query, $previousQueryParams); @@ -82,7 +84,9 @@ private function getPreviousQueryParameters(string $query): array return $previousQueryParams; } - // Symfony router will set props in query if they do not match route parameter + /** + * 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); diff --git a/src/LiveComponent/tests/Unit/EventListener/LiveUrlSubscriberTest.php b/src/LiveComponent/tests/Unit/EventListener/LiveUrlSubscriberTest.php index 20c862c8842..cf40ac37f57 100644 --- a/src/LiveComponent/tests/Unit/EventListener/LiveUrlSubscriberTest.php +++ b/src/LiveComponent/tests/Unit/EventListener/LiveUrlSubscriberTest.php @@ -72,7 +72,7 @@ public function testDoNothing( $this->assertNull($response->headers->get('X-Live-Url')); } - public function getData(): iterable + public static function provideTestUrlFactoryReceivesPathAndQuertyPropsFromRequestData(): iterable { yield 'prop_without_matching_property' => [ 'liveRequestData' => [ @@ -137,9 +137,9 @@ public function getData(): iterable } /** - * @dataProvider getData + * @dataProvider provideTestUrlFactoryReceivesPathAndQuertyPropsFromRequestData */ - public function testProps( + public function testUrlFactoryReceivesPathAndQuertyPropsFromRequestData( array $liveRequestData, array $expectedPathProps = [], array $expectedQueryProps = [], diff --git a/src/LiveComponent/tests/Unit/Util/UrlFactoryTest.php b/src/LiveComponent/tests/Unit/Util/UrlFactoryTest.php index 40203fa4720..7e993cffe41 100644 --- a/src/LiveComponent/tests/Unit/Util/UrlFactoryTest.php +++ b/src/LiveComponent/tests/Unit/Util/UrlFactoryTest.php @@ -19,7 +19,7 @@ class UrlFactoryTest extends TestCase { - public function getData(): \Generator + public static function provideTestCreate(): \Generator { yield 'keep_default_url' => []; @@ -104,7 +104,7 @@ public function getData(): \Generator } /** - * @dataProvider getData + * @dataProvider provideTestCreate */ public function testCreate( array $input = [], From c2adbd034bc8e09ee5700476fc894da0a37fef5d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Wed, 16 Jul 2025 13:23:33 +0200 Subject: [PATCH 17/18] rewrite doc --- src/LiveComponent/doc/index.rst | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst index d7e15205f2b..ad84dc5e24d 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -2684,34 +2684,34 @@ Map the parameter to path instead of query The ``mapPath`` option was added in LiveComponents 2.28. -Instead of setting the ``LiveProp`` as a query parameter, you can set it as route parameter. -definition:: +Instead of setting the ``LiveProp`` as a query parameter, it can be set as route parameter +by passing the ``mapPath`` option to the ``UrlMapping`` defined for the ``LiveProp``:: // ... use Symfony\UX\LiveComponent\Metadata\UrlMapping; #[AsLiveComponent] - class NodeModule + class SearchModule { #[LiveProp(writable: true, url: new UrlMapping(mapPath: true))] - public string $id = ''; + public string $query = ''; // ... } -If the symfony route is defined like this:: +If the current route is defined like this:: - // src/Controller/NodeController.php + // src/Controller/SearchController.php // ... - #[Route('/node/{id}', name: 'node')] - public function node(NodeModule $nodeModule): Response + #[Route('/search/{query}')] + public function __invoke(string $query): Response { // ... } -Then the ``id`` value will appear in the URL like ``https://my.domain/node/my-node-id``. +Then the ``query`` value will appear in the URL like ``https://my.domain/search/my+query+string``. If the route parameter name is different from the LiveProp name, the ``as`` option can be used to map the ``LiveProp``. From d8d2e654487715ede222a9009f56dd5d47f706f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micka=C3=ABl=20Buliard?= Date: Wed, 16 Jul 2025 13:42:49 +0200 Subject: [PATCH 18/18] returns --- .../src/EventListener/LiveUrlSubscriber.php | 21 ++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php index cae92743c50..df959f82d53 100644 --- a/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php +++ b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php @@ -36,19 +36,20 @@ public function onKernelResponse(ResponseEvent $event): void if (!$event->isMainRequest()) { return; } + $request = $event->getRequest(); if (!$request->attributes->has('_live_component')) { return; } - $newUrl = null; - if ($previousLocation = $request->headers->get(self::URL_HEADER)) { - $liveProps = $this->getLivePropsToMap($request); - $newUrl = $this->urlFactory->createFromPreviousAndProps($previousLocation, $liveProps['path'], $liveProps['query']); + $newLiveUrl = null; + if ($previousLiveUrl = $request->headers->get(self::URL_HEADER)) { + $liveProps = $this->getLivePropsFromRequest($request); + $newLiveUrl = $this->urlFactory->createFromPreviousAndProps($previousLiveUrl, $liveProps['path'], $liveProps['query']); } - if ($newUrl) { - $event->getResponse()->headers->set(self::URL_HEADER, $newUrl); + if ($newLiveUrl) { + $event->getResponse()->headers->set(self::URL_HEADER, $newLiveUrl); } } @@ -59,7 +60,13 @@ public static function getSubscribedEvents(): array ]; } - private function getLivePropsToMap(Request $request): array + /** + * @return array{ + * path: array, + * query: array + * } + */ + private function getLivePropsFromRequest(Request $request): array { $componentName = $request->attributes->get('_live_component'); $metadata = $this->metadataFactory->getMetadata($componentName);