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 `