diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md
index 89b02d5a10e..29a693f308a 100644
--- a/src/LiveComponent/CHANGELOG.md
+++ b/src/LiveComponent/CHANGELOG.md
@@ -6,6 +6,9 @@
- `min_length` and `max_length`: validate length from textual input elements
- `min_value` and `max_value`: validate value from numeral input elements
+- 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.
+
```twig
diff --git a/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts b/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts
index a51a6448707..71a1b4c0bab 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(): string | null;
}
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 7c906620a25..96f5062d316 100644
--- a/src/LiveComponent/assets/dist/live_controller.js
+++ b/src/LiveComponent/assets/dist/live_controller.js
@@ -33,6 +33,7 @@ class RequestBuilder {
fetchOptions.headers = {
Accept: 'application/vnd.live-component+html',
'X-Requested-With': 'XMLHttpRequest',
+ 'X-Live-Url': window.location.pathname + window.location.search,
};
const totalFiles = Object.entries(files).reduce((total, current) => total + current.length, 0);
const hasFingerprints = Object.keys(children).length > 0;
@@ -111,6 +112,12 @@ class BackendResponse {
}
return this.body;
}
+ getLiveUrl() {
+ if (undefined === this.liveUrl) {
+ this.liveUrl = this.response.headers.get('X-Live-Url');
+ }
+ return this.liveUrl;
+ }
}
function getElementAsTagText(element) {
@@ -2146,6 +2153,10 @@ class Component {
return response;
}
this.processRerender(html, backendResponse);
+ const liveUrl = backendResponse.getLiveUrl();
+ if (liveUrl) {
+ history.replaceState(history.state, '', new URL(liveUrl + window.location.hash, window.location.origin));
+ }
this.backendRequest = null;
thisPromiseResolve(backendResponse);
if (this.isRequestPending) {
@@ -2770,129 +2781,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;
- }
- 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);
@@ -3102,7 +2990,6 @@ class LiveControllerDefault extends Controller {
new PageUnloadingPlugin(),
new PollingPlugin(),
new SetValueOntoModelFieldsPlugin(),
- new QueryStringPlugin(this.queryMappingValue),
new ChildComponentPlugin(this.component),
];
plugins.forEach((plugin) => {
@@ -3233,7 +3120,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/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/Backend/BackendResponse.ts b/src/LiveComponent/assets/src/Backend/BackendResponse.ts
index 5b1357bd24e..c7d6d2cc3b5 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;
}
+
+ getLiveUrl(): string | null {
+ if (undefined === this.liveUrl) {
+ this.liveUrl = this.response.headers.get('X-Live-Url');
+ }
+
+ return this.liveUrl;
+ }
}
diff --git a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts
index 533e34fece9..12311b6d64a 100644
--- a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts
+++ b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts
@@ -26,6 +26,7 @@ export default class {
fetchOptions.headers = {
Accept: 'application/vnd.live-component+html',
'X-Requested-With': 'XMLHttpRequest',
+ 'X-Live-Url': window.location.pathname + window.location.search,
};
const totalFiles = Object.entries(files).reduce((total, current) => total + current.length, 0);
diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts
index b0be5f7b384..2a7decb6ae4 100644
--- a/src/LiveComponent/assets/src/Component/index.ts
+++ b/src/LiveComponent/assets/src/Component/index.ts
@@ -328,6 +328,14 @@ export default class Component {
}
this.processRerender(html, backendResponse);
+ const liveUrl = backendResponse.getLiveUrl();
+ if (liveUrl) {
+ history.replaceState(
+ history.state,
+ '',
+ new URL(liveUrl + window.location.hash, window.location.origin)
+ );
+ }
// finally resolve this promise
this.backendRequest = null;
diff --git a/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts b/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts
deleted file mode 100644
index c0ac2f08849..00000000000
--- a/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { HistoryStrategy, UrlUtils } from '../../url_utils';
-import type Component from '../index';
-import type { PluginInterface } from './PluginInterface';
-
-interface QueryMapping {
- /**
- * URL parameter name
- */
- name: string;
-}
-
-export default class implements PluginInterface {
- constructor(private readonly mapping: { [p: string]: QueryMapping }) {}
-
- attachToComponent(component: Component): void {
- component.on('render:finished', (component: Component) => {
- const urlUtils = new UrlUtils(window.location.href);
- const currentUrl = urlUtils.toString();
-
- Object.entries(this.mapping).forEach(([prop, mapping]) => {
- const value = component.valueStore.get(prop);
- urlUtils.set(mapping.name, value);
- });
-
- // Only update URL if it has changed
- if (currentUrl !== urlUtils.toString()) {
- HistoryStrategy.replace(urlUtils);
- }
- });
- }
-}
diff --git a/src/LiveComponent/assets/src/live_controller.ts b/src/LiveComponent/assets/src/live_controller.ts
index baa9a65b907..05ceadd21ff 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';
@@ -50,7 +49,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;
@@ -69,7 +67,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;
@@ -309,7 +306,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/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/Backend/RequestBuilder.test.ts b/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts
index 1b55fd27f4f..3aa503a104f 100644
--- a/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts
+++ b/src/LiveComponent/assets/test/Backend/RequestBuilder.test.ts
@@ -19,6 +19,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',
});
});
@@ -43,6 +44,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;
@@ -116,6 +118,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;
@@ -146,6 +149,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;
@@ -231,6 +235,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;
@@ -255,6 +260,7 @@ describe('buildRequest', () => {
expect(fetchOptions.method).toEqual('POST');
expect(fetchOptions.headers).toEqual({
Accept: 'application/vnd.live-component+html',
+ 'X-Live-Url': '/',
'X-Requested-With': 'XMLHttpRequest',
});
const body = fetchOptions.body;
diff --git a/src/LiveComponent/assets/test/controller/query-binding.test.ts b/src/LiveComponent/assets/test/controller/query-binding.test.ts
index cde1fbbf21e..b20c5846c2e 100644
--- a/src/LiveComponent/assets/test/controller/query-binding.test.ts
+++ b/src/LiveComponent/assets/test/controller/query-binding.test.ts
@@ -50,14 +50,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);
@@ -66,14 +66,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);
@@ -89,21 +89,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);
@@ -119,28 +123,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);
@@ -162,13 +172,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 () => {
@@ -180,14 +192,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 975241b16f9..5b38c103609 100644
--- a/src/LiveComponent/assets/test/tools.ts
+++ b/src/LiveComponent/assets/test/tools.ts
@@ -174,6 +174,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;
@@ -270,10 +271,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,
@@ -343,6 +350,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;
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 3a6353ed7c9..00000000000
--- a/src/LiveComponent/assets/test/url_utils.test.ts
+++ /dev/null
@@ -1,161 +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 { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest';
-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());
- });
- });
-});
diff --git a/src/LiveComponent/doc/index.rst b/src/LiveComponent/doc/index.rst
index a16f65f5e7e..058e4cd5d67 100644
--- a/src/LiveComponent/doc/index.rst
+++ b/src/LiveComponent/doc/index.rst
@@ -2737,6 +2737,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.28
+
+ The ``mapPath`` option was added in LiveComponents 2.28.
+
+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 SearchModule
+ {
+ #[LiveProp(writable: true, url: new UrlMapping(mapPath: true))]
+ public string $query = '';
+
+ // ...
+ }
+
+
+If the current route is defined like this::
+
+ // src/Controller/SearchController.php
+ // ...
+
+ #[Route('/search/{query}')]
+ public function __invoke(string $query): Response
+ {
+ // ... render template that uses SearchModule component ...
+ }
+
+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``.
+
+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
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php
index c0a832f964d..4315638e869 100644
--- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php
+++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php
@@ -33,7 +33,8 @@
use Symfony\UX\LiveComponent\EventListener\DeferLiveComponentSubscriber;
use Symfony\UX\LiveComponent\EventListener\InterceptChildComponentRenderSubscriber;
use Symfony\UX\LiveComponent\EventListener\LiveComponentSubscriber;
-use Symfony\UX\LiveComponent\EventListener\QueryStringInitializeSubscriber;
+use Symfony\UX\LiveComponent\EventListener\LiveUrlSubscriber;
+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;
@@ -50,8 +51,9 @@
use Symfony\UX\LiveComponent\Util\FingerprintCalculator;
use Symfony\UX\LiveComponent\Util\LiveComponentStack;
use Symfony\UX\LiveComponent\Util\LiveControllerAttributesCreator;
-use Symfony\UX\LiveComponent\Util\QueryStringPropsExtractor;
+use Symfony\UX\LiveComponent\Util\RequestPropsExtractor;
use Symfony\UX\LiveComponent\Util\TwigAttributeHelperFactory;
+use Symfony\UX\LiveComponent\Util\UrlFactory;
use Symfony\UX\TwigComponent\ComponentFactory;
use Symfony\UX\TwigComponent\ComponentRenderer;
@@ -136,6 +138,14 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) {
->addTag('container.service_subscriber', ['key' => LiveComponentMetadataFactory::class, 'id' => 'ux.live_component.metadata_factory'])
;
+ $container->register('ux.live_component.live_url_subscriber', LiveUrlSubscriber::class)
+ ->setArguments([
+ new Reference('ux.live_component.metadata_factory'),
+ new Reference('ux.live_component.url_factory'),
+ ])
+ ->addTag('kernel.event_subscriber')
+ ;
+
$container->register('ux.live_component.live_responder', LiveResponder::class);
$container->setAlias(LiveResponder::class, 'ux.live_component.live_responder');
@@ -202,6 +212,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'),
@@ -225,12 +238,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/LiveComponentSubscriber.php b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php
index 58b5df6d111..acde1db41a2 100644
--- a/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php
+++ b/src/LiveComponent/src/EventListener/LiveComponentSubscriber.php
@@ -255,7 +255,15 @@ 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
+ $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
new file mode 100644
index 00000000000..df959f82d53
--- /dev/null
+++ b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php
@@ -0,0 +1,94 @@
+
+ *
+ * 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\UX\LiveComponent\Metadata\LiveComponentMetadataFactory;
+use Symfony\UX\LiveComponent\Util\UrlFactory;
+
+/**
+ * @internal
+ */
+class LiveUrlSubscriber implements EventSubscriberInterface
+{
+ private const URL_HEADER = 'X-Live-Url';
+
+ public function __construct(
+ private LiveComponentMetadataFactory $metadataFactory,
+ private UrlFactory $urlFactory,
+ ) {
+ }
+
+ public function onKernelResponse(ResponseEvent $event): void
+ {
+ if (!$event->isMainRequest()) {
+ return;
+ }
+
+ $request = $event->getRequest();
+ if (!$request->attributes->has('_live_component')) {
+ return;
+ }
+
+ $newLiveUrl = null;
+ if ($previousLiveUrl = $request->headers->get(self::URL_HEADER)) {
+ $liveProps = $this->getLivePropsFromRequest($request);
+ $newLiveUrl = $this->urlFactory->createFromPreviousAndProps($previousLiveUrl, $liveProps['path'], $liveProps['query']);
+ }
+
+ if ($newLiveUrl) {
+ $event->getResponse()->headers->set(self::URL_HEADER, $newLiveUrl);
+ }
+ }
+
+ public static function getSubscribedEvents(): array
+ {
+ return [
+ KernelEvents::RESPONSE => 'onKernelResponse',
+ ];
+ }
+
+ /**
+ * @return array{
+ * path: array,
+ * query: array
+ * }
+ */
+ private function getLivePropsFromRequest(Request $request): array
+ {
+ $componentName = $request->attributes->get('_live_component');
+ $metadata = $this->metadataFactory->getMetadata($componentName);
+
+ $liveRequestData = $request->attributes->get('_live_request_data') ?? [];
+ $values = array_merge(
+ $liveRequestData['props'] ?? [],
+ $liveRequestData['updated'] ?? [],
+ $liveRequestData['responseProps'] ?? []
+ );
+
+ $urlLiveProps = [
+ 'path' => [],
+ 'query' => [],
+ ];
+ foreach ($metadata->getAllUrlMappings() as $name => $urlMapping) {
+ if (isset($values[$name]) && $urlMapping) {
+ $urlLiveProps[$urlMapping->mapPath ? 'path' : 'query'][$urlMapping->as ?? $name] =
+ $values[$name];
+ }
+ }
+
+ return $urlLiveProps;
+ }
+}
diff --git a/src/LiveComponent/src/EventListener/QueryStringInitializeSubscriber.php b/src/LiveComponent/src/EventListener/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/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/src/Metadata/UrlMapping.php b/src/LiveComponent/src/Metadata/UrlMapping.php
index be7fd86195e..24150156c9b 100644
--- a/src/LiveComponent/src/Metadata/UrlMapping.php
+++ b/src/LiveComponent/src/Metadata/UrlMapping.php
@@ -12,7 +12,7 @@
namespace Symfony\UX\LiveComponent\Metadata;
/**
- * Mapping configuration to bind a LiveProp to a URL query parameter.
+ * Mapping configuration to bind a LiveProp to a URL path or query parameter.
*
* @author Nicolas Rigaud
*/
@@ -23,6 +23,11 @@ public function __construct(
* The name of the prop that appears in the URL. If null, the LiveProp's field name is used.
*/
public readonly ?string $as = null,
+
+ /**
+ * True if the prop should be mapped to the path if it matches one of its parameters. Otherwise a query parameter will be used.
+ */
+ public readonly bool $mapPath = false,
) {
}
}
diff --git a/src/LiveComponent/src/Util/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/src/Util/UrlFactory.php b/src/LiveComponent/src/Util/UrlFactory.php
new file mode 100644
index 00000000000..f082394eaa9
--- /dev/null
+++ b/src/LiveComponent/src/Util/UrlFactory.php
@@ -0,0 +1,96 @@
+
+ *
+ * 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/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 9b39acb9a27..2038fd4f1d5 100644
--- a/src/LiveComponent/tests/Fixtures/Kernel.php
+++ b/src/LiveComponent/tests/Fixtures/Kernel.php
@@ -217,5 +217,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);
+ }
+}
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']],
];
}
}
diff --git a/src/LiveComponent/tests/Unit/EventListener/LiveUrlSubscriberTest.php b/src/LiveComponent/tests/Unit/EventListener/LiveUrlSubscriberTest.php
new file mode 100644
index 00000000000..cf40ac37f57
--- /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 static function provideTestUrlFactoryReceivesPathAndQuertyPropsFromRequestData(): 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 provideTestUrlFactoryReceivesPathAndQuertyPropsFromRequestData
+ */
+ public function testUrlFactoryReceivesPathAndQuertyPropsFromRequestData(
+ 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'));
+ }
+}
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']);
+ }
}
diff --git a/src/LiveComponent/tests/Unit/Util/UrlFactoryTest.php b/src/LiveComponent/tests/Unit/Util/UrlFactoryTest.php
new file mode 100644
index 00000000000..7e993cffe41
--- /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 static function provideTestCreate(): \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 provideTestCreate
+ */
+ 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;
+ }
+}