Skip to content

[WIP] LiveUrl #2673

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 6 commits into
base: 2.x
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/LiveComponent/assets/dist/Backend/BackendResponse.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
export default class {
response: Response;
private body;
private liveUrl;
constructor(response: Response);
getBody(): Promise<string>;
getLiveUrl(): Promise<string | null>;
}

This file was deleted.

9 changes: 0 additions & 9 deletions src/LiveComponent/assets/dist/live_controller.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,6 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
type: StringConstructor;
default: string;
};
queryMapping: {
type: ObjectConstructor;
default: {};
};
};
readonly nameValue: string;
readonly urlValue: string;
Expand All @@ -76,11 +72,6 @@ export default class LiveControllerDefault extends Controller<HTMLElement> imple
readonly debounceValue: number;
readonly fingerprintValue: string;
readonly requestMethodValue: 'get' | 'post';
readonly queryMappingValue: {
[p: string]: {
name: string;
};
};
private proxiedComponent;
private mutationObserver;
component: Component;
Expand Down
240 changes: 115 additions & 125 deletions src/LiveComponent/assets/dist/live_controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class RequestBuilder {
fetchOptions.headers = {
Accept: 'application/vnd.live-component+html',
'X-Requested-With': 'XMLHttpRequest',
'X-Live-Url': window.location.pathname + window.location.search,
};
const totalFiles = Object.entries(files).reduce((total, current) => total + current.length, 0);
const hasFingerprints = Object.keys(children).length > 0;
Expand Down Expand Up @@ -111,6 +112,12 @@ class BackendResponse {
}
return this.body;
}
async getLiveUrl() {
if (undefined === this.liveUrl) {
this.liveUrl = await this.response.headers.get('X-Live-Url');
}
return this.liveUrl;
}
}

function getElementAsTagText(element) {
Expand Down Expand Up @@ -1790,6 +1797,110 @@ function executeMorphdom(rootFromElement, rootToElement, modifiedFieldElements,
});
}

function isValueEmpty(value) {
if (null === value || value === '' || undefined === value || (Array.isArray(value) && value.length === 0)) {
return true;
}
if (typeof value !== 'object') {
return false;
}
for (const key of Object.keys(value)) {
if (!isValueEmpty(value[key])) {
return false;
}
}
return true;
}
function toQueryString(data) {
const buildQueryStringEntries = (data, entries = {}, baseKey = '') => {
Object.entries(data).forEach(([iKey, iValue]) => {
const key = baseKey === '' ? iKey : `${baseKey}[${iKey}]`;
if ('' === baseKey && isValueEmpty(iValue)) {
entries[key] = '';
}
else if (null !== iValue) {
if (typeof iValue === 'object') {
entries = { ...entries, ...buildQueryStringEntries(iValue, entries, key) };
}
else {
entries[key] = encodeURIComponent(iValue)
.replace(/%20/g, '+')
.replace(/%2C/g, ',');
}
}
});
return entries;
};
const entries = buildQueryStringEntries(data);
return Object.entries(entries)
.map(([key, value]) => `${key}=${value}`)
.join('&');
}
function fromQueryString(search) {
search = search.replace('?', '');
if (search === '')
return {};
const insertDotNotatedValueIntoData = (key, value, data) => {
const [first, second, ...rest] = key.split('.');
if (!second) {
data[key] = value;
return value;
}
if (data[first] === undefined) {
data[first] = Number.isNaN(Number.parseInt(second)) ? {} : [];
}
insertDotNotatedValueIntoData([second, ...rest].join('.'), value, data[first]);
};
const entries = search.split('&').map((i) => i.split('='));
const data = {};
entries.forEach(([key, value]) => {
value = decodeURIComponent(value.replace(/\+/g, '%20'));
if (!key.includes('[')) {
data[key] = value;
}
else {
if ('' === value)
return;
const dotNotatedKey = key.replace(/\[/g, '.').replace(/]/g, '');
insertDotNotatedValueIntoData(dotNotatedKey, value, data);
}
});
return data;
}
class UrlUtils extends URL {
has(key) {
const data = this.getData();
return Object.keys(data).includes(key);
}
set(key, value) {
const data = this.getData();
data[key] = value;
this.setData(data);
}
get(key) {
return this.getData()[key];
}
remove(key) {
const data = this.getData();
delete data[key];
this.setData(data);
}
getData() {
if (!this.search) {
return {};
}
return fromQueryString(this.search);
}
setData(data) {
this.search = toQueryString(data);
}
}
class HistoryStrategy {
static replace(url) {
history.replaceState(history.state, '', url);
}
}

