Skip to main content

Data & filters

@zuilib/apps is the UI half of an analytics product. It never talks to a backend: rows come in as props from wherever you fetch them, and intent — filter changes, actions, data requests — goes out as neutral JSON your code translates to your own API.

Most analytics data is tabular, so the contract is one declaration:

DatasetSchema

import { defineDataset } from '@zuilib/apps'

const orders = defineDataset({
name: 'orders', // the key the rows use in `datasets`
description: 'One row per order.', // grounds the LLM prompt
columns: [
{ field: 'region', dataType: 'enum', values: ['EMEA', 'AMER', 'APAC'], label: 'Region' },
{ field: 'status', dataType: 'enum', values: ['paid', 'open', 'refunded'] },
{ field: 'total', dataType: 'number', format: 'currency:USD', description: 'Order value.' },
{ field: 'placed', dataType: 'date' },
],
})

<ViewRenderer spec={spec} registry={registry}
datasets={{ orders: rows }} datasetSchemas={[orders]} />

One declaration powers every consumer:

  • filter-bar generates the right control per column — selects for enums and booleans, contains-search for strings, min/max ranges for numbers and dates.
  • data-grid with no columns derives them: labels, date cells, enum badges, right-aligned formatted numbers. Binding rows to a dataset is a complete table.
  • metric aggregates a column over the filtered rows and formats it with the column's token: { "label": "Revenue", "rows": { "$bind": "orders" }, "column": "total", "aggregate": "sum" }.
  • registryPrompt(registry, { datasets }) lists real fields, so a model binds total, not a hallucinated revenue_amount — and validateViewSpec(spec, registry, { datasets }) flags unknown datasets and columns.
  • The editor upgrades from free-text props to dataset and column pickers.

Format tokens ('currency:USD', 'percent', 'compact', 'date:short', …) are the same grammar @zuilib/charts uses, so a value formats identically in a chart axis, a grid cell and a KPI tile. Where no schema is declared, consumers fall back to inferDatasetSchema(name, rows) — types, dates and enums sniffed from the rows.

Filters

A filter is neutral JSON — produced by UI, consumed by you or by the renderer:

{ "dataset": "orders", "field": "region", "op": "in", "value": ["EMEA"] }

Ops: eq neq in not-in contains gt gte lt lte is-null not-null. The renderer owns a live filter list (controlled via filters + onFiltersChange, or self-kept), and applies it client-side to every dataset's rows before any node sees them — drop a filter-bar node next to a chart, a grid and a metric and they all narrow together with zero host wiring.

Cross-filtering

Charts participate by default: clicking a bar/slice/mark toggles an in filter on the chart's dataset (shift-click accumulates values). Every other consumer narrows; the chart itself keeps all categories visible and highlights the kept ones, so the interaction never dead-ends. Opt a chart out with crossFilter: false or spec.interaction: "none".

Your backend does the filtering

For datasets too big to ship to the browser, name them in serverDatasetNames: the renderer leaves their rows alone and instead reports every filter change through onDataRequest as a ViewDataQuery — the refetch signal your code translates to your own API, whatever it looks like:

<ViewRenderer
spec={spec} registry={registry}
datasets={{ orders }} datasetSchemas={[ordersSchema]}
serverDatasetNames={['orders']}
loadingDatasets={fetching ? ['orders'] : []}
onDataRequest={async ({ dataset, filters }) => {
setFetching(true)
const rows = await api.query(dataset, translate(filters)) // your contract
setOrders(rows)
setFetching(false)
}}
/>

The FilterDescriptor shape is the frontend/backend contract. Client-side and server-side datasets can coexist in one view, and the exported pure helpers (applyFilters, setFilterIn, toggleFilterValueIn, describeFilter, toDataQuery) work anywhere — including in your API translation layer or tests.

Params

Filters cover rows; params cover everything else one node should tell another. A param is a named value under the $params bind root:

{
"version": 2,
"params": { "period": "quarter" },
"root": { "type": "stack", "children": [
{ "id": "period-picker", "type": "select", "param": "period",
"props": { "options": [{ "value": "quarter" }, { "value": "year" }] } },
{ "id": "title", "type": "heading",
"props": { "text": { "$bind": "$params.period" } } }
] }
}

A node with param: "period" publishes its UI state there; anything can read it via { "$bind": "$params.period" }. Hosts control the channel with params / defaultParams / onParamsChange — persist the map to save the view's inputs, or write params from outside the view.

Custom components join the runtime

useViewRuntime() (or useOptionalViewRuntime() for components that degrade outside a renderer) exposes the same state the built-ins use — schemas, raw rows, the filter list and its setters, params:

import { useViewRuntime } from '@zuilib/apps'

function ActiveFilterCount() {
const runtime = useViewRuntime()
return <Badge>{runtime.filters.length} filters</Badge>
}

Register it like any component and it composes with filter-bar, charts and grids — and with the AI catalog, since the registry is the same.