diff --git a/src/hooks/use-url-state.ts b/src/hooks/use-url-state.ts new file mode 100644 index 0000000..2a00992 --- /dev/null +++ b/src/hooks/use-url-state.ts @@ -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 { + 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) => { + 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; diff --git a/tests/hooks/use-url-state.test.ts b/tests/hooks/use-url-state.test.ts new file mode 100644 index 0000000..4c8dba4 --- /dev/null +++ b/tests/hooks/use-url-state.test.ts @@ -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 = {}) { + 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); + } + }); + }); +});