@zestl/zvolv-widgets 0.0.0-stage → 0.0.0

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 CHANGED
@@ -1,3 +1,782 @@
1
- # Temporary Holding Version
1
+ # @zestl/zvolv-widgets
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
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
+ | `comments` `system` | COMMENTS, ON_BEHALF_OF, INPUT_IP, INPUT_DEVICE_INFO | system fields are filled automatically and never shown |
713
+
714
+ **Dependent lookups.** A `FETCH_OPTIONS_FILTERS` rule sits on the lookup it filters, as in
715
+ zvolv-web and ZAI-Dashboard: `{ type: "FETCH_OPTIONS_FILTERS", elementId: <source field>, masterField: <label on the
716
+ lookup's source form> }`. `useFieldOptions` waits for the source (`waitingFor: "Select … first"`), filters by its
717
+ value, clears the selection when the source changes, and auto-selects when exactly one row matches — so
718
+ "pick a laptop → Asset ID and Model fill in" is two read-only lookups filtered on Serial Number. The older shape
719
+ (rule on a non-lookup driver, `elementId` = the lookup) is still read. `cascade(source, target, masterField)` writes
720
+ the platform shape.
721
+
722
+ **Field parity with ZAI-Dashboard / zvolv-web.**
723
+
724
+ - **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.
725
+ - **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.
726
+ - **INPUT_TAG** can store a value that isn't in the list: `o.addFreeTag(text)` stores `{ view, select, extraLabels: [] }`.
727
+ - **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 }`.
728
+ - **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`.
729
+ - **CHECK_BOX** with `attributes.showAsSwitch` gets `field.variant === "switch"`. `"1"` is read as true.
730
+ - **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`).
731
+ - **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`.
732
+ - **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.
733
+ - **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.
734
+ - **Action buttons** (`form.runAction`): `actionType` comes from `properties.actionType` or `subType`. `OPEN_URL` (default; falls back to `defaultValue`), `SUBMIT`, `TRIGGER_AUTOMATION` (also treats `code` 400/500 as an error), and `UPDATE` / `UPDATE_SUBMISSION` / `UPDATE_OPEN_NEW_FORM` / unknown types (set the button `true`, then save). `SAVE_SUBMISSION`, `CREATE_SUBMISSION`, `EDIT_SUBMISSION`, `OPEN_TASK`, `PRINT_PREVIEW`, `INVOKE_AGENT` and the like open other UI, so they go to your `onAction`; the library no longer submits the current form for `SAVE_SUBMISSION`.
735
+
736
+ **Show / hide / mandatory rules (triage).** These match ZAI-Dashboard's `applyDependencies`:
737
+
738
+ - Rules sit on the driving field. `properties.triage` replaces `dependencies` when present.
739
+ - 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.
740
+ - 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.
741
+ - Hiding a field keeps its value, as in ZAI-Dashboard and zvolv-web. Hidden fields are skipped by validation.
742
+ - `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.
743
+
744
+ `form.runAction` handles OPEN_URL, SAVE_SUBMISSION and automation triggers (writing
745
+ `formData` back into the form). Pass `onAction` to `<ZvolvForm>` for host actions such as
746
+ "open another form".
747
+
748
+ ### Headless
749
+
750
+ `useZvolvForm({ formId, mode, submissionId, defaultValues, hiddenFields, skipTypes, onSubmitted })`
751
+ returns the same `ZvolvFormApi` without any markup. `setValue`, `validate(ids?)`, `submit()`,
752
+ `reset()`, `applyValues(map)`, `dirty`, `submitting`, `submitError`, `status`.
753
+
754
+ Pure helpers: `normaliseForm`, `serializeElementValue`, `buildSubmissionElements`,
755
+ `buildTriggerBody`, `validateField`, `applyRules`, `evaluateFormula` (a parser; there is no `eval`).
756
+
757
+ ### Creating forms (for Claude and scripts)
758
+
759
+ `el`, `when`, `cascade`, `defineForm` and `formWidget` build payloads for the MCP
760
+ `create_form` / `create_widget` tools:
761
+
762
+ ```ts
763
+ const sev = el.radio("Severity", ["Low", "High", "Critical"], { required: true });
764
+ const cause = el.longText("Root Cause Analysis");
765
+ when(sev, "High").show(cause).require(cause);
766
+ when(sev, "Critical").show(cause).require(cause);
767
+ const payload = defineForm({ title: "Incident Report", zappId: 11, zappName: "5S Audit", elements: [
768
+ el.lookup("Plant", { formId: PLANTS, field: "Plant", extra: ["Region"] }, { required: true }),
769
+ el.text("Region", { disabled: true, width: 25 }), // autofilled from the lookup
770
+ sev, cause,
771
+ el.number("Quantity"), el.currency("Rate"),
772
+ el.formula("Order Value", "MUL(Quantity, Rate)"),
773
+ ]});
774
+ const widget = formWidget({ formId: created.id, title: "Report an incident", layout: { x: 0, y: 91, w: 60, h: 56 } });
775
+ ```
776
+
777
+ Platform rules these helpers follow:
778
+ - `width` is a percentage (25, 50, 75 or 100). It goes at the element root and in `attributes.Width`.
779
+ - Show/hide/mandatory rules sit on the driving field. Give the target `hidden: true` and add a SHOW rule for each value.
780
+ - **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.
781
+ - 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.
782
+ - 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.