Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
129 changes: 129 additions & 0 deletions src/components/canvas/GraphComponent/GraphComponent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { Graph } from "../../../graph";
import { GraphEventsDefinitions } from "../../../graphEvents";
import { Component } from "../../../lib/Component";
import { HitBox } from "../../../services/HitTest";

import { GraphComponent, GraphComponentContext } from "./index";

class TestGraphComponent extends GraphComponent {
public getEntityId(): string {
return "test-id";
}

public subscribeGraphEvent<EventName extends keyof GraphEventsDefinitions>(
eventName: EventName,
handler: GraphEventsDefinitions[EventName],
options?: AddEventListenerOptions | boolean
): () => void {
return this.onGraphEvent(eventName, handler, options);
}

public subscribeRootEvent<K extends keyof HTMLElementEventMap>(
eventName: K,
handler: (this: HTMLElement, ev: HTMLElementEventMap[K]) => void,
options?: AddEventListenerOptions | boolean
): () => void {
return this.onRootEvent(eventName, handler, options);
}
}

type TestSetup = {
component: TestGraphComponent;
graphOn: jest.Mock<() => () => void>;
graphOff: jest.Mock<void, []>;
rootEl: HTMLDivElement;
hitTestRemove: jest.Mock<void, [HitBox]>;
};

function createTestComponent(root?: HTMLDivElement): TestSetup {
const graphOff = jest.fn();
const graphOn = jest.fn<() => () => void, Parameters<Graph["on"]>>().mockReturnValue(graphOff);

const hitTestRemove = jest.fn();
const fakeGraph = {
on: graphOn,
hitTest: {
remove: hitTestRemove,
update: jest.fn(),
},
// The rest of Graph API is not needed for these tests
};

const rootEl = root ?? document.createElement("div");

const parent = new Component({}, undefined);

parent.setContext({
graph: fakeGraph,
root: rootEl,
canvas: document.createElement("canvas"),
ctx: document.createElement("canvas").getContext("2d") as CanvasRenderingContext2D,
ownerDocument: document,
camera: {
isRectVisible: () => true,
},
constants: {} as GraphComponentContext["constants"],
colors: {} as GraphComponentContext["colors"],
graphCanvas: document.createElement("canvas"),
layer: {} as GraphComponentContext["layer"],
affectsUsableRect: true,
} as unknown as GraphComponentContext);

const component = new TestGraphComponent({}, parent);

return {
component,
graphOn,
graphOff,
rootEl,
hitTestRemove,
};
}

describe("GraphComponent event helpers", () => {
it("subscribes to graph events via onGraphEvent and cleans up on unmount", () => {
const { component, graphOn, graphOff } = createTestComponent();

const handler = jest.fn();

component.subscribeGraphEvent("camera-change", handler);

expect(graphOn).toHaveBeenCalledTimes(1);
expect(graphOn).toHaveBeenCalledWith("camera-change", handler, undefined);

Component.unmount(component);

expect(graphOff).toHaveBeenCalledTimes(1);
});

it("subscribes to root DOM events via onRootEvent and cleans up on unmount", () => {
const rootEl = document.createElement("div");
const addSpy = jest.spyOn(rootEl, "addEventListener");
const removeSpy = jest.spyOn(rootEl, "removeEventListener");

const { component } = createTestComponent(rootEl);

const handler = jest.fn((event: MouseEvent) => {
// Use event to keep types happy
expect(event).toBeInstanceOf(MouseEvent);
});

component.subscribeRootEvent("click", handler);

expect(addSpy).toHaveBeenCalledTimes(1);
const [eventName, addListener] = addSpy.mock.calls[0];
expect(eventName).toBe("click");
expect(typeof addListener).toBe("function");

const event = new MouseEvent("click");
rootEl.dispatchEvent(event);
expect(handler).toHaveBeenCalledTimes(1);

Component.unmount(component);

expect(removeSpy).toHaveBeenCalledTimes(1);
const [removedEventName, removeListener] = removeSpy.mock.calls[0];
expect(removedEventName).toBe("click");
expect(typeof removeListener).toBe("function");
});
});
56 changes: 56 additions & 0 deletions src/components/canvas/GraphComponent/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { Signal } from "@preact/signals-core";

import { Graph } from "../../../graph";
import { GraphEventsDefinitions } from "../../../graphEvents";
import { Component } from "../../../lib";
import { TComponentContext, TComponentProps, TComponentState } from "../../../lib/Component";
import { HitBox, HitBoxData } from "../../../services/HitTest";
Expand Down Expand Up @@ -164,6 +165,61 @@ export class GraphComponent<
});
}

/**
* Subscribes to a graph event and automatically unsubscribes on component unmount.
*
* This is a convenience wrapper around this.context.graph.on that also registers the
* returned unsubscribe function in the internal unsubscribe list, ensuring proper cleanup.
*
* @param eventName - Graph event name to subscribe to
* @param handler - Event handler callback
* @param options - Additional AddEventListener options
* @returns Unsubscribe function
*/
protected onGraphEvent<EventName extends keyof GraphEventsDefinitions, Cb extends GraphEventsDefinitions[EventName]>(
eventName: EventName,
handler: Cb,
options?: AddEventListenerOptions | boolean
): () => void {
const unsubscribe = this.context.graph.on(eventName, handler, options);
this.unsubscribe.push(unsubscribe);
return unsubscribe;
}

/**
* Subscribes to a DOM event on the graph root element and automatically unsubscribes on unmount.
*
* @param eventName - DOM event name to subscribe to
* @param handler - Event handler callback
* @param options - Additional AddEventListener options
* @returns Unsubscribe function
*/
protected onRootEvent<K extends keyof HTMLElementEventMap>(
eventName: K,
handler: ((this: HTMLElement, ev: HTMLElementEventMap[K]) => void) | EventListenerObject,
options?: AddEventListenerOptions | boolean
): () => void {
const root = this.context.root;
if (!root) {
throw new Error("Attempt to add event listener to non-existent root element");
}

const listener =
typeof handler === "function"
? (handler as (this: HTMLElement, ev: HTMLElementEventMap[K]) => void)
: (handler as EventListenerObject);

root.addEventListener(eventName, listener, options);

const unsubscribe = () => {
root.removeEventListener(eventName, listener, options);
};

this.unsubscribe.push(unsubscribe);

return unsubscribe;
}

protected subscribeSignal<T>(signal: Signal<T>, cb: (v: T) => void) {
this.unsubscribe.push(signal.subscribe(cb));
}
Expand Down
Loading