Skip to content

Commit 42998d4

Browse files
committed
[LiveComponent] Allow binding LiveProp to URL query parameter
1 parent 262db2b commit 42998d4

28 files changed

+896
-12
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## 2.13.0
44

5+
- Add support for URL binding in `LiveProp`
56
- Add deferred rendering of Live Components
67

78
## 2.12.0
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import Component from '../index';
2+
import { PluginInterface } from './PluginInterface';
3+
export default class implements PluginInterface {
4+
private element;
5+
private mapping;
6+
attachToComponent(component: Component): void;
7+
private registerBindings;
8+
private updateUrl;
9+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import Component from '../index';
2+
import { PluginInterface } from './PluginInterface';
3+
export default class implements PluginInterface {
4+
private element;
5+
private mapping;
6+
attachToComponent(component: Component): void;
7+
private registerBindings;
8+
private updateUrl;
9+
}

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2696,6 +2696,92 @@ class ComponentRegistry {
26962696
}
26972697
}
26982698

2699+
class AdvancedURLSearchParams extends URLSearchParams {
2700+
set(name, value) {
2701+
if (typeof value !== 'object') {
2702+
super.set(name, value);
2703+
}
2704+
else {
2705+
this.delete(name);
2706+
if (Array.isArray(value)) {
2707+
value.forEach((v) => {
2708+
this.append(`${name}[]`, v);
2709+
});
2710+
}
2711+
else {
2712+
Object.entries(value).forEach(([index, v]) => {
2713+
this.append(`${name}[${index}]`, v);
2714+
});
2715+
}
2716+
}
2717+
}
2718+
delete(name) {
2719+
super.delete(name);
2720+
const pattern = new RegExp(`^${name}(\\[.*])?$`);
2721+
for (const key of Array.from(this.keys())) {
2722+
if (key.match(pattern)) {
2723+
super.delete(key);
2724+
}
2725+
}
2726+
}
2727+
}
2728+
function setQueryParam(param, value) {
2729+
const queryParams = new AdvancedURLSearchParams(window.location.search);
2730+
queryParams.set(param, value);
2731+
const url = urlFromQueryParams(queryParams);
2732+
history.replaceState(history.state, '', url);
2733+
}
2734+
function removeQueryParam(param) {
2735+
const queryParams = new AdvancedURLSearchParams(window.location.search);
2736+
queryParams.delete(param);
2737+
const url = urlFromQueryParams(queryParams);
2738+
history.replaceState(history.state, '', url);
2739+
}
2740+
function urlFromQueryParams(queryParams) {
2741+
let queryString = '';
2742+
if (Array.from(queryParams.entries()).length > 0) {
2743+
queryString += '?' + queryParams.toString();
2744+
}
2745+
return window.location.origin + window.location.pathname + queryString + window.location.hash;
2746+
}
2747+
2748+
class QueryStringPlugin {
2749+
constructor() {
2750+
this.mapping = new Map;
2751+
}
2752+
attachToComponent(component) {
2753+
this.element = component.element;
2754+
this.registerBindings();
2755+
component.on('connect', (component) => {
2756+
this.updateUrl(component);
2757+
});
2758+
component.on('render:finished', (component) => {
2759+
this.updateUrl(component);
2760+
});
2761+
}
2762+
registerBindings() {
2763+
const rawQueryMapping = this.element.dataset.liveQueryMapping;
2764+
if (rawQueryMapping === undefined) {
2765+
return;
2766+
}
2767+
const mapping = JSON.parse(rawQueryMapping);
2768+
Object.entries(mapping).forEach(([key, config]) => {
2769+
this.mapping.set(key, config);
2770+
});
2771+
}
2772+
updateUrl(component) {
2773+
this.mapping.forEach((mapping, propName) => {
2774+
const value = component.valueStore.get(propName);
2775+
if (value === '' || value === null || value === undefined) {
2776+
removeQueryParam(mapping.name);
2777+
}
2778+
else {
2779+
setQueryParam(mapping.name, value);
2780+
}
2781+
});
2782+
}
2783+
}
2784+
26992785
const getComponent = (element) => LiveControllerDefault.componentRegistry.getComponent(element);
27002786
class LiveControllerDefault extends Controller {
27012787
constructor() {
@@ -2723,6 +2809,7 @@ class LiveControllerDefault extends Controller {
27232809
new PageUnloadingPlugin(),
27242810
new PollingPlugin(),
27252811
new SetValueOntoModelFieldsPlugin(),
2812+
new QueryStringPlugin(),
27262813
];
27272814
plugins.forEach((plugin) => {
27282815
this.component.addPlugin(plugin);
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export declare function setQueryParam(param: string, value: any): void;
2+
export declare function removeQueryParam(param: string): void;
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import Component from '../index';
2+
import { PluginInterface } from './PluginInterface';
3+
import {
4+
setQueryParam, removeQueryParam,
5+
} from '../../url_utils';
6+
7+
type QueryMapping = {
8+
name: string,
9+
}
10+
11+
export default class implements PluginInterface {
12+
private element: Element;
13+
private mapping: Map<string,QueryMapping> = new Map;
14+
15+
attachToComponent(component: Component): void {
16+
this.element = component.element;
17+
this.registerBindings();
18+
19+
component.on('connect', (component: Component) => {
20+
this.updateUrl(component);
21+
});
22+
23+
component.on('render:finished', (component: Component)=> {
24+
this.updateUrl(component);
25+
});
26+
}
27+
28+
private registerBindings(): void {
29+
const rawQueryMapping = (this.element as HTMLElement).dataset.liveQueryMapping;
30+
if (rawQueryMapping === undefined) {
31+
return;
32+
}
33+
34+
const mapping = JSON.parse(rawQueryMapping) as {[p: string]: QueryMapping};
35+
36+
Object.entries(mapping).forEach(([key, config]) => {
37+
this.mapping.set(key, config);
38+
})
39+
}
40+
41+
private updateUrl(component: Component){
42+
this.mapping.forEach((mapping, propName) => {
43+
const value = component.valueStore.get(propName);
44+
if (value === '' || value === null || value === undefined) {
45+
removeQueryParam(mapping.name);
46+
} else {
47+
setQueryParam(mapping.name, value);
48+
}
49+
50+
});
51+
}
52+
}

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import SetValueOntoModelFieldsPlugin from './Component/plugins/SetValueOntoModel
1818
import { PluginInterface } from './Component/plugins/PluginInterface';
1919
import getModelBinding from './Directive/get_model_binding';
2020
import ComponentRegistry from './ComponentRegistry';
21+
import QueryStringPlugin from './Component/plugins/QueryStringPlugin';
2122

2223
export { Component };
2324
export const getComponent = (element: HTMLElement): Promise<Component> =>
@@ -102,6 +103,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
102103
new PageUnloadingPlugin(),
103104
new PollingPlugin(),
104105
new SetValueOntoModelFieldsPlugin(),
106+
new QueryStringPlugin(),
105107
];
106108
plugins.forEach((plugin) => {
107109
this.component.addPlugin(plugin);
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
class AdvancedURLSearchParams extends URLSearchParams {
2+
set(name: string, value: any) {
3+
if (typeof value !== 'object') {
4+
super.set(name, value);
5+
} else {
6+
this.delete(name);
7+
if (Array.isArray(value)) {
8+
value.forEach((v) => {
9+
this.append(`${name}[]`, v);
10+
});
11+
} else {
12+
Object.entries(value).forEach(([index, v]) => {
13+
this.append(`${name}[${index}]`, v as string);
14+
});
15+
}
16+
}
17+
}
18+
19+
delete(name: string) {
20+
super.delete(name);
21+
const pattern = new RegExp(`^${name}(\\[.*])?$`);
22+
for (const key of Array.from(this.keys())) {
23+
if (key.match(pattern)) {
24+
super.delete(key);
25+
}
26+
}
27+
}
28+
}
29+
30+
export function setQueryParam(param: string, value: any) {
31+
const queryParams = new AdvancedURLSearchParams(window.location.search);
32+
33+
queryParams.set(param, value);
34+
35+
const url = urlFromQueryParams(queryParams);
36+
37+
history.replaceState(history.state, '', url);
38+
}
39+
40+
export function removeQueryParam(param: string) {
41+
const queryParams = new AdvancedURLSearchParams(window.location.search);
42+
43+
queryParams.delete(param);
44+
45+
const url = urlFromQueryParams(queryParams);
46+
47+
history.replaceState(history.state, '', url);
48+
}
49+
50+
function urlFromQueryParams(queryParams: URLSearchParams) {
51+
let queryString = '';
52+
if (Array.from(queryParams.entries()).length > 0) {
53+
queryString += '?' + queryParams.toString();
54+
}
55+
56+
return window.location.origin + window.location.pathname + queryString + window.location.hash;
57+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
* This file is part of the Symfony package.
3+
*
4+
* (c) Fabien Potencier <[email protected]>
5+
*
6+
* For the full copyright and license information, please view the LICENSE
7+
* file that was distributed with this source code.
8+
*/
9+
10+
'use strict';
11+
12+
import {createTest, initComponent, shutdownTests} from '../tools';
13+
import { getByText, waitFor } from '@testing-library/dom';
14+
15+
describe('LiveController query string binding', () => {
16+
afterEach(() => {
17+
shutdownTests();
18+
});
19+
20+
it('doesn\'t initialize URL if props are not defined', async () => {
21+
await createTest({ prop: ''}, (data: any) => `
22+
<div ${initComponent(data, { queryMapping: {prop: {name: 'prop'}}})}></div>
23+
`)
24+
25+
expect(window.location.search).toEqual('');
26+
})
27+
28+
it('initializes URL with defined props values', async () => {
29+
await createTest({ prop: 'foo'}, (data: any) => `
30+
<div ${initComponent(data, { queryMapping: {prop: {name: 'prop'}}})}></div>
31+
`)
32+
33+
expect(window.location.search).toEqual('?prop=foo');
34+
});
35+
36+
it('properly handles array props in the URL', async () => {
37+
await createTest({ prop: ['foo', 'bar']}, (data: any) => `
38+
<div ${initComponent(data, { queryMapping: {prop: {name: 'prop'}}})}></div>
39+
`)
40+
expect(decodeURIComponent(window.location.search)).toEqual('?prop[]=foo&prop[]=bar');
41+
});
42+
43+
it('updates the URL when the props changed', async () => {
44+
const test = await createTest({ prop: ''}, (data: any) => `
45+
<div ${initComponent(data, { queryMapping: {prop: {name: 'prop'}}})}></div>
46+
`)
47+
48+
test.expectsAjaxCall()
49+
.expectUpdatedData({prop: 'foo'});
50+
51+
await test.component.set('prop', 'foo', true);
52+
53+
expect(window.location.search).toEqual('?prop=foo');
54+
});
55+
56+
it('updates the URL with props changed by the server', async () => {
57+
const test = await createTest({ prop: ''}, (data: any) => `
58+
<div ${initComponent(data, {queryMapping: {prop: {name: 'prop'}}})}>
59+
Prop: ${data.prop}
60+
<button data-action="live#action" data-action-name="changeProp">Change prop</button>
61+
</div>
62+
`);
63+
64+
test.expectsAjaxCall()
65+
.expectActionCalled('changeProp')
66+
.serverWillChangeProps((data: any) => {
67+
data.prop = 'foo';
68+
});
69+
70+
getByText(test.element, 'Change prop').click();
71+
72+
await waitFor(() => expect(test.element).toHaveTextContent('Prop: foo'));
73+
74+
expect(window.location.search).toEqual('?prop=foo');
75+
});
76+
})

src/LiveComponent/assets/test/tools.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -434,6 +434,7 @@ export function initComponent(props: any = {}, controllerValues: any = {}) {
434434
${controllerValues.fingerprint ? `data-live-fingerprint-value="${controllerValues.fingerprint}"` : ''}
435435
${controllerValues.listeners ? `data-live-listeners-value="${dataToJsonAttribute(controllerValues.listeners)}"` : ''}
436436
${controllerValues.browserDispatch ? `data-live-browser-dispatch="${dataToJsonAttribute(controllerValues.browserDispatch)}"` : ''}
437+
${controllerValues.queryMapping ? `data-live-query-mapping="${dataToJsonAttribute(controllerValues.queryMapping)}"` : ''}
437438
`;
438439
}
439440

0 commit comments

Comments
 (0)