diff --git a/CONTEXT.md b/CONTEXT.md index 9d42611ef5..baf1050742 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -73,6 +73,10 @@ _Avoid_: Criterion (implies a configurable operator, which the product does not The set of vertices and edges a user has loaded through exploration for a given Connection. Persisted to IndexedDB so users can close the browser and restore where they left off. _Avoid_: State, workspace +**Graph Arrangement**: +The reproducible visual state of a Session in the Graph View: the selected layout, exact vertex positions, and viewport pan and zoom. Captured after layouts and user interaction, persisted with the Session, and restored without rerunning a complete layout. See `docs/adr/20260903-persist-exact-graph-arrangements.md`. +_Avoid_: Layout (only the algorithm), Graph state (too broad) + **Graph View**: The interactive canvas where vertices and edges are visualized using Cytoscape.js. Users explore the graph here by expanding neighbors and applying layouts. Nav label: "Graph". _Avoid_: Graph Explorer (ambiguous with the product name) @@ -145,7 +149,7 @@ _Avoid_: Save-status indicator - A **Vertex** has one or more **Vertex Types** and zero or more **Properties** - An **Edge** connects exactly two **Vertices** (source → target), has one **Edge Type**, and zero or more **Properties** - An **Edge Connection** links a source **Vertex Type** to a target **Vertex Type** via an **Edge Type** -- A **Session** belongs to a **Connection** and contains **Vertices** and **Edges** +- A **Session** belongs to a **Connection** and contains **Vertices**, **Edges**, and its **Graph Arrangement** - **Neighbors** are **Vertices** one hop away from a given **Vertex** - **Styles** are scoped per **Vertex Type** (**Vertex Styles**) and **Edge Type** (**Edge Styles**) - The **Graph View**, **Data Table View**, and **Schema View** all render from the same **Session** and **Schema** diff --git a/docs/adr/20260903-persist-exact-graph-arrangements.md b/docs/adr/20260903-persist-exact-graph-arrangements.md new file mode 100644 index 0000000000..c174e40705 --- /dev/null +++ b/docs/adr/20260903-persist-exact-graph-arrangements.md @@ -0,0 +1,37 @@ +# ADR — Persist exact graph arrangements + +- **Status:** Accepted +- **Date:** 2026-09-03 +- **Related:** Issue #890; ADR `indexeddb-not-localstorage-for-persistence`; ADR `read-time-transform-for-persisted-values`. + +## Context + +A layout algorithm name does not reproduce a Graph View reliably. Some algorithms are randomized, deterministic algorithms can depend on entity order, and manual vertex movement is not represented by the algorithm. Restoring a Session therefore requires the selected layout, exact vertex positions, and viewport pan and zoom. + +Graph rendering is asynchronous. Layouts, user interaction, Connection changes, overlapping restoration requests, and delayed Cytoscape events can otherwise overwrite a newer Graph Arrangement or write it to the wrong Session. + +## Decision + +A Session owns an optional Graph Arrangement. Graph export files carry the same optional arrangement so older files and persisted Sessions remain valid. + +- Capture positions and viewport after `layoutstop` and `dragfree`. Debounce pan and zoom capture. +- Scope capture and restoration to the target Connection. A restoration token prevents stale or overlapping requests from committing. +- Suppress capture while applying a restoration so programmatic position and viewport changes are not persisted as user changes. +- Apply a complete arrangement after Cytoscape elements exist and skip one automatic layout. For a partial arrangement, preserve matched positions while laying out unmatched vertices. +- Treat restoration revisions as monotonic. Once a newer revision is consumed, an older revision cannot replace it. +- Reconcile Graph Arrangements when Session membership changes: retain positions for surviving vertices, remove deleted positions, and preserve the viewport. +- Reconstruct a requested endpoint-only vertex from a restored edge when a connector cannot materialize vertex details. RDF resources can exist in the visualization without literal properties or an `rdf:type`. +- Validate exported arrangements strictly. Invalid persisted local arrangements are dropped during the read-time transform rather than preventing startup. + +## Considered Options + +- **Persist only the layout algorithm.** Rejected because it cannot reproduce randomized, order-sensitive, or manually adjusted arrangements. +- **Persist Graph Arrangement as app-global state.** Rejected because Sessions and their visual state belong to a Connection. +- **Always rerun the layout after restoration.** Rejected because it replaces exact positions and causes a visible transition away from the saved arrangement. + +## Consequences + +- Graph files and previous Sessions can reproduce the saved visualization exactly. +- Fresh Graph View instances apply the active Session's arrangement immediately instead of replaying the layout animation. +- Graph membership changes and incomplete restoration require explicit position reconciliation. +- New capture or restoration paths must preserve Connection and revision guards. diff --git a/docs/features/graph-view.md b/docs/features/graph-view.md index d33ab26f25..4d20396785 100644 --- a/docs/features/graph-view.md +++ b/docs/features/graph-view.md @@ -12,8 +12,8 @@ The graph visualization canvas that you can interact with. Double-click to expan - **Layout drop-down & reset:** You can display graph data using standard graph layouts in the Graph View. You can use the circular arrow to reset the physics of a layout. - **Screenshot:** Download a picture of the current window in Graph View. -- **Save Graph:** Save the current rendered graph as a JSON file that can be shared with others having the same connection or reloaded at a later time. -- **Load Graph:** Load a previously saved graph from a JSON file. +- **Save Graph:** Save the current graph as a JSON file, including its selected layout, node positions, pan, and zoom. The file can be shared with others using the same connection or loaded later. +- **Load Graph:** Load a saved graph and restore its arrangement. Graphs saved by older versions remain supported and use the selected or default layout when arrangement data is unavailable. - **Zoom In/Out & Clear:** To help users quickly zoom in/out or clear the whole canvas in the Graph View. - **Legend (i):** This displays an informational list of icons, colors, and display names available. diff --git a/packages/graph-explorer/src/components/Graph/Graph.test.tsx b/packages/graph-explorer/src/components/Graph/Graph.test.tsx new file mode 100644 index 0000000000..c7f0f9cfcd --- /dev/null +++ b/packages/graph-explorer/src/components/Graph/Graph.test.tsx @@ -0,0 +1,714 @@ +// @vitest-environment happy-dom +import { render, waitFor } from "@testing-library/react"; +import { act, useEffect, useRef } from "react"; +import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; + +import { getAppStore } from "@/core"; +import { createQueryClient } from "@/core/queryClient"; +import { createRandomRawConfiguration, TestProvider } from "@/utils/testing"; + +import { Graph, type GraphProps, type GraphRef } from "./Graph"; +import { GraphProvider, useGraphRef } from "./GraphContext"; + +function createMockContext() { + return new Proxy({} as Record, { + get(target, prop) { + if (prop in target) { + return target[prop as string]; + } + if (prop === "measureText") { + return () => ({ + width: 0, + actualBoundingBoxLeft: 0, + actualBoundingBoxRight: 0, + }); + } + if (prop === "getImageData") { + return () => ({ data: new Uint8ClampedArray(4) }); + } + if (prop === "createLinearGradient" || prop === "createRadialGradient") { + return () => ({ addColorStop: () => {} }); + } + if (prop === "createPattern") { + return () => null; + } + return () => {}; + }, + set(target, prop, value) { + target[prop as string] = value; + return true; + }, + }); +} + +let originalGetContext: typeof HTMLCanvasElement.prototype.getContext; +let originalOffscreenCanvas: unknown; +let originalGetComputedStyle: typeof window.getComputedStyle; + +beforeAll(() => { + originalOffscreenCanvas = (globalThis as any).OffscreenCanvas; + (globalThis as any).OffscreenCanvas = undefined; + + originalGetContext = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function ( + type: string, + ...args: any[] + ) { + if (type === "2d") { + return createMockContext() as any; + } + return originalGetContext.call(this, type, ...args); + }; + + originalGetComputedStyle = window.getComputedStyle; + window.getComputedStyle = vi.fn((_el: Element) => { + const prop = (name: string) => { + if (name === "width") return "600px"; + if (name === "height") return "400px"; + if (name.includes("padding")) return "0px"; + return ""; + }; + return { + getPropertyValue: prop, + width: "600px", + height: "400px", + } as any; + }); +}); + +afterAll(() => { + (globalThis as any).OffscreenCanvas = originalOffscreenCanvas; + HTMLCanvasElement.prototype.getContext = originalGetContext; + window.getComputedStyle = originalGetComputedStyle; +}); + +type GraphHarnessProps = { + onReady?: (graphRef: GraphRef) => void; +} & GraphProps; + +function GraphHarness({ onReady, ...props }: GraphHarnessProps) { + const graphRef = useGraphRef(); + const readyRef = useRef(false); + + useEffect(() => { + const id = setInterval(() => { + if (!readyRef.current && graphRef.current?.cytoscape) { + readyRef.current = true; + onReady?.(graphRef.current); + clearInterval(id); + } + }, 10); + return () => clearInterval(id); + }, [graphRef, onReady]); + + return ; +} + +function renderGraph(props: GraphHarnessProps) { + const store = getAppStore(); + const client = createQueryClient(); + + const container = document.createElement("div"); + container.style.width = "600px"; + container.style.height = "400px"; + Object.defineProperty(container, "clientWidth", { + value: 600, + configurable: true, + }); + Object.defineProperty(container, "clientHeight", { + value: 400, + configurable: true, + }); + document.body.appendChild(container); + + return render( + + + + + , + { container }, + ); +} + +function waitForGraphReady(onReady: ReturnType) { + return waitFor(() => expect(onReady).toHaveBeenCalled(), { timeout: 5000 }); +} + +describe("Graph lifecycle", () => { + test("applies a pending restoration once and skips the automatic layout", async () => { + const onReady = vi.fn(); + const onRestorationConsumed = vi.fn(); + const onLayoutUpdated = vi.fn(); + const onArrangementChanged = vi.fn(); + + const positions = [ + { id: "a", x: 12, y: 34 }, + { id: "b", x: 56, y: 78 }, + ]; + const viewport = { pan: { x: 9, y: 10 }, zoom: 2 }; + + renderGraph({ + onReady, + nodes: [{ data: { id: "a" } }, { data: { id: "b" } }], + edges: [], + layout: "F_COSE", + useAnimation: false, + restoration: { + revision: 1, + positions, + viewport, + }, + onRestorationConsumed, + onLayoutUpdated, + onArrangementChanged, + }); + + await waitForGraphReady(onReady); + + const graphRef = onReady.mock.calls[0][0] as GraphRef; + const cy = graphRef.cytoscape!; + + expect(cy.getElementById("a").position()).toMatchObject({ x: 12, y: 34 }); + expect(cy.getElementById("b").position()).toMatchObject({ x: 56, y: 78 }); + expect(cy.pan()).toEqual(viewport.pan); + expect(cy.zoom()).toBe(viewport.zoom); + + expect(onRestorationConsumed).toHaveBeenCalledTimes(1); + expect(onRestorationConsumed).toHaveBeenCalledWith(1); + + expect(onLayoutUpdated).not.toHaveBeenCalled(); + expect(onArrangementChanged).not.toHaveBeenCalled(); + }); + + test("restores the same revision only once", async () => { + const onReady = vi.fn(); + const onRestorationConsumed = vi.fn(); + const onLayoutUpdated = vi.fn(); + + const { rerender } = renderGraph({ + onReady, + nodes: [{ data: { id: "a" } }, { data: { id: "b" } }], + edges: [], + layout: "F_COSE", + useAnimation: false, + restoration: { + revision: 2, + positions: [ + { id: "a", x: 1, y: 2 }, + { id: "b", x: 3, y: 4 }, + ], + }, + onRestorationConsumed, + onLayoutUpdated, + }); + + await waitForGraphReady(onReady); + expect(onRestorationConsumed).toHaveBeenCalledTimes(1); + + rerender( + + + + + , + ); + + // Give React time to reconcile the identical restoration + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(onRestorationConsumed).toHaveBeenCalledTimes(1); + expect(onLayoutUpdated).not.toHaveBeenCalled(); + }); + + test("does not replace a newer restoration with an older revision", async () => { + const onReady = vi.fn(); + const onRestorationConsumed = vi.fn(); + const newerPositions = [ + { id: "a", x: 12, y: 34 }, + { id: "b", x: 56, y: 78 }, + ]; + + const { rerender } = renderGraph({ + onReady, + nodes: [{ data: { id: "a" } }, { data: { id: "b" } }], + edges: [], + layout: "F_COSE", + useAnimation: false, + restoration: { revision: 2, positions: newerPositions }, + onRestorationConsumed, + }); + + await waitForGraphReady(onReady); + const cy = (onReady.mock.calls[0][0] as GraphRef).cytoscape!; + + rerender( + + + + + , + ); + + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(cy.getElementById("a").position()).toMatchObject(newerPositions[0]); + expect(cy.getElementById("b").position()).toMatchObject(newerPositions[1]); + expect(onRestorationConsumed).toHaveBeenCalledTimes(1); + }); + + test("a later explicit rerun still runs the layout", async () => { + const onReady = vi.fn(); + const onLayoutRunningChanged = vi.fn(); + + const positions = [{ id: "a", x: 1, y: 2 }]; + renderGraph({ + onReady, + nodes: [{ data: { id: "a" } }], + edges: [], + layout: "F_COSE", + useAnimation: false, + restoration: { + revision: 3, + positions, + }, + onLayoutRunningChanged, + }); + + await waitForGraphReady(onReady); + expect(onLayoutRunningChanged).not.toHaveBeenCalled(); + + const graphRef = onReady.mock.calls[0][0] as GraphRef; + graphRef.runLayout(); + + await waitFor( + () => expect(onLayoutRunningChanged).toHaveBeenCalledWith(true), + { timeout: 3000 }, + ); + await waitFor( + () => expect(onLayoutRunningChanged).toHaveBeenCalledWith(false), + { timeout: 3000 }, + ); + }); + + test("a subsequent structural change still triggers the layout", async () => { + const onReady = vi.fn(); + const onLayoutUpdated = vi.fn(); + + const { rerender } = renderGraph({ + onReady, + nodes: [{ data: { id: "a" } }], + edges: [], + layout: "F_COSE", + useAnimation: false, + restoration: { + revision: 4, + positions: [{ id: "a", x: 1, y: 2 }], + }, + onLayoutUpdated, + }); + + await waitForGraphReady(onReady); + expect(onLayoutUpdated).not.toHaveBeenCalled(); + + rerender( + + + + + , + ); + + await waitFor(() => expect(onLayoutUpdated).toHaveBeenCalledTimes(1), { + timeout: 3000, + }); + }); + + test("partially restores matching nodes and lays out unmatched nodes", async () => { + const onReady = vi.fn(); + const onLayoutUpdated = vi.fn(); + + renderGraph({ + onReady, + nodes: [{ data: { id: "a" } }, { data: { id: "b" } }], + edges: [{ data: { id: "ab", source: "a", target: "b" } }], + layout: "F_COSE", + useAnimation: false, + restoration: { + revision: 11, + positions: [{ id: "a", x: 12, y: 34 }], + }, + onLayoutUpdated, + }); + + await waitForGraphReady(onReady); + await waitFor(() => expect(onLayoutUpdated).toHaveBeenCalledTimes(1)); + const cy = (onReady.mock.calls[0][0] as GraphRef).cytoscape!; + + expect(cy.getElementById("a").position()).toMatchObject({ x: 12, y: 34 }); + expect(cy.getElementById("b").position()).not.toMatchObject({ x: 0, y: 0 }); + expect(cy.getElementById("a").locked()).toBe(false); + }); + + test("a restoration with zero matching nodes runs the normal layout", async () => { + const onReady = vi.fn(); + const onLayoutUpdated = vi.fn(); + + renderGraph({ + onReady, + nodes: [{ data: { id: "a" } }, { data: { id: "b" } }], + edges: [{ data: { id: "ab", source: "a", target: "b" } }], + layout: "F_COSE", + useAnimation: false, + restoration: { + revision: 12, + positions: [{ id: "missing", x: 12, y: 34 }], + }, + onLayoutUpdated, + }); + + await waitForGraphReady(onReady); + await waitFor(() => expect(onLayoutUpdated).toHaveBeenCalledTimes(1)); + const cy = (onReady.mock.calls[0][0] as GraphRef).cytoscape!; + expect(cy.getElementById("a").position()).not.toMatchObject({ x: 0, y: 0 }); + }); + + test("mounting with no restoration runs the layout", async () => { + const onReady = vi.fn(); + const onLayoutUpdated = vi.fn(); + const onRestorationConsumed = vi.fn(); + + renderGraph({ + onReady, + nodes: [{ data: { id: "a" } }, { data: { id: "b" } }], + edges: [{ data: { id: "ab", source: "a", target: "b" } }], + layout: "F_COSE", + useAnimation: false, + onLayoutUpdated, + onRestorationConsumed, + }); + + await waitForGraphReady(onReady); + + expect(onLayoutUpdated).toHaveBeenCalled(); + expect(onRestorationConsumed).not.toHaveBeenCalled(); + }); +}); + +describe("Graph arrangement capture", () => { + test("layoutstop captures final coordinates", async () => { + const onReady = vi.fn(); + const onArrangementChanged = vi.fn(); + + renderGraph({ + onReady, + nodes: [{ data: { id: "a" } }, { data: { id: "b" } }], + edges: [{ data: { id: "ab", source: "a", target: "b" } }], + layout: "DAGRE_TB", + useAnimation: false, + onArrangementChanged, + }); + + await waitForGraphReady(onReady); + await waitFor(() => expect(onArrangementChanged).toHaveBeenCalled(), { + timeout: 3000, + }); + + const graphRef = onReady.mock.calls[0][0] as GraphRef; + const cy = graphRef.cytoscape!; + const capturedCy = onArrangementChanged.mock.calls[0][0]; + + expect(capturedCy).toBe(cy); + const pos = cy.getElementById("a").position(); + expect(pos).toMatchObject({ x: expect.any(Number), y: expect.any(Number) }); + expect(pos.x).not.toBe(0); + expect(pos.y).not.toBe(0); + }); + + test("dragfree captures user-adjusted coordinates once", async () => { + const onReady = vi.fn(); + const onArrangementChanged = vi.fn(); + + renderGraph({ + onReady, + nodes: [{ data: { id: "a" } }], + edges: [], + layout: "F_COSE", + useAnimation: false, + restoration: { + revision: 5, + positions: [{ id: "a", x: 0, y: 0 }], + }, + onArrangementChanged, + }); + + await waitForGraphReady(onReady); + expect(onArrangementChanged).not.toHaveBeenCalled(); + + const graphRef = onReady.mock.calls[0][0] as GraphRef; + const cy = graphRef.cytoscape!; + + const node = cy.getElementById("a"); + node.position({ x: 111, y: 222 }); + node.emit("dragfree"); + + await waitFor(() => expect(onArrangementChanged).toHaveBeenCalledTimes(1), { + timeout: 1000, + }); + + expect(cy.getElementById("a").position()).toMatchObject({ + x: 111, + y: 222, + }); + }); + + test("pan and zoom callbacks are debounced", async () => { + const onReady = vi.fn(); + const onArrangementChanged = vi.fn(); + const onPanChanged = vi.fn(); + const onZoomChanged = vi.fn(); + + renderGraph({ + onReady, + nodes: [{ data: { id: "a" } }], + edges: [], + layout: "F_COSE", + useAnimation: false, + restoration: { + revision: 6, + positions: [{ id: "a", x: 0, y: 0 }], + }, + onArrangementChanged, + onPanChanged, + onZoomChanged, + }); + + await waitForGraphReady(onReady); + + const graphRef = onReady.mock.calls[0][0] as GraphRef; + const cy = graphRef.cytoscape!; + + cy.pan({ x: 50, y: 60 }); + cy.zoom(3); + + expect(onPanChanged).not.toHaveBeenCalled(); + expect(onZoomChanged).not.toHaveBeenCalled(); + expect(onArrangementChanged).not.toHaveBeenCalled(); + + await new Promise(resolve => setTimeout(resolve, 50)); + + expect(onPanChanged).not.toHaveBeenCalled(); + expect(onZoomChanged).not.toHaveBeenCalled(); + + await new Promise(resolve => setTimeout(resolve, 150)); + + expect(onPanChanged).toHaveBeenCalledWith({ x: 50, y: 60 }); + expect(onZoomChanged).toHaveBeenCalledWith(3); + expect(onArrangementChanged).toHaveBeenCalled(); + }); + + test("captures the connection where layout and drag events started", async () => { + const onReady = vi.fn(); + const onArrangementChanged = vi.fn(); + const firstConnection = createRandomRawConfiguration().id; + const secondConnection = createRandomRawConfiguration().id; + + const { rerender } = renderGraph({ + onReady, + nodes: [{ data: { id: "a" } }], + edges: [], + layout: "F_COSE", + useAnimation: false, + restoration: { + revision: 8, + positions: [{ id: "a", x: 0, y: 0 }], + }, + connectionId: firstConnection, + onArrangementChanged, + }); + await waitForGraphReady(onReady); + const cy = (onReady.mock.calls[0][0] as GraphRef).cytoscape!; + + cy.emit("layoutstart"); + cy.getElementById("a").emit("grab"); + rerender( + + + + + , + ); + cy.emit("layoutstop"); + cy.getElementById("a").emit("dragfree"); + + expect(onArrangementChanged.mock.calls.map(call => call[1])).toEqual([ + firstConnection, + firstConnection, + ]); + }); + + test("drops a queued viewport capture after its connection changes", async () => { + const onReady = vi.fn(); + const onArrangementChanged = vi.fn(); + const firstConnection = createRandomRawConfiguration().id; + const secondConnection = createRandomRawConfiguration().id; + + const { rerender } = renderGraph({ + onReady, + nodes: [{ data: { id: "a" } }], + edges: [], + layout: "F_COSE", + useAnimation: false, + restoration: { + revision: 9, + positions: [{ id: "a", x: 0, y: 0 }], + }, + connectionId: firstConnection, + onArrangementChanged, + }); + await waitForGraphReady(onReady); + const cy = (onReady.mock.calls[0][0] as GraphRef).cytoscape!; + + cy.pan({ x: 10, y: 20 }); + rerender( + + + + + , + ); + + await new Promise(resolve => setTimeout(resolve, 150)); + expect(onArrangementChanged).not.toHaveBeenCalled(); + }); + + test("cancels a queued viewport capture when restoration starts", async () => { + const onReady = vi.fn(); + const onArrangementChanged = vi.fn(); + const onPanChanged = vi.fn(); + + const { rerender } = renderGraph({ + onReady, + nodes: [{ data: { id: "a" } }], + edges: [], + layout: "F_COSE", + useAnimation: false, + onArrangementChanged, + onPanChanged, + }); + await waitForGraphReady(onReady); + const cy = (onReady.mock.calls[0][0] as GraphRef).cytoscape!; + await waitFor(() => expect(onArrangementChanged).toHaveBeenCalled()); + onArrangementChanged.mockClear(); + onPanChanged.mockClear(); + + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); + cy.pan({ x: 10, y: 20 }); + rerender( + + + + + , + ); + act(() => { + vi.advanceTimersByTime(200); + }); + vi.useRealTimers(); + + expect(onPanChanged).not.toHaveBeenCalled(); + expect(onArrangementChanged).not.toHaveBeenCalled(); + }); + + test("cleanup cancels pending debounced callbacks", async () => { + const onReady = vi.fn(); + const onZoomChanged = vi.fn(); + + const { unmount } = renderGraph({ + onReady, + nodes: [{ data: { id: "a" } }], + edges: [], + layout: "F_COSE", + useAnimation: false, + restoration: { + revision: 7, + positions: [{ id: "a", x: 0, y: 0 }], + }, + onZoomChanged, + }); + + await waitForGraphReady(onReady); + + const graphRef = onReady.mock.calls[0][0] as GraphRef; + const cy = graphRef.cytoscape!; + + cy.zoom(5); + unmount(); + + await new Promise(resolve => setTimeout(resolve, 150)); + + expect(onZoomChanged).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/graph-explorer/src/components/Graph/Graph.tsx b/packages/graph-explorer/src/components/Graph/Graph.tsx index f19c848b39..f2f161ee35 100755 --- a/packages/graph-explorer/src/components/Graph/Graph.tsx +++ b/packages/graph-explorer/src/components/Graph/Graph.tsx @@ -8,10 +8,15 @@ import { type ComponentPropsWithoutRef, memo, useCallback, + useEffect, useImperativeHandle, + useRef, useState, } from "react"; +import type { ConfigurationId } from "@/core"; + +import { DEFAULT_GRAPH_LAYOUT } from "@/core/graphLayout"; import { cn } from "@/utils"; import type { @@ -74,6 +79,17 @@ export interface GraphProps< layout?: LayoutName; additionalLayoutsConfig?: { [key: string]: Partial }; onLayoutUpdated?: (cy: CytoscapeType, layout: string) => any; + restoration?: { + revision: number; + positions: { id: string; x: number; y: number }[]; + viewport?: { pan: { x: number; y: number }; zoom: number }; + }; + onRestorationConsumed?: (revision: number) => void; + connectionId?: ConfigurationId; + onArrangementChanged?: ( + cy: CytoscapeType, + connectionId?: ConfigurationId, + ) => void; //callbacks // TODO: Update callbacks type onGraphClick?: (...args: any) => any; @@ -163,7 +179,7 @@ export const Graph = ({ onNodeRightClick, onGraphClick, onGraphRightClick, - layout = "F_COSE", + layout = DEFAULT_GRAPH_LAYOUT, badgesEnabled = false, useAnimation = true, pan, @@ -172,6 +188,10 @@ export const Graph = ({ connectionsFilterConfig, onLayoutRunningChanged, onLayoutUpdated, + restoration, + onRestorationConsumed, + connectionId, + onArrangementChanged, minZoom = 0.01, maxZoom = 5, motionBlur = true, @@ -217,11 +237,16 @@ export const Graph = ({ [], ); // init cytoscape instance and attach some events listeners + const arrangementCaptureSuppressedRef = useRef(false); const cy = useInitCytoscape({ wrapper, + arrangementCaptureSuppressed: arrangementCaptureSuppressedRef, + arrangementCaptureResetKey: restoration?.revision, + connectionId, onLayoutRunningChanged, onPanChanged, onZoomChanged, + onArrangementChanged, zoom, minZoom, pan, @@ -267,6 +292,43 @@ export const Graph = ({ lockedNodesIds, disableLockOnChange, }); + const skipLayoutVersionRef = useRef(undefined); + const restorationLocksRef = useRef | undefined>( + undefined, + ); + const consumedRestorationRef = useRef(undefined); + useEffect(() => { + if ( + !cy || + !restoration || + (consumedRestorationRef.current != null && + restoration.revision <= consumedRestorationRef.current) || + graphStructureVersion === 0 + ) { + return; + } + arrangementCaptureSuppressedRef.current = true; + const restoredNodeLocks = new Map(); + cy.batch(() => { + restoration.positions.forEach(position => { + const node = cy.getElementById(position.id); + if (node.empty()) return; + node.position(position); + restoredNodeLocks.set(node.id(), node.locked()); + }); + }); + if (restoration.viewport) { + cy.viewport(restoration.viewport); + } + arrangementCaptureSuppressedRef.current = false; + consumedRestorationRef.current = restoration.revision; + if (restoredNodeLocks.size === cy.nodes().length) { + skipLayoutVersionRef.current = graphStructureVersion; + } else if (restoredNodeLocks.size > 0) { + restorationLocksRef.current = restoredNodeLocks; + } + onRestorationConsumed?.(restoration.revision); + }, [cy, graphStructureVersion, onRestorationConsumed, restoration]); useManageElementsSelection( { @@ -353,6 +415,8 @@ export const Graph = ({ additionalLayoutsConfig, graphStructureVersion, mounted, + skipLayoutVersionRef, + restorationLocksRef, }); // Set the graphRef context value so that the GraphContextProvider can access the graphRef diff --git a/packages/graph-explorer/src/components/Graph/SelectLayout.tsx b/packages/graph-explorer/src/components/Graph/SelectLayout.tsx index a31ceb9ebd..93f4bfff87 100644 --- a/packages/graph-explorer/src/components/Graph/SelectLayout.tsx +++ b/packages/graph-explorer/src/components/Graph/SelectLayout.tsx @@ -1,6 +1,6 @@ import type { ComponentPropsWithRef } from "react"; -import { type PrimitiveAtom, useAtom } from "jotai"; +import { useAtom, type WritableAtom } from "jotai"; import type { LayoutName } from "@/components/Graph/helpers/layoutConfig"; @@ -19,7 +19,7 @@ export function SelectLayout({ layoutAtom, ...props }: ComponentPropsWithRef & { - layoutAtom: PrimitiveAtom; + layoutAtom: WritableAtom; }) { const [value, setValue] = useAtom(layoutAtom); diff --git a/packages/graph-explorer/src/components/Graph/helpers/layoutConfig.ts b/packages/graph-explorer/src/components/Graph/helpers/layoutConfig.ts index 4ff30d66fa..12a3b0ea97 100644 --- a/packages/graph-explorer/src/components/Graph/helpers/layoutConfig.ts +++ b/packages/graph-explorer/src/components/Graph/helpers/layoutConfig.ts @@ -1,5 +1,7 @@ import type cytoscape from "cytoscape"; +import type { LayoutName } from "@/core/graphLayout"; + export const concentricLayout = { name: "concentric", @@ -335,6 +337,6 @@ export const availableLayoutsConfig = { SUBWAY_BT: subwayLayoutBottomToTop, SUBWAY_LR: subwayLayoutLeftToRight, SUBWAY_RL: subwayLayoutRightToLeft, -}; +} satisfies Record; -export type LayoutName = keyof typeof availableLayoutsConfig; +export type { LayoutName } from "@/core/graphLayout"; diff --git a/packages/graph-explorer/src/components/Graph/hooks/useInitCytoscape.tsx b/packages/graph-explorer/src/components/Graph/hooks/useInitCytoscape.tsx index e275726906..5356458c1c 100644 --- a/packages/graph-explorer/src/components/Graph/hooks/useInitCytoscape.tsx +++ b/packages/graph-explorer/src/components/Graph/hooks/useInitCytoscape.tsx @@ -1,6 +1,8 @@ import cytoscape from "cytoscape"; import debounce from "lodash/debounce"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, type RefObject } from "react"; + +import type { ConfigurationId } from "@/core"; import { useDeepMemo } from "@/hooks"; @@ -14,18 +16,29 @@ export interface UseInitCytoscapeProps extends Required< minZoom?: number; maxZoom?: number; pan?: { x: number; y: number }; + connectionId?: ConfigurationId; onLayoutRunningChanged?: (isRunning: boolean) => void; onZoomChanged?: (e: unknown) => void; onPanChanged?: (e: unknown) => void; + onArrangementChanged?: ( + cy: CytoscapeType, + connectionId?: ConfigurationId, + ) => void; + arrangementCaptureSuppressed?: RefObject; + arrangementCaptureResetKey?: number; } -const useInitCytoscape = ({ +function useInitCytoscape({ wrapper, onLayoutRunningChanged, onPanChanged, onZoomChanged, + onArrangementChanged, + arrangementCaptureSuppressed, + arrangementCaptureResetKey, + connectionId, ...config -}: UseInitCytoscapeProps) => { +}: UseInitCytoscapeProps) { const [cy, setCy] = useState(); const memoizedConfig = useDeepMemo(() => config, [config]); @@ -42,6 +55,7 @@ const useInitCytoscape = ({ onLayoutRunningChanged, onPanChanged, onZoomChanged, + onArrangementChanged, }); useEffect(() => { @@ -49,8 +63,14 @@ const useInitCytoscape = ({ onLayoutRunningChanged, onPanChanged, onZoomChanged, + onArrangementChanged, }; - }, [onLayoutRunningChanged, onPanChanged, onZoomChanged]); + }, [ + onLayoutRunningChanged, + onPanChanged, + onZoomChanged, + onArrangementChanged, + ]); useEffect(() => { layoutGraphConfig.current = { @@ -60,6 +80,28 @@ const useInitCytoscape = ({ }; }, [autolock, userZoomingEnabled, userPanningEnabled]); + const connectionIdRef = useRef(connectionId); + const layoutTargetRef = useRef(connectionId); + const dragTargetRef = useRef(connectionId); + const pendingViewportRef = useRef<{ + pan?: { x: number; y: number }; + zoom?: number; + }>({}); + const debouncedArrangementRef = useRef | null>( + null, + ); + + useEffect(() => { + connectionIdRef.current = connectionId; + pendingViewportRef.current = {}; + debouncedArrangementRef.current?.cancel(); + }, [connectionId]); + + useEffect(() => { + pendingViewportRef.current = {}; + debouncedArrangementRef.current?.cancel(); + }, [arrangementCaptureResetKey]); + useEffect(() => { if (wrapper) { const cy = cytoscape({ @@ -71,6 +113,7 @@ const useInitCytoscape = ({ cy.on("layoutstart", () => { cy.userPanningEnabled(false); cy.userZoomingEnabled(false); + layoutTargetRef.current = connectionIdRef.current; eventHandlerRefs.current.onLayoutRunningChanged?.(true); }); @@ -81,22 +124,70 @@ const useInitCytoscape = ({ cy.nodes().lock(); } eventHandlerRefs.current.onLayoutRunningChanged?.(false); + if (!arrangementCaptureSuppressed?.current) { + eventHandlerRefs.current.onArrangementChanged?.( + cy, + layoutTargetRef.current, + ); + } + }); + + cy.on("grab", "node", () => { + dragTargetRef.current = connectionIdRef.current; + }); + + cy.on("dragfree", "node", () => { + if (!arrangementCaptureSuppressed?.current) { + eventHandlerRefs.current.onArrangementChanged?.( + cy, + dragTargetRef.current, + ); + } }); // Avoid to notify every single change during animation - const debouncedZoom = debounce(() => { - eventHandlerRefs.current.onZoomChanged?.(cy.zoom()); - }, 100); - cy.on("zoom", debouncedZoom); + const debouncedArrangement = debounce( + (cy: CytoscapeType, targetId?: ConfigurationId) => { + if ( + arrangementCaptureSuppressed?.current || + targetId !== connectionIdRef.current + ) { + pendingViewportRef.current = {}; + return; + } + if (pendingViewportRef.current.pan) { + eventHandlerRefs.current.onPanChanged?.( + pendingViewportRef.current.pan, + ); + } + if (pendingViewportRef.current.zoom) { + eventHandlerRefs.current.onZoomChanged?.( + pendingViewportRef.current.zoom, + ); + } + eventHandlerRefs.current.onArrangementChanged?.(cy, targetId); + pendingViewportRef.current = {}; + }, + 100, + ); + debouncedArrangementRef.current = debouncedArrangement; + + cy.on("pan", () => { + if (arrangementCaptureSuppressed?.current) return; + pendingViewportRef.current.pan = cy.pan(); + debouncedArrangement(cy, connectionIdRef.current); + }); - const debouncedPan = debounce(() => { - eventHandlerRefs.current.onPanChanged?.(cy.pan()); - }, 100); - cy.on("pan", debouncedPan); + cy.on("zoom", () => { + if (arrangementCaptureSuppressed?.current) return; + pendingViewportRef.current.zoom = cy.zoom(); + debouncedArrangement(cy, connectionIdRef.current); + }); setCy(cy); return () => { + debouncedArrangement.cancel(); (cy.elements() as any).removeAllListeners(); (cy as any).removeAllListeners(); cy.destroy(); @@ -105,9 +196,9 @@ const useInitCytoscape = ({ }; } // since this is to init cytoscape, this should only run when wrapper is set - }, [memoizedConfig, wrapper]); + }, [arrangementCaptureSuppressed, memoizedConfig, wrapper]); return cy; -}; +} export default useInitCytoscape; diff --git a/packages/graph-explorer/src/components/Graph/hooks/useRunLayout.ts b/packages/graph-explorer/src/components/Graph/hooks/useRunLayout.ts index 2c869b5307..64adbd3c8b 100755 --- a/packages/graph-explorer/src/components/Graph/hooks/useRunLayout.ts +++ b/packages/graph-explorer/src/components/Graph/hooks/useRunLayout.ts @@ -1,6 +1,6 @@ import type cytoscape from "cytoscape"; -import { useEffect, useRef } from "react"; +import { useEffect, useRef, type RefObject } from "react"; import type { CytoscapeType, LayoutName } from "../Graph.model"; @@ -16,6 +16,8 @@ interface UseUpdateLayout { onLayoutUpdated?: (cy: CytoscapeType, layout: string) => void; graphStructureVersion: number; mounted: boolean; + skipLayoutVersionRef?: RefObject; + restorationLocksRef?: RefObject | undefined>; } /** @@ -31,6 +33,8 @@ function useUpdateLayout({ useAnimation, graphStructureVersion, mounted, + skipLayoutVersionRef, + restorationLocksRef, }: UseUpdateLayout) { const previousNodesRef = useRef(new Set()); const previousLayoutRef = useRef(layout); @@ -42,6 +46,14 @@ function useUpdateLayout({ return; } + if (skipLayoutVersionRef?.current === graphStructureVersion) { + skipLayoutVersionRef.current = undefined; + previousLayoutRef.current = layout; + previousNodesRef.current = new Set(cy.nodes().map(node => node.id())); + previousGraphStructureVersionRef.current = graphStructureVersion; + return; + } + // Only lock the previous nodes if the layout is the same, the graph has been updated, and there is at least one node. const shouldLock = previousLayoutRef.current === layout && @@ -56,26 +68,29 @@ function useUpdateLayout({ ) : []; - if (shouldLock) { - // Lock all the previous nodes + const restorationLocks = restorationLocksRef?.current; + if (shouldLock || restorationLocks) { cy.batch(() => { nodesToLock.forEach(node => { node.lock(); }); + restorationLocks?.forEach((_, id) => cy.getElementById(id).lock()); }); } - // Perform the layout for any new nodes runLayout(cy, layout, additionalLayoutsConfig, useAnimation); onLayoutUpdated?.(cy, layout); - if (shouldLock) { - // Unlock all the previous nodes + if (shouldLock || restorationLocks) { cy.batch(() => { nodesToLock.forEach(node => { node.unlock(); }); + restorationLocks?.forEach((wasLocked, id) => { + if (!wasLocked) cy.getElementById(id).unlock(); + }); }); + if (restorationLocksRef) restorationLocksRef.current = undefined; } // Update the refs for previous state so we can compare the next time the graph is updated @@ -90,6 +105,8 @@ function useUpdateLayout({ onLayoutUpdated, graphStructureVersion, mounted, + skipLayoutVersionRef, + restorationLocksRef, ]); } diff --git a/packages/graph-explorer/src/connector/queries/fetchEntityDetails.ts b/packages/graph-explorer/src/connector/queries/fetchEntityDetails.ts index 57495d8152..9ed362f319 100644 --- a/packages/graph-explorer/src/connector/queries/fetchEntityDetails.ts +++ b/packages/graph-explorer/src/connector/queries/fetchEntityDetails.ts @@ -2,9 +2,8 @@ import type { QueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; -import type { EdgeId, VertexId } from "@/core"; - import { bulkEdgeDetailsQuery, bulkVertexDetailsQuery } from "@/connector"; +import { createVertex, type EdgeId, type VertexId } from "@/core"; import { formatEntityCounts } from "@/utils"; /** @@ -28,11 +27,21 @@ export async function fetchEntityDetails( bulkEdgeDetailsQuery(edgesArray), ); - const vertexDetails = vertexResults.vertices; + const vertexDetails = [...vertexResults.vertices]; const edgeDetails = edgeResults.edges; + const requestedVertexIds = new Set(verticesArray); + const restoredVertexIds = new Set(vertexDetails.map(vertex => vertex.id)); + for (const edge of edgeDetails) { + for (const id of [edge.sourceId, edge.targetId]) { + if (requestedVertexIds.has(id) && !restoredVertexIds.has(id)) { + vertexDetails.push(createVertex({ id })); + restoredVertexIds.add(id); + } + } + } const countOfVertexNotFound = verticesArray.filter( - id => vertexDetails.find(v => v.id === id) == null, + id => !restoredVertexIds.has(id), ).length; const countOfEdgeNotFound = edgesArray.filter( id => edgeDetails.find(e => e.id === id) == null, diff --git a/packages/graph-explorer/src/connector/sparql/mappers/mapSparqlValueToScalar.test.ts b/packages/graph-explorer/src/connector/sparql/mappers/mapSparqlValueToScalar.test.ts index 815341f10c..7ffb6b6382 100644 --- a/packages/graph-explorer/src/connector/sparql/mappers/mapSparqlValueToScalar.test.ts +++ b/packages/graph-explorer/src/connector/sparql/mappers/mapSparqlValueToScalar.test.ts @@ -349,8 +349,7 @@ describe("mapSparqlValueToScalar", () => { const result = mapSparqlValueToScalar(sparqlValue); - expect(result).toBeInstanceOf(Date); - expect(result.toString()).toBe("Invalid Date"); + expect(result).toBe("not-a-date"); }); }); }); diff --git a/packages/graph-explorer/src/connector/sparql/mappers/mapSparqlValueToScalar.ts b/packages/graph-explorer/src/connector/sparql/mappers/mapSparqlValueToScalar.ts index 8e589aa2ba..97bd257d50 100644 --- a/packages/graph-explorer/src/connector/sparql/mappers/mapSparqlValueToScalar.ts +++ b/packages/graph-explorer/src/connector/sparql/mappers/mapSparqlValueToScalar.ts @@ -25,8 +25,10 @@ export function mapSparqlValueToScalar(sparqlValue: SparqlValue) { return sparqlValue.value === "true" || sparqlValue.value === "1"; case "http://www.w3.org/2001/XMLSchema#dateTime": - case "http://www.w3.org/2001/XMLSchema#date": - return new Date(sparqlValue.value); + case "http://www.w3.org/2001/XMLSchema#date": { + const date = new Date(sparqlValue.value); + return Number.isNaN(date.getTime()) ? sparqlValue.value : date; + } default: // For unknown datatypes, return as string diff --git a/packages/graph-explorer/src/core/StateProvider/graphSession/arrangement.test.ts b/packages/graph-explorer/src/core/StateProvider/graphSession/arrangement.test.ts new file mode 100644 index 0000000000..e435057ac9 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/graphSession/arrangement.test.ts @@ -0,0 +1,62 @@ +import { createNewConfigurationId, createVertexId } from "@/core"; + +import { + arrangementsEqual, + createPendingGraphRestoration, + getGraphRestorationForTarget, +} from "./arrangement"; + +const arrangement = { + positions: [{ id: createVertexId(1), x: 10, y: 20 }], + viewport: { pan: { x: 30, y: 40 }, zoom: 2 }, +}; + +test("compares positions and viewport exactly", () => { + expect(arrangementsEqual(arrangement, structuredClone(arrangement))).toBe( + true, + ); + expect( + arrangementsEqual(arrangement, { + ...arrangement, + viewport: { ...arrangement.viewport, zoom: 3 }, + }), + ).toBe(false); + expect( + arrangementsEqual(arrangement, { + ...arrangement, + positions: [{ ...arrangement.positions[0], x: 11 }], + }), + ).toBe(false); +}); + +test("distinguishes absent and present viewports", () => { + expect( + arrangementsEqual({ positions: arrangement.positions }, arrangement), + ).toBe(false); +}); + +test("creates unique monotonic target-scoped restoration revisions", () => { + const target = createNewConfigurationId(); + const first = createPendingGraphRestoration(target, arrangement); + const second = createPendingGraphRestoration(target, arrangement); + + expect(second.revision).toBeGreaterThan(first.revision); + expect(first.target).toBe(target); + expect(second.target).toBe(target); +}); + +test("does not expose a restoration to a stale connection target", () => { + const originalTarget = createNewConfigurationId(); + const activeTarget = createNewConfigurationId(); + const restoration = createPendingGraphRestoration( + originalTarget, + arrangement, + ); + + expect( + getGraphRestorationForTarget(restoration, activeTarget), + ).toBeUndefined(); + expect(getGraphRestorationForTarget(restoration, originalTarget)).toBe( + restoration, + ); +}); diff --git a/packages/graph-explorer/src/core/StateProvider/graphSession/arrangement.ts b/packages/graph-explorer/src/core/StateProvider/graphSession/arrangement.ts new file mode 100644 index 0000000000..1b59c2ecea --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/graphSession/arrangement.ts @@ -0,0 +1,129 @@ +import { atom } from "jotai"; + +import type { ConfigurationId } from "../../ConfigurationProvider"; +import type { VertexId } from "../../entities"; + +export type GraphNodePosition = { + id: VertexId; + x: number; + y: number; +}; + +export type GraphViewport = { + pan: { x: number; y: number }; + zoom: number; +}; + +export type GraphArrangement = { + positions: GraphNodePosition[]; + viewport?: GraphViewport; +}; + +export type PendingGraphRestoration = GraphArrangement & { + revision: number; + target: ConfigurationId; +}; + +export const pendingGraphRestorationAtom = atom( + null, +); + +let nextRestorationRevision = 0; + +export function createPendingGraphRestoration( + target: ConfigurationId, + arrangement: GraphArrangement, +): PendingGraphRestoration { + nextRestorationRevision += 1; + return { ...arrangement, revision: nextRestorationRevision, target }; +} + +export function getGraphRestorationForTarget( + restoration: PendingGraphRestoration | null, + target: ConfigurationId | undefined, +): PendingGraphRestoration | undefined { + return restoration != null && restoration.target === target + ? restoration + : undefined; +} + +export function arrangementsEqual( + left: GraphArrangement | undefined, + right: GraphArrangement, +): boolean { + if (!left || left.positions.length !== right.positions.length) return false; + if ( + left.viewport?.zoom !== right.viewport?.zoom || + left.viewport?.pan.x !== right.viewport?.pan.x || + left.viewport?.pan.y !== right.viewport?.pan.y + ) { + return false; + } + return left.positions.every((position, index) => { + const other = right.positions[index]; + return ( + other != null && + position.id === other.id && + position.x === other.x && + position.y === other.y + ); + }); +} + +export function retainGraphArrangementVertices( + arrangement: GraphArrangement | undefined, + vertexIds: Iterable, +): GraphArrangement | undefined { + if (!arrangement) return undefined; + + const retainedIds = new Set(vertexIds); + return { + positions: arrangement.positions.filter(position => + retainedIds.has(position.id), + ), + viewport: arrangement.viewport, + }; +} + +export function mergeGraphArrangements( + existing: GraphArrangement | undefined, + incoming: GraphArrangement, + vertexIds: Iterable, +): GraphArrangement { + const vertexIdSet = new Set(vertexIds); + const byId = new Map(); + + for (const position of existing?.positions ?? []) { + if (vertexIdSet.has(position.id)) { + byId.set(position.id, position); + } + } + + for (const position of incoming.positions) { + if (vertexIdSet.has(position.id)) { + byId.set(position.id, position); + } + } + + const positions: GraphNodePosition[] = []; + const seen = new Set(); + + for (const position of existing?.positions ?? []) { + if (!seen.has(position.id) && byId.has(position.id)) { + positions.push(byId.get(position.id)!); + seen.add(position.id); + } + } + + for (const position of incoming.positions) { + if (!seen.has(position.id) && byId.has(position.id)) { + positions.push(position); + seen.add(position.id); + } + } + + return { + positions, + viewport: incoming.viewport ?? existing?.viewport, + }; +} diff --git a/packages/graph-explorer/src/core/StateProvider/graphSession/graphViewLayoutAlgorithm.test.ts b/packages/graph-explorer/src/core/StateProvider/graphSession/graphViewLayoutAlgorithm.test.ts new file mode 100644 index 0000000000..b6d4d41157 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/graphSession/graphViewLayoutAlgorithm.test.ts @@ -0,0 +1,108 @@ +// @vitest-environment happy-dom +import { useAtom, useAtomValue } from "jotai"; +import { act } from "react"; + +import { + activeGraphSessionAtom, + allGraphSessionsAtom, + type EdgeId, + useAvailablePreviousSession, +} from "@/core"; +import { DEFAULT_GRAPH_LAYOUT } from "@/core/graphLayout"; +import { + createRandomConfigurationId, + DbState, + renderHookWithState, +} from "@/utils/testing"; + +import { graphViewLayoutAlgorithmAtom } from "./graphViewLayoutAlgorithm"; + +describe("graphViewLayoutAlgorithmAtom", () => { + it("starts with the default graph layout", () => { + const { result } = renderHookWithState( + () => useAtomValue(graphViewLayoutAlgorithmAtom), + new DbState(), + ); + + expect(result.current).toBe(DEFAULT_GRAPH_LAYOUT); + }); + + it("updates the transient live layout", () => { + const { result } = renderHookWithState(() => { + const [layout, setLayout] = useAtom(graphViewLayoutAlgorithmAtom); + return { layout, setLayout }; + }, new DbState()); + + act(() => result.current.setLayout("KLAY_TB")); + + expect(result.current.layout).toBe("KLAY_TB"); + }); + + it("updates an existing nonempty active session", () => { + const state = new DbState(); + state.createVertexInGraph(); + const { result } = renderHookWithState(() => { + const [, setLayout] = useAtom(graphViewLayoutAlgorithmAtom); + const session = useAtomValue(activeGraphSessionAtom); + return { session, setLayout }; + }, state); + + act(() => result.current.setLayout("DAGRE_TB")); + + expect(result.current.session?.layout).toBe("DAGRE_TB"); + }); + + it("does not update an existing empty active session", () => { + const state = new DbState().withGraphSession({ + vertices: new Set(), + edges: new Set(), + }); + const { result } = renderHookWithState(() => { + const [, setLayout] = useAtom(graphViewLayoutAlgorithmAtom); + const session = useAtomValue(activeGraphSessionAtom); + const availableSession = useAvailablePreviousSession(); + return { session, availableSession, setLayout }; + }, state); + const initialSession = result.current.session; + + act(() => result.current.setLayout("DAGRE_TB")); + + expect(result.current.session).toBe(initialSession); + expect(result.current.availableSession).toBeNull(); + }); + + it("does not revive a deleted active session", () => { + const state = new DbState(); + const { result } = renderHookWithState(() => { + const [, setLayout] = useAtom(graphViewLayoutAlgorithmAtom); + const sessions = useAtomValue(allGraphSessionsAtom); + return { sessions, setLayout }; + }, state); + result.current.sessions.delete(state.activeConfig.id); + + act(() => result.current.setLayout("DAGRE_TB")); + + expect(result.current.sessions.has(state.activeConfig.id)).toBe(false); + }); + + it("preserves sessions belonging to other connections", () => { + const state = new DbState(); + state.createVertexInGraph(); + const otherConnection = createRandomConfigurationId(); + const otherSession = { + vertices: new Set(state.vertices.map(vertex => vertex.id)), + edges: new Set(), + layout: "KLAY_LR" as const, + }; + const { result } = renderHookWithState(() => { + const [, setLayout] = useAtom(graphViewLayoutAlgorithmAtom); + const sessions = useAtomValue(allGraphSessionsAtom); + return { sessions, setLayout }; + }, state); + result.current.sessions.set(otherConnection, otherSession); + + act(() => result.current.setLayout("DAGRE_TB")); + + expect(result.current.sessions.get(otherConnection)).toBe(otherSession); + }); +}); diff --git a/packages/graph-explorer/src/core/StateProvider/graphSession/graphViewLayoutAlgorithm.ts b/packages/graph-explorer/src/core/StateProvider/graphSession/graphViewLayoutAlgorithm.ts new file mode 100644 index 0000000000..e0be82c0d7 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/graphSession/graphViewLayoutAlgorithm.ts @@ -0,0 +1,21 @@ +import { atom } from "jotai"; + +import { DEFAULT_GRAPH_LAYOUT, type LayoutName } from "@/core/graphLayout"; + +import { activeGraphSessionAtom } from "./storage"; + +const liveGraphLayoutAtom = atom(DEFAULT_GRAPH_LAYOUT); + +export const graphViewLayoutAlgorithmAtom = atom( + get => get(liveGraphLayoutAtom), + (get, set, layout: LayoutName) => { + if (get(liveGraphLayoutAtom) === layout) return; + + set(liveGraphLayoutAtom, layout); + + const session = get(activeGraphSessionAtom); + if (session && (session.vertices.size > 0 || session.edges.size > 0)) { + set(activeGraphSessionAtom, { ...session, layout }); + } + }, +); diff --git a/packages/graph-explorer/src/core/StateProvider/graphSession/index.ts b/packages/graph-explorer/src/core/StateProvider/graphSession/index.ts index 21648b26d6..ade5fdc8cf 100644 --- a/packages/graph-explorer/src/core/StateProvider/graphSession/index.ts +++ b/packages/graph-explorer/src/core/StateProvider/graphSession/index.ts @@ -1,4 +1,8 @@ +export * from "./arrangement"; export * from "./storage"; +export * from "./graphViewLayoutAlgorithm"; +export * from "./usePopulateGraph"; export * from "./useUpdateGraphSession"; export * from "./useRestoreGraphSession"; +export * from "./useSaveGraphArrangement"; export * from "./useAvailablePreviousSession"; diff --git a/packages/graph-explorer/src/core/StateProvider/graphSession/restoration.ts b/packages/graph-explorer/src/core/StateProvider/graphSession/restoration.ts new file mode 100644 index 0000000000..9d85206348 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/graphSession/restoration.ts @@ -0,0 +1,121 @@ +import type { Getter, Setter } from "jotai"; + +import { atom } from "jotai"; +import { RESET } from "jotai/utils"; + +import type { ConfigurationId } from "@/core/ConfigurationProvider"; +import type { LayoutName } from "@/core/graphLayout"; + +import { activeConfigurationAtom } from "@/core"; + +import { + createPendingGraphRestoration, + mergeGraphArrangements, + pendingGraphRestorationAtom, + type GraphArrangement, +} from "./arrangement"; +import { graphViewLayoutAlgorithmAtom } from "./graphViewLayoutAlgorithm"; +import { + activeGraphSessionAtom, + isRestorePreviousSessionAvailableAtom, + type GraphSessionStorageModel, +} from "./storage"; +import { getGraphSessionFromCurrentGraph } from "./useUpdateGraphSession"; + +export type GraphRestorationRequest = { + target: ConfigurationId; + token: number; +}; + +export const graphRestorationRequestAtom = atom( + null, +); + +let nextRestorationToken = 0; + +export function startGraphRestoration( + set: Setter, + target: ConfigurationId, +): number { + nextRestorationToken += 1; + const token = nextRestorationToken; + set(graphRestorationRequestAtom, { target, token }); + set(pendingGraphRestorationAtom, null); + return token; +} + +export function isCurrentGraphRestoration( + get: Getter, + token: number, + target: ConfigurationId, +): boolean { + return ( + get(graphRestorationRequestAtom)?.token === token && + get(activeConfigurationAtom) === target + ); +} + +export type GraphRestorationCommit = { + token: number; + target: ConfigurationId; + layout?: LayoutName; + arrangement?: GraphArrangement; + source?: GraphSessionStorageModel; +}; + +export function commitGraphRestoration( + get: Getter, + set: Setter, + commit: GraphRestorationCommit, +): boolean { + if (!isCurrentGraphRestoration(get, commit.token, commit.target)) { + return false; + } + + if (commit.layout != null) { + set(graphViewLayoutAlgorithmAtom, commit.layout); + } + + const currentGraph = getGraphSessionFromCurrentGraph(get); + const currentSession = get(activeGraphSessionAtom); + const baseSession: GraphSessionStorageModel = commit.source + ? { ...commit.source, layout: commit.source.layout ?? currentGraph.layout } + : currentGraph; + const baseArrangement = + commit.source?.arrangement ?? currentSession?.arrangement; + + const finalArrangement = + commit.arrangement != null && baseSession.vertices.size > 0 + ? mergeGraphArrangements( + baseArrangement, + commit.arrangement, + baseSession.vertices, + ) + : baseArrangement; + + const layout = commit.layout ?? baseSession.layout; + + if (baseSession.vertices.size === 0 && baseSession.edges.size === 0) { + set(activeGraphSessionAtom, RESET); + } else { + set(activeGraphSessionAtom, { + vertices: baseSession.vertices, + edges: baseSession.edges, + layout, + arrangement: finalArrangement, + }); + } + + set(isRestorePreviousSessionAvailableAtom, false); + + if (finalArrangement != null) { + set( + pendingGraphRestorationAtom, + createPendingGraphRestoration(commit.target, finalArrangement), + ); + } + + set(graphRestorationRequestAtom, null); + + return true; +} diff --git a/packages/graph-explorer/src/core/StateProvider/graphSession/storage.ts b/packages/graph-explorer/src/core/StateProvider/graphSession/storage.ts index 2726722816..16b4ceb388 100644 --- a/packages/graph-explorer/src/core/StateProvider/graphSession/storage.ts +++ b/packages/graph-explorer/src/core/StateProvider/graphSession/storage.ts @@ -1,14 +1,28 @@ import { atom } from "jotai"; import { atomWithReset, RESET } from "jotai/utils"; +import { z } from "zod"; -import { activeConfigurationAtom, allGraphSessionsAtom } from "@/core"; +import { + activeConfigurationAtom, + allGraphSessionsAtom, + createVertexId, +} from "@/core"; +import { + DEFAULT_GRAPH_LAYOUT, + isLayoutName, + type LayoutName, +} from "@/core/graphLayout"; +import { logger } from "@/utils"; import type { EdgeId, VertexId } from "../../entities"; +import type { GraphArrangement } from "./arrangement"; /** A model for the graph data that is stored in local storage. */ export type GraphSessionStorageModel = { vertices: Set; edges: Set; + layout?: LayoutName; + arrangement?: GraphArrangement; }; export const isRestorePreviousSessionAvailableAtom = atomWithReset(true); @@ -47,3 +61,90 @@ export const activeGraphSessionAtom = atom( set(allGraphSessionsAtom, newGraphs); }, ); + +export function resolveGraphSessionLayout( + value: unknown, +): LayoutName | undefined { + if (value == null) return undefined; + if (isLayoutName(value)) return value; + logger.debug( + `[graph-session] Unrecognized saved layout algorithm; using "${DEFAULT_GRAPH_LAYOUT}"`, + value, + ); + return DEFAULT_GRAPH_LAYOUT; +} + +const finiteNumberSchema = z.number().finite(); + +const graphArrangementSchema = z + .object({ + positions: z.array( + z.object({ + id: z + .union([z.string(), z.number()]) + .transform(value => createVertexId(value)), + x: finiteNumberSchema, + y: finiteNumberSchema, + }), + ), + viewport: z + .object({ + pan: z.object({ + x: finiteNumberSchema, + y: finiteNumberSchema, + }), + zoom: finiteNumberSchema, + }) + .optional(), + }) + .superRefine((arrangement, context) => { + const seen = new Set(); + arrangement.positions.forEach((position, index) => { + const key = `${typeof position.id}:${position.id}`; + if (seen.has(key)) { + context.addIssue({ + code: "custom", + message: "Duplicate node position id", + path: ["positions", index, "id"], + }); + } + seen.add(key); + }); + }); + +export function sanitizeGraphArrangement( + value: unknown, +): GraphArrangement | undefined { + const result = graphArrangementSchema.safeParse(value); + if (result.success) { + return result.data as GraphArrangement; + } + logger.warn( + "[graph-session] Dropping invalid persisted arrangement", + result.error, + ); + return undefined; +} + +export function transformGraphSessions( + sessions: Map, +): Map { + let transformed: Map | undefined; + + for (const [connectionId, session] of sessions) { + const layout = resolveGraphSessionLayout(session.layout); + const arrangement = + session.arrangement == null + ? session.arrangement + : sanitizeGraphArrangement(session.arrangement); + + if (layout === session.layout && arrangement === session.arrangement) { + continue; + } + + transformed ??= new Map(sessions); + transformed.set(connectionId, { ...session, layout, arrangement }); + } + + return transformed ?? sessions; +} diff --git a/packages/graph-explorer/src/core/StateProvider/graphSession/usePopulateGraph.ts b/packages/graph-explorer/src/core/StateProvider/graphSession/usePopulateGraph.ts new file mode 100644 index 0000000000..209ad932fc --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/graphSession/usePopulateGraph.ts @@ -0,0 +1,66 @@ +import { useSetAtom } from "jotai"; +import { useAtomCallback } from "jotai/utils"; +import { useCallback } from "react"; + +import { logger } from "@/utils"; + +import type { Entities } from "../../entities"; + +import { edgesAtom, toEdgeMap } from "../edges"; +import { nodesAtom, toNodeMap } from "../nodes"; +import { + activeSchemaSelector, + createVertexTypeLookup, + updateSchemaFromEntities, +} from "../schema"; + +export function usePopulateGraph() { + const setVertices = useSetAtom(nodesAtom); + const setEdges = useSetAtom(edgesAtom); + const setActiveSchema = useSetAtom(activeSchemaSelector); + + const getCanvasVertices = useAtomCallback( + useCallback(get => get(nodesAtom), []), + ); + + return useCallback( + (entities: Partial) => { + const newVerticesMap = toNodeMap(entities.vertices ?? []); + const newEdgesMap = toEdgeMap(entities.edges ?? []); + + if (newVerticesMap.size === 0 && newEdgesMap.size === 0) { + return; + } + + const vertexLookup = createVertexTypeLookup( + newVerticesMap, + getCanvasVertices(), + ); + + if (newVerticesMap.size > 0) { + logger.debug("Adding vertices to graph", newVerticesMap); + setVertices(prev => new Map([...prev, ...newVerticesMap])); + } + + if (newEdgesMap.size > 0) { + logger.debug("Adding edges to graph", newEdgesMap); + setEdges(prev => new Map([...prev, ...newEdgesMap])); + } + + setActiveSchema(prev => { + if (!prev) { + return prev; + } + return updateSchemaFromEntities( + { + vertices: newVerticesMap.values().toArray(), + edges: newEdgesMap.values().toArray(), + }, + prev, + vertexLookup, + ); + }); + }, + [setVertices, setEdges, setActiveSchema, getCanvasVertices], + ); +} diff --git a/packages/graph-explorer/src/core/StateProvider/graphSession/useRestoreGraphSession.test.ts b/packages/graph-explorer/src/core/StateProvider/graphSession/useRestoreGraphSession.test.ts new file mode 100644 index 0000000000..ec22d0725c --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/graphSession/useRestoreGraphSession.test.ts @@ -0,0 +1,219 @@ +// @vitest-environment happy-dom +import { waitFor } from "@testing-library/react"; +import { useAtom, useAtomValue } from "jotai"; +import { act } from "react"; + +import type { GraphSessionStorageModel } from "@/core/StateProvider/graphSession/storage"; + +import { activeGraphSessionAtom, graphViewLayoutAlgorithmAtom } from "@/core"; +import { DEFAULT_GRAPH_LAYOUT, type LayoutName } from "@/core/graphLayout"; +import { logger } from "@/utils"; +import { + createRandomEdge, + createRandomRawConfiguration, + createRandomVertex, + DbState, + FakeExplorer, + renderHookWithState, +} from "@/utils/testing"; + +import { useRestoreGraphSession } from "./useRestoreGraphSession"; + +type RestoreResult = { + restore: ReturnType; + layout: ReturnType>; + setLayout: (layout: LayoutName) => void; + session: ReturnType>; +}; + +function setup(currentLayout = DEFAULT_GRAPH_LAYOUT) { + const explorer = new FakeExplorer(); + const source = createRandomVertex(); + const target = createRandomVertex(); + const edge = createRandomEdge(source, target); + explorer.addVertex(source); + explorer.addVertex(target); + explorer.addEdge(edge); + + const state = new DbState(explorer); + + const { result } = renderHookWithState(() => { + const restore = useRestoreGraphSession(); + const [layout, setLayout] = useAtom(graphViewLayoutAlgorithmAtom); + const session = useAtomValue(activeGraphSessionAtom); + return { restore, layout, setLayout, session }; + }, state); + + act(() => result.current.setLayout(currentLayout)); + + return { result, explorer, source, target, edge }; +} + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +it("applies the saved layout after restoring entities", async () => { + const { result, source, target, edge } = setup("F_COSE"); + + const session: GraphSessionStorageModel = { + vertices: new Set([source.id, target.id]), + edges: new Set([edge.id]), + layout: "DAGRE_TB", + }; + + act(() => result.current.restore.mutate(session)); + + await waitFor(() => { + expect(result.current.layout).toBe("DAGRE_TB"); + expect(result.current.session?.layout).toBe("DAGRE_TB"); + expect(result.current.session?.vertices).toStrictEqual( + new Set([source.id, target.id]), + ); + }); +}); + +it("preserves the current live layout when the saved session has none", async () => { + const { result, source, target, edge } = setup("KLAY_LR"); + + const session: GraphSessionStorageModel = { + vertices: new Set([source.id, target.id]), + edges: new Set([edge.id]), + }; + + act(() => result.current.restore.mutate(session)); + + await waitFor(() => { + expect(result.current.layout).toBe("KLAY_LR"); + expect(result.current.session?.layout).toBe("KLAY_LR"); + }); +}); + +it("preserves the source session when the explorer cannot restore every vertex", async () => { + const explorer = new FakeExplorer(); + const v1 = createRandomVertex(); + const v2 = createRandomVertex(); + const v3 = createRandomVertex(); + const missing = createRandomVertex(); + explorer.addVertex(v1); + explorer.addVertex(v2); + explorer.addVertex(v3); + + const edges = [createRandomEdge(v1, v2), createRandomEdge(v2, v3)]; + for (const edge of edges) { + explorer.addEdge(edge); + } + + const arrangement = { + positions: [ + { id: v1.id, x: 1, y: 1 }, + { id: v2.id, x: 2, y: 2 }, + { id: v3.id, x: 3, y: 3 }, + { id: missing.id, x: 4, y: 4 }, + ], + viewport: { pan: { x: 0, y: 0 }, zoom: 1 }, + }; + + const state = new DbState(explorer); + state.activeConfig = createRandomRawConfiguration(); + state.withGraphSession({ + vertices: new Set([v1.id, v2.id, v3.id, missing.id]), + edges: new Set(edges.map(e => e.id)), + layout: "DAGRE_TB", + arrangement, + }); + + const { result } = renderHookWithState(() => { + const restore = useRestoreGraphSession(); + const [layout, setLayout] = useAtom(graphViewLayoutAlgorithmAtom); + const session = useAtomValue(activeGraphSessionAtom); + return { restore, layout, setLayout, session }; + }, state); + + act(() => result.current.setLayout("KLAY_LR")); + + const session: GraphSessionStorageModel = { + vertices: new Set([v1.id, v2.id, v3.id, missing.id]), + edges: new Set(edges.map(e => e.id)), + layout: "DAGRE_TB", + arrangement, + }; + + let restoreResult: + | Awaited> + | undefined; + await act(async () => { + restoreResult = await result.current.restore.mutateAsync(session); + }); + + expect(restoreResult?.counts.notFound.vertices).toBe(1); + expect(restoreResult?.entities.vertices).toHaveLength(3); + expect(result.current.session?.vertices.size).toBe(4); + expect(result.current.session?.edges.size).toBe(2); + expect(result.current.session?.layout).toBe("DAGRE_TB"); + expect(result.current.session?.arrangement?.positions).toHaveLength(4); + expect( + result.current.session?.arrangement?.positions.some( + p => p.id === missing.id, + ), + ).toBe(true); +}); + +it("restores an endpoint-only vertex from its saved edge", async () => { + const explorer = new FakeExplorer(); + const source = createRandomVertex(); + const endpointOnly = createRandomVertex(); + const edge = createRandomEdge(source, endpointOnly); + explorer.addVertex(source); + explorer.addEdge(edge); + + const state = new DbState(explorer); + state.withGraphSession({ + vertices: new Set([source.id, endpointOnly.id]), + edges: new Set([edge.id]), + layout: "DAGRE_TB", + }); + + const { result } = renderHookWithState(() => { + const restore = useRestoreGraphSession(); + const [layout, setLayout] = useAtom(graphViewLayoutAlgorithmAtom); + const session = useAtomValue(activeGraphSessionAtom); + return { restore, layout, setLayout, session }; + }, state); + + let restoreResult: + | Awaited> + | undefined; + await act(async () => { + restoreResult = await result.current.restore.mutateAsync({ + vertices: new Set([source.id, endpointOnly.id]), + edges: new Set([edge.id]), + layout: "DAGRE_TB", + }); + }); + + expect(restoreResult?.counts.notFound.vertices).toBe(0); + expect(restoreResult?.entities.vertices.map(vertex => vertex.id)).toContain( + endpointOnly.id, + ); +}); + +it("leaves the live layout unchanged when entity restoration fails", async () => { + const { result, explorer, source } = setup("KLAY_LR"); + const failure = new Error("restore failed"); + const session: GraphSessionStorageModel = { + vertices: new Set([source.id]), + edges: new Set(), + layout: "DAGRE_TB", + }; + vi.spyOn(explorer, "vertexDetails").mockRejectedValue(failure); + vi.spyOn(logger, "error").mockImplementation(() => {}); + + await act(async () => { + await expect(result.current.restore.mutateAsync(session)).rejects.toThrow( + failure, + ); + }); + + expect(result.current.layout).toBe("KLAY_LR"); +}); diff --git a/packages/graph-explorer/src/core/StateProvider/graphSession/useRestoreGraphSession.ts b/packages/graph-explorer/src/core/StateProvider/graphSession/useRestoreGraphSession.ts index 5fb6345965..6a215e9ab1 100644 --- a/packages/graph-explorer/src/core/StateProvider/graphSession/useRestoreGraphSession.ts +++ b/packages/graph-explorer/src/core/StateProvider/graphSession/useRestoreGraphSession.ts @@ -1,59 +1,105 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useAtomCallback } from "jotai/utils"; +import { useCallback } from "react"; import { toast } from "sonner"; import { fetchEntityDetails, notifyOnIncompleteRestoration } from "@/connector"; -import { useAddToGraph } from "@/hooks"; +import { activeConfigurationAtom, usePopulateGraph } from "@/core"; import { useEntityCountFormatterCallback } from "@/hooks/useEntityCountFormatter"; import { logger } from "@/utils"; import { createDisplayError } from "@/utils/createDisplayError"; -import type { GraphSessionStorageModel } from "./storage"; +import { + commitGraphRestoration, + graphRestorationRequestAtom, + isCurrentGraphRestoration, + startGraphRestoration, +} from "./restoration"; +import { + type GraphSessionStorageModel, + resolveGraphSessionLayout, +} from "./storage"; /** * Provides a mutation that restores the graph session from storage. */ export function useRestoreGraphSession() { const queryClient = useQueryClient(); - const addToGraph = useAddToGraph(); + const populateGraph = usePopulateGraph(); const formatEntityCounts = useEntityCountFormatterCallback(); - const mutation = useMutation({ - mutationFn: async (graph: GraphSessionStorageModel) => { - logger.debug("Restoring graph session", graph); - - const entityCountMessage = formatEntityCounts( - graph.vertices.size, - graph.edges.size, - ); - - const restorePromise = (async () => { - // Get the vertex and edge details from the database - const result = await fetchEntityDetails( - graph.vertices, - graph.edges, - queryClient, + const mutationFn = useAtomCallback( + useCallback( + async (get, set, graph: GraphSessionStorageModel) => { + const target = get(activeConfigurationAtom); + + if (!target) { + throw new Error("No active connection to restore the graph session"); + } + + const token = startGraphRestoration(set, target); + logger.debug("Restoring graph session", graph); + + const entityCountMessage = formatEntityCounts( + graph.vertices.size, + graph.edges.size, ); - // Update Graph Explorer state - await addToGraph(result.entities); + let committed = false; + + const restorePromise = (async () => { + const result = await fetchEntityDetails( + graph.vertices, + graph.edges, + queryClient, + ); + + if (!isCurrentGraphRestoration(get, token, target)) { + return result; + } + + populateGraph(result.entities); + + if (!isCurrentGraphRestoration(get, token, target)) { + return result; + } + + committed = commitGraphRestoration(get, set, { + token, + target, + source: graph, + layout: resolveGraphSessionLayout(graph.layout), + arrangement: graph.arrangement, + }); + + return result; + })(); - return result; - })(); + toast.promise(restorePromise, { + loading: `Loading ${entityCountMessage}`, + error: err => ({ + message: createDisplayError(err).title, + description: createDisplayError(err).message, + }), + }); - toast.promise(restorePromise, { - loading: `Loading ${entityCountMessage}`, - error: err => ({ - message: createDisplayError(err).title, - description: createDisplayError(err).message, - }), - }); + try { + const result = await restorePromise; - const result = await restorePromise; + if (committed) { + notifyOnIncompleteRestoration(result); + } - notifyOnIncompleteRestoration(result); + return result; + } finally { + if (isCurrentGraphRestoration(get, token, target)) { + set(graphRestorationRequestAtom, null); + } + } + }, + [queryClient, populateGraph, formatEntityCounts], + ), + ); - return result; - }, - }); - return mutation; + return useMutation({ mutationFn }); } diff --git a/packages/graph-explorer/src/core/StateProvider/graphSession/useSaveGraphArrangement.ts b/packages/graph-explorer/src/core/StateProvider/graphSession/useSaveGraphArrangement.ts new file mode 100644 index 0000000000..2807b67cc5 --- /dev/null +++ b/packages/graph-explorer/src/core/StateProvider/graphSession/useSaveGraphArrangement.ts @@ -0,0 +1,33 @@ +import { useAtomCallback } from "jotai/utils"; + +import type { ConfigurationId } from "@/core/ConfigurationProvider"; + +import type { GraphSessionStorageModel } from "./storage"; + +import { allGraphSessionsAtom } from "../storageAtoms"; +import { arrangementsEqual, type GraphArrangement } from "./arrangement"; + +export function useSaveGraphArrangement() { + return useAtomCallback( + ( + get, + set, + target: ConfigurationId, + expectedSession: GraphSessionStorageModel, + arrangement: GraphArrangement, + ) => { + const sessions = get(allGraphSessionsAtom); + if ( + sessions.get(target) !== expectedSession || + expectedSession.vertices.size === 0 || + arrangementsEqual(expectedSession.arrangement, arrangement) + ) { + return; + } + + const updatedSessions = new Map(sessions); + updatedSessions.set(target, { ...expectedSession, arrangement }); + set(allGraphSessionsAtom, updatedSessions); + }, + ); +} diff --git a/packages/graph-explorer/src/core/StateProvider/graphSession/useUpdateGraphSession.ts b/packages/graph-explorer/src/core/StateProvider/graphSession/useUpdateGraphSession.ts index d9211085f2..aec2f19234 100644 --- a/packages/graph-explorer/src/core/StateProvider/graphSession/useUpdateGraphSession.ts +++ b/packages/graph-explorer/src/core/StateProvider/graphSession/useUpdateGraphSession.ts @@ -1,16 +1,56 @@ +import type { Getter } from "jotai"; + import { useAtomCallback } from "jotai/utils"; +import { RESET } from "jotai/utils"; import { useCallback } from "react"; import { logger } from "@/utils"; import { edgesAtom } from "../edges"; import { nodesAtom } from "../nodes"; +import { retainGraphArrangementVertices } from "./arrangement"; +import { graphViewLayoutAlgorithmAtom } from "./graphViewLayoutAlgorithm"; import { activeGraphSessionAtom, type GraphSessionStorageModel, isRestorePreviousSessionAvailableAtom, } from "./storage"; +export function getGraphSessionFromCurrentGraph( + get: Getter, +): GraphSessionStorageModel { + const nodesInGraph = get(nodesAtom); + const edgesInGraph = get(edgesAtom); + + const vertices = new Set( + nodesInGraph + .entries() + .filter(([_key, node]) => !node.isBlankNode) + .map(([key]) => key), + ); + + const edges = new Set( + edgesInGraph + .entries() + .filter(([_key, edge]) => { + const source = nodesInGraph.get(edge.sourceId); + const target = nodesInGraph.get(edge.targetId); + return !source?.isBlankNode && !target?.isBlankNode; + }) + .map(([key]) => key), + ); + + const layout = get(graphViewLayoutAlgorithmAtom); + const arrangement = retainGraphArrangementVertices( + get(activeGraphSessionAtom)?.arrangement, + vertices, + ); + + return arrangement + ? { vertices, edges, layout, arrangement } + : { vertices, edges, layout }; +} + /** * Returns a callback that can be used to trigger an update of the graph * session storage for the active connection. @@ -18,37 +58,14 @@ import { export function useUpdateGraphSession() { return useAtomCallback( useCallback((get, set) => { - // Get the latest graph data from the atoms - const nodesInGraph = get(nodesAtom); - const edgesInGraph = get(edgesAtom); - - // Get the entity IDs, ignoring blank nodes - const vertices = new Set( - nodesInGraph - .entries() - .filter(([_key, node]) => !node.isBlankNode) - .map(([key]) => key), - ); - const edges = new Set( - edgesInGraph - .entries() - .filter(([_key, edge]) => { - const source = nodesInGraph.get(edge.sourceId); - const target = nodesInGraph.get(edge.targetId); - return !source?.isBlankNode && !target?.isBlankNode; - }) - .map(([key]) => key), - ); - - // Construct the graph storage model - const graphSession: GraphSessionStorageModel = { - vertices, - edges, - }; - - // Update the session + const graphSession = getGraphSessionFromCurrentGraph(get); + logger.debug("Updating graph session", graphSession); - set(activeGraphSessionAtom, graphSession); + if (graphSession.vertices.size === 0 && graphSession.edges.size === 0) { + set(activeGraphSessionAtom, RESET); + } else { + set(activeGraphSessionAtom, graphSession); + } set(isRestorePreviousSessionAvailableAtom, false); }, []), ); diff --git a/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.test.ts b/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.test.ts index 1f4c86900e..f1664ff2e0 100644 --- a/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.test.ts +++ b/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.test.ts @@ -1,22 +1,73 @@ +import { logger } from "@/utils"; + import { + defaultSchemaViewLayout, transformSchemaViewLayout, type SchemaViewLayout, } from "./schemaViewLayoutDefaults"; +it("defaults the layout algorithm to F_COSE", () => { + expect(defaultSchemaViewLayout.layoutAlgorithm).toBe("F_COSE"); +}); + /** * BACKWARD COMPATIBILITY — PERSISTED DATA * * SchemaViewLayout is persisted to IndexedDB via localforage. Older versions * stored the styling sidebar as two separate panels, so `activeSidebarItem` - * could be "nodes-styling" or "edges-styling". Those were merged into a single - * "styles" panel, but previously persisted layouts may still hold the old - * values. transformSchemaViewLayout normalizes them on read so the sidebar isn't - * stuck pointing at a panel that no longer exists. + * could be "nodes-styling" or "edges-styling". Those versions also had no + * `layoutAlgorithm`. transformSchemaViewLayout maps the old panels to "styles" + * and supplies F_COSE when the algorithm is absent so legacy preferences remain + * usable. * * DO NOT delete or weaken these tests without confirming that all persisted * data has been transformed or that the old values are no longer in the wild. */ describe("transformSchemaViewLayout backward compatibility", () => { + it("defaults a missing layout algorithm to F_COSE", () => { + const legacy = { + activeSidebarItem: "details", + sidebar: { width: 400 }, + detailsAutoOpenOnSelection: false, + } as unknown as SchemaViewLayout; + + expect(transformSchemaViewLayout(legacy)).toStrictEqual({ + activeSidebarItem: "details", + sidebar: { width: 400 }, + detailsAutoOpenOnSelection: false, + layoutAlgorithm: "F_COSE", + }); + expect(logger.debug).not.toHaveBeenCalled(); + }); + + it("preserves a recognized layout algorithm", () => { + const layout: SchemaViewLayout = { + activeSidebarItem: "details", + sidebar: { width: 400 }, + layoutAlgorithm: "D3", + }; + + expect(transformSchemaViewLayout(layout)).toBe(layout); + }); + + it("recovers an unrecognized layout algorithm to F_COSE", () => { + const invalid = { + activeSidebarItem: "details", + sidebar: { width: 400 }, + layoutAlgorithm: "REMOVED_LAYOUT", + } as unknown as SchemaViewLayout; + + expect(transformSchemaViewLayout(invalid)).toStrictEqual({ + activeSidebarItem: "details", + sidebar: { width: 400 }, + layoutAlgorithm: "F_COSE", + }); + expect(logger.debug).toHaveBeenCalledWith( + '[schema-view-layout] Unrecognized layout algorithm; using "F_COSE"', + "REMOVED_LAYOUT", + ); + }); + it("maps legacy nodes-styling to styles", () => { const legacy = { activeSidebarItem: "nodes-styling", @@ -39,6 +90,7 @@ describe("transformSchemaViewLayout backward compatibility", () => { const layout: SchemaViewLayout = { activeSidebarItem: "details", sidebar: { width: 400 }, + layoutAlgorithm: "F_COSE", }; expect(transformSchemaViewLayout(layout)).toBe(layout); @@ -48,6 +100,7 @@ describe("transformSchemaViewLayout backward compatibility", () => { const layout: SchemaViewLayout = { activeSidebarItem: null, sidebar: { width: 400 }, + layoutAlgorithm: "F_COSE", }; expect(transformSchemaViewLayout(layout)).toBe(layout); diff --git a/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.ts b/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.ts index f167b890db..444ffece2d 100644 --- a/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.ts +++ b/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.ts @@ -1,3 +1,10 @@ +import { + DEFAULT_GRAPH_LAYOUT, + isLayoutName, + type LayoutName, +} from "@/core/graphLayout"; +import { logger } from "@/utils"; + import { DEFAULT_SIDEBAR_WIDTH, transformLegacySidebarItem, @@ -12,6 +19,7 @@ export type SchemaViewLayout = { activeSidebarItem: SchemaViewSidebarItem | null; sidebar: { width: number }; detailsAutoOpenOnSelection?: boolean; + layoutAlgorithm: LayoutName; }; /** Initial layout state used when no persisted layout exists. */ @@ -19,6 +27,7 @@ export const defaultSchemaViewLayout: SchemaViewLayout = { activeSidebarItem: "details", sidebar: { width: DEFAULT_SIDEBAR_WIDTH }, detailsAutoOpenOnSelection: true, + layoutAlgorithm: DEFAULT_GRAPH_LAYOUT, }; /** Normalizes a persisted schema view layout from an older app version. */ @@ -28,7 +37,19 @@ export function transformSchemaViewLayout( const activeSidebarItem = transformLegacySidebarItem( layout.activeSidebarItem, ); - return activeSidebarItem === layout.activeSidebarItem + const layoutAlgorithm = resolveLayoutAlgorithm(layout.layoutAlgorithm); + return activeSidebarItem === layout.activeSidebarItem && + layoutAlgorithm === layout.layoutAlgorithm ? layout - : { ...layout, activeSidebarItem }; + : { ...layout, activeSidebarItem, layoutAlgorithm }; +} + +function resolveLayoutAlgorithm(value: unknown): LayoutName { + if (value == null) return DEFAULT_GRAPH_LAYOUT; + if (isLayoutName(value)) return value; + logger.debug( + `[schema-view-layout] Unrecognized layout algorithm; using "${DEFAULT_GRAPH_LAYOUT}"`, + value, + ); + return DEFAULT_GRAPH_LAYOUT; } diff --git a/packages/graph-explorer/src/core/StateProvider/storageAtoms.ts b/packages/graph-explorer/src/core/StateProvider/storageAtoms.ts index 433c489b49..74baf6340b 100644 --- a/packages/graph-explorer/src/core/StateProvider/storageAtoms.ts +++ b/packages/graph-explorer/src/core/StateProvider/storageAtoms.ts @@ -3,12 +3,15 @@ import type { RawConfiguration, } from "../ConfigurationProvider"; import type { EdgeType, VertexType } from "../entities"; -import type { GraphSessionStorageModel } from "./graphSession/storage"; import type { EdgeStyleStorage, VertexStyleStorage } from "./graphStyles"; import type { SchemaStorageModel } from "./schema"; import { createActiveConfigurationAtom } from "./activeConnectionStorage"; import { atomWithLocalForage, reconcileMapByKey } from "./atomWithLocalForage"; +import { + type GraphSessionStorageModel, + transformGraphSessions, +} from "./graphSession/storage"; import { defaultGraphViewLayout, transformGraphViewLayout, @@ -109,7 +112,7 @@ const [ atomWithLocalForage>( "graph-sessions", new Map(), - { reconcile: reconcileMapByKey }, + { reconcile: reconcileMapByKey, transform: transformGraphSessions }, ), /* * General App Settings diff --git a/packages/graph-explorer/src/core/graphLayout.test.ts b/packages/graph-explorer/src/core/graphLayout.test.ts new file mode 100644 index 0000000000..bc332b9796 --- /dev/null +++ b/packages/graph-explorer/src/core/graphLayout.test.ts @@ -0,0 +1,15 @@ +import { DEFAULT_GRAPH_LAYOUT, isLayoutName, layoutNames } from "./graphLayout"; + +describe("graph layout vocabulary", () => { + it("defines F_COSE as the default layout", () => { + expect(DEFAULT_GRAPH_LAYOUT).toBe("F_COSE"); + }); + + it("recognizes every supported layout name", () => { + expect(layoutNames.every(isLayoutName)).toBe(true); + }); + + it("rejects an unsupported layout name", () => { + expect(isLayoutName("UNSUPPORTED_LAYOUT")).toBe(false); + }); +}); diff --git a/packages/graph-explorer/src/core/graphLayout.ts b/packages/graph-explorer/src/core/graphLayout.ts new file mode 100644 index 0000000000..07481613b9 --- /dev/null +++ b/packages/graph-explorer/src/core/graphLayout.ts @@ -0,0 +1,25 @@ +export const layoutNames = [ + "CONCENTRIC", + "DAGRE_TB", + "DAGRE_BT", + "DAGRE_LR", + "DAGRE_RL", + "F_COSE", + "D3", + "KLAY_LR", + "KLAY_TB", + "SUBWAY_TB", + "SUBWAY_BT", + "SUBWAY_LR", + "SUBWAY_RL", +] as const; + +export type LayoutName = (typeof layoutNames)[number]; + +export const DEFAULT_GRAPH_LAYOUT: LayoutName = "F_COSE"; + +const layoutNameSet = new Set(layoutNames); + +export function isLayoutName(value: unknown): value is LayoutName { + return typeof value === "string" && layoutNameSet.has(value); +} diff --git a/packages/graph-explorer/src/core/index.ts b/packages/graph-explorer/src/core/index.ts index 3ecb1beb52..4c6f1f9a9a 100644 --- a/packages/graph-explorer/src/core/index.ts +++ b/packages/graph-explorer/src/core/index.ts @@ -2,3 +2,4 @@ export * from "./ConfigurationProvider"; export * from "./StateProvider"; export * from "./connector"; export * from "./entities"; +export * from "./graphLayout"; diff --git a/packages/graph-explorer/src/hooks/useAddToGraph.test.ts b/packages/graph-explorer/src/hooks/useAddToGraph.test.ts index 413277a44b..2381872426 100644 --- a/packages/graph-explorer/src/hooks/useAddToGraph.test.ts +++ b/packages/graph-explorer/src/hooks/useAddToGraph.test.ts @@ -291,6 +291,7 @@ test("should update graph storage when adding a node", async () => { const expectedGraph: GraphSessionStorageModel = { vertices: new Set([vertex.id]), edges: new Set(), + layout: "F_COSE", }; expect(result.current.graph).toStrictEqual(expectedGraph); @@ -317,6 +318,7 @@ test("should update graph storage when adding an edge", async () => { const expectedGraph: GraphSessionStorageModel = { vertices: new Set([node1.id, node2.id]), edges: new Set([edge.id]), + layout: "F_COSE", }; expect(result.current.graph).toStrictEqual(expectedGraph); @@ -439,7 +441,25 @@ test("should ignore blank nodes when updating graph storage", async () => { const expectedGraph: GraphSessionStorageModel = { vertices: new Set([vertex.id]), edges: new Set(), + layout: "F_COSE", }; expect(result.current.graph).toStrictEqual(expectedGraph); }); + +test("does not create an empty session when adding only blank nodes", async () => { + const dbState = new DbState(); + + const blankNode = createRandomVertexForRdf(); + blankNode.isBlankNode = true; + + const { result } = renderHookWithState(() => { + const callback = useAddToGraph(); + const graph = useAtomValue(activeGraphSessionAtom); + return { callback, graph }; + }, dbState); + + await act(() => result.current.callback({ vertices: [blankNode] })); + + expect(result.current.graph).toBeNull(); +}); diff --git a/packages/graph-explorer/src/hooks/useAddToGraph.ts b/packages/graph-explorer/src/hooks/useAddToGraph.ts index 7952963e9f..2705f92e48 100644 --- a/packages/graph-explorer/src/hooks/useAddToGraph.ts +++ b/packages/graph-explorer/src/hooks/useAddToGraph.ts @@ -1,19 +1,10 @@ import { useMutation } from "@tanstack/react-query"; -import { useSetAtom } from "jotai"; -import { useAtomCallback } from "jotai/utils"; -import { useCallback } from "react"; import { toast } from "sonner"; import { - activeSchemaSelector, - createVertexTypeLookup, type Edge, - edgesAtom, type Entities, - nodesAtom, - toEdgeMap, - toNodeMap, - updateSchemaFromEntities, + usePopulateGraph, useUpdateGraphSession, type Vertex, } from "@/core"; @@ -22,61 +13,13 @@ import { createDisplayError } from "@/utils/createDisplayError"; /** Returns a callback that adds an array of nodes and edges to the graph. */ export function useAddToGraph() { - const setVertices = useSetAtom(nodesAtom); - const setEdges = useSetAtom(edgesAtom); - const setActiveSchema = useSetAtom(activeSchemaSelector); + const populateGraph = usePopulateGraph(); const updateGraphStorage = useUpdateGraphSession(); - const getCanvasVertices = useAtomCallback( - useCallback(get => get(nodesAtom), []), - ); - - // async is required because useMutation expects a Promise return type - // oxlint-disable-next-line @typescript-eslint/require-await - return async (entities: Partial) => { - const newVerticesMap = toNodeMap(entities.vertices ?? []); - const newEdgesMap = toEdgeMap(entities.edges ?? []); - - // Ensure there is something to add - if (newVerticesMap.size === 0 && newEdgesMap.size === 0) { - return; - } - - // Build vertex lookup from batch + canvas before modifying state - // Batch vertices take priority over canvas vertices - const vertexLookup = createVertexTypeLookup( - newVerticesMap, - getCanvasVertices(), - ); - - // Add new vertices to the graph - if (newVerticesMap.size > 0) { - logger.debug("Adding vertices to graph", newVerticesMap); - setVertices(prev => new Map([...prev, ...newVerticesMap])); - } - - // Add new edges to the graph - if (newEdgesMap.size > 0) { - logger.debug("Adding edges to graph", newEdgesMap); - setEdges(prev => new Map([...prev, ...newEdgesMap])); - } - - // Update the schema with any new vertex or edge types or attributes - setActiveSchema(prev => { - if (!prev) { - return prev; - } - return updateSchemaFromEntities( - { - vertices: newVerticesMap.values().toArray(), - edges: newEdgesMap.values().toArray(), - }, - prev, - vertexLookup, - ); - }); - + return (entities: Partial) => { + populateGraph(entities); updateGraphStorage(); + return Promise.resolve(); }; } diff --git a/packages/graph-explorer/src/hooks/useRemoveFromGraph.test.ts b/packages/graph-explorer/src/hooks/useRemoveFromGraph.test.ts index 07bc51429a..f35ab3903f 100644 --- a/packages/graph-explorer/src/hooks/useRemoveFromGraph.test.ts +++ b/packages/graph-explorer/src/hooks/useRemoveFromGraph.test.ts @@ -21,6 +21,7 @@ import { import { createRandomEdge, createRandomVertex, + createRandomVertexForRdf, DbState, renderHookWithJotai, } from "@/utils/testing"; @@ -227,6 +228,18 @@ test("should update graph session", async () => { dbState.addVertexToGraph(node2); dbState.addEdgeToGraph(edge1); dbState.addEdgeToGraph(edge2); + dbState.withGraphSession({ + vertices: new Set([node1.id, node2.id]), + edges: new Set([edge1.id, edge2.id]), + layout: "F_COSE", + arrangement: { + positions: [ + { id: node1.id, x: 10, y: 20 }, + { id: node2.id, x: 30, y: 40 }, + ], + viewport: { pan: { x: 50, y: 60 }, zoom: 2 }, + }, + }); const { result } = renderHookWithJotai( () => { @@ -248,9 +261,44 @@ test("should update graph session", async () => { const expected: GraphSessionStorageModel = { vertices: new Set([node2.id]), edges: new Set(), + layout: "F_COSE", + arrangement: { + positions: [{ id: node2.id, x: 30, y: 40 }], + viewport: { pan: { x: 50, y: 60 }, zoom: 2 }, + }, }; await waitFor(() => { expect(result.current.graph).toEqual(expected); }); }); + +test("deletes the active session when only blank nodes remain", async () => { + const dbState = new DbState(); + + const nonBlank = createRandomVertexForRdf(); + const blankNode = createRandomVertexForRdf(); + blankNode.isBlankNode = true; + const edge = createRandomEdge(nonBlank, blankNode); + + dbState.addVertexToGraph(nonBlank); + dbState.addVertexToGraph(blankNode); + dbState.addEdgeToGraph(edge); + + const { result } = renderHookWithJotai( + () => { + const callback = useRemoveFromGraph(); + const graph = useAtomValue(activeGraphSessionAtom); + return { callback, graph }; + }, + store => { + dbState.applyTo(store); + }, + ); + + act(() => result.current.callback({ vertices: [nonBlank.id] })); + + await waitFor(() => { + expect(result.current.graph).toBeNull(); + }); +}); diff --git a/packages/graph-explorer/src/modules/GraphViewer/ExportGraphButton.tsx b/packages/graph-explorer/src/modules/GraphViewer/ExportGraphButton.tsx index 9f6095dbc8..4441690514 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/ExportGraphButton.tsx +++ b/packages/graph-explorer/src/modules/GraphViewer/ExportGraphButton.tsx @@ -2,10 +2,18 @@ import { useAtomValue } from "jotai"; import { SaveIcon } from "lucide-react"; import { Button } from "@/components"; -import { edgesAtom, nodesAtom, useConfiguration, useExplorer } from "@/core"; +import { useGraphRef } from "@/components/Graph/GraphContext"; +import { + edgesAtom, + graphViewLayoutAlgorithmAtom, + nodesAtom, + useConfiguration, + useExplorer, +} from "@/core"; import { saveFile, toJsonFileData } from "@/utils/fileData"; import { createDefaultFileName, createExportedGraph } from "./exportedGraph"; +import { captureGraphArrangement } from "./graphArrangement"; export function ExportGraphButton() { const exportGraph = useExportGraph(); @@ -27,12 +35,22 @@ export function useExportGraph() { const edgeIds = useAtomValue(edgesAtom).keys().toArray(); const connection = useExplorer().connection; const config = useConfiguration(); + const layout = useAtomValue(graphViewLayoutAlgorithmAtom); + const graphRef = useGraphRef(); const exportGraph = async () => { const fileName = createDefaultFileName( config?.displayLabel ?? "Connection", ); - const exportData = createExportedGraph(vertexIds, edgeIds, connection); + const exportData = createExportedGraph( + vertexIds, + edgeIds, + connection, + layout, + graphRef.current?.cytoscape + ? captureGraphArrangement(graphRef.current.cytoscape, vertexIds) + : undefined, + ); const fileToSave = toJsonFileData(exportData); await saveFile(fileToSave, fileName); }; diff --git a/packages/graph-explorer/src/modules/GraphViewer/GraphViewer.test.tsx b/packages/graph-explorer/src/modules/GraphViewer/GraphViewer.test.tsx new file mode 100644 index 0000000000..4ddac50381 --- /dev/null +++ b/packages/graph-explorer/src/modules/GraphViewer/GraphViewer.test.tsx @@ -0,0 +1,404 @@ +// @vitest-environment happy-dom +import { render, waitFor } from "@testing-library/react"; +import { act } from "react"; +import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; + +import type { Edge, Vertex } from "@/core/entities"; + +import { TooltipProvider } from "@/components/Tooltip"; +import { + activeConfigurationAtom, + activeGraphSessionAtom, + allGraphSessionsAtom, + configurationAtom, + createPendingGraphRestoration, + getAppStore, + graphViewLayoutAlgorithmAtom, + pendingGraphRestorationAtom, + schemaAtom, + type GraphArrangement, + type GraphSessionStorageModel, +} from "@/core"; +import { createQueryClient } from "@/core/queryClient"; +import { useRestoreGraphSession } from "@/core/StateProvider/graphSession/useRestoreGraphSession"; +import { TestProvider } from "@/utils/testing"; +import { + createRandomEdge, + createRandomRawConfiguration, + createRandomVertex, + DbState, + FakeExplorer, + renderHookWithState, +} from "@/utils/testing"; + +import GraphViewer from "./GraphViewer"; + +function createMockContext() { + return new Proxy({} as Record, { + get(target, prop) { + if (prop in target) { + return target[prop as string]; + } + if (prop === "measureText") { + return () => ({ + width: 0, + actualBoundingBoxLeft: 0, + actualBoundingBoxRight: 0, + }); + } + if (prop === "getImageData") { + return () => ({ data: new Uint8ClampedArray(4) }); + } + if (prop === "createLinearGradient" || prop === "createRadialGradient") { + return () => ({ addColorStop: () => {} }); + } + if (prop === "createPattern") { + return () => null; + } + return () => {}; + }, + set(target, prop, value) { + target[prop as string] = value; + return true; + }, + }); +} + +let originalGetContext: typeof HTMLCanvasElement.prototype.getContext; +let originalOffscreenCanvas: unknown; +let originalGetComputedStyle: typeof window.getComputedStyle; +let originalGetBoundingClientRect: typeof Element.prototype.getBoundingClientRect; + +beforeAll(() => { + originalOffscreenCanvas = (globalThis as any).OffscreenCanvas; + (globalThis as any).OffscreenCanvas = undefined; + + originalGetContext = HTMLCanvasElement.prototype.getContext; + HTMLCanvasElement.prototype.getContext = function ( + type: string, + ...args: any[] + ) { + if (type === "2d") { + return createMockContext() as any; + } + return originalGetContext.call(this, type, ...args); + }; + + originalGetComputedStyle = window.getComputedStyle; + window.getComputedStyle = vi.fn((_el: Element) => { + const prop = (name: string) => { + if (name === "width") return "600px"; + if (name === "height") return "400px"; + if (name.includes("padding")) return "0px"; + return ""; + }; + return { + getPropertyValue: prop, + width: "600px", + height: "400px", + } as any; + }); + + const boundingBox = { + width: 600, + height: 400, + top: 0, + left: 0, + right: 600, + bottom: 400, + x: 0, + y: 0, + toJSON: () => boundingBox, + }; + originalGetBoundingClientRect = Element.prototype.getBoundingClientRect; + Element.prototype.getBoundingClientRect = vi.fn(() => boundingBox) as any; +}); + +afterAll(() => { + (globalThis as any).OffscreenCanvas = originalOffscreenCanvas; + HTMLCanvasElement.prototype.getContext = originalGetContext; + window.getComputedStyle = originalGetComputedStyle; + Element.prototype.getBoundingClientRect = originalGetBoundingClientRect; +}); + +function renderGraphViewer(state: DbState) { + const store = getAppStore(); + state.applyTo(store); + return renderGraphViewerIntoStore(store); +} + +function renderGraphViewerIntoStore(store: ReturnType) { + const client = createQueryClient(); + + const container = document.createElement("div"); + container.style.width = "600px"; + container.style.height = "400px"; + Object.defineProperty(container, "clientWidth", { + value: 600, + configurable: true, + }); + Object.defineProperty(container, "clientHeight", { + value: 400, + configurable: true, + }); + document.body.appendChild(container); + + const result = render( + + + + + , + { container }, + ); + + return { store, ...result }; +} + +function createArrangement( + vertices: Vertex[], + overrides: Partial = {}, +): GraphArrangement { + return { + positions: vertices.map(v => ({ id: v.id, x: 123, y: 456 })), + viewport: { pan: { x: 7, y: 8 }, zoom: 2 }, + ...overrides, + }; +} + +function withLayoutAndSession( + state: DbState, + vertices: Vertex[], + edges: Edge[], + arrangement?: GraphArrangement, +) { + state.vertices = vertices; + state.edges = edges; + state.withGraphSession({ + vertices: new Set(vertices.map(v => v.id)), + edges: new Set(edges.map(e => e.id)), + layout: "DAGRE_TB", + arrangement, + }); + return state; +} + +describe("GraphViewer session integration", () => { + test("updates an existing nonempty active session with the layout result", async () => { + const vertex = createRandomVertex(); + const state = new DbState(); + withLayoutAndSession(state, [vertex], []); + + const { store } = renderGraphViewer(state); + act(() => store.set(graphViewLayoutAlgorithmAtom, "DAGRE_TB")); + + await waitFor( + () => { + const session = store.get(activeGraphSessionAtom); + expect(session?.arrangement).toBeDefined(); + expect(session?.arrangement?.positions).toHaveLength(1); + }, + { timeout: 5000 }, + ); + }); + + test("preserves a saved arrangement after the graph view remounts", async () => { + const vertices = [createRandomVertex(), createRandomVertex()]; + const state = new DbState(); + withLayoutAndSession(state, vertices, []); + + const { store, unmount } = renderGraphViewer(state); + await waitFor( + () => + expect(store.get(activeGraphSessionAtom)?.arrangement).toBeDefined(), + { timeout: 5000 }, + ); + + const savedArrangement = createArrangement(vertices, { + positions: [ + { id: vertices[0].id, x: 321, y: 654 }, + { id: vertices[1].id, x: 987, y: 123 }, + ], + }); + act(() => { + const session = store.get(activeGraphSessionAtom)!; + store.set(activeGraphSessionAtom, { + ...session, + arrangement: savedArrangement, + }); + }); + + unmount(); + renderGraphViewerIntoStore(store); + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 1000)); + }); + + expect(store.get(activeGraphSessionAtom)?.arrangement).toStrictEqual( + savedArrangement, + ); + }); + + test("does not create a session when none exists", async () => { + const vertex = createRandomVertex(); + const state = new DbState(); + state.vertices = [vertex]; + state.edges = []; + + const { store } = renderGraphViewer(state); + act(() => { + store.set(allGraphSessionsAtom, new Map()); + store.set(graphViewLayoutAlgorithmAtom, "DAGRE_TB"); + }); + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 300)); + }); + + expect(store.get(allGraphSessionsAtom).size).toBe(0); + expect(store.get(activeGraphSessionAtom)).toBeNull(); + }); + + test("does not revive an empty active session", async () => { + const vertex = createRandomVertex(); + const state = new DbState(); + state.vertices = [vertex]; + state.edges = []; + state.withGraphSession({ + vertices: new Set(), + edges: new Set(), + layout: "DAGRE_TB", + }); + + const { store } = renderGraphViewer(state); + act(() => store.set(graphViewLayoutAlgorithmAtom, "DAGRE_TB")); + + await act(async () => { + await new Promise(resolve => setTimeout(resolve, 300)); + }); + + const session = store.get(activeGraphSessionAtom); + expect(session).toBeDefined(); + expect(session?.arrangement).toBeUndefined(); + }); + + test("consumes a matching pending restoration and preserves exact coordinates", async () => { + const vertex = createRandomVertex(); + const state = new DbState(); + const arrangement = createArrangement([vertex]); + withLayoutAndSession(state, [vertex], [], arrangement); + const { store } = renderGraphViewer(state); + act(() => { + store.set( + pendingGraphRestorationAtom, + createPendingGraphRestoration(state.activeConfig.id, arrangement), + ); + store.set(graphViewLayoutAlgorithmAtom, "DAGRE_TB"); + }); + + await waitFor( + () => expect(store.get(pendingGraphRestorationAtom)).toBeNull(), + { timeout: 5000 }, + ); + + const session = store.get(activeGraphSessionAtom); + expect(session?.arrangement).toEqual(arrangement); + }); + + test("a stale ConfigurationId restoration cannot apply after connection switch", async () => { + const vertex = createRandomVertex(); + const originalConfig = createRandomRawConfiguration(); + const activeConfig = createRandomRawConfiguration(); + + const state = new DbState(); + state.vertices = [vertex]; + state.edges = []; + state.activeConfig = activeConfig; + state.withGraphSession({ + vertices: new Set([vertex.id]), + edges: new Set(), + layout: "DAGRE_TB", + }); + + const { store } = renderGraphViewer(state); + act(() => { + store.set( + configurationAtom, + new Map([ + [originalConfig.id, originalConfig], + [activeConfig.id, activeConfig], + ]), + ); + store.set( + schemaAtom, + new Map([ + [originalConfig.id, state.activeSchema], + [activeConfig.id, state.activeSchema], + ]), + ); + store.set( + allGraphSessionsAtom, + new Map([[activeConfig.id, store.get(activeGraphSessionAtom)!]]), + ); + store.set(activeConfigurationAtom, activeConfig.id); + store.set( + pendingGraphRestorationAtom, + createPendingGraphRestoration( + originalConfig.id, + createArrangement([vertex]), + ), + ); + store.set(graphViewLayoutAlgorithmAtom, "DAGRE_TB"); + }); + + await waitFor( + () => { + const session = store.get(activeGraphSessionAtom); + expect(session?.arrangement).toBeDefined(); + }, + { timeout: 5000 }, + ); + + expect(store.get(pendingGraphRestorationAtom)).not.toBeNull(); + }); +}); + +describe("GraphViewer refresh/session exact-coordinate regression", () => { + test("restore from FakeExplorer preserves exact coordinates through GraphViewer", async () => { + const explorer = new FakeExplorer(); + const vertex = createRandomVertex(); + const edge = createRandomEdge(vertex, vertex); + explorer.addVertex(vertex); + explorer.addEdge(edge); + + const state = new DbState(explorer); + state.activeConfig = createRandomRawConfiguration(); + + const arrangement = createArrangement([vertex]); + const session: GraphSessionStorageModel = { + vertices: new Set([vertex.id]), + edges: new Set([edge.id]), + layout: "DAGRE_TB", + arrangement, + }; + + const { result } = renderHookWithState( + () => useRestoreGraphSession(), + state, + ); + + await act(async () => { + await result.current.mutateAsync(session); + }); + + const store = getAppStore(); + renderGraphViewerIntoStore(store); + + await waitFor( + () => expect(store.get(pendingGraphRestorationAtom)).toBeNull(), + { timeout: 5000 }, + ); + + expect(store.get(activeGraphSessionAtom)?.arrangement).toEqual(arrangement); + }); +}); diff --git a/packages/graph-explorer/src/modules/GraphViewer/GraphViewer.tsx b/packages/graph-explorer/src/modules/GraphViewer/GraphViewer.tsx index fe3b95e137..651f78981f 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/GraphViewer.tsx +++ b/packages/graph-explorer/src/modules/GraphViewer/GraphViewer.tsx @@ -1,4 +1,6 @@ -import { atom, useAtomValue } from "jotai"; +import type { Core } from "cytoscape"; + +import { useAtomValue, useSetAtom } from "jotai"; import { BadgeInfoIcon } from "lucide-react"; import { Activity, @@ -25,7 +27,6 @@ import { DownloadScreenshotButton, Graph, GraphProvider, - type LayoutName, RerunLayoutButton, type SelectedElements, SelectLayout, @@ -34,14 +35,22 @@ import { ZoomToFitButton, } from "@/components/Graph"; import { + allGraphSessionsAtom, + createPendingGraphRestoration, createRenderedEdgeId, createRenderedVertexId, getEdgeIdFromRenderedEdgeId, getVertexIdFromRenderedVertexId, + graphViewLayoutAlgorithmAtom, + getGraphRestorationForTarget, + pendingGraphRestorationAtom, + type ConfigurationId, type RenderedEdgeId, type RenderedVertex, type RenderedVertexId, + useConfiguration, useDisplayVertexTypeConfigs, + useSaveGraphArrangement, useRenderedEdges, useRenderedVertices, } from "@/core"; @@ -52,6 +61,7 @@ import { useDefaultNeighborExpansionLimit } from "@/hooks/useExpandNode"; import { cn, isVisible } from "@/utils"; import { ExportGraphButton } from "./ExportGraphButton"; +import { captureGraphArrangement } from "./graphArrangement"; import { GraphViewerEmptyState } from "./GraphViewerEmptyState"; import { ImportGraphButton } from "./ImportGraphButton"; import ContextMenu from "./internalComponents/ContextMenu"; @@ -60,8 +70,6 @@ import { useGraphSelection } from "./useGraphSelection"; import useGraphStyles from "./useGraphStyles"; import useNodeBadges from "./useNodeBadges"; -const graphLayoutSelectionAtom = atom("F_COSE"); - // Prevent open context menu on Windows function onContextMenu(e: MouseEvent) { e.preventDefault(); @@ -72,9 +80,11 @@ export default function GraphViewer({ className, ...props }: Omit, "children" | "onContextMenu">) { + const config = useConfiguration(); + return ( - + ); } @@ -146,10 +156,50 @@ function GraphViewerContent({ }); }; - const layout = useAtomValue(graphLayoutSelectionAtom); + const layout = useAtomValue(graphViewLayoutAlgorithmAtom); const nodes = useRenderedVertices(); const edges = useRenderedEdges(); + const config = useConfiguration(); + const sessions = useAtomValue(allGraphSessionsAtom); + const pendingRestoration = useAtomValue(pendingGraphRestorationAtom); + const setPendingRestoration = useSetAtom(pendingGraphRestorationAtom); + const [mountedRestoration, setMountedRestoration] = useState(() => { + const arrangement = config + ? sessions.get(config.id)?.arrangement + : undefined; + return config && arrangement + ? createPendingGraphRestoration(config.id, arrangement) + : null; + }); + const targetedRestoration = + getGraphRestorationForTarget(pendingRestoration, config?.id) ?? + getGraphRestorationForTarget(mountedRestoration, config?.id); + const restoration = targetedRestoration + ? { + ...targetedRestoration, + positions: targetedRestoration.positions.map(position => ({ + ...position, + id: createRenderedVertexId(position.id), + })), + } + : undefined; + + const saveGraphArrangement = useSaveGraphArrangement(); + function saveArrangement(cy: Core, target?: ConfigurationId) { + if (!target) return; + const targetSession = sessions.get(target); + if (!targetSession || targetSession.vertices.size === 0) return; + saveGraphArrangement( + target, + targetSession, + captureGraphArrangement( + cy, + targetSession.vertices, + targetSession.arrangement, + ), + ); + } const isEmpty = !nodes.length && !edges.length; @@ -161,7 +211,7 @@ function GraphViewerContent({ @@ -201,6 +251,15 @@ function GraphViewerContent({ onGraphRightClick={onGraphRightClick} styles={styles} layout={layout} + connectionId={config?.id} + restoration={restoration} + onRestorationConsumed={revision => { + if (pendingRestoration?.revision === revision) { + setMountedRestoration(null); + setPendingRestoration(null); + } + }} + onArrangementChanged={saveArrangement} className="col-start-1 row-start-1 min-h-0 min-w-0" onContextMenu={onContextMenu} /> diff --git a/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.test.tsx b/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.test.tsx index e6aeab7eff..5e33a2d3a4 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.test.tsx +++ b/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.test.tsx @@ -1,18 +1,200 @@ // @vitest-environment happy-dom import { createArray } from "@shared/utils/testing"; +import { useAtom, useAtomValue } from "jotai"; +import { act } from "react"; import { ZodError } from "zod"; +import { + activeGraphSessionAtom, + graphViewLayoutAlgorithmAtom, + pendingGraphRestorationAtom, +} from "@/core"; import { createRandomExportedGraphConnection, createRandomFile, createRandomRawConfiguration, + createRandomVertex, + DbState, + FakeExplorer, + renderHookWithState, } from "@/utils/testing"; +import { createExportedGraph } from "./exportedGraph"; import { createErrorNotification, InvalidConnectionError, + useImportGraphMutation, } from "./ImportGraphButton"; +function importFile(data: object) { + return new File([JSON.stringify(data)], "graph.json", { + type: "application/json", + }); +} + +function setupImport() { + const explorer = new FakeExplorer(); + const vertex = createRandomVertex(); + explorer.addVertex(vertex); + const state = new DbState(explorer); + const { result } = renderHookWithState(() => { + const mutation = useImportGraphMutation(); + const [layout, setLayout] = useAtom(graphViewLayoutAlgorithmAtom); + const [restoration, setRestoration] = useAtom(pendingGraphRestorationAtom); + const session = useAtomValue(activeGraphSessionAtom); + return { + mutation, + layout, + setLayout, + restoration, + setRestoration, + session, + }; + }, state); + act(() => result.current.setLayout("KLAY_LR")); + return { explorer, vertex, result }; +} + +describe("useImportGraphMutation", () => { + it("applies a valid layout after successful entity restoration", async () => { + const { explorer, vertex, result } = setupImport(); + const exported = createExportedGraph( + [vertex.id], + [], + explorer.connection, + "DAGRE_TB", + ); + + await act(() => result.current.mutation.mutateAsync(importFile(exported))); + + expect(result.current.layout).toBe("DAGRE_TB"); + }); + + it("installs a target-scoped arrangement after successful entity restoration", async () => { + const { explorer, vertex, result } = setupImport(); + const arrangement = { + positions: [{ id: vertex.id, x: 12, y: 34 }], + viewport: { pan: { x: 56, y: 78 }, zoom: 2 }, + }; + const exported = createExportedGraph( + [vertex.id], + [], + explorer.connection, + "F_COSE", + arrangement, + ); + + await act(() => result.current.mutation.mutateAsync(importFile(exported))); + + expect(result.current.restoration).toMatchObject(arrangement); + expect(result.current.restoration?.target).toBeDefined(); + }); + + it("commits a coherent session with actual restored entities and arrangement for matches", async () => { + const { explorer, vertex, result } = setupImport(); + const missing = createRandomVertex(); + const arrangement = { + positions: [ + { id: vertex.id, x: 12, y: 34 }, + { id: missing.id, x: 56, y: 78 }, + ], + viewport: { pan: { x: 1, y: 2 }, zoom: 3 }, + }; + const exported = createExportedGraph( + [vertex.id, missing.id], + [], + explorer.connection, + "DAGRE_TB", + arrangement, + ); + + await act(() => result.current.mutation.mutateAsync(importFile(exported))); + + expect(result.current.session?.vertices.size).toBe(1); + expect(result.current.session?.vertices.has(vertex.id)).toBe(true); + expect(result.current.session?.arrangement?.positions).toHaveLength(1); + expect(result.current.session?.arrangement?.positions[0].id).toBe( + vertex.id, + ); + }); + + it("does not replace pending restoration when entity restoration fails", async () => { + const { explorer, vertex, result } = setupImport(); + const exported = createExportedGraph( + [vertex.id], + [], + explorer.connection, + "F_COSE", + { positions: [{ id: vertex.id, x: 12, y: 34 }] }, + ); + vi.spyOn(explorer, "vertexDetails").mockRejectedValue( + new Error("restore failed"), + ); + + await act(async () => { + await expect( + result.current.mutation.mutateAsync(importFile(exported)), + ).rejects.toThrow(new Error("restore failed")); + }); + + expect(result.current.restoration).toBeNull(); + }); + + it("preserves the live layout for a legacy export", async () => { + const { explorer, vertex, result } = setupImport(); + const exported = createExportedGraph( + [vertex.id], + [], + explorer.connection, + "DAGRE_TB", + ); + delete exported.data.layout; + + await act(() => result.current.mutation.mutateAsync(importFile(exported))); + + expect(result.current.layout).toBe("KLAY_LR"); + }); + + it("preserves the live layout when the connection does not match", async () => { + const { vertex, result } = setupImport(); + const exported = createExportedGraph( + [vertex.id], + [], + new FakeExplorer().connection, + "DAGRE_TB", + ); + + await act(async () => { + await expect( + result.current.mutation.mutateAsync(importFile(exported)), + ).rejects.toBeInstanceOf(InvalidConnectionError); + }); + + expect(result.current.layout).toBe("KLAY_LR"); + }); + + it("preserves the live layout when entity restoration fails", async () => { + const { explorer, vertex, result } = setupImport(); + const exported = createExportedGraph( + [vertex.id], + [], + explorer.connection, + "DAGRE_TB", + ); + vi.spyOn(explorer, "vertexDetails").mockRejectedValue( + new Error("restore failed"), + ); + + await act(async () => { + await expect( + result.current.mutation.mutateAsync(importFile(exported)), + ).rejects.toThrow(new Error("restore failed")); + }); + + expect(result.current.layout).toBe("KLAY_LR"); + }); +}); + describe("createErrorNotification", () => { it("should use generic error for an unrecognized error", () => { const error = new Error("test"); diff --git a/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.tsx b/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.tsx index ae9314578e..238dd5b188 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.tsx +++ b/packages/graph-explorer/src/modules/GraphViewer/ImportGraphButton.tsx @@ -1,14 +1,28 @@ import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useAtomValue } from "jotai"; +import { useAtomCallback } from "jotai/utils"; import { FolderOpenIcon } from "lucide-react"; +import { useCallback } from "react"; import { toast } from "sonner"; import { ZodError } from "zod"; import { Button, FileButton, Spinner } from "@/components"; import { fetchEntityDetails, notifyOnIncompleteRestoration } from "@/connector"; -import { configurationAtom, type ConnectionWithId, useExplorer } from "@/core"; +import { + configurationAtom, + type ConnectionWithId, + useConfiguration, + useExplorer, + usePopulateGraph, +} from "@/core"; import { FileEnvelopeError } from "@/core/fileEnvelope"; -import { useAddToGraph } from "@/hooks"; +import { + commitGraphRestoration, + graphRestorationRequestAtom, + isCurrentGraphRestoration, + startGraphRestoration, +} from "@/core/StateProvider/graphSession/restoration"; +import { resolveGraphSessionLayout } from "@/core/StateProvider/graphSession/storage"; import { useEntityCountFormatterCallback } from "@/hooks/useEntityCountFormatter"; import { getTranslation } from "@/hooks/useTranslations"; import { logger } from "@/utils"; @@ -40,10 +54,11 @@ export function ImportGraphButton() { ); } -function useImportGraphMutation() { +export function useImportGraphMutation() { const queryClient = useQueryClient(); const explorer = useExplorer(); - const addToGraph = useAddToGraph(); + const config = useConfiguration(); + const populateGraph = usePopulateGraph(); const formatEntityCounts = useEntityCountFormatterCallback(); const allConfigs = useAtomValue(configurationAtom); const allConnections = allConfigs @@ -60,47 +75,85 @@ function useImportGraphMutation() { .filter(c => c != null) .toArray(); - const mutation = useMutation({ - mutationFn: async (file: File) => { - // 1. Parse the file - const graph = await parseExportedGraph(file); - - // 2. Check connection - if (!isMatchingConnection(explorer.connection, graph.connection)) { - throw new InvalidConnectionError( - "Connection must match active connection", - graph.connection, - ); - } - - // 3. Get the vertex and edge details from the database - const entityCountMessage = formatEntityCounts( - graph.vertices.size, - graph.edges.size, - ); - - const loadPromise = (async () => { - const result = await fetchEntityDetails( - graph.vertices, - graph.edges, - queryClient, + const mutationFn = useAtomCallback( + useCallback( + async (get, set, file: File) => { + const target = config?.id; + + if (!target) { + throw new Error("No active connection to import the graph"); + } + + const graph = await parseExportedGraph(file); + + if (!isMatchingConnection(explorer.connection, graph.connection)) { + throw new InvalidConnectionError( + "Connection must match active connection", + graph.connection, + ); + } + + const token = startGraphRestoration(set, target); + + const entityCountMessage = formatEntityCounts( + graph.vertices.size, + graph.edges.size, ); - // 4. Update Graph Explorer state - await addToGraph(result.entities); + let committed = false; - return result; - })(); + const loadPromise = (async () => { + const result = await fetchEntityDetails( + graph.vertices, + graph.edges, + queryClient, + ); - toast.promise(loadPromise, { - loading: `Loading ${entityCountMessage}`, - error: "Failed to load the graph", - }); - const result = await loadPromise; - notifyOnIncompleteRestoration(result); + if (!isCurrentGraphRestoration(get, token, target)) { + return result; + } - return result; - }, + populateGraph(result.entities); + + if (!isCurrentGraphRestoration(get, token, target)) { + return result; + } + + committed = commitGraphRestoration(get, set, { + token, + target, + layout: resolveGraphSessionLayout(graph.layout), + arrangement: graph.arrangement, + }); + + return result; + })(); + + toast.promise(loadPromise, { + loading: `Loading ${entityCountMessage}`, + error: "Failed to load the graph", + }); + + try { + const result = await loadPromise; + + if (committed) { + notifyOnIncompleteRestoration(result); + } + + return result; + } finally { + if (isCurrentGraphRestoration(get, token, target)) { + set(graphRestorationRequestAtom, null); + } + } + }, + [queryClient, explorer, config, populateGraph, formatEntityCounts], + ), + ); + + const mutation = useMutation({ + mutationFn, onError: (error, file) => { const notification = createErrorNotification(error, file, allConnections); logger.error(`Loading graph failed: ${notification}`, error); diff --git a/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.test.ts b/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.test.ts index 122ebc74e6..5a0de6f08c 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.test.ts +++ b/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.test.ts @@ -8,6 +8,7 @@ import { import type { EdgeId, VertexId } from "@/core"; +import { DEFAULT_GRAPH_LAYOUT } from "@/core/graphLayout"; import { createRandomConnectionWithId, createRandomEdgeId, @@ -76,12 +77,18 @@ describe("createExportedGraph", () => { sourceVersion: appVersion, } satisfies ExportedGraphFile["meta"]; - const graph = createExportedGraph(vertexIds, edgeIds, connection); + const graph = createExportedGraph( + vertexIds, + edgeIds, + connection, + DEFAULT_GRAPH_LAYOUT, + ); expect(graph.meta).toEqual(expectedMeta); expect(graph.data.connection).toEqual(expectedConnection); expect(graph.data.vertices).toEqual(vertexIds); expect(graph.data.edges).toEqual(edgeIds); + expect(graph.data.layout).toBe(DEFAULT_GRAPH_LAYOUT); }); it("stamps the generation-1 version as the '1.0' decimal string", () => { @@ -91,7 +98,7 @@ describe("createExportedGraph", () => { // stay on the wire as the decimal string. const connection = createRandomConnectionWithId(); - const graph = createExportedGraph([], [], connection); + const graph = createExportedGraph([], [], connection, DEFAULT_GRAPH_LAYOUT); expect(graph.meta.version).toBe("1.0"); }); @@ -100,11 +107,27 @@ describe("createExportedGraph", () => { const connection = createRandomConnectionWithId(); const expectedConnection = createExportedConnection(connection); - const graph = createExportedGraph([], [], connection); + const graph = createExportedGraph([], [], connection, DEFAULT_GRAPH_LAYOUT); expect(graph.data.connection).toEqual(expectedConnection); expect(graph.data.vertices).toEqual([]); expect(graph.data.edges).toEqual([]); + expect(graph.data.layout).toBe(DEFAULT_GRAPH_LAYOUT); + }); + + it("includes the selected layout in the export payload", () => { + const vertexIds = createArray(2, () => createRandomVertexId()); + const edgeIds = createArray(2, () => createRandomEdgeId()); + const connection = createRandomConnectionWithId(); + + const graph = createExportedGraph( + vertexIds, + edgeIds, + connection, + "DAGRE_TB", + ); + + expect(graph.data.layout).toBe("DAGRE_TB"); }); it("should use current timestamp when creating graph", () => { @@ -112,7 +135,12 @@ describe("createExportedGraph", () => { const edgeIds = createArray(2, () => createRandomEdgeId()); const connection = createRandomConnectionWithId(); - const graph = createExportedGraph(vertexIds, edgeIds, connection); + const graph = createExportedGraph( + vertexIds, + edgeIds, + connection, + DEFAULT_GRAPH_LAYOUT, + ); expect(graph.meta.timestamp).toEqual(timestamp.toISOString()); }); @@ -122,7 +150,12 @@ describe("createExportedGraph", () => { const edgeIds = createArray(2, () => createRandomEdgeId()); const connection = createRandomConnectionWithId(); - const graph = createExportedGraph(vertexIds, edgeIds, connection); + const graph = createExportedGraph( + vertexIds, + edgeIds, + connection, + DEFAULT_GRAPH_LAYOUT, + ); expect(graph.meta.sourceVersion).toBe(appVersion); }); @@ -132,7 +165,12 @@ describe("createExportedGraph", () => { const edgeIds = createArray(2, () => createRandomEdgeId()); const connection = createRandomConnectionWithId(); - const graph = createExportedGraph(vertexIds, edgeIds, connection); + const graph = createExportedGraph( + vertexIds, + edgeIds, + connection, + DEFAULT_GRAPH_LAYOUT, + ); expect(graph.meta.kind).toBe("graph-export"); }); @@ -142,7 +180,12 @@ describe("createExportedGraph", () => { const edgeIds = createArray(2, () => createRandomEdgeId()); const connection = createRandomConnectionWithId(); - const graph = createExportedGraph(vertexIds, edgeIds, connection); + const graph = createExportedGraph( + vertexIds, + edgeIds, + connection, + DEFAULT_GRAPH_LAYOUT, + ); expect(graph.meta.source).toBe("Graph Explorer"); }); @@ -196,6 +239,7 @@ describe("parseExportedGraph", () => { connection: exportedGraph.data.connection, vertices: new Set(exportedGraph.data.vertices), edges: new Set(exportedGraph.data.edges), + layout: exportedGraph.data.layout, }; const parsed = await parseExportedGraph(toGraphFileBlob(exportedGraph)); expect(parsed).toEqual(expected); @@ -207,11 +251,116 @@ describe("parseExportedGraph", () => { connection: exportedGraph.data.connection, vertices: new Set(exportedGraph.data.vertices), edges: new Set(exportedGraph.data.edges), + layout: exportedGraph.data.layout, }; const parsed = await parseExportedGraph(toGraphFileBlob(exportedGraph)); expect(parsed).toEqual(expected); }); + it("round-trips exact arrangement positions and viewport with typed IDs", async () => { + const exportedGraph = createRandomExportedGraph(); + exportedGraph.data.vertices = [1, "1"]; + exportedGraph.data.arrangement = { + positions: [ + { id: 1, x: 10.25, y: -20.5 }, + { id: "1", x: 30.75, y: 40.125 }, + ], + viewport: { pan: { x: -50.5, y: 60.25 }, zoom: 1.75 }, + }; + + const parsed = await parseExportedGraph(toGraphFileBlob(exportedGraph)); + + expect(parsed.arrangement).toStrictEqual(exportedGraph.data.arrangement); + }); + + it.each([ + ["position x", { positions: [{ id: 1, x: Number.NaN, y: 0 }] }], + ["position y", { positions: [{ id: 1, x: 0, y: Infinity }] }], + [ + "pan x", + { positions: [], viewport: { pan: { x: -Infinity, y: 0 }, zoom: 1 } }, + ], + [ + "pan y", + { positions: [], viewport: { pan: { x: 0, y: Number.NaN }, zoom: 1 } }, + ], + [ + "zoom", + { positions: [], viewport: { pan: { x: 0, y: 0 }, zoom: Infinity } }, + ], + ])("rejects non-finite %s", async (_name, arrangement) => { + const exportedGraph = createRandomExportedGraph(); + exportedGraph.data.arrangement = arrangement; + + await expect( + parseExportedGraph(toGraphFileBlob(exportedGraph)), + ).rejects.toThrow(); + }); + + it("rejects duplicate same-typed arrangement IDs", async () => { + const exportedGraph = createRandomExportedGraph(); + exportedGraph.data.arrangement = { + positions: [ + { id: 1, x: 0, y: 0 }, + { id: 1, x: 1, y: 1 }, + ], + }; + + await expect( + parseExportedGraph(toGraphFileBlob(exportedGraph)), + ).rejects.toThrow(); + }); + + it("accepts numeric and string arrangement IDs with the same value", async () => { + const exportedGraph = createRandomExportedGraph(); + exportedGraph.data.arrangement = { + positions: [ + { id: 1, x: 0, y: 0 }, + { id: "1", x: 1, y: 1 }, + ], + }; + + await expect( + parseExportedGraph(toGraphFileBlob(exportedGraph)), + ).resolves.toMatchObject({ + arrangement: exportedGraph.data.arrangement, + }); + }); + + it("parses a legacy export with no arrangement", async () => { + const exportedGraph = createRandomExportedGraph(); + delete exportedGraph.data.arrangement; + + const parsed = await parseExportedGraph(toGraphFileBlob(exportedGraph)); + + expect(parsed.arrangement).toBeUndefined(); + }); + + it("parses a legacy export with no layout", async () => { + const exportedGraph = createRandomExportedGraph(); + const legacy = { + ...exportedGraph, + data: { ...exportedGraph.data }, + }; + delete legacy.data.layout; + + const parsed = await parseExportedGraph(toGraphFileBlob(legacy)); + + expect(parsed.layout).toBeUndefined(); + }); + + it("rejects an export with an unknown layout", async () => { + const exportedGraph = createRandomExportedGraph(); + const unknownLayout = { + ...exportedGraph, + data: { ...exportedGraph.data, layout: "UNKNOWN_LAYOUT" }, + }; + + await expect( + parseExportedGraph(toGraphFileBlob(unknownLayout)), + ).rejects.toThrow(); + }); + it("should skip empty IDs", async () => { const exportedGraph = createRandomExportedGraph(); exportedGraph.data.vertices.push(""); @@ -246,6 +395,9 @@ describe("parseExportedGraph", () => { const maliciousEdgeId = `${edgeName}${suffix}`; exportedGraph.data.vertices.push(maliciousVertexId); exportedGraph.data.edges.push(maliciousEdgeId); + exportedGraph.data.arrangement = { + positions: [{ id: maliciousVertexId, x: 10, y: 20 }], + }; const parsed = await parseExportedGraph(toGraphFileBlob(exportedGraph)); @@ -257,6 +409,9 @@ describe("parseExportedGraph", () => { expect( parsed.edges.has(`${edgeName}${escapedSuffix}` as EdgeId), ).toBeTruthy(); + expect(parsed.arrangement?.positions).toStrictEqual([ + { id: `${vertexName}${escapedSuffix}`, x: 10, y: 20 }, + ]); }); it("should trim leading and trailing whitespace", async () => { @@ -265,6 +420,9 @@ describe("parseExportedGraph", () => { const edgeIdWithWhitespace = ` ${createRandomName("EdgeId")} `; exportedGraph.data.vertices.push(vertexIdWithWhitespace); exportedGraph.data.edges.push(edgeIdWithWhitespace); + exportedGraph.data.arrangement = { + positions: [{ id: vertexIdWithWhitespace, x: 10, y: 20 }], + }; const parsed = await parseExportedGraph(toGraphFileBlob(exportedGraph)); @@ -276,6 +434,9 @@ describe("parseExportedGraph", () => { expect( parsed.edges.has(edgeIdWithWhitespace.trim() as EdgeId), ).toBeTruthy(); + expect(parsed.arrangement?.positions).toStrictEqual([ + { id: vertexIdWithWhitespace.trim(), x: 10, y: 20 }, + ]); }); it("should skip invalid RDF edge IDs", async () => { @@ -288,6 +449,7 @@ describe("parseExportedGraph", () => { connection: exportedGraph.data.connection, vertices: new Set(exportedGraph.data.vertices), edges: new Set(exportedGraph.data.edges.slice(0, -1)), + layout: exportedGraph.data.layout, }; const parsed = await parseExportedGraph(toGraphFileBlob(exportedGraph)); expect(parsed).toEqual(expected); @@ -302,6 +464,7 @@ describe("parseExportedGraph", () => { connection: exportedGraph.data.connection, vertices: new Set(exportedGraph.data.vertices), edges: new Set(exportedGraph.data.edges.slice(0, -1)), + layout: exportedGraph.data.layout, }; const parsed = await parseExportedGraph(toGraphFileBlob(exportedGraph)); expect(parsed).toEqual(expected); diff --git a/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.ts b/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.ts index 6280431e90..5a59af8cfc 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.ts +++ b/packages/graph-explorer/src/modules/GraphViewer/exportedGraph.ts @@ -6,6 +6,7 @@ import { import { z } from "zod"; import type { FileEnvelope } from "@/core/fileEnvelope"; +import type { GraphArrangement } from "@/core/StateProvider/graphSession"; import { parseRdfEdgeIdString } from "@/connector/sparql/parseEdgeId"; import { @@ -21,6 +22,7 @@ import { FileEnvelopeError, parseFileEnvelope, } from "@/core/fileEnvelope"; +import { layoutNames, type LayoutName } from "@/core/graphLayout"; import { logger } from "@/utils"; /** The envelope `kind` discriminator for graph export files. */ @@ -45,6 +47,39 @@ export const GRAPH_EXPORT_VERSION = 1; */ export const GRAPH_EXPORT_WIRE_VERSION: EnvelopeVersion = "1.0"; +const rawIdSchema = z.union([z.string(), z.number()]); +const finiteNumberSchema = z.number().finite(); +const graphArrangementSchema = z + .object({ + positions: z.array( + z.object({ + id: rawIdSchema, + x: finiteNumberSchema, + y: finiteNumberSchema, + }), + ), + viewport: z + .object({ + pan: z.object({ x: finiteNumberSchema, y: finiteNumberSchema }), + zoom: finiteNumberSchema, + }) + .optional(), + }) + .superRefine((arrangement, context) => { + const ids = new Set(); + arrangement.positions.forEach((position, index) => { + const key = `${typeof position.id}:${position.id}`; + if (ids.has(key)) { + context.addIssue({ + code: "custom", + message: "Duplicate node position", + path: ["positions", index, "id"], + }); + } + ids.add(key); + }); + }); + const graphExportPayloadSchema = z.object({ connection: z.object({ dbUrl: z.string(), @@ -52,6 +87,8 @@ const graphExportPayloadSchema = z.object({ }), vertices: z.array(z.union([z.string(), z.number()])), edges: z.array(z.union([z.string(), z.number()])), + layout: z.enum(layoutNames).optional(), + arrangement: graphArrangementSchema.optional(), }); export type GraphExportPayload = z.infer; @@ -63,11 +100,15 @@ export function createExportedGraph( vertexIds: VertexId[], edgeIds: EdgeId[], connection: ConnectionConfig, + layout: LayoutName, + arrangement?: GraphArrangement, ): ExportedGraphFile { return createFileEnvelope(GRAPH_EXPORT_KIND, GRAPH_EXPORT_WIRE_VERSION, { connection: createExportedConnection(connection), vertices: vertexIds, edges: edgeIds, + layout, + arrangement, }); } @@ -128,15 +169,11 @@ export async function parseExportedGraph(blob: Blob) { const connection = payload.connection; // Do some basic validation and skip any invalid IDs - const vertices = new Set( - payload.vertices - .values() - .map(trimIfString) - .filter(isNotEmptyIfString) - .filter(isNotMaliciousIfSparql(connection.queryEngine)) - .map(escapeIfPropertyGraphAndString(connection.queryEngine)) - .map(createVertexId), - ); + const vertices = new Set(); + for (const value of payload.vertices) { + const id = normalizeVertexId(value, connection.queryEngine); + if (id != null) vertices.add(id); + } // Do some basic validation and skip any invalid IDs const edges = new Set( @@ -149,7 +186,23 @@ export async function parseExportedGraph(blob: Blob) { .map(createEdgeId), ); - return { connection, vertices, edges }; + const arrangement = payload.arrangement + ? { + ...payload.arrangement, + positions: payload.arrangement.positions.flatMap(position => { + const id = normalizeVertexId(position.id, connection.queryEngine); + return id != null ? [{ ...position, id }] : []; + }), + } + : undefined; + + return { + connection, + vertices, + edges, + layout: payload.layout, + arrangement, + }; } function isNotEmptyIfString(value: EntityRawId) { @@ -214,6 +267,20 @@ function isValidRdfEdgeIdIfSparql(queryEngine: QueryEngine) { }; } +function normalizeVertexId( + value: EntityRawId, + queryEngine: QueryEngine, +): VertexId | undefined { + const trimmed = trimIfString(value); + if ( + !isNotEmptyIfString(trimmed) || + !isNotMaliciousIfSparql(queryEngine)(trimmed) + ) { + return undefined; + } + return createVertexId(escapeIfPropertyGraphAndString(queryEngine)(trimmed)); +} + function trimIfString(value: EntityRawId) { return typeof value === "string" ? value.trim() : value; } diff --git a/packages/graph-explorer/src/modules/GraphViewer/graphArrangement.test.ts b/packages/graph-explorer/src/modules/GraphViewer/graphArrangement.test.ts new file mode 100644 index 0000000000..934c8b4566 --- /dev/null +++ b/packages/graph-explorer/src/modules/GraphViewer/graphArrangement.test.ts @@ -0,0 +1,55 @@ +import cytoscape from "cytoscape"; + +import { createRenderedVertexId, createVertexId } from "@/core"; + +import { captureGraphArrangement } from "./graphArrangement"; + +test("captures requested existing positions and the viewport", () => { + const present = createVertexId(1); + const absent = createVertexId("absent"); + const cy = cytoscape({ + headless: true, + elements: [{ data: { id: createRenderedVertexId(present) } }], + }); + cy.getElementById(createRenderedVertexId(present)).position({ x: 12, y: 34 }); + cy.viewport({ pan: { x: 56, y: 78 }, zoom: 2 }); + + expect(captureGraphArrangement(cy, [present, absent])).toStrictEqual({ + positions: [{ id: present, x: 12, y: 34 }], + viewport: { pan: { x: 56, y: 78 }, zoom: 2 }, + }); +}); + +test("merges captured positions with existing positions for vertices not currently rendered", () => { + const present = createVertexId("present"); + const missing = createVertexId("missing"); + const obsolete = createVertexId("obsolete"); + const cy = cytoscape({ + headless: true, + elements: [{ data: { id: createRenderedVertexId(present) } }], + }); + cy.getElementById(createRenderedVertexId(present)).position({ + x: 100, + y: 200, + }); + cy.viewport({ pan: { x: 1, y: 2 }, zoom: 3 }); + + const existing = { + positions: [ + { id: present, x: 12, y: 34 }, + { id: missing, x: 56, y: 78 }, + { id: obsolete, x: 90, y: 10 }, + ], + viewport: { pan: { x: 4, y: 5 }, zoom: 6 }, + }; + + const result = captureGraphArrangement(cy, [present, missing], existing); + + expect(result).toStrictEqual({ + positions: [ + { id: present, x: 100, y: 200 }, + { id: missing, x: 56, y: 78 }, + ], + viewport: { pan: { x: 1, y: 2 }, zoom: 3 }, + }); +}); diff --git a/packages/graph-explorer/src/modules/GraphViewer/graphArrangement.ts b/packages/graph-explorer/src/modules/GraphViewer/graphArrangement.ts new file mode 100644 index 0000000000..13855dce05 --- /dev/null +++ b/packages/graph-explorer/src/modules/GraphViewer/graphArrangement.ts @@ -0,0 +1,30 @@ +import type { Core } from "cytoscape"; + +import { createRenderedVertexId, type VertexId } from "@/core"; +import { + mergeGraphArrangements, + type GraphArrangement, +} from "@/core/StateProvider/graphSession"; + +export function captureGraphArrangement( + cy: Core, + vertexIds: Iterable, + existingArrangement?: GraphArrangement, +): GraphArrangement { + const positions = []; + for (const id of vertexIds) { + const node = cy.getElementById(createRenderedVertexId(id)); + if (node.empty()) continue; + const position = node.position(); + positions.push({ id, x: position.x, y: position.y }); + } + + const captured: GraphArrangement = { + positions, + viewport: { pan: cy.pan(), zoom: cy.zoom() }, + }; + + return existingArrangement + ? mergeGraphArrangements(existingArrangement, captured, vertexIds) + : captured; +} diff --git a/packages/graph-explorer/src/modules/SchemaGraph/SchemaGraph.tsx b/packages/graph-explorer/src/modules/SchemaGraph/SchemaGraph.tsx index 6b9fccf7ba..3b7521ae3d 100644 --- a/packages/graph-explorer/src/modules/SchemaGraph/SchemaGraph.tsx +++ b/packages/graph-explorer/src/modules/SchemaGraph/SchemaGraph.tsx @@ -1,4 +1,4 @@ -import { atom, useAtomValue } from "jotai"; +import { useAtomValue } from "jotai"; import { type ComponentPropsWithRef, type MouseEvent, useState } from "react"; import { @@ -7,11 +7,7 @@ import { PanelContent, PanelGroup, } from "@/components"; -import { - Graph, - type LayoutName, - type SelectedElements, -} from "@/components/Graph"; +import { Graph, type SelectedElements } from "@/components/Graph"; import { createVertexType, type EdgeConnectionId, @@ -19,6 +15,7 @@ import { } from "@/core"; import { cn, logger } from "@/utils"; +import { schemaViewLayoutAlgorithmAtom } from "./schemaGraphLayout"; import { SchemaGraphToolbar } from "./SchemaGraphToolbar"; import { SchemaExplorerSidebar } from "./Sidebar/SchemaExplorerSidebar"; import { useSchemaViewSidebar } from "./Sidebar/schemaViewLayout"; @@ -41,9 +38,6 @@ export type SchemaGraphProps = Omit< "children" | "onContextMenu" >; -/** Atom for storing the selected graph layout algorithm */ -export const schemaGraphLayoutAtom = atom("F_COSE"); - function preventContextMenu(e: MouseEvent) { e.preventDefault(); e.stopPropagation(); @@ -53,7 +47,7 @@ function preventContextMenu(e: MouseEvent) { export default function SchemaGraph({ className, ...props }: SchemaGraphProps) { const { nodes, edges } = useSchemaGraphData(); const styles = useSchemaGraphStyles(); - const layout = useAtomValue(schemaGraphLayoutAtom); + const layout = useAtomValue(schemaViewLayoutAlgorithmAtom); const [selection, setSelection] = useState(null); const [graphSelection, setGraphSelection] = useState( diff --git a/packages/graph-explorer/src/modules/SchemaGraph/SchemaGraphToolbar.tsx b/packages/graph-explorer/src/modules/SchemaGraph/SchemaGraphToolbar.tsx index b38665dcfe..a66143bd25 100644 --- a/packages/graph-explorer/src/modules/SchemaGraph/SchemaGraphToolbar.tsx +++ b/packages/graph-explorer/src/modules/SchemaGraph/SchemaGraphToolbar.tsx @@ -19,7 +19,7 @@ import { import { useSchemaSync } from "@/hooks/useSchemaSync"; import { ASCII, logger } from "@/utils"; -import { schemaGraphLayoutAtom } from "./SchemaGraph"; +import { schemaViewLayoutAlgorithmAtom } from "./schemaGraphLayout"; /** Toolbar for schema graph with layout controls and schema refresh */ export function SchemaGraphToolbar() { @@ -29,7 +29,7 @@ export function SchemaGraphToolbar() { diff --git a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/SchemaExplorerSidebar.test.tsx b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/SchemaExplorerSidebar.test.tsx index 6ee864d48e..04bf99d3c5 100644 --- a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/SchemaExplorerSidebar.test.tsx +++ b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/SchemaExplorerSidebar.test.tsx @@ -34,6 +34,7 @@ function stateWithDetailsTab() { return new DbState().withSchemaViewLayout({ activeSidebarItem: "details", sidebar: { width: 400 }, + layoutAlgorithm: "F_COSE", }); } diff --git a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/schemaViewLayout.test.ts b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/schemaViewLayout.test.ts index 53bc11fa8a..e66f60d62f 100644 --- a/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/schemaViewLayout.test.ts +++ b/packages/graph-explorer/src/modules/SchemaGraph/Sidebar/schemaViewLayout.test.ts @@ -11,6 +11,7 @@ import { useSchemaViewSidebar } from "./schemaViewLayout"; const baseLayout: SchemaViewLayout = { activeSidebarItem: "details", sidebar: { width: DEFAULT_SIDEBAR_WIDTH }, + layoutAlgorithm: "F_COSE", }; /** Seeds a schema view layout, overriding only the fields a test pins. */ diff --git a/packages/graph-explorer/src/modules/SchemaGraph/schemaGraphLayout.test.ts b/packages/graph-explorer/src/modules/SchemaGraph/schemaGraphLayout.test.ts new file mode 100644 index 0000000000..8807fe6050 --- /dev/null +++ b/packages/graph-explorer/src/modules/SchemaGraph/schemaGraphLayout.test.ts @@ -0,0 +1,69 @@ +// @vitest-environment happy-dom + +import { useAtom, useAtomValue } from "jotai"; +import { act } from "react"; + +import { schemaViewLayoutAtom } from "@/core/StateProvider/storageAtoms"; +import { DbState, renderHookWithState } from "@/utils/testing"; + +import { schemaViewLayoutAlgorithmAtom } from "./schemaGraphLayout"; + +describe("schemaViewLayoutAlgorithmAtom", () => { + it("exposes the persisted layout algorithm", () => { + const state = new DbState().withSchemaViewLayout({ + activeSidebarItem: "styles", + sidebar: { width: 420 }, + detailsAutoOpenOnSelection: false, + layoutAlgorithm: "DAGRE_LR", + }); + + const { result } = renderHookWithState( + () => useAtomValue(schemaViewLayoutAlgorithmAtom), + state, + ); + + expect(result.current).toBe("DAGRE_LR"); + }); + + it("keeps the persisted layout unchanged when selecting the current algorithm", () => { + const state = new DbState().withSchemaViewLayout({ + activeSidebarItem: "styles", + sidebar: { width: 420 }, + detailsAutoOpenOnSelection: false, + layoutAlgorithm: "DAGRE_LR", + }); + const { result } = renderHookWithState(() => { + const [, setLayoutAlgorithm] = useAtom(schemaViewLayoutAlgorithmAtom); + const layout = useAtomValue(schemaViewLayoutAtom); + return { layout, setLayoutAlgorithm }; + }, state); + const initialLayout = result.current.layout; + + act(() => result.current.setLayoutAlgorithm("DAGRE_LR")); + + expect(result.current.layout).toBe(initialLayout); + }); + + it("updates only the persisted layout algorithm", () => { + const state = new DbState().withSchemaViewLayout({ + activeSidebarItem: "styles", + sidebar: { width: 420 }, + detailsAutoOpenOnSelection: false, + layoutAlgorithm: "DAGRE_LR", + }); + const { result } = renderHookWithState(() => { + const [, setLayoutAlgorithm] = useAtom(schemaViewLayoutAlgorithmAtom); + const layout = useAtomValue(schemaViewLayoutAtom); + return { layout, setLayoutAlgorithm }; + }, state); + + act(() => result.current.setLayoutAlgorithm("KLAY_TB")); + + expect(result.current.layout).toStrictEqual({ + activeSidebarItem: "styles", + sidebar: { width: 420 }, + detailsAutoOpenOnSelection: false, + layoutAlgorithm: "KLAY_TB", + }); + }); +}); diff --git a/packages/graph-explorer/src/modules/SchemaGraph/schemaGraphLayout.ts b/packages/graph-explorer/src/modules/SchemaGraph/schemaGraphLayout.ts new file mode 100644 index 0000000000..11beb48539 --- /dev/null +++ b/packages/graph-explorer/src/modules/SchemaGraph/schemaGraphLayout.ts @@ -0,0 +1,15 @@ +import { atom } from "jotai"; + +import type { LayoutName } from "@/core/graphLayout"; + +import { schemaViewLayoutAtom } from "@/core/StateProvider/storageAtoms"; + +export const schemaViewLayoutAlgorithmAtom = atom( + get => get(schemaViewLayoutAtom).layoutAlgorithm, + (_get, set, layoutAlgorithm: LayoutName) => + set(schemaViewLayoutAtom, previous => + previous.layoutAlgorithm === layoutAlgorithm + ? previous + : { ...previous, layoutAlgorithm }, + ), +); diff --git a/packages/graph-explorer/src/utils/testing/DbState.ts b/packages/graph-explorer/src/utils/testing/DbState.ts index 54ad06c765..d32431809e 100644 --- a/packages/graph-explorer/src/utils/testing/DbState.ts +++ b/packages/graph-explorer/src/utils/testing/DbState.ts @@ -14,6 +14,7 @@ import { edgesTypesFilteredAtom, type EdgeType, explorerForTestingAtom, + type GraphSessionStorageModel, type GraphViewLayout, graphViewLayoutAtom, mapEdgeToTypeConfig, @@ -34,6 +35,7 @@ import { userVertexStylesAtom, type VertexType, } from "@/core"; +import { DEFAULT_GRAPH_LAYOUT } from "@/core/graphLayout"; import { createMockExplorer } from "./createMockExplorer"; import { @@ -59,6 +61,7 @@ export class DbState { edgeStyles: Map; graphViewLayout: GraphViewLayout; schemaViewLayout: SchemaViewLayout; + graphSession?: GraphSessionStorageModel; explorer: Explorer; @@ -215,6 +218,11 @@ export class DbState { return this; } + withGraphSession(session: GraphSessionStorageModel) { + this.graphSession = session; + return this; + } + /** Applies the state to the given Jotai store. */ applyTo(store: AppStore) { // Config @@ -256,9 +264,10 @@ export class DbState { new Map([ [ this.activeConfig.id, - { + this.graphSession ?? { vertices: new Set(this.vertices.map(v => v.id)), edges: new Set(this.edges.map(e => e.id)), + layout: DEFAULT_GRAPH_LAYOUT, }, ], ]), diff --git a/packages/graph-explorer/src/utils/testing/persistence.test.ts b/packages/graph-explorer/src/utils/testing/persistence.test.ts index 6965a8cd9f..9119e1f105 100644 --- a/packages/graph-explorer/src/utils/testing/persistence.test.ts +++ b/packages/graph-explorer/src/utils/testing/persistence.test.ts @@ -4,7 +4,6 @@ import type { ConfigurationId, RawConfiguration, } from "@/core/ConfigurationProvider"; -import type { EdgeType, VertexType } from "@/core/entities"; import type { GraphSessionStorageModel } from "@/core/StateProvider/graphSession/storage"; import type { EdgeStyleStorage, @@ -12,7 +11,17 @@ import type { } from "@/core/StateProvider/graphStyles"; import type { SchemaStorageModel } from "@/core/StateProvider/schema"; +import { + createVertexId, + type EdgeId, + type EdgeType, + type VertexId, + type VertexType, +} from "@/core/entities"; +import { DEFAULT_GRAPH_LAYOUT } from "@/core/graphLayout"; import { reconcileMapByKey } from "@/core/StateProvider/atomWithLocalForage"; +import { transformGraphSessions } from "@/core/StateProvider/graphSession/storage"; +import { logger } from "@/utils"; import { openPersistenceTab, readPersistedValue } from "./persistence"; import { @@ -338,6 +347,217 @@ describe("cross-tab connection reconciliation", () => { }); }); +/** + * BACKWARD COMPATIBILITY — PERSISTED GRAPH SESSION ARRANGEMENT + * + * GraphSessionStorageModel may contain an `arrangement` with saved node + * positions and viewport. Older versions stored no arrangement, and persisted + * values may include non-finite coordinates, duplicate positions, or a malformed + * shape. `transformGraphSessions` validates each arrangement on read and drops + * invalid ones with a diagnostic so the app never crashes on bad legacy data. + * + * DO NOT delete or weaken these tests without confirming that all persisted + * data has been migrated or that the old/invalid shapes are no longer in the + * wild. + */ +describe("backward compatibility: graph session arrangement", () => { + function arrangementSession( + arrangement?: GraphSessionStorageModel["arrangement"], + ): GraphSessionStorageModel { + return { + vertices: new Set([createRandomVertexId()]), + edges: new Set(), + layout: "F_COSE", + arrangement, + }; + } + + test("normalizes legacy, current, and invalid arrangements when preloading persistence", async () => { + const key = createRandomName("graph-sessions"); + const legacyConnection = createRandomConfigurationId(); + const currentConnection = createRandomConfigurationId(); + const invalidConnection = createRandomConfigurationId(); + + const legacySession = arrangementSession(undefined); + const currentSession = arrangementSession({ + positions: [{ id: createRandomVertexId(), x: 10, y: 20 }], + viewport: { pan: { x: 30, y: 40 }, zoom: 2 }, + }); + const invalidSession = arrangementSession({ + positions: [{ id: createRandomVertexId(), x: Number.NaN, y: 0 }], + viewport: { pan: { x: 0, y: 0 }, zoom: 1 }, + }); + + const sessions = new Map([ + [legacyConnection, legacySession], + [currentConnection, currentSession], + [invalidConnection, invalidSession], + ]); + + const writer = await openPersistenceTab(key, new Map()); + writer.write(sessions); + await writer.flush(); + + vi.spyOn(logger, "warn").mockImplementation(() => {}); + + const reader = await openPersistenceTab( + key, + new Map(), + reconcileMapByKey, + transformGraphSessions, + ); + + const read = reader.read(); + expect(read.get(legacyConnection)?.arrangement).toBeUndefined(); + expect(read.get(currentConnection)?.arrangement).toEqual( + currentSession.arrangement, + ); + expect(read.get(invalidConnection)?.arrangement).toBeUndefined(); + expect(logger.warn).toHaveBeenCalled(); + }); + + test("preserves numeric and string vertex IDs in valid arrangements", async () => { + const key = createRandomName("graph-sessions"); + const connection = createRandomConfigurationId(); + const arrangement = { + positions: [ + { id: createVertexId(1), x: 10, y: 20 }, + { id: createVertexId("1"), x: 30, y: 40 }, + ], + }; + + const sessions = new Map([ + [connection, arrangementSession(arrangement)], + ]); + + const writer = await openPersistenceTab(key, new Map()); + writer.write(sessions); + await writer.flush(); + + const reader = await openPersistenceTab( + key, + new Map(), + reconcileMapByKey, + transformGraphSessions, + ); + + const read = reader.read().get(connection)?.arrangement; + expect(read?.positions.map(p => [p.id, p.x, p.y])).toEqual([ + [1, 10, 20], + ["1", 30, 40], + ]); + }); + + test("drops arrangements with duplicate position IDs", async () => { + const key = createRandomName("graph-sessions"); + const connection = createRandomConfigurationId(); + const arrangement = { + positions: [ + { id: createVertexId(1), x: 10, y: 20 }, + { id: createVertexId(1), x: 30, y: 40 }, + ], + }; + + const sessions = new Map([ + [connection, arrangementSession(arrangement)], + ]); + + const writer = await openPersistenceTab(key, new Map()); + writer.write(sessions); + await writer.flush(); + + vi.spyOn(logger, "warn").mockImplementation(() => {}); + + const reader = await openPersistenceTab( + key, + new Map(), + reconcileMapByKey, + transformGraphSessions, + ); + + expect(reader.read().get(connection)?.arrangement).toBeUndefined(); + }); +}); + +/** + * BACKWARD COMPATIBILITY — PERSISTED GRAPH SESSION LAYOUT + * + * GraphSessionStorageModel is persisted to IndexedDB via localForage in the + * `graph-sessions` atom. Older versions stored only `vertices` and `edges` + * without a `layout` field, and persisted values may include an invalid or + * unrecognized layout string. `transformGraphSessions` normalizes each session's + * `layout` to a recognized `LayoutName` or `undefined` on read so previously + * saved sessions remain usable. + * + * DO NOT delete or weaken these tests without confirming that all persisted + * data has been migrated or that the old/invalid shapes are no longer in the + * wild. + */ +describe("backward compatibility: graph session layout", () => { + type RawGraphSessionStorageModel = { + vertices: Set; + edges: Set; + layout?: string; + }; + + test("normalizes legacy, current, and invalid session layouts when preloading persistence", async () => { + const key = createRandomName("graph-sessions"); + const legacyConnection = createRandomConfigurationId(); + const currentConnection = createRandomConfigurationId(); + const invalidConnection = createRandomConfigurationId(); + const legacySession: RawGraphSessionStorageModel = { + vertices: new Set([createRandomVertexId()]), + edges: new Set(), + }; + const currentSession: RawGraphSessionStorageModel = { + vertices: new Set([createRandomVertexId()]), + edges: new Set(), + layout: "DAGRE_LR", + }; + const invalidSession: RawGraphSessionStorageModel = { + vertices: new Set([createRandomVertexId()]), + edges: new Set(), + layout: "INVALID_LAYOUT", + }; + const sessions = new Map([ + [legacyConnection, legacySession as GraphSessionStorageModel], + [currentConnection, currentSession as GraphSessionStorageModel], + [invalidConnection, invalidSession as GraphSessionStorageModel], + ]); + const writer = await openPersistenceTab(key, new Map()); + writer.write(sessions); + await writer.flush(); + + vi.spyOn(logger, "debug").mockImplementation(() => {}); + + const reader = await openPersistenceTab( + key, + new Map(), + reconcileMapByKey, + transformGraphSessions, + ); + + expect(reader.read()).toStrictEqual( + new Map([ + [legacyConnection, legacySession as GraphSessionStorageModel], + [currentConnection, currentSession as GraphSessionStorageModel], + [ + invalidConnection, + { + ...invalidSession, + layout: DEFAULT_GRAPH_LAYOUT, + } as GraphSessionStorageModel, + ], + ]), + ); + + expect(logger.debug).toHaveBeenCalledWith( + `[graph-session] Unrecognized saved layout algorithm; using "${DEFAULT_GRAPH_LAYOUT}"`, + "INVALID_LAYOUT", + ); + }); +}); + /** * REGRESSION — #1820 cross-tab session clobber * diff --git a/packages/graph-explorer/src/utils/testing/persistence.ts b/packages/graph-explorer/src/utils/testing/persistence.ts index 744dba0f91..42369ec7e7 100644 --- a/packages/graph-explorer/src/utils/testing/persistence.ts +++ b/packages/graph-explorer/src/utils/testing/persistence.ts @@ -2,7 +2,10 @@ import { createStore } from "jotai"; import localforage from "localforage"; import type { AppStore } from "@/core"; -import type { ReconcileWrite } from "@/core/StateProvider/atomWithLocalForage"; +import type { + ReadTransform, + ReconcileWrite, +} from "@/core/StateProvider/atomWithLocalForage"; import { atomWithLocalForage } from "@/core/StateProvider/atomWithLocalForage"; import { persistenceStatusStore } from "@/core/StateProvider/persistence"; @@ -67,9 +70,13 @@ export async function openPersistenceTab( key: string, initialValue: T, reconcile?: ReconcileWrite, + transform?: ReadTransform, ): Promise> { const store = createStore(); - const atom = await atomWithLocalForage(key, initialValue, { reconcile }); + const atom = await atomWithLocalForage(key, initialValue, { + reconcile, + transform, + }); return new PersistenceTab(store, atom); } diff --git a/packages/graph-explorer/src/utils/testing/randomData.ts b/packages/graph-explorer/src/utils/testing/randomData.ts index 0adf1cf980..635d51649e 100644 --- a/packages/graph-explorer/src/utils/testing/randomData.ts +++ b/packages/graph-explorer/src/utils/testing/randomData.ts @@ -50,6 +50,7 @@ import { type EntityRawId, type FeatureFlags, type GraphViewLayout, + layoutNames, type LineStyle, type PrefixTypeConfig, type RawConfiguration, @@ -578,7 +579,8 @@ export function createRandomExportedGraph() { connection.queryEngine = pickRandomElement( queryEngineOptions.filter(e => e !== "sparql"), ); - const result = createExportedGraph(vertexIds, edgeIds, connection); + const layout = pickRandomElement([...layoutNames]); + const result = createExportedGraph(vertexIds, edgeIds, connection, layout); result.meta.sourceVersion = createRandomVersion(); return result; } @@ -589,7 +591,8 @@ export function createRandomExportedGraphForRdf() { const edgeIds = entities.edges.map(e => e.id); const connection = createRandomConnectionWithId(); connection.queryEngine = "sparql"; - const result = createExportedGraph(vertexIds, edgeIds, connection); + const layout = pickRandomElement([...layoutNames]); + const result = createExportedGraph(vertexIds, edgeIds, connection, layout); result.meta.sourceVersion = createRandomVersion(); return result; } @@ -841,5 +844,6 @@ export function createRandomSchemaViewLayout(): SchemaViewLayout { }), }, detailsAutoOpenOnSelection: randomlyUndefined(createRandomBoolean()), + layoutAlgorithm: pickRandomElement([...layoutNames]), }; }