Skip to content

Commit 2a22483

Browse files
committed
initial component child tracking/setup
and removing originalData - new child logic will not need this
1 parent 32d4ff3 commit 2a22483

File tree

9 files changed

+183
-244
lines changed

9 files changed

+183
-244
lines changed

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

Lines changed: 43 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ declare const Turbo: any;
1616
export default class Component {
1717
readonly element: HTMLElement;
1818
private readonly backend: BackendInterface;
19+
id: string|null;
1920

2021
readonly valueStore: ValueStore;
2122
private readonly unsyncedInputsTracker: UnsyncedInputsTracker;
@@ -24,7 +25,6 @@ export default class Component {
2425

2526
defaultDebounce = 150;
2627

27-
private originalData = {};
2828
private backendRequest: BackendRequest|null;
2929
/** Actions that are waiting to be executed */
3030
private pendingActions: BackendAction[] = [];
@@ -33,23 +33,25 @@ export default class Component {
3333
/** Current "timeout" before the pending request should be sent. */
3434
private requestDebounceTimeout: number | null = null;
3535

36+
private children: Map<string, Component> = new Map();
37+
private parent: Component|null = null;
38+
3639
/**
3740
* @param element The root element
3841
* @param data Component data
42+
* @param id Some unique id to identify this component. Needed to be a child component
3943
* @param backend Backend instance for updating
4044
* @param modelElementResolver Class to get "model" name from any element.
4145
*/
42-
constructor(element: HTMLElement, data: any, backend: BackendInterface, modelElementResolver: ModelElementResolver) {
46+
constructor(element: HTMLElement, data: any, id: string|null, backend: BackendInterface, modelElementResolver: ModelElementResolver) {
4347
this.element = element;
4448
this.backend = backend;
49+
this.id = id;
4550

4651
this.valueStore = new ValueStore(data);
4752
this.unsyncedInputsTracker = new UnsyncedInputsTracker(element, modelElementResolver);
4853
this.hooks = new HookManager();
4954
this.pollingDirector = new PollingDirectory(this);
50-
51-
// deep clone the data
52-
this.snapshotOriginalData();
5355
}
5456

5557
connect(): void {
@@ -138,6 +140,32 @@ export default class Component {
138140
this.pollingDirector.clearPolling();
139141
}
140142

143+
addChild(component: Component): void {
144+
if (!component.id) {
145+
throw new Error('Children components must have an id.');
146+
}
147+
148+
this.children.set(component.id, component);
149+
component.parent = this;
150+
}
151+
152+
removeChild(child: Component): void {
153+
if (!child.id) {
154+
throw new Error('Children components must have an id.');
155+
}
156+
157+
this.children.delete(child.id);
158+
child.parent = null;
159+
}
160+
161+
getParent(): Component|null {
162+
return this.parent;
163+
}
164+
165+
getChildren(): Map<string, Component> {
166+
return new Map(this.children);
167+
}
168+
141169
private tryStartingRequest(): void {
142170
if (!this.backendRequest) {
143171
this.performRequest()
@@ -236,16 +264,10 @@ export default class Component {
236264
newElement,
237265
this.unsyncedInputsTracker.getUnsyncedInputs(),
238266
(element: HTMLElement) => getValueFromElement(element, this.valueStore),
239-
this.originalData,
240-
this.valueStore.all(),
241-
newDataFromServer
242267
);
243268
// TODO: could possibly do this by listening to the dataValue value change
244269
this.valueStore.reinitialize(newDataFromServer);
245270

246-
// take a new snapshot of the "original data"
247-
this.snapshotOriginalData();
248-
249271
// reset the modified values back to their client-side version
250272
Object.keys(modifiedModelValues).forEach((modelName) => {
251273
this.valueStore.set(modelName, modifiedModelValues[modelName]);
@@ -262,10 +284,6 @@ export default class Component {
262284
}));
263285
}
264286

265-
private snapshotOriginalData() {
266-
this.originalData = JSON.parse(JSON.stringify(this.valueStore.all()));
267-
}
268-
269287
private caculateDebounce(debounce: number|boolean): number {
270288
if (debounce === true) {
271289
return this.defaultDebounce;
@@ -343,9 +361,16 @@ export default class Component {
343361
}
344362
}
345363

346-
export function createComponent(element: HTMLElement, data: any, backend: BackendInterface, modelElementResolver: ModelElementResolver): Component {
347-
const component = new Component(element, data, backend, modelElementResolver);
348-
364+
/**
365+
* Makes the Component feel more like a JS-version of the PHP component:
366+
*
367+
* // set model like properties
368+
* component.firstName = 'Ryan';
369+
*
370+
* // call a live action called "saveStatus" with a "status" arg
371+
* component.saveStatus({ status: 'published' });
372+
*/
373+
export function proxifyComponent(component: Component): Component {
349374
return new Proxy(component, {
350375
get(component: Component, prop: string|symbol): any {
351376
// string check is to handle symbols

src/LiveComponent/assets/src/dom_utils.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,8 @@ export function getModelDirectiveFromElement(element: HTMLElement, throwOnMissin
159159
* B) NOT also live inside a child "live controller" element
160160
*/
161161
export function elementBelongsToThisController(element: Element, controller: LiveController): boolean {
162+
// TODO fix this
163+
return true;
162164
if (controller.element !== element && !controller.element.contains(element)) {
163165
return false;
164166
}

src/LiveComponent/assets/src/have_rendered_values_changed.ts

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

src/LiveComponent/assets/src/live_controller.ts

Lines changed: 58 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -8,60 +8,77 @@ import {
88
getValueFromElement,
99
elementBelongsToThisController,
1010
} from './dom_utils';
11-
import Component, {createComponent} from "./Component";
11+
import Component, {proxifyComponent} from "./Component";
1212
import Backend from "./Backend";
13-
import {DataModelElementResolver} from "./Component/ModelElementResolver";
13+
import {
14+
DataModelElementResolver,
15+
} from "./Component/ModelElementResolver";
1416
import LoadingHelper from "./LoadingHelper";
1517

1618
interface UpdateModelOptions {
1719
dispatch?: boolean;
1820
debounce?: number|boolean;
1921
}
2022

21-
export interface LiveController {
22-
dataValue: any;
23-
element: Element,
24-
childComponentControllers: Array<LiveController>
23+
export interface LiveEvent extends CustomEvent {
24+
detail: {
25+
controller: LiveController,
26+
component: Component
27+
},
2528
}
2629

27-
export default class extends Controller implements LiveController {
30+
export default class LiveController extends Controller {
2831
static values = {
2932
url: String,
3033
data: Object,
3134
csrf: String,
3235
debounce: { type: Number, default: 150 },
36+
id: String,
3337
}
3438

3539
readonly urlValue!: string;
3640
dataValue!: any;
3741
readonly csrfValue!: string;
3842
readonly hasDebounceValue: boolean;
3943
readonly debounceValue: number;
44+
readonly idValue: string;
45+
46+
/** The component, wrapped in the convenience Proxy */
47+
private proxiedComponent: Component;
48+
/** The raw Component object */
49+
private component: Component;
4050

41-
component: Component;
4251
isConnected = false;
43-
childComponentControllers: Array<LiveController> = [];
4452
pendingActionTriggerModelElement: HTMLElement|null = null;
4553

4654
private elementEventListeners: Array<{ event: string, callback: (event: any) => void }> = [
47-
{ event: 'input', callback: (event) => this.handleInputEvent(event) },
48-
{ event: 'change', callback: (event) => this.handleChangeEvent(event) },
55+
{ event: 'input', callback: (event) => this.handleInputEvent(event) },
56+
{ event: 'change', callback: (event) => this.handleChangeEvent(event) },
57+
{ event: 'live:connect', callback: (event) => this.handleConnectedControllerEvent(event) },
4958
];
5059

5160
initialize() {
52-
this.handleConnectedControllerEvent = this.handleConnectedControllerEvent.bind(this);
53-
this.handleDisconnectedControllerEvent = this.handleDisconnectedControllerEvent.bind(this);
61+
this.handleDisconnectedChildControllerEvent = this.handleDisconnectedChildControllerEvent.bind(this);
5462

5563
if (!(this.element instanceof HTMLElement)) {
5664
throw new Error('Invalid Element Type');
5765
}
5866

59-
this.component = createComponent(
67+
68+
const id = this.idValue || null;
69+
70+
this.component = new Component(
6071
this.element,
6172
this.dataValue,
73+
id,
6274
new Backend(this.urlValue, this.csrfValue),
6375
new DataModelElementResolver(),
6476
);
77+
this.proxiedComponent = proxifyComponent(this.component);
78+
79+
// @ts-ignore Adding the dynamic property
80+
this.element.__component = this.proxiedComponent;
81+
6582
if (this.hasDebounceValue) {
6683
this.component.defaultDebounce = this.debounceValue;
6784
}
@@ -100,9 +117,7 @@ export default class extends Controller implements LiveController {
100117
throw new Error('Invalid Element Type');
101118
}
102119

103-
this.element.addEventListener('live:connect', this.handleConnectedControllerEvent);
104-
105-
this._dispatchEvent('live:connect', { controller: this });
120+
this._dispatchEvent('live:connect');
106121
}
107122

108123
disconnect() {
@@ -112,11 +127,8 @@ export default class extends Controller implements LiveController {
112127
this.component.element.removeEventListener(event, callback);
113128
});
114129

115-
this.element.removeEventListener('live:connect', this.handleConnectedControllerEvent);
116-
this.element.removeEventListener('live:disconnect', this.handleDisconnectedControllerEvent);
117-
118-
this._dispatchEvent('live:disconnect', { controller: this });
119130
this.isConnected = false;
131+
this._dispatchEvent('live:disconnect');
120132
}
121133

122134
/**
@@ -334,37 +346,49 @@ export default class extends Controller implements LiveController {
334346
this.component.set(modelDirective.action, finalValue, shouldRender, debounce);
335347
}
336348

337-
handleConnectedControllerEvent(event: any) {
349+
handleConnectedControllerEvent(event: LiveEvent) {
338350
if (event.target === this.element) {
339351
return;
340352
}
341353

342-
this.childComponentControllers.push(event.detail.controller);
354+
const childController = event.detail.controller;
355+
if (childController.component.getParent()) {
356+
// child already has a parent - we are a grandparent
357+
return;
358+
}
359+
360+
this.component.addChild(childController.component);
361+
343362
// live:disconnect needs to be registered on the child element directly
344363
// that's because if the child component is removed from the DOM, then
345364
// the parent controller is no longer an ancestor, so the live:disconnect
346365
// event would not bubble up to it.
347-
event.detail.controller.element.addEventListener('live:disconnect', this.handleDisconnectedControllerEvent);
366+
// @ts-ignore TS doesn't like the LiveEvent arg in the listener, not sure how to fix
367+
childController.element.addEventListener('live:disconnect', this.handleDisconnectedChildControllerEvent);
348368
}
349369

350-
handleDisconnectedControllerEvent(event: any) {
351-
if (event.target === this.element) {
352-
return;
353-
}
370+
handleDisconnectedChildControllerEvent(event: LiveEvent): void {
371+
const childController = event.detail.controller;
354372

355-
const index = this.childComponentControllers.indexOf(event.detail.controller);
373+
// @ts-ignore TS doesn't like the LiveEvent arg in the listener, not sure how to fix
374+
childController.element.removeEventListener('live:disconnect', this.handleDisconnectedChildControllerEvent);
356375

357-
// Remove value from an array
358-
if (index > -1) {
359-
this.childComponentControllers.splice(index, 1);
376+
// this shouldn't happen: but double-check we're the parent
377+
if (childController.component.getParent() !== this.component) {
378+
return;
360379
}
380+
381+
this.component.removeChild(childController.component);
361382
}
362383

363-
_dispatchEvent(name: string, payload: any = null, canBubble = true, cancelable = false) {
384+
_dispatchEvent(name: string, detail: any = {}, canBubble = true, cancelable = false) {
385+
detail.controller = this;
386+
detail.component = this.proxiedComponent;
387+
364388
return this.element.dispatchEvent(new CustomEvent(name, {
365389
bubbles: canBubble,
366390
cancelable,
367-
detail: payload
391+
detail
368392
}));
369393
}
370394

0 commit comments

Comments
 (0)