Skip to content

Commit 9bf843b

Browse files
committed
# This is a combination of 6 commits.
# This is the 1st commit message: WIP heavy refactoring to Component Initial "hook" system used to reset model field after re-render Adding a 2nd hook to handle window unloaded reinit polling after re-render Adding Component proxy # This is the commit message #2: fixing some tests # This is the commit message #3: Refactoring loading to a hook # This is the commit message #4: fixing tests # This is the commit message #5: rearranging # This is the commit message #6: Refactoring polling to a separate class
1 parent c68def3 commit 9bf843b

27 files changed

+1367
-1044
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@
4848
],
4949
"rules": {
5050
"@typescript-eslint/no-explicit-any": "off",
51-
"@typescript-eslint/no-empty-function": "off"
51+
"@typescript-eslint/no-empty-function": "off",
52+
"@typescript-eslint/ban-ts-comment": "off"
5253
},
5354
"env": {
5455
"browser": true

src/LiveComponent/assets/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
"@hotwired/stimulus": "^3.0.0",
3030
"@testing-library/dom": "^7.31.0",
3131
"@testing-library/user-event": "^13.1.9",
32+
"@types/node-fetch": "^2.6.2",
3233
"fetch-mock-jest": "^1.5.1",
3334
"node-fetch": "^2.6.1"
3435
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import BackendRequest from './BackendRequest';
2+
3+
export interface BackendInterface {
4+
makeRequest(data: any, actions: BackendAction[], updatedModels: string[]): BackendRequest;
5+
}
6+
7+
export interface BackendAction {
8+
name: string,
9+
args: Record<string, string>
10+
}
11+
12+
export default class implements BackendInterface {
13+
private url: string;
14+
private csrfToken: string|null;
15+
16+
constructor(url: string, csrfToken: string|null = null) {
17+
this.url = url;
18+
this.csrfToken = csrfToken;
19+
}
20+
21+
makeRequest(data: any, actions: BackendAction[], updatedModels: string[]): BackendRequest {
22+
const splitUrl = this.url.split('?');
23+
let [url] = splitUrl
24+
const [, queryString] = splitUrl;
25+
const params = new URLSearchParams(queryString || '');
26+
27+
const fetchOptions: RequestInit = {};
28+
fetchOptions.headers = {
29+
'Accept': 'application/vnd.live-component+html',
30+
};
31+
32+
if (actions.length === 0 && this.willDataFitInUrl(JSON.stringify(data), params)) {
33+
params.set('data', JSON.stringify(data));
34+
updatedModels.forEach((model) => {
35+
params.append('updatedModels[]', model);
36+
});
37+
fetchOptions.method = 'GET';
38+
} else {
39+
fetchOptions.method = 'POST';
40+
fetchOptions.headers['Content-Type'] = 'application/json';
41+
const requestData: any = { data };
42+
requestData.updatedModels = updatedModels;
43+
44+
if (actions.length > 0) {
45+
// one or more ACTIONs
46+
if (this.csrfToken) {
47+
fetchOptions.headers['X-CSRF-TOKEN'] = this.csrfToken;
48+
}
49+
50+
if (actions.length === 1) {
51+
// simple, single action
52+
requestData.args = actions[0].args;
53+
54+
url += `/${encodeURIComponent(actions[0].name)}`;
55+
} else {
56+
url += '/_batch';
57+
requestData.actions = actions;
58+
}
59+
}
60+
61+
fetchOptions.body = JSON.stringify(requestData);
62+
}
63+
64+
const paramsString = params.toString();
65+
66+
return new BackendRequest(
67+
fetch(`${url}${paramsString.length > 0 ? `?${paramsString}` : ''}`, fetchOptions),
68+
actions.map((backendAction) => backendAction.name),
69+
updatedModels
70+
);
71+
}
72+
73+
private willDataFitInUrl(dataJson: string, params: URLSearchParams) {
74+
const urlEncodedJsonData = new URLSearchParams(dataJson).toString();
75+
76+
// if the URL gets remotely close to 2000 chars, it may not fit
77+
return (urlEncodedJsonData + params.toString()).length < 1500;
78+
}
79+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
export default class {
2+
promise: Promise<Response>;
3+
actions: string[];
4+
updatedModels: string[];
5+
isResolved = false;
6+
7+
constructor(promise: Promise<Response>, actions: string[], updateModels: string[]) {
8+
this.promise = promise;
9+
this.promise.then((response) => {
10+
this.isResolved = true;
11+
12+
return response;
13+
});
14+
this.actions = actions;
15+
this.updatedModels = updateModels;
16+
}
17+
18+
/**
19+
* Does this BackendRequest contain at least on action in targetedActions?
20+
*/
21+
containsOneOfActions(targetedActions: string[]): boolean {
22+
return (this.actions.filter(action => targetedActions.includes(action))).length > 0;
23+
}
24+
25+
/**
26+
* Does this BackendRequest includes updates for any of these models?
27+
*/
28+
areAnyModelsUpdated(targetedModels: string[]): boolean {
29+
return (this.updatedModels.filter(model => targetedModels.includes(model))).length > 0;
30+
}
31+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import {getModelDirectiveFromElement} from "../dom_utils";
2+
3+
export interface ModelElementResolver {
4+
getModelName(element: HTMLElement): string|null;
5+
}
6+
7+
export class DataModelElementResolver implements ModelElementResolver {
8+
getModelName(element: HTMLElement): string|null {
9+
const modelDirective = getModelDirectiveFromElement(element, false);
10+
11+
if (!modelDirective) {
12+
return null;
13+
}
14+
15+
return modelDirective.action;
16+
}
17+
}
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import {ModelElementResolver} from "./ModelElementResolver";
2+
3+
export default class {
4+
private readonly element: HTMLElement;
5+
private readonly modelElementResolver: ModelElementResolver;
6+
/** Fields that have changed, but whose value is not set back onto the value store */
7+
private readonly unsyncedInputs: UnsyncedInputContainer;
8+
9+
private elementEventListeners: Array<{ event: string, callback: (event: any) => void }> = [
10+
{ event: 'input', callback: (event) => this.handleInputEvent(event) },
11+
];
12+
13+
constructor(element: HTMLElement, modelElementResolver: ModelElementResolver) {
14+
this.element = element;
15+
this.modelElementResolver = modelElementResolver;
16+
this.unsyncedInputs = new UnsyncedInputContainer();
17+
}
18+
19+
activate(): void {
20+
this.elementEventListeners.forEach(({event, callback}) => {
21+
this.element.addEventListener(event, callback);
22+
});
23+
}
24+
25+
deactivate(): void {
26+
this.elementEventListeners.forEach(({event, callback}) => {
27+
this.element.removeEventListener(event, callback);
28+
});
29+
}
30+
31+
markModelAsSynced(modelName: string): void {
32+
this.unsyncedInputs.markModelAsSynced(modelName);
33+
}
34+
35+
private handleInputEvent(event: Event) {
36+
const target = event.target as Element;
37+
if (!target) {
38+
return;
39+
}
40+
41+
this.updateModelFromElement(target)
42+
}
43+
44+
private updateModelFromElement(element: Element) {
45+
// TODO: put back this child element check
46+
// if (!elementBelongsToThisController(element, this)) {
47+
// return;
48+
// }
49+
50+
if (!(element instanceof HTMLElement)) {
51+
throw new Error('Could not update model for non HTMLElement');
52+
}
53+
54+
const modelName = this.modelElementResolver.getModelName(element);
55+
// track any inputs that are "unsynced"
56+
this.unsyncedInputs.add(element, modelName);
57+
}
58+
59+
getUnsyncedInputs(): HTMLElement[] {
60+
return this.unsyncedInputs.all();
61+
}
62+
63+
getModifiedModels(): string[] {
64+
return Array.from(this.unsyncedInputs.getModifiedModels());
65+
}
66+
}
67+
68+
/**
69+
* Tracks field & models whose values are "unsynced".
70+
*
71+
* Unsynced means that the value has been updated inside of
72+
* a field (e.g. an input), but that this new value hasn't
73+
* yet been set onto the actual model data. It is "unsynced"
74+
* from the underlying model data.
75+
*/
76+
export class UnsyncedInputContainer {
77+
#mappedFields: Map<string, HTMLElement>;
78+
#unmappedFields: Array<HTMLElement> = [];
79+
80+
constructor() {
81+
this.#mappedFields = new Map();
82+
}
83+
84+
add(element: HTMLElement, modelName: string|null = null) {
85+
if (modelName) {
86+
this.#mappedFields.set(modelName, element);
87+
88+
return;
89+
}
90+
91+
this.#unmappedFields.push(element);
92+
}
93+
94+
all(): HTMLElement[] {
95+
return [...this.#unmappedFields, ...this.#mappedFields.values()]
96+
}
97+
98+
markModelAsSynced(modelName: string): void {
99+
this.#mappedFields.delete(modelName);
100+
}
101+
102+
getModifiedModels(): string[] {
103+
return Array.from(this.#mappedFields.keys());
104+
}
105+
}
Lines changed: 11 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
1-
import { getDeepData, setDeepData } from './data_manipulation_utils';
2-
import { LiveController } from './live_controller';
3-
import { normalizeModelName } from './string_utils';
1+
import { getDeepData, setDeepData } from '../data_manipulation_utils';
2+
import { normalizeModelName } from '../string_utils';
43

54
export default class {
6-
controller: LiveController;
75
updatedModels: string[] = [];
6+
private data: any = {};
87

9-
constructor(liveController: LiveController) {
10-
this.controller = liveController;
8+
constructor(data: any) {
9+
this.data = data;
1110
}
1211

1312
/**
@@ -20,7 +19,7 @@ export default class {
2019
get(name: string): any {
2120
const normalizedName = normalizeModelName(name);
2221

23-
return getDeepData(this.controller.dataValue, normalizedName);
22+
return getDeepData(this.data, normalizedName);
2423
}
2524

2625
has(name: string): boolean {
@@ -38,7 +37,7 @@ export default class {
3837
this.updatedModels.push(normalizedName);
3938
}
4039

41-
this.controller.dataValue = setDeepData(this.controller.dataValue, normalizedName, value);
40+
this.data = setDeepData(this.data, normalizedName, value);
4241
}
4342

4443
/**
@@ -47,21 +46,14 @@ export default class {
4746
hasAtTopLevel(name: string): boolean {
4847
const parts = name.split('.');
4948

50-
return this.controller.dataValue[parts[0]] !== undefined;
51-
}
52-
53-
asJson(): string {
54-
return JSON.stringify(this.controller.dataValue);
49+
return this.data[parts[0]] !== undefined;
5550
}
5651

5752
all(): any {
58-
return this.controller.dataValue;
53+
return this.data;
5954
}
6055

61-
/**
62-
* Are any of the passed models currently "updated"?
63-
*/
64-
areAnyModelsUpdated(targetedModels: string[]): boolean {
65-
return (this.updatedModels.filter(modelName => targetedModels.includes(modelName))).length > 0;
56+
reinitialize(data: any) {
57+
this.data = data;
6658
}
6759
}

0 commit comments

Comments
 (0)