diff --git a/packages/graph-explorer/src/components/Graph/Graph.tsx b/packages/graph-explorer/src/components/Graph/Graph.tsx index f19c848b3..a9acf4ab9 100755 --- a/packages/graph-explorer/src/components/Graph/Graph.tsx +++ b/packages/graph-explorer/src/components/Graph/Graph.tsx @@ -12,6 +12,7 @@ import { useState, } from "react"; +import { DEFAULT_GRAPH_LAYOUT } from "@/core/graphLayout"; import { cn } from "@/utils"; import type { @@ -163,7 +164,7 @@ export const Graph = ({ onNodeRightClick, onGraphClick, onGraphRightClick, - layout = "F_COSE", + layout = DEFAULT_GRAPH_LAYOUT, badgesEnabled = false, useAnimation = true, pan, diff --git a/packages/graph-explorer/src/components/Graph/SelectLayout.tsx b/packages/graph-explorer/src/components/Graph/SelectLayout.tsx index a31ceb9eb..93f4bfff8 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 4ff30d66f..12a3b0ea9 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/core/StateProvider/schemaViewLayoutDefaults.test.ts b/packages/graph-explorer/src/core/StateProvider/schemaViewLayoutDefaults.test.ts index 1f4c86900..f1664ff2e 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 f167b890d..444ffece2 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/graphLayout.test.ts b/packages/graph-explorer/src/core/graphLayout.test.ts new file mode 100644 index 000000000..bc332b979 --- /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 000000000..07481613b --- /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 3ecb1beb5..4c6f1f9a9 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/modules/GraphViewer/GraphViewer.tsx b/packages/graph-explorer/src/modules/GraphViewer/GraphViewer.tsx index fe3b95e13..f573402bf 100644 --- a/packages/graph-explorer/src/modules/GraphViewer/GraphViewer.tsx +++ b/packages/graph-explorer/src/modules/GraphViewer/GraphViewer.tsx @@ -25,7 +25,6 @@ import { DownloadScreenshotButton, Graph, GraphProvider, - type LayoutName, RerunLayoutButton, type SelectedElements, SelectLayout, @@ -34,6 +33,7 @@ import { ZoomToFitButton, } from "@/components/Graph"; import { + DEFAULT_GRAPH_LAYOUT, createRenderedEdgeId, createRenderedVertexId, getEdgeIdFromRenderedEdgeId, @@ -60,7 +60,7 @@ import { useGraphSelection } from "./useGraphSelection"; import useGraphStyles from "./useGraphStyles"; import useNodeBadges from "./useNodeBadges"; -const graphLayoutSelectionAtom = atom("F_COSE"); +const graphLayoutSelectionAtom = atom(DEFAULT_GRAPH_LAYOUT); // Prevent open context menu on Windows function onContextMenu(e: MouseEvent) { diff --git a/packages/graph-explorer/src/modules/SchemaGraph/SchemaGraph.tsx b/packages/graph-explorer/src/modules/SchemaGraph/SchemaGraph.tsx index 6b9fccf7b..3b7521ae3 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 b38665dcf..a66143bd2 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 6ee864d48..04bf99d3c 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 53bc11fa8..e66f60d62 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 000000000..8807fe605 --- /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 000000000..11beb4853 --- /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/randomData.ts b/packages/graph-explorer/src/utils/testing/randomData.ts index 0adf1cf98..636cea900 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, @@ -841,5 +842,6 @@ export function createRandomSchemaViewLayout(): SchemaViewLayout { }), }, detailsAutoOpenOnSelection: randomlyUndefined(createRandomBoolean()), + layoutAlgorithm: pickRandomElement([...layoutNames]), }; }