Skip to content

Commit b1ac91c

Browse files
committed
client-side implementation of differentiating between data & props
This will be needed later for child component handling, where the server will send down a list of updated "props" only, and the client side will need to update the props of a component, without changing the "data" that may have already been altered by the user.
1 parent 2a22483 commit b1ac91c

File tree

10 files changed

+132
-94
lines changed

10 files changed

+132
-94
lines changed

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,8 @@
4949
"rules": {
5050
"@typescript-eslint/no-explicit-any": "off",
5151
"@typescript-eslint/no-empty-function": "off",
52-
"@typescript-eslint/ban-ts-comment": "off"
52+
"@typescript-eslint/ban-ts-comment": "off",
53+
"quotes": ["error", "single"]
5354
},
5455
"env": {
5556
"browser": true

src/LiveComponent/assets/src/Component/UnsyncedInputsTracker.ts

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1-
import {ModelElementResolver} from "./ModelElementResolver";
1+
import {ModelElementResolver} from './ModelElementResolver';
2+
import {elementBelongsToThisComponent} from '../dom_utils';
3+
import Component from './index';
24

35
export default class {
4-
private readonly element: HTMLElement;
6+
private readonly component: Component;
57
private readonly modelElementResolver: ModelElementResolver;
68
/** Fields that have changed, but whose value is not set back onto the value store */
79
private readonly unsyncedInputs: UnsyncedInputContainer;
@@ -10,21 +12,21 @@ export default class {
1012
{ event: 'input', callback: (event) => this.handleInputEvent(event) },
1113
];
1214

13-
constructor(element: HTMLElement, modelElementResolver: ModelElementResolver) {
14-
this.element = element;
15+
constructor(component: Component, modelElementResolver: ModelElementResolver) {
16+
this.component = component;
1517
this.modelElementResolver = modelElementResolver;
1618
this.unsyncedInputs = new UnsyncedInputContainer();
1719
}
1820

1921
activate(): void {
2022
this.elementEventListeners.forEach(({event, callback}) => {
21-
this.element.addEventListener(event, callback);
23+
this.component.element.addEventListener(event, callback);
2224
});
2325
}
2426

2527
deactivate(): void {
2628
this.elementEventListeners.forEach(({event, callback}) => {
27-
this.element.removeEventListener(event, callback);
29+
this.component.element.removeEventListener(event, callback);
2830
});
2931
}
3032

@@ -42,10 +44,9 @@ export default class {
4244
}
4345

4446
private updateModelFromElement(element: Element) {
45-
// TODO: put back this child element check
46-
// if (!elementBelongsToThisController(element, this)) {
47-
// return;
48-
// }
47+
if (!elementBelongsToThisComponent(element, this.component)) {
48+
return;
49+
}
4950

5051
if (!(element instanceof HTMLElement)) {
5152
throw new Error('Could not update model for non HTMLElement');

src/LiveComponent/assets/src/Component/ValueStore.ts

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,16 @@ import { normalizeModelName } from '../string_utils';
33

44
export default class {
55
updatedModels: string[] = [];
6+
private props: any = {};
67
private data: any = {};
78

8-
constructor(data: any) {
9+
constructor(props: any, data: any) {
10+
this.props = props;
911
this.data = data;
1012
}
1113

1214
/**
13-
* Returns the data with the given name.
15+
* Returns the data or props with the given name.
1416
*
1517
* This allows for non-normalized model names - e.g.
1618
* user[firstName] -> user.firstName and also will fetch
@@ -19,7 +21,13 @@ export default class {
1921
get(name: string): any {
2022
const normalizedName = normalizeModelName(name);
2123

22-
return getDeepData(this.data, normalizedName);
24+
const result = getDeepData(this.data, normalizedName);
25+
26+
if (result !== undefined) {
27+
return result;
28+
}
29+
30+
return getDeepData(this.props, normalizedName);
2331
}
2432

2533
has(name: string): boolean {
@@ -40,20 +48,11 @@ export default class {
4048
this.data = setDeepData(this.data, normalizedName, value);
4149
}
4250

43-
/**
44-
* Checks if the given name/propertyPath is for a valid top-level key.
45-
*/
46-
hasAtTopLevel(name: string): boolean {
47-
const parts = name.split('.');
48-
49-
return this.data[parts[0]] !== undefined;
50-
}
51-
5251
all(): any {
53-
return this.data;
52+
return { ...this.props, ...this.data };
5453
}
5554

56-
reinitialize(data: any) {
55+
reinitializeData(data: any) {
5756
this.data = data;
5857
}
5958
}

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

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -38,18 +38,19 @@ export default class Component {
3838

3939
/**
4040
* @param element The root element
41-
* @param data Component data
41+
* @param props Readonly component props
42+
* @param data Modifiable component data/state
4243
* @param id Some unique id to identify this component. Needed to be a child component
4344
* @param backend Backend instance for updating
4445
* @param modelElementResolver Class to get "model" name from any element.
4546
*/
46-
constructor(element: HTMLElement, data: any, id: string|null, backend: BackendInterface, modelElementResolver: ModelElementResolver) {
47+
constructor(element: HTMLElement, props: any, data: any, id: string|null, backend: BackendInterface, modelElementResolver: ModelElementResolver) {
4748
this.element = element;
4849
this.backend = backend;
4950
this.id = id;
5051

51-
this.valueStore = new ValueStore(data);
52-
this.unsyncedInputsTracker = new UnsyncedInputsTracker(element, modelElementResolver);
52+
this.valueStore = new ValueStore(props, data);
53+
this.unsyncedInputsTracker = new UnsyncedInputsTracker(this, modelElementResolver);
5354
this.hooks = new HookManager();
5455
this.pollingDirector = new PollingDirectory(this);
5556
}
@@ -104,7 +105,7 @@ export default class Component {
104105
this.debouncedStartRequest(debounce);
105106
}
106107

107-
get(model: string): any {
108+
getData(model: string): any {
108109
const modelName = normalizeModelName(model);
109110
if (!this.valueStore.has(modelName)) {
110111
throw new Error(`Invalid model "${model}".`);
@@ -266,7 +267,7 @@ export default class Component {
266267
(element: HTMLElement) => getValueFromElement(element, this.valueStore),
267268
);
268269
// TODO: could possibly do this by listening to the dataValue value change
269-
this.valueStore.reinitialize(newDataFromServer);
270+
this.valueStore.reinitializeData(newDataFromServer);
270271

271272
// reset the modified values back to their client-side version
272273
Object.keys(modifiedModelValues).forEach((modelName) => {
@@ -388,7 +389,7 @@ export function proxifyComponent(component: Component): Component {
388389

389390
// return model
390391
if (component.valueStore.has(prop)) {
391-
return component.get(prop)
392+
return component.getData(prop)
392393
}
393394

394395
// try to call an action

src/LiveComponent/assets/src/data_manipulation_utils.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
export function getDeepData(data: any, propertyPath: string) {
22
const { currentLevelData, finalKey } = parseDeepData(data, propertyPath);
33

4+
if (currentLevelData === undefined) {
5+
return undefined;
6+
}
7+
48
return currentLevelData[finalKey];
59
}
610

src/LiveComponent/assets/src/dom_utils.ts

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import ValueStore from './Component/ValueStore';
22
import { Directive, parseDirectives } from './directives_parser';
3-
import { LiveController } from './live_controller';
43
import { normalizeModelName } from './string_utils';
4+
import Component from "./Component";
55

66
/**
77
* Return the "value" of any given element.
@@ -152,32 +152,34 @@ export function getModelDirectiveFromElement(element: HTMLElement, throwOnMissin
152152
}
153153

154154
/**
155-
* Does the given element "belong" to the given live controller.
155+
* Does the given element "belong" to the given component.
156156
*
157157
* To "belong" the element needs to:
158-
* A) Live inside the controller element (of course)
159-
* B) NOT also live inside a child "live controller" element
158+
* A) Live inside the component element (of course)
159+
* B) NOT also live inside a child component
160160
*/
161-
export function elementBelongsToThisController(element: Element, controller: LiveController): boolean {
162-
// TODO fix this
163-
return true;
164-
if (controller.element !== element && !controller.element.contains(element)) {
161+
export function elementBelongsToThisComponent(element: Element, component: Component): boolean {
162+
if (component.element === element) {
163+
return true;
164+
}
165+
166+
if (!component.element.contains(element)) {
165167
return false;
166168
}
167169

168-
let foundChildController = false;
169-
controller.childComponentControllers.forEach((childComponentController) => {
170-
if (foundChildController) {
170+
let foundChildComponent = false;
171+
component.getChildren().forEach((childComponent) => {
172+
if (foundChildComponent) {
171173
// return early
172174
return;
173175
}
174176

175-
if (childComponentController.element === element || childComponentController.element.contains(element)) {
176-
foundChildController = true;
177+
if (childComponent.element === element || childComponent.element.contains(element)) {
178+
foundChildComponent = true;
177179
}
178180
});
179181

180-
return !foundChildController;
182+
return !foundChildComponent;
181183
}
182184

183185
export function cloneHTMLElement(element: HTMLElement): HTMLElement {

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import {
66
getElementAsTagText,
77
setValueOnElement,
88
getValueFromElement,
9-
elementBelongsToThisController,
9+
elementBelongsToThisComponent,
1010
} from './dom_utils';
1111
import Component, {proxifyComponent} from "./Component";
1212
import Backend from "./Backend";
@@ -27,17 +27,24 @@ export interface LiveEvent extends CustomEvent {
2727
},
2828
}
2929

30-
export default class LiveController extends Controller {
30+
export interface LiveController {
31+
element: HTMLElement,
32+
component: Component
33+
}
34+
35+
export default class extends Controller<HTMLElement> implements LiveController {
3136
static values = {
3237
url: String,
3338
data: Object,
39+
props: Object,
3440
csrf: String,
3541
debounce: { type: Number, default: 150 },
3642
id: String,
3743
}
3844

3945
readonly urlValue!: string;
40-
dataValue!: any;
46+
readonly dataValue!: any;
47+
readonly propsValue!: any;
4148
readonly csrfValue!: string;
4249
readonly hasDebounceValue: boolean;
4350
readonly debounceValue: number;
@@ -46,7 +53,7 @@ export default class LiveController extends Controller {
4653
/** The component, wrapped in the convenience Proxy */
4754
private proxiedComponent: Component;
4855
/** The raw Component object */
49-
private component: Component;
56+
component: Component;
5057

5158
isConnected = false;
5259
pendingActionTriggerModelElement: HTMLElement|null = null;
@@ -60,15 +67,11 @@ export default class LiveController extends Controller {
6067
initialize() {
6168
this.handleDisconnectedChildControllerEvent = this.handleDisconnectedChildControllerEvent.bind(this);
6269

63-
if (!(this.element instanceof HTMLElement)) {
64-
throw new Error('Invalid Element Type');
65-
}
66-
67-
6870
const id = this.idValue || null;
6971

7072
this.component = new Component(
7173
this.element,
74+
this.propsValue,
7275
this.dataValue,
7376
id,
7477
new Backend(this.urlValue, this.csrfValue),
@@ -272,7 +275,7 @@ export default class LiveController extends Controller {
272275
* If not passed, the model will always be updated.
273276
*/
274277
private updateModelFromElementEvent(element: Element, eventName: string|null) {
275-
if (!elementBelongsToThisController(element, this)) {
278+
if (!elementBelongsToThisComponent(element, this.component)) {
276279
return;
277280
}
278281

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ describe('Component class', () => {
2929

3030
const component = new Component(
3131
document.createElement('div'),
32+
{},
3233
{firstName: ''},
3334
null,
3435
backend,
@@ -68,7 +69,7 @@ describe('Component class', () => {
6869
const { proxy } = makeDummyComponent();
6970
// @ts-ignore
7071
proxy.firstName = 'Ryan';
71-
expect(proxy.get('firstName')).toBe('Ryan');
72+
expect(proxy.getData('firstName')).toBe('Ryan');
7273
});
7374

7475
it('calls an action on a component', async () => {

0 commit comments

Comments
 (0)