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
1 change: 1 addition & 0 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
## Improvements

- Support `neo4j.EagerResult` in the `from_neo4j` integration which is the default return type by `neo4j.Driver.execute_query()`.
- Detect light/dark theme changes and adapt rendering unless theme was explicitly set. Before the theme would only be checked on the first render.


## Other changes
1,026 changes: 570 additions & 456 deletions examples/gds-example.ipynb

Large diffs are not rendered by default.

684 changes: 380 additions & 304 deletions examples/getting-started.ipynb

Large diffs are not rendered by default.

798 changes: 418 additions & 380 deletions examples/neo4j-example.ipynb

Large diffs are not rendered by default.

100 changes: 91 additions & 9 deletions js-applet/src/graph-widget.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,27 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { act } from "react";
import { afterEach, describe, expect, it } from "vitest";
import { act, type ReactNode } from "react";
import { afterEach, describe, expect, it, vi } from "vitest";

vi.mock("@neo4j-ndl/react", async () => {
const actual =
await vi.importActual<typeof import("@neo4j-ndl/react")>("@neo4j-ndl/react");

return {
...actual,
NeedleThemeProvider: ({
theme,
children,
}: {
theme: "light" | "dark";
children: ReactNode;
}) => (
<div data-testid="needle-theme-provider" data-theme={theme}>
{children}
</div>
),
};
});

import widget from "./graph-widget";

type WidgetState = {
Expand Down Expand Up @@ -48,22 +69,27 @@ type RenderedWidget = {
};

async function renderWidget(
overrides: Partial<WidgetState["options"]> = {}
overrides: Partial<WidgetState> = {}
): Promise<RenderedWidget> {
const el = document.createElement("div");
document.body.appendChild(el);

const defaultNodes = [{ id: "n1", caption: "Node 1", properties: {} }];
const defaultRelationships = [
{ id: "r1", from: "n1", to: "n1", properties: {} },
];

const model = new FakeModel({
nodes: [{ id: "n1", caption: "Node 1", properties: {} }],
relationships: [{ id: "r1", from: "n1", to: "n1", properties: {} }],
nodes: overrides.nodes ?? defaultNodes,
relationships: overrides.relationships ?? defaultRelationships,
options: {
layout: "d3Force",
showLayoutButton: true,
...overrides,
...(overrides.options ?? {}),
},
height: "400px",
width: "600px",
theme: "light",
height: overrides.height ?? "400px",
width: overrides.width ?? "600px",
theme: overrides.theme ?? "light",
});

let teardown: RenderedWidget["teardown"] = undefined;
Expand Down Expand Up @@ -174,4 +200,60 @@ describe("graph-widget button testing", () => {
}
}
});

it("updates the resolved theme when host theme classes change after mount", async () => {
document.body.className = "light-theme";

const { teardown } = await renderWidget({ theme: "auto" });

try {
await waitFor(() => {
expect(
screen.getByTestId("needle-theme-provider").getAttribute("data-theme")
).toBe("light");
});

await act(async () => {
document.body.className = "dark-theme";
});

await waitFor(() => {
expect(
screen.getByTestId("needle-theme-provider").getAttribute("data-theme")
).toBe("dark");
});
} finally {
if (typeof teardown === "function") {
await teardown();
}
}
});

it("keeps an explicit light theme fixed when host theme classes change", async () => {
document.body.className = "dark-theme";

const { teardown } = await renderWidget({ theme: "light" });

try {
await waitFor(() => {
expect(
screen.getByTestId("needle-theme-provider").getAttribute("data-theme")
).toBe("light");
});

await act(async () => {
document.body.className = "light-theme";
});

await waitFor(() => {
expect(
screen.getByTestId("needle-theme-provider").getAttribute("data-theme")
).toBe("light");
});
} finally {
if (typeof teardown === "function") {
await teardown();
}
}
});
});
42 changes: 41 additions & 1 deletion js-applet/src/graph-widget.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,46 @@ function resolveTheme(theme: Theme): "light" | "dark" {
return theme === "auto" ? detectTheme() : theme;
}

function useResolvedTheme(theme: Theme | undefined): "light" | "dark" {
const normalizedTheme = theme ?? "auto";
const [resolvedTheme, setResolvedTheme] = useState<"light" | "dark">(() =>
resolveTheme(normalizedTheme)
);

useEffect(() => {
if (normalizedTheme !== "auto") {
setResolvedTheme(normalizedTheme);
return;
}

const updateTheme = () => {
const nextTheme = detectTheme();
setResolvedTheme((currentTheme) =>
currentTheme === nextTheme ? currentTheme : nextTheme
);
};

updateTheme();

if (typeof MutationObserver === "undefined") {
return;
}

const observer = new MutationObserver(updateTheme);
const observerOptions = {
attributes: true,
attributeFilter: ["class", "style"],
} satisfies MutationObserverInit;

observer.observe(document.documentElement, observerOptions);
observer.observe(document.body, observerOptions);

return () => observer.disconnect();
}, [normalizedTheme]);

return resolvedTheme;
}

// @font-face rules in shadow DOM adopted stylesheets don't register fonts at the
// document level, so the browser can't find them for rendering. We extract and hoist
// them into document.head eagerly at module load so fonts begin loading immediately.
Expand Down Expand Up @@ -135,7 +175,7 @@ function GraphWidget() {
};

const wrapperRef = useRef<HTMLDivElement>(null);
const resolvedTheme = resolveTheme(theme ?? "auto");
const resolvedTheme = useResolvedTheme(theme);

useEffect(() => {
if (!wrapperRef.current) return;
Expand Down
132 changes: 66 additions & 66 deletions python-wrapper/src/neo4j_viz/resources/nvl_entrypoint/index.html

Large diffs are not rendered by default.

Loading
Loading