Skip to content

Commit 524ac5a

Browse files
committed
feature #1230 [LiveComponent] Allow binding LiveProp to URL query parameter (squrious)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [LiveComponent] Allow binding LiveProp to URL query parameter | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Issues | N/A | License | MIT Inspired from the Livewire Url attribute, let's bind our LiveProp props to the URL query string! This is a first proposal, with the minimum feature behavior: - Add a new `url` constructor parameter to `LiveProp` - Bound props appear in the URL in a parameter with the same name - When the component is mounted for the first time (not in a live request), its props are initialized with provided query parameters - When the component is rendered on client side, the URL is updated according to the mapping (on first render and further live updates) Currently supported data types: - Scalar props - Array props - Custom DTO objects with scalar and array properties ```php #[AsLiveComponent()] final class MyComponent { #[LiveProp(writable: true, url: true)] public ?string $search = null; #[LiveProp(writable: ['id', 'name'], url: true)] public ?SomeDto $dto = null; #[LiveProp(url: true)] public array $tags = []; } ``` A bound LiveProp doesn't need to be writable, so it could be updated on server side based on a custom action. BUT for DTOs, for the moment I bind the writable props only. Maybe another config option could be a better solution? What could be next? - Improve data types support (nested DTOs, enums, specific properties from DTOs...) - Aliasing a prop name to a query parameter name (`search` => `q`, `dto.id` => `some_id`) - Add a `keep` option to force presence in the URL even if the prop is empty - Listen to external URL modification to synchronize props - Multiple history strategies (currently the URL is replaced, but we could push new entries instead) - Enable configuration from a `getQueryString` method in the component instead of attributes You can also check my [demo app](https://github.com/squrious/symfony-ux-demo-live-url) to try the feature. Commits ------- 68e4dd3 [LiveComponent] Allow binding LiveProp to URL query parameter
2 parents f880e01 + 68e4dd3 commit 524ac5a

38 files changed

+1296
-90
lines changed

src/Autocomplete/assets/dist/controller.js

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,12 @@ LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
1515
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
1616
PERFORMANCE OF THIS SOFTWARE.
1717
***************************************************************************** */
18-
/* global Reflect, Promise, SuppressedError, Symbol */
19-
2018

2119
function __classPrivateFieldGet(receiver, state, kind, f) {
2220
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
2321
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");
2422
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
25-
}
26-
27-
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
28-
var e = new Error(message);
29-
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
30-
};
23+
}
3124

3225
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;
3326
class default_1 extends Controller {

src/LiveComponent/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+
}

src/LiveComponent/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;

src/LiveComponent/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

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+
}

src/LiveComponent/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)