Skip to content

Commit b51130b

Browse files
committed
feature #2673 [LiveComponent] Add the possibility to map LiveProp as a path parameter (mbuliard)
This PR was squashed before being merged into the 2.x branch. Discussion ---------- [LiveComponent] Add the possibility to map LiveProp as a path parameter | Q | A | ------------- | --- | Bug fix? | no | New feature? | yes | Docs? | yes | Issues | No | License | MIT The purpose of this MR is to give the responsability of changing the URL to the backend, allowing to use Symfony Router and to have LiveProps in the path, not only in query parameters : `http://example.com/content?id=123` vs `http://example.com/content/123` To set the LiveProp in the path, a new option, `mapPath` has been added to the UrlMapping option of the LiveProp : ```php #[LiveProp(writable: true, url: new UrlMapping(mapPath: true))] public int $id; ``` ### WORKFLOW 1. When sending a request to the backend, the frontend add a new header `X-Live-Url` containing the current path and query parameters. 2. On KernelResponse event, the new path and query string is calculated from the header received and the new props and put in the response header `X-Live-Url`. 3. When the frontend receives the response, the current path and query are placed by those received, via `history.replaceState`. ### BACKEND CHANGES - New **UrlFactory** service to generate URL from the previous one, the path-mapped props and the query-mapped props. The url is first generated by the Symfony Router, using the previous one and the path-mapped props. Then the query-mapped props and the previous query parameters are added. - **QueryStringPropsExtractor** is renamed to **RequestPropsExtractor** and now extract props from the request attributes and query parameters. - **UrlMapping** now has a new option `mapPath`, boolean, false by default. - **LiveComponentMetadata** has new method `getAllUrlMappings` returning urlMappings of all LiveProps. - **QueryStringInitializeSubscriber** is renamed to **RequestInitializeSubscriber** - new **LiveUrlSubscriber**, listening to KernelResponse, and setting the `X-Live-Url` of the response with the new URL, generated by the `UrlFactory`. To generate it, the previous location is extracted from the request and the props are extracted from metadata, hydrated with the values of `_live_request_data` and sorted between path-mapped and query-mapped. - **LiveComponentSubscriber** now add `responseProps` data to the `_live_request_data` attribute, containing the mounted component data when the action is not the default one. This change is made to take server-side changes into account. - **LiveComponentMetadata** has new method `getAllUrlMappings` returning urlMappings of all LiveProps. ### FRONTEND CHANGES - **Backend/RequestBuilder** now add the current pathname and search as `X-Live-Url` header in the request. - **Backend/BackendResponse** has new property `liveUrl`, populated from the HTTP response `X-Live-Url` header. - **Component/index.ts** : `performRequest` now check for `X-Live-Url` header in response and, when found, do `history.replace` with the new url and the current hash and origin. - **url_utils** is removed. - **Component/plugins/QueryStringPlugin** is removed. ### TODO Review :-) Commits ------- bc5f27d [LiveComponent] Add the possibility to map LiveProp as a path parameter
2 parents 45f31cc + bc5f27d commit b51130b

35 files changed

+883
-565
lines changed

src/LiveComponent/CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
- `min_length` and `max_length`: validate length from textual input elements
77
- `min_value` and `max_value`: validate value from numeral input elements
88

