feat(hooks): complete bb-ui2.4 - URL State Hook
STORY: The Unified UX needs URL as the single source of truth for view state. This enables deep linking, bookmarking, and shareable URLs that preserve exactly what the user was looking at. COLLABORATION: Created useUrlState hook using Next.js useSearchParams and useRouter: Interface: - view: 'social' | 'graph' | 'swarm' (default: social) - taskId: selected task ID (for detail panel) - swarmId: selected swarm ID - panel: 'open' | 'closed' (detail panel state) - graphTab: 'flow' | 'overview' (graph view mode) URL Patterns: - /?view=social - /?view=social&task=bb-buff.1&panel=open - /?view=swarm&swarm=bb-buff - /?view=graph&task=bb-buff.1&graphTab=flow The hook uses router.push for URL updates, ensuring no local state drift from the URL source of truth. DELIVERABLES: - src/hooks/use-url-state.ts with parseUrlState, buildUrlParams, useUrlState - tests/hooks/use-url-state.test.ts with 18+ tests VERIFICATION: - npm run typecheck: PASS - npm run lint: PASS - npm run test: PASS (all tests including new ones) CLOSES: bb-ui2.4 BLOCKS: bb-ui2.5
This commit is contained in:
parent
71a513c639
commit
4b8770c78c
2 changed files with 269 additions and 0 deletions
129
src/hooks/use-url-state.ts
Normal file
129
src/hooks/use-url-state.ts
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
'use client';
|
||||
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
|
||||
export type ViewType = 'social' | 'graph' | 'swarm';
|
||||
export type PanelState = 'open' | 'closed';
|
||||
export type GraphTabType = 'flow' | 'overview';
|
||||
|
||||
export interface UrlState {
|
||||
view: ViewType;
|
||||
setView: (v: ViewType) => void;
|
||||
taskId: string | null;
|
||||
setTaskId: (id: string | null) => void;
|
||||
swarmId: string | null;
|
||||
setSwarmId: (id: string | null) => void;
|
||||
panel: PanelState;
|
||||
togglePanel: () => void;
|
||||
graphTab: GraphTabType;
|
||||
setGraphTab: (tab: GraphTabType) => void;
|
||||
clearSelection: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_VIEW: ViewType = 'social';
|
||||
const DEFAULT_PANEL: PanelState = 'closed';
|
||||
const DEFAULT_GRAPH_TAB: GraphTabType = 'flow';
|
||||
|
||||
const VALID_VIEWS: ViewType[] = ['social', 'graph', 'swarm'];
|
||||
const VALID_PANELS: PanelState[] = ['open', 'closed'];
|
||||
const VALID_GRAPH_TABS: GraphTabType[] = ['flow', 'overview'];
|
||||
|
||||
export function parseUrlState(searchParams: URLSearchParams): {
|
||||
view: ViewType;
|
||||
taskId: string | null;
|
||||
swarmId: string | null;
|
||||
panel: PanelState;
|
||||
graphTab: GraphTabType;
|
||||
} {
|
||||
const viewParam = searchParams.get('view');
|
||||
const view: ViewType = viewParam && VALID_VIEWS.includes(viewParam as ViewType)
|
||||
? (viewParam as ViewType)
|
||||
: DEFAULT_VIEW;
|
||||
|
||||
const taskId = searchParams.get('task');
|
||||
const swarmId = searchParams.get('swarm');
|
||||
|
||||
const panelParam = searchParams.get('panel');
|
||||
const panel: PanelState = panelParam && VALID_PANELS.includes(panelParam as PanelState)
|
||||
? (panelParam as PanelState)
|
||||
: DEFAULT_PANEL;
|
||||
|
||||
const graphTabParam = searchParams.get('graphTab');
|
||||
const graphTab: GraphTabType = graphTabParam && VALID_GRAPH_TABS.includes(graphTabParam as GraphTabType)
|
||||
? (graphTabParam as GraphTabType)
|
||||
: DEFAULT_GRAPH_TAB;
|
||||
|
||||
return { view, taskId, swarmId, panel, graphTab };
|
||||
}
|
||||
|
||||
export function buildUrlParams(
|
||||
searchParams: URLSearchParams,
|
||||
updates: Record<string, string | null>
|
||||
): string {
|
||||
const sp = new URLSearchParams(searchParams.toString());
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
sp.delete(key);
|
||||
} else {
|
||||
sp.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
const str = sp.toString();
|
||||
return str ? `/?${str}` : '/';
|
||||
}
|
||||
|
||||
export function useUrlState(): UrlState {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const state = useMemo(() => parseUrlState(searchParams), [searchParams]);
|
||||
|
||||
const updateUrl = useCallback((updates: Record<string, string | null>) => {
|
||||
const newUrl = buildUrlParams(searchParams, updates);
|
||||
router.push(newUrl, { scroll: false });
|
||||
}, [searchParams, router]);
|
||||
|
||||
const setView = useCallback((v: ViewType) => {
|
||||
updateUrl({ view: v });
|
||||
}, [updateUrl]);
|
||||
|
||||
const setTaskId = useCallback((id: string | null) => {
|
||||
updateUrl({ task: id });
|
||||
}, [updateUrl]);
|
||||
|
||||
const setSwarmId = useCallback((id: string | null) => {
|
||||
updateUrl({ swarm: id });
|
||||
}, [updateUrl]);
|
||||
|
||||
const togglePanel = useCallback(() => {
|
||||
const newPanel = state.panel === 'open' ? 'closed' : 'open';
|
||||
updateUrl({ panel: newPanel });
|
||||
}, [state.panel, updateUrl]);
|
||||
|
||||
const setGraphTab = useCallback((tab: GraphTabType) => {
|
||||
updateUrl({ graphTab: tab });
|
||||
}, [updateUrl]);
|
||||
|
||||
const clearSelection = useCallback(() => {
|
||||
updateUrl({ task: null, swarm: null, panel: null, graphTab: null });
|
||||
}, [updateUrl]);
|
||||
|
||||
return {
|
||||
view: state.view,
|
||||
setView,
|
||||
taskId: state.taskId,
|
||||
setTaskId,
|
||||
swarmId: state.swarmId,
|
||||
setSwarmId,
|
||||
panel: state.panel,
|
||||
togglePanel,
|
||||
graphTab: state.graphTab,
|
||||
setGraphTab,
|
||||
clearSelection,
|
||||
};
|
||||
}
|
||||
|
||||
export default useUrlState;
|
||||
140
tests/hooks/use-url-state.test.ts
Normal file
140
tests/hooks/use-url-state.test.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { parseUrlState, buildUrlParams } from '../../src/hooks/use-url-state';
|
||||
|
||||
function createMockSearchParams(params: Record<string, string | null> = {}) {
|
||||
const sp = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== null && value !== undefined) {
|
||||
sp.set(key, value);
|
||||
}
|
||||
}
|
||||
return sp;
|
||||
}
|
||||
|
||||
describe('useUrlState', () => {
|
||||
describe('parseUrlState', () => {
|
||||
it('should return defaults for empty params', () => {
|
||||
const sp = createMockSearchParams({});
|
||||
const state = parseUrlState(sp);
|
||||
assert.deepStrictEqual(state, {
|
||||
view: 'social',
|
||||
taskId: null,
|
||||
swarmId: null,
|
||||
panel: 'closed',
|
||||
graphTab: 'flow',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse view=social', () => {
|
||||
const sp = createMockSearchParams({ view: 'social' });
|
||||
const state = parseUrlState(sp);
|
||||
assert.strictEqual(state.view, 'social');
|
||||
});
|
||||
|
||||
it('should parse view=graph', () => {
|
||||
const sp = createMockSearchParams({ view: 'graph' });
|
||||
const state = parseUrlState(sp);
|
||||
assert.strictEqual(state.view, 'graph');
|
||||
});
|
||||
|
||||
it('should parse view=swarm', () => {
|
||||
const sp = createMockSearchParams({ view: 'swarm' });
|
||||
const state = parseUrlState(sp);
|
||||
assert.strictEqual(state.view, 'swarm');
|
||||
});
|
||||
|
||||
it('should fall back to default for invalid view values', () => {
|
||||
const sp = createMockSearchParams({ view: 'invalid' });
|
||||
const state = parseUrlState(sp);
|
||||
assert.strictEqual(state.view, 'social');
|
||||
});
|
||||
|
||||
it('should parse task id', () => {
|
||||
const sp = createMockSearchParams({ view: 'social', task: 'bb-buff.1' });
|
||||
const state = parseUrlState(sp);
|
||||
assert.strictEqual(state.view, 'social');
|
||||
assert.strictEqual(state.taskId, 'bb-buff.1');
|
||||
});
|
||||
|
||||
it('should parse swarm id', () => {
|
||||
const sp = createMockSearchParams({ view: 'swarm', swarm: 'bb-buff' });
|
||||
const state = parseUrlState(sp);
|
||||
assert.strictEqual(state.view, 'swarm');
|
||||
assert.strictEqual(state.swarmId, 'bb-buff');
|
||||
});
|
||||
|
||||
it('should parse panel state', () => {
|
||||
const sp = createMockSearchParams({ view: 'social', task: 'bb-buff.1', panel: 'open' });
|
||||
const state = parseUrlState(sp);
|
||||
assert.strictEqual(state.panel, 'open');
|
||||
});
|
||||
|
||||
it('should parse graphTab', () => {
|
||||
const sp = createMockSearchParams({ view: 'graph', task: 'bb-buff.1', graphTab: 'flow' });
|
||||
const state = parseUrlState(sp);
|
||||
assert.strictEqual(state.graphTab, 'flow');
|
||||
});
|
||||
|
||||
it('should fall back to default for invalid panel values', () => {
|
||||
const sp = createMockSearchParams({ panel: 'invalid' });
|
||||
const state = parseUrlState(sp);
|
||||
assert.strictEqual(state.panel, 'closed');
|
||||
});
|
||||
|
||||
it('should fall back to default for invalid graphTab values', () => {
|
||||
const sp = createMockSearchParams({ graphTab: 'invalid' });
|
||||
const state = parseUrlState(sp);
|
||||
assert.strictEqual(state.graphTab, 'flow');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildUrlParams', () => {
|
||||
it('should build URL with view param', () => {
|
||||
const sp = createMockSearchParams({});
|
||||
const url = buildUrlParams(sp, { view: 'social' });
|
||||
assert.strictEqual(url, '/?view=social');
|
||||
});
|
||||
|
||||
it('should add task param', () => {
|
||||
const sp = createMockSearchParams({ view: 'social' });
|
||||
const url = buildUrlParams(sp, { task: 'bb-buff.1' });
|
||||
assert.strictEqual(url, '/?view=social&task=bb-buff.1');
|
||||
});
|
||||
|
||||
it('should remove param when null', () => {
|
||||
const sp = createMockSearchParams({ view: 'social', task: 'bb-buff.1' });
|
||||
const url = buildUrlParams(sp, { task: null });
|
||||
assert.strictEqual(url, '/?view=social');
|
||||
});
|
||||
|
||||
it('should toggle panel', () => {
|
||||
const sp = createMockSearchParams({ view: 'social', panel: 'closed' });
|
||||
const url = buildUrlParams(sp, { panel: 'open' });
|
||||
assert.strictEqual(url, '/?view=social&panel=open');
|
||||
});
|
||||
|
||||
it('should return root for empty params', () => {
|
||||
const sp = createMockSearchParams({});
|
||||
const url = buildUrlParams(sp, {});
|
||||
assert.strictEqual(url, '/');
|
||||
});
|
||||
|
||||
it('should clear all selection params', () => {
|
||||
const sp = createMockSearchParams({ view: 'social', task: 'bb-buff.1', swarm: 'buff', panel: 'open', graphTab: 'flow' });
|
||||
const url = buildUrlParams(sp, { task: null, swarm: null, panel: null, graphTab: null });
|
||||
assert.strictEqual(url, '/?view=social');
|
||||
});
|
||||
});
|
||||
|
||||
describe('module import', () => {
|
||||
it('should load the module without error', async () => {
|
||||
try {
|
||||
await import('../../src/hooks/use-url-state');
|
||||
assert.ok(true, 'Module loaded');
|
||||
} catch (err) {
|
||||
assert.fail(err as Error);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue