Skip to content

Commit 34fd4f8

Browse files
authored
Rename graph layout to graph view and flatten sidebar hooks (#1874)
Align the graph-view layout state with the canonical "Graph View" vocabulary already declared in CONTEXT.md ("Graph Explorer" is listed as a term to avoid), and collapse the three separate graph sidebar hooks into one deep module. Rename: - userLayoutAtom → graphViewLayoutAtom - UserLayout → GraphViewLayout - SidebarItems → GraphViewSidebarItem - useSidebar → useGraphViewSidebar - userLayout.ts / userLayoutDefaults.ts → graphViewLayout.ts / graphViewLayoutDefaults.ts Flatten: useSidebar, useSidebarSize, and useAutoOpenDetailsSidebar are merged into a single useGraphViewSidebar that owns toggle, resize, close, and auto-open. Storage key: the shipped "user-layout" migrates to "graph-view-layout" directly via migrateUserLayout (idempotent, old key left as rollback). Auto-open behavior: the hook now treats an undefined detailsAutoOpenOnSelection as enabled (=== false guard, ?? true reads), matching the toggle button's displayed state, and exposes toggleDetailsAutoOpen so the button flips state internally rather than recomputing it at the call site. All three value states (true, false, undefined) are covered by tests.
1 parent e1787df commit 34fd4f8

18 files changed

Lines changed: 675 additions & 507 deletions
Lines changed: 347 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,347 @@
1+
// @vitest-environment happy-dom
2+
import { act } from "react";
3+
4+
import {
5+
DbState,
6+
renderHookWithJotai,
7+
renderHookWithState,
8+
} from "@/utils/testing";
9+
10+
import type { GraphViewLayout } from "./graphViewLayout";
11+
12+
import {
13+
useGraphViewSidebar,
14+
useTableViewSize,
15+
useViewToggles,
16+
} from "./graphViewLayout";
17+
import { graphViewLayoutAtom } from "./storageAtoms";
18+
19+
describe("useViewToggles", () => {
20+
it("should default to both views open", () => {
21+
const { result } = renderHookWithState(() => useViewToggles());
22+
23+
expect(result.current.isGraphVisible).toBe(true);
24+
expect(result.current.isTableVisible).toBe(true);
25+
});
26+
27+
it("should toggle graph view", () => {
28+
const { result } = renderHookWithState(() => useViewToggles());
29+
30+
act(() => result.current.toggleGraphVisibility());
31+
32+
expect(result.current.isGraphVisible).toBe(false);
33+
expect(result.current.isTableVisible).toBe(true);
34+
35+
act(() => result.current.toggleGraphVisibility());
36+
37+
expect(result.current.isGraphVisible).toBe(true);
38+
expect(result.current.isTableVisible).toBe(true);
39+
});
40+
41+
it("should toggle table view", () => {
42+
const { result } = renderHookWithState(() => useViewToggles());
43+
44+
act(() => result.current.toggleTableVisibility());
45+
46+
expect(result.current.isGraphVisible).toBe(true);
47+
expect(result.current.isTableVisible).toBe(false);
48+
49+
act(() => result.current.toggleTableVisibility());
50+
51+
expect(result.current.isGraphVisible).toBe(true);
52+
expect(result.current.isTableVisible).toBe(true);
53+
});
54+
});
55+
56+
describe("useGraphViewSidebar", () => {
57+
it("should default to sidebar open", () => {
58+
const { result } = renderHookWithJotai(() => useGraphViewSidebar());
59+
60+
expect(result.current.isSidebarOpen).toBe(true);
61+
});
62+
63+
it("should change to the given sidebar item", () => {
64+
const { result } = renderHookWithJotai(() => useGraphViewSidebar());
65+
66+
act(() => result.current.toggleSidebar("details"));
67+
expect(result.current.isSidebarOpen).toBe(true);
68+
expect(result.current.activeSidebarItem).toBe("details");
69+
70+
act(() => result.current.toggleSidebar("search"));
71+
expect(result.current.isSidebarOpen).toBe(true);
72+
expect(result.current.activeSidebarItem).toBe("search");
73+
});
74+
75+
it("should close the sidebar if toggling to the same item", () => {
76+
const { result } = renderHookWithJotai(
77+
() => useGraphViewSidebar(),
78+
store =>
79+
store.set(graphViewLayoutAtom, {
80+
activeSidebarItem: "details",
81+
activeToggles: new Set(),
82+
sidebar: { width: 400 },
83+
} satisfies GraphViewLayout),
84+
);
85+
86+
act(() => result.current.toggleSidebar("details"));
87+
88+
expect(result.current.isSidebarOpen).toBe(false);
89+
expect(result.current.activeSidebarItem).toBeNull();
90+
});
91+
92+
it("should close the sidebar", () => {
93+
const { result } = renderHookWithJotai(() => useGraphViewSidebar());
94+
95+
act(() => result.current.closeSidebar());
96+
97+
expect(result.current.isSidebarOpen).toBe(false);
98+
});
99+
100+
it("should show namespaces when the connection is a SPARQL connection", () => {
101+
const dbState = new DbState();
102+
dbState.activeConfig.connection!.queryEngine = "sparql";
103+
104+
const { result } = renderHookWithJotai(
105+
() => useGraphViewSidebar(),
106+
store => {
107+
dbState.applyTo(store);
108+
store.set(graphViewLayoutAtom, {
109+
activeSidebarItem: "namespaces",
110+
activeToggles: new Set(),
111+
sidebar: { width: 400 },
112+
} satisfies GraphViewLayout);
113+
},
114+
);
115+
116+
expect(result.current.isSidebarOpen).toBe(true);
117+
expect(result.current.activeSidebarItem).toBe("namespaces");
118+
expect(result.current.shouldShowNamespaces).toBe(true);
119+
});
120+
121+
it("should be closed when active item is namespaces but connection is not RDF", () => {
122+
const dbState = new DbState();
123+
dbState.activeConfig.connection!.queryEngine = "gremlin";
124+
125+
const { result } = renderHookWithJotai(
126+
() => useGraphViewSidebar(),
127+
store => {
128+
dbState.applyTo(store);
129+
store.set(graphViewLayoutAtom, {
130+
activeSidebarItem: "namespaces",
131+
activeToggles: new Set(),
132+
sidebar: { width: 400 },
133+
} satisfies GraphViewLayout);
134+
},
135+
);
136+
137+
expect(result.current.isSidebarOpen).toBe(false);
138+
expect(result.current.activeSidebarItem).toBeNull();
139+
expect(result.current.shouldShowNamespaces).toBe(false);
140+
});
141+
142+
it("should return persisted sidebar width", () => {
143+
const { result } = renderHookWithJotai(
144+
() => useGraphViewSidebar(),
145+
store =>
146+
store.set(graphViewLayoutAtom, {
147+
activeSidebarItem: "search",
148+
activeToggles: new Set(),
149+
sidebar: { width: 500 },
150+
} satisfies GraphViewLayout),
151+
);
152+
153+
expect(result.current.sidebarWidth).toBe(500);
154+
});
155+
156+
it("should adjust sidebar width by delta", () => {
157+
const { result } = renderHookWithJotai(() => useGraphViewSidebar());
158+
159+
act(() => result.current.setSidebarWidth(100));
160+
expect(result.current.sidebarWidth).toBe(500);
161+
162+
act(() => result.current.setSidebarWidth(-200));
163+
expect(result.current.sidebarWidth).toBe(300);
164+
});
165+
166+
it("should auto-open details when detailsAutoOpenOnSelection is true", () => {
167+
const { result } = renderHookWithJotai(
168+
() => useGraphViewSidebar(),
169+
store =>
170+
store.set(graphViewLayoutAtom, {
171+
activeSidebarItem: "search",
172+
activeToggles: new Set(),
173+
sidebar: { width: 400 },
174+
detailsAutoOpenOnSelection: true,
175+
} satisfies GraphViewLayout),
176+
);
177+
178+
act(() => result.current.autoOpenDetails());
179+
180+
expect(result.current.activeSidebarItem).toBe("details");
181+
});
182+
183+
it("should not auto-open details when detailsAutoOpenOnSelection is false", () => {
184+
const { result } = renderHookWithJotai(
185+
() => useGraphViewSidebar(),
186+
store =>
187+
store.set(graphViewLayoutAtom, {
188+
activeSidebarItem: "search",
189+
activeToggles: new Set(),
190+
sidebar: { width: 400 },
191+
detailsAutoOpenOnSelection: false,
192+
} satisfies GraphViewLayout),
193+
);
194+
195+
act(() => result.current.autoOpenDetails());
196+
197+
expect(result.current.activeSidebarItem).toBe("search");
198+
});
199+
200+
it("should auto-open details when detailsAutoOpenOnSelection is undefined (legacy data)", () => {
201+
const { result } = renderHookWithJotai(
202+
() => useGraphViewSidebar(),
203+
store =>
204+
store.set(graphViewLayoutAtom, {
205+
activeSidebarItem: "search",
206+
activeToggles: new Set(),
207+
sidebar: { width: 400 },
208+
} satisfies GraphViewLayout),
209+
);
210+
211+
act(() => result.current.autoOpenDetails());
212+
213+
expect(result.current.activeSidebarItem).toBe("details");
214+
});
215+
216+
it("should report detailsAutoOpenOnSelection as enabled by default", () => {
217+
const { result } = renderHookWithJotai(() => useGraphViewSidebar());
218+
219+
expect(result.current.detailsAutoOpenOnSelection).toBe(true);
220+
});
221+
222+
it("should toggle detailsAutoOpenOnSelection from true to false", () => {
223+
const { result } = renderHookWithJotai(
224+
() => useGraphViewSidebar(),
225+
store =>
226+
store.set(graphViewLayoutAtom, {
227+
activeSidebarItem: "search",
228+
activeToggles: new Set(),
229+
sidebar: { width: 400 },
230+
detailsAutoOpenOnSelection: true,
231+
} satisfies GraphViewLayout),
232+
);
233+
234+
act(() => result.current.toggleDetailsAutoOpen());
235+
236+
expect(result.current.detailsAutoOpenOnSelection).toBe(false);
237+
});
238+
239+
it("should toggle detailsAutoOpenOnSelection from false to true", () => {
240+
const { result } = renderHookWithJotai(
241+
() => useGraphViewSidebar(),
242+
store =>
243+
store.set(graphViewLayoutAtom, {
244+
activeSidebarItem: "search",
245+
activeToggles: new Set(),
246+
sidebar: { width: 400 },
247+
detailsAutoOpenOnSelection: false,
248+
} satisfies GraphViewLayout),
249+
);
250+
251+
act(() => result.current.toggleDetailsAutoOpen());
252+
253+
expect(result.current.detailsAutoOpenOnSelection).toBe(true);
254+
});
255+
256+
it("should toggle detailsAutoOpenOnSelection from undefined (enabled) to false", () => {
257+
const { result } = renderHookWithJotai(
258+
() => useGraphViewSidebar(),
259+
store =>
260+
store.set(graphViewLayoutAtom, {
261+
activeSidebarItem: "search",
262+
activeToggles: new Set(),
263+
sidebar: { width: 400 },
264+
} satisfies GraphViewLayout),
265+
);
266+
267+
// The button shows enabled (undefined reads as `?? true`), so toggling
268+
// must turn it off, not flip `!undefined` to true.
269+
act(() => result.current.toggleDetailsAutoOpen());
270+
271+
expect(result.current.detailsAutoOpenOnSelection).toBe(false);
272+
});
273+
});
274+
275+
describe("useTableViewSize", () => {
276+
it("should default to DEFAULT_TABLE_VIEW_HEIGHT", () => {
277+
const { result } = renderHookWithState(() => useTableViewSize());
278+
279+
expect(result.current[0]).toBe(300);
280+
});
281+
282+
it("should return 100% when graph viewer is hidden", () => {
283+
const { result } = renderHookWithState(() => ({
284+
tableView: useTableViewSize(),
285+
toggles: useViewToggles(),
286+
}));
287+
288+
act(() => result.current.toggles.toggleGraphVisibility());
289+
290+
expect(result.current.tableView[0]).toBe("100%");
291+
});
292+
293+
it("should adjust height by delta", () => {
294+
const { result } = renderHookWithState(() => useTableViewSize());
295+
296+
act(() => result.current[1](50));
297+
expect(result.current[0]).toBe(350);
298+
299+
act(() => result.current[1](-100));
300+
expect(result.current[0]).toBe(250);
301+
});
302+
});
303+
304+
/**
305+
* BACKWARD COMPATIBILITY — PERSISTED DATA
306+
*
307+
* GraphViewLayout is persisted to IndexedDB via localforage. Older versions
308+
* stored the layout with a flat `sidebar?: { width: number }` field (optional)
309+
* and used `SidebarItems` type name (now `GraphViewSidebarItem`). Previously
310+
* persisted data may not have the `sidebar` field at all. These tests verify
311+
* that the hooks still work correctly with the old shape.
312+
*
313+
* DO NOT delete or weaken these tests without confirming that all persisted
314+
* data has been migrated or that the old shape is no longer in the wild.
315+
*/
316+
describe("backward compatibility: missing sidebar field", () => {
317+
it("should fall back to default width when sidebar field is absent", () => {
318+
const legacyLayout = {
319+
activeSidebarItem: "search",
320+
activeToggles: new Set(["graph-viewer", "table-view"]),
321+
detailsAutoOpenOnSelection: true,
322+
} as GraphViewLayout;
323+
324+
const { result } = renderHookWithJotai(
325+
() => useGraphViewSidebar(),
326+
store => store.set(graphViewLayoutAtom, legacyLayout),
327+
);
328+
329+
expect(result.current.sidebarWidth).toBe(400);
330+
expect(result.current.isSidebarOpen).toBe(true);
331+
});
332+
333+
it("should adjust width by delta even when sidebar field was absent", () => {
334+
const legacyLayout = {
335+
activeSidebarItem: "search",
336+
activeToggles: new Set(),
337+
} as GraphViewLayout;
338+
339+
const { result } = renderHookWithJotai(
340+
() => useGraphViewSidebar(),
341+
store => store.set(graphViewLayoutAtom, legacyLayout),
342+
);
343+
344+
act(() => result.current.setSidebarWidth(50));
345+
expect(result.current.sidebarWidth).toBe(450);
346+
});
347+
});

0 commit comments

Comments
 (0)