Skip to content

Commit 25fd4c8

Browse files
authored
feat: Add helpers to subsribe events for GraphComponent (#189)
* feat: Add helpers to subsribe events for GraphComponent * fix: review fixes
1 parent 4fce63f commit 25fd4c8

File tree

2 files changed

+185
-0
lines changed

2 files changed

+185
-0
lines changed
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { Graph } from "../../../graph";
2+
import { GraphEventsDefinitions } from "../../../graphEvents";
3+
import { Component } from "../../../lib/Component";
4+
import { HitBox } from "../../../services/HitTest";
5+
6+
import { GraphComponent, GraphComponentContext } from "./index";
7+
8+
class TestGraphComponent extends GraphComponent {
9+
public getEntityId(): string {
10+
return "test-id";
11+
}
12+
13+
public subscribeGraphEvent<EventName extends keyof GraphEventsDefinitions>(
14+
eventName: EventName,
15+
handler: GraphEventsDefinitions[EventName],
16+
options?: AddEventListenerOptions | boolean
17+
): () => void {
18+
return this.onGraphEvent(eventName, handler, options);
19+
}
20+
21+
public subscribeRootEvent<K extends keyof HTMLElementEventMap>(
22+
eventName: K,
23+
handler: (this: HTMLElement, ev: HTMLElementEventMap[K]) => void,
24+
options?: AddEventListenerOptions | boolean
25+
): () => void {
26+
return this.onRootEvent(eventName, handler, options);
27+
}
28+
}
29+
30+
type TestSetup = {
31+
component: TestGraphComponent;
32+
graphOn: jest.Mock<() => void, Parameters<Graph["on"]>>;
33+
graphOff: jest.Mock<void, []>;
34+
rootEl: HTMLDivElement;
35+
hitTestRemove: jest.Mock<void, [HitBox]>;
36+
};
37+
38+
function createTestComponent(root?: HTMLDivElement): TestSetup {
39+
const graphOff = jest.fn();
40+
const graphOn = jest.fn<() => void, Parameters<Graph["on"]>>().mockReturnValue(graphOff);
41+
42+
const hitTestRemove = jest.fn();
43+
const fakeGraph = {
44+
on: graphOn,
45+
hitTest: {
46+
remove: hitTestRemove,
47+
update: jest.fn(),
48+
},
49+
// The rest of Graph API is not needed for these tests
50+
};
51+
52+
const rootEl = root ?? document.createElement("div");
53+
54+
const parent = new Component({}, undefined);
55+
56+
parent.setContext({
57+
graph: fakeGraph,
58+
root: rootEl,
59+
canvas: document.createElement("canvas"),
60+
ctx: document.createElement("canvas").getContext("2d") as CanvasRenderingContext2D,
61+
ownerDocument: document,
62+
camera: {
63+
isRectVisible: () => true,
64+
},
65+
constants: {} as GraphComponentContext["constants"],
66+
colors: {} as GraphComponentContext["colors"],
67+
graphCanvas: document.createElement("canvas"),
68+
layer: {} as GraphComponentContext["layer"],
69+
affectsUsableRect: true,
70+
} as unknown as GraphComponentContext);
71+
72+
const component = new TestGraphComponent({}, parent);
73+
74+
return {
75+
component,
76+
graphOn,
77+
graphOff,
78+
rootEl,
79+
hitTestRemove,
80+
};
81+
}
82+
83+
describe("GraphComponent event helpers", () => {
84+
it("subscribes to graph events via onGraphEvent and cleans up on unmount", () => {
85+
const { component, graphOn, graphOff } = createTestComponent();
86+
87+
const handler = jest.fn();
88+
89+
component.subscribeGraphEvent("camera-change", handler);
90+
91+
expect(graphOn).toHaveBeenCalledTimes(1);
92+
expect(graphOn).toHaveBeenCalledWith("camera-change", handler, undefined);
93+
94+
Component.unmount(component);
95+
96+
expect(graphOff).toHaveBeenCalledTimes(1);
97+
});
98+
99+
it("subscribes to root DOM events via onRootEvent and cleans up on unmount", () => {
100+
const rootEl = document.createElement("div");
101+
const addSpy = jest.spyOn(rootEl, "addEventListener");
102+
const removeSpy = jest.spyOn(rootEl, "removeEventListener");
103+
104+
const { component } = createTestComponent(rootEl);
105+
106+
const handler = jest.fn((event: MouseEvent) => {
107+
// Use event to keep types happy
108+
expect(event).toBeInstanceOf(MouseEvent);
109+
});
110+
111+
component.subscribeRootEvent("click", handler);
112+
113+
expect(addSpy).toHaveBeenCalledTimes(1);
114+
const [eventName, addListener] = addSpy.mock.calls[0];
115+
expect(eventName).toBe("click");
116+
expect(typeof addListener).toBe("function");
117+
118+
const event = new MouseEvent("click");
119+
rootEl.dispatchEvent(event);
120+
expect(handler).toHaveBeenCalledTimes(1);
121+
122+
Component.unmount(component);
123+
124+
expect(removeSpy).toHaveBeenCalledTimes(1);
125+
const [removedEventName, removeListener] = removeSpy.mock.calls[0];
126+
expect(removedEventName).toBe("click");
127+
expect(typeof removeListener).toBe("function");
128+
});
129+
});

src/components/canvas/GraphComponent/index.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Signal } from "@preact/signals-core";
22