class UnsyncedInputsTracker {
constructor(component, modelElementResolver) {
this.elementEventListeners = [
Expand Down Expand Up @@ -2137,6 +2248,10 @@ class Component {
return response;
}
this.processRerender(html, backendResponse);
const liveUrl = await backendResponse.getLiveUrl();
if (liveUrl) {
HistoryStrategy.replace(new UrlUtils(liveUrl + window.location.hash, window.location.origin));
}
this.backendRequest = null;
thisPromiseResolve(backendResponse);
if (this.isRequestPending) {
Expand Down Expand Up @@ -2741,129 +2856,6 @@ class PollingPlugin {
}
}

function isValueEmpty(value) {
if (null === value || value === '' || undefined === value || (Array.isArray(value) && value.length === 0)) {
return true;
}
if (typeof value !== 'object') {
return false;
}
for (const key of Object.keys(value)) {
if (!isValueEmpty(value[key])) {
return false;
}
}
return true;
}
function toQueryString(data) {
const buildQueryStringEntries = (data, entries = {}, baseKey = '') => {
Object.entries(data).forEach(([iKey, iValue]) => {
const key = baseKey === '' ? iKey : `${baseKey}[${iKey}]`;
if ('' === baseKey && isValueEmpty(iValue)) {
entries[key] = '';
}
else if (null !== iValue) {
if (typeof iValue === 'object') {
entries = { ...entries, ...buildQueryStringEntries(iValue, entries, key) };
}
else {
entries[key] = encodeURIComponent(iValue)
.replace(/%20/g, '+')
.replace(/%2C/g, ',');
}
}
});
return entries;
};
const entries = buildQueryStringEntries(data);
return Object.entries(entries)
.map(([key, value]) => `${key}=${value}`)
.join('&');
}
function fromQueryString(search) {
search = search.replace('?', '');
if (search === '')
return {};
const insertDotNotatedValueIntoData = (key, value, data) => {
const [first, second, ...rest] = key.split('.');
if (!second) {
data[key] = value;
return value;
}
if (data[first] === undefined) {
data[first] = Number.isNaN(Number.parseInt(second)) ? {} : [];
}
insertDotNotatedValueIntoData([second, ...rest].join('.'), value, data[first]);
};
const entries = search.split('&').map((i) => i.split('='));
const data = {};
entries.forEach(([key, value]) => {
value = decodeURIComponent(value.replace(/\+/g, '%20'));
if (!key.includes('[')) {
data[key] = value;
}
else {
if ('' === value)
return;
const dotNotatedKey = key.replace(/\[/g, '.').replace(/]/g, '');
insertDotNotatedValueIntoData(dotNotatedKey, value, data);
}
});
return data;
}
class UrlUtils extends URL {
has(key) {
const data = this.getData();
return Object.keys(data).includes(key);
}
set(key, value) {
const data = this.getData();
data[key] = value;
this.setData(data);
}
get(key) {
return this.getData()[key];
}
remove(key) {
const data = this.getData();
delete data[key];
this.setData(data);
}
getData() {
if (!this.search) {
return {};
}
return fromQueryString(this.search);
}
setData(data) {
this.search = toQueryString(data);
}
}
class HistoryStrategy {
static replace(url) {
history.replaceState(history.state, '', url);
}
}

class QueryStringPlugin {
constructor(mapping) {
this.mapping = mapping;
}
attachToComponent(component) {
component.on('render:finished', (component) => {
const urlUtils = new UrlUtils(window.location.href);
const currentUrl = urlUtils.toString();
Object.entries(this.mapping).forEach(([prop, mapping]) => {
const value = component.valueStore.get(prop);
urlUtils.set(mapping.name, value);
});
if (currentUrl !== urlUtils.toString()) {
HistoryStrategy.replace(urlUtils);
}
});
}
}

class SetValueOntoModelFieldsPlugin {
attachToComponent(component) {
this.synchronizeValueOfModelFields(component);
Expand Down Expand Up @@ -3073,7 +3065,6 @@ class LiveControllerDefault extends Controller {
new PageUnloadingPlugin(),
new PollingPlugin(),
new SetValueOntoModelFieldsPlugin(),
new QueryStringPlugin(this.queryMappingValue),
new ChildComponentPlugin(this.component),
];
plugins.forEach((plugin) => {
Expand Down Expand Up @@ -3183,7 +3174,6 @@ LiveControllerDefault.values = {
debounce: { type: Number, default: 150 },
fingerprint: { type: String, default: '' },
requestMethod: { type: String, default: 'post' },
queryMapping: { type: Object, default: {} },
};
LiveControllerDefault.backendFactory = (controller) => new Backend(controller.urlValue, controller.requestMethodValue);

Expand Down
9 changes: 9 additions & 0 deletions src/LiveComponent/assets/src/Backend/BackendResponse.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export default class {
response: Response;
private body: string;
private liveUrl: string | null;

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

return this.body;
}

async getLiveUrl(): Promise<string | null> {
if (undefined === this.liveUrl) {
this.liveUrl = await this.response.headers.get('X-Live-Url');
}

return this.liveUrl;
}
}
1 change: 1 addition & 0 deletions src/LiveComponent/assets/src/Backend/RequestBuilder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export default class {
fetchOptions.headers = {
Accept: 'application/vnd.live-component+html',
'X-Requested-With': 'XMLHttpRequest',
'X-Live-Url': window.location.pathname + window.location.search,
};

const totalFiles = Object.entries(files).reduce((total, current) => total + current.length, 0);
Expand Down
5 changes: 5 additions & 0 deletions src/LiveComponent/assets/src/Component/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import ExternalMutationTracker from '../Rendering/ExternalMutationTracker';
import { elementBelongsToThisComponent, getValueFromElement, htmlToElement } from '../dom_utils';
import { executeMorphdom } from '../morphdom';
import { normalizeModelName } from '../string_utils';
import { HistoryStrategy, UrlUtils } from "../url_utils";
import type { ElementDriver } from './ElementDriver';
import UnsyncedInputsTracker from './UnsyncedInputsTracker';
import ValueStore from './ValueStore';
Expand Down Expand Up @@ -328,6 +329,10 @@ export default class Component {
}

this.processRerender(html, backendResponse);
const liveUrl = await backendResponse.getLiveUrl();
if (liveUrl) {
HistoryStrategy.replace(new UrlUtils(liveUrl + window.location.hash, window.location.origin));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
HistoryStrategy.replace(new UrlUtils(liveUrl + window.location.hash, window.location.origin));
history.replaceState(history.state, '', liveUrl + window.location.hash);

? (not sure)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would allow the remove both queryStringPlugin.ts and url_utils.ts (i think)

}

// finally resolve this promise
this.backendRequest = null;
Expand Down
Loading
Loading