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-bargenerates the right control per column — selects for enums and booleans, contains-search for strings, min/max ranges for numbers and dates.data-gridwith nocolumnsderives them: labels, date cells, enum badges, right-aligned formatted numbers. Bindingrowsto a dataset is a complete table.metricaggregates 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 bindstotal, not a hallucinatedrevenue_amount— andvalidateViewSpec(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.