Skip to content

Commit 2f3e927

Browse files
committed
Use component hydrator to extract query string data
1 parent 37ed99f commit 2f3e927

19 files changed

+347
-220
lines changed

src/LiveComponent/assets/dist/Component/plugins/QueryStringPlugin.d.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,15 @@ import Component from '../index';
22
import { PluginInterface } from './PluginInterface';
33
export default class implements PluginInterface {
44
private mapping;
5+
private initialPropsValues;
6+
private changedProps;
57
constructor(mapping: {
68
[p: string]: any;
79
});
810
attachToComponent(component: Component): void;
9-
private updateUrl;
11+
private updateUrlParam;
12+
private getParamFromModel;
13+
private getNormalizedPropNames;
14+
private isValueEmpty;
15+
private isObjectValue;
1016
}

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 51 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2710,7 +2710,9 @@ class AdvancedURLSearchParams extends URLSearchParams {
27102710
}
27112711
else {
27122712
Object.entries(value).forEach(([index, v]) => {
2713-
this.append(`${name}[${index}]`, v);
2713+
if (v !== null && v !== '' && v !== undefined) {
2714+
this.append(`${name}[${index}]`, v);
2715+
}
27142716
});
27152717
}
27162718
}
@@ -2748,25 +2750,63 @@ function urlFromQueryParams(queryParams) {
27482750
class QueryStringPlugin {
27492751
constructor(mapping) {
27502752
this.mapping = new Map;
2753+
this.initialPropsValues = new Map;
2754+
this.changedProps = {};
27512755
Object.entries(mapping).forEach(([key, config]) => {
27522756
this.mapping.set(key, config);
27532757
});
27542758
}
27552759
attachToComponent(component) {
2760+
component.on('connect', (component) => {
2761+
for (const model of this.mapping.keys()) {
2762+
for (const prop of this.getNormalizedPropNames(component.valueStore.get(model), model)) {
2763+
this.initialPropsValues.set(prop, component.valueStore.get(prop));
2764+
}
2765+
}
2766+
});
27562767
component.on('render:finished', (component) => {
2757-
this.updateUrl(component);
2768+
this.initialPropsValues.forEach((initialValue, prop) => {
2769+
var _a;
2770+
const value = component.valueStore.get(prop);
2771+
(_a = this.changedProps)[prop] || (_a[prop] = JSON.stringify(value) !== JSON.stringify(initialValue));
2772+
if (this.changedProps) {
2773+
this.updateUrlParam(prop, value);
2774+
}
2775+
});
27582776
});
27592777
}
2760-
updateUrl(component) {
2761-
this.mapping.forEach((mapping, propName) => {
2762-
const value = component.valueStore.get(propName);
2763-
if (value === '' || value === null || value === undefined) {
2764-
removeQueryParam(mapping.name);
2765-
}
2766-
else {
2767-
setQueryParam(mapping.name, value);
2778+
updateUrlParam(model, value) {
2779+
const paramName = this.getParamFromModel(model);
2780+
if (paramName === undefined) {
2781+
return;
2782+
}
2783+
this.isValueEmpty(value)
2784+
? removeQueryParam(paramName)
2785+
: setQueryParam(paramName, value);
2786+
}
2787+
getParamFromModel(model) {
2788+
const modelParts = model.split('.');
2789+
const rootPropMapping = this.mapping.get(modelParts[0]);
2790+
if (rootPropMapping === undefined) {
2791+
return undefined;
2792+
}
2793+
return rootPropMapping.name + modelParts.slice(1).map((v) => `[${v}]`).join('');
2794+
}
2795+
*getNormalizedPropNames(value, propertyPath) {
2796+
if (this.isObjectValue(value)) {
2797+
for (const key in value) {
2798+
yield* this.getNormalizedPropNames(value[key], `${propertyPath}.${key}`);
27682799
}
2769-
});
2800+
}
2801+
else {
2802+
yield propertyPath;
2803+
}
2804+
}
2805+
isValueEmpty(value) {
2806+
return (value === '' || value === null || value === undefined);
2807+
}
2808+
isObjectValue(value) {
2809+
return !(Array.isArray(value) || value === null || typeof value !== 'object');
27702810
}
27712811
}
27722812

src/LiveComponent/assets/src/Component/plugins/QueryStringPlugin.ts

Lines changed: 74 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,9 @@ type QueryMapping = {
99
}
1010

1111
export default class implements PluginInterface {
12-
private mapping: Map<string,QueryMapping> = new Map;
12+
private mapping = new Map<string,QueryMapping>;
13+
private initialPropsValues = new Map<string, any>;
14+
private changedProps: {[p: string]: boolean} = {};
1315

1416
constructor(mapping: {[p: string]: any}) {
1517
Object.entries(mapping).forEach(([key, config]) => {
@@ -18,20 +20,81 @@ export default class implements PluginInterface {
1820
}
1921

2022
attachToComponent(component: Component): void {
21-
component.on('render:finished', (component: Component)=> {
22-
this.updateUrl(component);
23+
component.on('connect', (component: Component) => {
24+
// Store initial values of mapped props
25+
for (const model of this.mapping.keys()) {
26+
for (const prop of this.getNormalizedPropNames(component.valueStore.get(model), model)) {
27+
this.initialPropsValues.set(prop, component.valueStore.get(prop));
28+
}
29+
}
30+
});
31+
32+
component.on('render:finished', (component: Component) => {
33+
this.initialPropsValues.forEach((initialValue, prop) => {
34+
const value = component.valueStore.get(prop);
35+
36+
// Only update the URL if the prop has changed
37+
this.changedProps[prop] ||= JSON.stringify(value) !== JSON.stringify(initialValue);
38+
if (this.changedProps) {
39+
this.updateUrlParam(prop, value);
40+
}
41+
});
2342
});
2443
}
2544

26-
private updateUrl(component: Component){
27-
this.mapping.forEach((mapping, propName) => {
28-
const value = component.valueStore.get(propName);
29-
if (value === '' || value === null || value === undefined) {
30-
removeQueryParam(mapping.name);
31-
} else {
32-
setQueryParam(mapping.name, value);
45+
private updateUrlParam(model: string, value: any)
46+
{
47+
const paramName = this.getParamFromModel(model);
48+
49+
if (paramName === undefined) {
50+
return;
51+
}
52+
53+
this.isValueEmpty(value)
54+
? removeQueryParam(paramName)
55+
: setQueryParam(paramName, value);
56+
}
57+
58+
/**
59+
* Convert a normalized property path (foo.bar) in brace notation (foo[bar]).
60+
*/
61+
private getParamFromModel(model: string)
62+
{
63+
const modelParts = model.split('.');
64+
const rootPropMapping = this.mapping.get(modelParts[0]);
65+
66+
if (rootPropMapping === undefined) {
67+
return undefined;
68+
}
69+
70+
return rootPropMapping.name + modelParts.slice(1).map((v) => `[${v}]`).join('');
71+
}
72+
73+
/**
74+
* Get property names for the given value in the "foo.bar" format:
75+
*
76+
* getNormalizedPropNames({'foo': ..., 'baz': ...}, 'prop') yields 'prop.foo', 'prop.baz', etc.
77+
*
78+
* Non-object values will yield the propertyPath without any change.
79+
*/
80+
private *getNormalizedPropNames(value: any, propertyPath: string): Generator<string>
81+
{
82+
if (this.isObjectValue(value)) {
83+
for (const key in value) {
84+
yield* this.getNormalizedPropNames(value[key], `${propertyPath}.${key}`)
3385
}
86+
} else {
87+
yield propertyPath;
88+
}
89+
}
3490

35-
});
91+
private isValueEmpty(value: any)
92+
{
93+
return (value === '' || value === null || value === undefined);
94+
}
95+
96+
private isObjectValue(value: any): boolean
97+
{
98+
return !(Array.isArray(value) || value === null || typeof value !== 'object');
3699
}
37100
}

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
4545
debounce: { type: Number, default: 150 },
4646
id: String,
4747
fingerprint: { type: String, default: '' },
48-
queryMapping: { type: Object, default: {}},
48+
queryMapping: { type: Object, default: {} },
4949
};
5050

5151
declare readonly nameValue: string;
@@ -56,7 +56,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
5656
declare readonly hasDebounceValue: boolean;
5757
declare readonly debounceValue: number;
5858
declare readonly fingerprintValue: string;
59-
declare readonly queryMappingValue: Map<string,any>;
59+
declare readonly queryMappingValue: Map<string, any>;
6060

6161
/** The component, wrapped in the convenience Proxy */
6262
private proxiedComponent: Component;

src/LiveComponent/assets/src/url_utils.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ class AdvancedURLSearchParams extends URLSearchParams {
1010
});
1111
} else {
1212
Object.entries(value).forEach(([index, v]) => {
13-
this.append(`${name}[${index}]`, v as string);
13+
if (v !== null && v !== '' && v !== undefined) {
14+
this.append(`${name}[${index}]`, v as string);
15+
}
1416
});
1517
}
1618
}

src/LiveComponent/assets/test/controller/query-binding.test.ts

Lines changed: 105 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,50 +9,142 @@
99

1010
'use strict';
1111

12-
import {createTest, initComponent, shutdownTests} from '../tools';
12+
import {createTest, initComponent, shutdownTests, setCurrentSearch, expectCurrentSearch} from '../tools';
1313
import { getByText, waitFor } from '@testing-library/dom';
1414

1515
describe('LiveController query string binding', () => {
1616
afterEach(() => {
1717
shutdownTests();
18+
setCurrentSearch('');
1819
});
1920

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

25-
expect(window.location.search).toEqual('');
26+
expectCurrentSearch().toEqual('');
2627
})
2728

28-
it('initializes URL with defined props values', async () => {
29+
it('doesn\'t initialize URL with defined props values', async () => {
2930
await createTest({ prop: 'foo'}, (data: any) => `
3031
<div ${initComponent(data, { queryMapping: {prop: {name: 'prop'}}})}></div>
3132
`)
3233

33-
expect(window.location.search).toEqual('?prop=foo');
34+
expectCurrentSearch().toEqual('');
3435
});
3536

36-
it('properly handles array props in the URL', async () => {
37-
await createTest({ prop: ['foo', 'bar']}, (data: any) => `
37+
it('updates basic props in the URL', async () => {
38+
const test = await createTest({ prop1: '', prop2: null}, (data: any) => `
39+
<div ${initComponent(data, { queryMapping: {prop1: {name: 'prop1'}, prop2: {name: 'prop2'}}})}></div>
40+
`)
41+
42+
// String
43+
44+
// Set value
45+
test.expectsAjaxCall()
46+
.expectUpdatedData({prop1: 'foo'});
47+
48+
await test.component.set('prop1', 'foo', true);
49+
50+
expectCurrentSearch().toEqual('?prop1=foo');
51+
52+
// Remove value
53+
test.expectsAjaxCall()
54+
.expectUpdatedData({prop1: ''});
55+
56+
await test.component.set('prop1', '', true);
57+
58+
expectCurrentSearch().toEqual('');
59+
60+
// Number
61+
62+
// Set value
63+
test.expectsAjaxCall()
64+
.expectUpdatedData({prop2: 42});
65+
66+
await test.component.set('prop2', 42, true);
67+
68+
expectCurrentSearch().toEqual('?prop2=42');
69+
70+
// Remove value
71+
test.expectsAjaxCall()
72+
.expectUpdatedData({prop2: null});
73+
74+
await test.component.set('prop2', null, true);
75+
76+
expectCurrentSearch().toEqual('');
77+
});
78+
79+
it('updates array props in the URL', async () => {
80+
const test = await createTest({ prop: []}, (data: any) => `
3881
<div ${initComponent(data, { queryMapping: {prop: {name: 'prop'}}})}></div>
3982
`)
40-
expect(decodeURIComponent(window.location.search)).toEqual('?prop[]=foo&prop[]=bar');
83+
84+
// Set value
85+
test.expectsAjaxCall()
86+
.expectUpdatedData({prop: ['foo', 'bar']});
87+
88+
await test.component.set('prop', ['foo', 'bar'], true);
89+
90+
expectCurrentSearch().toEqual('?prop[]=foo&prop[]=bar');
91+
92+
// Remove one value
93+
test.expectsAjaxCall()
94+
.expectUpdatedData({prop: ['foo']});
95+
96+
await test.component.set('prop', ['foo'], true);
97+
98+
expectCurrentSearch().toEqual('?prop[]=foo');
99+
100+
// Remove all remaining values
101+
test.expectsAjaxCall()
102+
.expectUpdatedData({prop: []});
103+
104+
await test.component.set('prop', [], true);
105+
106+
expectCurrentSearch().toEqual('');
41107
});
42108

43-
it('updates the URL when the props changed', async () => {
44-
const test = await createTest({ prop: ''}, (data: any) => `
109+
it('updates objects in the URL', async () => {
110+
const test = await createTest({ prop: { 'foo': null, 'bar': null, 'baz': null}}, (data: any) => `
45111
<div ${initComponent(data, { queryMapping: {prop: {name: 'prop'}}})}></div>
46112
`)
47113

114+
// Set single nested prop
48115
test.expectsAjaxCall()
49-
.expectUpdatedData({prop: 'foo'});
116+
.expectUpdatedData({'prop.foo': 'dummy' });
117+
118+
await test.component.set('prop.foo', 'dummy', true);
50119

51-
await test.component.set('prop', 'foo', true);
120+
expectCurrentSearch().toEqual('?prop[foo]=dummy');
52121

53-
expect(window.location.search).toEqual('?prop=foo');
122+
// Set multiple values
123+
test.expectsAjaxCall()
124+
.expectUpdatedData({'prop': { 'foo': 'other', 'bar': 42 } });
125+
126+
await test.component.set('prop', { 'foo': 'other', 'bar': 42 }, true);
127+
128+
expectCurrentSearch().toEqual('?prop[foo]=other&prop[bar]=42');
129+
130+
// Remove one value
131+
test.expectsAjaxCall()
132+
.expectUpdatedData({'prop': { 'foo': 'other', 'bar': null } });
133+
134+
await test.component.set('prop', { 'foo': 'other', 'bar': null }, true);
135+
136+
expectCurrentSearch().toEqual('?prop[foo]=other');
137+
138+
// Remove all values
139+
test.expectsAjaxCall()
140+
.expectUpdatedData({'prop': { 'foo': null, 'bar': null } });
141+
142+
await test.component.set('prop', { 'foo': null, 'bar': null }, true);
143+
144+
expectCurrentSearch().toEqual('');
54145
});
55146

147+
56148
it('updates the URL with props changed by the server', async () => {
57149
const test = await createTest({ prop: ''}, (data: any) => `
58150
<div ${initComponent(data, {queryMapping: {prop: {name: 'prop'}}})}>
@@ -71,6 +163,6 @@ describe('LiveController query string binding', () => {
71163

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

74-
expect(window.location.search).toEqual('?prop=foo');
166+
expectCurrentSearch().toEqual('?prop=foo');
75167
});
76168
})

0 commit comments

Comments
 (0)