Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
35 changes: 7 additions & 28 deletions js-applet/src/graph-widget.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ vi.mock("@neo4j-ndl/react", async () => {
});

import widget from "./graph-widget";
import { createLocalModel } from "./local-model";

type WidgetState = {
nodes: Array<{ id: string; caption?: string; properties: Record<string, unknown> }>;
Expand All @@ -37,32 +38,10 @@ type WidgetState = {
selected: { nodeIds: string[]; relationshipIds: string[] };
};

class FakeModel {
private readonly listeners = new Map<string, Set<() => void>>();

constructor(private readonly state: WidgetState) {}

get<K extends keyof WidgetState>(key: K): WidgetState[K] {
return this.state[key];
}

set<K extends keyof WidgetState>(key: K, value: WidgetState[K]): void {
this.state[key] = value;
this.listeners.get(`change:${String(key)}`)?.forEach((listener) => listener());
}

on(event: string, listener: () => void): void {
const listeners = this.listeners.get(event) ?? new Set<() => void>();
listeners.add(listener);
this.listeners.set(event, listeners);
}

off(event: string, listener: () => void): void {
this.listeners.get(event)?.delete(listener);
}

save_changes(): void {}
}
// The static HTML render path uses the real `createLocalModel` shim, so tests
// exercise it directly rather than a hand-rolled fake — this keeps the shim's
// contract (notably `set` emitting change events, see GDS-286) under test.
type FakeModel = ReturnType<typeof createLocalModel<WidgetState>>;

type RenderedWidget = {
el: HTMLDivElement;
Expand All @@ -81,7 +60,7 @@ async function renderWidget(
{ id: "r1", from: "n1", to: "n1", properties: {} },
];

const model = new FakeModel({
const model = createLocalModel<WidgetState>({
nodes: overrides.nodes ?? defaultNodes,
relationships: overrides.relationships ?? defaultRelationships,
options: {
Expand Down Expand Up @@ -116,7 +95,7 @@ async function renderWidgetInShadowRoot(
const el = document.createElement("div");
shadowRoot.appendChild(el);

const model = new FakeModel({
const model = createLocalModel<WidgetState>({
nodes: [{ id: "n1", caption: "Node 1", properties: {} }],
relationships: [{ id: "r1", from: "n1", to: "n1", properties: {} }],
options: {
Expand Down
69 changes: 69 additions & 0 deletions js-applet/src/local-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { describe, expect, it, vi } from "vitest";
import { createLocalModel } from "./local-model";

type State = {
selected: { nodeIds: string[]; relationshipIds: string[] };
theme: "light" | "dark";
};

const initial = (): Partial<State> => ({
selected: { nodeIds: [], relationshipIds: [] },
theme: "light",
});

describe("createLocalModel", () => {
it("returns the current value from get", () => {
const model = createLocalModel<State>(initial());
expect(model.get("theme")).toBe("light");
expect(model.get("selected")).toEqual({ nodeIds: [], relationshipIds: [] });
});

// Regression guard for GDS-286: a no-op `set` froze controlled props such as
// `selected`, so clicking a node in the static HTML could never select it.
it("updates state on set and notifies change listeners", () => {
const model = createLocalModel<State>(initial());
const listener = vi.fn();
model.on("change:selected", listener);

const next = { nodeIds: ["n1"], relationshipIds: [] };
model.set("selected", next);

expect(model.get("selected")).toBe(next);
expect(listener).toHaveBeenCalledTimes(1);
});

it("only notifies listeners for the changed key", () => {
const model = createLocalModel<State>(initial());
const selectedListener = vi.fn();
const themeListener = vi.fn();
model.on("change:selected", selectedListener);
model.on("change:theme", themeListener);

model.set("theme", "dark");

expect(themeListener).toHaveBeenCalledTimes(1);
expect(selectedListener).not.toHaveBeenCalled();
});

it("stops notifying a listener after off", () => {
const model = createLocalModel<State>(initial());
const listener = vi.fn();
model.on("change:selected", listener);
model.off("change:selected", listener);

model.set("selected", { nodeIds: ["n1"], relationshipIds: [] });

expect(listener).not.toHaveBeenCalled();
});

it("returns a stable reference from get until the next set (useSyncExternalStore contract)", () => {
const model = createLocalModel<State>(initial());
const first = model.get("selected");
expect(model.get("selected")).toBe(first);

const next = { nodeIds: ["n1"], relationshipIds: [] };
model.set("selected", next);
expect(model.get("selected")).toBe(next);
expect(model.get("selected")).toBe(next);
});
});
40 changes: 40 additions & 0 deletions js-applet/src/local-model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
/**
* The subset of the anywidget model API the React bindings rely on.
*/
export type LocalModel<T extends Record<string, unknown>> = {
get<K extends keyof T>(key: K): T[K];
set<K extends keyof T>(key: K, value: T[K]): void;
on(event: string, listener: () => void): void;
off(event: string, listener: () => void): void;
save_changes(): void;
};

/**
* A minimal, kernel-less model for the static HTML render path.
* `set` must still update the in-memory data and notify listeners.
* State is kept local to the page; nothing is synced back to Python.
*/
export function createLocalModel<T extends Record<string, unknown>>(
data: Partial<T>
): LocalModel<T> {
const listeners = new Map<string, Set<() => void>>();

return {
get<K extends keyof T>(key: K): T[K] {
return data[key] as T[K];
},
set<K extends keyof T>(key: K, value: T[K]): void {
data[key] = value;
listeners.get(`change:${String(key)}`)?.forEach((listener) => listener());
},
on(event: string, listener: () => void): void {
const eventListeners = listeners.get(event) ?? new Set<() => void>();
eventListeners.add(listener);
listeners.set(event, eventListeners);
},
off(event: string, listener: () => void): void {
listeners.get(event)?.delete(listener);
},
save_changes() {},
};
}
22 changes: 5 additions & 17 deletions js-applet/src/standalone-entrypoint.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { AnyModel } from "@anywidget/types";
import widget, { type WidgetData } from "./graph-widget";
import { createLocalModel } from "./local-model";

/**
* Standalone entrypoint for static HTML rendering (non-Jupyter).
Expand All @@ -25,22 +25,10 @@ if (!data) {
throw new Error("window.__NEO4J_VIZ_DATA__ is not defined");
}

/**
* Read-only model shim for static HTML rendering.
* Mutations (set/save_changes) are no-ops since there's no kernel to sync with.
*/
const model: Pick<
AnyModel<WidgetData>,
"get" | "on" | "off" | "set" | "save_changes"
> = {
get<K extends keyof WidgetData>(key: K): WidgetData[K] {
return data[key] as WidgetData[K];
},
on() {},
off() {},
set() {},
save_changes() {},
};
// Kernel-less model for the static HTML page: `set` updates local state and
// notifies listeners so controlled props (e.g. `selected`) stay interactive;
// nothing is synced back to Python. See createLocalModel for details.
const model = createLocalModel<WidgetData>(data);

const el = document.getElementById("neo4j-viz-container");
if (!el) {
Expand Down
Loading
Loading