@astroapps/datagrid-search
v0.3.0
Published
Headless sort, filter and paging for @astroapps/datagrid, over @astroapps/searchstate state
Readme
@astroapps/datagrid-search
Headless sort, filter, paging and filter-option resolution for
@astroapps/datagrid, over
@astroapps/searchstate state.
Renders nothing. It turns a Control<SearchRequest> plus a set of columns into
the things a renderer needs — which column is sorted, what a header click should
do, which values a filter offers, and the current page of rows.
@astroapps/datagrid-fluent-ui is the Fluent v9
renderer built on it.
npm i @astroapps/datagrid-searchThe shape of it
const state = useControl<SearchRequest>({
...defaultSearchOptions,
length: 25,
});
// ── swap the data source to move between client and server ──
const data = useClientData(state, { rows: allRows, columns });
// const data = useServerData(state, { queryKey: "files", search }); // react-query
const search = useGridSearch(state, { columns, data });search is everything a renderer consumes: sort, filter, data,
canFilter(column) and useFilterOptions(column). Every source produces the same
GridData, so nothing downstream can tell which mode it's in.
Two things are worth internalising:
One state shape, and it's yours to extend.
Control<SearchRequest>holds query, sort, filters, offset and length. URL sync, persistence and "sort by X" buttons elsewhere on the page all just read and write it. Real pages usually have filtering that isn't a column filter — a date range, a tenant, a "show archived" toggle — so put those fields in the same state:interface FilesSearch extends SearchRequest { dateFrom: string | null; includeArchived: boolean; } const state = useControl<FilesSearch>({ ...defaultSearchOptions, dateFrom: null, includeArchived: false, });Every hook is generic over the state, so
fetchreceives the whole thing, changing an extra field refetches, and the count key includes it — nodepswiring to forget. Client-side, passadditionalFiltertouseClientData, since the library can't know what your fields mean.Affordances follow column metadata. A column with a
sortFieldis sortable; a column whose filter options resolve is filterable. There are noenableSortingflags.
Sorting
SearchRequest.sort is searchstate's string[], each entry a direction
character followed by the field: ["dfile"] is descending by file.
const sort = makeGridSort(state, { mode: "shift", cycleUnsorted: false });
sort.isSortable(column); // has a sortField
sort.direction(column); // "asc" | "desc" | undefined
sort.priority(column); // 1-based, multi-sort only
sort.toggle(column, ev); // ev?.shiftKey matters in "shift" mode| mode | A header click |
| -------------------- | --------------------------------------------------------------- |
| "single" (default) | replaces any other sort — Fluent's behaviour |
| "multiple" | keeps the others; a new column is appended as least significant |
| "shift" | plain click replaces, shift-click appends |
cycleUnsorted adds the third step, so clicks go default → reverse → unsorted
instead of flipping between two.
Note what isn't here: applying the sort to rows. That belongs to the data source, and its absence is how a server source says "these rows are already ordered".
Filtering
SearchRequest.filters is Record<string, string[]> — filter values are always
strings. GridFilter is the typed accessor over it:
const filter = makeGridFilter(state, { filterFor });
filter.values(field); // string[]
filter.selected(field); // Control<string[] | undefined>, writable
filter.toggle(field, value, on);
filter.setValues(field, next); // removes the key when `next` is empty
filter.clear(); // or clear(field)
filter.activeFields(); // for a chip barAn emptied filter deletes its key rather than storing [], since an empty array
is a visible difference in a URL and a different query key for an identical
search.
Which columns filter, and how
One grid-level function, called with the whole ColumnDef:
const getColumnFilter: GetColumnFilter<Row, Meta> = (column) => {
switch (column.data?.kind) {
case "enum":
return { options: fetchEnumValues };
case "number":
return { render: RangePopup, matches: rangeMatches };
default:
return column.filterField ? {} : undefined;
}
};A function rather than a field-keyed map because filtering is usually
patterned: one rule keyed off column.data covers every enum column, which
is also how a schema-generated column gets its behaviour without being registered
by hand. byFilterField({ status: {...} }) is available when you do want to
enumerate.
{} means "filterable, all defaults". With no getColumnFilter at all the
default is column.filterField ? {} : undefined, so the zero-config path is
unchanged.
getColumnFilter must be pure. It's called per column; returning a fresh
options array or render closure each call breaks memo dependencies downstream
and, against an async source, can loop. Results are cached per column id.
matches is the escape hatch for filters whose selected values aren't row values
— ranges, dates, free text. Client-side only: a server has to implement the
equivalent itself.
The rest of ColumnFilter is presentation, per column: multiple (default true)
for checkboxes vs radios, searchable for an options-search box (on past ~12
options), and showCounts (default true) for the Document (3) counts beside
each option. showCounts: false only hides a count — to avoid computing one, a
derived source takes counts: false.
Filter modes
filterMode decides when a popup's selection reaches the search, and what a tick
means. Three settings:
| filterMode | unfiltered shows | a click | Apply |
| ------------- | ---------------- | -------------- | ----- |
| immediate | nothing ticked | searches | — |
| apply | nothing ticked | edits a draft | yes |
| excel | everything ticked, with a select-all | edits a draft | yes |
const search = useGridSearch(state, { columns, data, filterMode: "apply" });
// three values ticked = one search, not threeGrid-level, not per column — which click searches, and what a tick means,
shouldn't vary between one funnel and the next. It lands on search.filter.mode
(with search.filter.deferApply as the derived "not immediate"), and
useFilterDraft reads it from there: a renderer asks that hook for values,
calls toggle/setAll/clear/apply, and reads canApply/canClear for its
buttons, without knowing which mode it's in.
apply is worth it against a server, where each click would otherwise be a
request, two of them already stale before they land. Closing the popup any other
way discards — there's no cancel to write, because nothing was written.
deferApply: true is the older spelling of filterMode: "apply" and still
works; filterMode wins if both are given.
Excel mode
excel is apply plus Excel's inversion: an unfiltered column opens with every
value ticked rather than none, above a (Select All) row that toggles the
lot. Under an active options-search the select-all covers the matches, as Excel's
does.
The storage doesn't change — an absent key still means unfiltered — so this is a
presentation layer over the same string[]:
| applied | opens as | Apply writes |
| ----------- | -------------------- | --------------------------- |
| absent | everything ticked | nothing, if still everything |
| ["doc"] | just doc ticked | ["doc"] |
| — | nothing ticked | refused |
Two consequences worth knowing:
- Applying with everything ticked clears the column. It isn't narrowing anything, so the field leaves no trace in URLs or query keys and the funnel goes back to idle.
- Applying with nothing ticked is refused (
canApplyis false, and the renderers disable the button). The empty array is already spoken for — it's what an absent filter reads as — so "match none" and "match everything" would be the same stored value. Excel refuses it too. Clear is the way back out of that state: in excel mode it re-ticks everything rather than emptying the selection, which is the same thing said the other way round.
A single-select column (multiple: false) keeps the plain behaviour whatever the
grid is set to — "everything selected" isn't a state a radio group can be in.
In the other modes, Clear empties the selection (the draft when deferred), so removing a deferred filter is Clear then Apply.
A ColumnFilter.render of your own is the one exception, unavoidably: it gets the
real selected control and close, so when a selection is final is its own call —
which is what the range popup in the demo does by hand.
Filter options
Four source shapes, one result:
options: [{ value: "doc", label: "Document" }] // static
options: async (ctx) => api.facets(ctx.field, ctx.signal) // async, per field
options: { fromRows: () => rows, max: 50 } // derived
options: { hook: (ctx) => makeFilterOptions(useQuery(...)) } // your libraryResolution order, three deep:
| | Source | Typically |
| --- | --------------------------------- | -------------------------------------------- |
| 1 | getColumnFilter(column).options | a column that knows its own values |
| 2 | data.facets[field] | server-side: counts returned with the page |
| 3 | data.optionRows(field) | client-side: derived from the rows on screen |
| — | nothing resolves | no filter control is rendered |
Options load lazily, because useFilterOptions is called inside the popup
surface, which only mounts when the popover opens. Nothing is fetched for a
column nobody filters, and in-flight requests abort on unmount.
Nothing is cached here. State lives in the hook, so closing the popover
discards it and reopening fetches again. An internal cache only bought surviving
close/reopen, and a second caching layer can disagree with the real one — so if
you want caching, deduping, retries or stale-while-revalidate, use the { hook }
source and let your query library do all of it:
options: {
hook: ({ field }) =>
makeFilterOptions(
useQuery({
queryKey: ["facets", field],
queryFn: () => api.facets(field),
staleTime: 5 * 60_000,
}),
),
}Client-derived options ignore the column's own filter by default, so picking
one value doesn't hide the others — Excel's behaviour. Turn it off with
optionsIgnoreOwnFilter: false.
Server-side
useServerData is the server counterpart to useClientData. It's built on
react-query (a peer dependency), so the fetching — abort on change,
stale-response ordering, keep-previous, cross-component cache sharing — is the
query library's, not a reimplementation. You provide a search; it returns the
same GridData a client grid does. Wrap the app in a QueryClientProvider.
const data = useServerData(state, {
queryKey: "files", // cache-key prefix; ["files", tenantId] to scope it
search: (options, includeTotal, signal) =>
api.search(options, signal, includeTotal), // returns a GridPage
debounce: 300, // `query` only; sort/filter/paging fetch immediately
keepPrevious: true, // hold the old page while the next loads
});search gets the state's whole value, so a state that extends SearchRequest with
its own filtering is carried through — and because it's part of the query key,
changing it refetches with no extra wiring.
The total is optional
Counting is usually a second query over the whole filtered set, so GridPage.total
is optional and GridData.total may be undefined. undefined and 0 are
different answers: the first means "not counted", the second "counted, nothing
matched" — use pageInfo(options, data) rather than reading total directly, and
the uncounted case (pager shows 1-10, infers Next from a full page) is handled
for you.
A page's total also accepts null, so a generated response type can be returned
as-is — SearchResults<T>'s int? becomes total: number | null, and a null
count means the same as an absent one. makeGridData folds it to undefined, so
GridData.total still has exactly one "not counted" value.
useServerData counts once per search, not once per page. includeTotal — the
flag handed to search — is true only when there's no total for the current
search; the total is then cached on a key that excludes offset/length/sort,
so paging and sorting reuse it and a filter or query change re-counts. That
condition is "the search changed", not "offset is 0", so a restored URL like
?offset=30 still gets its total. Honour it however suits the endpoint:
- Cheap combined count (
COUNT(*) OVER()): ignoreincludeTotal, always returntotal. Harmless — it just counts on every page. - Expensive count: return
totalonly whenincludeTotal, and skip it otherwise. This maps straight onto aSearchHelper-style endpoint, whoseSearchResults<T>(Total, Entries)is aGridPageand whoseincludeTotalis exactly this flag. - No count: pass
count: false. Nothing is ever asked for and nothing is ever reported — not even a total another grid sharing the key prefix cached, since a grid opts out because a total would be wrong or unwanted for it.
A search that's asked but returns no total is recorded as "asked, none came"
and not retried until the search moves; an error surfaces as GridData.error.
Return facets with the page and server-side filter options need no second request:
{ rows, total, facets: { category: [{ value: "Video", count: 12 }] } }Another query library, or none
GridData and GridPage are plain interfaces, so useServerData isn't the only
way in. Anything that produces a GridPage drives a grid through makeGridData,
and useDebouncedSearchRequest — the text-debounce a query library lacks — is
usable on its own:
const options = useDebouncedSearchRequest(state, 300);
const query = useQuery({ queryKey: ["files", options], queryFn: ... });
const data = makeGridData({
page: query.data,
loading: query.isFetching, // not isPending: with placeholderData there *is*
error: query.error, // data during a refetch, so isPending is false
reload: query.refetch,
});Reach for this when you want the count wired your own way, or a library other than react-query.
What can't be uniform across modes
Two things don't transfer when you swap client for server, and both are silent rather than loud:
- Free-text
queryscope. Client-side it searches every leaf column with agetter, joined so a query can't match across a column boundary. Server-side it searches whatever your API searches. matchespredicates. They run in JavaScript over in-memory rows. A range filter with amatcheswill simply not filter against an API unless the API implements the equivalent.
Custom filter popups
A popup gets a Control<string[] | undefined> scoped to its own column, and that
is the entire contract:
function RangePopup({ selected, values, close }: FilterPopupProps<Row>) {
return (
<Range
value={values[0]}
onApply={(from, to) => {
selected.value = [`${from}..${to}`];
close();
}}
/>
);
}Pair it with a matches that interprets whatever string it wrote. The popup never
learns that a shared filters map exists.
Shared by every renderer
Three things here render nothing but exist because more than one renderer needs them, and each is somewhere a renderer would otherwise get it subtly wrong on its own.
makeGridSelection / arraySelection — page-scoped row selection. The
header checkbox reflects and acts on the rows currently rendered and never
disturbs a selection made on another page; a renderer supplies only the checkbox
column over it. Cross-page "select all N matching" is deliberately absent — it
needs the filtered total, the live search and a way to fetch every matching id,
at which point it isn't a renderer's business. Neither function is a hook,
despite taking a control: they read .value when called, so call them during
render.
shouldIgnoreRowClick(event) — whether a click that landed on a row was
aimed at the row. False for two cases that are easy to miss: content that owns
its own clicks (a checkbox, a link, a role="button", a label wrapping a
control) and the click that ends a text drag. A row-click handler without this
looks fine until someone tries to copy a cell.
pagerVisible(options, data, { pageSizes }) — whether a pager is worth
rendering. One page and nowhere to go means dead prev/next chrome, unless the
pager also carries a page-size selector, which is the only way back from a size
that fits every row.
Testing
Most of the package is plain functions over controls — makeGridSort,
makeGridFilter, clientSearch, deriveFilterOptions — testable without a DOM
or a renderer. Only the data hooks and useFilterOptions need React.
makeGridSort / makeGridFilter are deliberately not named use*: they read
.value when called, so they must run on every render and must not be memoised.
