Skip to content

Commit

Permalink
[LiveComponent] Allow binding LiveProp to URL query parameter
Browse files Browse the repository at this point in the history
  • Loading branch information
squrious committed Oct 25, 2023
1 parent 262db2b commit 42998d4
Show file tree
Hide file tree
Showing 28 changed files with 896 additions and 12 deletions.
1 change: 1 addition & 0 deletions src/LiveComponent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## 2.13.0

- Add support for URL binding in `LiveProp`
- Add deferred rendering of Live Components

## 2.12.0
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
87 changes: 87 additions & 0 deletions src/LiveComponent/assets/dist/live_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -2696,6 +2696,92 @@ class ComponentRegistry {
}
}

class AdvancedURLSearchParams extends URLSearchParams {
set(name, value) {
if (typeof value !== 'object') {
super.set(name, value);
}
else {
this.delete(name);
if (Array.isArray(value)) {
value.forEach((v) => {
this.append(`${name}[]`, v);
});
}
else {
Object.entries(value).forEach(([index, v]) => {
this.append(`${name}[${index}]`, v);
});
}
}
}
delete(name) {
super.delete(name);
const pattern = new RegExp(`^${name}(\\[.*])?$`);
for (const key of Array.from(this.keys())) {
if (key.match(pattern)) {
super.delete(key);
}
}
}
}
function setQueryParam(param, value) {
const queryParams = new AdvancedURLSearchParams(window.location.search);
queryParams.set(param, value);
const url = urlFromQueryParams(queryParams);
history.replaceState(history.state, '', url);
}
function removeQueryParam(param) {
const queryParams = new AdvancedURLSearchParams(window.location.search);
queryParams.delete(param);
const url = urlFromQueryParams(queryParams);
history.replaceState(history.state, '', url);
}
function urlFromQueryParams(queryParams) {
let queryString = '';
if (Array.from(queryParams.entries()).length > 0) {
queryString += '?' + queryParams.toString();
}
return window.location.origin + window.location.pathname + queryString + window.location.hash;
}

class QueryStringPlugin {
constructor() {
this.mapping = new Map;
}
attachToComponent(component) {
this.element = component.element;
this.registerBindings();
component.on('connect', (component) => {
this.updateUrl(component);
});
component.on('render:finished', (component) => {
this.updateUrl(component);
});
}
registerBindings() {
const rawQueryMapping = this.element.dataset.liveQueryMapping;
if (rawQueryMapping === undefined) {
return;
}
const mapping = JSON.parse(rawQueryMapping);
Object.entries(mapping).forEach(([key, config]) => {
this.mapping.set(key, config);
});
}
updateUrl(component) {
this.mapping.forEach((mapping, propName) => {
const value = component.valueStore.get(propName);
if (value === '' || value === null || value === undefined) {
removeQueryParam(mapping.name);
}
else {
setQueryParam(mapping.name, value);
}
});
}
}

