Spreadsheet-UX op een echte relationele engine

main

Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.

9.6 kB Ruw bestand
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;
}