9+
- Add new `mapPath` options (default `false`) to `UrlMapping` of a `LiveProp`
10+
to allow the prop to be mapped to the path instead of the query in the url.
11+
912
```twig
1013
<!-- Do not trigger model update until 3 characters are typed -->
1114
<input data-model="min_length(3)|username" type="text" value="" />
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
export default class {
22
response: Response;
33
private body;
4+
private liveUrl;
45
constructor(response: Response);
56
getBody(): Promise<string>;
7+
getLiveUrl(): string | null;
68
}

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

Lines changed: 0 additions & 13 deletions
This file was deleted.

src/LiveComponent/assets/dist/live_controller.d.ts

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,10 +49,6 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
4949
type: StringConstructor;
5050
default: string;
5151
};
52-
queryMapping: {
53-
type: ObjectConstructor;
54-
default: {};
55-
};
5652
};
5753
readonly nameValue: string;
5854
readonly urlValue: string;
@@ -76,11 +72,6 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
7672
readonly debounceValue: number;
7773
readonly fingerprintValue: string;
7874
readonly requestMethodValue: 'get' | 'post';
79-
readonly queryMappingValue: {
80-
[p: string]: {
81-
name: string;
82-
};
83-
};
8475
private proxiedComponent;
8576
private mutationObserver;
8677
component: Component;

src/LiveComponent/assets/dist/live_controller.js

Lines changed: 11 additions & 125 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class RequestBuilder {
3333
fetchOptions.headers = {
3434
Accept: 'application/vnd.live-component+html',
3535
'X-Requested-With': 'XMLHttpRequest',
36+
'X-Live-Url': window.location.pathname + window.location.search,
3637
};
3738
const totalFiles = Object.entries(files).reduce((total, current) => total + current.length, 0);
3839
const hasFingerprints = Object.keys(children).length > 0;
@@ -111,6 +112,12 @@ class BackendResponse {
111112
}
112113
return this.body;
113114
}
115+
getLiveUrl() {
116+
if (undefined === this.liveUrl) {
117+
this.liveUrl = this.response.headers.get('X-Live-Url');
118+
}
119+
return this.liveUrl;
120+
}
114121
}
115122

116123
function getElementAsTagText(element) {
@@ -2146,6 +2153,10 @@ class Component {
21462153
return response;
21472154
}
21482155
this.processRerender(html, backendResponse);
2156+
const liveUrl = backendResponse.getLiveUrl();
2157+
if (liveUrl) {
2158+
history.replaceState(history.state, '', new URL(liveUrl + window.location.hash, window.location.origin));
2159+
}
21492160
this.backendRequest = null;
21502161
thisPromiseResolve(backendResponse);
21512162
if (this.isRequestPending) {
@@ -2770,129 +2781,6 @@ class PollingPlugin {
27702781
}
27712782
}
27722783

2773-
function isValueEmpty(value) {
2774-
if (null === value || value === '' || undefined === value || (Array.isArray(value) && value.length === 0)) {
2775-
return true;
2776-
}
2777-
if (typeof value !== 'object') {
2778-
return false;
2779-
}
2780-
for (const key of Object.keys(value)) {
2781-
if (!isValueEmpty(value[key])) {
2782-
return false;
2783-
}
2784-
}
2785-
return true;
2786-
}
2787-
function toQueryString(data) {
2788-
const buildQueryStringEntries = (data, entries = {}, baseKey = '') => {
2789-
Object.entries(data).forEach(([iKey, iValue]) => {
2790-
const key = baseKey === '' ? iKey : `${baseKey}[${iKey}]`;
2791-
if ('' === baseKey && isValueEmpty(iValue)) {
2792-
entries[key] = '';
2793-
}
2794-
else if (null !== iValue) {
2795-
if (typeof iValue === 'object') {
2796-
entries = { ...entries, ...buildQueryStringEntries(iValue, entries, key) };
2797-
}
2798-
else {
2799-
entries[key] = encodeURIComponent(iValue)
2800-
.replace(/%20/g, '+')
2801-
.replace(/%2C/g, ',');
2802-
}
2803-
}
2804-
});
2805-
return entries;
2806-
};
2807-
const entries = buildQueryStringEntries(data);
2808-
return Object.entries(entries)
2809-
.map(([key, value]) => `${key}=${value}`)
2810-
.join('&');
2811-
}
2812-
function fromQueryString(search) {
2813-
search = search.replace('?', '');
2814-
if (search === '')
2815-
return {};
2816-
const insertDotNotatedValueIntoData = (key, value, data) => {
2817-
const [first, second, ...rest] = key.split('.');
2818-
if (!second) {
2819-
data[key] = value;
2820-
return value;
2821-
}
2822-
if (data[first] === undefined) {
2823-
data[first] = Number.isNaN(Number.parseInt(second)) ? {} : [];
2824-
}
2825-
insertDotNotatedValueIntoData([second, ...rest].join('.'), value, data[first]);
2826-
};
2827-
const entries = search.split('&').map((i) => i.split('='));
2828-
const data = {};
2829-
entries.forEach(([key, value]) => {
2830-
value = decodeURIComponent(String(value || '').replace(/\+/g, '%20'));
2831-
if (!key.includes('[')) {
2832-
data[key] = value;
2833-
}
2834-
else {
2835-
if ('' === value)
2836-
return;
2837-
const dotNotatedKey = key.replace(/\[/g, '.').replace(/]/g, '');
2838-
insertDotNotatedValueIntoData(dotNotatedKey, value, data);
2839-
}
2840-
});
2841-
return data;
2842-
}
2843-
class UrlUtils extends URL {
2844-
has(key) {
2845-
const data = this.getData();
2846-
return Object.keys(data).includes(key);
2847-
}
2848-
set(key, value) {
2849-
const data = this.getData();
2850-
data[key] = value;
2851-
this.setData(data);
2852-
}
2853-
get(key) {
2854-
return this.getData()[key];
2855-
}
2856-
remove(key) {
2857-
const data = this.getData();
2858-
delete data[key];
2859-
this.setData(data);
2860-
}
2861-
getData() {
2862-
if (!this.search) {
2863-
return {};
2864-
}
2865-
return fromQueryString(this.search);
2866-
}
2867-
setData(data) {
2868-
this.search = toQueryString(data);
2869-
}
2870-
}
2871-
class HistoryStrategy {
2872-
static replace(url) {
2873-
history.replaceState(history.state, '', url);
2874-
}
2875-
}
2876-
2877-
class QueryStringPlugin {
2878-
constructor(mapping) {
2879-
this.mapping = mapping;
2880-
}
2881-
attachToComponent(component) {
2882-
component.on('render:finished', (component) => {
2883-
const urlUtils = new UrlUtils(window.location.href);
2884-
const currentUrl = urlUtils.toString();
2885-
Object.entries(this.mapping).forEach(([prop, mapping]) => {
2886-
const value = component.valueStore.get(prop);
2887-
urlUtils.set(mapping.name, value);
2888-
});
2889-
if (currentUrl !== urlUtils.toString()) {
2890-
HistoryStrategy.replace(urlUtils);
2891-
}
2892-
});
2893-
}
2894-
}
2895-
28962784
class SetValueOntoModelFieldsPlugin {
28972785
attachToComponent(component) {
28982786
this.synchronizeValueOfModelFields(component);
@@ -3102,7 +2990,6 @@ class LiveControllerDefault extends Controller {
31022990
new PageUnloadingPlugin(),
31032991
new PollingPlugin(),
31042992
new SetValueOntoModelFieldsPlugin(),
3105-
new QueryStringPlugin(this.queryMappingValue),
31062993
new ChildComponentPlugin(this.component),
31072994
];
31082995
plugins.forEach((plugin) => {
@@ -3233,7 +3120,6 @@ LiveControllerDefault.values = {
32333120
debounce: { type: Number, default: 150 },
32343121
fingerprint: { type: String, default: '' },
32353122
requestMethod: { type: String, default: 'post' },
3236-
queryMapping: { type: Object, default: {} },
32373123
};
32383124
LiveControllerDefault.backendFactory = (controller) => new Backend(controller.urlValue, controller.requestMethodValue);
32393125

src/LiveComponent/assets/dist/url_utils.d.ts

Lines changed: 0 additions & 11 deletions
This file was deleted.

src/LiveComponent/assets/src/Backend/BackendResponse.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export default class {
22
response: Response;
33
private body: string;
4+
private liveUrl: string | null;
45

56
constructor(response: Response) {
67
this.response = response;
@@ -13,4 +14,12 @@ export default class {
1314

1415
return this.body;
1516
}
17+
18+
getLiveUrl(): string | null {
19+
if (undefined === this.liveUrl) {
20+
this.liveUrl = this.response.headers.get('X-Live-Url');
21+
}
22+
23+
return this.liveUrl;
24+
}
1625
}

src/LiveComponent/assets/src/Backend/RequestBuilder.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export default class {
2626
fetchOptions.headers = {
2727
Accept: 'application/vnd.live-component+html',
2828
'X-Requested-With': 'XMLHttpRequest',
29+
'X-Live-Url': window.location.pathname + window.location.search,
2930
};
3031

3132
const totalFiles = Object.entries(files).reduce((total, current) => total + current.length, 0);

src/LiveComponent/assets/src/Component/index.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -328,6 +328,14 @@ export default class Component {
328328
}
329329

330330
this.processRerender(html, backendResponse);
331+
const liveUrl = backendResponse.getLiveUrl();
332+
if (liveUrl) {
333+
history.replaceState(
334+
history.state,
335+
'',
336+
new URL(liveUrl + window.location.hash, window.location.origin)
337+
);
338+
}
331339

332340
// finally resolve this promise
333341
this.backendRequest = null;

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

Lines changed: 0 additions & 31 deletions
This file was deleted.

0 commit comments

Comments
 (0)