Spreadsheet-UX op een echte relationele engine

main

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

6.6 kB Ruw bestand
import { useCallback, useMemo } from 'react';
import {
  useInfiniteQuery,
  useQueries,
  useQuery,
  useQueryClient,
  type QueryClient,
} from '@tanstack/react-query';
import type { Filter, Row, RowsResponse, Sort, Summaries } from '@wellformed/shared';
import { api } from './client';

export const PAGE_SIZE = 200;

export function useMeta() {
  return useQuery({
    queryKey: ['meta'],
    queryFn: () => api.meta(),
    staleTime: 30_000,
  });
}

export interface RowsParams {
  table: string;
  viewId: string | null;
  filters: Filter[];
  /** `undefined` means "whatever the view says". */
  sort: Sort | null | undefined;
  /** `undefined` means "whatever the view says". */
  hidden: string[] | undefined;
  search: string;
  enabled?: boolean;
}

/** Stable, JSON-safe key. `undefined` would vanish from a plain object key. */
function rowsKey(p: RowsParams) {
  return [
    'rows',
    p.table,
    p.viewId ?? '',
    JSON.stringify(p.filters),
    p.sort === undefined ? 'inherit' : JSON.stringify(p.sort),
    p.hidden === undefined ? 'inherit' : JSON.stringify(p.hidden),
    p.search,
  ] as const;
}

export function useRows(params: RowsParams) {
  const query = useInfiniteQuery({
    queryKey: rowsKey(params),
    enabled: params.enabled ?? true,
    initialPageParam: 0,
    queryFn: ({ pageParam }) =>
      api.rows({
        table: params.table,
        viewId: params.viewId,
        filters: params.filters,
        sort: params.sort,
        hidden: params.hidden,
        search: params.search,
        limit: PAGE_SIZE,
        offset: pageParam,
      }),
    getNextPageParam: (last: RowsResponse, pages: RowsResponse[]) => {
      const loaded = pages.reduce((n, p) => n + p.rows.length, 0);
      return loaded < last.total ? loaded : undefined;
    },
    // Keeping the previous page visible while a filter changes stops the grid
    // from collapsing to a spinner on every keystroke.
    placeholderData: (prev) => prev,
  });

  const pages = query.data?.pages;
  // Memoised so `rows` keeps its identity between renders that change no data.
  // The grid re-renders on every scroll (the virtualizer moves its window),
  // and effects keyed on `rows` — keeping the selected cell in view — must not
  // re-run then: rebuilding this array each render made the selection yank the
  // scroll back, so a selected cell blocked scrolling away from it.
  const rows: Row[] = useMemo(() => (pages ?? []).flatMap((p) => p.rows), [pages]);
  const head = pages?.[0];

  return {
    ...query,
    rows,
    total: head?.total ?? 0,
    sql: head?.sql ?? '',
    tokens: head?.tokens ?? [],
    columnErrors: head?.columnErrors ?? {},
  };
}

export interface SummaryParams {
  table: string;
  viewId: string | null;
  filters: Filter[];
  /** `undefined` means "whatever the view says". */
  hidden: string[] | undefined;
  search: string;
  summaries: Summaries;
  enabled?: boolean;
}

/**
 * The aggregate row.
 *
 * Its own query, not a slice of the rows query: it is one statement over every
 * matching row, so it must not be refetched per page — and it must be refetched
 * when the filters, the search or the chosen functions change, which is exactly
 * what its key says. Idle until a function is chosen; an empty footer costs
 * nothing.
 */
export function useSummary(params: SummaryParams) {
  const chosen = Object.keys(params.summaries).length > 0;
  const query = useQuery({
    queryKey: [
      'summary',
      params.table,
      params.viewId ?? '',
      JSON.stringify(params.filters),
      params.hidden === undefined ? 'inherit' : JSON.stringify(params.hidden),
      params.search,
      JSON.stringify(params.summaries),
    ],
    enabled: (params.enabled ?? true) && chosen,
    queryFn: () =>
      api.summary({
        table: params.table,
        viewId: params.viewId,
        filters: params.filters,
        hidden: params.hidden,
        search: params.search,
        summaries: params.summaries,
      }),
    placeholderData: (prev) => prev,
  });

  return {
    ...query,
    values: query.data?.values ?? {},
    errors: query.data?.errors ?? {},
    sql: query.data?.sql ?? '',
    tokens: query.data?.tokens ?? [],
  };
}

/**
 * Rows of every table reachable by a link column, so a link cell can offer a
 * picker. Capped — a picker over more than a few hundred rows wants search,
 * not a longer `<select>`.
 */
export const LINK_OPTION_LIMIT = 500;

export function useLinkTargets(targets: string[]): Record<string, Row[]> {
  const results = useQueries({
    queries: targets.map((table) => ({
      queryKey: ['linkOptions', table],
      queryFn: () => api.rows({ table, limit: LINK_OPTION_LIMIT, offset: 0 }),
      staleTime: 60_000,
    })),
  });

  const map: Record<string, Row[]> = {};
  targets.forEach((table, i) => {
    map[table] = results[i]?.data?.rows ?? [];
  });
  return map;
}

export function useAudit(scope: { table?: string; rowId?: string }) {
  return useInfiniteQuery({
    queryKey: ['audit', scope.table ?? '', scope.rowId ?? ''],
    initialPageParam: undefined as number | undefined,
    queryFn: ({ pageParam }) =>
      api.audit({ table: scope.table, rowId: scope.rowId, before: pageParam, limit: 40 }),
    getNextPageParam: (last) =>
      last.hasMore ? last.entries[last.entries.length - 1]?.id : undefined,
    staleTime: 5_000,
  });
}

/**
 * What this install offers. Operator configuration, so it changes on redeploy
 * and never during a session — cached for the life of the page.
 */
export function usePublicConfig() {
  return useQuery({
    queryKey: ['publicConfig'],
    queryFn: () => api.publicConfig(),
    staleTime: Infinity,
  });
}

export function useMembers(enabled: boolean) {
  return useQuery({ queryKey: ['members'], queryFn: () => api.members(), enabled });
}

export function useInvites(enabled: boolean) {
  return useQuery({ queryKey: ['invites'], queryFn: () => api.invites(), enabled });
}

export function useAgentKeys(enabled: boolean) {
  return useQuery({ queryKey: ['agentKeys'], queryFn: () => api.agentKeys(), enabled });
}

/** Everything a write can invalidate, in one place so nothing goes stale. */
export function invalidateAfterWrite(client: QueryClient): Promise<void> {
  return Promise.all([
    client.invalidateQueries({ queryKey: ['rows'] }),
    // A written row changes the total under it; a stale footer is a wrong one.
    client.invalidateQueries({ queryKey: ['summary'] }),
    client.invalidateQueries({ queryKey: ['audit'] }),
    client.invalidateQueries({ queryKey: ['meta'] }),
  ]).then(() => undefined);
}

export function useInvalidateAfterWrite(): () => Promise<void> {
  const client = useQueryClient();
  return useCallback(() => invalidateAfterWrite(client), [client]);
}