Skip to content

Commit d0897b7

Browse files
squriousweaverryan
authored andcommitted
[LiveComponent] Allow binding LiveProp to URL query parameter
1 parent 8d44f31 commit d0897b7

31 files changed

+1204
-72
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
- Fix instantiating LiveComponentMetadata multiple times.
1616
- Change JavaScript package to `type: module`.
1717
- Throwing an error when setting an invalid model name.
18+
- Add support for URL binding in `LiveProp`
1819

1920
## 2.12.0
2021

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import Component from '../index';
2+
import { PluginInterface } from './PluginInterface';
3+
interface QueryMapping {
4+
name: string;
5+
}
6+
export default class implements PluginInterface {
7+
private readonly mapping;
8+
constructor(mapping: {
9+
[p: string]: QueryMapping;
10+
});
11+
attachToComponent(component: Component): void;
12+
}
13+
export {};
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+
}

assets/dist/live_controller.d.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,10 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
3232
type: StringConstructor;
3333
default: string;
3434
};
35+
queryMapping: {
36+
type: ObjectConstructor;
37+
default: {};
38+
};
3539
};
3640
readonly nameValue: string;
3741
readonly urlValue: string;
@@ -44,6 +48,11 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
4448
readonly hasDebounceValue: boolean;
4549
readonly debounceValue: number;
4650
readonly fingerprintValue: string;
51+
readonly queryMappingValue: {
52+
[p: string]: {
53+
name: string;
54+
};
55+
};
4756
private proxiedComponent;
4857
component: Component;
4958
pendingActionTriggerModelElement: HTMLElement | null;

assets/dist/live_controller.js

Lines changed: 123 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -592,9 +592,6 @@ var commonjsGlobal = typeof globalThis !== 'undefined' ? globalThis : typeof win
592592
let insertionPoint = oldParent.firstChild;
593593
let newChild;
594594

595-
newParent.children;
596-
oldParent.children;
597-
598595
// run through all the new content
599596
while (nextNewChild) {
600597

@@ -2729,6 +2726,127 @@ class ComponentRegistry {
27292726
}
27302727
}
27312728

