Saved views.
Every DataList can now capture, restore and share its working state — filters, quick filters, search, sort and visible columns — as named views. One schema block turns it on; the engine owns the rest.
Why
Operators live in our tables — and rebuild the same working setups all day, every day.
1.1The operator ritual
Watch anyone work a dense back-office table and the pattern is unmistakable: status filter on, sort by issue date, hide the four columns they never read — then do the exact same dance after lunch, and again tomorrow morning. DataList already persists its state in the URL, but that state is ephemeral: leave the page with different filters and the ritual starts over.
Worse, a working setup was not transferable. There was no way to hand a colleague "the table exactly as I'm looking at it" — you described your filters over Slack and hoped they clicked the same six things.
1.2What a view captures
A saved view snapshots the five dimensions that define "how I look at this table":
export interface DataListPresetState {
filters: GenericObject
quickFilters: GenericObject
searchQuery: string
sort: { key: string; dir: SortOrder } | null
visibleColumns: string[]
}
export interface DataListPreset {
id: string
label: string
state: DataListPresetState
}Column capture is deliberately careful: only visible leaf columns are recorded — never internal group keys — and required columns survive application no matter what a stale preset says. Active detection compares structural equality with order-insensitive columns, so the selector always knows whether what you're looking at is a saved view, without storing any extra flag.
1.3Try it: the selector
This is a faithful recreation of the shipped UI — the playground's invoice table (/dev/table) with its three seeded views. Open the bookmark control, switch views, watch the table obey. Remove a filter tag and notice the active badge drop — structural equality at work. Save your own view; delete it; try the search.
The design
Three decisions shape the whole feature: presets live in the Query cache, the engine and the app split ownership along a strict line, and types flow past the minimal shape.
2.1Presets are server state
The obvious implementation — a reactive array somewhere in the table engine — was rejected on day one. Saved views belong to a user and an organisation; they are server state, and this codebase already has exactly one owner for server state: TanStack Query (PR #137).
So presets.query() returns canonical queryOptions, and DataList mounts a real Query observer on it. After a successful create or delete, the engine writes back through typed cache upserts and removals — no duplicate local list, no synchronization watcher, no "refresh the views" call:
const preset = await handler(deepClone(currentState.value))
if (!preset) return // app cancelled its own modal — nothing happened
query.setData((current) => upsertDataListPreset(current, preset))Everything Query gives every other feature comes along free: caching between tables that share a key, background revalidation, request dedup, devtools visibility, and controller invalidation once views are persisted behind the generated client.
2.2The ownership split
The API is four callbacks — one required, three optional. The line between them is the whole design:
| Callback | Contract | Why it lives in the app |
|---|---|---|
query() | Canonical queryOptions returning presets | Persistence is a product decision — API, localStorage, anything |
create(state) | Async; may open any UI; resolves the created preset, or null when cancelled | Naming UX, validation, ownership rules differ per app |
delete(preset) | Async; false cancels; resolves after persistence succeeds | Confirmation dialogs and notifications are app furniture |
canDelete(preset) | Sync; gates the affordance per preset | Rights & ownership are runtime facts the engine can't know |
Just as deliberate is what the config does not have: no schema key, no state serializer, no rendering callbacks. The selector, the previews, the active detection, the loading states — none of it is configurable, so none of it can drift between tables. Every DataList in every app gets the identical, polished affordance, and a table adopts the feature without shipping a single component.
delete, no delete affordance renders at all. Without canDelete, every preset is eligible. And canDelete only controls the button — the skill docs are explicit that the delete handler must still enforce authorization server-side.2.3Typed past the minimal shape
A preset only needs { id, label, state } — but real presets carry metadata: an owner, a scope, permissions. The naive typing would widen everything to the minimal shape and force casts in every callback. Instead, a Preset generic is threaded through defineTableSchema's overloads, and defineDataListPresets pins the query's concrete item type so it flows into every lifecycle callback:
interface OrgPreset extends DataListPreset {
isOwner: boolean
scope: 'personal' | 'organisation'
}
presets: defineDataListPresets({
query: () => $client.tableViews.list.queryOptions({ input: { tableKey } }),
// ^ resolves OrgPreset[]
canDelete: (preset) => preset.isOwner,
// ^ OrgPreset — `isOwner` is right there, fully typed
delete: (preset) => confirmThenDelete(preset.scope, preset.id),
})This is covered by dedicated type-level tests: expectTypeOf assertions guarantee that query-owned properties survive into create, delete and canDelete, and that the config never degrades to the minimal shape.
Shareable by construction
"Look at the table the way I see it" is now a URL.
3.1?preset=<id>, one shot
A preset-enabled DataList accepts a ?preset=<id> deep link. The semantics are deliberately one-shot: on initialization the table waits for the preset query to settle, applies the matching state if the id exists, then consumes the parameter through the shared route-query manager — leaving the URL clean and the table free to diverge.
- Waits, doesn't race. If presets are still loading, application is deferred until the query settles — a link never silently loses because the network was slow.
- Consumes only its own key. Filters, sort and pagination already in the URL survive untouched; only
presetis removed. - Fails closed. An unknown id is consumed without changing table state — a deleted view degrades to the default table, not an error.
- Composes with the ecosystem. Anything that can build a URL — a notification email, a dashboard tile, a Slack message — can now open a table pre-configured.
3.2Try it: open a shared link
Replay what happens when a colleague opens your link while the preset query is still in flight — and watch the applied state take over the URL: DataList serializes filters and sort into the route, so what you end up with is not a bare path but the shareable state itself.
The decoupling
The selector must answer "what does this view contain?" with human labels — outside any mounted filter form. That forced a fix on a long-standing coupling, and it's the part of this PR that pays for itself beyond presets.
4.1Before: labels through form state
Filter tags — the little chips that say Status: Overdue instead of status: ["overdue"] — used to resolve their display labels by reaching into the mounted filter form through $formApi.getFieldApi(...). Preview rendering was welded to live form state: it only worked inside a mounted DataList, options followed the form's lifecycle instead of a cache, and rendering a preset's contents in a popover or a modal was structurally impossible.
// display contract drags FieldApi along
render?: (value: any, api: FieldApi) => VNodeChild
// tag resolves labels via live form state
const fieldApi = $formApi.getFieldApi({
fieldKey: key,
instanceId: `${tableKey}::filters`,
})
const options = fieldApi?.getOptions?.() ?? []// display contract takes the value. Period.
render?: (value: any) => VNodeChild
// options resolved from the schema itself,
// through the app QueryClient
const { options, pending } =
useFilterOptions(computed(() => filter))4.2Filter options are queries
Table filter options that need remote data now expose canonical TanStack queryOptions — the exact contract form fields adopted in PR #137, sharing the same isFieldOptionQuery detection and buildOption mapping. One migration in customer-front shows the whole move:
options: () =>
$client.manufacturers.list.call()
.then((manufacturers) =>
manufacturers.map(toOption).sort(byLabel),
)options: () =>
$client.manufacturers.list.queryOptions({
select: (manufacturers) =>
manufacturers.map(toOption).sort(byLabel),
})The new useFilterOptions composable resolves every source shape a filter can declare — static arrays stay synchronous, zero-argument functions may return arrays, promises or query options — and mounts a Query observer only when there's actually a query, with keepPreviousData so revalidation never blanks a label.
4.3One resolver everywhere
Live filter tags, the preset selector's previews, and the standalone DataListPresetStatePreview component all render through the same resolver. The practical consequences:
DataListPresetStatePreview takes a schema and a state — it renders sort, column count, search and resolved filter tags in any popover or app modal, no mounted DataList required.
Cached labels render instantly while the query revalidates in the background. Only a genuinely cold query shows a tag skeleton — covered by its own test.
A filter panel, its tags, a preset preview and a form field sharing a generated key share one cache entry — request dedup across surfaces, free.
preview.render(value) and preview.tagProps(value) lost their FieldApi parameter — previews are pure functions of the stored value now.
Consuming it
The playground page is the reference implementation: everything below ships in this PR and runs at /dev/table.
5.1The schema block
The entire integration surface, on the playground's invoice table — a Query source over localStorage, an app-owned creation modal, a confirm dialog for deletion, and canDelete protecting a shared view:
const schema = defineTableSchema({
// …filters, columns, source, actions — unchanged…
presets: {
query: () =>
queryOptions({
queryKey: ['playground', 'data-list-presets', 'invoice-groups'],
queryFn: async () => structuredClone(toRaw(savedPresets.value)),
}),
create: requestPresetCreation,
delete: requestPresetDeletion,
canDelete: (preset) => preset.id !== 'month-end-review',
},
})That's it. The bookmark control appears in the table header automatically (the standard controls opt-out applies if a surface shouldn't have it).
5.2The creation modal
Creation UI belongs to the app — so the playground builds its modal with the existing form engine, and drops the shared preview component in to show exactly what's being saved:
async function requestPresetCreation(state: DataListPresetState) {
const result = await $formApi.createForm(defineFormSchema({
title: 'Save current view',
layout: { displayMode: 'modal', gridSize: 1 },
fields: [
{ key: 'label', label: 'View name', type: 'text', required: true },
{ key: 'preview', type: 'custom-component', omit: true,
render: () => <DataListPresetStatePreview state={state} schema={schema} /> },
],
}))
if (!result.isCompleted) return null // cancel = nothing happened
const preset = { id: crypto.randomUUID(), label: result.formData.label, state }
savedPresets.value = [...savedPresets.value, preset]
return preset
}5.3What every table gets free
- The selector — bookmark trigger with an active-view badge, per-view state previews, per-preset delete with loading state, empty and no-result states.
- Search that respects language — diacritic-insensitive matching (reunion finds Réunion) and locale-aware
Intl.Collatorordering, derived in the UI so persistence order stays an API concern. - Active detection — structural equality against current state; modify anything and the badge drops, restore it by hand and it lights back up.
- Accessibility — keyboard operable cards,
aria-pressed, focus rings, testids on every affordance. - Five locales — 13 new strings each in de, en, fr, nl, pl.
- Docs as contract — the
tars-shared-ui-tableskill and a new schema-anatomy reference document the pattern future tables are expected to follow.
Scope & proof
Small surface, sharp edges — and honest about what ships now versus next.
6.1Deliberate scope
- The engine ships; adoption follows. A customer-front prototype (saved item views, persisted behind the API) was built to validate the callbacks end-to-end — then pulled from this PR. App adoption lands separately, with real persistence and its own review.
- No configuration surface beyond the four callbacks. No schema keys, no serializers, no render hooks. If a table needs a different selector, that's a conversation, not a config flag.
- Order is an API concern. The selector displays locale-aware alphabetical order without rewriting Query data — persistence keeps whatever order it wants.
- Riding along: directive-safe overlay roots for
PersistantDrawer, and a Sentry router instrumentation fix.
6.2Validation
| Surface | Covered |
|---|---|
| Preset capture | Visible leaf columns only, no internal group keys; required columns preserved on application |
| Cache lifecycle | Upsert replaces matching ids without mutating; removal is non-destructive |
| Type flow | expectTypeOf: query-owned metadata reaches create / delete / canDelete unwidened |
| Schema resolution | View templates resolve for previews without mutating the owning schema |
| Form-free previews | Option labels and custom renderers resolve from value + schema alone |
| Revalidation UX | Cached labels render immediately while the option query revalidates |
| Plus | Lint, typecheck, shared-ui build, playground exercised end-to-end in the browser |