tars-monorepo · PR #145 · feat/data-list-presets

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.

0
files changed
+1,718 −431
lines
0
granular commits
1
schema block to enable
0
render callbacks to maintain
01

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.

A view an operator rebuilds three times a day is not UI state. It's a work object — it deserves a name, a home, and a link.

1.2What a view captures

A saved view snapshots the five dimensions that define "how I look at this table":

packages/shared-ui/src/runtime/lib/data-list/types/presets.ts
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.

Recreation · PresetControl + DataList header · shared-ui playground
Invoice operations
page 1 / 1
Save current view
Saved setup
Everything above is real logic from the feature, re-implemented in miniature: structural-equality active detection, order-insensitive column capture, diacritic-insensitive search, app-owned creation modal with the shared state preview.
02

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:

packages/shared-ui/src/runtime/lib/data-list/composables/useTablePresets.ts
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:

engine owns
Capture · apply · detect · render
state snapshot · structural-equality active detection · selector UI · per-preset loading · Query cache sync
app owns
Persist · confirm · authorize
presets.query() · create(state) → preset | null · delete(preset) → false cancels · canDelete(preset)
CallbackContractWhy it lives in the app
query()Canonical queryOptions returning presetsPersistence is a product decision — API, localStorage, anything
create(state)Async; may open any UI; resolves the created preset, or null when cancelledNaming UX, validation, ownership rules differ per app
delete(preset)Async; false cancels; resolves after persistence succeedsConfirmation dialogs and notifications are app furniture
canDelete(preset)Sync; gates the affordance per presetRights & 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.

Guard rails included. Without 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:

the query's concrete type reaches every callback — no casts, no widening
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.

04

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.

BEFOREpreview coupled to a mounted form
// 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?.() ?? []
AFTERvalue-only, resolved from the cache
// 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:

BEFOREone-shot promise, refetched per mount
options: () =>
  $client.manufacturers.list.call()
    .then((manufacturers) =>
      manufacturers.map(toOption).sort(byLabel),
    )
AFTERcached, deduplicated, invalidated with its controller
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:

Anywhere
Previews without a table

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.

Warm cache
Labels never flicker

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.

Shared
One key, one fetch

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.

Simpler
Value-only contract

preview.render(value) and preview.tagProps(value) lost their FieldApi parameter — previews are pure functions of the stored value now.

Display used to be a side effect of a mounted form. Now it's a pure function of schema + value + cache — which is why a preset can describe itself anywhere.
05

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:

packages/shared-ui/playground/app/pages/dev/table.vue
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:

packages/shared-ui/playground/app/pages/dev/table.vue
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.Collator ordering, 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-table skill and a new schema-anatomy reference document the pattern future tables are expected to follow.
06

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

SurfaceCovered
Preset captureVisible leaf columns only, no internal group keys; required columns preserved on application
Cache lifecycleUpsert replaces matching ids without mutating; removal is non-destructive
Type flowexpectTypeOf: query-owned metadata reaches create / delete / canDelete unwidened
Schema resolutionView templates resolve for previews without mutating the owning schema
Form-free previewsOption labels and custom renderers resolve from value + schema alone
Revalidation UXCached labels render immediately while the option query revalidates
PlusLint, typecheck, shared-ui build, playground exercised end-to-end in the browser