From 68e4dd39efc2bf92a0b7369c0f41af97eb0860de Mon Sep 17 00:00:00 2001 From: Nicolas Rigaud Date: Wed, 18 Oct 2023 20:26:58 +0200 Subject: [PATCH] [LiveComponent] Allow binding LiveProp to URL query parameter --- src/Autocomplete/assets/dist/controller.js | 9 +- src/LiveComponent/CHANGELOG.md | 1 + .../Component/plugins/QueryStringPlugin.d.ts | 13 ++ .../Component/plugins/QueryStringPluging.d.ts | 9 + .../assets/dist/live_controller.d.ts | 9 + .../assets/dist/live_controller.js | 126 ++++++++++++- src/LiveComponent/assets/dist/url_utils.d.ts | 11 ++ .../Component/plugins/QueryStringPlugin.ts | 31 ++++ .../assets/src/live_controller.ts | 4 + src/LiveComponent/assets/src/url_utils.ts | 169 ++++++++++++++++++ .../test/controller/query-binding.test.ts | 168 +++++++++++++++++ src/LiveComponent/assets/test/tools.ts | 9 + .../assets/test/url_utils.test.ts | 136 ++++++++++++++ src/LiveComponent/src/Attribute/LiveProp.php | 16 ++ .../LiveComponentExtension.php | 15 ++ .../QueryStringInitializeSubscriber.php | 66 +++++++ .../src/LiveComponentHydrator.php | 118 ++++++------ .../src/Metadata/LiveComponentMetadata.php | 11 ++ .../Metadata/LiveComponentMetadataFactory.php | 22 ++- .../src/Metadata/LivePropMetadata.php | 6 + .../src/Util/LiveAttributesCollection.php | 5 + .../Util/LiveControllerAttributesCreator.php | 15 +- .../src/Util/QueryStringPropsExtractor.php | 84 +++++++++ .../Component/ComponentWithUrlBoundProps.php | 41 +++++ .../component_with_url_bound_props.html.twig | 8 + ...r_component_with_url_bound_props.html.twig | 1 + .../AddLiveAttributesSubscriberTest.php | 21 +++ .../QueryStringInitializerSubscriberTest.php | 34 ++++ .../LiveComponentMetadataFactoryTest.php | 45 +++++ .../Util/QueryStringPropsExtractorTest.php | 69 +++++++ .../Metadata/LiveComponentMetadataTest.php | 8 +- .../Util/LiveAttributesCollectionTest.php | 5 + src/TwigComponent/CHANGELOG.md | 1 + src/TwigComponent/src/ComponentFactory.php | 12 +- .../src/Event/PostMountEvent.php | 28 ++- src/TwigComponent/src/Event/PreMountEvent.php | 11 +- .../tests/Unit/Event/MountEventsTest.php | 45 +++++ .../TwigComponentLoggerListenerTest.php | 4 +- 38 files changed, 1296 insertions(+), 90 deletions(-) create mode 100644 src/LiveComponent/assets/dist/Component/plugins/QueryStringPlugin.d.ts create mode 100644 src/LiveComponent/assets/dist/Component/plugins/QueryStringPluging.d.ts create mode 100644 src/LiveComponent/assets/dist/url_utils.d.ts create mode 100644 src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts create mode 100644 src/LiveComponent/assets/src/url_utils.ts create mode 100644 src/LiveComponent/assets/test/controller/query-binding.test.ts create mode 100644 src/LiveComponent/assets/test/url_utils.test.ts create mode 100644 src/LiveComponent/src/EventListener/QueryStringInitializeSubscriber.php create mode 100644 src/LiveComponent/src/Util/QueryStringPropsExtractor.php create mode 100644 src/LiveComponent/tests/Fixtures/Component/ComponentWithUrlBoundProps.php create mode 100644 src/LiveComponent/tests/Fixtures/templates/components/component_with_url_bound_props.html.twig create mode 100644 src/LiveComponent/tests/Fixtures/templates/render_component_with_url_bound_props.html.twig create mode 100644 src/LiveComponent/tests/Functional/EventListener/QueryStringInitializerSubscriberTest.php create mode 100644 src/LiveComponent/tests/Functional/Metadata/LiveComponentMetadataFactoryTest.php create mode 100644 src/LiveComponent/tests/Functional/Util/QueryStringPropsExtractorTest.php create mode 100644 src/TwigComponent/tests/Unit/Event/MountEventsTest.php diff --git a/src/Autocomplete/assets/dist/controller.js b/src/Autocomplete/assets/dist/controller.js index f273e56485a..bc12f0d3e50 100644 --- a/src/Autocomplete/assets/dist/controller.js +++ b/src/Autocomplete/assets/dist/controller.js @@ -15,19 +15,12 @@ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. ***************************************************************************** */ -/* global Reflect, Promise, SuppressedError, Symbol */ - function __classPrivateFieldGet(receiver, state, kind, f) { if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter"); if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it"); return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver); -} - -typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) { - var e = new Error(message); - return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e; -}; +} var _default_1_instances, _default_1_getCommonConfig, _default_1_createAutocomplete, _default_1_createAutocompleteWithHtmlContents, _default_1_createAutocompleteWithRemoteData, _default_1_stripTags, _default_1_mergeObjects, _default_1_createTomSelect; class default_1 extends Controller { diff --git a/src/LiveComponent/CHANGELOG.md b/src/LiveComponent/CHANGELOG.md index 6bbfa22d6f4..43ef04de01d 100644 --- a/src/LiveComponent/CHANGELOG.md +++ b/src/LiveComponent/CHANGELOG.md @@ -15,6 +15,7 @@ - Fix instantiating LiveComponentMetadata multiple times. - Change JavaScript package to `type: module`. - Throwing an error when setting an invalid model name. +- Add support for URL binding in `LiveProp` ## 2.12.0 diff --git a/src/LiveComponent/assets/dist/Component/plugins/QueryStringPlugin.d.ts b/src/LiveComponent/assets/dist/Component/plugins/QueryStringPlugin.d.ts new file mode 100644 index 00000000000..3a6ff1319ec --- /dev/null +++ b/src/LiveComponent/assets/dist/Component/plugins/QueryStringPlugin.d.ts @@ -0,0 +1,13 @@ +import Component from '../index'; +import { 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/Component/plugins/QueryStringPluging.d.ts b/src/LiveComponent/assets/dist/Component/plugins/QueryStringPluging.d.ts new file mode 100644 index 00000000000..6f9feeeda3b --- /dev/null +++ b/src/LiveComponent/assets/dist/Component/plugins/QueryStringPluging.d.ts @@ -0,0 +1,9 @@ +import Component from '../index'; +import { PluginInterface } from './PluginInterface'; +export default class implements PluginInterface { + private element; + private mapping; + attachToComponent(component: Component): void; + private registerBindings; + private updateUrl; +} diff --git a/src/LiveComponent/assets/dist/live_controller.d.ts b/src/LiveComponent/assets/dist/live_controller.d.ts index 37ff9c48cfc..535cc5f39e9 100644 --- a/src/LiveComponent/assets/dist/live_controller.d.ts +++ b/src/LiveComponent/assets/dist/live_controller.d.ts @@ -32,6 +32,10 @@ export default class LiveControllerDefault extends Controller imple type: StringConstructor; default: string; }; + queryMapping: { + type: ObjectConstructor; + default: {}; + }; }; readonly nameValue: string; readonly urlValue: string; @@ -44,6 +48,11 @@ export default class LiveControllerDefault extends Controller imple readonly hasDebounceValue: boolean; readonly debounceValue: number; readonly fingerprintValue: string; + readonly queryMappingValue: { + [p: string]: { + name: string; + }; + }; private proxiedComponent; component: Component; pendingActionTriggerModelElement: HTMLElement | null; diff --git a/src/LiveComponent/assets/dist/live_controller.js b/src/LiveComponent/assets/dist/live_controller.js index dc9442c3baa..70c7e543eca 100644 --- a/src/LiveComponent/assets/dist/live_controller.js +++ b/src/LiveComponent/assets/dist/live_controller.js @@ -592,9 +592,6 @@ var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof win let insertionPoint = oldParent.firstChild; let newChild; - newParent.children; - oldParent.children; - // run through all the new content while (nextNewChild) { @@ -2729,6 +2726,127 @@ class ComponentRegistry { } } +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 = Object.assign(Object.assign({}, 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) + return (data[key] = 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); + } + }); + } +} + const getComponent = (element) => LiveControllerDefault.componentRegistry.getComponent(element); class LiveControllerDefault extends Controller { constructor() { @@ -2756,6 +2874,7 @@ class LiveControllerDefault extends Controller { new PageUnloadingPlugin(), new PollingPlugin(), new SetValueOntoModelFieldsPlugin(), + new QueryStringPlugin(this.queryMappingValue), ]; plugins.forEach((plugin) => { this.component.addPlugin(plugin); @@ -2976,6 +3095,7 @@ LiveControllerDefault.values = { debounce: { type: Number, default: 150 }, id: String, fingerprint: { type: String, default: '' }, + queryMapping: { type: Object, default: {} }, }; LiveControllerDefault.componentRegistry = new ComponentRegistry(); diff --git a/src/LiveComponent/assets/dist/url_utils.d.ts b/src/LiveComponent/assets/dist/url_utils.d.ts new file mode 100644 index 00000000000..c54c70f08ac --- /dev/null +++ b/src/LiveComponent/assets/dist/url_utils.d.ts @@ -0,0 +1,11 @@ +export declare class UrlUtils extends URL { + has(key: string): boolean; + set(key: string, value: any): void; + get(key: string): any | undefined; + remove(key: string): void; + private getData; + private setData; +} +export declare class HistoryStrategy { + static replace(url: URL): void; +} diff --git a/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts b/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts new file mode 100644 index 00000000000..63f7deb0085 --- /dev/null +++ b/src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts @@ -0,0 +1,31 @@ +import Component from '../index'; +import { PluginInterface } from './PluginInterface'; +import { UrlUtils, HistoryStrategy } from '../../url_utils'; + +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 227a14b00c7..2a0e2a445b2 100644 --- a/src/LiveComponent/assets/src/live_controller.ts +++ b/src/LiveComponent/assets/src/live_controller.ts @@ -18,6 +18,7 @@ import SetValueOntoModelFieldsPlugin from './Component/plugins/SetValueOntoModel import { PluginInterface } from './Component/plugins/PluginInterface'; import getModelBinding from './Directive/get_model_binding'; import ComponentRegistry from './ComponentRegistry'; +import QueryStringPlugin from './Component/plugins/QueryStringPlugin'; export { Component }; export const getComponent = (element: HTMLElement): Promise => @@ -44,6 +45,7 @@ export default class LiveControllerDefault extends Controller imple debounce: { type: Number, default: 150 }, id: String, fingerprint: { type: String, default: '' }, + queryMapping: { type: Object, default: {} }, }; declare readonly nameValue: string; @@ -54,6 +56,7 @@ export default class LiveControllerDefault extends Controller imple declare readonly hasDebounceValue: boolean; declare readonly debounceValue: number; declare readonly fingerprintValue: string; + declare readonly queryMappingValue: { [p: string]: { name: string } }; /** The component, wrapped in the convenience Proxy */ private proxiedComponent: Component; @@ -102,6 +105,7 @@ export default class LiveControllerDefault extends Controller imple new PageUnloadingPlugin(), new PollingPlugin(), new SetValueOntoModelFieldsPlugin(), + new QueryStringPlugin(this.queryMappingValue), ]; plugins.forEach((plugin) => { this.component.addPlugin(plugin); diff --git a/src/LiveComponent/assets/src/url_utils.ts b/src/LiveComponent/assets/src/url_utils.ts new file mode 100644 index 00000000000..7294f91ad1e --- /dev/null +++ b/src/LiveComponent/assets/src/url_utils.ts @@ -0,0 +1,169 @@ +/** + * 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) return (data[key] = 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/controller/query-binding.test.ts b/src/LiveComponent/assets/test/controller/query-binding.test.ts new file mode 100644 index 00000000000..a028bed96b1 --- /dev/null +++ b/src/LiveComponent/assets/test/controller/query-binding.test.ts @@ -0,0 +1,168 @@ +/* + * 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. + */ + +'use strict'; + +import {createTest, initComponent, shutdownTests, setCurrentSearch, expectCurrentSearch} from '../tools'; +import { getByText, waitFor } from '@testing-library/dom'; + +describe('LiveController query string binding', () => { + afterEach(() => { + shutdownTests(); + setCurrentSearch(''); + }); + + it('doesn\'t initialize URL if props are not defined', async () => { + await createTest({ prop: ''}, (data: any) => ` +
+ `) + + expectCurrentSearch().toEqual(''); + }) + + it('doesn\'t initialize URL with defined props values', async () => { + await createTest({ prop: 'foo'}, (data: any) => ` +
+ `) + + expectCurrentSearch().toEqual(''); + }); + + it('updates basic props in the URL', async () => { + const test = await createTest({ prop1: '', prop2: null}, (data: any) => ` +
+ `) + + // String + + // Set value + test.expectsAjaxCall() + .expectUpdatedData({prop1: 'foo'}); + + await test.component.set('prop1', 'foo', true); + + expectCurrentSearch().toEqual('?prop1=foo&prop2='); + + // Remove value + test.expectsAjaxCall() + .expectUpdatedData({prop1: ''}); + + await test.component.set('prop1', '', true); + + expectCurrentSearch().toEqual('?prop1=&prop2='); + + // Number + + // Set value + test.expectsAjaxCall() + .expectUpdatedData({prop2: 42}); + + await test.component.set('prop2', 42, true); + + expectCurrentSearch().toEqual('?prop1=&prop2=42'); + + // Remove value + test.expectsAjaxCall() + .expectUpdatedData({prop2: null}); + + await test.component.set('prop2', null, true); + + expectCurrentSearch().toEqual('?prop1=&prop2='); + }); + + it('updates array props in the URL', async () => { + const test = await createTest({ prop: []}, (data: any) => ` +
+ `) + + // Set value + test.expectsAjaxCall() + .expectUpdatedData({prop: ['foo', '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']}); + + await test.component.set('prop', ['foo'], true); + + expectCurrentSearch().toEqual('?prop[0]=foo'); + + // Remove all remaining values + test.expectsAjaxCall() + .expectUpdatedData({prop: []}); + + await test.component.set('prop', [], true); + + expectCurrentSearch().toEqual('?prop='); + }); + + it('updates objects in the URL', async () => { + const test = await createTest({ prop: { 'foo': null, 'bar': null, 'baz': null}}, (data: any) => ` +
+ `) + + // Set single nested prop + test.expectsAjaxCall() + .expectUpdatedData({'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 } }); + + 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 } }); + + 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 } }); + + await test.component.set('prop', { 'foo': null, 'bar': null }, true); + + expectCurrentSearch().toEqual('?prop='); + }); + + + it('updates the URL with props changed by the server', async () => { + const test = await createTest({ prop: ''}, (data: any) => ` +
+ Prop: ${data.prop} + +
+ `); + + test.expectsAjaxCall() + .expectActionCalled('changeProp') + .serverWillChangeProps((data: any) => { + data.prop = 'foo'; + }); + + getByText(test.element, 'Change prop').click(); + + await waitFor(() => expect(test.element).toHaveTextContent('Prop: foo')); + + expectCurrentSearch().toEqual('?prop=foo'); + }); +}) \ No newline at end of file diff --git a/src/LiveComponent/assets/test/tools.ts b/src/LiveComponent/assets/test/tools.ts index d50d1678a82..29d1ac97aae 100644 --- a/src/LiveComponent/assets/test/tools.ts +++ b/src/LiveComponent/assets/test/tools.ts @@ -434,6 +434,7 @@ export function initComponent(props: any = {}, controllerValues: any = {}) { ${controllerValues.fingerprint ? `data-live-fingerprint-value="${controllerValues.fingerprint}"` : ''} ${controllerValues.listeners ? `data-live-listeners-value="${dataToJsonAttribute(controllerValues.listeners)}"` : ''} ${controllerValues.browserDispatch ? `data-live-browser-dispatch="${dataToJsonAttribute(controllerValues.browserDispatch)}"` : ''} + ${controllerValues.queryMapping ? `data-live-query-mapping-value="${dataToJsonAttribute(controllerValues.queryMapping)}"` : ''} `; } @@ -450,3 +451,11 @@ export function getComponent(element: HTMLElement|null) { return component; } + +export function setCurrentSearch(search: string){ + history.replaceState(history.state, '', window.location.origin + window.location.pathname + search); +} + +export function expectCurrentSearch (){ + return expect(decodeURIComponent(window.location.search)); +} diff --git a/src/LiveComponent/assets/test/url_utils.test.ts b/src/LiveComponent/assets/test/url_utils.test.ts new file mode 100644 index 00000000000..2b9a7c678cd --- /dev/null +++ b/src/LiveComponent/assets/test/url_utils.test.ts @@ -0,0 +1,136 @@ +/* + * 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. + */ + +'use strict'; + +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/Attribute/LiveProp.php b/src/LiveComponent/src/Attribute/LiveProp.php index cb78eee2698..e08eeb51a07 100644 --- a/src/LiveComponent/src/Attribute/LiveProp.php +++ b/src/LiveComponent/src/Attribute/LiveProp.php @@ -58,6 +58,13 @@ final class LiveProp */ private null|string|array $onUpdated; + /** + * @var bool + * + * Tells if this property should be bound to the URL + */ + private bool $url; + /** * @param bool|array $writable If true, this property can be changed by the frontend. * Or set to an array of paths within this object/array @@ -73,6 +80,8 @@ final class LiveProp * from the value used when originally rendering * this child, the value in the child will be updated * to match the new value and the child will be re-rendered + * @param bool $url if true, this property will be synchronized with a query parameter + * in the URL */ public function __construct( bool|array $writable = false, @@ -84,6 +93,7 @@ public function __construct( string $format = null, bool $updateFromParent = false, string|array $onUpdated = null, + bool $url = false, ) { $this->writable = $writable; $this->hydrateWith = $hydrateWith; @@ -94,6 +104,7 @@ public function __construct( $this->format = $format; $this->acceptUpdatesFromParent = $updateFromParent; $this->onUpdated = $onUpdated; + $this->url = $url; if ($this->useSerializerForHydration && ($this->hydrateWith || $this->dehydrateWith)) { throw new \InvalidArgumentException('Cannot use useSerializerForHydration with hydrateWith or dehydrateWith.'); @@ -188,4 +199,9 @@ public function onUpdated(): null|string|array { return $this->onUpdated; } + + public function url(): bool + { + return $this->url; + } } diff --git a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php index 2e168c9d5bb..1f34352e306 100644 --- a/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php +++ b/src/LiveComponent/src/DependencyInjection/LiveComponentExtension.php @@ -28,6 +28,7 @@ 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\ResetDeterministicIdSubscriber; use Symfony\UX\LiveComponent\Form\Type\LiveCollectionType; use Symfony\UX\LiveComponent\Hydration\HydrationExtensionInterface; @@ -44,6 +45,7 @@ use Symfony\UX\LiveComponent\Util\FingerprintCalculator; use Symfony\UX\LiveComponent\Util\LiveComponentStack; use Symfony\UX\LiveComponent\Util\LiveControllerAttributesCreator; +use Symfony\UX\LiveComponent\Util\QueryStringPropsExtractor; use Symfony\UX\LiveComponent\Util\TwigAttributeHelperFactory; use Symfony\UX\TwigComponent\ComponentFactory; use Symfony\UX\TwigComponent\ComponentRenderer; @@ -217,6 +219,19 @@ 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) + ->setArguments([ + new Reference('ux.live_component.component_hydrator'), + ]); + + $container->register('ux.live_component.query_string_initializer_subscriber', QueryStringInitializeSubscriber::class) + ->setArguments([ + new Reference('request_stack'), + new Reference('ux.live_component.metadata_factory'), + new Reference('ux.live_component.query_string_props_extractor'), + ]) + ->addTag('kernel.event_subscriber'); + $container->register('ux.live_component.defer_live_component_subscriber', DeferLiveComponentSubscriber::class) ->setArguments([ new Reference('ux.twig_component.component_stack'), diff --git a/src/LiveComponent/src/EventListener/QueryStringInitializeSubscriber.php b/src/LiveComponent/src/EventListener/QueryStringInitializeSubscriber.php new file mode 100644 index 00000000000..c3b0829b5e4 --- /dev/null +++ b/src/LiveComponent/src/EventListener/QueryStringInitializeSubscriber.php @@ -0,0 +1,66 @@ + + * + * 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\RequestStack; +use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory; +use Symfony\UX\LiveComponent\Util\QueryStringPropsExtractor; +use Symfony\UX\TwigComponent\Event\PreMountEvent; + +/** + * @author Nicolas Rigaud + * + * @experimental + * + * @internal + */ +class QueryStringInitializeSubscriber implements EventSubscriberInterface +{ + public function __construct( + private readonly RequestStack $requestStack, + private readonly LiveComponentMetadataFactory $metadataFactory, + private readonly QueryStringPropsExtractor $queryStringPropsExtractor, + ) { + } + + public static function getSubscribedEvents(): array + { + return [ + PreMountEvent::class => 'onPreMount', + ]; + } + + public function onPreMount(PreMountEvent $event): void + { + if (!$event->getMetadata()->get('live', false)) { + // Not a live component + return; + } + + $request = $this->requestStack->getMainRequest(); + + if (null === $request) { + return; + } + + $metadata = $this->metadataFactory->getMetadata($event->getMetadata()->getName()); + + if (!$metadata->hasQueryStringBindings()) { + return; + } + + $queryStringData = $this->queryStringPropsExtractor->extract($request, $metadata, $event->getComponent()); + + $event->setData(array_merge($event->getData(), $queryStringData)); + } +} diff --git a/src/LiveComponent/src/LiveComponentHydrator.php b/src/LiveComponent/src/LiveComponentHydrator.php index fdb2d272b68..85638731516 100644 --- a/src/LiveComponent/src/LiveComponentHydrator.php +++ b/src/LiveComponent/src/LiveComponentHydrator.php @@ -19,6 +19,7 @@ use Symfony\Component\PropertyInfo\Extractor\ReflectionExtractor; use Symfony\Component\PropertyInfo\PropertyInfoExtractor; use Symfony\Component\PropertyInfo\Type; +use Symfony\Component\Serializer\Exception\ExceptionInterface as SerializerExceptionInterface; use Symfony\Component\Serializer\Normalizer\DenormalizerInterface; use Symfony\Component\Serializer\Normalizer\NormalizerInterface; use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; @@ -226,6 +227,71 @@ public function hydrate(object $component, array $props, array $updatedProps, Li return $attributes; } + /** + * Hydrate a value from a dehydrated value. + * + * Depending on the prop configuration, the value may be hydrated by a custom method or the Serializer component. + * + * @internal + * + * @throws SerializerExceptionInterface + */ + public function hydrateValue(mixed $value, LivePropMetadata $propMetadata, object $parentObject): mixed + { + if ($propMetadata->hydrateMethod()) { + if (!method_exists($parentObject, $propMetadata->hydrateMethod())) { + throw new \LogicException(sprintf('The "%s" object has a hydrateMethod of "%s" but the method does not exist.', $parentObject::class, $propMetadata->hydrateMethod())); + } + + return $parentObject->{$propMetadata->hydrateMethod()}($value); + } + + if ($propMetadata->useSerializerForHydration()) { + if (!interface_exists(DenormalizerInterface::class)) { + throw new \LogicException(sprintf('The LiveProp "%s" on component "%s" has "useSerializerForHydration: true", but the Serializer component is not installed. Try running "composer require symfony/serializer".', $propMetadata->getName(), $parentObject::class)); + } + if (null === $this->serializer) { + throw new \LogicException(sprintf('The LiveProp "%s" on component "%s" has "useSerializerForHydration: true", but no serializer has been set.', $propMetadata->getName(), $parentObject::class)); + } + if (!$this->serializer instanceof DenormalizerInterface) { + throw new \LogicException(sprintf('The LiveProp "%s" on component "%s" has "useSerializerForHydration: true", but the given serializer does not implement DenormalizerInterface.', $propMetadata->getName(), $parentObject::class)); + } + + if (null === $propMetadata->getType()) { + throw new \LogicException(sprintf('The "%s::%s" object should be hydrated with the Serializer, but no type could be guessed.', $parentObject::class, $propMetadata->getName())); + } + + return $this->serializer->denormalize($value, $propMetadata->getType(), 'json', $propMetadata->serializationContext()); + } + + if ($propMetadata->collectionValueType() && Type::BUILTIN_TYPE_OBJECT === $propMetadata->collectionValueType()->getBuiltinType()) { + $collectionClass = $propMetadata->collectionValueType()->getClassName(); + foreach ($value as $key => $objectItem) { + $value[$key] = $this->hydrateObjectValue($objectItem, $collectionClass, true, $propMetadata->getFormat(), $parentObject::class, sprintf('%s.%s', $propMetadata->getName(), $key), $parentObject); + } + } + + // no type? no hydration + if (!$propMetadata->getType()) { + return $value; + } + + if (null === $value) { + return null; + } + + if (\is_string($value) && $propMetadata->isBuiltIn() && \in_array($propMetadata->getType(), ['int', 'float', 'bool'], true)) { + return self::coerceStringValue($value, $propMetadata->getType(), $propMetadata->allowsNull()); + } + + // for all other built-ins: int, boolean, array, return as is + if ($propMetadata->isBuiltIn()) { + return $value; + } + + return $this->hydrateObjectValue($value, $propMetadata->getType(), $propMetadata->allowsNull(), $propMetadata->getFormat(), $parentObject::class, $propMetadata->getName(), $parentObject); + } + public function addChecksumToData(array $data): array { $data[self::CHECKSUM_KEY] = $this->calculateChecksum($data); @@ -430,58 +496,6 @@ private function dehydrateObjectValue(object $value, string $classType, ?string return $dehydratedObjectValues; } - private function hydrateValue(mixed $value, LivePropMetadata $propMetadata, object $parentObject): mixed - { - if ($propMetadata->hydrateMethod()) { - if (!method_exists($parentObject, $propMetadata->hydrateMethod())) { - throw new \LogicException(sprintf('The "%s" object has a hydrateMethod of "%s" but the method does not exist.', $parentObject::class, $propMetadata->hydrateMethod())); - } - - return $parentObject->{$propMetadata->hydrateMethod()}($value); - } - - if ($propMetadata->useSerializerForHydration()) { - if (!interface_exists(DenormalizerInterface::class)) { - throw new \LogicException(sprintf('The LiveProp "%s" on component "%s" has "useSerializerForHydration: true", but the Serializer component is not installed. Try running "composer require symfony/serializer".', $propMetadata->getName(), $parentObject::class)); - } - if (null === $this->serializer) { - throw new \LogicException(sprintf('The LiveProp "%s" on component "%s" has "useSerializerForHydration: true", but no serializer has been set.', $propMetadata->getName(), $parentObject::class)); - } - if (!$this->serializer instanceof DenormalizerInterface) { - throw new \LogicException(sprintf('The LiveProp "%s" on component "%s" has "useSerializerForHydration: true", but the given serializer does not implement DenormalizerInterface.', $propMetadata->getName(), $parentObject::class)); - } - - return $this->serializer->denormalize($value, $propMetadata->getType(), 'json', $propMetadata->serializationContext()); - } - - if ($propMetadata->collectionValueType() && Type::BUILTIN_TYPE_OBJECT === $propMetadata->collectionValueType()->getBuiltinType()) { - $collectionClass = $propMetadata->collectionValueType()->getClassName(); - foreach ($value as $key => $objectItem) { - $value[$key] = $this->hydrateObjectValue($objectItem, $collectionClass, true, $propMetadata->getFormat(), $parentObject::class, sprintf('%s.%s', $propMetadata->getName(), $key), $parentObject); - } - } - - // no type? no hydration - if (!$propMetadata->getType()) { - return $value; - } - - if (null === $value) { - return null; - } - - if (\is_string($value) && $propMetadata->isBuiltIn() && \in_array($propMetadata->getType(), ['int', 'float', 'bool'], true)) { - return self::coerceStringValue($value, $propMetadata->getType(), $propMetadata->allowsNull()); - } - - // for all other built-ins: int, boolean, array, return as is - if ($propMetadata->isBuiltIn()) { - return $value; - } - - return $this->hydrateObjectValue($value, $propMetadata->getType(), $propMetadata->allowsNull(), $propMetadata->getFormat(), $parentObject::class, $propMetadata->getName(), $parentObject); - } - private function hydrateObjectValue(mixed $value, string $className, bool $allowsNull, ?string $dateFormat, string $componentClassForError, string $propertyPathForError, object $component): ?object { // enum diff --git a/src/LiveComponent/src/Metadata/LiveComponentMetadata.php b/src/LiveComponent/src/Metadata/LiveComponentMetadata.php index 2c5be6796b1..d4b5c99ae71 100644 --- a/src/LiveComponent/src/Metadata/LiveComponentMetadata.php +++ b/src/LiveComponent/src/Metadata/LiveComponentMetadata.php @@ -61,4 +61,15 @@ public function getOnlyPropsThatAcceptUpdatesFromParent(array $inputProps): arra return array_intersect_key($inputProps, array_flip($propNames)); } + + public function hasQueryStringBindings(): bool + { + foreach ($this->getAllLivePropsMetadata() as $livePropMetadata) { + if ($livePropMetadata->queryStringMapping()) { + return true; + } + } + + return false; + } } diff --git a/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php b/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php index 80fb3fdd763..8661f1796f5 100644 --- a/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php +++ b/src/LiveComponent/src/Metadata/LiveComponentMetadataFactory.php @@ -95,24 +95,22 @@ public function createLivePropMetadata(string $className, string $propertyName, if (null === $type && null === $collectionValueType && isset($infoTypes[0])) { $infoType = Type::BUILTIN_TYPE_OBJECT === $infoTypes[0]->getBuiltinType() ? $infoTypes[0]->getClassName() : $infoTypes[0]->getBuiltinType(); - - return new LivePropMetadata( - $property->getName(), - $liveProp, - $infoType, - null === $infoTypes[0]->getClassName(), - $infoTypes[0]->isNullable(), - null, - ); + $isTypeBuiltIn = null === $infoTypes[0]->getClassName(); + $isTypeNullable = $infoTypes[0]->isNullable(); + } else { + $infoType = $type?->getName(); + $isTypeBuiltIn = $type?->isBuiltin() ?? false; + $isTypeNullable = $type?->allowsNull() ?? true; } return new LivePropMetadata( $property->getName(), $liveProp, - $type?->getName(), - $type && $type->isBuiltin(), - !$type || $type->allowsNull(), + $infoType, + $isTypeBuiltIn, + $isTypeNullable, $collectionValueType, + $liveProp->url() ); } diff --git a/src/LiveComponent/src/Metadata/LivePropMetadata.php b/src/LiveComponent/src/Metadata/LivePropMetadata.php index 7e56f982f07..896edd3aa23 100644 --- a/src/LiveComponent/src/Metadata/LivePropMetadata.php +++ b/src/LiveComponent/src/Metadata/LivePropMetadata.php @@ -30,6 +30,7 @@ public function __construct( private bool $isBuiltIn, private bool $allowsNull, private ?Type $collectionValueType, + private bool $queryStringMapping, ) { } @@ -53,6 +54,11 @@ public function allowsNull(): bool return $this->allowsNull; } + public function queryStringMapping(): bool + { + return $this->queryStringMapping; + } + public function calculateFieldName(object $component, string $fallback): string { return $this->liveProp->calculateFieldName($component, $fallback); diff --git a/src/LiveComponent/src/Util/LiveAttributesCollection.php b/src/LiveComponent/src/Util/LiveAttributesCollection.php index 2c6108d7de4..74fa3759de4 100644 --- a/src/LiveComponent/src/Util/LiveAttributesCollection.php +++ b/src/LiveComponent/src/Util/LiveAttributesCollection.php @@ -98,6 +98,11 @@ public function setBrowserEventsToDispatch(array $browserEventsToDispatch): void $this->attributes['data-live-browser-dispatch'] = $browserEventsToDispatch; } + public function setQueryUrlMapping(array $queryUrlMapping): void + { + $this->attributes['data-live-query-mapping-value'] = $queryUrlMapping; + } + private function escapeAttribute(string $value): string { if (method_exists(EscaperExtension::class, 'escape')) { diff --git a/src/LiveComponent/src/Util/LiveControllerAttributesCreator.php b/src/LiveComponent/src/Util/LiveControllerAttributesCreator.php index c0865e0e6f7..b00b97cbf1e 100644 --- a/src/LiveComponent/src/Util/LiveControllerAttributesCreator.php +++ b/src/LiveComponent/src/Util/LiveControllerAttributesCreator.php @@ -98,10 +98,23 @@ public function attributesForRendering(MountedComponent $mounted, ComponentMetad $mountedAttributes = $mountedAttributes->defaults(['data-live-id' => $id]); } + $liveMetadata = $this->metadataFactory->getMetadata($mounted->getName()); + + if ($liveMetadata->hasQueryStringBindings()) { + $queryMapping = []; + foreach ($liveMetadata->getAllLivePropsMetadata() as $livePropMetadata) { + if ($livePropMetadata->queryStringMapping()) { + $frontendName = $livePropMetadata->calculateFieldName($mounted, $livePropMetadata->getName()); + $queryMapping[$frontendName] = ['name' => $frontendName]; + } + } + $attributesCollection->setQueryUrlMapping($queryMapping); + } + if ($isChildComponent) { $fingerprint = $this->fingerprintCalculator->calculateFingerprint( $mounted->getInputProps(), - $this->metadataFactory->getMetadata($mounted->getName()) + $liveMetadata ); if ($fingerprint) { $attributesCollection->setFingerprint($fingerprint); diff --git a/src/LiveComponent/src/Util/QueryStringPropsExtractor.php b/src/LiveComponent/src/Util/QueryStringPropsExtractor.php new file mode 100644 index 00000000000..a9cdbd13f55 --- /dev/null +++ b/src/LiveComponent/src/Util/QueryStringPropsExtractor.php @@ -0,0 +1,84 @@ + + * + * 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\HttpFoundation\Request; +use Symfony\UX\LiveComponent\Exception\HydrationException; +use Symfony\UX\LiveComponent\LiveComponentHydrator; +use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadata; +use Symfony\UX\LiveComponent\Metadata\LivePropMetadata; + +/** + * @author Nicolas Rigaud + * + * @experimental + * + * @internal + */ +final class QueryStringPropsExtractor +{ + public function __construct(private readonly LiveComponentHydrator $hydrator) + { + } + + /** + * Extracts relevant query parameters from the current URL and hydrates them. + */ + public function extract(Request $request, LiveComponentMetadata $metadata, object $component): array + { + $query = $request->query->all(); + + if (empty($query)) { + return []; + } + $data = []; + + foreach ($metadata->getAllLivePropsMetadata() as $livePropMetadata) { + if ($livePropMetadata->queryStringMapping()) { + $frontendName = $livePropMetadata->calculateFieldName($component, $livePropMetadata->getName()); + if (null !== ($value = $query[$frontendName] ?? null)) { + if ('' === $value && null !== $livePropMetadata->getType() && (!$livePropMetadata->isBuiltIn() || 'array' === $livePropMetadata->getType())) { + // Cast empty string to empty array for objects and arrays + $value = []; + } + + try { + $hydratedValue = $this->hydrator->hydrateValue($value, $livePropMetadata, $component); + + if ($this->isValueTypeConsistent($hydratedValue, $livePropMetadata)) { + // Only set data if hydrated value type is consistent with prop metadata type + $data[$livePropMetadata->getName()] = $hydratedValue; + } + } catch (HydrationException) { + // Skip hydration errors (e.g. with objects) + } + } + } + } + + return $data; + } + + private function isValueTypeConsistent(mixed $value, LivePropMetadata $livePropMetadata): bool + { + $propType = $livePropMetadata->getType(); + + if ($livePropMetadata->allowsNull() && null === $value) { + return true; + } + + return + \in_array($propType, [null, 'mixed']) + || $livePropMetadata->isBuiltIn() && ('\is_'.$propType)($value) + || !$livePropMetadata->isBuiltIn() && $value instanceof $propType; + } +} diff --git a/src/LiveComponent/tests/Fixtures/Component/ComponentWithUrlBoundProps.php b/src/LiveComponent/tests/Fixtures/Component/ComponentWithUrlBoundProps.php new file mode 100644 index 00000000000..3ca64324169 --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/Component/ComponentWithUrlBoundProps.php @@ -0,0 +1,41 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\LiveComponent\Tests\Fixtures\Component; + +use Symfony\UX\LiveComponent\Attribute\AsLiveComponent; +use Symfony\UX\LiveComponent\Attribute\LiveProp; +use Symfony\UX\LiveComponent\DefaultActionTrait; +use Symfony\UX\LiveComponent\Tests\Fixtures\Dto\Address; + +#[AsLiveComponent('component_with_url_bound_props')] +class ComponentWithUrlBoundProps +{ + use DefaultActionTrait; + + #[LiveProp(url: true)] + public ?string $prop1 = null; + + #[LiveProp(url: true)] + public ?int $prop2 = null; + + #[LiveProp(url: true)] + public array $prop3 = []; + + #[LiveProp] + public ?string $prop4 = null; + + #[LiveProp(url: true)] + public ?Address $prop5 = null; + + #[LiveProp(fieldName: 'field6', url: true)] + public ?string $prop6 = null; +} 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 new file mode 100644 index 00000000000..cf1820c0cdf --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/templates/components/component_with_url_bound_props.html.twig @@ -0,0 +1,8 @@ +
+ Prop1: {{ prop1 }} + Prop2: {{ prop2 }} + Prop3: {{ prop3|join(',') }} + Prop4: {{ prop4 }} + Prop5: address: {{ prop5.address ?? '' }} city: {{ prop5.city ?? '' }} + Prop6: {{ prop6 }} +
\ No newline at end of file diff --git a/src/LiveComponent/tests/Fixtures/templates/render_component_with_url_bound_props.html.twig b/src/LiveComponent/tests/Fixtures/templates/render_component_with_url_bound_props.html.twig new file mode 100644 index 00000000000..06800a2e53c --- /dev/null +++ b/src/LiveComponent/tests/Fixtures/templates/render_component_with_url_bound_props.html.twig @@ -0,0 +1 @@ +{{ component('component_with_url_bound_props') }} diff --git a/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php index b478be74034..b6019f1f9ce 100644 --- a/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php +++ b/src/LiveComponent/tests/Functional/EventListener/AddLiveAttributesSubscriberTest.php @@ -133,4 +133,25 @@ public function testItDoesNotOverrideDataLiveIdIfSpecified(): void $this->assertSame('todo-item-1', $lis->first()->attr('data-live-id')); $this->assertSame('todo-item-3', $lis->last()->attr('data-live-id')); } + + public function testQueryStringMappingAttribute() + { + $div = $this->browser() + ->visit('/render-template/render_component_with_url_bound_props') + ->assertSuccessful() + ->crawler() + ->filter('div') + ; + + $queryMapping = json_decode($div->attr('data-live-query-mapping-value'), true); + $expected = [ + 'prop1' => ['name' => 'prop1'], + 'prop2' => ['name' => 'prop2'], + 'prop3' => ['name' => 'prop3'], + 'prop5' => ['name' => 'prop5'], + 'field6' => ['name' => 'field6'], + ]; + + $this->assertEquals($expected, $queryMapping); + } } diff --git a/src/LiveComponent/tests/Functional/EventListener/QueryStringInitializerSubscriberTest.php b/src/LiveComponent/tests/Functional/EventListener/QueryStringInitializerSubscriberTest.php new file mode 100644 index 00000000000..d4e2ebae2c9 --- /dev/null +++ b/src/LiveComponent/tests/Functional/EventListener/QueryStringInitializerSubscriberTest.php @@ -0,0 +1,34 @@ + + * + * 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 Zenstruck\Browser\Test\HasBrowser; + +class QueryStringInitializerSubscriberTest extends KernelTestCase +{ + use HasBrowser; + + public function testQueryStringPropsInitialization() + { + $this->browser() + ->get('/render-template/render_component_with_url_bound_props?prop1=foo&prop2=42&prop3[]=foo&prop3[]=bar&prop4=unbound&prop5[address]=foo&prop5[city]=bar&field6=foo') + ->assertSuccessful() + ->assertContains('Prop1: foo') + ->assertContains('Prop2: 42') + ->assertContains('Prop3: foo,bar') + ->assertContains('Prop4:') + ->assertContains('Prop5: address: foo city: bar') + ->assertContains('Prop6: foo') + ; + } +} diff --git a/src/LiveComponent/tests/Functional/Metadata/LiveComponentMetadataFactoryTest.php b/src/LiveComponent/tests/Functional/Metadata/LiveComponentMetadataFactoryTest.php new file mode 100644 index 00000000000..c2bc68d21e4 --- /dev/null +++ b/src/LiveComponent/tests/Functional/Metadata/LiveComponentMetadataFactoryTest.php @@ -0,0 +1,45 @@ + + * + * 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\Metadata; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\UX\LiveComponent\Metadata\LiveComponentMetadataFactory; +use Symfony\UX\LiveComponent\Tests\Fixtures\Component\ComponentWithUrlBoundProps; + +class LiveComponentMetadataFactoryTest extends KernelTestCase +{ + public function testQueryStringMapping() + { + /** @var LiveComponentMetadataFactory $metadataFactory */ + $metadataFactory = self::getContainer()->get('ux.live_component.metadata_factory'); + + $class = new \ReflectionClass(ComponentWithUrlBoundProps::class); + $propsMetadata = $metadataFactory->createPropMetadatas($class); + + $propsMetadataByName = []; + foreach ($propsMetadata as $propMetadata) { + $propsMetadataByName[$propMetadata->getName()] = $propMetadata; + } + + $this->assertTrue($propsMetadataByName['prop1']->queryStringMapping()); + + $this->assertTrue($propsMetadataByName['prop2']->queryStringMapping()); + + $this->assertTrue($propsMetadataByName['prop3']->queryStringMapping()); + + $this->assertFalse($propsMetadataByName['prop4']->queryStringMapping()); + + $this->assertTrue($propsMetadataByName['prop5']->queryStringMapping()); + + $this->assertTrue($propsMetadataByName['prop6']->queryStringMapping()); + } +} diff --git a/src/LiveComponent/tests/Functional/Util/QueryStringPropsExtractorTest.php b/src/LiveComponent/tests/Functional/Util/QueryStringPropsExtractorTest.php new file mode 100644 index 00000000000..161a69a6b11 --- /dev/null +++ b/src/LiveComponent/tests/Functional/Util/QueryStringPropsExtractorTest.php @@ -0,0 +1,69 @@ + + * + * 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\Util; + +use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase; +use Symfony\Component\HttpFoundation\Request; +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; + +class QueryStringPropsExtractorTest extends KernelTestCase +{ + use LiveComponentTestHelper; + + /** + * @dataProvider getQueryStringTests + */ + public function testExtract(string $queryString, array $expected) + { + $extractor = new QueryStringPropsExtractor($this->hydrator()); + + $request = Request::create('/'.!empty($queryString) ? '?'.$queryString : ''); + + /** @var LiveComponentMetadataFactory $metadataFactory */ + $metadataFactory = self::getContainer()->get('ux.live_component.metadata_factory'); + + $metadata = $metadataFactory->getMetadata('component_with_url_bound_props'); + $component = $this->getComponent('component_with_url_bound_props'); + + $data = $extractor->extract($request, $metadata, $component); + + $this->assertEquals($expected, $data); + } + + public function getQueryStringTests(): iterable + { + yield from [ + 'no query string' => ['', []], + 'empty value for nullable string' => ['prop1=', ['prop1' => null]], + 'string value' => ['prop1=foo', ['prop1' => 'foo']], + 'empty value for nullable int' => ['prop2=', ['prop2' => null]], + 'int value' => ['prop2=42', ['prop2' => 42]], + 'array value' => ['prop3[]=foo&prop3[]=bar', ['prop3' => ['foo', 'bar']]], + 'array value indexed' => ['prop3[1]=foo&prop3[0]=bar', ['prop3' => [1 => 'foo', 0 => 'bar']]], + 'not bound prop' => ['prop4=foo', []], + 'object value' => ['prop5[address]=foo&prop5[city]=bar', ['prop5' => (function () { + $address = new Address(); + $address->address = 'foo'; + $address->city = 'bar'; + + return $address; + })(), + ]], + 'invalid scalar value' => ['prop1[]=foo&prop1[]=bar', []], + 'invalid array value' => ['prop3=foo', []], + 'invalid object value' => ['prop5=foo', []], + ]; + } +} diff --git a/src/LiveComponent/tests/Unit/Metadata/LiveComponentMetadataTest.php b/src/LiveComponent/tests/Unit/Metadata/LiveComponentMetadataTest.php index f2bd9e7d446..c2af8c9d5d5 100644 --- a/src/LiveComponent/tests/Unit/Metadata/LiveComponentMetadataTest.php +++ b/src/LiveComponent/tests/Unit/Metadata/LiveComponentMetadataTest.php @@ -22,10 +22,10 @@ class LiveComponentMetadataTest extends TestCase public function testGetOnlyPropsThatAcceptUpdatesFromParent() { $propMetadatas = [ - new LivePropMetadata('noUpdateFromParent1', new LiveProp(updateFromParent: false), null, false, false, null), - new LivePropMetadata('noUpdateFromParent2', new LiveProp(updateFromParent: false), null, false, false, null), - new LivePropMetadata('yesUpdateFromParent1', new LiveProp(updateFromParent: true), null, false, false, null), - new LivePropMetadata('yesUpdateFromParent2', new LiveProp(updateFromParent: true), null, false, false, null), + new LivePropMetadata('noUpdateFromParent1', new LiveProp(updateFromParent: false), null, false, false, null, false), + new LivePropMetadata('noUpdateFromParent2', new LiveProp(updateFromParent: false), null, false, false, null, false), + new LivePropMetadata('yesUpdateFromParent1', new LiveProp(updateFromParent: true), null, false, false, null, false), + new LivePropMetadata('yesUpdateFromParent2', new LiveProp(updateFromParent: true), null, false, false, null, false), ]; $liveComponentMetadata = new LiveComponentMetadata(new ComponentMetadata([]), $propMetadatas); $inputProps = [ diff --git a/src/LiveComponent/tests/Unit/Util/LiveAttributesCollectionTest.php b/src/LiveComponent/tests/Unit/Util/LiveAttributesCollectionTest.php index 84ec68a19a3..eea84503a9f 100644 --- a/src/LiveComponent/tests/Unit/Util/LiveAttributesCollectionTest.php +++ b/src/LiveComponent/tests/Unit/Util/LiveAttributesCollectionTest.php @@ -43,6 +43,10 @@ public function testToEscapedArray(): void 'componentName' => null, ], ]); + $collection->setQueryUrlMapping([ + 'foo' => ['name' => 'foo'], + 'bar' => ['name' => 'bar'], + ]); $expected = [ 'data-controller' => 'live', @@ -54,6 +58,7 @@ public function testToEscapedArray(): void 'data-live-csrf-value' => 'the-csrf-token', 'data-live-listeners-value' => '{"event_name":"theActionName"}', 'data-live-emit' => '[{"event":"event_name1","data":{"the":"data"},"target":"up","componentName":"the-component"},{"event":"event_name2","data":{"the":"data"},"target":null,"componentName":null}]', + 'data-live-query-mapping-value' => '{"foo":{"name":"foo"},"bar":{"name":"bar"}}', ]; $this->assertSame($expected, $collection->toEscapedArray()); diff --git a/src/TwigComponent/CHANGELOG.md b/src/TwigComponent/CHANGELOG.md index d5c1a3d1d7e..716e0c555a8 100644 --- a/src/TwigComponent/CHANGELOG.md +++ b/src/TwigComponent/CHANGELOG.md @@ -2,6 +2,7 @@ ## 2.13.0 +- [BC BREAK] Add component metadata to `PreMountEvent` and `PostMountEvent` - Added configuration to separate your components into different "namespaces" - Add `outerScope` variable reach variables from the parent template of an "embedded" component. diff --git a/src/TwigComponent/src/ComponentFactory.php b/src/TwigComponent/src/ComponentFactory.php index 4e0dcb0d26f..e138879a321 100644 --- a/src/TwigComponent/src/ComponentFactory.php +++ b/src/TwigComponent/src/ComponentFactory.php @@ -75,7 +75,7 @@ public function create(string $name, array $data = []): MountedComponent public function mountFromObject(object $component, array $data, ComponentMetadata $componentMetadata): MountedComponent { $originalData = $data; - $data = $this->preMount($component, $data); + $data = $this->preMount($component, $data, $componentMetadata); $this->mount($component, $data); @@ -88,7 +88,7 @@ public function mountFromObject(object $component, array $data, ComponentMetadat } } - $postMount = $this->postMount($component, $data); + $postMount = $this->postMount($component, $data, $componentMetadata); $data = $postMount['data']; $extraMetadata = $postMount['extraMetadata']; @@ -174,9 +174,9 @@ private function getComponent(string $name): object return $this->components->get($name); } - private function preMount(object $component, array $data): array + private function preMount(object $component, array $data, ComponentMetadata $componentMetadata): array { - $event = new PreMountEvent($component, $data); + $event = new PreMountEvent($component, $data, $componentMetadata); $this->eventDispatcher->dispatch($event); $data = $event->getData(); @@ -194,9 +194,9 @@ private function preMount(object $component, array $data): array /** * @return array{data: array, extraMetadata: array} */ - private function postMount(object $component, array $data): array + private function postMount(object $component, array $data, ComponentMetadata $componentMetadata): array { - $event = new PostMountEvent($component, $data); + $event = new PostMountEvent($component, $data, $componentMetadata); $this->eventDispatcher->dispatch($event); $data = $event->getData(); $extraMetadata = $event->getExtraMetadata(); diff --git a/src/TwigComponent/src/Event/PostMountEvent.php b/src/TwigComponent/src/Event/PostMountEvent.php index 170e1cd555f..0706655833a 100644 --- a/src/TwigComponent/src/Event/PostMountEvent.php +++ b/src/TwigComponent/src/Event/PostMountEvent.php @@ -12,17 +12,38 @@ namespace Symfony\UX\TwigComponent\Event; use Symfony\Contracts\EventDispatcher\Event; +use Symfony\UX\TwigComponent\ComponentMetadata; /** * @author Ryan Weaver */ final class PostMountEvent extends Event { + private ?ComponentMetadata $metadata; + private array $extraMetadata; + public function __construct( private object $component, private array $data, - private array $extraMetadata = [], + array|ComponentMetadata $metadata = [], + $extraMetadata = [] ) { + if (\is_array($metadata)) { + trigger_deprecation('symfony/ux-twig-component', '2.13', 'In TwigComponent 3.0, the third argument of "%s()" will be a "%s" object and the "$extraMetadata" array should be passed as the fourth argument.', __METHOD__, ComponentMetadata::class); + + $this->metadata = null; + $this->extraMetadata = $metadata; + } else { + if (null !== $metadata && !$metadata instanceof ComponentMetadata) { + throw new \InvalidArgumentException(sprintf('Expecting "$metadata" to be null or an instance of "%s", given: "%s."', ComponentMetadata::class, get_debug_type($metadata))); + } + if (!\is_array($extraMetadata)) { + throw new \InvalidArgumentException(sprintf('Expecting "$extraMetadata" to be array, given: "%s".', get_debug_type($extraMetadata))); + } + + $this->metadata = $metadata; + $this->extraMetadata = $extraMetadata; + } } public function getComponent(): object @@ -40,6 +61,11 @@ public function setData(array $data): void $this->data = $data; } + public function getMetadata(): ?ComponentMetadata + { + return $this->metadata; + } + public function getExtraMetadata(): array { return $this->extraMetadata; diff --git a/src/TwigComponent/src/Event/PreMountEvent.php b/src/TwigComponent/src/Event/PreMountEvent.php index 890998372f9..9472bfed5b5 100644 --- a/src/TwigComponent/src/Event/PreMountEvent.php +++ b/src/TwigComponent/src/Event/PreMountEvent.php @@ -12,14 +12,18 @@ namespace Symfony\UX\TwigComponent\Event; use Symfony\Contracts\EventDispatcher\Event; +use Symfony\UX\TwigComponent\ComponentMetadata; /** * @author Ryan Weaver */ final class PreMountEvent extends Event { - public function __construct(private object $component, private array $data) + public function __construct(private object $component, private array $data, private readonly ?ComponentMetadata $metadata = null) { + if (null === $this->metadata) { + trigger_deprecation('symfony/ux-twig-component', '2.13', 'In TwigComponent 3.0, "%s()" method will require a "%s $metadata" argument. Not passing it is deprecated.', __METHOD__, ComponentMetadata::class); + } } public function getComponent(): object @@ -36,4 +40,9 @@ public function setData(array $data): void { $this->data = $data; } + + public function getMetadata(): ?ComponentMetadata + { + return $this->metadata; + } } diff --git a/src/TwigComponent/tests/Unit/Event/MountEventsTest.php b/src/TwigComponent/tests/Unit/Event/MountEventsTest.php new file mode 100644 index 00000000000..cddf6d28e0a --- /dev/null +++ b/src/TwigComponent/tests/Unit/Event/MountEventsTest.php @@ -0,0 +1,45 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\UX\TwigComponent\Tests\Unit\Event; + +use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; +use Symfony\UX\TwigComponent\Event\PostMountEvent; +use Symfony\UX\TwigComponent\Event\PreMountEvent; + +/** + * Remove in TwigComponent 3.0. + * + * @group legacy + */ +class MountEventsTest extends TestCase +{ + use ExpectDeprecationTrait; + + public function testPreMountEventCreation() + { + $this->expectDeprecation('Since symfony/ux-twig-component 2.13: In TwigComponent 3.0, "Symfony\UX\TwigComponent\Event\PreMountEvent::__construct()" method will require a "Symfony\UX\TwigComponent\ComponentMetadata $metadata" argument. Not passing it is deprecated.'); + new PreMountEvent(new \stdClass(), []); + + $this->expectDeprecation('Since symfony/ux-twig-component 2.13: In TwigComponent 3.0, "Symfony\UX\TwigComponent\Event\PreMountEvent::__construct()" method will require a "Symfony\UX\TwigComponent\ComponentMetadata $metadata" argument. Not passing it is deprecated.'); + new PreMountEvent(new \stdClass(), [], null); + } + + public function testPostMountEventCreation() + { + $this->expectDeprecation('Since symfony/ux-twig-component 2.13: In TwigComponent 3.0, the third argument of "Symfony\UX\TwigComponent\Event\PostMountEvent::__construct()" will be a "Symfony\UX\TwigComponent\ComponentMetadata" object and the "$extraMetadata" array should be passed as the fourth argument.'); + new PostMountEvent(new \stdClass(), []); + + $this->expectDeprecation('Since symfony/ux-twig-component 2.13: In TwigComponent 3.0, the third argument of "Symfony\UX\TwigComponent\Event\PostMountEvent::__construct()" will be a "Symfony\UX\TwigComponent\ComponentMetadata" object and the "$extraMetadata" array should be passed as the fourth argument.'); + new PostMountEvent(new \stdClass(), [], []); + } +} diff --git a/src/TwigComponent/tests/Unit/EventListener/TwigComponentLoggerListenerTest.php b/src/TwigComponent/tests/Unit/EventListener/TwigComponentLoggerListenerTest.php index 9f2b3c81e3c..df6b81c7a9f 100644 --- a/src/TwigComponent/tests/Unit/EventListener/TwigComponentLoggerListenerTest.php +++ b/src/TwigComponent/tests/Unit/EventListener/TwigComponentLoggerListenerTest.php @@ -38,9 +38,9 @@ public function testLoggerStoreEvents(): void $eventB = new PreCreateForRenderEvent('b'); $logger->onPreCreateForRender($eventB); - $eventC = new PreMountEvent(new \stdClass(), []); + $eventC = new PreMountEvent(new \stdClass(), [], new ComponentMetadata([])); $logger->onPreMount($eventC); - $eventD = new PostMountEvent(new \stdClass(), []); + $eventD = new PostMountEvent(new \stdClass(), [], new ComponentMetadata([])); $logger->onPostMount($eventD); $mounted = new MountedComponent('foo', new \stdClass(), new ComponentAttributes([]));