33
import { Graph } from "../../../graph";
4+
import { GraphEventsDefinitions } from "../../../graphEvents";
45
import { Component } from "../../../lib";
56
import { TComponentContext, TComponentProps, TComponentState } from "../../../lib/Component";
67
import { HitBox, HitBoxData } from "../../../services/HitTest";
@@ -164,6 +165,61 @@ export class GraphComponent<
164165
});
165166
}
166167

168+
/**
169+
* Subscribes to a graph event and automatically unsubscribes on component unmount.
170+
*
171+
* This is a convenience wrapper around this.context.graph.on that also registers the
172+
* returned unsubscribe function in the internal unsubscribe list, ensuring proper cleanup.
173+
*
174+
* @param eventName - Graph event name to subscribe to
175+
* @param handler - Event handler callback
176+
* @param options - Additional AddEventListener options
177+
* @returns Unsubscribe function
178+
*/
179+
protected onGraphEvent<EventName extends keyof GraphEventsDefinitions, Cb extends GraphEventsDefinitions[EventName]>(
180+
eventName: EventName,
181+
handler: Cb,
182+
options?: AddEventListenerOptions | boolean
183+
): () => void {
184+
const unsubscribe = this.context.graph.on(eventName, handler, options);
185+
this.unsubscribe.push(unsubscribe);
186+
return unsubscribe;
187+
}
188+
189+
/**
190+
* Subscribes to a DOM event on the graph root element and automatically unsubscribes on unmount.
191+
*
192+
* @param eventName - DOM event name to subscribe to
193+
* @param handler - Event handler callback
194+
* @param options - Additional AddEventListener options
195+
* @returns Unsubscribe function
196+
*/
197+
protected onRootEvent<K extends keyof HTMLElementEventMap>(
198+
eventName: K,
199+
handler: ((this: HTMLElement, ev: HTMLElementEventMap[K]) => void) | EventListenerObject,
200+
options?: AddEventListenerOptions | boolean
201+
): () => void {
202+
const root = this.context.root;
203+
if (!root) {
204+
throw new Error("Attempt to add event listener to non-existent root element");
205+
}
206+
207+
const listener =
208+
typeof handler === "function"
209+
? (handler as (this: HTMLElement, ev: HTMLElementEventMap[K]) => void)
210+
: (handler as EventListenerObject);
211+
212+
root.addEventListener(eventName, listener, options);
213+
214+
const unsubscribe = () => {
215+
root.removeEventListener(eventName, listener, options);
216+
};
217+
218+
this.unsubscribe.push(unsubscribe);
219+
220+
return unsubscribe;
221+
}
222+
167223
protected subscribeSignal<T>(signal: Signal<T>, cb: (v: T) => void) {
168224
this.unsubscribe.push(signal.subscribe(cb));
169225
}

0 commit comments

Comments
 (0)