npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@zestl/zvolv-widgets

v0.0.3

Published

Zvolv dashboard widgets — Zvolv owns data, query generation and normalization; Claude owns the UI via pluggable templates and design tokens.

Readme

@zestl/zvolv-widgets

React widgets for Zvolv dashboards. The library owns the data. You (or Claude) own the UI.

The library handles Elasticsearch query generation, pivot postOperations, summary/series alignment, filter merging, date math, user-id → name resolution, and viewer interactions: search, column filters, sort and widget-level filters. This logic is ported from ZAI-Dashboard's production widgets. Every visual decision can be overridden.

| The library owns — never reimplement | You own — design freely | | --- | --- | | Query generation, Elasticsearch calls | Layout, spacing, composition | | Pivot postOperations, summary/series alignment | Colour, type, density (design tokens) | | Filter merging, date math, user-id → labels | Chart marks, every template | | Applying search / column filters / sort / widget filters | The controls for search / filters / sort (render slots) | | Loading / empty / unconfigured / error detection | Every pixel |

48 widget type strings render (plus 4 non-rendering filter/automation types). A real-data runtime is built in.

For LLM agents: read SKILL.md before writing code. It is the short, rule-first contract. This README is the full reference.


Contents

  1. Quick start
  2. Props every widget accepts
  3. Search, column filters, sort and widget filters
  4. Data sources
  5. Controlling the UI
  6. Widget catalogue
  7. Headless hooks
  8. Runtime: mock and real data
  9. Install, link, test
  10. Rules and known limitations
  11. Forms

1. Quick start

import {
  ZvolvProvider, ZvolvThemeProvider, createMockRuntime,
  DashboardTableWidget, BarChartWidget,
  formSource, field, groupBy, aggregate, pivot,
} from "@zestl/zvolv-widgets";

const region  = field("Region",  "DROP_DOWN");
const revenue = field("Revenue", "INPUT_CURRENCY");
const owner   = field("Owner",   "INPUT_TEXT");

// Raw rows → table
const tableSource = formSource(1042, [region, revenue, owner]);

// Grouped + aggregated → chart
const chartSource = formSource(1042, [region, revenue], {
  pivot: pivot({ rows: [groupBy(region)], summary: [aggregate(revenue, "sum")] }),
});

export function Dashboard() {
  return (
    <ZvolvProvider runtime={createMockRuntime()}>   {/* swap for the real runtime, §8 */}
      <ZvolvThemeProvider tokens={{ colors: { primary: "#0f766e" } }}>
        <div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, height: 420 }}>
          <DashboardTableWidget
            dataSource={tableSource}
            configurations={{ title: "Deals", showSearchbar: true, showColumnFilter: true }}
          />
          <BarChartWidget
            dataSource={chartSource}
            configurations={{
              title: "Revenue by region",
              series: [{ name: "Revenue", valueField: "Revenue", argumentField: "Region" }],
            }}
            showWidgetFilter
          />
        </div>
      </ZvolvThemeProvider>
    </ZvolvProvider>
  );
}

That table has a search box, per-column filters and click-to-sort headers. The chart has a "Filter" control. Each one re-queries or re-filters the data, and nothing else needs wiring.


2. Props every widget accepts

Every data widget (charts, KPI, tables, collections, temporal, display) takes the same props. TabWidget, FormWidget, FilterPanelWidget and ZaiChatWidget hold no data of their own, so they do not take them.

Data props

| Prop | Type | Notes | | --- | --- | --- | | dataSource | DataSourceDefinition | What is queried. Build with formSource() / taskSource(). Pass server-stored ones through untouched. | | configurations | object | JSON string | How it looks and which fields it plots. Strings are parsed for you. | | dashFilters | object | Dashboard filter state (host { filter, filter_cond, cdate, search }). | | cdateFilters | array | Dashboard date-filter definitions. | | localFilters | object | Raw host-format widget filter ({ filter, filter_cond } from ZFilters). Merged per form with dashFilters. Prefer widgetFilters. | | automationRows / automationLoading | any[] / boolean | Rows from an automation binding. No query is issued. | | rowLimit | number | Max rows fetched. Default 1000. | | dateGap | { unit, start?, end? } | Fill empty periods on a time axis. | | widgetId, workflowTypeId | | Legacy (non form-framework) sources only. |

Chrome props

title, description, emptyMessage, unconfiguredMessage, className, style, and render (see §5). The same chrome keys also work inside configurations: title, description, hideHeader, customMessage, bgColor, template, style.

Interaction props

These are covered in §3.

| Prop | Type | Applies to | | --- | --- | --- | | search / defaultSearch / onSearchChange | string | all data widgets | | widgetFilters / defaultWidgetFilters / onWidgetFiltersChange | WidgetFilter[] | all data widgets | | columnFilters / defaultColumnFilters / onColumnFiltersChange | Record<string, any[]> | tables | | sort / defaultSort / onSortChange | { key, direction: "asc" \| "desc" } \| null | tables | | onInteractionsChange | (state) => void | all: fires with the full state after any change | | showSearch | boolean | all. Default config.showSearchbar | | showWidgetFilter | boolean | all. Default config.showWidgetFilter | | showColumnFilter | boolean | tables. Default config.showColumnFilter | | enableSort | boolean | tables. Default true | | searchPlaceholder | string | all | | searchDebounceMs | number | all. Default 400, applies to server-side search only | | renderSearch, renderWidgetFilter, renderToolbar | slot | all | | renderColumnFilter, renderSortIndicator | slot | tables |


3. Search, column filters, sort and widget filters

There are four kinds of viewer control:

| Control | What it does | Where it shows | | --- | --- | --- | | Search | Free text across all columns | Widget header | | Widget filter | Field + operator + value conditions (AND-ed) | Widget header | | Column filter | Tick values in one column | Table header cell | | Sort | One column, asc/desc | Table header cell |

3.1 Turn them on

You can enable each control from config, from props, or by passing a slot. Any one of these is enough:

// from config (this is how dashboards store it)
configurations={{ showSearchbar: true, showColumnFilter: true, showWidgetFilter: true }}

// from props
<DashboardTableWidget showSearch showColumnFilter showWidgetFilter ... />

// by supplying a slot — passing renderSearch turns search on
<DashboardTableWidget renderSearch={(a) => <MySearch {...a} />} ... />

Sort is on by default for tables. Turn it off with enableSort={false} or config.enableSort: false. Active widget filters always show as removable chips, unless you pass showWidgetFilter={false}.

3.2 Where each control is applied

The library decides this for you. You never write query code.

| Data source | Search / column filter / sort | Widget filters | | --- | --- | --- | | Form or task source, no pivot.rows | Server: sent through generateQuery, so it covers the full dataset | Server | | Form or task source with pivot.rows (grouped) | Local: applied to the loaded buckets | Server (before aggregation) | | Legacy widget-id source | Local | Local | | automationRows | Local | Local | | ChildTableWidget | Local | Server |

