import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from 'react'; import type { AggFn, Filter, MetaResponse, Sort, Summaries, TableMeta, ViewDef, } from '@wellformed/shared'; import { activeWorkspace } from '../api/client'; import { useMeta, useInvalidateAfterWrite } from '../api/hooks'; import { useUndoStack, type UndoStack } from './undo'; export interface CellRef { rowId: string; col: string; } interface WorkspaceApi { meta: MetaResponse | undefined; metaLoading: boolean; metaError: unknown; tables: TableMeta[]; table: TableMeta | undefined; tableName: string; setTable(name: string): void; views: ViewDef[]; view: ViewDef | undefined; viewId: string | null; setViewId(id: string | null): void; /** Jump straight to a view, switching table if it belongs to another one. */ openView(table: string, id: string): void; /** Filters the user added on top of the view, this session. */ adHocFilters: Filter[]; addFilter(filter: Filter): void; removeFilter(index: number): void; clearFilters(): void; /** View filters first, then ad-hoc — the order the chips are shown in. */ allFilters: Filter[]; /** `undefined` defers to the view; `null` means explicitly unsorted. */ sortOverride: Sort | null | undefined; sort: Sort | null; toggleSort(column: string): void; setSort(sort: Sort | null | undefined): void; /** `undefined` defers to the view's saved aggregate row. */ summariesOverride: Summaries | undefined; summaries: Summaries; /** Choose what the footer computes under a column, or `null` to clear it. */ setSummary(column: string, fn: AggFn | null): void; /** Drop the ad-hoc choice and defer to the view again. */ clearSummaries(): void; search: string; setSearch(value: string): void; /** `undefined` defers to the view's hidden columns; a list replaces them whole. */ hiddenOverride: string[] | undefined; /** The columns the grid leaves out right now: the override, or the view's. */ hiddenCols: string[]; hideColumn(name: string): void; /** Bring back the named columns, or every hidden one when called bare. */ revealColumns(names?: string[]): void; /** Drop the ad-hoc choice and defer to the view again. */ clearHiddenOverride(): void; selection: CellRef | null; setSelection(cell: CellRef | null): void; showAudit: boolean; setShowAudit(value: boolean): void; auditRowId: string | null; setAuditRowId(id: string | null): void; undo: UndoStack; refresh(): Promise; } const WorkspaceContext = createContext(null); /** * Which table was last open, remembered per workspace. * * Keyed by slug because two workspaces have different tables: coming back to * one and landing on a table name that only exists in the other would look * like the app losing its place. */ function lastTableKey(): string { return `wellformed.lastTable.${activeWorkspace() ?? ''}`; } export function WorkspaceProvider({ children }: { children: ReactNode }): ReactNode { const { data: meta, isLoading, error } = useMeta(); const refresh = useInvalidateAfterWrite(); const undo = useUndoStack(refresh); // These hold what the user *chose*. What is actually shown is derived below, // so a table or view that disappears resolves to a sensible fallback during // render instead of through a second render triggered by an effect. const [chosenTable, setChosenTable] = useState( () => window.localStorage.getItem(lastTableKey()) ?? '', ); const [chosenView, setChosenView] = useState(null); const [adHocFilters, setAdHocFilters] = useState([]); const [sortOverride, setSortOverride] = useState(undefined); const [summariesOverride, setSummariesOverride] = useState(undefined); const [search, setSearch] = useState(''); const [hiddenOverride, setHiddenOverride] = useState(undefined); const [selection, setSelection] = useState(null); // Closed by default: the firehose is rarely the question. "Show this row's // history" in the row menu is the way most people arrive here. const [showAudit, setShowAudit] = useState(false); const [auditRowId, setAuditRowId] = useState(null); const tables = useMemo(() => meta?.tables ?? [], [meta]); // Fall back to the first table if the remembered one has been dropped. const table = tables.find((t) => t.name === chosenTable) ?? tables[0]; const tableName = table?.name ?? ''; const views = useMemo( () => (meta?.views ?? []).filter((v) => v.table === tableName), [meta, tableName], ); const view = views.find((v) => v.id === chosenView) ?? views[0]; const viewId = view?.id ?? null; const setTable = useCallback((name: string) => { setChosenTable(name); window.localStorage.setItem(lastTableKey(), name); // A view, its filters and its sort all belong to the table being left. setChosenView(null); setAdHocFilters([]); setSortOverride(undefined); setSummariesOverride(undefined); setHiddenOverride(undefined); setSelection(null); setAuditRowId(null); }, []); const chooseView = useCallback((id: string | null) => { setChosenView(id); setAdHocFilters([]); setSortOverride(undefined); setSummariesOverride(undefined); setHiddenOverride(undefined); setSelection(null); }, []); // Selecting a view from the sidebar may cross tables. Doing it in one call // keeps the table and the view from being set in an order that matters. const openView = useCallback((name: string, id: string) => { setChosenTable(name); window.localStorage.setItem(lastTableKey(), name); setChosenView(id); setAdHocFilters([]); setSortOverride(undefined); setSummariesOverride(undefined); setHiddenOverride(undefined); setSelection(null); setAuditRowId(null); }, []); const allFilters = useMemo( () => [...(view?.filters ?? []), ...adHocFilters], [view, adHocFilters], ); const sort = sortOverride === undefined ? (view?.sort ?? null) : sortOverride; const toggleSort = useCallback( (column: string) => { setSortOverride((current) => { const active = current === undefined ? (view?.sort ?? null) : current; if (!active || active.col !== column) return { col: column, dir: 'asc' }; if (active.dir === 'asc') return { col: column, dir: 'desc' }; return null; }); }, [view], ); // Memoised because it is a query key: a fresh `{}` every render would look // like a changed request and refetch the footer forever. const summaries = useMemo( () => summariesOverride ?? view?.summaries ?? {}, [summariesOverride, view], ); const setSummary = useCallback( (column: string, fn: AggFn | null) => { setSummariesOverride((current) => { const next = { ...(current ?? view?.summaries ?? {}) }; if (fn) next[column] = fn; else delete next[column]; return next; }); }, [view], ); // Memoised for the same reason as `summaries`: it travels into a query key. const hiddenCols = useMemo( () => hiddenOverride ?? view?.hidden ?? [], [hiddenOverride, view], ); const hideColumn = useCallback( (name: string) => { setHiddenOverride((current) => { const base = current ?? view?.hidden ?? []; return base.includes(name) ? base : [...base, name]; }); }, [view], ); // Bare, this leaves `[]` rather than `undefined`: showing everything is a // choice of its own, distinct from deferring to whatever the view hides. const revealColumns = useCallback( (names?: string[]) => { setHiddenOverride((current) => { const base = current ?? view?.hidden ?? []; return names ? base.filter((n) => !names.includes(n)) : []; }); }, [view], ); const value = useMemo( () => ({ meta, metaLoading: isLoading, metaError: error, tables, table, tableName, setTable, views, view, viewId, setViewId: chooseView, openView, adHocFilters, addFilter: (filter) => setAdHocFilters((f) => [...f, filter]), removeFilter: (index) => setAdHocFilters((f) => f.filter((_, i) => i !== index)), clearFilters: () => setAdHocFilters([]), allFilters, sortOverride, sort, toggleSort, setSort: setSortOverride, summariesOverride, summaries, setSummary, clearSummaries: () => setSummariesOverride(undefined), search, setSearch, hiddenOverride, hiddenCols, hideColumn, revealColumns, clearHiddenOverride: () => setHiddenOverride(undefined), selection, setSelection, showAudit, setShowAudit, auditRowId, setAuditRowId, undo, refresh, }), [ meta, isLoading, error, tables, table, tableName, setTable, views, view, viewId, chooseView, openView, adHocFilters, allFilters, sortOverride, sort, toggleSort, summariesOverride, summaries, setSummary, search, hiddenOverride, hiddenCols, hideColumn, revealColumns, selection, showAudit, auditRowId, undo, refresh, ], ); return {children}; } export function useWorkspace(): WorkspaceApi { const ctx = useContext(WorkspaceContext); if (!ctx) throw new Error('useWorkspace must be used inside a WorkspaceProvider.'); return ctx; }