@zestl/zvolv-widgets 0.0.0-stage → 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +821 -2
- package/SKILL.md +251 -0
- package/dist/index.cjs +9003 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +3070 -0
- package/dist/index.d.ts +3070 -0
- package/dist/index.js +8785 -0
- package/dist/index.js.map +1 -0
- package/package.json +47 -4
package/README.md
CHANGED
|
@@ -1,3 +1,822 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @zestl/zvolv-widgets
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
React widgets for Zvolv dashboards. **The library owns the data. You (or Claude) own the UI.**
|
|
4
|
+
|
|
5
|
+
The library handles Elasticsearch query generation, pivot `postOperations`,
|
|
6
|
+
summary/series alignment, filter merging, date math, user-id → name resolution,
|
|
7
|
+
and **viewer interactions**: search, column filters, sort and widget-level
|
|
8
|
+
filters. This logic is ported from ZAI-Dashboard's production widgets. Every
|
|
9
|
+
visual decision can be overridden.
|
|
10
|
+
|
|
11
|
+
| The library owns — never reimplement | You own — design freely |
|
|
12
|
+
| --- | --- |
|
|
13
|
+
| Query generation, Elasticsearch calls | Layout, spacing, composition |
|
|
14
|
+
| Pivot `postOperations`, summary/series alignment | Colour, type, density (design tokens) |
|
|
15
|
+
| Filter merging, date math, user-id → labels | Chart marks, every template |
|
|
16
|
+
| **Applying** search / column filters / sort / widget filters | The **controls** for search / filters / sort (render slots) |
|
|
17
|
+
| Loading / empty / unconfigured / error detection | Every pixel |
|
|
18
|
+
|
|
19
|
+
48 widget type strings render (plus 4 non-rendering filter/automation types). A real-data runtime is built in.
|
|
20
|
+
|
|
21
|
+
> **For LLM agents:** read [SKILL.md](./SKILL.md) before writing code. It is the
|
|
22
|
+
> short, rule-first contract. This README is the full reference.
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## Contents
|
|
27
|
+
|
|
28
|
+
1. [Quick start](#1-quick-start)
|
|
29
|
+
2. [Props every widget accepts](#2-props-every-widget-accepts)
|
|
30
|
+
3. [Search, column filters, sort and widget filters](#3-search-column-filters-sort-and-widget-filters)
|
|
31
|
+
4. [Data sources](#4-data-sources--where-the-data-comes-from)
|
|
32
|
+
5. [Controlling the UI](#5-controlling-the-ui)
|
|
33
|
+
6. [Widget catalogue](#6-widget-catalogue)
|
|
34
|
+
7. [Headless hooks](#7-headless-hooks)
|
|
35
|
+
8. [Runtime: mock and real data](#8-runtime-mock-and-real-data)
|
|
36
|
+
9. [Install, link, test](#9-install-link-test)
|
|
37
|
+
10. [Rules and known limitations](#10-rules-and-known-limitations)
|
|
38
|
+
11. [Forms](#11-forms)
|
|
39
|
+
|
|
40
|
+
---
|
|
41
|
+
|
|
42
|
+
## 1. Quick start
|
|
43
|
+
|
|
44
|
+
```tsx
|
|
45
|
+
import {
|
|
46
|
+
ZvolvProvider, ZvolvThemeProvider, createMockRuntime,
|
|
47
|
+
DashboardTableWidget, BarChartWidget,
|
|
48
|
+
formSource, field, groupBy, aggregate, pivot,
|
|
49
|
+
} from "@zestl/zvolv-widgets";
|
|
50
|
+
|
|
51
|
+
const region = field("Region", "DROP_DOWN");
|
|
52
|
+
const revenue = field("Revenue", "INPUT_CURRENCY");
|
|
53
|
+
const owner = field("Owner", "INPUT_TEXT");
|
|
54
|
+
|
|
55
|
+
// Raw rows → table
|
|
56
|
+
const tableSource = formSource(1042, [region, revenue, owner]);
|
|
57
|
+
|
|
58
|
+
// Grouped + aggregated → chart
|
|
59
|
+
const chartSource = formSource(1042, [region, revenue], {
|
|
60
|
+
pivot: pivot({ rows: [groupBy(region)], summary: [aggregate(revenue, "sum")] }),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
export function Dashboard() {
|
|
64
|
+
return (
|
|
65
|
+
<ZvolvProvider runtime={createMockRuntime()}> {/* swap for the real runtime, §8 */}
|
|
66
|
+
<ZvolvThemeProvider tokens={{ colors: { primary: "#0f766e" } }}>
|
|
67
|
+
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16, height: 420 }}>
|
|
68
|
+
<DashboardTableWidget
|
|
69
|
+
dataSource={tableSource}
|
|
70
|
+
configurations={{ title: "Deals", showSearchbar: true, showColumnFilter: true }}
|
|
71
|
+
/>
|
|
72
|
+
<BarChartWidget
|
|
73
|
+
dataSource={chartSource}
|
|
74
|
+
configurations={{
|
|
75
|
+
title: "Revenue by region",
|
|
76
|
+
series: [{ name: "Revenue", valueField: "Revenue", argumentField: "Region" }],
|
|
77
|
+
}}
|
|
78
|
+
showWidgetFilter
|
|
79
|
+
/>
|
|
80
|
+
</div>
|
|
81
|
+
</ZvolvThemeProvider>
|
|
82
|
+
</ZvolvProvider>
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
That table has a search box, per-column filters and click-to-sort headers. The
|
|
88
|
+
chart has a "Filter" control. Each one re-queries or re-filters the data, and
|
|
89
|
+
nothing else needs wiring.
|
|
90
|
+
|
|
91
|
+
---
|
|
92
|
+
|
|
93
|
+
## 2. Props every widget accepts
|
|
94
|
+
|
|
95
|
+
Every data widget (charts, KPI, tables, collections, temporal, display) takes
|
|
96
|
+
the same props. `TabWidget`, `FormWidget`, `FilterPanelWidget` and
|
|
97
|
+
`ZaiChatWidget` hold no data of their own, so they do not take them.
|
|
98
|
+
|
|
99
|
+
### Data props
|
|
100
|
+
|
|
101
|
+
| Prop | Type | Notes |
|
|
102
|
+
| --- | --- | --- |
|
|
103
|
+
| `dataSource` | `DataSourceDefinition` | **What** is queried. Build with `formSource()` / `taskSource()`. Pass server-stored ones through untouched. |
|
|
104
|
+
| `configurations` | object \| JSON string | **How** it looks and which fields it plots. Strings are parsed for you. |
|
|
105
|
+
| `dashFilters` | object | Dashboard filter state (host `{ filter, filter_cond, cdate, search }`). |
|
|
106
|
+
| `cdateFilters` | array | Dashboard date-filter definitions. |
|
|
107
|
+
| `localFilters` | object | Raw host-format widget filter (`{ filter, filter_cond }` from ZFilters). Merged per form with `dashFilters`. Prefer `widgetFilters`. |
|
|
108
|
+
| `automationRows` / `automationLoading` | `any[]` / `boolean` | Rows from an automation binding. No query is issued. |
|
|
109
|
+
| `rowLimit` | number | Max rows fetched. Default 1000. |
|
|
110
|
+
| `dateGap` | `{ unit, start?, end? }` | Fill empty periods on a time axis. |
|
|
111
|
+
| `widgetId`, `workflowTypeId` | | Legacy (non form-framework) sources only. |
|
|
112
|
+
|
|
113
|
+
### Chrome props
|
|
114
|
+
|
|
115
|
+
`title`, `description`, `emptyMessage`, `unconfiguredMessage`, `className`,
|
|
116
|
+
`style`, and `render` (see §5). The same chrome keys also work inside
|
|
117
|
+
`configurations`: `title`, `description`, `hideHeader`, `customMessage`,
|
|
118
|
+
`bgColor`, `template`, `style`.
|
|
119
|
+
|
|
120
|
+
### Interaction props
|
|
121
|
+
|
|
122
|
+
These are covered in §3.
|
|
123
|
+
|
|
124
|
+
| Prop | Type | Applies to |
|
|
125
|
+
| --- | --- | --- |
|
|
126
|
+
| `search` / `defaultSearch` / `onSearchChange` | `string` | all data widgets |
|
|
127
|
+
| `widgetFilters` / `defaultWidgetFilters` / `onWidgetFiltersChange` | `WidgetFilter[]` | all data widgets |
|
|
128
|
+
| `columnFilters` / `defaultColumnFilters` / `onColumnFiltersChange` | `Record<string, any[]>` | tables |
|
|
129
|
+
| `sort` / `defaultSort` / `onSortChange` | `{ key, direction: "asc" \| "desc" } \| null` | tables |
|
|
130
|
+
| `onInteractionsChange` | `(state) => void` | all: fires with the full state after any change |
|
|
131
|
+
| `showSearch` | boolean | all. Default `config.showSearchbar` |
|
|
132
|
+
| `showWidgetFilter` | boolean | all. Default `config.showWidgetFilter` |
|
|
133
|
+
| `showColumnFilter` | boolean | tables. Default `config.showColumnFilter` |
|
|
134
|
+
| `enableSort` | boolean | tables. Default `true` |
|
|
135
|
+
| `searchPlaceholder` | string | all |
|
|
136
|
+
| `searchDebounceMs` | number | all. Default 400, applies to server-side search only |
|
|
137
|
+
| `renderSearch`, `renderWidgetFilter`, `renderToolbar` | slot | all |
|
|
138
|
+
| `renderColumnFilter`, `renderSortIndicator` | slot | tables |
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## 3. Search, column filters, sort and widget filters
|
|
143
|
+
|
|
144
|
+
There are four kinds of viewer control:
|
|
145
|
+
|
|
146
|
+
| Control | What it does | Where it shows |
|
|
147
|
+
| --- | --- | --- |
|
|
148
|
+
| **Search** | Free text across all columns | Widget header |
|
|
149
|
+
| **Widget filter** | Field + operator + value conditions (AND-ed) | Widget header |
|
|
150
|
+
| **Column filter** | Tick values in one column | Table header cell |
|
|
151
|
+
| **Sort** | One column, asc/desc | Table header cell |
|
|
152
|
+
|
|
153
|
+
### 3.1 Turn them on
|
|
154
|
+
|
|
155
|
+
You can enable each control from config, from props, or by passing a slot.
|
|
156
|
+
Any one of these is enough:
|
|
157
|
+
|
|
158
|
+
```tsx
|
|
159
|
+
// from config (this is how dashboards store it)
|
|
160
|
+
configurations={{ showSearchbar: true, showColumnFilter: true, showWidgetFilter: true }}
|
|
161
|
+
|
|
162
|
+
// from props
|
|
163
|
+
<DashboardTableWidget showSearch showColumnFilter showWidgetFilter ... />
|
|
164
|
+
|
|
165
|
+
// by supplying a slot — passing renderSearch turns search on
|
|
166
|
+
<DashboardTableWidget renderSearch={(a) => <MySearch {...a} />} ... />
|
|
167
|
+
```
|
|
168
|
+
|
|
169
|
+
Sort is on by default for tables. Turn it off with `enableSort={false}` or
|
|
170
|
+
`config.enableSort: false`. Active widget filters always show as removable
|
|
171
|
+
chips, unless you pass `showWidgetFilter={false}`.
|
|
172
|
+
|
|
173
|
+
### 3.2 Where each control is applied
|
|
174
|
+
|
|
175
|
+
The library decides this for you. You never write query code.
|
|
176
|
+
|
|
177
|
+
| Data source | Search / column filter / sort | Widget filters |
|
|
178
|
+
| --- | --- | --- |
|
|
179
|
+
| Form or task source, **no** `pivot.rows` | **Server**: sent through `generateQuery`, so it covers the full dataset | **Server** |
|
|
180
|
+
| Form or task source **with** `pivot.rows` (grouped) | **Local**: applied to the loaded buckets | **Server** (before aggregation) |
|
|
181
|
+
| Legacy widget-id source | Local | Local |
|
|
182
|
+
| `automationRows` | Local | Local |
|
|
183
|
+
| `ChildTableWidget` | Local | Server |
|
|
184
|
+
|
|
185
|
+
Server-side details (these match ZAI-Dashboard's `DashboardTable`):
|
|
186
|
+
- 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.
|
|
187
|
+
- 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.
|
|
188
|
+
- Sort becomes `generateQuery`'s `dashboardSort`. If `configuration.columns` is missing (`generateQuery` needs it), the library builds it from `dataSource.fields`.
|
|
189
|
+
- Widget filters become field clauses. `DATE_IS` tokens become `BETWEEN` date ranges, and numeric fields get numeric operands.
|
|
190
|
+
|
|
191
|
+
`data.meta.tableMode` and `data.meta.filterMode` tell you which mode applies.
|
|
192
|
+
Every slot also receives `mode`.
|
|
193
|
+
|
|
194
|
+
### 3.3 Controlled vs uncontrolled
|
|
195
|
+
|
|
196
|
+
**Uncontrolled.** The widget owns the state. Seed it with `default*`:
|
|
197
|
+
|
|
198
|
+
```tsx
|
|
199
|
+
<DashboardTableWidget
|
|
200
|
+
dataSource={ds} configurations={cfg}
|
|
201
|
+
defaultSort={{ key: "Revenue", direction: "desc" }}
|
|
202
|
+
defaultWidgetFilters={[{ field: "Status", operator: "IN", value: ["Open"] }]}
|
|
203
|
+
/>
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
**Controlled.** You own the state (for example, sync it to the URL). Pass the
|
|
207
|
+
value **and** its `on*Change`:
|
|
208
|
+
|
|
209
|
+
```tsx
|
|
210
|
+
const [search, setSearch] = useState("");
|
|
211
|
+
const [sort, setSort] = useState<SortState | null>(null);
|
|
212
|
+
const [columnFilters, setColumnFilters] = useState<ColumnFilters>({});
|
|
213
|
+
const [widgetFilters, setWidgetFilters] = useState<WidgetFilter[]>([]);
|
|
214
|
+
|
|
215
|
+
<DashboardTableWidget
|
|
216
|
+
dataSource={ds} configurations={cfg} showSearch showColumnFilter
|
|
217
|
+
search={search} onSearchChange={setSearch}
|
|
218
|
+
sort={sort} onSortChange={setSort}
|
|
219
|
+
columnFilters={columnFilters} onColumnFiltersChange={setColumnFilters}
|
|
220
|
+
widgetFilters={widgetFilters} onWidgetFiltersChange={setWidgetFilters}
|
|
221
|
+
/>
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
A controlled value only changes when you update it. If you pass `search`
|
|
225
|
+
without `onSearchChange`, the box cannot be edited.
|
|
226
|
+
|
|
227
|
+
### 3.4 Value shapes
|
|
228
|
+
|
|
229
|
+
```ts
|
|
230
|
+
type SortState = { key: string; direction: "asc" | "desc" }; // key = row field / column key
|
|
231
|
+
type ColumnFilters = Record<string, any[]>; // column key → ticked values; "" = blanks
|
|
232
|
+
interface WidgetFilter {
|
|
233
|
+
field: string; // a `header` from dataSource.fields
|
|
234
|
+
operator: "IN" | "NOT_IN" | "EQUALS" | "CONTAINS" | "STARTS_WITH"
|
|
235
|
+
| "GREATER_THAN" | "LESS_THAN" | "BETWEEN" | "EMPTY" | "NOT_EMPTY" | "DATE_IS";
|
|
236
|
+
value?: any; // IN/NOT_IN: list · BETWEEN: [from, to] · DATE_IS: token · EMPTY/NOT_EMPTY: none
|
|
237
|
+
}
|
|
238
|
+
// DATE_IS tokens: TODAY YESTERDAY LAST_SEVEN_DAYS LAST_THIRTY_DAYS THIS_WEEK THIS_MONTH THIS_YEAR
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
Some rules to follow:
|
|
242
|
+
- `WidgetFilter.field` must be a field in `dataSource.fields`. Unknown fields are skipped with a console warning.
|
|
243
|
+
- User fields (AUTO_COMPLETE, assignees) filter by **user id**, not name.
|
|
244
|
+
- A column filter with both blanks and values can only OR them locally. Server-side, the blanks are dropped (with a warning).
|
|
245
|
+
|
|
246
|
+
### 3.5 Supplying your own controls (slots)
|
|
247
|
+
|
|
248
|
+
Each slot receives the current value and setters. It returns any React node.
|
|
249
|
+
Whatever it sets is applied to the data automatically.
|
|
250
|
+
|
|
251
|
+
```tsx
|
|
252
|
+
<DashboardTableWidget
|
|
253
|
+
dataSource={ds} configurations={cfg}
|
|
254
|
+
|
|
255
|
+
renderSearch={({ value, setValue, placeholder, loading }) => (
|
|
256
|
+
<Input.Search value={value} onChange={(e) => setValue(e.target.value)}
|
|
257
|
+
placeholder={placeholder} loading={loading} allowClear />
|
|
258
|
+
)}
|
|
259
|
+
|
|
260
|
+
renderWidgetFilter={({ filters, fields, addFilter, removeFilter, clear }) => (
|
|
261
|
+
<MyFilterBuilder
|
|
262
|
+
fields={fields} // [{ field, label, kind, operators[] }]
|
|
263
|
+
value={filters}
|
|
264
|
+
onAdd={addFilter} onRemove={removeFilter} onClear={clear}
|
|
265
|
+
/>
|
|
266
|
+
)}
|
|
267
|
+
|
|
268
|
+
showColumnFilter
|
|
269
|
+
renderColumnFilter={({ column, options, selected, setSelected }) => (
|
|
270
|
+
<MultiSelectDropdown
|
|
271
|
+
title={column.header}
|
|
272
|
+
options={options} // [{ value, label, count }]
|
|
273
|
+
value={selected}
|
|
274
|
+
onChange={setSelected} // [] clears this column
|
|
275
|
+
/>
|
|
276
|
+
)}
|
|
277
|
+
|
|
278
|
+
renderSortIndicator={({ direction, toggle }) => (
|
|
279
|
+
<SortIcon direction={direction} onClick={toggle} /> // toggle: none → asc → desc → none
|
|
280
|
+
)}
|
|
281
|
+
|
|
282
|
+
renderToolbar={({ search, widgetFilter, interactions }) => (
|
|
283
|
+
<Space>{widgetFilter}{search}
|
|
284
|
+
{interactions.active && <a onClick={interactions.clearAll}>Reset</a>}
|
|
285
|
+
</Space>
|
|
286
|
+
)}
|
|
287
|
+
/>
|
|
288
|
+
```
|
|
289
|
+
|
|
290
|
+
| Slot | Receives |
|
|
291
|
+
| --- | --- |
|
|
292
|
+
| `renderSearch` | `{ value, setValue, clear, placeholder, mode, loading, tokens }` |
|
|
293
|
+
| `renderWidgetFilter` | `{ filters, setFilters, addFilter, removeFilter, clear, fields, declared, mode, tokens }` |
|
|
294
|
+
| `renderColumnFilter` | `{ column, selected, setSelected, clear, options, mode, tokens }` |
|
|
295
|
+
| `renderSortIndicator` | `{ column, direction, toggle, setDirection, tokens }` |
|
|
296
|
+
| `renderToolbar` | `{ search, widgetFilter, interactions, tokens }`. `search` and `widgetFilter` are the resolved nodes |
|
|
297
|
+
|
|
298
|
+
`fields` comes from `dataSource.fields`. Each entry has `kind` (`text` /
|
|
299
|
+
`number` / `date` / `boolean`) and the `operators` that suit that kind.
|
|
300
|
+
`declared` is the host's `config.filters` list, when one exists.
|
|
301
|
+
|
|
302
|
+
If you omit a slot, you get a small token-styled default: a search input, a
|
|
303
|
+
filter builder with chips, a checkbox list for column values, and ▲▼ sort
|
|
304
|
+
buttons.
|
|
305
|
+
|
|
306
|
+
> **Return an element from a slot. Don't pass a component that uses hooks as the
|
|
307
|
+
> slot itself.** Slots are called as plain functions, so
|
|
308
|
+
> `renderColumnFilter={MyFilter}` runs `MyFilter`'s hooks inside the widget and
|
|
309
|
+
> breaks when the column count changes. Write
|
|
310
|
+
> `renderColumnFilter={(a) => <MyFilter {...a} />}` instead.
|
|
311
|
+
|
|
312
|
+
### 3.6 A fully custom table
|
|
313
|
+
|
|
314
|
+
A table `render` receives everything it needs to build its own header:
|
|
315
|
+
|
|
316
|
+
```tsx
|
|
317
|
+
<DashboardTableWidget
|
|
318
|
+
dataSource={ds} configurations={cfg}
|
|
319
|
+
render={({ rows, columns, renderCell, interactions, headerControls, getColumnOptions, enableSort }) => (
|
|
320
|
+
<MyGrid
|
|
321
|
+
rows={rows} // already searched / filtered / sorted
|
|
322
|
+
columns={columns.map((c) => ({
|
|
323
|
+
...c,
|
|
324
|
+
header: <>{c.header}{headerControls(c)}</>, // slot or default sort + filter controls
|
|
325
|
+
sortOrder: interactions.sortDirectionFor(c.key),
|
|
326
|
+
onHeaderClick: enableSort ? () => interactions.toggleSort(c.key) : undefined,
|
|
327
|
+
}))}
|
|
328
|
+
cell={renderCell}
|
|
329
|
+
/>
|
|
330
|
+
)}
|
|
331
|
+
/>
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
`interactions` also exposes `setSearch`, `setColumnFilter(key, values)`, `setSort`,
|
|
335
|
+
`setWidgetFilters`, `addWidgetFilter`, `removeWidgetFilter`, `clearAll` and `active`.
|
|
336
|
+
|
|
337
|
+
---
|
|
338
|
+
|
|
339
|
+
## 4. Data sources — where the data comes from
|
|
340
|
+
|
|
341
|
+
`configurations` describes how a widget looks. `dataSource` describes what is
|
|
342
|
+
queried. The query generator reads `dataSource`, so a widget with a perfect
|
|
343
|
+
config and no data source shows "No data source configured".
|
|
344
|
+
|
|
345
|
+
```tsx
|
|
346
|
+
import { formSource, taskSource, field, taskField, groupBy, aggregate, derive, where, pivot } from "@zestl/zvolv-widgets";
|
|
347
|
+
|
|
348
|
+
const region = field("Region", "DROP_DOWN");
|
|
349
|
+
const revenue = field("Revenue", "INPUT_CURRENCY");
|
|
350
|
+
const target = field("Target", "INPUT_CURRENCY");
|
|
351
|
+
|
|
352
|
+
const dataSource = formSource(1042, [region, revenue, target], {
|
|
353
|
+
pivot: pivot({
|
|
354
|
+
rows: [groupBy(region)], // GROUP BY Region
|
|
355
|
+
summary: [aggregate(revenue, "sum"), aggregate(target, "sum")],
|
|
356
|
+
postOperations: [derive("Gap", "SUB", ["Target", "Revenue"])],
|
|
357
|
+
}),
|
|
358
|
+
});
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### The name-matching rule (mismatches fail silently)
|
|
362
|
+
|
|
363
|
+
| Config key | Must equal |
|
|
364
|
+
| --- | --- |
|
|
365
|
+
| `series[].argumentField` | `pivot.rows[].title` (defaults to the field's `header`) |
|
|
366
|
+
| `series[].valueField` | `pivot.summary[].value` (defaults to the field's `header`), or a `derive()` title |
|
|
367
|
+
| `WidgetFilter.field`, `SortState.key`, `ColumnFilters` keys | a `header` in `dataSource.fields` |
|
|
368
|
+
|
|
369
|
+
```tsx
|
|
370
|
+
series: [
|
|
371
|
+
{ name: "Revenue", valueField: "Revenue", argumentField: "Region" },
|
|
372
|
+
{ name: "Gap", valueField: "Gap", argumentField: "Region" },
|
|
373
|
+
]
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
A mismatch produces an empty chart and **no error**. Reusing the same `field()`
|
|
377
|
+
objects everywhere keeps the names in step.
|
|
378
|
+
|
|
379
|
+
### Other data-source rules
|
|
380
|
+
|
|
381
|
+
- **Declare every field** you group by, plot, filter, search or sort on in `fields`. An undeclared field silently returns nothing.
|
|
382
|
+
- **`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`.
|
|
383
|
+
- **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.
|
|
384
|
+
- **Never invent a `formId` or field name.** Ask for them, or copy them from an existing widget's stored `DataSource`.
|
|
385
|
+
|
|
386
|
+
### Baked-in (author) filters vs viewer filters
|
|
387
|
+
|
|
388
|
+
```tsx
|
|
389
|
+
// Always applied, part of the widget definition:
|
|
390
|
+
field("Status", "DROP_DOWN", { filter: where("IN", "Open", "Pending") })
|
|
391
|
+
field("Created", "INPUT_DATE", { filter: where("DATE_IS", "{@[TODAY]}") })
|
|
392
|
+
|
|
393
|
+
// Chosen by the viewer at runtime (§3):
|
|
394
|
+
defaultWidgetFilters={[{ field: "Status", operator: "IN", value: ["Open"] }]}
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
---
|
|
398
|
+
|
|
399
|
+
## 5. Controlling the UI
|
|
400
|
+
|
|
401
|
+
These are ordered from cheapest to most control.
|
|
402
|
+
|
|
403
|
+
**1. Design tokens** restyle everything without touching components:
|
|
404
|
+
|
|
405
|
+
```tsx
|
|
406
|
+
<ZvolvThemeProvider tokens={{
|
|
407
|
+
colors: { primary: "#0f766e", charts: { palette: ["#0f766e", "#b45309", "#6d28d9"] } },
|
|
408
|
+
typography: { kpiValue: { fontSize: "34px", fontWeight: 700 } },
|
|
409
|
+
widget: { appearance: "flat", borderRadius: "18px", padding: "22px" },
|
|
410
|
+
surfaces: { table: { headerBg: "#f3f4f1", altRowBg: "#fafaf8" } },
|
|
411
|
+
overrides: { KPI_CARD: { appearance: "border" } }, // per widget type
|
|
412
|
+
widgetDefaults: { BAR_CHART: { template: "horizontal" } }, // layered UNDER each widget's config
|
|
413
|
+
}}>
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
**2. The `render` prop** lets you own the visuals while keeping the data:
|
|
417
|
+
|
|
418
|
+
```tsx
|
|
419
|
+
<BarChartWidget dataSource={ds} configurations={cfg}
|
|
420
|
+
render={({ rows, xKey, series, colorFor, toLabel }) => (
|
|
421
|
+
<ResponsiveContainer>
|
|
422
|
+
<BarChart data={rows}>
|
|
423
|
+
<XAxis dataKey={xKey} tickFormatter={toLabel} />
|
|
424
|
+
{series.map((s, i) => <Bar key={s.valueField} dataKey={s.valueField} fill={colorFor(i, s)} />)}
|
|
425
|
+
</BarChart>
|
|
426
|
+
</ResponsiveContainer>
|
|
427
|
+
)}
|
|
428
|
+
/>
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
Always use `xKey` and `series[].valueField` from the args. Never hard-code
|
|
432
|
+
field names. Run category and user values through `toLabel`.
|
|
433
|
+
|
|
434
|
+
**3. Template overrides** replace a template everywhere:
|
|
435
|
+
|
|
436
|
+
```tsx
|
|
437
|
+
<TemplateProvider templates={{ "KPI_CARD:standard": MyKpiCard, "BAR_CHART:*": MyBars }}>
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
KPI templates receive `{ vm, index }`. `vm` is fully resolved: `value`,
|
|
441
|
+
`rawValue`, `prefix`, `suffix`, `delta`, `progress`, `status`, `series`.
|
|
442
|
+
Never re-derive values from `vm.raw`.
|
|
443
|
+
|
|
444
|
+
**4. Interaction slots** replace the search, filter and sort controls (§3.5).
|
|
445
|
+
|
|
446
|
+
---
|
|
447
|
+
|
|
448
|
+
## 6. Widget catalogue
|
|
449
|
+
|
|
450
|
+
| Family | Components | Type strings | `render` receives |
|
|
451
|
+
| --- | --- | --- | --- |
|
|
452
|
+
| KPI | `KpiWidget` | `KPI` `KPI_CARD` `KPI_CARD_V2` | templates get `{ vm, index }` |
|
|
453
|
+
| 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 }` |
|
|
454
|
+
| Ranking | `RankingBarWidget` | `RANKING_BAR` | `{ rows: {rank,label,value,ratio,raw}[], max, … }` |
|
|
455
|
+
| Gauge | `GaugeMeterWidget` | `GAUGE_METER` | `{ value, min, max, ratio, color, band, thresholds, rows }` |
|
|
456
|
+
| Heat map | `HeatMapWidget` | `HEAT_MAP` | `{ cells: {zone,count,color,band}[], grid }` |
|
|
457
|
+
| 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) |
|
|
458
|
+
| Collections | `CardListWidget` `ActivityFeedWidget` `EntityGalleryWidget` `GridCardWidget` `CarouselWidget` `BannerWidget` | `CARDLIST` `ACTIVITYFEED` `ENTITY_GALLERY` `GRID_CARD` `CAROUSEL` `BANNER` | `{ items: CollectionItem[], template, config, tokens, toLabel }` |
|
|
459
|
+
| Temporal | `TimelineWidget` `GanttChartWidget` `CalendarWidget` `MeetingSchedulerWidget` | `TIMELINE` `GANTT(_CHART)` `CALENDAR` `MEETING_SCHEDULER` | `{ events: TemporalEvent[], range, lanes }` |
|
|
460
|
+
| Stepper | `StepperWidget` | `STEPPER` | `{ steps: StepItem[], currentIndex }` |
|
|
461
|
+
| 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 }` |
|
|
462
|
+
| Containers | `TabWidget` `FormWidget` `FilterPanelWidget` `ZaiChatWidget` | `TAB` `FORM` `FORM_WIDGET` `FILTER_PANEL` `ZAI_CHAT` | see types |
|
|
463
|
+
|
|
464
|
+
Render any widget from its stored type string: `<ZvolvWidget type="BAR_CHART" {...props} />`.
|
|
465
|
+
`DATE_FILTER`, `FILTER`, `FILTERS` and `DASHBOARD_AUTOMATION` render nothing by design.
|
|
466
|
+
|
|
467
|
+
**`render` is required** for `FormWidget`, `ZaiChatWidget`, `GoogleMapWidget`
|
|
468
|
+
and HTML markup in `HtmlWidget`. Form rendering, the chat transport, map SDKs
|
|
469
|
+
and HTML sanitization stay in the host app. The package never calls
|
|
470
|
+
`dangerouslySetInnerHTML`.
|
|
471
|
+
|
|
472
|
+
**`FilterPanelWidget` vs widget filters.** `FilterPanelWidget` is a
|
|
473
|
+
*dashboard-level* control. It is controlled (`value` / `onChange`) and issues no
|
|
474
|
+
queries. Feed its values into other widgets' `dashFilters`. A widget filter
|
|
475
|
+
(§3) narrows only its own widget.
|
|
476
|
+
|
|
477
|
+
---
|
|
478
|
+
|
|
479
|
+
## 7. Headless hooks
|
|
480
|
+
|
|
481
|
+
Use these when a built-in widget is not enough and you want to own all of the
|
|
482
|
+
rendering.
|
|
483
|
+
|
|
484
|
+
```tsx
|
|
485
|
+
import { useWidgetInteractions, useWidgetData } from "@zestl/zvolv-widgets";
|
|
486
|
+
|
|
487
|
+
function MyTable({ dataSource, configurations }) {
|
|
488
|
+
const ix = useWidgetInteractions({ defaultSort: { key: "Revenue", direction: "desc" } });
|
|
489
|
+
const { rows, sourceRows, state, meta, refresh } = useWidgetData({
|
|
490
|
+
dataSource, configurations, widgetType: "TABLE",
|
|
491
|
+
search: ix.search, columnFilters: ix.columnFilters, sort: ix.sort, widgetFilters: ix.widgetFilters,
|
|
492
|
+
});
|
|
493
|
+
// rows: after interactions · sourceRows: as fetched (use for filter options)
|
|
494
|
+
// state: "loading" | "ready" | "empty" | "unconfigured" | "error"
|
|
495
|
+
// meta: { xKey, series, toLabel, tableMode, filterMode, filtered }
|
|
496
|
+
}
|
|
497
|
+
```
|
|
498
|
+
|
|
499
|
+
`useInteractiveWidgetData(props, { table })` combines both hooks and adds the
|
|
500
|
+
toolbar and slot resolution. Every built-in widget uses it. Other hooks:
|
|
501
|
+
`useKpiContents`, `useCollectionItems`, `useTemporalEvents`, `useTableColumns`.
|
|
502
|
+
|
|
503
|
+
Pure helpers, which also work outside React: `applyLocalSearch`,
|
|
504
|
+
`applyLocalColumnFilters`, `applyLocalSort`, `applyLocalWidgetFilters`,
|
|
505
|
+
`distinctColumnValues`, `buildWidgetFilterClauses`, `buildColumnFilterClauses`,
|
|
506
|
+
`buildSearchClause`, `buildRemoteSort`, `resolveTableMode`, `resolveFilterMode`.
|
|
507
|
+
|
|
508
|
+
---
|
|
509
|
+
|
|
510
|
+
## 8. Runtime: mock and real data
|
|
511
|
+
|
|
512
|
+
### Mock (no backend)
|
|
513
|
+
|
|
514
|
+
```tsx
|
|
515
|
+
<ZvolvProvider runtime={createMockRuntime({ delay: 400, rows: myRows, onQuery: console.log })}>
|
|
516
|
+
```
|
|
517
|
+
|
|
518
|
+
The mock applies the same filter clauses and sort that a real query would carry,
|
|
519
|
+
so search, filters and sort visibly work in previews. It does **not** aggregate
|
|
520
|
+
pivots: every widget receives the raw rows. The default rows (`SAMPLE_ROWS`)
|
|
521
|
+
have `region`, `owner`, `revenue`, `target`, `orders`, `status`, `startDate`,
|
|
522
|
+
`endDate`, `latitude`, `longitude` and `parentId`.
|
|
523
|
+
|
|
524
|
+
### Real data — built in
|
|
525
|
+
|
|
526
|
+
The query path (ZAI-Dashboard's `generateQuery`, filter formatters, logged-in-user
|
|
527
|
+
resolution, field mappings, user-name lookup) ships inside the package, ported
|
|
528
|
+
verbatim. Pass an initialised, logged-in SDK client:
|
|
529
|
+
|
|
530
|
+
```tsx
|
|
531
|
+
import { ZvolvClient } from "@zestl/zvolv-js-sdk";
|
|
532
|
+
import { ZvolvProvider, createZvolvRuntime } from "@zestl/zvolv-widgets";
|
|
533
|
+
|
|
534
|
+
const client = new ZvolvClient("app.zvolv.in", true);
|
|
535
|
+
await client.workspace.init("your-domain");
|
|
536
|
+
await client.auth.login(email, password); // or client.auth.init() to restore a session
|
|
537
|
+
|
|
538
|
+
const runtime = createZvolvRuntime({ client: () => client }); // a getter: swap clients freely
|
|
539
|
+
<ZvolvProvider runtime={runtime}>…</ZvolvProvider>
|
|
540
|
+
```
|
|
541
|
+
|
|
542
|
+
Call `resetQueryUser()` after login or logout, so the cached user group is
|
|
543
|
+
re-resolved. [`../zvolv-insights`](../zvolv-insights) is a complete app built
|
|
544
|
+
this way: login → apps → dashboards on live data.
|
|
545
|
+
|
|
546
|
+
Inside ZAI-Dashboard you can instead wire the host's own utilities into
|
|
547
|
+
`<ZvolvProvider runtime={…}>` (see the runtime keys below).
|
|
548
|
+
|
|
549
|
+
| Runtime key | Required | Used for |
|
|
550
|
+
| --- | --- | --- |
|
|
551
|
+
| `client` | yes | `workflow.elasticSearch`, `workflow.getDataSource` |
|
|
552
|
+
| `generateQuery` | yes, for form/task sources | building the ES query |
|
|
553
|
+
| `formatDashboardFilters` | recommended | `dashFilters` / `localFilters` → clauses |
|
|
554
|
+
| `formatHeaderFilters` | optional | column filters with id mapping. A built-in fallback exists |
|
|
555
|
+
| `prefetchQueryMappings`, `resolveQueryUser` | recommended | field mappings, logged-in-user filters |
|
|
556
|
+
| `resolveUserLabels`, `getUserFieldKind` | optional | user id → name on axes and in search |
|
|
557
|
+
|
|
558
|
+
`createZvolvRuntime` fills in all of them. Widget code is identical under the
|
|
559
|
+
mock and the real runtime.
|
|
560
|
+
|
|
561
|
+
### Rendering stored dashboards
|
|
562
|
+
|
|
563
|
+
`workflow.getDashboard(dashId, zappId).elements[0].widgets` is a list of stored
|
|
564
|
+
widgets. These helpers turn it into widgets, layout and filter state, using the
|
|
565
|
+
same rules ZAI-Dashboard applies:
|
|
566
|
+
|
|
567
|
+
| Helper | Does |
|
|
568
|
+
| --- | --- |
|
|
569
|
+
| `normalizeWidget(w)` | deep-parses `Configuration` / `DataSource` (double-encoded JSON), hoists legacy `isTask`/`formId` from `dataSource.query`. Returns `{ type, title, configurations, dataSource, … }` |
|
|
570
|
+
| `gridWidgets(ws)` | drops FILTER / DATE_FILTER / automation types and widgets nested in a TAB |
|
|
571
|
+
| `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 |
|
|
572
|
+
| `gridHeight(h)`, `GRID` | pixel height of a tile (`h × 5 + (h − 1) × 16`) |
|
|
573
|
+
| `dashboardFilterDefs(ws)` | the FILTER widget's declared fields, for a dashboard filter bar |
|
|
574
|
+
| `buildDashFilters(values, range)` | the `dashFilters` object every widget takes (`{ filter, filter_cond, cdate }`) |
|
|
575
|
+
| `dateRangeFor(token)`, `dashboardDefaultDateToken(ws)` | date presets, and the DATE_FILTER widget's default |
|
|
576
|
+
| `cdateFiltersFor(w, ws)` | the per-widget `cdateFilters` (the fields a date range lands on) |
|
|
577
|
+
| `buildDashboardTree(elements, isAdmin)` | `getDashboards()` → a sorted tree (`ChildReportIDs` nest), with hidden/mobile-only removed |
|
|
578
|
+
|
|
579
|
+
```tsx
|
|
580
|
+
const w = normalizeWidget(stored);
|
|
581
|
+
<ZvolvWidget type={w.type} title={w.title} dataSource={w.dataSource} configurations={w.configurations}
|
|
582
|
+
dashFilters={buildDashFilters(values, range)} cdateFilters={cdateFiltersFor(stored, all)} />
|
|
583
|
+
```
|
|
584
|
+
|
|
585
|
+
---
|
|
586
|
+
|
|
587
|
+
## 9. Install, link, test
|
|
588
|
+
|
|
589
|
+
Install from npm, together with the peer dependencies:
|
|
590
|
+
|
|
591
|
+
```bash
|
|
592
|
+
npm install @zestl/zvolv-widgets @zestl/zvolv-js-sdk react react-dom
|
|
593
|
+
```
|
|
594
|
+
|
|
595
|
+
To work against an unpublished change, link the library locally:
|
|
596
|
+
|
|
597
|
+
```bash
|
|
598
|
+
cd /path/to/zvolv-widgets
|
|
599
|
+
npm install
|
|
600
|
+
npm run build
|
|
601
|
+
npm link
|
|
602
|
+
|
|
603
|
+
cd /path/to/your-dashboard-app
|
|
604
|
+
npm link @zestl/zvolv-widgets
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
Your app must already have these peer dependencies: `react` ≥18, `react-dom` ≥18
|
|
608
|
+
and `@zestl/zvolv-js-sdk`. After changing library source, run `npm run build`
|
|
609
|
+
again. The link picks it up without a reinstall.
|
|
610
|
+
|
|
611
|
+
### Scripts
|
|
612
|
+
|
|
613
|
+
| Command | What it does |
|
|
614
|
+
| --- | --- |
|
|
615
|
+
| `npm run build` | ESM + CJS + types into `dist/` |
|
|
616
|
+
| `npm run dev` | rebuild on change |
|
|
617
|
+
| `npm run typecheck` | types only |
|
|
618
|
+
| `npm test` | build, then `test/interactions.mjs`, `test/runtime.mjs` and `test/smoke.mjs` |
|
|
619
|
+
| `npm run verify` | typecheck + tests + packaging checks (`test/verify.mjs`) |
|
|
620
|
+
|
|
621
|
+
| Suite | Proves |
|
|
622
|
+
| --- | --- |
|
|
623
|
+
| `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) |
|
|
624
|
+
| `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 |
|
|
625
|
+
| `test/smoke.mjs` | All 48 renderable type strings render without React errors, both plain and with search and widget filter on. `unconfigured` ≠ `empty` |
|
|
626
|
+
| `test/verify.mjs` | Build artifacts, `SKILL.md` and `README.md` exist, and the built module loads |
|
|
627
|
+
|
|
628
|
+
### Using with Claude
|
|
629
|
+
|
|
630
|
+
`SKILL.md` (shipped in the package) is the short, rule-first contract for LLM
|
|
631
|
+
agents. Point Claude at `node_modules/@zestl/zvolv-widgets/SKILL.md` before it
|
|
632
|
+
writes widget code. Without it, Claude tends to write its own data fetching.
|
|
633
|
+
|
|
634
|
+
---
|
|
635
|
+
|
|
636
|
+
## 10. Rules and known limitations
|
|
637
|
+
|
|
638
|
+
### Rules
|
|
639
|
+
|
|
640
|
+
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.
|
|
641
|
+
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.
|
|
642
|
+
3. Pass stored `dataSource` / `configurations` through untouched.
|
|
643
|
+
4. Widgets fill their container. Give them a sized parent.
|
|
644
|
+
5. No colour, font or shadow literals in templates or slots. Read `tokens` (or `useTokens()`).
|
|
645
|
+
6. A `ChildTableWidget` must be clamped to its parent's visible width.
|
|
646
|
+
|
|
647
|
+
### Known limitations
|
|
648
|
+
|
|
649
|
+
- **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.
|
|
650
|
+
- **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.
|
|
651
|
+
- **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.
|
|
652
|
+
- **Widget filters are AND-ed.** For OR logic, pass a host ZFilters payload through `localFilters`.
|
|
653
|
+
- **Calendar / MeetingScheduler are simplified.** There is no drag-to-resize, month-grid layout or virtual scrolling. For full parity, wrap FullCalendar in `render`.
|
|
654
|
+
|
|
655
|
+
---
|
|
656
|
+
|
|
657
|
+
## 11. Forms
|
|
658
|
+
|
|
659
|
+
Every ZAI-Dashboard form element is available as a headless engine. The
|
|
660
|
+
library owns the behaviour: loading the schema (v2 and legacy), values,
|
|
661
|
+
show/hide/mandatory rules, formulas, validation, lookups, uploads, field
|
|
662
|
+
automations, action buttons, child sheets and the exact submit wire format.
|
|
663
|
+
Your app owns every pixel.
|
|
664
|
+
|
|
665
|
+
### Rendering a form
|
|
666
|
+
|
|
667
|
+
```tsx
|
|
668
|
+
import { ZvolvForm, FormKitSlot } from "@zestl/zvolv-widgets";
|
|
669
|
+
|
|
670
|
+
// 1. Once, near the root: how each field kind looks in your app.
|
|
671
|
+
<FormKitSlot.Provider value={myKit}>…</FormKitSlot.Provider>
|
|
672
|
+
|
|
673
|
+
// 2. Anywhere: a form by id. A FORM / FORM_WIDGET dashboard widget does this for you.
|
|
674
|
+
<ZvolvForm formId="6ab5019b13f5e4aa859b1d28" onSubmitted={({ submissionId }) => …} />
|
|
675
|
+
<ZvolvForm formId={id} mode="EDIT" submissionId={subId} />
|
|
676
|
+
```
|
|
677
|
+
|
|
678
|
+
With no kit, plain native controls render so a form still works.
|
|
679
|
+
|
|
680
|
+
### The kit
|
|
681
|
+
|
|
682
|
+
```ts
|
|
683
|
+
interface FormKit {
|
|
684
|
+
field?: (field: FormField, form: ZvolvFormApi) => ReactNode; // one control per field
|
|
685
|
+
form?: (form, { body, title, submitLabel }) => ReactNode; // header / body / footer
|
|
686
|
+
status?: ({ status: "loading" | "error" | "submitted", form }) => ReactNode;
|
|
687
|
+
grid?: (cells, fields) => ReactNode; // 24-column span layout
|
|
688
|
+
}
|
|
689
|
+
```
|
|
690
|
+
|
|
691
|
+
Switch on `field.kind`. Each `FormField` carries `id, type, kind, label, placeholder, help,
|
|
692
|
+
required, disabled, hidden, span (of 24), value, error, options, multiple, min, max, step,
|
|
693
|
+
precision, prefix, suffix, accept, maxSizeKB, maxCount, childIds, pages, html, setValue, onBlur`.
|
|
694
|
+
`required` / `disabled` / `hidden` already include the form's rules.
|
|
695
|
+
|
|
696
|
+
| `kind` | Platform types | Behaviour hook |
|
|
697
|
+
| --- | --- | --- |
|
|
698
|
+
| `text` `textarea` `email` `phone` `url` `scan` | INPUT_SHORT_TEXT, EDIT_TEXT, INPUT_EMAIL, INPUT_PHONE, INPUT_URL, INPUT_SCAN | — |
|
|
699
|
+
| `number` `currency` `percent` | INPUT_NUMBER, INPUT_CURRENCY, INPUT_PERCENT | — |
|
|
700
|
+
| `select` `radio` | SPINNER, RADIO_GROUP | `useFieldOptions` |
|
|
701
|
+
| `lookup` | FORM_SEARCH, INPUT_TAG, EXTERNAL_API, form-backed AUTO_COMPLETE | `useFieldOptions` (search, cascades, ExtraLabels autofill) |
|
|
702
|
+
| `user` | AUTO_COMPLETE with jsonData (people / role search) | `useFieldOptions` |
|
|
703
|
+
| `checkbox` | CHECK_BOX (`attributes.showAsSwitch`) | — |
|
|
704
|
+
| `date` `time` `datetime` `daterange` | DATE_PICKER, TIME_PICKER, DATE_TIME_PICKER, INPUT_DATE_RANGE | — |
|
|
705
|
+
| `rating` `slider` | RATING_BAR / INPUT_RATE, PROGRESS_BAR | — |
|
|
706
|
+
| `file` | FILE_UPLOAD_V2, UPLOAD_FILE_IMAGE, MEDIA_UPLOAD | `useFileField` (validates, uploads, stores records) |
|
|
707
|
+
| `signature` `richtext` `timer` `location` | SIGNATURE, RICH_TEXT_EDITOR, INPUT_TIMER, GPS | — |
|
|
708
|
+
| `formula` `autonumber` `display` | INPUT_FORMULA, INPUT_AUTONUMBER / CREATED* / MODIFIED*, TEXT_VIEW / HTML_TEXT | computed by the engine |
|
|
709
|
+
| `section` `container` `tabs` `steps` `matrix` `table` | GROUP_VIEW subtypes, STEPPER, TAB, MATRIX, TABLE_START | `form.childrenOf(field)` + `<FormFields>` |
|
|
710
|
+
| `sheet` | INPUT_SHEET, NESTED_FORM, INPUT_CHILD_WIDGET | `useSheetField` (rows are posted before the parent) |
|
|
711
|
+
| `action` | ACTION_BUTTON, TRIGGER_AUTOMATION, CASCADED_ACTIONS | `form.runAction(field)` |
|
|
712
|
+
| `trials` | INPUT_PERCENT_CORRECT, INPUT_TIME_SAMPLING, INPUT_TASK_ANALYSIS (ABA trial widgets) | `useTrialField` (response / prompt buttons, trials, timer, steps, % correct, log) |
|
|
713
|
+
| `onbehalf` | ON_BEHALF_OF | read-only; `field.displayText` is the user's name |
|
|
714
|
+
| `comments` `system` | COMMENTS, ON_BEHALF_OF, INPUT_IP, INPUT_DEVICE_INFO | system fields are filled automatically and never shown |
|
|
715
|
+
|
|
716
|
+
**Dependent lookups.** A `FETCH_OPTIONS_FILTERS` rule sits on the lookup it filters, as in
|
|
717
|
+
zvolv-web and ZAI-Dashboard: `{ type: "FETCH_OPTIONS_FILTERS", elementId: <source field>, masterField: <label on the
|
|
718
|
+
lookup's source form> }`. `useFieldOptions` waits for the source (`waitingFor: "Select … first"`), filters by its
|
|
719
|
+
value, clears the selection when the source changes, and auto-selects when exactly one row matches — so
|
|
720
|
+
"pick a laptop → Asset ID and Model fill in" is two read-only lookups filtered on Serial Number. The older shape
|
|
721
|
+
(rule on a non-lookup driver, `elementId` = the lookup) is still read. `cascade(source, target, masterField)` writes
|
|
722
|
+
the platform shape.
|
|
723
|
+
|
|
724
|
+
**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.
|
|
725
|
+
|
|
726
|
+
**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.
|
|
727
|
+
|
|
728
|
+
**Field parity with ZAI-Dashboard / zvolv-web.**
|
|
729
|
+
|
|
730
|
+
- **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.
|
|
731
|
+
- **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.
|
|
732
|
+
- **INPUT_TAG** can store a value that isn't in the list: `o.addFreeTag(text)` stores `{ view, select, extraLabels: [] }`.
|
|
733
|
+
- **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 }`.
|
|
734
|
+
- **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.
|
|
735
|
+
- **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`.
|
|
736
|
+
- **CHECK_BOX** with `attributes.showAsSwitch` gets `field.variant === "switch"`. `"1"` is read as true.
|
|
737
|
+
- **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`).
|
|
738
|
+
- **RICH_TEXT_EDITOR**: markup with no text (`<p><br></p>`, ` `) counts as empty and is sent as `null`. `field.maxLength` also reads `attributes.charactersCount`.
|
|
739
|
+
- **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.
|
|
740
|
+
- **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.
|
|
741
|
+
- **Action buttons** (`form.runAction`) handle every actionType ZAI-Dashboard and zvolv-web do. `actionType` = `properties.actionType` ?? `attributes.subType` ?? `properties.subType` ?? `OPEN_URL`.
|
|
742
|
+
|
|
743
|
+
| actionType | What runs |
|
|
744
|
+
| --- | --- |
|
|
745
|
+
| `OPEN_URL` / `URL` | opens `properties.url`, the button's value, or `defaultValue` |
|
|
746
|
+
| `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 |
|
|
747
|
+
| `INVOKE_AGENT` | `invokeAgent({ content: { query } }, flowId, agentId)` with a label-keyed body; the reply's `formData` is written back |
|
|
748
|
+
| `DOWNLOAD_FILE` | downloads the button's value (a URL), `properties.fileName` |
|
|
749
|
+
| `PRINT_PREVIEW` | loads the PDF at the button's value and opens the print dialog |
|
|
750
|
+
| `UPLOAD_FILE` | file picker → upload (card upload when `cardID` is set) → stores the file URL in the button |
|
|
751
|
+
| `UPDATE`, `UPDATE_SUBMISSION`, `UPDATE_OPEN_NEW_FORM`, `EDIT_OPEN_NEW_FORM`, unknown | sets the button `true`, then saves |
|
|
752
|
+
| `SUBMIT` | saves the form |
|
|
753
|
+
| `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) }` |
|
|
754
|
+
| `CASCADED_ACTIONS` | runs `actionEleIds` in order, stops at the first failure, then saves when `submit` is true |
|
|
755
|
+
|
|
756
|
+
`successMessage`, `errorMessage` and `hideSuccessMessage` shape the returned message; `form.reload()` re-fetches the record.
|
|
757
|
+
|
|
758
|
+
**Show / hide / mandatory rules (triage).** These match ZAI-Dashboard's `applyDependencies`:
|
|
759
|
+
|
|
760
|
+
- Rules sit on the driving field. `properties.triage` replaces `dependencies` when present.
|
|
761
|
+
- 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.
|
|
762
|
+
- 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.
|
|
763
|
+
- Hiding a field keeps its value, as in ZAI-Dashboard and zvolv-web. Hidden fields are skipped by validation.
|
|
764
|
+
- `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.
|
|
765
|
+
|
|
766
|
+
`form.runAction` handles OPEN_URL, SAVE_SUBMISSION and automation triggers (writing
|
|
767
|
+
`formData` back into the form). Pass `onAction` to `<ZvolvForm>` for host actions such as
|
|
768
|
+
"open another form".
|
|
769
|
+
|
|
770
|
+
### Inspector (validation, rules, filters, formulas, payload)
|
|
771
|
+
|
|
772
|
+
`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:
|
|
773
|
+
|
|
774
|
+
| Key | What it holds |
|
|
775
|
+
| --- | --- |
|
|
776
|
+
| `rules` | each dependency / triage rule: source value, expected value, effect (show / hide / required / optional / editable / read-only), target, `matched` |
|
|
777
|
+
| `filters` | each lookup filter (FETCH_OPTIONS_FILTERS, DynamicFilters, StaticFilters) with the feeding value and `active` |
|
|
778
|
+
| `validation` | each input field and sheet cell: checks, current `error` from `validateField`, the error shown to the user |
|
|
779
|
+
| `formulas` | each form and sheet-row formula: inputs, `expected`, `current`, `inSync`, `serverSafe`, `serverFormula` |
|
|
780
|
+
| `defaults` | dynamic and fixed defaults and the value they produced |
|
|
781
|
+
| `payload` | the exact submission body — sheet rows (posted first) and the parent |
|
|
782
|
+
| `summary` | counts for tiles |
|
|
783
|
+
|
|
784
|
+
**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.
|
|
785
|
+
|
|
786
|
+
Sheet row formulas are recalculated by `useSheetField().updateRow` / `addRow` (`computeRowFormulas`), and again before rows are posted.
|
|
787
|
+
|
|
788
|
+
### Headless
|
|
789
|
+
|
|
790
|
+
`useZvolvForm({ formId, mode, submissionId, defaultValues, hiddenFields, skipTypes, onSubmitted })`
|
|
791
|
+
returns the same `ZvolvFormApi` without any markup. `setValue`, `validate(ids?)`, `submit()`,
|
|
792
|
+
`reset()`, `applyValues(map)`, `dirty`, `submitting`, `submitError`, `status`.
|
|
793
|
+
|
|
794
|
+
Pure helpers: `normaliseForm`, `serializeElementValue`, `buildSubmissionElements`,
|
|
795
|
+
`buildTriggerBody`, `validateField`, `applyRules`, `evaluateFormula` (a parser; there is no `eval`).
|
|
796
|
+
|
|
797
|
+
### Creating forms (for Claude and scripts)
|
|
798
|
+
|
|
799
|
+
`el`, `when`, `cascade`, `defineForm` and `formWidget` build payloads for the MCP
|
|
800
|
+
`create_form` / `create_widget` tools:
|
|
801
|
+
|
|
802
|
+
```ts
|
|
803
|
+
const sev = el.radio("Severity", ["Low", "High", "Critical"], { required: true });
|
|
804
|
+
const cause = el.longText("Root Cause Analysis");
|
|
805
|
+
when(sev, "High").show(cause).require(cause);
|
|
806
|
+
when(sev, "Critical").show(cause).require(cause);
|
|
807
|
+
const payload = defineForm({ title: "Incident Report", zappId: 11, zappName: "5S Audit", elements: [
|
|
808
|
+
el.lookup("Plant", { formId: PLANTS, field: "Plant", extra: ["Region"] }, { required: true }),
|
|
809
|
+
el.text("Region", { disabled: true, width: 25 }), // autofilled from the lookup
|
|
810
|
+
sev, cause,
|
|
811
|
+
el.number("Quantity"), el.currency("Rate"),
|
|
812
|
+
el.formula("Order Value", "MUL(Quantity, Rate)"),
|
|
813
|
+
]});
|
|
814
|
+
const widget = formWidget({ formId: created.id, title: "Report an incident", layout: { x: 0, y: 91, w: 60, h: 56 } });
|
|
815
|
+
```
|
|
816
|
+
|
|
817
|
+
Platform rules these helpers follow:
|
|
818
|
+
- `width` is a percentage (25, 50, 75 or 100). It goes at the element root and in `attributes.Width`.
|
|
819
|
+
- Show/hide/mandatory rules sit on the driving field. Give the target `hidden: true` and add a SHOW rule for each value.
|
|
820
|
+
- **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.
|
|
821
|
+
- 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.
|
|
822
|
+
- 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.
|