@kitr/table 0.0.0-stage → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +14 -2
- package/dist/index.d.ts +97 -0
- package/dist/index.js +135 -0
- package/dist/kitr.d.ts +17 -0
- package/dist/kitr.js +19 -0
- package/docs/api.md +68 -0
- package/docs/index.md +864 -0
- package/docs/overview.md +90 -0
- package/package.json +65 -3
package/docs/index.md
ADDED
|
@@ -0,0 +1,864 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Tables
|
|
3
|
+
description: Show rows of data with sorting, paging and filters that stay in the URL.
|
|
4
|
+
order: 1
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Tables
|
|
8
|
+
|
|
9
|
+
Admin screens, dashboards and reports all need a table people can sort, page through and filter. Grace sorts Acme's orders by total, filters to the open ones, goes to page 3, and sends Linus the link. `@kitr/table` keeps those choices in the page's URL, so the view survives a reload, works with the back button, and opens the same for Linus.
|
|
10
|
+
|
|
11
|
+
> [!SUMMARY]
|
|
12
|
+
> Sortable, pageable, filterable tables whose state lives in the URL. Add it with `kitr add table`; the overview is at [/packages/table](/packages/table).
|
|
13
|
+
|
|
14
|
+
It is [TanStack Table](https://tanstack.com/table/latest) v9, re-exported unchanged, plus `useUrlTableState()` (binds sorting, pagination and filters to search params), `parseTableSearch()` (validates those params from an untrusted URL) and `dataTableFeatures` (the feature set kitr's `<DataTable>` expects). It runs in the browser, inside React; the examples use [router](/packages/router).
|
|
15
|
+
|
|
16
|
+
The examples build Acme's orders screen at `app.acme.com/orders`: thousands of orders with a customer, a total, a status and a date, served one page at a time by `/api/orders`.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
kitr add table
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
<details>
|
|
25
|
+
<summary>Everything kitr add changes</summary>
|
|
26
|
+
|
|
27
|
+
- Adds `@kitr/table` and `@tanstack/react-table` to `dependencies` and installs them. Pass `--no-install` to skip the install.
|
|
28
|
+
- Adds `'@kitr/table'` to `packages` in `kitrrc.ts`. It registers no provider.
|
|
29
|
+
- Adds a `## kitr: table` section to `AGENTS.md`.
|
|
30
|
+
|
|
31
|
+
It writes no config file, adds no env variables or scripts, and asks no questions. Tables are a client package, so in a project without React `kitr add` skips it with a warning. The `app`, `fullstack`, `saas`, `desktop` and `mobile` kits include it, along with the `data-table` component.
|
|
32
|
+
|
|
33
|
+
`kitr remove table` takes the package out of `kitrrc.ts`, removes both dependencies and the `AGENTS.md` section, and lists files that still import `@kitr/table`. It leaves `data-table.tsx` in place.
|
|
34
|
+
|
|
35
|
+
</details>
|
|
36
|
+
|
|
37
|
+
The styled table component is yours to edit, so copy it in separately:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
kitr ui add data-table
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
That writes `app/components/ui/data-table.tsx`, plus the `button` and `icon` components it uses.
|
|
44
|
+
|
|
45
|
+
## Configure
|
|
46
|
+
|
|
47
|
+
`@kitr/table` has no config file. Each table is configured where you call `useUrlTableState`:
|
|
48
|
+
|
|
49
|
+
```ts
|
|
50
|
+
// app/routes.ts
|
|
51
|
+
import { parseTableSearch } from '@kitr/table'
|
|
52
|
+
|
|
53
|
+
r.page('/orders', {
|
|
54
|
+
validateSearch: parseTableSearch,
|
|
55
|
+
lazy: () => import('./pages/orders/index'),
|
|
56
|
+
})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
// app/pages/orders/index.tsx
|
|
61
|
+
import { useUrlTableState } from '@kitr/table'
|
|
62
|
+
import { getRouteApi } from '@kitr/router'
|
|
63
|
+
|
|
64
|
+
const route = getRouteApi('/orders')
|
|
65
|
+
|
|
66
|
+
export default function OrdersPage() {
|
|
67
|
+
const search = route.useSearch()
|
|
68
|
+
const navigate = route.useNavigate()
|
|
69
|
+
const urlState = useUrlTableState({
|
|
70
|
+
search,
|
|
71
|
+
setSearch: (patch) => navigate({ search: (prev) => ({ ...prev, ...patch }) }),
|
|
72
|
+
defaultPageSize: 25,
|
|
73
|
+
})
|
|
74
|
+
// ...
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
| Option | Default | Description |
|
|
79
|
+
| --- | --- | --- |
|
|
80
|
+
| `search` | required | The current table params, `{ page?, size?, sort?, filter? }`. Usually `getRouteApi(path).useSearch()`. |
|
|
81
|
+
| `setSearch` | required | Merges a patch into the URL. A key set to `undefined` is removed from it. |
|
|
82
|
+
| `defaultPageSize` | `20` | Rows per page when the URL has no `size`. A size equal to it is left out of the URL. |
|
|
83
|
+
| `manual` | `true` | `false` turns the three manual flags off, for data the table sorts and pages itself (with `clientTableFeatures`). |
|
|
84
|
+
|
|
85
|
+
`useUrlTableState` returns options to spread into `useTable` or `<DataTable>`:
|
|
86
|
+
|
|
87
|
+
| Returned | Value |
|
|
88
|
+
| --- | --- |
|
|
89
|
+
| `state` | `{ sorting, pagination, columnFilters }`, derived from `search`. |
|
|
90
|
+
| `onSortingChange`, `onPaginationChange`, `onColumnFiltersChange` | Write the change to the URL through `setSearch`. |
|
|
91
|
+
| `manualSorting`, `manualPagination`, `manualFiltering` | All `true`: the server sorts, pages and filters. See [Sort and page in the browser](#sort-and-page-in-the-browser) to turn them off. |
|
|
92
|
+
|
|
93
|
+
The `<DataTable>` component from `kitr ui add data-table` takes these props, plus any [TanStack table option](https://tanstack.com/table/latest/docs/api/core/table):
|
|
94
|
+
|
|
95
|
+
| Prop | Default | Description |
|
|
96
|
+
| --- | --- | --- |
|
|
97
|
+
| `columns` | required | Column definitions, typed `ColumnDef<DataTableFeatures, Order>[]`. |
|
|
98
|
+
| `data` | required | The rows to show: the current page when the server pages. |
|
|
99
|
+
| `rowCount` | none | Total rows across all pages, so the footer can say "Page 3 of 40" and disable Next on the last page. |
|
|
100
|
+
| `empty` | `'No results.'` | What the body shows when `data` is empty. |
|
|
101
|
+
| `className` | none | Classes for the outer wrapper. |
|
|
102
|
+
|
|
103
|
+
## Show your first sortable table
|
|
104
|
+
|
|
105
|
+
Three pieces: column definitions, a route that accepts table params, and the table bound to them. Start with the columns:
|
|
106
|
+
|
|
107
|
+
```tsx
|
|
108
|
+
// app/components/orders/columns.tsx
|
|
109
|
+
import type { ColumnDef, DataTableFeatures } from '@kitr/table'
|
|
110
|
+
|
|
111
|
+
export interface Order {
|
|
112
|
+
id: string
|
|
113
|
+
customer: string
|
|
114
|
+
total: number
|
|
115
|
+
status: 'open' | 'paid' | 'refunded'
|
|
116
|
+
placedAt: string
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export const columns: ColumnDef<DataTableFeatures, Order>[] = [
|
|
120
|
+
{ accessorKey: 'customer', header: 'Customer' },
|
|
121
|
+
{
|
|
122
|
+
accessorKey: 'total',
|
|
123
|
+
header: 'Total',
|
|
124
|
+
cell: ({ row }) => `€${row.original.total.toFixed(2)}`,
|
|
125
|
+
},
|
|
126
|
+
{ accessorKey: 'status', header: 'Status', enableSorting: false },
|
|
127
|
+
{
|
|
128
|
+
accessorKey: 'placedAt',
|
|
129
|
+
header: 'Placed',
|
|
130
|
+
cell: ({ row }) => new Date(row.original.placedAt).toLocaleDateString('en-GB'),
|
|
131
|
+
},
|
|
132
|
+
]
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Then the page. `validateSearch: parseTableSearch` makes `page`, `size`, `sort` and `filter` typed search params; the loader fetches the matching page; `useUrlTableState` turns the params into table state:
|
|
136
|
+
|
|
137
|
+
```ts
|
|
138
|
+
// app/routes.ts
|
|
139
|
+
import { parseTableSearch } from '@kitr/table'
|
|
140
|
+
import { ordersPageQuery } from '#app/queries/orders'
|
|
141
|
+
|
|
142
|
+
r.page('/orders', {
|
|
143
|
+
validateSearch: parseTableSearch,
|
|
144
|
+
loaderDeps: ({ search }) => search,
|
|
145
|
+
loader: ({ context, deps }) => context.queryClient.ensureQueryData(ordersPageQuery.options(deps)),
|
|
146
|
+
lazy: () => import('./pages/orders/index'),
|
|
147
|
+
})
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
```tsx
|
|
151
|
+
// app/pages/orders/index.tsx
|
|
152
|
+
import { useSuspenseQuery } from '@kitr/query'
|
|
153
|
+
import { useUrlTableState } from '@kitr/table'
|
|
154
|
+
import { DataTable } from '#components/ui/data-table'
|
|
155
|
+
import { columns } from '#app/components/orders/columns'
|
|
156
|
+
import { ordersPageQuery } from '#app/queries/orders'
|
|
157
|
+
import { getRouteApi } from '@kitr/router'
|
|
158
|
+
|
|
159
|
+
const route = getRouteApi('/orders')
|
|
160
|
+
|
|
161
|
+
export default function OrdersPage() {
|
|
162
|
+
const search = route.useSearch()
|
|
163
|
+
const navigate = route.useNavigate()
|
|
164
|
+
const { data } = useSuspenseQuery(ordersPageQuery.options(search))
|
|
165
|
+
|
|
166
|
+
const urlState = useUrlTableState({
|
|
167
|
+
search,
|
|
168
|
+
setSearch: (patch) => navigate({ search: (prev) => ({ ...prev, ...patch }) }),
|
|
169
|
+
})
|
|
170
|
+
|
|
171
|
+
return <DataTable columns={columns} data={data.rows} rowCount={data.total} {...urlState} />
|
|
172
|
+
}
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
Open `/orders` and click the Total header. The URL becomes `/orders?sort=-total` and the table shows the largest orders first, even after a reload. [Fetch one page from the server](#fetch-one-page-from-the-server) shows `ordersPageQuery` and the API behind it.
|
|
176
|
+
|
|
177
|
+
## Define columns
|
|
178
|
+
|
|
179
|
+
A column needs an id and a header. `accessorKey` reads a field and doubles as the id; `cell` renders it; `id` names a column that reads no field, such as a row of buttons:
|
|
180
|
+
|
|
181
|
+
```tsx
|
|
182
|
+
// app/components/orders/columns.tsx
|
|
183
|
+
import type { ColumnDef, DataTableFeatures } from '@kitr/table'
|
|
184
|
+
import { Badge } from '#components/ui/badge'
|
|
185
|
+
import { Button } from '#components/ui/button'
|
|
186
|
+
import type { Order } from '#app/lib/orders/types'
|
|
187
|
+
|
|
188
|
+
export const columns: ColumnDef<DataTableFeatures, Order>[] = [
|
|
189
|
+
{ accessorKey: 'customer', header: 'Customer' },
|
|
190
|
+
{
|
|
191
|
+
accessorKey: 'status',
|
|
192
|
+
header: 'Status',
|
|
193
|
+
enableSorting: false,
|
|
194
|
+
cell: ({ row }) => (
|
|
195
|
+
<Badge variant={row.original.status === 'paid' ? 'default' : 'secondary'}>
|
|
196
|
+
{row.original.status}
|
|
197
|
+
</Badge>
|
|
198
|
+
),
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
id: 'actions',
|
|
202
|
+
header: '',
|
|
203
|
+
enableSorting: false,
|
|
204
|
+
cell: ({ row }) => (
|
|
205
|
+
<Button variant="ghost" size="sm" onClick={() => console.log('refund', row.original.id)}>
|
|
206
|
+
Refund
|
|
207
|
+
</Button>
|
|
208
|
+
),
|
|
209
|
+
},
|
|
210
|
+
]
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The column id is what appears in the URL (`sort=customer`), so keep ids short and stable: changing one breaks saved links. `enableSorting: false` makes a header plain text in `<DataTable>`.
|
|
214
|
+
|
|
215
|
+
For a typed `getValue()` in each cell, use TanStack's column helper with the same feature set:
|
|
216
|
+
|
|
217
|
+
```tsx
|
|
218
|
+
// app/components/orders/columns.tsx
|
|
219
|
+
import { createColumnHelper, type DataTableFeatures } from '@kitr/table'
|
|
220
|
+
import type { Order } from '#app/lib/orders/types'
|
|
221
|
+
|
|
222
|
+
const column = createColumnHelper<DataTableFeatures, Order>()
|
|
223
|
+
|
|
224
|
+
export const columns = [
|
|
225
|
+
column.accessor('customer', { header: 'Customer' }),
|
|
226
|
+
column.accessor('total', {
|
|
227
|
+
header: 'Total',
|
|
228
|
+
cell: (info) => `€${info.getValue().toFixed(2)}`,
|
|
229
|
+
}),
|
|
230
|
+
]
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Define columns outside the component, or in `useMemo`, so the table does not rebuild them on every render.
|
|
234
|
+
|
|
235
|
+
## Keep sorting, paging and filters in the URL
|
|
236
|
+
|
|
237
|
+
`useUrlTableState` reads the table's state from `search` and writes every change back through `setSearch`. The URL is the only copy of the state, so there is nothing to sync. With TanStack Router's default serialiser:
|
|
238
|
+
|
|
239
|
+
| Table state | URL | Notes |
|
|
240
|
+
| --- | --- | --- |
|
|
241
|
+
| Page 3 | `?page=3` | One-based, unlike TanStack's zero-based `pageIndex`. Page 1 is left out. |
|
|
242
|
+
| 50 rows per page | `?size=50` | The default size is left out. `parseTableSearch` accepts up to 500. |
|
|
243
|
+
| Total, largest first | `?sort=-total` | A leading `-` means descending. |
|
|
244
|
+
| Customer A to Z, then newest | `?sort=customer%2C-placedAt` | Comma-separated, in priority order. The comma is URL-encoded. |
|
|
245
|
+
| Status is open | `?filter=%7B%22status%22%3A%22open%22%7D` | `filter` is an object, which the router stores as JSON: `{"status":"open"}`. |
|
|
246
|
+
|
|
247
|
+
Some writes also reset the page, because page 4 of the old order means nothing in the new one:
|
|
248
|
+
|
|
249
|
+
| Change | What `setSearch` receives |
|
|
250
|
+
| --- | --- |
|
|
251
|
+
| Click a header | `{ sort: '-total', page: undefined }` |
|
|
252
|
+
| Clear sorting | `{ sort: undefined, page: undefined }` |
|
|
253
|
+
| Set a filter | `{ filter: { status: 'open' }, page: undefined }` |
|
|
254
|
+
| Clear the last filter | `{ filter: undefined, page: undefined }` |
|
|
255
|
+
| Next page | `{ page: 2, size: undefined }` |
|
|
256
|
+
| Back to page 1 at the default size | `{ page: undefined, size: undefined }` |
|
|
257
|
+
|
|
258
|
+
Clicking a header cycles the sort directions. A number column such as Total starts largest first (`-total`, then `total`, then unsorted); a text column starts A to Z.
|
|
259
|
+
|
|
260
|
+
> [!WARNING] One source of truth
|
|
261
|
+
> Don't keep a copy of the sort or page in `useState` next to the table. The two drift apart, and the back button restores only one.
|
|
262
|
+
|
|
263
|
+
## Validate the URL
|
|
264
|
+
|
|
265
|
+
Anyone can edit a URL, so every search param is untrusted. `parseTableSearch` keeps what is valid and drops the rest without throwing:
|
|
266
|
+
|
|
267
|
+
```ts
|
|
268
|
+
// app/lib/orders/search.ts
|
|
269
|
+
import { parseTableSearch } from '@kitr/table'
|
|
270
|
+
|
|
271
|
+
parseTableSearch({ page: '2', size: '50', sort: '-total,customer', filter: { status: 'open' } })
|
|
272
|
+
// => { page: 2, size: 50, sort: '-total,customer', filter: { status: 'open' } }
|
|
273
|
+
|
|
274
|
+
parseTableSearch({ page: '0', size: '9999', sort: 'total;drop table', filter: 'open' })
|
|
275
|
+
// => {}
|
|
276
|
+
|
|
277
|
+
parseTableSearch({ page: '1.5', filter: { status: 'open', total: 100 } })
|
|
278
|
+
// => { filter: { status: 'open' } }
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
| Param | Kept when |
|
|
282
|
+
| --- | --- |
|
|
283
|
+
| `page` | A whole number of 1 or more. |
|
|
284
|
+
| `size` | A whole number from 1 to 500. |
|
|
285
|
+
| `sort` | Comma-separated column ids of letters, digits, `_` and `.`, each optionally prefixed with `-`. |
|
|
286
|
+
| `filter` | An object; only its string values are kept. |
|
|
287
|
+
|
|
288
|
+
For params of your own, such as a search box, call it inside your own `validateSearch`:
|
|
289
|
+
|
|
290
|
+
```ts
|
|
291
|
+
// app/routes.ts
|
|
292
|
+
import { parseTableSearch } from '@kitr/table'
|
|
293
|
+
|
|
294
|
+
r.page('/orders', {
|
|
295
|
+
validateSearch: (raw: Record<string, unknown>) => ({
|
|
296
|
+
...parseTableSearch(raw),
|
|
297
|
+
q: typeof raw.q === 'string' && raw.q.length <= 100 ? raw.q : undefined,
|
|
298
|
+
}),
|
|
299
|
+
lazy: () => import('./pages/orders/index'),
|
|
300
|
+
})
|
|
301
|
+
```
|
|
302
|
+
|
|
303
|
+
```tsx
|
|
304
|
+
// app/pages/orders/index.tsx
|
|
305
|
+
import { getRouteApi } from '@kitr/router'
|
|
306
|
+
|
|
307
|
+
const route = getRouteApi('/orders')
|
|
308
|
+
|
|
309
|
+
export default function OrdersPage() {
|
|
310
|
+
const { q } = route.useSearch()
|
|
311
|
+
return <p>Searching for {q}</p>
|
|
312
|
+
}
|
|
313
|
+
```
|
|
314
|
+
|
|
315
|
+
The sort pattern allows dotted ids such as `customer.name`. Your API should still accept only the columns it knows.
|
|
316
|
+
|
|
317
|
+
## Fetch one page from the server
|
|
318
|
+
|
|
319
|
+
`useUrlTableState` sets `manualSorting`, `manualPagination` and `manualFiltering`: the server sorts, filters and slices, and the table shows what came back. Turn the search into a request in the query:
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
// app/queries/orders.ts
|
|
323
|
+
import { queryFrom } from '@kitr/query'
|
|
324
|
+
import type { TableSearch } from '@kitr/table'
|
|
325
|
+
import type { Order } from './columns'
|
|
326
|
+
|
|
327
|
+
export interface OrdersPage {
|
|
328
|
+
rows: Order[]
|
|
329
|
+
total: number
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export const ordersPageQuery = queryFrom<TableSearch, OrdersPage>({
|
|
333
|
+
key: (search) => ['orders', 'page', search],
|
|
334
|
+
fetch: async (search, { signal }) => {
|
|
335
|
+
const params = new URLSearchParams()
|
|
336
|
+
if (search.page) params.set('page', String(search.page))
|
|
337
|
+
if (search.size) params.set('size', String(search.size))
|
|
338
|
+
if (search.sort) params.set('sort', search.sort)
|
|
339
|
+
for (const [column, value] of Object.entries(search.filter ?? {})) params.set(column, value)
|
|
340
|
+
const res = await fetch(`/api/orders?${params}`, { signal })
|
|
341
|
+
return (await res.json()) as OrdersPage
|
|
342
|
+
},
|
|
343
|
+
})
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
The whole `search` object is in the key, so every view is cached on its own and the back button shows the previous page instantly. On the server, read the same params, allow only known columns, and return the page with the total:
|
|
347
|
+
|
|
348
|
+
```ts
|
|
349
|
+
// server/controllers/orders-controller.ts
|
|
350
|
+
import type { HttpContext } from '@kitr/api'
|
|
351
|
+
import { parseSort } from '@kitr/table'
|
|
352
|
+
import { type Order, orders } from '#server/orders/data'
|
|
353
|
+
|
|
354
|
+
const sorters = {
|
|
355
|
+
customer: (a: Order, b: Order) => a.customer.localeCompare(b.customer),
|
|
356
|
+
total: (a: Order, b: Order) => a.total - b.total,
|
|
357
|
+
placedAt: (a: Order, b: Order) => a.placedAt.localeCompare(b.placedAt),
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export default class OrdersController {
|
|
361
|
+
async index({ query }: HttpContext<Record<string, string>, Record<string, string>>) {
|
|
362
|
+
const page = Math.max(1, Number(query.page) || 1)
|
|
363
|
+
const size = Math.min(500, Number(query.size) || 20)
|
|
364
|
+
const sorting = parseSort(query.sort).filter((s) => s.id in sorters)
|
|
365
|
+
|
|
366
|
+
const rows = query.status ? orders.filter((o) => o.status === query.status) : [...orders]
|
|
367
|
+
rows.sort((a, b) => {
|
|
368
|
+
for (const { id, desc } of sorting) {
|
|
369
|
+
const result = sorters[id as keyof typeof sorters](a, b)
|
|
370
|
+
if (result) return desc ? -result : result
|
|
371
|
+
}
|
|
372
|
+
return 0
|
|
373
|
+
})
|
|
374
|
+
return { rows: rows.slice((page - 1) * size, page * size), total: rows.length }
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
```
|
|
378
|
+
|
|
379
|
+
`parseSort('-total,customer')` returns `[{ id: 'total', desc: true }, { id: 'customer', desc: false }]`, and `formatSort()` reverses it. Use the same page size default on both sides, or always pass `size`. Pass the total as `rowCount`, or Next stays disabled.
|
|
380
|
+
|
|
381
|
+
> [!WARNING] Allow-list sort columns
|
|
382
|
+
> Only ids in `sorters` are used, so a hand-edited `?sort=password` sorts nothing. With a database, build the `ORDER BY` from such an allow-list, never from the raw string.
|
|
383
|
+
|
|
384
|
+
`@kitr/table` imports React; in a server without React, split the sort string yourself.
|
|
385
|
+
|
|
386
|
+
## Filter by a column
|
|
387
|
+
|
|
388
|
+
A filter is a column id and a string value. Set it from any input with `column.setFilterValue()`; `useUrlTableState` writes it to the URL and resets to page 1. An empty string removes it:
|
|
389
|
+
|
|
390
|
+
```tsx
|
|
391
|
+
// app/components/orders/status-filter.tsx
|
|
392
|
+
import type { DataTableFeatures, ReactTable } from '@kitr/table'
|
|
393
|
+
import type { Order } from './columns'
|
|
394
|
+
|
|
395
|
+
export function StatusFilter({ table }: { table: ReactTable<DataTableFeatures, Order> }) {
|
|
396
|
+
const column = table.getColumn('status')
|
|
397
|
+
return (
|
|
398
|
+
<select
|
|
399
|
+
aria-label="Status"
|
|
400
|
+
value={(column?.getFilterValue() as string | undefined) ?? ''}
|
|
401
|
+
onChange={(event) => column?.setFilterValue(event.target.value)}
|
|
402
|
+
>
|
|
403
|
+
<option value="">All statuses</option>
|
|
404
|
+
<option value="open">Open</option>
|
|
405
|
+
<option value="paid">Paid</option>
|
|
406
|
+
<option value="refunded">Refunded</option>
|
|
407
|
+
</select>
|
|
408
|
+
)
|
|
409
|
+
}
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
`<DataTable>` builds its table inside, so a filter outside it writes the URL directly, which is the same thing because the URL is the state:
|
|
413
|
+
|
|
414
|
+
```ts
|
|
415
|
+
// app/routes.ts
|
|
416
|
+
import { parseTableSearch } from '@kitr/table'
|
|
417
|
+
|
|
418
|
+
r.page('/orders', {
|
|
419
|
+
validateSearch: parseTableSearch,
|
|
420
|
+
lazy: () => import('./pages/orders/index'),
|
|
421
|
+
})
|
|
422
|
+
```
|
|
423
|
+
|
|
424
|
+
```tsx
|
|
425
|
+
// app/pages/orders/index.tsx
|
|
426
|
+
import { getRouteApi } from '@kitr/router'
|
|
427
|
+
|
|
428
|
+
const route = getRouteApi('/orders')
|
|
429
|
+
|
|
430
|
+
export default function OrdersToolbar() {
|
|
431
|
+
const search = route.useSearch()
|
|
432
|
+
const navigate = route.useNavigate()
|
|
433
|
+
return (
|
|
434
|
+
<select
|
|
435
|
+
aria-label="Status"
|
|
436
|
+
value={search.filter?.status ?? ''}
|
|
437
|
+
onChange={(event) => {
|
|
438
|
+
const status = event.target.value
|
|
439
|
+
const { status: _, ...rest } = search.filter ?? {}
|
|
440
|
+
const filter = status ? { ...rest, status } : rest
|
|
441
|
+
navigate({
|
|
442
|
+
search: (prev) => ({
|
|
443
|
+
...prev,
|
|
444
|
+
filter: Object.keys(filter).length ? filter : undefined,
|
|
445
|
+
page: undefined,
|
|
446
|
+
}),
|
|
447
|
+
})
|
|
448
|
+
}}
|
|
449
|
+
>
|
|
450
|
+
<option value="">All statuses</option>
|
|
451
|
+
<option value="open">Open</option>
|
|
452
|
+
<option value="paid">Paid</option>
|
|
453
|
+
</select>
|
|
454
|
+
)
|
|
455
|
+
}
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
Filter values are always strings in the URL, so convert a number filter (`minTotal=100`) on the server. For free-text search across columns, add your own `q` param as in [Validate the URL](#validate-the-url).
|
|
459
|
+
|
|
460
|
+
## Change the page size
|
|
461
|
+
|
|
462
|
+
`table.setPageSize()` writes `size` to the URL and keeps the first visible row on screen:
|
|
463
|
+
|
|
464
|
+
```tsx
|
|
465
|
+
// app/components/orders/page-size.tsx
|
|
466
|
+
import type { DataTableFeatures, ReactTable } from '@kitr/table'
|
|
467
|
+
import type { Order } from './columns'
|
|
468
|
+
|
|
469
|
+
export function PageSize({ table }: { table: ReactTable<DataTableFeatures, Order> }) {
|
|
470
|
+
return (
|
|
471
|
+
<select
|
|
472
|
+
aria-label="Rows per page"
|
|
473
|
+
value={table.state.pagination.pageSize}
|
|
474
|
+
onChange={(event) => table.setPageSize(Number(event.target.value))}
|
|
475
|
+
>
|
|
476
|
+
{[20, 50, 100].map((size) => (
|
|
477
|
+
<option key={size} value={size}>
|
|
478
|
+
{size} rows
|
|
479
|
+
</option>
|
|
480
|
+
))}
|
|
481
|
+
</select>
|
|
482
|
+
)
|
|
483
|
+
}
|
|
484
|
+
// choosing 50 => setSearch({ page: undefined, size: 50 })
|
|
485
|
+
```
|
|
486
|
+
|
|
487
|
+
The default size is removed from the URL, and `parseTableSearch` drops sizes above 500, so a hand-edited `?size=100000` falls back to the default.
|
|
488
|
+
|
|
489
|
+
## Sort and page in the browser
|
|
490
|
+
|
|
491
|
+
For a small list that arrives in one response, such as one customer's twenty orders, let the table sort, filter and page itself. Add TanStack's row models to the feature set, and turn the manual flags off after the spread:
|
|
492
|
+
|
|
493
|
+
```tsx
|
|
494
|
+
// app/components/orders/customer-orders.tsx
|
|
495
|
+
import {
|
|
496
|
+
type ColumnDef,
|
|
497
|
+
createFilteredRowModel,
|
|
498
|
+
createPaginatedRowModel,
|
|
499
|
+
createSortedRowModel,
|
|
500
|
+
dataTableFeatures,
|
|
501
|
+
filterFn_equalsString,
|
|
502
|
+
sortFn_alphanumeric,
|
|
503
|
+
sortFn_basic,
|
|
504
|
+
type TableSearch,
|
|
505
|
+
tableFeatures,
|
|
506
|
+
useTable,
|
|
507
|
+
useUrlTableState,
|
|
508
|
+
} from '@kitr/table'
|
|
509
|
+
import type { Order } from './columns'
|
|
510
|
+
|
|
511
|
+
const clientFeatures = tableFeatures({
|
|
512
|
+
...dataTableFeatures,
|
|
513
|
+
sortedRowModel: createSortedRowModel(),
|
|
514
|
+
paginatedRowModel: createPaginatedRowModel(),
|
|
515
|
+
filteredRowModel: createFilteredRowModel(),
|
|
516
|
+
sortFns: { alphanumeric: sortFn_alphanumeric, basic: sortFn_basic },
|
|
517
|
+
filterFns: { equalsString: filterFn_equalsString },
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
const columns: ColumnDef<typeof clientFeatures, Order>[] = [
|
|
521
|
+
{ accessorKey: 'customer', header: 'Customer' },
|
|
522
|
+
{ accessorKey: 'total', header: 'Total' },
|
|
523
|
+
{ accessorKey: 'status', header: 'Status', filterFn: 'equalsString' },
|
|
524
|
+
]
|
|
525
|
+
|
|
526
|
+
export function CustomerOrders(props: {
|
|
527
|
+
orders: Order[]
|
|
528
|
+
search: TableSearch
|
|
529
|
+
setSearch: (patch: Partial<TableSearch>) => void
|
|
530
|
+
}) {
|
|
531
|
+
const urlState = useUrlTableState({ ...props, defaultPageSize: 10 })
|
|
532
|
+
const table = useTable({
|
|
533
|
+
features: clientFeatures,
|
|
534
|
+
columns,
|
|
535
|
+
data: props.orders,
|
|
536
|
+
...urlState,
|
|
537
|
+
manualSorting: false,
|
|
538
|
+
manualPagination: false,
|
|
539
|
+
manualFiltering: false,
|
|
540
|
+
})
|
|
541
|
+
return <p>{table.getRowModel().rows.map((row) => row.original.customer).join(', ')}</p>
|
|
542
|
+
}
|
|
543
|
+
```
|
|
544
|
+
|
|
545
|
+
Given Grace's 120, Ada's 80 and Linus's 300 and `?sort=-total`, the rows come out Linus, Grace, Ada. The state still lives in the URL; only the work moved to the browser.
|
|
546
|
+
|
|
547
|
+
> [!WARNING] Add the row models
|
|
548
|
+
> Turning the flags off is not enough: `dataTableFeatures` has no row models, so without `sortedRowModel` and the rest the table keeps your row order, whatever the URL says.
|
|
549
|
+
|
|
550
|
+
`clientTableFeatures` is that feature set ready-made. With it, `useUrlTableState({ search, setSearch, manual: false })` is all a client-side table needs, and `<DataTable columns={columns} data={rows} clientSide {...tableState} />` does both.
|
|
551
|
+
|
|
552
|
+
## Hide columns
|
|
553
|
+
|
|
554
|
+
`dataTableFeatures` includes column visibility. Keep it in component state, or in [state](/packages/state) to remember it across visits, and merge it into the URL state:
|
|
555
|
+
|
|
556
|
+
```tsx
|
|
557
|
+
// app/components/orders/orders-table.tsx
|
|
558
|
+
import {
|
|
559
|
+
type ColumnDef,
|
|
560
|
+
type DataTableFeatures,
|
|
561
|
+
dataTableFeatures,
|
|
562
|
+
type TableSearch,
|
|
563
|
+
useTable,
|
|
564
|
+
useUrlTableState,
|
|
565
|
+
type VisibilityState,
|
|
566
|
+
} from '@kitr/table'
|
|
567
|
+
import { useState } from 'react'
|
|
568
|
+
import type { Order } from './columns'
|
|
569
|
+
|
|
570
|
+
export function OrdersTable(props: {
|
|
571
|
+
columns: ColumnDef<DataTableFeatures, Order>[]
|
|
572
|
+
rows: Order[]
|
|
573
|
+
search: TableSearch
|
|
574
|
+
setSearch: (patch: Partial<TableSearch>) => void
|
|
575
|
+
}) {
|
|
576
|
+
const urlState = useUrlTableState(props)
|
|
577
|
+
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({ placedAt: false })
|
|
578
|
+
const table = useTable({
|
|
579
|
+
features: dataTableFeatures,
|
|
580
|
+
columns: props.columns,
|
|
581
|
+
data: props.rows,
|
|
582
|
+
...urlState,
|
|
583
|
+
state: { ...urlState.state, columnVisibility },
|
|
584
|
+
onColumnVisibilityChange: setColumnVisibility,
|
|
585
|
+
})
|
|
586
|
+
|
|
587
|
+
return (
|
|
588
|
+
<fieldset>
|
|
589
|
+
{table.getAllLeafColumns().map((column) => (
|
|
590
|
+
<label key={column.id}>
|
|
591
|
+
<input
|
|
592
|
+
type="checkbox"
|
|
593
|
+
checked={column.getIsVisible()}
|
|
594
|
+
onChange={column.getToggleVisibilityHandler()}
|
|
595
|
+
/>
|
|
596
|
+
{column.id}
|
|
597
|
+
</label>
|
|
598
|
+
))}
|
|
599
|
+
</fieldset>
|
|
600
|
+
)
|
|
601
|
+
}
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
Spread `urlState` first and merge `urlState.state` into `state`: writing `state: { columnVisibility }` alone would drop sorting, paging and filters. Visibility is a viewer's preference, so it stays out of the URL.
|
|
605
|
+
|
|
606
|
+
## Select rows
|
|
607
|
+
|
|
608
|
+
Row selection is not in the default set. Add `rowSelectionFeature` to a feature set of your own, and give rows a stable id so a selection survives a refetch:
|
|
609
|
+
|
|
610
|
+
```tsx
|
|
611
|
+
// app/components/orders/refund-table.tsx
|
|
612
|
+
import {
|
|
613
|
+
type ColumnDef,
|
|
614
|
+
dataTableFeatures,
|
|
615
|
+
type RowSelectionState,
|
|
616
|
+
rowSelectionFeature,
|
|
617
|
+
tableFeatures,
|
|
618
|
+
useTable,
|
|
619
|
+
} from '@kitr/table'
|
|
620
|
+
import { useState } from 'react'
|
|
621
|
+
import type { Order } from './columns'
|
|
622
|
+
|
|
623
|
+
const features = tableFeatures({ ...dataTableFeatures, rowSelectionFeature })
|
|
624
|
+
|
|
625
|
+
const columns: ColumnDef<typeof features, Order>[] = [{ accessorKey: 'customer', header: 'Customer' }]
|
|
626
|
+
|
|
627
|
+
export function RefundTable({ orders }: { orders: Order[] }) {
|
|
628
|
+
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
|
629
|
+
const table = useTable({
|
|
630
|
+
features,
|
|
631
|
+
columns,
|
|
632
|
+
data: orders,
|
|
633
|
+
getRowId: (order) => order.id,
|
|
634
|
+
state: { rowSelection },
|
|
635
|
+
onRowSelectionChange: setRowSelection,
|
|
636
|
+
})
|
|
637
|
+
|
|
638
|
+
return (
|
|
639
|
+
<form>
|
|
640
|
+
{table.getRowModel().rows.map((row) => (
|
|
641
|
+
<label key={row.id}>
|
|
642
|
+
<input
|
|
643
|
+
type="checkbox"
|
|
644
|
+
checked={row.getIsSelected()}
|
|
645
|
+
onChange={row.getToggleSelectedHandler()}
|
|
646
|
+
/>
|
|
647
|
+
{row.original.customer}
|
|
648
|
+
</label>
|
|
649
|
+
))}
|
|
650
|
+
<button type="submit" disabled={!table.getSelectedRowModel().rows.length}>
|
|
651
|
+
Refund {table.getSelectedRowModel().rows.length} orders
|
|
652
|
+
</button>
|
|
653
|
+
</form>
|
|
654
|
+
)
|
|
655
|
+
}
|
|
656
|
+
```
|
|
657
|
+
|
|
658
|
+
Checking Grace's order 1001 sets `rowSelection` to `{ '1001': true }`. Without `getRowId`, rows are keyed by page index, so a selection points at different orders after a sort. `<DataTable>` is typed for `dataTableFeatures`; for extra features, edit your copy of `data-table.tsx` or render the rows yourself.
|
|
659
|
+
|
|
660
|
+
## Render your own markup
|
|
661
|
+
|
|
662
|
+
`<DataTable>` is one rendering. For another, such as cards on phones, call `useTable` with `dataTableFeatures` and render with `table.FlexRender`:
|
|
663
|
+
|
|
664
|
+
```tsx
|
|
665
|
+
// app/components/orders/orders-grid.tsx
|
|
666
|
+
import {
|
|
667
|
+
type ColumnDef,
|
|
668
|
+
type DataTableFeatures,
|
|
669
|
+
dataTableFeatures,
|
|
670
|
+
type TableSearch,
|
|
671
|
+
useTable,
|
|
672
|
+
useUrlTableState,
|
|
673
|
+
} from '@kitr/table'
|
|
674
|
+
import type { Order } from './columns'
|
|
675
|
+
|
|
676
|
+
export function OrdersGrid(props: {
|
|
677
|
+
columns: ColumnDef<DataTableFeatures, Order>[]
|
|
678
|
+
rows: Order[]
|
|
679
|
+
total: number
|
|
680
|
+
search: TableSearch
|
|
681
|
+
setSearch: (patch: Partial<TableSearch>) => void
|
|
682
|
+
}) {
|
|
683
|
+
const table = useTable({
|
|
684
|
+
features: dataTableFeatures,
|
|
685
|
+
columns: props.columns,
|
|
686
|
+
data: props.rows,
|
|
687
|
+
rowCount: props.total,
|
|
688
|
+
...useUrlTableState(props),
|
|
689
|
+
})
|
|
690
|
+
|
|
691
|
+
return (
|
|
692
|
+
<table>
|
|
693
|
+
<thead>
|
|
694
|
+
{table.getHeaderGroups().map((group) => (
|
|
695
|
+
<tr key={group.id}>
|
|
696
|
+
{group.headers.map((header) => (
|
|
697
|
+
<th key={header.id} aria-sort={header.column.getIsSorted() === 'desc' ? 'descending' : header.column.getIsSorted() === 'asc' ? 'ascending' : 'none'}>
|
|
698
|
+
<button
|
|
699
|
+
type="button"
|
|
700
|
+
disabled={!header.column.getCanSort()}
|
|
701
|
+
onClick={header.column.getToggleSortingHandler()}
|
|
702
|
+
>
|
|
703
|
+
<table.FlexRender header={header} />
|
|
704
|
+
</button>
|
|
705
|
+
</th>
|
|
706
|
+
))}
|
|
707
|
+
</tr>
|
|
708
|
+
))}
|
|
709
|
+
</thead>
|
|
710
|
+
<tbody>
|
|
711
|
+
{table.getRowModel().rows.map((row) => (
|
|
712
|
+
<tr key={row.id}>
|
|
713
|
+
{row.getVisibleCells().map((cell) => (
|
|
714
|
+
<td key={cell.id}>
|
|
715
|
+
<table.FlexRender cell={cell} />
|
|
716
|
+
</td>
|
|
717
|
+
))}
|
|
718
|
+
</tr>
|
|
719
|
+
))}
|
|
720
|
+
</tbody>
|
|
721
|
+
<tfoot>
|
|
722
|
+
<tr>
|
|
723
|
+
<td>
|
|
724
|
+
Page {table.state.pagination.pageIndex + 1} of {table.getPageCount()}
|
|
725
|
+
<button type="button" disabled={!table.getCanNextPage()} onClick={() => table.nextPage()}>
|
|
726
|
+
Next
|
|
727
|
+
</button>
|
|
728
|
+
</td>
|
|
729
|
+
</tr>
|
|
730
|
+
</tfoot>
|
|
731
|
+
</table>
|
|
732
|
+
)
|
|
733
|
+
}
|
|
734
|
+
```
|
|
735
|
+
|
|
736
|
+
With 45 orders and the default size of 20, `table.getPageCount()` is `3`. [TanStack's table guides](https://tanstack.com/table/latest/docs/guide/tables) cover header groups, footers, pinning and the rest.
|
|
737
|
+
|
|
738
|
+
## Keep table state out of the URL
|
|
739
|
+
|
|
740
|
+
`useUrlTableState` only needs `search` and `setSearch`, so any state store works. For a table in a dialog, where the URL should not change, use `useState`:
|
|
741
|
+
|
|
742
|
+
```tsx
|
|
743
|
+
// app/components/orders/pick-order-dialog.tsx
|
|
744
|
+
import { type ColumnDef, type DataTableFeatures, type TableSearch, useUrlTableState } from '@kitr/table'
|
|
745
|
+
import { useState } from 'react'
|
|
746
|
+
import { DataTable } from '#components/ui/data-table'
|
|
747
|
+
import type { Order } from './columns'
|
|
748
|
+
|
|
749
|
+
export function PickOrderDialog(props: { columns: ColumnDef<DataTableFeatures, Order>[]; orders: Order[] }) {
|
|
750
|
+
const [search, setSearchState] = useState<TableSearch>({})
|
|
751
|
+
const urlState = useUrlTableState({
|
|
752
|
+
search,
|
|
753
|
+
setSearch: (patch) => setSearchState((prev) => ({ ...prev, ...patch })),
|
|
754
|
+
defaultPageSize: 10,
|
|
755
|
+
})
|
|
756
|
+
return <DataTable columns={props.columns} data={props.orders} {...urlState} />
|
|
757
|
+
}
|
|
758
|
+
```
|
|
759
|
+
|
|
760
|
+
Closing the dialog forgets the sort; everything else behaves as it does in the URL.
|
|
761
|
+
|
|
762
|
+
## Testing
|
|
763
|
+
|
|
764
|
+
A test renders the table with a `search` object and a mock `setSearch`, clicks, and asserts what would have gone into the URL. No router is needed:
|
|
765
|
+
|
|
766
|
+
```tsx
|
|
767
|
+
// app/components/orders/orders-grid.test.tsx
|
|
768
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
769
|
+
import {
|
|
770
|
+
type ColumnDef,
|
|
771
|
+
type DataTableFeatures,
|
|
772
|
+
dataTableFeatures,
|
|
773
|
+
type TableSearch,
|
|
774
|
+
useTable,
|
|
775
|
+
useUrlTableState,
|
|
776
|
+
} from '@kitr/table'
|
|
777
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
778
|
+
|
|
779
|
+
interface Order {
|
|
780
|
+
id: string
|
|
781
|
+
customer: string
|
|
782
|
+
total: number
|
|
783
|
+
status: string
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
const orders: Order[] = [
|
|
787
|
+
{ id: '1001', customer: 'Grace', total: 120, status: 'open' },
|
|
788
|
+
{ id: '1002', customer: 'Ada', total: 80, status: 'paid' },
|
|
789
|
+
]
|
|
790
|
+
|
|
791
|
+
const columns: ColumnDef<DataTableFeatures, Order>[] = [
|
|
792
|
+
{ accessorKey: 'customer', header: 'Customer' },
|
|
793
|
+
{ accessorKey: 'total', header: 'Total' },
|
|
794
|
+
{ accessorKey: 'status', header: 'Status', enableSorting: false },
|
|
795
|
+
]
|
|
796
|
+
|
|
797
|
+
function OrdersGrid(props: { search: TableSearch; setSearch: (patch: Partial<TableSearch>) => void }) {
|
|
798
|
+
const table = useTable({
|
|
799
|
+
features: dataTableFeatures,
|
|
800
|
+
columns,
|
|
801
|
+
data: orders,
|
|
802
|
+
rowCount: 45,
|
|
803
|
+
...useUrlTableState(props),
|
|
804
|
+
})
|
|
805
|
+
return (
|
|
806
|
+
<div>
|
|
807
|
+
{table.getHeaderGroups()[0]?.headers.map((header) => (
|
|
808
|
+
<button
|
|
809
|
+
key={header.id}
|
|
810
|
+
type="button"
|
|
811
|
+
disabled={!header.column.getCanSort()}
|
|
812
|
+
onClick={header.column.getToggleSortingHandler()}
|
|
813
|
+
>
|
|
814
|
+
<table.FlexRender header={header} />
|
|
815
|
+
</button>
|
|
816
|
+
))}
|
|
817
|
+
<button type="button" onClick={() => table.nextPage()}>
|
|
818
|
+
Next
|
|
819
|
+
</button>
|
|
820
|
+
<p>
|
|
821
|
+
Page {table.state.pagination.pageIndex + 1} of {table.getPageCount()}
|
|
822
|
+
</p>
|
|
823
|
+
</div>
|
|
824
|
+
)
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
describe('orders grid', () => {
|
|
828
|
+
it('reads the page from the URL', () => {
|
|
829
|
+
render(<OrdersGrid search={{ page: 2 }} setSearch={vi.fn()} />)
|
|
830
|
+
expect(screen.getByText('Page 2 of 3')).toBeTruthy()
|
|
831
|
+
})
|
|
832
|
+
|
|
833
|
+
it('sorts by total, largest first, and resets the page', () => {
|
|
834
|
+
const setSearch = vi.fn()
|
|
835
|
+
render(<OrdersGrid search={{ page: 2 }} setSearch={setSearch} />)
|
|
836
|
+
|
|
837
|
+
fireEvent.click(screen.getByRole('button', { name: 'Total' }))
|
|
838
|
+
|
|
839
|
+
expect(setSearch).toHaveBeenLastCalledWith({ sort: '-total', page: undefined })
|
|
840
|
+
})
|
|
841
|
+
|
|
842
|
+
it('writes the next page to the URL', () => {
|
|
843
|
+
const setSearch = vi.fn()
|
|
844
|
+
render(<OrdersGrid search={{}} setSearch={setSearch} />)
|
|
845
|
+
|
|
846
|
+
fireEvent.click(screen.getByRole('button', { name: 'Next' }))
|
|
847
|
+
|
|
848
|
+
expect(setSearch).toHaveBeenLastCalledWith({ page: 2, size: undefined })
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
it('does not sort by status', () => {
|
|
852
|
+
render(<OrdersGrid search={{}} setSearch={vi.fn()} />)
|
|
853
|
+
expect((screen.getByRole('button', { name: 'Status' }) as HTMLButtonElement).disabled).toBe(true)
|
|
854
|
+
})
|
|
855
|
+
})
|
|
856
|
+
```
|
|
857
|
+
|
|
858
|
+
To test the route's own `validateSearch`, call `parseTableSearch` with the raw params a link could carry, as in [Validate the URL](#validate-the-url). To render the real page with its loader, see [testing](/packages/testing#render-a-whole-page).
|
|
859
|
+
|
|
860
|
+
## Next steps
|
|
861
|
+
|
|
862
|
+
- [API reference](/packages/table/api): every export of `@kitr/table`.
|
|
863
|
+
- [Router](/packages/router): `validateSearch`, `loaderDeps` and `navigate`, which the URL state uses.
|
|
864
|
+
- [Query](/packages/query): cache each page of rows by its search params.
|