const getComponent = (element) => LiveControllerDefault.componentRegistry.getComponent(element);
class LiveControllerDefault extends Controller {
constructor() {
Expand Down Expand Up @@ -2723,6 +2809,7 @@ class LiveControllerDefault extends Controller {
new PageUnloadingPlugin(),
new PollingPlugin(),
new SetValueOntoModelFieldsPlugin(),
new QueryStringPlugin(),
];
plugins.forEach((plugin) => {
this.component.addPlugin(plugin);
Expand Down
2 changes: 2 additions & 0 deletions src/LiveComponent/assets/dist/url_utils.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export declare function setQueryParam(param: string, value: any): void;
export declare function removeQueryParam(param: string): void;
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import Component from '../index';
import { PluginInterface } from './PluginInterface';
import {
setQueryParam, removeQueryParam,
} from '../../url_utils';

type QueryMapping = {
name: string,
}

export default class implements PluginInterface {
private element: Element;
private mapping: Map<string,QueryMapping> = new Map;

attachToComponent(component: Component): void {
this.element = component.element;
this.registerBindings();

component.on('connect', (component: Component) => {
this.updateUrl(component);
});

component.on('render:finished', (component: Component)=> {
this.updateUrl(component);
});
}

private registerBindings(): void {
const rawQueryMapping = (this.element as HTMLElement).dataset.liveQueryMapping;
if (rawQueryMapping === undefined) {
return;
}

const mapping = JSON.parse(rawQueryMapping) as {[p: string]: QueryMapping};

Object.entries(mapping).forEach(([key, config]) => {
this.mapping.set(key, config);
})
}

private updateUrl(component: Component){
this.mapping.forEach((mapping, propName) => {
const value = component.valueStore.get(propName);
if (value === '' || value === null || value === undefined) {
removeQueryParam(mapping.name);
} else {
setQueryParam(mapping.name, value);
}

});
}
}
2 changes: 2 additions & 0 deletions src/LiveComponent/assets/src/live_controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Component> =>
Expand Down Expand Up @@ -102,6 +103,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
new PageUnloadingPlugin(),
new PollingPlugin(),
new SetValueOntoModelFieldsPlugin(),
new QueryStringPlugin(),
];
plugins.forEach((plugin) => {
this.component.addPlugin(plugin);
Expand Down
57 changes: 57 additions & 0 deletions src/LiveComponent/assets/src/url_utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
class AdvancedURLSearchParams extends URLSearchParams {
set(name: string, value: any) {
if (typeof value !== 'object') {
super.set(name, value);
} else {
this.delete(name);
if (Array.isArray(value)) {
value.forEach((v) => {
this.append(`${name}[]`, v);
});
} else {
Object.entries(value).forEach(([index, v]) => {
this.append(`${name}[${index}]`, v as string);
});
}
}
}

delete(name: string) {
super.delete(name);
const pattern = new RegExp(`^${name}(\\[.*])?$`);
for (const key of Array.from(this.keys())) {
if (key.match(pattern)) {
super.delete(key);
}
}
}
}

export function setQueryParam(param: string, value: any) {
const queryParams = new AdvancedURLSearchParams(window.location.search);

queryParams.set(param, value);

const url = urlFromQueryParams(queryParams);

history.replaceState(history.state, '', url);
}

export function removeQueryParam(param: string) {
const queryParams = new AdvancedURLSearchParams(window.location.search);

queryParams.delete(param);

const url = urlFromQueryParams(queryParams);

history.replaceState(history.state, '', url);
}

function urlFromQueryParams(queryParams: URLSearchParams) {
let queryString = '';
if (Array.from(queryParams.entries()).length > 0) {
queryString += '?' + queryParams.toString();
}

return window.location.origin + window.location.pathname + queryString + window.location.hash;
}
76 changes: 76 additions & 0 deletions src/LiveComponent/assets/test/controller/query-binding.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
* This file is part of the Symfony package.
*
* (c) Fabien Potencier <fabien@symfony.com>
*
* 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} from '../tools';
import { getByText, waitFor } from '@testing-library/dom';

describe('LiveController query string binding', () => {
afterEach(() => {
shutdownTests();
});

it('doesn\'t initialize URL if props are not defined', async () => {
await createTest({ prop: ''}, (data: any) => `
<div ${initComponent(data, { queryMapping: {prop: {name: 'prop'}}})}></div>
`)

expect(window.location.search).toEqual('');
})

it('initializes URL with defined props values', async () => {
await createTest({ prop: 'foo'}, (data: any) => `
<div ${initComponent(data, { queryMapping: {prop: {name: 'prop'}}})}></div>
`)

expect(window.location.search).toEqual('?prop=foo');
});

it('properly handles array props in the URL', async () => {
await createTest({ prop: ['foo', 'bar']}, (data: any) => `
<div ${initComponent(data, { queryMapping: {prop: {name: 'prop'}}})}></div>
`)
expect(decodeURIComponent(window.location.search)).toEqual('?prop[]=foo&prop[]=bar');
});

it('updates the URL when the props changed', async () => {
const test = await createTest({ prop: ''}, (data: any) => `
<div ${initComponent(data, { queryMapping: {prop: {name: 'prop'}}})}></div>
`)

test.expectsAjaxCall()
.expectUpdatedData({prop: 'foo'});

await test.component.set('prop', 'foo', true);

expect(window.location.search).toEqual('?prop=foo');
});

it('updates the URL with props changed by the server', async () => {
const test = await createTest({ prop: ''}, (data: any) => `
<div ${initComponent(data, {queryMapping: {prop: {name: 'prop'}}})}>
Prop: ${data.prop}
<button data-action="live#action" data-action-name="changeProp">Change prop</button>
</div>
`);

test.expectsAjaxCall()
.expectActionCalled('changeProp')
.serverWillChangeProps((data: any) => {
data.prop = 'foo';
});

getByText(test.element, 'Change prop').click();

await waitFor(() => expect(test.element).toHaveTextContent('Prop: foo'));

expect(window.location.search).toEqual('?prop=foo');
});
})
1 change: 1 addition & 0 deletions src/LiveComponent/assets/test/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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="${dataToJsonAttribute(controllerValues.queryMapping)}"` : ''}
`;
}

Expand Down

0 comments on commit 42998d4

Please sign in to comment.