From f17623838ff7ac28aa849c9ac1538ae9c1f72f99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=B8ren=20Bramer=20Schmidt?= Date: Fri, 17 Jul 2026 21:18:42 +0700 Subject: [PATCH] feat: remember the schema visualizer's manual table layout Dragged node positions are now persisted in a localStorage-backed TanStack DB collection (uiPersistentStateCollection) scoped per schema, so a manual arrangement survives leaving the visualizer and full page reloads. Saved positions are merged over the ELK auto-layout on mount, so tables without a remembered position (for example newly created ones) still fall back to auto-layout, and the existing Reset layout header action now also clears the remembered manual layout. Closes prisma/studio#1397 Co-Authored-By: Claude Fable 5 --- .../schema-visualizer-remember-layout.md | 5 + Architecture/ui-state.md | 7 + FEATURES.md | 2 +- ui/hooks/use-ui-state.context.test.tsx | 49 +++++- ui/hooks/use-ui-state.ts | 14 +- ui/studio/context.tsx | 18 +++ ui/studio/views/schema/SchemaView.test.tsx | 60 ++++--- ui/studio/views/schema/SchemaView.tsx | 11 ++ ui/studio/views/schema/Visualiser.test.tsx | 153 ++++++++++++++++-- ui/studio/views/schema/Visualiser.tsx | 37 ++++- ui/studio/views/schema/schema-layout.test.ts | 43 +++++ ui/studio/views/schema/schema-layout.ts | 16 ++ 12 files changed, 379 insertions(+), 36 deletions(-) create mode 100644 .changeset/schema-visualizer-remember-layout.md diff --git a/.changeset/schema-visualizer-remember-layout.md b/.changeset/schema-visualizer-remember-layout.md new file mode 100644 index 00000000..18e5de88 --- /dev/null +++ b/.changeset/schema-visualizer-remember-layout.md @@ -0,0 +1,5 @@ +--- +"@prisma/studio-core": minor +--- + +Remember the schema visualizer's manual table layout. Dragged node positions are now stored in localStorage-backed UI state scoped per schema, so a manual arrangement survives leaving the visualizer and full page reloads. Tables without a remembered position (for example newly created ones) fall back to ELK auto-layout, and the `Reset layout` action clears the remembered layout. diff --git a/Architecture/ui-state.md b/Architecture/ui-state.md index d1234a26..d242abdb 100644 --- a/Architecture/ui-state.md +++ b/Architecture/ui-state.md @@ -40,6 +40,10 @@ Studio context provides the canonical stores in [`ui/studio/context.tsx`](../ui/ - `filteredRowCount` - `uiLocalStateCollection` (`localOnlyCollectionOptions`) - General scoped UI state (for example DataGrid selection machine state). +- `uiPersistentStateCollection` (`localStorageCollectionOptions`) + - General scoped UI state that must survive page reloads, accessed through + `useUiState` with `{ persistent: true }` (for example the schema + visualizer's manually arranged node positions). - `sqlEditorStateCollection` (`localStorageCollectionOptions`) - Persisted SQL editor draft state: - `queryText` @@ -135,6 +139,9 @@ The following are valid examples of UI state and where they belong: - Schema visualizer node positions and layout state: `uiLocalStateCollection` via `useUiState` - Scoped by active schema plus the current visualized table set so returning to the same schema graph restores dragged positions without leaking across schemas. - Includes the stored ELK baseline positions and reset-layout request token used by the header action. +- Schema visualizer manually arranged node positions: `uiPersistentStateCollection` via `useUiState({ persistent: true })` + - Scoped by active schema name (`schema-visualizer:${schema}:manual-layout:node-positions`) with per-table entries, so manual layouts survive reloads, new tables fall back to the ELK auto-layout, and different schemas do not collide. + - The header `Reset layout` action clears this store in addition to re-applying the ELK baseline. - Command-palette `x more...` handoff into table browsing: the same navigation table-name search `useUiState` entry, not a second command-palette-specific table-filter store If new UI state is shared across components, it MUST be assigned to one of these stores (or a new TanStack DB collection added in Studio context). diff --git a/FEATURES.md b/FEATURES.md index 2acaa70e..98e2c4f1 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -144,7 +144,7 @@ Cards keep a fixed width inside their own horizontal scroller, the header toggle Studio includes a schema graph view with table nodes, column metadata, and detected foreign-key relationships labeled as 1:1 or 1:n. The visualizer now runs ELK auto-layout with component-aware spacing so disconnected tables do not collapse into the same visual band, and orthogonal step edges leave clearer corridors between nodes. -Dragged node positions persist when you leave and return to the same schema view, and a header-level `Reset layout` action re-applies the current ELK baseline when you want to discard manual placement. +Dragged node positions are remembered in localStorage-backed UI state scoped per schema, so a manual arrangement survives leaving the visualizer, switching views, and full page reloads; tables without a remembered position (for example newly created ones) fall back to ELK auto-layout. A header-level `Reset layout` action re-applies the current ELK baseline and forgets the stored manual placement. Users can pan/zoom, inspect key and nullable markers, and jump from a node directly to that table’s data view. ## Query Insights diff --git a/ui/hooks/use-ui-state.context.test.tsx b/ui/hooks/use-ui-state.context.test.tsx index af952b39..5896956d 100644 --- a/ui/hooks/use-ui-state.context.test.tsx +++ b/ui/hooks/use-ui-state.context.test.tsx @@ -13,6 +13,7 @@ const useOptionalStudioMock = vi.fn< () => | { uiLocalStateCollection: ReturnType; + uiPersistentStateCollection?: ReturnType; } | undefined >(); @@ -27,10 +28,14 @@ vi.mock("../studio/context", () => { globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean } ).IS_REACT_ACT_ENVIRONMENT = true; +let collectionInstanceCounter = 0; + function createUiCollection() { + collectionInstanceCounter += 1; + return createCollection( localOnlyCollectionOptions({ - id: "use-ui-state-context-test", + id: `use-ui-state-context-test-${collectionInstanceCounter}`, getKey(item) { return item.id; }, @@ -87,6 +92,48 @@ describe("useUiState with Studio context collection", () => { container.remove(); }); + it("routes persistent state into the persistent ui collection", () => { + const persistentCollection = createUiCollection(); + + useOptionalStudioMock.mockReturnValue({ + uiLocalStateCollection: uiCollection, + uiPersistentStateCollection: persistentCollection, + }); + + const key = "context-persistent-state"; + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + let latestState: ReturnType> | undefined; + + function Harness() { + latestState = useUiState(key, "alpha", { persistent: true }); + return null; + } + + act(() => { + root.render(); + }); + + expect(latestState?.[0]).toBe("alpha"); + expect(persistentCollection.get(key)?.value).toBe("alpha"); + expect(uiCollection.has(key)).toBe(false); + + act(() => { + latestState?.[1]("beta"); + }); + + expect(latestState?.[0]).toBe("beta"); + expect(persistentCollection.get(key)?.value).toBe("beta"); + expect(uiCollection.has(key)).toBe(false); + + act(() => { + root.unmount(); + }); + container.remove(); + }); + it("does not mutate the shared ui collection for cleanup-on-unmount state", () => { const key = "context-cleanup-state"; const insertSpy = vi.spyOn(uiCollection, "insert"); diff --git a/ui/hooks/use-ui-state.ts b/ui/hooks/use-ui-state.ts index 8cf4cbe3..0acf4eca 100644 --- a/ui/hooks/use-ui-state.ts +++ b/ui/hooks/use-ui-state.ts @@ -14,6 +14,11 @@ type Updater = T | ((previous: T) => T); export interface UseUiStateOptions { cleanupOnUnmount?: boolean; + /** + * When enabled, state is stored in the localStorage-backed persistent UI + * state collection so it survives page reloads. + */ + persistent?: boolean; } const fallbackUiStateCollection = instrumentTanStackCollectionMutations( @@ -98,15 +103,18 @@ export function useUiState( initialValue: T, options: UseUiStateOptions = {}, ) { - const { cleanupOnUnmount = false } = options; + const { cleanupOnUnmount = false, persistent = false } = options; const [volatileValue, setVolatileValue] = useState(() => cloneValue(initialValue), ); const previousVolatileKeyRef = useRef(key); const studioContext = useOptionalStudio(); const uiLocalStateCollection = - (studioContext?.uiLocalStateCollection as typeof fallbackUiStateCollection) ?? - fallbackUiStateCollection; + ((persistent + ? studioContext?.uiPersistentStateCollection + : studioContext?.uiLocalStateCollection) as + | typeof fallbackUiStateCollection + | undefined) ?? fallbackUiStateCollection; const { data: stateRow } = useLiveQuery( (q) => { diff --git a/ui/studio/context.tsx b/ui/studio/context.tsx index f074dd0b..2e1a9363 100644 --- a/ui/studio/context.tsx +++ b/ui/studio/context.tsx @@ -44,6 +44,7 @@ const STUDIO_UI_STATE_ID = "studio-ui-state"; const STUDIO_UI_STORAGE_KEY = "prisma-studio-ui-state-v1"; const SQL_EDITOR_STATE_ID = "studio-sql-editor-state"; const SQL_EDITOR_STORAGE_KEY = "prisma-studio-sql-editor-state-v1"; +const UI_PERSISTENT_STATE_STORAGE_KEY = "prisma-studio-persistent-ui-state-v1"; const DEFAULT_TABLE_PAGE_SIZE = 25; export const DEFAULT_NAVIGATION_WIDTH = 192; export const MIN_NAVIGATION_WIDTH = 192; @@ -280,6 +281,7 @@ interface StudioContextValue { tableUiStateCollection: Collection; tableQueryMetaCollection: Collection; uiLocalStateCollection: Collection; + uiPersistentStateCollection: Collection; sqlEditorStateCollection: Collection; navigationTableNamesCollection: Collection< NavigationTableNameState, @@ -394,6 +396,20 @@ export function StudioContextProvider(props: StudioContextProviderProps) { { collectionName: "studio-local-ui-state" }, ), ); + const uiPersistentStateCollectionRef = useRef( + instrumentTanStackCollectionMutations( + createCollection( + localStorageCollectionOptions({ + id: "studio-persistent-ui-state", + storageKey: UI_PERSISTENT_STATE_STORAGE_KEY, + getKey(item) { + return item.id; + }, + }), + ), + { collectionName: "studio-persistent-ui-state" }, + ), + ); const sqlEditorStateCollectionRef = useRef( instrumentTanStackCollectionMutations( createCollection( @@ -429,6 +445,7 @@ export function StudioContextProvider(props: StudioContextProviderProps) { const tableUiStateCollection = tableUiStateCollectionRef.current; const tableQueryMetaCollection = tableQueryMetaCollectionRef.current; const uiLocalStateCollection = uiLocalStateCollectionRef.current; + const uiPersistentStateCollection = uiPersistentStateCollectionRef.current; const sqlEditorStateCollection = sqlEditorStateCollectionRef.current; const navigationTableNamesCollection = navigationTableNamesCollectionRef.current; @@ -865,6 +882,7 @@ export function StudioContextProvider(props: StudioContextProviderProps) { tableUiStateCollection, tableQueryMetaCollection, uiLocalStateCollection, + uiPersistentStateCollection, sqlEditorStateCollection, navigationTableNamesCollection, getOrCreateRowsCollection, diff --git a/ui/studio/views/schema/SchemaView.test.tsx b/ui/studio/views/schema/SchemaView.test.tsx index fcce68cb..53607158 100644 --- a/ui/studio/views/schema/SchemaView.test.tsx +++ b/ui/studio/views/schema/SchemaView.test.tsx @@ -10,18 +10,23 @@ function cloneMockValue(value: T): T { return structuredClone(value); } -const { uiStateStore, useNavigationMock, useSchemaVisualizationMock } = - vi.hoisted(() => ({ - uiStateStore: new Map(), - useNavigationMock: vi.fn< - () => { - metadata: { - activeSchema: { name: string }; - }; - } - >(), - useSchemaVisualizationMock: vi.fn<() => SchemaVisualizationData>(), - })); +const { + persistentUiStateStore, + uiStateStore, + useNavigationMock, + useSchemaVisualizationMock, +} = vi.hoisted(() => ({ + persistentUiStateStore: new Map(), + uiStateStore: new Map(), + useNavigationMock: vi.fn< + () => { + metadata: { + activeSchema: { name: string }; + }; + } + >(), + useSchemaVisualizationMock: vi.fn<() => SchemaVisualizationData>(), +})); vi.mock("@/ui/hooks/use-navigation", () => ({ useNavigation: useNavigationMock, @@ -31,15 +36,20 @@ vi.mock("@/ui/hooks/use-ui-state", async () => { const React = await vi.importActual("react"); return { - useUiState: (key: string, initialValue: T) => { + useUiState: ( + key: string, + initialValue: T, + options?: { persistent?: boolean }, + ) => { + const store = options?.persistent ? persistentUiStateStore : uiStateStore; + const [value, setValue] = React.useState(() => { - if (!uiStateStore.has(key)) { - uiStateStore.set(key, cloneMockValue(initialValue)); + if (!store.has(key)) { + store.set(key, cloneMockValue(initialValue)); } return ( - (uiStateStore.get(key) as T | undefined) ?? - cloneMockValue(initialValue) + (store.get(key) as T | undefined) ?? cloneMockValue(initialValue) ); }); @@ -51,11 +61,11 @@ vi.mock("@/ui/hooks/use-ui-state", async () => { ? (updater as (previous: T) => T)(previous) : updater; - uiStateStore.set(key, cloneMockValue(nextValue)); + store.set(key, cloneMockValue(nextValue)); return cloneMockValue(nextValue); }); }, - [key], + [key, store], ); return [value, setSharedValue] as const; @@ -93,6 +103,7 @@ vi.mock("./Visualiser", () => ({ describe("SchemaView", () => { beforeEach(() => { uiStateStore.clear(); + persistentUiStateStore.clear(); useNavigationMock.mockReturnValue({ metadata: { activeSchema: { name: "public" }, @@ -157,6 +168,12 @@ describe("SchemaView", () => { "schema-visualizer:public:posts|users:reset-layout-version", 0, ); + persistentUiStateStore.set( + "schema-visualizer:public:manual-layout:node-positions", + { + users: { x: 333, y: 444 }, + }, + ); const container = document.createElement("div"); document.body.appendChild(container); @@ -190,6 +207,11 @@ describe("SchemaView", () => { "schema-visualizer:public:posts|users:reset-layout-version", ), ).toBe(1); + expect( + persistentUiStateStore.get( + "schema-visualizer:public:manual-layout:node-positions", + ), + ).toEqual({}); expect(container.textContent).not.toContain("Reset layout"); act(() => { diff --git a/ui/studio/views/schema/SchemaView.tsx b/ui/studio/views/schema/SchemaView.tsx index ef633114..35b97852 100644 --- a/ui/studio/views/schema/SchemaView.tsx +++ b/ui/studio/views/schema/SchemaView.tsx @@ -9,6 +9,7 @@ import { useSchemaVisualization } from "../../../hooks/use-schema-visualization" import { StudioHeader } from "../../StudioHeader"; import { ViewProps } from "../View"; import { + createSchemaVisualizerPersistentStateScope, createSchemaVisualizerStateScope, createSchemaVisualizerUiStateKey, doSchemaNodePositionsDiffer, @@ -32,10 +33,19 @@ export function SchemaView(_props: ViewProps) { .sort((left, right) => left.localeCompare(right)), [tables], ); + const persistentStateScope = useMemo( + () => createSchemaVisualizerPersistentStateScope(activeSchema?.name), + [activeSchema?.name], + ); const [nodePositions, setNodePositions] = useUiState( createSchemaVisualizerUiStateKey(stateScope, "node-positions"), {}, ); + const [, setManualNodePositions] = useUiState( + createSchemaVisualizerUiStateKey(persistentStateScope, "node-positions"), + {}, + { persistent: true }, + ); const [autoLayoutPositions] = useUiState( createSchemaVisualizerUiStateKey(stateScope, "auto-layout-node-positions"), {}, @@ -54,6 +64,7 @@ export function SchemaView(_props: ViewProps) { variant="outline" onClick={() => { setNodePositions(autoLayoutPositions); + setManualNodePositions({}); setResetLayoutVersion((currentVersion) => currentVersion + 1); }} > diff --git a/ui/studio/views/schema/Visualiser.test.tsx b/ui/studio/views/schema/Visualiser.test.tsx index 91ee182b..6d2569f4 100644 --- a/ui/studio/views/schema/Visualiser.test.tsx +++ b/ui/studio/views/schema/Visualiser.test.tsx @@ -41,6 +41,7 @@ const mocks = vi.hoisted(() => ({ fitViewMock: vi.fn(), layoutMock: vi.fn(), latestReactFlowProps: null as ReactFlowProps | null, + persistentUiStateStore: new Map(), uiStateStore: new Map(), useNavigationMock: vi.fn< () => { @@ -63,30 +64,32 @@ vi.mock("@/ui/hooks/use-ui-state", async () => { useUiState: ( key: string, initialValue: T, - options?: { cleanupOnUnmount?: boolean }, + options?: { cleanupOnUnmount?: boolean; persistent?: boolean }, ) => { const cleanupOnUnmount = options?.cleanupOnUnmount ?? false; + const store = options?.persistent + ? mocks.persistentUiStateStore + : mocks.uiStateStore; const [value, setValue] = React.useState(() => { - if (!mocks.uiStateStore.has(key)) { - mocks.uiStateStore.set(key, cloneMockValue(initialValue)); + if (!store.has(key)) { + store.set(key, cloneMockValue(initialValue)); } return ( - (mocks.uiStateStore.get(key) as T | undefined) ?? - cloneMockValue(initialValue) + (store.get(key) as T | undefined) ?? cloneMockValue(initialValue) ); }); React.useEffect(() => { if (cleanupOnUnmount) { return () => { - mocks.uiStateStore.delete(key); + store.delete(key); }; } return undefined; - }, [cleanupOnUnmount, key]); + }, [cleanupOnUnmount, key, store]); const setSharedValue = React.useCallback( (updater: T | ((previous: T) => T)) => { @@ -96,11 +99,11 @@ vi.mock("@/ui/hooks/use-ui-state", async () => { ? (updater as (previous: T) => T)(previous) : updater; - mocks.uiStateStore.set(key, cloneMockValue(nextValue)); + store.set(key, cloneMockValue(nextValue)); return cloneMockValue(nextValue); }); }, - [key], + [key, store], ); return [value, setSharedValue] as const; @@ -203,6 +206,7 @@ describe("SchemaVisualization", () => { mocks.layoutMock.mockReset(); mocks.latestReactFlowProps = null; mocks.uiStateStore.clear(); + mocks.persistentUiStateStore.clear(); mocks.useNavigationMock.mockReturnValue({ createUrl: () => "#", metadata: { @@ -330,4 +334,135 @@ describe("SchemaVisualization", () => { }); container.remove(); }); + + it("persists manual positions and restores them after in-memory state is lost", async () => { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const tables = [ + { + name: "users", + fields: [{ name: "id", type: "text", isPrimary: true }], + }, + { + name: "posts", + fields: [{ name: "id", type: "text", isPrimary: true }], + }, + ]; + const relationships = [{ from: "users", to: "posts", type: "1:n" }]; + + act(() => { + root.render( + , + ); + }); + + await flush(); + + act(() => { + mocks.latestReactFlowProps?.onNodesChange?.([ + { + id: "users", + position: { x: 333, y: 444 }, + type: "position", + }, + ]); + }); + + expect( + mocks.persistentUiStateStore.get( + "schema-visualizer:public:manual-layout:node-positions", + ), + ).toEqual({ + users: { x: 333, y: 444 }, + }); + + act(() => { + root.unmount(); + }); + + // Simulate a full page reload: in-memory ui state is gone, persisted + // localStorage-backed state survives. + mocks.uiStateStore.clear(); + + const remountRoot = createRoot(container); + + act(() => { + remountRoot.render( + , + ); + }); + + await flush(); + + expect(mocks.layoutMock).toHaveBeenCalledTimes(2); + expect(mocks.latestReactFlowProps?.nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "users", + position: { x: 333, y: 444 }, + }), + expect.objectContaining({ + id: "posts", + position: { x: 300, y: 140 }, + }), + ]), + ); + + act(() => { + remountRoot.unmount(); + }); + container.remove(); + }); + + it("auto-layouts tables that have no persisted position", async () => { + mocks.persistentUiStateStore.set( + "schema-visualizer:public:manual-layout:node-positions", + { + users: { x: 333, y: 444 }, + }, + ); + + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + + act(() => { + root.render( + , + ); + }); + + await flush(); + + expect(mocks.latestReactFlowProps?.nodes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "users", + position: { x: 333, y: 444 }, + }), + expect.objectContaining({ + id: "posts", + position: { x: 300, y: 140 }, + }), + ]), + ); + + act(() => { + root.unmount(); + }); + container.remove(); + }); }); diff --git a/ui/studio/views/schema/Visualiser.tsx b/ui/studio/views/schema/Visualiser.tsx index 9cbd4e27..02cf8b23 100644 --- a/ui/studio/views/schema/Visualiser.tsx +++ b/ui/studio/views/schema/Visualiser.tsx @@ -44,10 +44,12 @@ import { createSchemaEdges, createSchemaLayoutSignature, createSchemaNodePositions, + createSchemaVisualizerPersistentStateScope, createSchemaVisualizerStateScope, createSchemaVisualizerUiStateKey, getAutoLayoutedSchemaNodes, hasSchemaNodePositionsForAllNodes, + mergeSchemaNodePositions, type SchemaNodeData, type SchemaNodePositions, } from "./schema-layout"; @@ -238,6 +240,7 @@ export function SchemaVisualization({ metadata: { activeSchema }, } = useNavigation(); const nodePositionsRef = useRef({}); + const manualNodePositionsRef = useRef({}); const reactFlowInstanceRef = useRef | null>( null, ); @@ -273,10 +276,20 @@ export function SchemaVisualization({ () => createSchemaLayoutSignature(baseNodes, initialEdges), [baseNodes, initialEdges], ); + const persistentStateScope = useMemo( + () => createSchemaVisualizerPersistentStateScope(activeSchema?.name), + [activeSchema?.name], + ); const [nodePositions, setNodePositions] = useUiState( createSchemaVisualizerUiStateKey(stateScope, "node-positions"), {}, ); + const [manualNodePositions, setManualNodePositions] = + useUiState( + createSchemaVisualizerUiStateKey(persistentStateScope, "node-positions"), + {}, + { persistent: true }, + ); const [_autoLayoutPositions, setAutoLayoutPositions] = useUiState( createSchemaVisualizerUiStateKey( @@ -308,6 +321,10 @@ export function SchemaVisualization({ nodePositionsRef.current = nodePositions; }, [nodePositions]); + useEffect(() => { + manualNodePositionsRef.current = manualNodePositions; + }, [manualNodePositions]); + const scheduleFitView = useCallback(() => { if (typeof window === "undefined") { return; @@ -346,7 +363,12 @@ export function SchemaVisualization({ createSchemaNodePositions(layoutedNodes); setAutoLayoutPositions(nextAutoLayoutPositions); - setNodePositions(nextAutoLayoutPositions); + setNodePositions( + mergeSchemaNodePositions( + nextAutoLayoutPositions, + manualNodePositionsRef.current, + ), + ); } catch { if (cancelled) { return; @@ -355,7 +377,12 @@ export function SchemaVisualization({ const fallbackPositions = createSchemaNodePositions(baseNodes); setAutoLayoutPositions(fallbackPositions); - setNodePositions(fallbackPositions); + setNodePositions( + mergeSchemaNodePositions( + fallbackPositions, + manualNodePositionsRef.current, + ), + ); } setHasAutoLayout(true); @@ -418,8 +445,12 @@ export function SchemaVisualization({ ...nodePositionsRef.current, ...nextPositions, }); + setManualNodePositions({ + ...manualNodePositionsRef.current, + ...nextPositions, + }); }, - [setNodePositions], + [setManualNodePositions, setNodePositions], ); const controlStyles = cn( diff --git a/ui/studio/views/schema/schema-layout.test.ts b/ui/studio/views/schema/schema-layout.test.ts index c235f752..e1b32717 100644 --- a/ui/studio/views/schema/schema-layout.test.ts +++ b/ui/studio/views/schema/schema-layout.test.ts @@ -3,9 +3,12 @@ import { describe, expect, it, vi } from "vitest"; import type { Table } from "../../../hooks/use-schema-visualization"; import { + createSchemaVisualizerPersistentStateScope, + createSchemaVisualizerUiStateKey, doSchemaNodePositionsDiffer, getAutoLayoutedSchemaNodes, type LayoutEngine, + mergeSchemaNodePositions, } from "./schema-layout"; function createTable(name: string, fieldCount: number): Table { @@ -108,4 +111,44 @@ describe("schema-layout", () => { ), ).toBe(false); }); + + it("prefers saved positions over auto layout when merging", () => { + expect( + mergeSchemaNodePositions( + { + posts: { x: 300, y: 140 }, + users: { x: 0, y: 0 }, + }, + { + users: { x: 333, y: 444 }, + }, + ), + ).toEqual({ + posts: { x: 300, y: 140 }, + users: { x: 333, y: 444 }, + }); + }); + + it("scopes persistent layout state by schema name", () => { + const publicKey = createSchemaVisualizerUiStateKey( + createSchemaVisualizerPersistentStateScope("public"), + "node-positions", + ); + const otherKey = createSchemaVisualizerUiStateKey( + createSchemaVisualizerPersistentStateScope("analytics"), + "node-positions", + ); + const unknownKey = createSchemaVisualizerUiStateKey( + createSchemaVisualizerPersistentStateScope(undefined), + "node-positions", + ); + + expect(publicKey).toBe( + "schema-visualizer:public:manual-layout:node-positions", + ); + expect(publicKey).not.toBe(otherKey); + expect(unknownKey).toBe( + "schema-visualizer:__unknown__:manual-layout:node-positions", + ); + }); }); diff --git a/ui/studio/views/schema/schema-layout.ts b/ui/studio/views/schema/schema-layout.ts index e84bb110..199d2892 100644 --- a/ui/studio/views/schema/schema-layout.ts +++ b/ui/studio/views/schema/schema-layout.ts @@ -453,6 +453,22 @@ export function createSchemaVisualizerUiStateKey( return `schema-visualizer:${stateScope}:${key}`; } +export function createSchemaVisualizerPersistentStateScope( + schemaName: string | undefined, +): string { + return `${schemaName ?? "__unknown__"}:manual-layout`; +} + +export function mergeSchemaNodePositions( + autoLayoutPositions: SchemaNodePositions, + savedPositions: SchemaNodePositions, +): SchemaNodePositions { + return { + ...autoLayoutPositions, + ...savedPositions, + }; +} + export function createSchemaNodePositions( nodes: Pick[], ): SchemaNodePositions {