Skip to content

Commit b2ffdae

Browse files
authored
fix: improve schema visualizer layout (#1460)
1 parent 226a239 commit b2ffdae

11 files changed

Lines changed: 1288 additions & 218 deletions

File tree

Architecture/ui-state.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ The following are valid examples of UI state and where they belong:
128128
- Navigation table-name search term/open state: `uiLocalStateCollection` via `useUiState`
129129
- Navigation table-selection grid-focus request: `uiLocalStateCollection` via `useUiState`
130130
- Navigation table-name source rows: `navigationTableNamesCollection`
131+
- Schema visualizer node positions and layout state: `uiLocalStateCollection` via `useUiState`
132+
- Scoped by active schema plus the current visualized table set so returning to the same schema graph restores dragged positions without leaking across schemas.
133+
- Includes the stored ELK baseline positions and reset-layout request token used by the header action.
131134
- 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
132135

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

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
- Keep PostgreSQL `date` and `timestamp` cells aligned with the stored values by normalizing `postgres.js` results before Studio renders them, so host-local timezones no longer shift table timestamps.
88
- Simplify the Compute demo bundling path around `@prisma/dev@0.22.3`, so the deploy build no longer manually copies PGlite runtime assets and plain Bun server bundles no longer need `--packages external`.
9+
- Auto-arrange the schema visualizer with ELK, space disconnected tables cleanly, and add a `Reset layout` action while keeping dragged node positions when you leave and return.
910

1011
## 0.27.3
1112

FEATURES.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ Choosing a filtered table, whether by `Enter` or mouse click, closes the search
3939
## Schema Visualizer
4040

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

4446
## Data Grid Browsing

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,7 @@
243243
},
244244
"dependencies": {
245245
"@radix-ui/react-toggle": "1.1.10",
246-
"chart.js": "4.5.1"
246+
"chart.js": "4.5.1",
247+
"elkjs": "0.11.1"
247248
}
248249
}

pnpm-lock.yaml

Lines changed: 7 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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

Lines changed: 138 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,82 @@ import { act } from "react";
33
import { createRoot } from "react-dom/client";
44
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
55

6+
import type { SchemaVisualizationData } from "../../../hooks/use-schema-visualization";
67
import { SchemaView } from "./SchemaView";
78

8-
const { useSchemaVisualizationMock } = vi.hoisted(() => ({
9-
useSchemaVisualizationMock: vi.fn<
10-
() => {
11-
tables: [];
12-
relationships: [];
13-
}
14-
>(),
9+
function cloneMockValue<T>(value: T): T {
10+
return structuredClone(value);
11+
}
12+
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+
}));
25+
26+
vi.mock("@/ui/hooks/use-navigation", () => ({
27+
useNavigation: useNavigationMock,
1528
}));
1629

30+
vi.mock("@/ui/hooks/use-ui-state", async () => {
31+
const React = await vi.importActual<typeof import("react")>("react");
32+
33+
return {
34+
useUiState: <T,>(key: string, initialValue: T) => {
35+
const [value, setValue] = React.useState<T>(() => {
36+
if (!uiStateStore.has(key)) {
37+
uiStateStore.set(key, cloneMockValue(initialValue));
38+
}
39+
40+
return (
41+
(uiStateStore.get(key) as T | undefined) ??
42+
cloneMockValue(initialValue)
43+
);
44+
});
45+
46+
const setSharedValue = React.useCallback(
47+
(updater: T | ((previous: T) => T)) => {
48+
setValue((previous) => {
49+
const nextValue =
50+
typeof updater === "function"
51+
? (updater as (previous: T) => T)(previous)
52+
: updater;
53+
54+
uiStateStore.set(key, cloneMockValue(nextValue));
55+
return cloneMockValue(nextValue);
56+
});
57+
},
58+
[key],
59+
);
60+
61+
return [value, setSharedValue] as const;
62+
},
63+
};
64+
});
65+
1766
vi.mock("../../../hooks/use-schema-visualization", () => ({
1867
useSchemaVisualization: useSchemaVisualizationMock,
1968
}));
2069

