diff --git a/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts b/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts index a51a6448707..b6fef064a7d 100644 --- a/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts +++ b/src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts @@ -1,6 +1,8 @@ export default class { response: Response; private body; + private liveUrl; constructor(response: Response); getBody(): Promise; + getLiveUrl(): Promise; } diff --git a/src/LiveComponent/assets/dist/Component/plugins/QueryStringPlugin.d.ts b/src/LiveComponent/assets/dist/Component/plugins/QueryStringPlugin.d.ts deleted file mode 100644 index f91f5e6c871..00000000000 --- a/src/LiveComponent/assets/dist/Component/plugins/QueryStringPlugin.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type Component from '../index'; -import type { PluginInterface } from './PluginInterface'; -interface QueryMapping { - name: string; -} -export default class implements PluginInterface { - private readonly mapping; - constructor(mapping: { - [p: string]: QueryMapping; - }); - attachToComponent(component: Component): void; -} -export {}; diff --git a/src/LiveComponent/assets/dist/live_controller.d.ts b/src/LiveComponent/assets/dist/live_controller.d.ts index 7e5cff52474..21a6b186ce8 100644 --- a/src/LiveComponent/assets/dist/live_controller.d.ts +++ b/src/LiveComponent/assets/dist/live_controller.d.ts @@ -49,10 +49,6 @@ export default class LiveControllerDefault extends Controller imple type: StringConstructor; default: string; }; - queryMapping: { - type: ObjectConstructor; - default: {}; - }; }; readonly nameValue: string; readonly urlValue: string; @@ -76,11 +72,6 @@ export default class LiveControllerDefault extends Controller imple readonly debounceValue: number; readonly fingerprintValue: string; readonly requestMethodValue: 'get' | 'post'; - readonly queryMappingValue: { - [p: string]: { - name: string; - }; - }; private proxiedComponent; private mutationObserver; component: Component; diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index 41177512fdd..afb6899c40b 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -33,6 +33,7 @@ class RequestBuilder { fetchOptions.headers = { Accept: 'application/vnd.live-component+html', 'X-Requested-With': 'XMLHttpRequest', + 'X-Live-Url': window.location.pathname + window.location.search, }; const totalFiles = Object.entries(files).reduce((total, current) => total + current.length, 0); const hasFingerprints = Object.keys(children).length > 0; @@ -111,6 +112,12 @@ class BackendResponse { } return this.body; } + async getLiveUrl() { + if (undefined === this.liveUrl) { + this.liveUrl = await this.response.headers.get('X-Live-Url'); + } + return this.liveUrl; + } } function getElementAsTagText(element) { @@ -2137,6 +2144,10 @@ class Component { return response; } this.processRerender(html, backendResponse); + const liveUrl = await 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) { @@ -2741,129 +2752,6 @@ class PollingPlugin { } } -function isValueEmpty(value) { - if (null === value || value === '' || undefined === value || (Array.isArray(value) && value.length === 0)) { - return true; - } - if (typeof value !== 'object') { - return false; - } - for (const key of Object.keys(value)) { - if (!isValueEmpty(value[key])) { - return false; - } - } - return true; -} -function toQueryString(data) { - const buildQueryStringEntries = (data, entries = {}, baseKey = '') => { - Object.entries(data).forEach(([iKey, iValue]) => { - const key = baseKey === '' ? iKey : `${baseKey}[${iKey}]`; - if ('' === baseKey && isValueEmpty(iValue)) { - entries[key] = ''; - } - else if (null !== iValue) { - if (typeof iValue === 'object') { - entries = { ...entries, ...buildQueryStringEntries(iValue, entries, key) }; - } - else { - entries[key] = encodeURIComponent(iValue) - .replace(/%20/g, '+') - .replace(/%2C/g, ','); - } - } - }); - return entries; - }; - const entries = buildQueryStringEntries(data); - return Object.entries(entries) - .map(([key, value]) => `${key}=${value}`) - .join('&'); -} -function fromQueryString(search) { - search = search.replace('?', ''); - if (search === '') - return {}; - const insertDotNotatedValueIntoData = (key, value, data) => { - const [first, second, ...rest] = key.split('.'); - if (!second) { - data[key] = value; - return value; - } - if (data[first] === undefined) { - data[first] = Number.isNaN(Number.parseInt(second)) ? {} : []; - } - insertDotNotatedValueIntoData([second, ...rest].join('.'), value, data[first]); - }; - const entries = search.split('&').map((i) => i.split('=')); - const data = {}; - entries.forEach(([key, value]) => { - value = decodeURIComponent(value.replace(/\+/g, '%20')); - if (!key.includes('[')) { - data[key] = value; - } - else { - if ('' === value) - return; - const dotNotatedKey = key.replace(/\[/g, '.').replace(/]/g, ''); - insertDotNotatedValueIntoData(dotNotatedKey, value, data); - } - }); - return data; -} -class UrlUtils extends URL { - has(key) { - const data = this.getData(); - return Object.keys(data).includes(key); - } - set(key, value) { - const data = this.getData(); - data[key] = value; - this.setData(data); - } - get(key) { - return this.getData()[key]; - } - remove(key) { - const data = this.getData(); - delete data[key]; - this.setData(data); - } - getData() { - if (!this.search) { - return {}; - } - return fromQueryString(this.search); - } - setData(data) { - this.search = toQueryString(data); - } -} -class HistoryStrategy { - static replace(url) { - history.replaceState(history.state, '', url); - } -} - -class QueryStringPlugin { - constructor(mapping) { - this.mapping = mapping; - } - attachToComponent(component) { - component.on('render:finished', (component) => { - const urlUtils = new UrlUtils(window.location.href); - const currentUrl = urlUtils.toString(); - Object.entries(this.mapping).forEach(([prop, mapping]) => { - const value = component.valueStore.get(prop); - urlUtils.set(mapping.name, value); - }); - if (currentUrl !== urlUtils.toString()) { - HistoryStrategy.replace(urlUtils); - } - }); - } -} - class SetValueOntoModelFieldsPlugin { attachToComponent(component) { this.synchronizeValueOfModelFields(component); @@ -3073,7 +2961,6 @@ class LiveControllerDefault extends Controller { new PageUnloadingPlugin(), new PollingPlugin(), new SetValueOntoModelFieldsPlugin(), - new QueryStringPlugin(this.queryMappingValue), new ChildComponentPlugin(this.component), ]; plugins.forEach((plugin) => { @@ -3183,7 +3070,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..afd963d2e02 100644 --- a/src/LiveComponent/assets/src/Backend/BackendResponse.ts +++ b/src/LiveComponent/assets/src/Backend/BackendResponse.ts @@ -1,6 +1,7 @@ export default class { response: Response; private body: string; + private liveUrl: string | null; constructor(response: Response) { this.response = response; @@ -13,4 +14,12 @@ export default class { return this.body; } + + async getLiveUrl(): Promise { + if (undefined === this.liveUrl) { + this.liveUrl = await this.response.headers.get('X-Live-Url'); + } + + return this.liveUrl; + } } diff --git a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts index 533e34fece9..12311b6d64a 100644 --- a/src/LiveComponent/assets/src/Backend/RequestBuilder.ts +++ b/src/LiveComponent/assets/src/Backend/RequestBuilder.ts @@ -26,6 +26,7 @@ export default class { fetchOptions.headers = { Accept: 'application/vnd.live-component+html', 'X-Requested-With': 'XMLHttpRequest', + 'X-Live-Url': window.location.pathname + window.location.search, }; const totalFiles = Object.entries(files).reduce((total, current) => total + current.length, 0); diff --git a/src/LiveComponent/assets/src/Component/index.ts b/src/LiveComponent/assets/src/Component/index.ts index 7db1f564a7b..79c46c74ae9 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 = await 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 a9ea7f115ee..f0638451704 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -8,7 +8,6 @@ import LoadingPlugin from './Component/plugins/LoadingPlugin'; import PageUnloadingPlugin from './Component/plugins/PageUnloadingPlugin'; import type { PluginInterface } from './Component/plugins/PluginInterface'; import PollingPlugin from './Component/plugins/PollingPlugin'; -import QueryStringPlugin from './Component/plugins/QueryStringPlugin'; import SetValueOntoModelFieldsPlugin from './Component/plugins/SetValueOntoModelFieldsPlugin'; import ValidatedFieldsPlugin from './Component/plugins/ValidatedFieldsPlugin'; import { type DirectiveModifier, parseDirectives } from './Directive/directives_parser'; @@ -42,7 +41,6 @@ export default class LiveControllerDefault extends Controller imple debounce: { type: Number, default: 150 }, fingerprint: { type: String, default: '' }, requestMethod: { type: String, default: 'post' }, - queryMapping: { type: Object, default: {} }, }; declare readonly nameValue: string; @@ -61,7 +59,6 @@ export default class LiveControllerDefault extends Controller imple declare readonly debounceValue: number; declare readonly fingerprintValue: string; declare readonly requestMethodValue: 'get' | 'post'; - declare readonly queryMappingValue: { [p: string]: { name: string } }; /** The component, wrapped in the convenience Proxy */ private proxiedComponent: Component; @@ -301,7 +298,6 @@ export default class LiveControllerDefault extends Controller imple new PageUnloadingPlugin(), new PollingPlugin(), new SetValueOntoModelFieldsPlugin(), - new QueryStringPlugin(this.queryMappingValue), new ChildComponentPlugin(this.component), ]; plugins.forEach((plugin) => { diff --git a/src/LiveComponent/assets/src/url_utils.ts b/src/LiveComponent/assets/src/url_utils.ts deleted file mode 100644 index 93fedacfe7e..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(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 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; 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; 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 fcb711f59cc..00000000000 --- a/src/LiveComponent/assets/test/url_utils.test.ts +++ /dev/null @@ -1,134 +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('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/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index dc04bfb10fa..2d12f13a02a 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; @@ -135,6 +137,14 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) { ->addTag('container.service_subscriber', ['key' => LiveComponentMetadataFactory::class, 'id' => 'ux.live_component.metadata_factory']) ; + $container->register('ux.live_component.live_url_subscriber', LiveUrlSubscriber::class) + ->setArguments([ + new Reference('ux.live_component.metadata_factory'), + new Reference('ux.live_component.url_factory'), + ]) + ->addTag('kernel.event_subscriber') + ; + $container->register('ux.live_component.live_responder', LiveResponder::class); $container->setAlias(LiveResponder::class, 'ux.live_component.live_responder'); @@ -200,6 +210,9 @@ function (ChildDefinition $definition, AsLiveComponent $attribute) { $container->register('ux.live_component.attribute_helper_factory', TwigAttributeHelperFactory::class) ->setArguments([new Reference('twig')]); + $container->register('ux.live_component.url_factory', UrlFactory::class) + ->setArguments([new Reference('router')]); + $container->register('ux.live_component.live_controller_attributes_creator', LiveControllerAttributesCreator::class) ->setArguments([ new Reference('ux.live_component.metadata_factory'), @@ -222,12 +235,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..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 new file mode 100644 index 00000000000..085ce89bb49 --- /dev/null +++ b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.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\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; + +class LiveUrlSubscriber implements EventSubscriberInterface +{ + private const URL_HEADER = 'X-Live-Url'; + + public function __construct( + private LiveComponentMetadataFactory $metadataFactory, + private UrlFactory $urlFactory, + ) { + } + + public function onKernelResponse(ResponseEvent $event): void + { + $request = $event->getRequest(); + if (!$request->attributes->has('_live_component')) { + return; + } + if (!$event->isMainRequest()) { + return; + } + + $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'] + ); + } + } + + 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'] ?? [], + $liveData['responseProps'] ?? [] + ); + + $urlLiveProps = [ + 'path' => [], + 'query' => [], + ]; + foreach ($metadata->getAllLivePropsMetadata($component) as $liveProp) { + $name = $liveProp->getName(); + $urlMapping = $liveProp->urlMapping(); + if (isset($values[$name]) && $urlMapping) { + $urlLiveProps[$urlMapping->mapPath ? 'path' : 'query'][$urlMapping->as ?? $name] = + $values[$name]; + } + } + + return $urlLiveProps; + } +} diff --git a/src/LiveComponent/src/EventListener/QueryStringInitializeSubscriber.php b/src/LiveComponent/src/EventListener/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/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 86% rename from src/LiveComponent/src/Util/QueryStringPropsExtractor.php rename to src/LiveComponent/src/Util/RequestPropsExtractor.php index 48e852d70ba..865e0032116 100644 --- a/src/LiveComponent/src/Util/QueryStringPropsExtractor.php +++ b/src/LiveComponent/src/Util/RequestPropsExtractor.php @@ -22,20 +22,21 @@ * * @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)) { + // @todo never empty because custom values prefixed with _ ... do something ? + if (empty($parameters)) { return []; } $data = []; @@ -43,7 +44,7 @@ public function extract(Request $request, LiveComponentMetadata $metadata, objec foreach ($metadata->getAllLivePropsMetadata($component) as $livePropMetadata) { if ($queryMapping = $livePropMetadata->urlMapping()) { $frontendName = $livePropMetadata->calculateFieldName($component, $livePropMetadata->getName()); - if (null !== ($value = $query[$queryMapping->as ?? $frontendName] ?? null)) { + if (null !== ($value = $parameters[$queryMapping->as ?? $frontendName] ?? null)) { if ('' === $value && null !== $livePropMetadata->getType() && (!$livePropMetadata->isBuiltIn() || 'array' === $livePropMetadata->getType())) { // Cast empty string to empty array for objects and arrays $value = []; diff --git a/src/LiveComponent/src/Util/UrlFactory.php b/src/LiveComponent/src/Util/UrlFactory.php new file mode 100644 index 00000000000..cda618698f0 --- /dev/null +++ b/src/LiveComponent/src/Util/UrlFactory.php @@ -0,0 +1,86 @@ + + * + * 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\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']; + } + + $newUrl = $this->createPath($previousUrl, $pathMappedProps); + + return $this->replaceQueryString( + $newUrl, + array_merge( + $this->getPreviousQueryParameters($parsed['query'] ?? ''), + $this->getRemnantProps($newUrl), + $queryMappedProps, + ) + ); + } + + private function createPath(string $previousUrl, array $props): string + { + return $this->router->generate( + $this->router->match($previousUrl)['_route'] ?? '', + $props + ); + } + + private function replaceQueryString($url, array $props): string + { + $queryString = http_build_query($props); + + return preg_replace('/[?#].*/', '', $url). + ('' !== $queryString ? '?' : ''). + $queryString; + } + + // Keep the query parameters of the previous request + private function getPreviousQueryParameters(string $query): array + { + parse_str($query, $previousQueryParams); + + return $previousQueryParams; + } + + // Symfony router will set props in query if they do not match route parameter + private function getRemnantProps(string $newUrl): array + { + parse_str(parse_url($newUrl)['query'] ?? '', $remnantQueryParams); + + return $remnantQueryParams; + } +} diff --git a/src/LiveComponent/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 93% rename from src/LiveComponent/tests/Functional/Util/QueryStringPropsExtractorTest.php rename to src/LiveComponent/tests/Functional/Util/RequestPropsExtractorTest.php index cabfb98e406..fd29065b531 100644 --- a/src/LiveComponent/tests/Functional/Util/QueryStringPropsExtractorTest.php +++ b/src/LiveComponent/tests/Functional/Util/RequestPropsExtractorTest.php @@ -16,9 +16,9 @@ use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory; use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\Address; use Symfony\UX\LiveComponent\Tests\LiveComponentTestHelper; -use Symfony\UX\LiveComponent\Util\QueryStringPropsExtractor; +use Symfony\UX\LiveComponent\Util\RequestPropsExtractor; -class QueryStringPropsExtractorTest extends KernelTestCase +class RequestPropsExtractorTest extends KernelTestCase { use LiveComponentTestHelper; @@ -27,7 +27,7 @@ class QueryStringPropsExtractorTest extends KernelTestCase */ public function testExtract(string $queryString, array $expected) { - $extractor = new QueryStringPropsExtractor($this->hydrator()); + $extractor = new RequestPropsExtractor($this->hydrator()); $request = Request::create('/'.!empty($queryString) ? '?'.$queryString : ''); diff --git a/src/LiveComponent/tests/Unit/Util/UrlFactoryTest.php b/src/LiveComponent/tests/Unit/Util/UrlFactoryTest.php new file mode 100644 index 00000000000..89a2f304f5d --- /dev/null +++ b/src/LiveComponent/tests/Unit/Util/UrlFactoryTest.php @@ -0,0 +1,139 @@ + []; + + 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); + } + + 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; + } +}