diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index 7ba0351676c..8e49ae71cb0 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -1,5 +1,9 @@ # CHANGELOG +## 2.26.0 +- Add new `mapPath` options (default `false`) to `UrlMapping` of a `LiveProp` + to allow the prop to be mapped to the path instead of the query in the url. + ## 2.23.0 - Allow configuring the secret used to compute fingerprints and checksums. 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/doc/index.rst b/src/LiveComponent/doc/index.rst index a473881d50b..55bdedb437b 100644 --- a/src/LiveComponent/doc/index.rst +++ b/src/LiveComponent/doc/index.rst @@ -2651,6 +2651,44 @@ This way you can also use the component multiple times in the same page and avoi +Map the parameter to path instead of query +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 2.26 + + The ``mapPath`` option was added in LiveComponents 2.26. + +Instead of setting the LiveProp as a query parameter, you can set it as route parameter. +definition:: + + // ... + use Symfony\UX\LiveComponent\Metadata\UrlMapping; + + #[AsLiveComponent] + class NodeModule + { + #[LiveProp(writable: true, url: new UrlMapping(mapPath: true))] + public string $id = ''; + + // ... + } + + +The if the symfony route is defined like this:: + + #[Route('/node/{id}', name: 'node')] + public function node(NodeModule $nodeModule): Response + { + // ... + } + +Then the ``id`` value will appear in the URL like ``https://my.domain/node/my-node-id``. + +If the route parameter name is different from the LiveProp name, the ``as`` option can be used to map the LiveProp. + +If the route parameter is not defined, the ``mapPath`` option will be ignored and the LiveProp value will fallback to a query parameter. + + Validating the Query Parameter Values ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 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..7e486a1d370 --- /dev/null +++ b/src/LiveComponent/src/EventListener/LiveUrlSubscriber.php @@ -0,0 +1,95 @@ + + * + * 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 + { + $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); + $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); + + $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 667449151f3..512194c2acf 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 89% rename from src/LiveComponent/src/Util/QueryStringPropsExtractor.php rename to src/LiveComponent/src/Util/RequestPropsExtractor.php index 48e852d70ba..a2bcb1c03a4 100644 --- a/src/LiveComponent/src/Util/QueryStringPropsExtractor.php +++ b/src/LiveComponent/src/Util/RequestPropsExtractor.php @@ -22,20 +22,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 = []; @@ -43,7 +43,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..0ad6062754b --- /dev/null +++ b/src/LiveComponent/src/Util/UrlFactory.php @@ -0,0 +1,92 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Util; + +use Symfony\Component\Routing\Exception\MissingMandatoryParametersException; +use Symfony\Component\Routing\Exception\ResourceNotFoundException; +use Symfony\Component\Routing\RouterInterface; + +/** + * @internal + */ +class UrlFactory +{ + public function __construct( + private RouterInterface $router, + ) { + } + + public function createFromPreviousAndProps( + string $previousUrl, + array $pathMappedProps, + array $queryMappedProps, + ): ?string { + $parsed = parse_url($previousUrl); + if (false === $parsed) { + return null; + } + + // Make sure to handle only path and query + $previousUrl = $parsed['path'] ?? ''; + if (isset($parsed['query'])) { + $previousUrl .= '?'.$parsed['query']; + } + + try { + $newUrl = $this->createPath($previousUrl, $pathMappedProps); + } catch (ResourceNotFoundException|MissingMandatoryParametersException) { + return null; + } + + return $this->replaceQueryString( + $newUrl, + array_merge( + $this->getPreviousQueryParameters($parsed['query'] ?? ''), + $this->getRemnantProps($newUrl), + $queryMappedProps, + ) + ); + } + + private function createPath(string $previousUrl, array $props): string + { + return $this->router->generate( + $this->router->match($previousUrl)['_route'] ?? '', + $props + ); + } + + private function replaceQueryString($url, array $props): string + { + $queryString = http_build_query($props); + + return preg_replace('/[?#].*/', '', $url). + ('' !== $queryString ? '?' : ''). + $queryString; + } + + // Keep the query parameters of the previous request + private function getPreviousQueryParameters(string $query): array + { + parse_str($query, $previousQueryParams); + + return $previousQueryParams; + } + + // Symfony router will set props in query if they do not match route parameter + private function getRemnantProps(string $newUrl): array + { + parse_str(parse_url($newUrl)['query'] ?? '', $remnantQueryParams); + + return $remnantQueryParams; + } +} diff --git a/src/LiveComponent/tests/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 508dd24274b..554cca0e6dc 100644 --- a/src/LiveComponent/tests/Fixtures/Kernel.php +++ b/src/LiveComponent/tests/Fixtures/Kernel.php @@ -216,5 +216,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..d3d3f9972f1 --- /dev/null +++ b/src/LiveComponent/tests/Unit/EventListener/LiveUrlSubscriberTest.php @@ -0,0 +1,194 @@ + + * + * 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\Attribute\LiveProp; +use Symfony\UX\LiveComponent\EventListener\LiveUrlSubscriber; +use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadata; +use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory; +use Symfony\UX\LiveComponent\Metadata\LivePropMetadata; +use Symfony\UX\LiveComponent\Metadata\UrlMapping; +use Symfony\UX\LiveComponent\Util\UrlFactory; + +class LiveUrlSubscriberTest extends TestCase +{ + public function getIgnoreData(): iterable + { + yield 'not_a_live_component' => [ + 'attributes' => [], + 'requestType' => HttpKernelInterface::MAIN_REQUEST, + 'headers' => ['X-Live-Url' => '/foo/bar'], + ]; + yield 'not_main_request' => [ + 'attributes' => ['_live_component' => 'componentName'], + 'requestType' => HttpKernelInterface::SUB_REQUEST, + 'headers' => ['X-Live-Url' => '/foo/bar'], + ]; + yield 'no_previous_url' => [ + 'attributes' => ['_live_component' => 'componentName'], + 'requestType' => HttpKernelInterface::MAIN_REQUEST, + 'headers' => [], + ]; + } + + /** + * @dataProvider getIgnoreData + */ + public function testDoNothing( + array $attributes = ['_live_component' => 'componentName'], + int $requestType = HttpKernelInterface::MAIN_REQUEST, + array $headers = ['X-Live-Url' => '/foo/bar'], + ): void { + $request = new Request(); + $request->attributes->add($attributes); + $request->headers->add($headers); + $response = new Response(); + $event = new ResponseEvent( + $this->createMock(HttpKernelInterface::class), + $request, + $requestType, + $response + ); + + $metadataFactory = $this->createMock(LiveComponentMetadataFactory::class); + $metadataFactory->expects(self::never())->method('getMetadata'); + $urlFactory = $this->createMock(UrlFactory::class); + $urlFactory->expects(self::never())->method('createFromPreviousAndProps'); + $liveUrlSubscriber = new LiveUrlSubscriber($metadataFactory, $urlFactory); + + $liveUrlSubscriber->onKernelResponse($event); + $this->assertNull($response->headers->get('X-Live-Url')); + } + + public function getData(): iterable + { + yield 'prop_without_matching_property' => [ + 'liveRequestData' => [ + 'props' => ['notMatchingProp' => 0], + ], + ]; + yield 'prop_matching_non_mapped_property' => [ + 'liveRequestData' => [ + 'props' => ['nonMappedProp' => 0], + ], + ]; + yield 'props_matching_query_mapped_properties' => [ + 'liveRequestData' => [ + 'props' => ['queryMappedProp1' => 1], + 'updated' => ['queryMappedProp2' => 2], + 'responseProps' => ['queryMappedProp3' => 3], + ], + 'expectedPathProps' => [], + 'expectedQueryProps' => [ + 'queryMappedProp1' => 1, + 'queryMappedProp2' => 2, + 'queryMappedProp3' => 3, + ], + ]; + yield 'props_matching_path_mapped_properties' => [ + 'liveRequestData' => [ + 'props' => ['pathMappedProp1' => 1], + 'updated' => ['pathMappedProp2' => 2], + 'responseProps' => ['pathMappedProp3' => 3], + ], + 'expectedPathProps' => [ + 'pathMappedProp1' => 1, + 'pathMappedProp2' => 2, + 'pathMappedProp3' => 3, + ], + 'expectedQueryProps' => [], + ]; + yield 'props_matching_properties_with_alias' => [ + 'liveRequestData' => [ + 'props' => ['pathMappedPropWithAlias' => 1, 'queryMappedPropWithAlias' => 2], + ], + 'expectedPathProps' => ['pathAlias' => 1], + 'expectedQueryProps' => ['queryAlias' => 2], + ]; + yield 'responseProps_have_highest_priority' => [ + 'liveRequestData' => [ + 'props' => ['queryMappedProp1' => 1], + 'updated' => ['queryMappedProp1' => 2], + 'responseProps' => ['queryMappedProp1' => 3], + ], + 'expectedPathProps' => [], + 'expectedQueryProps' => ['queryMappedProp1' => 3], + ]; + yield 'updated_have_second_priority' => [ + 'liveRequestData' => [ + 'props' => ['queryMappedProp1' => 1], + 'updated' => ['queryMappedProp1' => 2], + ], + 'expectedPathProps' => [], + 'expectedQueryProps' => ['queryMappedProp1' => 2], + ]; + } + + /** + * @dataProvider getData + */ + public function testProps( + array $liveRequestData, + array $expectedPathProps = [], + array $expectedQueryProps = [], + ): void { + $previousLocation = '/foo/bar'; + $newLocation = '/foo/baz'; + $componentName = 'componentName'; + $component = $this->createMock(\stdClass::class); + $metaData = $this->createMock(LiveComponentMetadata::class); + $metaData->expects(self::once()) + ->method('getAllUrlMappings') + ->willReturn([ + 'nonMappedProp' => false, + 'queryMappedProp1' => new UrlMapping(), + 'queryMappedProp2' => new UrlMapping(), + 'queryMappedProp3' => new UrlMapping(), + 'pathMappedProp1' => new UrlMapping(mapPath: true), + 'pathMappedProp2' => new UrlMapping(mapPath: true), + 'pathMappedProp3' => new UrlMapping(mapPath: true), + 'queryMappedPropWithAlias' => new UrlMapping(as: 'queryAlias'), + 'pathMappedPropWithAlias' => new UrlMapping(as: 'pathAlias', mapPath: true), + ]); + $request = new Request(); + $request->attributes->add([ + '_live_component' => $componentName, + '_mounted_component' => $component, + '_live_request_data' => $liveRequestData, + ]); + $request->headers->add(['X-Live-Url' => $previousLocation]); + $response = new Response(); + $event = new ResponseEvent( + $this->createMock(HttpKernelInterface::class), + $request, + HttpKernelInterface::MAIN_REQUEST, + $response + ); + + $metadataFactory = $this->createMock(LiveComponentMetadataFactory::class); + $metadataFactory->expects(self::once())->method('getMetadata')->with($componentName)->willReturn($metaData); + $urlFactory = $this->createMock(UrlFactory::class); + $liveUrlSubscriber = new LiveUrlSubscriber($metadataFactory, $urlFactory); + + $urlFactory->expects(self::once()) + ->method('createFromPreviousAndProps') + ->with($previousLocation, $expectedPathProps, $expectedQueryProps) + ->willReturn($newLocation); + $liveUrlSubscriber->onKernelResponse($event); + $this->assertEquals($newLocation, $response->headers->get('X-Live-Url')); + } +} 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..fb4a2e3a062 --- /dev/null +++ b/src/LiveComponent/tests/Unit/Util/UrlFactoryTest.php @@ -0,0 +1,179 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Unit\Util; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\Routing\Exception\MissingMandatoryParametersException; +use Symfony\Component\Routing\Exception\ResourceNotFoundException; +use Symfony\Component\Routing\RouterInterface; +use Symfony\UX\LiveComponent\Util\UrlFactory; + +class UrlFactoryTest extends TestCase +{ + public function getData(): \Generator + { + yield 'keep_default_url' => []; + + yield 'keep_relative_url' => [ + 'input' => ['previousUrl' => '/foo/bar'], + 'expectedUrl' => '/foo/bar', + ]; + + yield 'keep_absolute_url' => [ + 'input' => ['previousUrl' => 'https://symfony.com/foo/bar'], + 'expectedUrl' => '/foo/bar', + 'routerStubData' => [ + 'previousUrl' => '/foo/bar', + 'newUrl' => '/foo/bar', + ], + ]; + + yield 'keep_url_with_query_parameters' => [ + 'input' => ['previousUrl' => 'https://symfony.com/foo/bar?prop1=val1&prop2=val2'], + '/foo/bar?prop1=val1&prop2=val2', + 'routerStubData' => [ + 'previousUrl' => '/foo/bar?prop1=val1&prop2=val2', + 'newUrl' => '/foo/bar?prop1=val1&prop2=val2', + ], + ]; + + yield 'add_query_parameters' => [ + 'input' => [ + 'previousUrl' => '/foo/bar', + 'queryMappedProps' => ['prop1' => 'val1', 'prop2' => 'val2'], + ], + 'expectedUrl' => '/foo/bar?prop1=val1&prop2=val2', + ]; + + yield 'override_previous_matching_query_parameters' => [ + 'input' => [ + 'previousUrl' => '/foo/bar?prop1=oldValue&prop3=oldValue', + 'queryMappedProps' => ['prop1' => 'val1', 'prop2' => 'val2'], + ], + 'expectedUrl' => '/foo/bar?prop1=val1&prop3=oldValue&prop2=val2', + ]; + + yield 'add_path_parameters' => [ + 'input' => [ + 'previousUrl' => '/foo/bar', + 'pathMappedProps' => ['value' => 'baz'], + ], + 'expectedUrl' => '/foo/baz', + 'routerStubData' => [ + 'previousUrl' => '/foo/bar', + 'newUrl' => '/foo/baz', + 'props' => ['value' => 'baz'], + ], + ]; + + yield 'add_both_parameters' => [ + 'input' => [ + 'previousUrl' => '/foo/bar', + 'pathMappedProps' => ['value' => 'baz'], + 'queryMappedProps' => ['filter' => 'all'], + ], + 'expectedUrl' => '/foo/baz?filter=all', + 'routerStubData' => [ + 'previousUrl' => '/foo/bar', + 'newUrl' => '/foo/baz', + 'props' => ['value' => 'baz'], + ], + ]; + + yield 'handle_path_parameter_not_recognized' => [ + 'input' => [ + 'previousUrl' => '/foo/bar', + 'pathMappedProps' => ['value' => 'baz'], + ], + 'expectedUrl' => '/foo/bar?value=baz', + 'routerStubData' => [ + 'previousUrl' => '/foo/bar', + 'newUrl' => '/foo/bar?value=baz', + 'props' => ['value' => 'baz'], + ], + ]; + } + + /** + * @dataProvider getData + */ + public function testCreate( + array $input = [], + string $expectedUrl = '', + array $routerStubData = [], + ): void { + $previousUrl = $input['previousUrl'] ?? ''; + $router = $this->createRouterStub( + $routerStubData['previousUrl'] ?? $previousUrl, + $routerStubData['newUrl'] ?? $previousUrl, + $routerStubData['props'] ?? [], + ); + $factory = new UrlFactory($router); + $newUrl = $factory->createFromPreviousAndProps( + $previousUrl, + $input['pathMappedProps'] ?? [], + $input['queryMappedProps'] ?? [] + ); + + $this->assertEquals($expectedUrl, $newUrl); + } + + public function testResourceNotFoundException(): void + { + $previousUrl = '/foo/bar'; + $router = $this->createMock(RouterInterface::class); + $router->expects(self::once()) + ->method('match') + ->with($previousUrl) + ->willThrowException(new ResourceNotFoundException()); + $factory = new UrlFactory($router); + + $this->assertNull($factory->createFromPreviousAndProps($previousUrl, [], [])); + } + + public function testMissingMandatoryParametersException(): void + { + $previousUrl = '/foo/bar'; + $matchedRoute = 'foo_bar'; + $router = $this->createMock(RouterInterface::class); + $router->expects(self::once()) + ->method('match') + ->with($previousUrl) + ->willReturn(['_route' => $matchedRoute]); + $router->expects(self::once()) + ->method('generate') + ->with($matchedRoute, []) + ->willThrowException(new MissingMandatoryParametersException($matchedRoute)); + $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; + } +}