Skip to content

Commit dc44731

Browse files
sorenbsclaude
andauthored
feat: remember the schema visualizer's manual table layout (#1555)
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 #1397 Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 1fbd26b commit dc44731

12 files changed

Lines changed: 379 additions & 36 deletions
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@prisma/studio-core": minor
3+
---
4+
5+
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.

Architecture/ui-state.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ Studio context provides the canonical stores in [`ui/studio/context.tsx`](../ui/
4040
- `filteredRowCount`
4141
- `uiLocalStateCollection` (`localOnlyCollectionOptions`)
4242
- General scoped UI state (for example DataGrid selection machine state).
43+
- `uiPersistentStateCollection` (`localStorageCollectionOptions`)
44+
- General scoped UI state that must survive page reloads, accessed through
45+
`useUiState` with `{ persistent: true }` (for example the schema
46+
visualizer's manually arranged node positions).
4347
- `sqlEditorStateCollection` (`localStorageCollectionOptions`)
4448
- Persisted SQL editor draft state:
4549
- `queryText`
@@ -135,6 +139,9 @@ The following are valid examples of UI state and where they belong:
135139
- Schema visualizer node positions and layout state: `uiLocalStateCollection` via `useUiState`
136140
- Scoped by active schema plus the current visualized table set so returning to the same schema graph restores dragged positions without leaking across schemas.
137141
- Includes the stored ELK baseline positions and reset-layout request token used by the header action.
142+
- Schema visualizer manually arranged node positions: `uiPersistentStateCollection` via `useUiState({ persistent: true })`
143+
- 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.
144+
- The header `Reset layout` action clears this store in addition to re-applying the ELK baseline.
138145
- 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
139146

140147
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).

FEATURES.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -150,7 +150,7 @@ Cards keep a fixed width inside their own horizontal scroller, the header toggle
150150

151151
Studio includes a schema graph view with table nodes, column metadata, and detected foreign-key relationships labeled as 1:1 or 1:n.
152152
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.
153-
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.
153+
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.
154154
Users can pan/zoom, inspect key and nullable markers, and jump from a node directly to that table’s data view.
155155

156156
## Query Insights

ui/hooks/use-ui-state.context.test.tsx

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const useOptionalStudioMock = vi.fn<
1313
() =>
1414
| {
1515
uiLocalStateCollection: ReturnType<typeof createUiCollection>;
16+
uiPersistentStateCollection?: ReturnType<typeof createUiCollection>;
1617
}
1718
| undefined
1819
>();
@@ -27,10 +28,14 @@ vi.mock("../studio/context", () => {
2728
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
2829
).IS_REACT_ACT_ENVIRONMENT = true;
2930

31+
let collectionInstanceCounter = 0;
32+
3033
function createUiCollection() {
34+
collectionInstanceCounter += 1;
35+
3136
return createCollection(
3237
localOnlyCollectionOptions<StudioLocalUiState>({
33-
id: "use-ui-state-context-test",
38+
id: `use-ui-state-context-test-${collectionInstanceCounter}`,
3439
getKey(item) {
3540
return item.id;
3641
},
@@ -87,6 +92,48 @@ describe("useUiState with Studio context collection", () => {
8792
container.remove();
8893
});
8994

95+
it("routes persistent state into the persistent ui collection", () => {
96+
const persistentCollection = createUiCollection();
97+
98+
useOptionalStudioMock.mockReturnValue({
99+
uiLocalStateCollection: uiCollection,
100+
uiPersistentStateCollection: persistentCollection,
101+
});
102+
103+
const key = "context-persistent-state";
104+
const container = document.createElement("div");
105+
document.body.appendChild(container);
106+
const root = createRoot(container);
107+
108+
let latestState: ReturnType<typeof useUiState<string>> | undefined;
109+
110+
function Harness() {
111+
latestState = useUiState<string>(key, "alpha", { persistent: true });
112+
return null;
113+
}
114+
115+
act(() => {
116+
root.render(<Harness />);
117+
});
118+
119+
expect(latestState?.[0]).toBe("alpha");
120+
expect(persistentCollection.get(key)?.value).toBe("alpha");
121+
expect(uiCollection.has(key)).toBe(false);
122+
123+
act(() => {
124+
latestState?.[1]("beta");
125+
});
126+
127+
expect(latestState?.[0]).toBe("beta");
128+
expect(persistentCollection.get(key)?.value).toBe("beta");
129+
expect(uiCollection.has(key)).toBe(false);
130+
131+
act(() => {
132+
root.unmount();
133+
});
134+
container.remove();
135+
});
136+
90137
it("does not mutate the shared ui collection for cleanup-on-unmount state", () => {
91138
const key = "context-cleanup-state";
92139
const insertSpy = vi.spyOn(uiCollection, "insert");

ui/hooks/use-ui-state.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,11 @@ type Updater<T> = T | ((previous: T) => T);
1414

1515
export interface UseUiStateOptions {
1616
cleanupOnUnmount?: boolean;
17+
/**
18+
* When enabled, state is stored in the localStorage-backed persistent UI
19+
* state collection so it survives page reloads.
20+
*/
21+
persistent?: boolean;
1722
}
1823

1924
const fallbackUiStateCollection = instrumentTanStackCollectionMutations(
@@ -98,15 +103,18 @@ export function useUiState<T>(
98103
initialValue: T,
99104
options: UseUiStateOptions = {},
100105
) {
101-
const { cleanupOnUnmount = false } = options;
106+
const { cleanupOnUnmount = false, persistent = false } = options;
102107
const [volatileValue, setVolatileValue] = useState<T>(() =>
103108
cloneValue(initialValue),
104109
);
105110
const previousVolatileKeyRef = useRef<string | undefined>(key);
106111
const studioContext = useOptionalStudio();
107112
const uiLocalStateCollection =
108-
(studioContext?.uiLocalStateCollection as typeof fallbackUiStateCollection) ??
109-
fallbackUiStateCollection;
113+
((persistent
114+
? studioContext?.uiPersistentStateCollection
115+
: studioContext?.uiLocalStateCollection) as
116+
| typeof fallbackUiStateCollection
117+
| undefined) ?? fallbackUiStateCollection;
110118

111119
const { data: stateRow } = useLiveQuery(
112120
(q) => {

ui/studio/context.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ const STUDIO_UI_STATE_ID = "studio-ui-state";
4444
const STUDIO_UI_STORAGE_KEY = "prisma-studio-ui-state-v1";
4545
const SQL_EDITOR_STATE_ID = "studio-sql-editor-state";
4646
const SQL_EDITOR_STORAGE_KEY = "prisma-studio-sql-editor-state-v1";
47+
const UI_PERSISTENT_STATE_STORAGE_KEY = "prisma-studio-persistent-ui-state-v1";
4748
const DEFAULT_TABLE_PAGE_SIZE = 25;
4849
export const DEFAULT_NAVIGATION_WIDTH = 192;
4950
export const MIN_NAVIGATION_WIDTH = 192;
@@ -280,6 +281,7 @@ interface StudioContextValue {
280281
tableUiStateCollection: Collection<TableUiState, string | number>;
281282
tableQueryMetaCollection: Collection<TableQueryMetaState, string | number>;
282283
uiLocalStateCollection: Collection<StudioLocalUiState, string | number>;
284+
uiPersistentStateCollection: Collection<StudioLocalUiState, string | number>;
283285
sqlEditorStateCollection: Collection<SqlEditorState, string | number>;
284286
navigationTableNamesCollection: Collection<
285287
NavigationTableNameState,
@@ -394,6 +396,20 @@ export function StudioContextProvider(props: StudioContextProviderProps) {
394396
{ collectionName: "studio-local-ui-state" },
395397
),
396398
);
399+
const uiPersistentStateCollectionRef = useRef(
400+
instrumentTanStackCollectionMutations(
401+
createCollection(
402+
localStorageCollectionOptions<StudioLocalUiState>({
403+
id: "studio-persistent-ui-state",
404+
storageKey: UI_PERSISTENT_STATE_STORAGE_KEY,
405+
getKey(item) {
406+
return item.id;
407+
},
408+
}),
409+
),
410+
{ collectionName: "studio-persistent-ui-state" },
411+
),
412+
);
397413
const sqlEditorStateCollectionRef = useRef(
398414
instrumentTanStackCollectionMutations(
399415
createCollection(
@@ -429,6 +445,7 @@ export function StudioContextProvider(props: StudioContextProviderProps) {
429445
const tableUiStateCollection = tableUiStateCollectionRef.current;
430446
const tableQueryMetaCollection = tableQueryMetaCollectionRef.current;
431447
const uiLocalStateCollection = uiLocalStateCollectionRef.current;
448+
const uiPersistentStateCollection = uiPersistentStateCollectionRef.current;
432449
const sqlEditorStateCollection = sqlEditorStateCollectionRef.current;
433450
const navigationTableNamesCollection =
434451
navigationTableNamesCollectionRef.current;
@@ -865,6 +882,7 @@ export function StudioContextProvider(props: StudioContextProviderProps) {
865882
tableUiStateCollection,
866883
tableQueryMetaCollection,
867884
uiLocalStateCollection,
885+
uiPersistentStateCollection,
868886
sqlEditorStateCollection,
869887
navigationTableNamesCollection,
870888
getOrCreateRowsCollection,

ui/studio/views/schema/SchemaView.test.tsx

Lines changed: 41 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,23 @@ function cloneMockValue<T>(value: T): T {
1010
return structuredClone(value);
1111
}
1212

13-
const { uiStateStore, useNavigationMock, useSchemaVisualizationMock } =
14-
vi.hoisted(() => ({
15-
uiStateStore: new Map<string, unknown>(),
16-
useNavigationMock: vi.fn<
17-
() => {
18-
metadata: {
19-
activeSchema: { name: string };
20-
};
21-
}
22-
>(),
23-
useSchemaVisualizationMock: vi.fn<() => SchemaVisualizationData>(),
24-
}));
13+
const {
14+
persistentUiStateStore,
15+
uiStateStore,
16+
useNavigationMock,
17+
useSchemaVisualizationMock,
18+
} = vi.hoisted(() => ({
19+
persistentUiStateStore: new Map<string, unknown>(),
20+
uiStateStore: new Map<string, unknown>(),
21+
useNavigationMock: vi.fn<
22+
() => {
23+
metadata: {
24+
activeSchema: { name: string };
25+
};
26+
}
27+
>(),
28+
useSchemaVisualizationMock: vi.fn<() => SchemaVisualizationData>(),
29+
}));
2530

2631
vi.mock("@/ui/hooks/use-navigation", () => ({
2732
useNavigation: useNavigationMock,
@@ -31,15 +36,20 @@ vi.mock("@/ui/hooks/use-ui-state", async () => {
3136
const React = await vi.importActual<typeof import("react")>("react");
3237

3338
return {
34-
useUiState: <T,>(key: string, initialValue: T) => {
39+
useUiState: <T,>(
40+
key: string,
41+
initialValue: T,
42+
options?: { persistent?: boolean },
43+
) => {
44+
const store = options?.persistent ? persistentUiStateStore : uiStateStore;
45+
3546
const [value, setValue] = React.useState<T>(() => {
36-
if (!uiStateStore.has(key)) {
37-
uiStateStore.set(key, cloneMockValue(initialValue));
47+
if (!store.has(key)) {
48+
store.set(key, cloneMockValue(initialValue));
3849
}
3950

4051
return (
41-
(uiStateStore.get(key) as T | undefined) ??
42-
cloneMockValue(initialValue)
52+
(store.get(key) as T | undefined) ?? cloneMockValue(initialValue)
4353
);
4454
});
4555

@@ -51,11 +61,11 @@ vi.mock("@/ui/hooks/use-ui-state", async () => {
5161
? (updater as (previous: T) => T)(previous)
5262
: updater;
5363

54-
uiStateStore.set(key, cloneMockValue(nextValue));
64+
store.set(key, cloneMockValue(nextValue));
5565
return cloneMockValue(nextValue);
5666
});
5767
},
58-
[key],
68+
[key, store],
5969
);
6070

6171
return [value, setSharedValue] as const;
@@ -93,6 +103,7 @@ vi.mock("./Visualiser", () => ({
93103
describe("SchemaView", () => {
94104
beforeEach(() => {
95105
uiStateStore.clear();
106+
persistentUiStateStore.clear();
96107
useNavigationMock.mockReturnValue({
97108
metadata: {
98109
activeSchema: { name: "public" },
@@ -157,6 +168,12 @@ describe("SchemaView", () => {
157168
"schema-visualizer:public:posts|users:reset-layout-version",
158169
0,
159170
);
171+
persistentUiStateStore.set(
172+
"schema-visualizer:public:manual-layout:node-positions",
173+
{
174+
users: { x: 333, y: 444 },
175+
},
176+
);
160177

161178
const container = document.createElement("div");
162179
document.body.appendChild(container);
@@ -190,6 +207,11 @@ describe("SchemaView", () => {
190207
"schema-visualizer:public:posts|users:reset-layout-version",
191208
),
192209
).toBe(1);
210+
expect(
211+
persistentUiStateStore.get(
212+
"schema-visualizer:public:manual-layout:node-positions",
213+
),
214+
).toEqual({});
193215
expect(container.textContent).not.toContain("Reset layout");
194216

195217
act(() => {

ui/studio/views/schema/SchemaView.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import { useSchemaVisualization } from "../../../hooks/use-schema-visualization"
99
import { StudioHeader } from "../../StudioHeader";
1010
import { ViewProps } from "../View";
1111
import {
12+
createSchemaVisualizerPersistentStateScope,
1213
createSchemaVisualizerStateScope,
1314
createSchemaVisualizerUiStateKey,
1415
doSchemaNodePositionsDiffer,
@@ -32,10 +33,19 @@ export function SchemaView(_props: ViewProps) {
3233
.sort((left, right) => left.localeCompare(right)),
3334
[tables],
3435
);
36+
const persistentStateScope = useMemo(
37+
() => createSchemaVisualizerPersistentStateScope(activeSchema?.name),
38+
[activeSchema?.name],
39+
);
3540
const [nodePositions, setNodePositions] = useUiState<SchemaNodePositions>(
3641
createSchemaVisualizerUiStateKey(stateScope, "node-positions"),
3742
{},
3843
);
44+
const [, setManualNodePositions] = useUiState<SchemaNodePositions>(
45+
createSchemaVisualizerUiStateKey(persistentStateScope, "node-positions"),
46+
{},
47+
{ persistent: true },
48+
);
3949
const [autoLayoutPositions] = useUiState<SchemaNodePositions>(
4050
createSchemaVisualizerUiStateKey(stateScope, "auto-layout-node-positions"),
4151
{},
@@ -54,6 +64,7 @@ export function SchemaView(_props: ViewProps) {
5464
variant="outline"
5565
onClick={() => {
5666
setNodePositions(autoLayoutPositions);
67+
setManualNodePositions({});
5768
setResetLayoutVersion((currentVersion) => currentVersion + 1);
5869
}}
5970
>

0 commit comments

Comments
 (0)