Spreadsheet-UX op een echte relationele engine
Deze repository wordt als momentopname gepubliceerd: een clone bevat de volledige inhoud, maar geen commitgeschiedenis.
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]);
}