2170
vi.mock("../../StudioHeader", () => ({
22-
StudioHeader: ({ children }: { children?: ReactNode }) => (
23-
<div data-testid="studio-header">{children}</div>
71+
StudioHeader: ({
72+
children,
73+
endContent,
74+
}: {
75+
children?: ReactNode;
76+
endContent?: ReactNode;
77+
}) => (
78+
<div data-testid="studio-header">
79+
<div>{children}</div>
80+
<div data-testid="studio-header-end">{endContent}</div>
81+
</div>
2482
),
2583
}));
2684

@@ -34,6 +92,12 @@ vi.mock("./Visualiser", () => ({
3492

3593
describe("SchemaView", () => {
3694
beforeEach(() => {
95+
uiStateStore.clear();
96+
useNavigationMock.mockReturnValue({
97+
metadata: {
98+
activeSchema: { name: "public" },
99+
},
100+
});
37101
useSchemaVisualizationMock.mockReturnValue({
38102
tables: [],
39103
relationships: [],
@@ -68,4 +132,69 @@ describe("SchemaView", () => {
68132
});
69133
container.remove();
70134
});
135+
136+
it("shows reset layout only after positions diverge from the stored auto layout", () => {
137+
useSchemaVisualizationMock.mockReturnValue({
138+
tables: [
139+
{ fields: [], name: "users" },
140+
{ fields: [], name: "posts" },
141+
],
142+
relationships: [],
143+
});
144+
145+
uiStateStore.set("schema-visualizer:public:posts|users:node-positions", {
146+
posts: { x: 420, y: 220 },
147+
users: { x: 333, y: 444 },
148+
});
149+
uiStateStore.set(
150+
"schema-visualizer:public:posts|users:auto-layout-node-positions",
151+
{
152+
posts: { x: 420, y: 220 },
153+
users: { x: 120, y: 80 },
154+
},
155+
);
156+
uiStateStore.set(
157+
"schema-visualizer:public:posts|users:reset-layout-version",
158+
0,
159+
);
160+
161+
const container = document.createElement("div");
162+
document.body.appendChild(container);
163+
const root = createRoot(container);
164+
165+
act(() => {
166+
root.render(<SchemaView />);
167+
});
168+
169+
const resetButton = Array.from(container.querySelectorAll("button")).find(
170+
(button) => button.textContent?.includes("Reset layout"),
171+
);
172+
173+
expect(resetButton).toBeTruthy();
174+
175+
act(() => {
176+
resetButton?.dispatchEvent(
177+
new MouseEvent("click", { bubbles: true, cancelable: true }),
178+
);
179+
});
180+
181+
expect(
182+
uiStateStore.get("schema-visualizer:public:posts|users:node-positions"),
183+
).toEqual(
184+
uiStateStore.get(
185+
"schema-visualizer:public:posts|users:auto-layout-node-positions",
186+
),
187+
);
188+
expect(
189+
uiStateStore.get(
190+
"schema-visualizer:public:posts|users:reset-layout-version",
191+
),
192+
).toBe(1);
193+
expect(container.textContent).not.toContain("Reset layout");
194+
195+
act(() => {
196+
root.unmount();
197+
});
198+
container.remove();
199+
});
71200
});

ui/studio/views/schema/SchemaView.tsx

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,70 @@
1-
import { Key } from "lucide-react";
1+
import { Key, RotateCcw } from "lucide-react";
2+
import { useMemo } from "react";
3+
4+
import { Button } from "@/ui/components/ui/button";
5+
import { useNavigation } from "@/ui/hooks/use-navigation";
6+
import { useUiState } from "@/ui/hooks/use-ui-state";
27

38
import { useSchemaVisualization } from "../../../hooks/use-schema-visualization";
49
import { StudioHeader } from "../../StudioHeader";
510
import { ViewProps } from "../View";
11+
import {
12+
createSchemaVisualizerStateScope,
13+
createSchemaVisualizerUiStateKey,
14+
doSchemaNodePositionsDiffer,
15+
type SchemaNodePositions,
16+
} from "./schema-layout";
617
import { SchemaVisualization } from "./Visualiser";
718

819
export function SchemaView(_props: ViewProps) {
920
const { tables, relationships } = useSchemaVisualization();
21+
const {
22+
metadata: { activeSchema },
23+
} = useNavigation();
24+
const stateScope = useMemo(
25+
() => createSchemaVisualizerStateScope(activeSchema?.name, tables),
26+
[activeSchema?.name, tables],
27+
);
28+
const nodeIds = useMemo(
29+
() =>
30+
tables
31+
.map((table) => table.name)
32+
.sort((left, right) => left.localeCompare(right)),
33+
[tables],
34+
);
35+
const [nodePositions, setNodePositions] = useUiState<SchemaNodePositions>(
36+
createSchemaVisualizerUiStateKey(stateScope, "node-positions"),
37+
{},
38+
);
39+
const [autoLayoutPositions] = useUiState<SchemaNodePositions>(
40+
createSchemaVisualizerUiStateKey(stateScope, "auto-layout-node-positions"),
41+
{},
42+
);
43+
const [, setResetLayoutVersion] = useUiState<number>(
44+
createSchemaVisualizerUiStateKey(stateScope, "reset-layout-version"),
45+
0,
46+
);
47+
const isResetLayoutVisible =
48+
Object.keys(autoLayoutPositions).length > 0 &&
49+
doSchemaNodePositionsDiffer(nodeIds, nodePositions, autoLayoutPositions);
50+
51+
const resetLayoutButton = isResetLayoutVisible ? (
52+
<Button
53+
size="sm"
54+
variant="outline"
55+
onClick={() => {
56+
setNodePositions(autoLayoutPositions);
57+
setResetLayoutVersion((currentVersion) => currentVersion + 1);
58+
}}
59+
>
60+
<RotateCcw data-icon="inline-start" />
61+
Reset layout
62+
</Button>
63+
) : null;
1064

1165
return (
1266
<>
13-
<StudioHeader>
67+
<StudioHeader endContent={resetLayoutButton}>
1468
{/* Legend Item: Primary Key */}
1569
<div className="flex items-center gap-2">
1670
<span className="flex size-5 items-center justify-center rounded-full bg-muted p-0.5 text-muted-foreground">

0 commit comments

Comments
 (0)