Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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<void>;
}
const WorkspaceContext = createContext<WorkspaceApi | null>(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<string>(
() => window.localStorage.getItem(lastTableKey()) ?? '',
);
const [chosenView, setChosenView] = useState<string | null>(null);
const [adHocFilters, setAdHocFilters] = useState<Filter[]>([]);
const [sortOverride, setSortOverride] = useState<Sort | null | undefined>(undefined);
const [summariesOverride, setSummariesOverride] = useState<Summaries | undefined>(undefined);
const [search, setSearch] = useState('');
const [hiddenOverride, setHiddenOverride] = useState<string[] | undefined>(undefined);
const [selection, setSelection] = useState<CellRef | null>(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<string | null>(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<WorkspaceApi>(
() => ({
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 <WorkspaceContext.Provider value={value}>{children}</WorkspaceContext.Provider>;
}
export function useWorkspace(): WorkspaceApi {
const ctx = useContext(WorkspaceContext);
if (!ctx) throw new Error('useWorkspace must be used inside a WorkspaceProvider.');
return ctx;
}