2729+
function isValueEmpty(value) {
2730+
if (null === value || value === '' || undefined === value || (Array.isArray(value) && value.length === 0)) {
2731+
return true;
2732+
}
2733+
if (typeof value !== 'object') {
2734+
return false;
2735+
}
2736+
for (const key of Object.keys(value)) {
2737+
if (!isValueEmpty(value[key])) {
2738+
return false;
2739+
}
2740+
}
2741+
return true;
2742+
}
2743+
function toQueryString(data) {
2744+
const buildQueryStringEntries = (data, entries = {}, baseKey = '') => {
2745+
Object.entries(data).forEach(([iKey, iValue]) => {
2746+
const key = baseKey === '' ? iKey : `${baseKey}[${iKey}]`;
2747+
if ('' === baseKey && isValueEmpty(iValue)) {
2748+
entries[key] = '';
2749+
}
2750+
else if (null !== iValue) {
2751+
if (typeof iValue === 'object') {
2752+
entries = Object.assign(Object.assign({}, entries), buildQueryStringEntries(iValue, entries, key));
2753+
}
2754+
else {
2755+
entries[key] = encodeURIComponent(iValue)
2756+
.replace(/%20/g, '+')
2757+
.replace(/%2C/g, ',');
2758+
}
2759+
}
2760+
});
2761+
return entries;
2762+
};
2763+
const entries = buildQueryStringEntries(data);
2764+
return Object.entries(entries)
2765+
.map(([key, value]) => `${key}=${value}`)
2766+
.join('&');
2767+
}
2768+
function fromQueryString(search) {
2769+
search = search.replace('?', '');
2770+
if (search === '')
2771+
return {};
2772+
const insertDotNotatedValueIntoData = (key, value, data) => {
2773+
const [first, second, ...rest] = key.split('.');
2774+
if (!second)
2775+
return (data[key] = value);
2776+
if (data[first] === undefined) {
2777+
data[first] = Number.isNaN(Number.parseInt(second)) ? {} : [];
2778+
}
2779+
insertDotNotatedValueIntoData([second, ...rest].join('.'), value, data[first]);
2780+
};
2781+
const entries = search.split('&').map((i) => i.split('='));
2782+
const data = {};
2783+
entries.forEach(([key, value]) => {
2784+
value = decodeURIComponent(value.replace(/\+/g, '%20'));
2785+
if (!key.includes('[')) {
2786+
data[key] = value;
2787+
}
2788+
else {
2789+
if ('' === value)
2790+
return;
2791+
const dotNotatedKey = key.replace(/\[/g, '.').replace(/]/g, '');
2792+
insertDotNotatedValueIntoData(dotNotatedKey, value, data);
2793+
}
2794+
});
2795+
return data;
2796+
}
2797+
class UrlUtils extends URL {
2798+
has(key) {
2799+
const data = this.getData();
2800+
return Object.keys(data).includes(key);
2801+
}
2802+
set(key, value) {
2803+
const data = this.getData();
2804+
data[key] = value;
2805+
this.setData(data);
2806+
}
2807+
get(key) {
2808+
return this.getData()[key];
2809+
}
2810+
remove(key) {
2811+
const data = this.getData();
2812+
delete data[key];
2813+
this.setData(data);
2814+
}
2815+
getData() {
2816+
if (!this.search) {
2817+
return {};
2818+
}
2819+
return fromQueryString(this.search);
2820+
}
2821+
setData(data) {
2822+
this.search = toQueryString(data);
2823+
}
2824+
}
2825+
class HistoryStrategy {
2826+
static replace(url) {
2827+
history.replaceState(history.state, '', url);
2828+
}
2829+
}
2830+
2831+
class QueryStringPlugin {
2832+
constructor(mapping) {
2833+
this.mapping = mapping;
2834+
}
2835+
attachToComponent(component) {
2836+
component.on('render:finished', (component) => {
2837+
const urlUtils = new UrlUtils(window.location.href);
2838+
const currentUrl = urlUtils.toString();
2839+
Object.entries(this.mapping).forEach(([prop, mapping]) => {
2840+
const value = component.valueStore.get(prop);
2841+
urlUtils.set(mapping.name, value);
2842+
});
2843+
if (currentUrl !== urlUtils.toString()) {
2844+
HistoryStrategy.replace(urlUtils);
2845+
}
2846+
});
2847+
}
2848+
}
2849+
27322850
const getComponent = (element) => LiveControllerDefault.componentRegistry.getComponent(element);
27332851
class LiveControllerDefault extends Controller {
27342852
constructor() {
@@ -2756,6 +2874,7 @@ class LiveControllerDefault extends Controller {
27562874
new PageUnloadingPlugin(),
27572875
new PollingPlugin(),
27582876
new SetValueOntoModelFieldsPlugin(),
2877+
new QueryStringPlugin(this.queryMappingValue),
27592878
];
27602879
plugins.forEach((plugin) => {
27612880
this.component.addPlugin(plugin);
@@ -2976,6 +3095,7 @@ LiveControllerDefault.values = {
29763095
debounce: { type: Number, default: 150 },
29773096
id: String,
29783097
fingerprint: { type: String, default: '' },
3098+
queryMapping: { type: Object, default: {} },
29793099
};
29803100
LiveControllerDefault.componentRegistry = new ComponentRegistry();
29813101

assets/dist/url_utils.d.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
export declare class UrlUtils extends URL {
2+
has(key: string): boolean;
3+
set(key: string, value: any): void;
4+
get(key: string): any | undefined;
5+
remove(key: string): void;
6+
private getData;
7+
private setData;
8+
}
9+
export declare class HistoryStrategy {
10+
static replace(url: URL): void;
11+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import Component from '../index';
2+
import { PluginInterface } from './PluginInterface';
3+
import { UrlUtils, HistoryStrategy } from '../../url_utils';
4+
5+
interface QueryMapping {
6+
/**
7+
* URL parameter name
8+
*/
9+
name: string,
10+
}
11+
12+
export default class implements PluginInterface {
13+
constructor(private readonly mapping: {[p: string]: QueryMapping}) {}
14+
15+
attachToComponent(component: Component): void {
16+
component.on('render:finished', (component: Component) => {
17+
const urlUtils = new UrlUtils(window.location.href);
18+
const currentUrl = urlUtils.toString();
19+
20+
Object.entries(this.mapping).forEach(([prop, mapping]) => {
21+
const value = component.valueStore.get(prop);
22+
urlUtils.set(mapping.name, value);
23+
});
24+
25+
// Only update URL if it has changed
26+
if (currentUrl !== urlUtils.toString()) {
27+
HistoryStrategy.replace(urlUtils);
28+
}
29+
});
30+
}
31+
}

assets/src/live_controller.ts

Lines changed: 4 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> =>
@@ -44,6 +45,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
4445
debounce: { type: Number, default: 150 },
4546
id: String,
4647
fingerprint: { type: String, default: '' },
48+
queryMapping: { type: Object, default: {} },
4749
};
4850

4951
declare readonly nameValue: string;
@@ -54,6 +56,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
5456
declare readonly hasDebounceValue: boolean;
5557
declare readonly debounceValue: number;
5658
declare readonly fingerprintValue: string;
59+
declare readonly queryMappingValue: { [p: string]: { name: string } };
5760

5861
/** The component, wrapped in the convenience Proxy */
5962
private proxiedComponent: Component;
@@ -102,6 +105,7 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
102105
new PageUnloadingPlugin(),
103106
new PollingPlugin(),
104107
new SetValueOntoModelFieldsPlugin(),
108+
new QueryStringPlugin(this.queryMappingValue),
105109
];
106110
plugins.forEach((plugin) => {
107111
this.component.addPlugin(plugin);

0 commit comments

Comments
 (0)