Server-side details (these match ZAI-Dashboard's DashboardTable):

  • Search becomes an _ALL clause, or a MULTIPLE clause when the text is a full date (05/01/2026 → 2026-01-05). It is debounced 400 ms.
  • Column filters become IN clauses, or EMPTY when only blanks are ticked. If the runtime provides formatHeaderFilters, it is used instead, which adds the user-name → id and workflow-title → id mapping.
  • Sort becomes generateQuery's dashboardSort. If configuration.columns is missing (generateQuery needs it), the library builds it from dataSource.fields.
  • Widget filters become field clauses. DATE_IS tokens become BETWEEN date ranges, and numeric fields get numeric operands.

data.meta.tableMode and data.meta.filterMode tell you which mode applies. Every slot also receives mode.

3.3 Controlled vs uncontrolled

Uncontrolled. The widget owns the state. Seed it with default*:

<DashboardTableWidget
  dataSource={ds} configurations={cfg}
  defaultSort={{ key: "Revenue", direction: "desc" }}
  defaultWidgetFilters={[{ field: "Status", operator: "IN", value: ["Open"] }]}
/>

Controlled. You own the state (for example, sync it to the URL). Pass the value and its on*Change:

const [search, setSearch] = useState("");
const [sort, setSort] = useState<SortState | null>(null);
const [columnFilters, setColumnFilters] = useState<ColumnFilters>({});
const [widgetFilters, setWidgetFilters] = useState<WidgetFilter[]>([]);

<DashboardTableWidget
  dataSource={ds} configurations={cfg} showSearch showColumnFilter
  search={search} onSearchChange={setSearch}
  sort={sort} onSortChange={setSort}
  columnFilters={columnFilters} onColumnFiltersChange={setColumnFilters}
  widgetFilters={widgetFilters} onWidgetFiltersChange={setWidgetFilters}
/>

A controlled value only changes when you update it. If you pass search without onSearchChange, the box cannot be edited.

3.4 Value shapes

type SortState     = { key: string; direction: "asc" | "desc" };  // key = row field / column key
type ColumnFilters = Record<string, any[]>;                         // column key → ticked values; "" = blanks
interface WidgetFilter {
  field: string;     // a `header` from dataSource.fields
  operator: "IN" | "NOT_IN" | "EQUALS" | "CONTAINS" | "STARTS_WITH"
          | "GREATER_THAN" | "LESS_THAN" | "BETWEEN" | "EMPTY" | "NOT_EMPTY" | "DATE_IS";
  value?: any;       // IN/NOT_IN: list · BETWEEN: [from, to] · DATE_IS: token · EMPTY/NOT_EMPTY: none
}
// DATE_IS tokens: TODAY YESTERDAY LAST_SEVEN_DAYS LAST_THIRTY_DAYS THIS_WEEK THIS_MONTH THIS_YEAR

Some rules to follow:

  • WidgetFilter.field must be a field in dataSource.fields. Unknown fields are skipped with a console warning.
  • User fields (AUTO_COMPLETE, assignees) filter by user id, not name.
  • A column filter with both blanks and values can only OR them locally. Server-side, the blanks are dropped (with a warning).

3.5 Supplying your own controls (slots)

Each slot receives the current value and setters. It returns any React node. Whatever it sets is applied to the data automatically.

<DashboardTableWidget
  dataSource={ds} configurations={cfg}

  renderSearch={({ value, setValue, placeholder, loading }) => (
    <Input.Search value={value} onChange={(e) => setValue(e.target.value)}
                  placeholder={placeholder} loading={loading} allowClear />
  )}

  renderWidgetFilter={({ filters, fields, addFilter, removeFilter, clear }) => (
    <MyFilterBuilder
      fields={fields}                 // [{ field, label, kind, operators[] }]
      value={filters}
      onAdd={addFilter} onRemove={removeFilter} onClear={clear}
    />
  )}

  showColumnFilter
  renderColumnFilter={({ column, options, selected, setSelected }) => (
    <MultiSelectDropdown
      title={column.header}
      options={options}              // [{ value, label, count }]
      value={selected}
      onChange={setSelected}         // [] clears this column
    />
  )}

  renderSortIndicator={({ direction, toggle }) => (
    <SortIcon direction={direction} onClick={toggle} />   // toggle: none → asc → desc → none
  )}

  renderToolbar={({ search, widgetFilter, interactions }) => (
    <Space>{widgetFilter}{search}
      {interactions.active && <a onClick={interactions.clearAll}>Reset</a>}
    </Space>
  )}
/>

| Slot | Receives | | --- | --- | | renderSearch | { value, setValue, clear, placeholder, mode, loading, tokens } | | renderWidgetFilter | { filters, setFilters, addFilter, removeFilter, clear, fields, declared, mode, tokens } | | renderColumnFilter | { column, selected, setSelected, clear, options, mode, tokens } | | renderSortIndicator | { column, direction, toggle, setDirection, tokens } | | renderToolbar | { search, widgetFilter, interactions, tokens }. search and widgetFilter are the resolved nodes |

fields comes from dataSource.fields. Each entry has kind (text / number / date / boolean) and the operators that suit that kind. declared is the host's config.filters list, when one exists.

If you omit a slot, you get a small token-styled default: a search input, a filter builder with chips, a checkbox list for column values, and ▲▼ sort buttons.

Return an element from a slot. Don't pass a component that uses hooks as the slot itself. Slots are called as plain functions, so renderColumnFilter={MyFilter} runs MyFilter's hooks inside the widget and breaks when the column count changes. Write renderColumnFilter={(a) => <MyFilter {...a} />} instead.

3.6 A fully custom table

A table render receives everything it needs to build its own header:

<DashboardTableWidget
  dataSource={ds} configurations={cfg}
  render={({ rows, columns, renderCell, interactions, headerControls, getColumnOptions, enableSort }) => (
    <MyGrid
      rows={rows}                                       // already searched / filtered / sorted
      columns={columns.map((c) => ({
        ...c,
        header: <>{c.header}{headerControls(c)}</>,     // slot or default sort + filter controls
        sortOrder: interactions.sortDirectionFor(c.key),
        onHeaderClick: enableSort ? () => interactions.toggleSort(c.key) : undefined,
      }))}
      cell={renderCell}
    />
  )}
/>

interactions also exposes setSearch, setColumnFilter(key, values), setSort, setWidgetFilters, addWidgetFilter, removeWidgetFilter, clearAll and active.


4. Data sources — where the data comes from

configurations describes how a widget looks. dataSource describes what is queried. The query generator reads dataSource, so a widget with a perfect config and no data source shows "No data source configured".

import { formSource, taskSource, field, taskField, groupBy, aggregate, dateDiff, derive, where, pivot } from "@zestl/zvolv-widgets";

const region  = field("Region",  "DROP_DOWN");
const revenue = field("Revenue", "INPUT_CURRENCY");
const target  = field("Target",  "INPUT_CURRENCY");

const dataSource = formSource(1042, [region, revenue, target], {
  pivot: pivot({
    rows:    [groupBy(region)],                               // GROUP BY Region
    summary: [aggregate(revenue, "sum"), aggregate(target, "sum")],
    postOperations: [derive("Gap", "SUB", ["Target", "Revenue"])],
  }),
});

The name-matching rule (mismatches fail silently)

| Config key | Must equal | | --- | --- | | series[].argumentField | pivot.rows[].title (defaults to the field's header) | | series[].valueField | pivot.summary[].value (defaults to the field's header), or a derive() title | | WidgetFilter.field, SortState.key, ColumnFilters keys | a header in dataSource.fields |

series: [
  { name: "Revenue", valueField: "Revenue", argumentField: "Region" },
  { name: "Gap",     valueField: "Gap",     argumentField: "Region" },
]

A mismatch produces an empty chart and no error. Reusing the same field() objects everywhere keeps the names in step.

Other data-source rules

  • Declare every field you group by, plot, filter, search or sort on in fields. An undeclared field silently returns nothing.
  • taskSource([...]) never takes a formId. A task source with a formId queries submissions and returns the wrong rows. Use taskField("Status"), which camelCases the server name to status.
  • Use the real element type in field(header, type). The ES data type is derived from it, and aggregations depend on it. INPUT_NUMBER / INPUT_CURRENCY / INPUT_PERCENT → FLOAT; FORM_SEARCH / INPUT_TAG → ARRAY; CHECK_BOX → BOOLEAN; everything else → STRING.
  • Never invent a formId or field name. Ask for them, or copy them from an existing widget's stored DataSource.

Baked-in (author) filters vs viewer filters

// Always applied, part of the widget definition:
field("Status", "DROP_DOWN", { filter: where("IN", "Open", "Pending") })
field("Created", "INPUT_DATE", { filter: where("DATE_IS", "{@[TODAY]}") })

// Chosen by the viewer at runtime (§3):
defaultWidgetFilters={[{ field: "Status", operator: "IN", value: ["Open"] }]}

Output names vs field names

groupBy(f, title) and aggregate(f, type, outputName) take an output name, which is the key the rows come back under. The query always groups and aggregates on the field's masterfield. So groupBy(requestDate, "day") queries Request Date and returns rows keyed day. (Before 0.0.2, the title was used as the field name, so an alias returned no buckets.)

Grouping by calendar period

Pass interval to group a date field by calendar period (date_histogram in Elasticsearch) instead of by exact value:

const requestDate = field("Request Date", "DATE_PICKER");
const requests    = field("Request ID", "INPUT_TEXT");

const monthly = formSource(1042, [requestDate, requests], {
  pivot: pivot({
    rows:    [groupBy(requestDate, "Month", { interval: "month" })], // day | week | month | quarter | year
    summary: [aggregate(requests, "value_count", "Requests")],
  }),
});

<LineChartWidget
  dataSource={monthly}
  configurations={{ series: [{ argumentField: "Month", valueField: "Requests" }] }}
  dateGap={{ unit: "month", start: "2026-01-01", end: "2026-12-31" }}
/>
  • Row keys are epoch ms at the start of each period.
  • Weeks start on Monday (ISO).
  • Period boundaries use the browser's time zone. Pass { interval, timeZone: "Asia/Kolkata" } to fix one. dateGap then fills and merges periods in that same zone.
  • interval on a non-date field is ignored, with a console warning.

dateGap fills empty periods with zero rows. Rows that fall in the same period are summed (merge: "sum", the default), so day-level data shown weekly or monthly no longer loses rows. merge: "last" restores the old keep-the-last-row behaviour.

Running totals (level over time)

<LineChartWidget
  dataSource={monthly}
  configurations={{ series: [{ argumentField: "Month", valueField: "Requests" }] }}
  dateGap={{
    unit: "month", start: "2026-01-01", end: "2026-12-31",
    cumulative: true,                 // or ["Requests"]
    baseline: { Requests: 1250 },     // the total before `start`
  }}
/>

cumulative runs after gap filling, so each point is the level at the end of its period. Rows before start are dropped even when there is no end, because the baseline already counts them. You can let the hook fetch the baseline instead. This costs one extra request:

dateGap={{
  unit: "month", start: "2026-01-01", end: "2026-12-31", cumulative: true,
  baselineSource: {
    dataSource: formSource(1042, [requestDate, requests], {
      pivot: pivot({ summary: [aggregate(requests, "value_count", "Requests")] }), // same output name
    }),
    dateField: "Request Date",   // the hook adds Request Date < 2026-01-01
  },
}}

The baseline query applies the widget's dashboard and widget filters, but not the dashboard date range, because that range would exclude everything before start. An explicit baseline wins over the fetched value, field by field.

KPI sparklines without custom code

A KPI item can fetch its own sparkline:

<KpiWidget
  dataSource={formSource(1042, [requests], { pivot: pivot({ summary: [aggregate(requests, "value_count", "Total")] }) })}
  configurations={{ contents: [{
    title: "Requests", dataPoint: "Total", template: "trend",
    seriesSource: {
      mode: "timeseries",
      dataSource: monthly,            // grouped by a date row with `interval`
      xField: "Month", valueField: "Requests",
      dateGap: { unit: "month", start: "2026-01-01", end: "2026-06-30", cumulative: true },
    },
  }] }}
/>

Each such item makes one request through useWidgetData, with the widget's dashboard filters applied. externalSeriesByIndex={{ 0: [..] }} still works and takes precedence.

Time between two dates (avg / min / max / sum / percentiles)

const offerDate = field("Actual Offer Date", "DATE_PICKER");
const partner   = field("Lead Partner", "DROP_DOWN");

// Overall: one number
formSource(1042, [requestDate, offerDate], { pivot: pivot({
  summary: [aggregate(dateDiff(requestDate, offerDate, "days"), "avg", "Avg Days to Offer")],
}) });

// Per partner, or per month with groupBy(requestDate, "Month", { interval: "month" })
formSource(1042, [requestDate, offerDate, partner], { pivot: pivot({
  rows:    [groupBy(partner)],
  summary: [
    aggregate(dateDiff(requestDate, offerDate), "avg", "Avg Days to Offer"),
    aggregate(dateDiff(requestDate, offerDate), "percentiles", "Days", { percents: [50, 90] }),
    aggregate(dateDiff(requestDate, "TODAY"), "max", "Oldest Open (days)"),
  ],
}) });
  • The value is end − start. The unit is "days", "hours" or "minutes", and the result is fractional.
  • Documents missing either date are skipped.
  • end can be a fixed end instead of a field: "TODAY", a date token such as {@[30DAYSAGO]}, or a literal date ("2026-03-31" means local midnight; an ISO date-time is also accepted). Only the start date is then required. Any other string is treated as a field name, and an unknown {@[…]} token logs a warning.
  • Percentiles come back as <name>_p50, <name>_p90 (99.9 becomes _p99_9). <name> itself holds the median when 50 was requested.

Filtered metrics: many KPIs, one request

const amount = field("Amount", "INPUT_CURRENCY");
const status = field("Status", "DROP_DOWN");
const invoiceDate = field("Invoice Date", "DATE_PICKER");

formSource(1042, [amount, status, invoiceDate], { pivot: pivot({
  summary: [
    aggregate(amount, "sum", "Billed", { where: [
      [status, where("IN", "Invoiced", "Paid")],
      [invoiceDate, where("BETWEEN", "2026-04-01", "2026-06-30")],  // AND-ed
    ] }),
    aggregate(amount, "sum", "Pipeline", { where: [[status, where("IN", "Quoted")]] }),
    aggregate(amount, "sum", "Total"),
  ],
}) });

Each metric is filtered on its own, and all of them run in one request. A KPI card with 20 numbers on one form is one call. pivot(), formSource() and taskSource() move the where conditions into pivot.filters, marked combine: "AND". Stored pivot.filters entries without that marker keep the original rule: the last entry per metric wins.

Reshaping rows after the query (0.0.3)

These run in the browser after the query returns. They never change what is queried.

One wide row → one row per category (unpivot). A multi-number source returns one row such as { Longlist: 4, Contacted: 4, … }. Turn it into one row per column, in your order:

const STAGES = ["Longlist", "Contacted", "Interviewed", "Offered", "Placed"];

<RankingBarWidget
  dataSource={FUNNEL_ONE}
  unpivot={{ columns: STAGES, extra: { "Contacted %": "step" } }}
  configurations={{ ranking: { order: "none" } }}   // keep stage order; ratio = value / max
/>
  • Output rows are { label, value }. Rename the keys with labelField / valueField.
  • A missing column gives 0.
  • unpivot runs after postOperations, so a derive() column can be a stage.
  • With no declared series, x and y become labelField and valueField.
  • extra copies a source column onto the stage row whose name it starts with. "Contacted %" lands on the "Contacted" row as step. A source column that matches no stage is copied onto every row.

All categories, in dropdown order (categoryGap). ES returns a bucket only for values that have data. categoryGap adds a zero row for every missing category and orders the rows:

<BarChartWidget
  dataSource={MINI_FUNNEL}
  categoryGap={{ field: "Pipeline Stage", values: "fromForm" }}   // or an explicit list
  widgetFilters={[{ field: "Assignment", operator: "IN", value: [title] }]}
  render={({ rows }) => <MiniBars rows={rows} />}
/>
  • field is the chart's argumentField.
  • "fromForm" reads that field's dropdown options from the form schema, once per form; it reuses the widget's form lookup.
  • Every series valueField is 0 in the added rows.
  • Values that are not in the list are kept, after the listed ones.
  • It runs after postOperations and widget filters, and before local search, column filters and sort.

Funnel

<FunnelWidget
  dataSource={FUNNEL_ONE}
  unpivot={{ columns: STAGES }}
  onItemClick={(row) => setStage(row.label)}
/>

Each stage resolves to { label, value, ratioToFirst, stepPct, isFirst, index, raw }:

  • stepPct is value ÷ previous stage × 100. It is null for the first stage, and after a stage of 0.
  • ratioToFirst is value ÷ first stage. It is 0 when the first stage is 0.

Stages are never re-sorted. render gets { rows, first, tokens, config, toLabel, onItemClick }.

One KPI card from several numbers

<KpiWidget dataSource={cardSource} configurations={{ layout: "card", contents: [
  { dataPoint: "value",  role: "main", title: "Open mandates", template: "chart", seriesPoints: P1toP12 },
  { dataPoint: "total",  role: "sub",  title: "total" },
  { dataPoint: "atRisk", role: "sub",  title: "at risk" },
  { dataPoint: "P12",    role: "chip", template: "trend", deltaSource: "datapoint", deltaPoint: "P8",
    deltaMode: "absolute", subtitle: "vs 4 wk" },
]}} />

The default card puts the items in slots by role:

  • main is the big value. Without a main role, it is the first item with no role.
  • sub items are small inline values under it.
  • chip is the change badge (its delta).
  • series supplies the sparkline. Without it, the main item's series is used.

Card mode turns on only with layout: "card", set either at the config root or in cardConfig, where platform KPI payloads keep their items. To build the card yourself, pass renderCard={({ vms, byKey, roles, state, tokens }) => …}; render also works, but likewise only in card mode, so a render a host passes to every widget never changes a KPI. byKey maps each dataPoint to its resolved item; the first item wins when two share one. You can also register a "KPI_CARD:card" template. KpiSparkline accepts width, height, area (fill under the line) and scale: "minmax" | "zero".

Filter options from another form

<FilterPanelWidget
  configurations={{ filters: [{
    key: "scope", field: "Assignment", type: "select",
    optionsSource: {
      dataSource: OPEN_MANDATES,              // e.g. a formSource on Assignment with Status IN Open
      labelField: "Assignment Title", valueField: "Assignment Title",
      sort: "asc", allLabel: "Firm-wide",
    },
  }] }}
  onFiltersChange={(filters) => setScopeFilters(filters)}   // → other widgets' widgetFilters
  renderFilter={({ options, value, setValue, loading }) => <MySelect … />}
/>
  • Fetch: the options come from one request: a distinct-values (composite terms) aggregation over the whole index, with the source's own filters applied by Elasticsearch. It is not built from a page of rows. The cap is the composite size (50,000 distinct values, or displayRowLimit). Declare labelField and valueField in the source's fields so text fields aggregate on .keyword. A source that already has a pivot is used as is. The panel refetches only when optionsSource.dataSource changes.
  • Where the filter runs: the emitted filter is applied in the target widget's ES query, so over the whole index. Only automation-row and legacy widget-id sources, which have no query, filter their loaded rows.
  • URL values: values restored from URL params (strings) match numeric options, and the emitted filter carries the option's own typed value.
  • Output: choosing a value emits [{ field, operator: "IN", value: [v] }]; "All" emits [].
  • Lookup targets: when field is a FORM_SEARCH lookup on the target form, the filter matches the lookup's .view value. So valueField must be the field named as the lookup's ViewLabel.
  • Without a slot: the panel renders a plain <select> for these controls.

5. Controlling the UI

These are ordered from cheapest to most control.

1. Design tokens restyle everything without touching components:

<ZvolvThemeProvider tokens={{
  colors: { primary: "#0f766e", charts: { palette: ["#0f766e", "#b45309", "#6d28d9"] } },
  typography: { kpiValue: { fontSize: "34px", fontWeight: 700 } },
  widget: { appearance: "flat", borderRadius: "18px", padding: "22px" },
  surfaces: { table: { headerBg: "#f3f4f1", altRowBg: "#fafaf8" } },
  overrides: { KPI_CARD: { appearance: "border" } },           // per widget type
  widgetDefaults: { BAR_CHART: { template: "horizontal" } },   // layered UNDER each widget's config
}}>

2. The render prop lets you own the visuals while keeping the data:

<BarChartWidget dataSource={ds} configurations={cfg}
  render={({ rows, xKey, series, colorFor, toLabel }) => (
    <ResponsiveContainer>
      <BarChart data={rows}>
        <XAxis dataKey={xKey} tickFormatter={toLabel} />
        {series.map((s, i) => <Bar key={s.valueField} dataKey={s.valueField} fill={colorFor(i, s)} />)}
      </BarChart>
    </ResponsiveContainer>
  )}
/>

Always use xKey and series[].valueField from the args. Never hard-code field names. Run category and user values through toLabel.

3. Template overrides replace a template everywhere:

<TemplateProvider templates={{ "KPI_CARD:standard": MyKpiCard, "BAR_CHART:*": MyBars }}>

KPI templates receive { vm, index }. vm is fully resolved: value, rawValue, prefix, suffix, delta, progress, status, series. Never re-derive values from vm.raw.

4. Interaction slots replace the search, filter and sort controls (§3.5).


6. Widget catalogue

| Family | Components | Type strings | render receives | | --- | --- | --- | --- | | KPI | KpiWidget | KPI KPI_CARD KPI_CARD_V2 | templates get { vm, index }; render / "KPI_CARD:card" get { vms, byKey, roles, state, tokens, config } | | Charts | BarChartWidget LineChartWidget AreaChartWidget PieChartWidget DoughnutChartWidget ScatterChartWidget | BAR(_CHART) LINE(_CHART) AREA_CHART PIE(_CHART) DOUGHNUT(_CHART) SCATTER_CHART | { rows, xKey, series, colorFor, toLabel, template, config, tokens, meta } | | Ranking | RankingBarWidget | RANKING_BAR | { rows: {rank,label,value,ratio,raw}[], max, … } (ranking.order: "none" keeps row order) | | Funnel | FunnelWidget | FUNNEL | { rows: {label,value,ratioToFirst,stepPct,isFirst,index,raw}[], first, tokens, config, toLabel, onItemClick } | | Gauge | GaugeMeterWidget | GAUGE_METER | { value, min, max, ratio, color, band, thresholds, rows } | | Heat map | HeatMapWidget | HEAT_MAP | { cells: {zone,count,color,band}[], grid } | | Tables (columns from config.columns[] as { label, dataPoint, type }, the stored shape) | DashboardTableWidget DatabaseTableWidget TaskTrackerTableWidget ChildTableWidget PivotGridWidget | TABLE DATABASE_TABLE TASK_TRACKER_TABLE CHILD_TABLE CHILD_DATABASE_TABLE PIVOTGRID | { rows, columns, renderCell, toLabel, interactions, headerControls, getColumnOptions, enableSort, showColumnFilter, mode } (+ rowFields, columnFields, valueFields for pivot) | | Collections | CardListWidget ActivityFeedWidget EntityGalleryWidget GridCardWidget CarouselWidget BannerWidget | CARDLIST ACTIVITYFEED ENTITY_GALLERY GRID_CARD CAROUSEL BANNER | { items: CollectionItem[], template, config, tokens, toLabel } | | Temporal | TimelineWidget GanttChartWidget CalendarWidget MeetingSchedulerWidget | TIMELINE GANTT(_CHART) CALENDAR MEETING_SCHEDULER | { events: TemporalEvent[], range, lanes } | | Stepper | StepperWidget | STEPPER | { steps: StepItem[], currentIndex } | | Display | HtmlWidget RawHtmlWidget IframeWidget GoogleMapWidget TreeWidget ActionCardWidget | HTML RAW_HTML STATICHTML IFRAME GOOGLE_MAP MAP_CHART TREE ACTION(_CARD) | html { html, rows } · map { markers, center, zoom } · tree { nodes } · action { actions } | | Containers | TabWidget FormWidget FilterPanelWidget ZaiChatWidget | TAB FORM FORM_WIDGET FILTER_PANEL ZAI_CHAT | see types |

Render any widget from its stored type string: <ZvolvWidget type="BAR_CHART" {...props} />. DATE_FILTER, FILTER, FILTERS and DASHBOARD_AUTOMATION render nothing by design.

render is required for FormWidget, ZaiChatWidget, GoogleMapWidget and HTML markup in HtmlWidget. Form rendering, the chat transport, map SDKs and HTML sanitization stay in the host app. The package never calls dangerouslySetInnerHTML.

FilterPanelWidget vs widget filters. FilterPanelWidget is a dashboard-level control. It is controlled (value / onChange), and it issues no queries except one per optionsSource. Feed its values into other widgets' dashFilters, or pass onFiltersChange output to their widgetFilters. A widget filter (§3) narrows only its own widget.


7. Headless hooks

Use these when a built-in widget is not enough and you want to own all of the rendering.

import { useWidgetInteractions, useWidgetData } from "@zestl/zvolv-widgets";

function MyTable({ dataSource, configurations }) {
  const ix = useWidgetInteractions({ defaultSort: { key: "Revenue", direction: "desc" } });
  const { rows, sourceRows, state, meta, refresh } = useWidgetData({
    dataSource, configurations, widgetType: "TABLE",
    search: ix.search, columnFilters: ix.columnFilters, sort: ix.sort, widgetFilters: ix.widgetFilters,
  });
  // rows: after interactions · sourceRows: as fetched (use for filter options)
  // state: "loading" | "ready" | "empty" | "unconfigured" | "error"
  // meta: { xKey, series, toLabel, tableMode, filterMode, filtered }
}

useInteractiveWidgetData(props, { table }) combines both hooks and adds the toolbar and slot resolution. Every built-in widget uses it. Other hooks: useKpiContents, useCollectionItems, useTemporalEvents, useTableColumns.

Pure helpers, which also work outside React: applyLocalSearch, applyLocalColumnFilters, applyLocalSort, applyLocalWidgetFilters, distinctColumnValues, buildWidgetFilterClauses, buildColumnFilterClauses, buildSearchClause, buildRemoteSort, resolveTableMode, resolveFilterMode.


8. Runtime: mock and real data

Mock (no backend)

<ZvolvProvider runtime={createMockRuntime({ delay: 400, rows: myRows, onQuery: console.log })}>

The mock applies the same filter clauses and sort that a real query would carry, so search, filters and sort visibly work in previews. It does not aggregate pivots: every widget receives the raw rows. The default rows (SAMPLE_ROWS) have region, owner, revenue, target, orders, status, startDate, endDate, latitude, longitude and parentId.

Real data — built in

The query path (ZAI-Dashboard's generateQuery, filter formatters, logged-in-user resolution, field mappings, user-name lookup) ships inside the package, ported verbatim. Pass an initialised, logged-in SDK client:

import { ZvolvClient } from "@zestl/zvolv-js-sdk";
import { ZvolvProvider, createZvolvRuntime } from "@zestl/zvolv-widgets";

const client = new ZvolvClient("app.zvolv.in", true);
await client.workspace.init("your-domain");
await client.auth.login(email, password);        // or client.auth.init() to restore a session

const runtime = createZvolvRuntime({ client: () => client });   // a getter: swap clients freely
<ZvolvProvider runtime={runtime}>…</ZvolvProvider>

Call resetQueryUser() after login or logout, so the cached user group is re-resolved. ../zvolv-insights is a complete app built this way: login → apps → dashboards on live data.

Inside ZAI-Dashboard you can instead wire the host's own utilities into <ZvolvProvider runtime={…}> (see the runtime keys below).

| Runtime key | Required | Used for | | --- | --- | --- | | client | yes | workflow.elasticSearch, workflow.getDataSource | | generateQuery | yes, for form/task sources | building the ES query | | formatDashboardFilters | recommended | dashFilters / localFilters → clauses | | formatHeaderFilters | optional | column filters with id mapping. A built-in fallback exists | | prefetchQueryMappings, resolveQueryUser | recommended | field mappings, logged-in-user filters | | resolveUserLabels, getUserFieldKind | optional | user id → name on axes and in search |

createZvolvRuntime fills in all of them. Widget code is identical under the mock and the real runtime.

Rendering stored dashboards

workflow.getDashboard(dashId, zappId).elements[0].widgets is a list of stored widgets. These helpers turn it into widgets, layout and filter state, using the same rules ZAI-Dashboard applies:

| Helper | Does | | --- | --- | | normalizeWidget(w) | deep-parses Configuration / DataSource (double-encoded JSON), hoists legacy isTask/formId from dataSource.query. Returns { type, title, configurations, dataSource, … } | | gridWidgets(ws) | drops FILTER / DATE_FILTER / automation types and widgets nested in a TAB | | resolveDashboardLayout(ws) | { id, x, y, w, h } on the 60-column grid. Legacy 12-col layouts are scaled ×5, unset positions are packed, and the result is compacted vertically | | gridHeight(h), GRID | pixel height of a tile (h × 5 + (h − 1) × 16) | | dashboardFilterDefs(ws) | the FILTER widget's declared fields, for a dashboard filter bar | | buildDashFilters(values, range) | the dashFilters object every widget takes ({ filter, filter_cond, cdate }) | | dateRangeFor(token), dashboardDefaultDateToken(ws) | date presets, and the DATE_FILTER widget's default | | cdateFiltersFor(w, ws) | the per-widget cdateFilters (the fields a date range lands on) | | buildDashboardTree(elements, isAdmin) | getDashboards() → a sorted tree (ChildReportIDs nest), with hidden/mobile-only removed |

const w = normalizeWidget(stored);
<ZvolvWidget type={w.type} title={w.title} dataSource={w.dataSource} configurations={w.configurations}
             dashFilters={buildDashFilters(values, range)} cdateFilters={cdateFiltersFor(stored, all)} />

9. Install, link, test

Install from npm, together with the peer dependencies:

npm install @zestl/zvolv-widgets @zestl/zvolv-js-sdk react react-dom

To work against an unpublished change, link the library locally:

cd /path/to/zvolv-widgets
npm install
npm run build
npm link

cd /path/to/your-dashboard-app
npm link @zestl/zvolv-widgets

Your app must already have these peer dependencies: react ≥18, react-dom ≥18 and @zestl/zvolv-js-sdk. After changing library source, run npm run build again. The link picks it up without a reinstall.

Scripts

| Command | What it does | | --- | --- | | npm run build | ESM + CJS + types into dist/ | | npm run dev | rebuild on change | | npm run typecheck | types only | | npm test | build, then test/interactions.mjs, test/runtime.mjs and test/smoke.mjs | | npm run verify | typecheck + tests + packaging checks (test/verify.mjs) |

| Suite | Proves | | --- | --- | | test/runtime.mjs | The built-in real runtime against a fake SDK client: search, sort, widget, column and dashboard filters all appear in the generated ES query. Pivot aggregation queries. Stored-dashboard helpers (parsing, layout scaling and packing, tree, filters) | | test/interactions.mjs | Mode selection. Clause building for search, widget filters, column filters and sort. Local evaluators. Rendered end to end: typing, header-click sort, column-filter slots, the filter builder, controlled vs uncontrolled state, local vs remote, automation rows, dashboard + widget filter merging | | test/smoke.mjs | All 48 renderable type strings render without React errors, both plain and with search and widget filter on. unconfigured ≠ empty | | test/verify.mjs | Build artifacts, SKILL.md and README.md exist, and the built module loads |

Using with Claude

SKILL.md (shipped in the package) is the short, rule-first contract for LLM agents. Point Claude at node_modules/@zestl/zvolv-widgets/SKILL.md before it writes widget code. Without it, Claude tends to write its own data fetching.


10. Rules and known limitations

Rules

  1. Never write a raw API call, build an ES query, recompute a pivot, or filter/sort rows yourself. Pass search / columnFilters / sort / widgetFilters, and the library applies them in the right place.
  2. empty ≠ unconfigured. unconfigured means no data source is wired (a build mistake). empty means it is wired correctly and returned zero rows. Never fall back to demo data for either.
  3. Pass stored dataSource / configurations through untouched.
  4. Widgets fill their container. Give them a sized parent.
  5. No colour, font or shadow literals in templates or slots. Read tokens (or useTokens()).
  6. A ChildTableWidget must be clamped to its parent's visible width.

Known limitations

  • Live backend: zvolv-insights reaches the live API through createZvolvRuntime (workspace init and login verified). Check real dashboards against ZAI-Dashboard before relying on the numbers.
  • Tables fetch up to rowLimit rows (default 1000). There is no server pagination yet. Server-side search, filters and sort run over the full dataset, but only the first rowLimit matches are shown.
  • Column-filter options come from the rows loaded so far. They accumulate across fetches, so options do not vanish once one is ticked. They are not a full distinct-values query.
  • Widget filters are AND-ed. For OR logic, pass a host ZFilters payload through localFilters.
  • 0.0.2 query features need server and host support. interval grouping, dateDiff metrics, percentiles and AND-ed per-metric filters produce ES shapes that the server's aggregation flattener and ZAI-Dashboard must also handle. See MIRROR_NOTES.md.
  • Calendar / MeetingScheduler are simplified. There is no drag-to-resize, month-grid layout or virtual scrolling. For full parity, wrap FullCalendar in render.

11. Forms

Every ZAI-Dashboard form element is available as a headless engine. The library owns the behaviour: loading the schema (v2 and legacy), values, show/hide/mandatory rules, formulas, validation, lookups, uploads, field automations, action buttons, child sheets and the exact submit wire format. Your app owns every pixel.

Rendering a form

import { ZvolvForm, FormKitSlot } from "@zestl/zvolv-widgets";

// 1. Once, near the root: how each field kind looks in your app.
<FormKitSlot.Provider value={myKit}>…</FormKitSlot.Provider>

// 2. Anywhere: a form by id. A FORM / FORM_WIDGET dashboard widget does this for you.
<ZvolvForm formId="6ab5019b13f5e4aa859b1d28" onSubmitted={({ submissionId }) => …} />
<ZvolvForm formId={id} mode="EDIT" submissionId={subId} />

With no kit, plain native controls render so a form still works.

The kit

interface FormKit {
  field?:  (field: FormField, form: ZvolvFormApi) => ReactNode;   // one control per field
  form?:   (form, { body, title, submitLabel }) => ReactNode;     // header / body / footer
  status?: ({ status: "loading" | "error" | "submitted", form }) => ReactNode;
  grid?:   (cells, fields) => ReactNode;                          // 24-column span layout
}

Switch on field.kind. Each FormField carries id, type, kind, label, placeholder, help, required, disabled, hidden, span (of 24), value, error, options, multiple, min, max, step, precision, prefix, suffix, accept, maxSizeKB, maxCount, childIds, pages, html, setValue, onBlur. required / disabled / hidden already include the form's rules.

| kind | Platform types | Behaviour hook | | --- | --- | --- | | text textarea email phone url scan | INPUT_SHORT_TEXT, EDIT_TEXT, INPUT_EMAIL, INPUT_PHONE, INPUT_URL, INPUT_SCAN | — | | number currency percent | INPUT_NUMBER, INPUT_CURRENCY, INPUT_PERCENT | — | | select radio | SPINNER, RADIO_GROUP | useFieldOptions | | lookup | FORM_SEARCH, INPUT_TAG, EXTERNAL_API, form-backed AUTO_COMPLETE | useFieldOptions (search, cascades, ExtraLabels autofill) | | user | AUTO_COMPLETE with jsonData (people / role search) | useFieldOptions | | checkbox | CHECK_BOX (attributes.showAsSwitch) | — | | date time datetime daterange | DATE_PICKER, TIME_PICKER, DATE_TIME_PICKER, INPUT_DATE_RANGE | — | | rating slider | RATING_BAR / INPUT_RATE, PROGRESS_BAR | — | | file | FILE_UPLOAD_V2, UPLOAD_FILE_IMAGE, MEDIA_UPLOAD | useFileField (validates, uploads, stores records) | | signature richtext timer location | SIGNATURE, RICH_TEXT_EDITOR, INPUT_TIMER, GPS | — | | formula autonumber display | INPUT_FORMULA, INPUT_AUTONUMBER / CREATED* / MODIFIED*, TEXT_VIEW / HTML_TEXT | computed by the engine | | section container tabs steps matrix table | GROUP_VIEW subtypes, STEPPER, TAB, MATRIX, TABLE_START | form.childrenOf(field) + <FormFields> | | sheet | INPUT_SHEET, NESTED_FORM, INPUT_CHILD_WIDGET | useSheetField (rows are posted before the parent) | | action | ACTION_BUTTON, TRIGGER_AUTOMATION, CASCADED_ACTIONS | form.runAction(field) | | trials | INPUT_PERCENT_CORRECT, INPUT_TIME_SAMPLING, INPUT_TASK_ANALYSIS (ABA trial widgets) | useTrialField (response / prompt buttons, trials, timer, steps, % correct, log) | | onbehalf | ON_BEHALF_OF | read-only; field.displayText is the user's name | | comments system | COMMENTS, ON_BEHALF_OF, INPUT_IP, INPUT_DEVICE_INFO | system fields are filled automatically and never shown |

Dependent lookups. A FETCH_OPTIONS_FILTERS rule sits on the lookup it filters, as in zvolv-web and ZAI-Dashboard: { type: "FETCH_OPTIONS_FILTERS", elementId: <source field>, masterField: <label on the lookup's source form> }. useFieldOptions waits for the source (waitingFor: "Select … first"), filters by its value, clears the selection when the source changes, and auto-selects when exactly one row matches — so "pick a laptop → Asset ID and Model fill in" is two read-only lookups filtered on Serial Number. The older shape (rule on a non-lookup driver, elementId = the lookup) is still read. cascade(source, target, masterField) writes the platform shape.

Type coverage. Every element type ZAI-Dashboard's FormFieldRouter and zvolv-web's z-form-widgets render has a kind. Aliases: SCAN → INPUT_SCAN, HTML_TEXT → TEXT_VIEW, TRIGGER_AUTOMATION → an action button, ACTION_DROPDOWN → an action with field.variant === "dropdown" whose field.options are its actions (form.runAction(field, label) runs only the picked one, as in zvolv-web). INPUT_CARD_CONTAINER is commented out in zvolv-web and is not supported. Like zvolv-web, any attributes.subType that is a known type replaces type — so an element saved as INPUT_SHORT_TEXT with subType INPUT_PERCENT_CORRECT (and dataType: "OBJECT") is a trial widget. DOWNLOAD_FILE / PRINT_PREVIEW fall back to properties.url when the button has no value.

Trial widgets (useTrialField(form, field)): the configuration comes from the stored value's conf (ZAI) over element.properties (zvolv-web): ResponseButtons (with PromptButtons), maximumNumberOfTrials, minimumNumberOfTrials, LinkedTimerField, timeLimit / timeInterval, steps and type (forward / backward / whole). Values match ZAI: { conf, buttonResp, lastStatus? , currentStatus? }. minimumNumberOfTrials is checked on submit for percent correct and time sampling.

Field parity with ZAI-Dashboard / zvolv-web.

  • Options load on focus. Call o.onFocus() from your control's focus/open handler. Lookups and people search fetch nothing until then, as in zvolv-web (nzFocus → onFocus) and ZAI (handleFocus). Lookups re-fetch on later focuses (throttled to 2 s); people search re-fetches only when it has nothing loaded. Dependency-filtered lookups still load as soon as their source is set. Pass useFieldOptions(form, field, { loadOn: "mount" }) for the old behaviour.

  • Single vs multi comes from properties.maxResults for FORM_SEARCH, INPUT_TAG and AUTO_COMPLETE (1 = single). toggle refuses picks past the limit; o.maxCount and o.atLimit tell the control.

  • INPUT_TAG can store a value that isn't in the list: o.addFreeTag(text) stores { view, select, extraLabels: [] }.

  • ExtraLabels auto-fill is off by default. Neither platform copies a picked row's ExtraLabels into other fields; use a FETCH_OPTIONS_FILTERS dependent lookup. Opt in with { autofillExtraLabels: true }.

  • Signed-in user is decrypted like ZAI-Dashboard getStoredUser(): the host's readUser, else the SDK's readUserFromStorage(), else client.auth.userInstance through decryptAuthObject. Values still enc::… are dropped, and forms re-read the user on the SDK's zvAuthCryptReady event. Pass createZvolvRuntime({ client, readUser: readUserFromStorage }) to be explicit.

  • ON_BEHALF_OF has kind "onbehalf". It is shown read-only, stores the signed-in user's id (never a role id), shows field.displayText (the user's name), and isn't overwritten by automation formData.

  • CHECK_BOX with attributes.showAsSwitch gets field.variant === "switch". "1" is read as true.

  • DATE_PICKER dynamicDefault supports ADD and SUB, any unit (days, weeks, months, years, hours), a bare number of days, and the DynamicDefault key. field.dateFormat is properties.format converted to dayjs tokens (default DD/MM/YYYY).

  • RICH_TEXT_EDITOR: markup with no text (<p><br></p>, &nbsp;) counts as empty and is sent as null. field.maxLength also reads attributes.charactersCount.

  • FILE_UPLOAD_V2: the count limit also reads allowedFileMaxCount and properties.max (1 for ShowAsProfileImagePicker). Blocked extensions (.exe, .bat, .js, … — BLOCKED_FILE_EXTENSIONS) are refused.

  • INPUT_SHEET: useSheetField returns canAdd / canDelete (from allowedAddActions, allowedDeleteActions or allowedActions), maxRows and limitMessage. Saved rows load without marking the form dirty. Row validation skips hidden, disabled and non-input columns and untouched saved rows.

  • Action buttons (form.runAction) handle every actionType ZAI-Dashboard and zvolv-web do. actionType = properties.actionType ?? attributes.subType ?? properties.subType ?? OPEN_URL.

    | actionType | What runs | | --- | --- | | OPEN_URL / URL | opens properties.url, the button's value, or defaultValue | | TRIGGER_AUTOMATION | invokeTriggers(triggerID); automationType: "ZENO" uses invokeNuclioAutomation(automationUuid). formData is written back; code 400/500 or error is a failure; refreshForm / refreshFormDelay reloads a saved record | | INVOKE_AGENT | invokeAgent({ content: { query } }, flowId, agentId) with a label-keyed body; the reply's formData is written back | | DOWNLOAD_FILE | downloads the button's value (a URL), properties.fileName | | PRINT_PREVIEW | loads the PDF at the button's value and opens the print dialog | | UPLOAD_FILE | file picker → upload (card upload when cardID is set) → stores the file URL in the button | | UPDATE, UPDATE_SUBMISSION, UPDATE_OPEN_NEW_FORM, EDIT_OPEN_NEW_FORM, unknown | sets the button true, then saves | | SUBMIT | saves the form | | CREATE_SUBMISSION, EDIT_SUBMISSION, SAVE_SUBMISSION, OPEN_TASK, OPEN_PROJECT | open other screens: handle in onAction; openFormRequest(field, form) gives { formId, submissionId, mode, defaultValues (inputMap) } | | CASCADED_ACTIONS | runs actionEleIds in order, stops at the first failure, then saves when submit is true |

    successMessage, errorMessage and hideSuccessMessage shape the returned message; form.reload() re-fetches the record.

Show / hide / mandatory rules (triage). These match ZAI-Dashboard's applyDependencies:

  • Rules sit on the driving field. properties.triage replaces dependencies when present.
  • A rule with an array value needs the selected values to equal that set exactly. A lookup rule with one value needs exactly one selection.
  • When rules for the same target disagree, a matching rule beats a non-matching one. With no match, the field's own hidden / required / disabled applies. *_WITHOUT_RESET types act like the plain ones.
  • Hiding a field keeps its value, as in ZAI-Dashboard and zvolv-web. Hidden fields are skipped by validation.
  • field.error shows only after a submit (or form.validate()), and clears as soon as that field changes. A field that a rule newly makes mandatory does not turn red until the next submit.

form.runAction handles OPEN_URL, SAVE_SUBMISSION and automation triggers (writing formData back into the form). Pass onAction to <ZvolvForm> for host actions such as "open another form".

Inspector (validation, rules, filters, formulas, payload)

useFormInspector(form) (or the pure inspectForm(form)) explains what the engine is doing for the current values. Every verdict comes from the same functions the form uses, so a host can draw an inspector panel without re-implementing anything:

| Key | What it holds | | --- | --- | | rules | each dependency / triage rule: source value, expected value, effect (show / hide / required / optional / editable / read-only), target, matched | | filters | each lookup filter (FETCH_OPTIONS_FILTERS, DynamicFilters, StaticFilters) with the feeding value and active | | validation | each input field and sheet cell: checks, current error from validateField, the error shown to the user | | formulas | each form and sheet-row formula: inputs, expected, current, inSync, serverSafe, serverFormula | | defaults | dynamic and fixed defaults and the value they produced | | payload | the exact submission body — sheet rows (posted first) and the parent | | summary | counts for tiles |

Formulas must use {Label} references. The submission API re-evaluates every INPUT_FORMULA and rejects a mismatch (Evaluated value "0" and existing value "8000" … do not match). It resolves only {Label}; bare labels (MUL(Qty, Price)) evaluate to 0 on the server even though the library computes them. isServerSafeFormula(formula, labels) detects this and toBraceFormula gives the fix; the inspector flags it per formula.

Sheet row formulas are recalculated by useSheetField().updateRow / addRow (computeRowFormulas), and again before rows are posted.

Headless

useZvolvForm({ formId, mode, submissionId, defaultValues, hiddenFields, skipTypes, onSubmitted }) returns the same ZvolvFormApi without any markup. setValue, validate(ids?), submit(), reset(), applyValues(map), dirty, submitting, submitError, status.

Pure helpers: normaliseForm, serializeElementValue, buildSubmissionElements, buildTriggerBody, validateField, applyRules, evaluateFormula (a parser; there is no eval).

Creating forms (for Claude and scripts)

el, when, cascade, defineForm and formWidget build payloads for the MCP create_form / create_widget tools:

const sev = el.radio("Severity", ["Low", "High", "Critical"], { required: true });
const cause = el.longText("Root Cause Analysis");
when(sev, "High").show(cause).require(cause);
when(sev, "Critical").show(cause).require(cause);
const payload = defineForm({ title: "Incident Report", zappId: 11, zappName: "5S Audit", elements: [
  el.lookup("Plant", { formId: PLANTS, field: "Plant", extra: ["Region"] }, { required: true }),
  el.text("Region", { disabled: true, width: 25 }),   // autofilled from the lookup
  sev, cause,
  el.number("Quantity"), el.currency("Rate"),
  el.formula("Order Value", "MUL(Quantity, Rate)"),
]});
const widget = formWidget({ formId: created.id, title: "Report an incident", layout: { x: 0, y: 91, w: 60, h: 56 } });

Platform rules these helpers follow:

  • width is a percentage (25, 50, 75 or 100). It goes at the element root and in attributes.Width.
  • Show/hide/mandatory rules sit on the driving field. Give the target hidden: true and add a SHOW rule for each value.
  • Do not use AVG: the platform lists it but does not implement it. Every write to the form then fails with a 502. Write the average out instead, e.g. DIVI(ADD(A, ADD(B, C)), 3). ADD, SUB, MUL, DIVI and IF are verified working.
  • The create_form validator only accepts formulas with bare, single-word labels, e.g. MUL(Quantity, Rate). It rejects {Label} and labels containing spaces, even though the KB documents {Label}. The engine evaluates both styles.
  • Row height is 21px. A tile of h rows is h × 21 − 16 px tall, so size form tiles to their fields; forms scroll inside the tile with the footer pinned.