@zestl/zvolv-widgets 0.0.2 → 0.0.4

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/CHANGELOG.md CHANGED
@@ -1,5 +1,40 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.4
4
+
5
+ ### Added
6
+ - **`canViewDashboard(d, { isAdmin, groupId })`** — ZAI-Dashboard's dashboard view rule (admins all; `permissions` VIEW/EDIT_PERMISSION; `HideInMobileApp` needs EDIT_PERMISSION; `ViewPerm` fallback).
7
+ - **`visibleDashboardTree(elements, viewer)`** — `canViewDashboard` + `buildDashboardTree`, for an app's sidebar.
8
+ - README §12 and SKILL.md: apps on stored dashboards (pages, access, per-page loading, per-widget refresh) and writing records with `fetchSubmission` / `updateSubmission` / `createSubmission`.
9
+
10
+ ### Documented
11
+ - A TABLE widget with `configuration.columns` returns only `id` + those columns (`_source`); store every field a host reads or writes back as a column.
12
+
13
+ ## 0.0.3
14
+
15
+ These are all client-side enhancements that run after the query; none changes
16
+ what is queried. Existing configs give byte-identical queries, rows and default
17
+ markup, which `test/compat.mjs` checks against snapshots captured from 0.0.2.
18
+ There is nothing for ZAI-Dashboard or the server to mirror.
19
+
20
+ ### Added
21
+ - **KPI card layout.**
22
+ - `config.layout: "card"` (or `cardConfig.layout`) renders every item inside one card, through a `"KPI_CARD:card"` template override or the default `KpiCard`.
23
+ - `KpiWidget` takes `renderCard` (or `render`), which receives `{ vms, byKey, roles, state, tokens, config, onItemClick }`. Both are used only in card mode, so a generic `render` a host passes to every widget leaves KPIs unchanged.
24
+ - `KpiContentConfig.role` (`"main" | "sub" | "chip" | "series"`) places an item in the card; `resolveKpiRoles` does this sorting.
25
+ - `KpiSparkline` gains `width`, `height`, `area` and `scale: "minmax" | "zero"`.
26
+ - **`unpivot`.** On `useWidgetData` and every data widget. It turns row 0's columns into one `{ label, value }` row per column, in the given order. A missing column gives 0. `extra` copies more columns onto the output rows. It runs after postOperations. With no declared series, x and y become `labelField` and `valueField`. Pure helper: `unpivotRows`.
27
+ - **`ranking.order: "none"`.** `RankingBarWidget` keeps the incoming row order.
28
+ - **`FunnelWidget` (type `FUNNEL`).** Each stage resolves to `{ label, value, ratioToFirst, stepPct, isFirst, index, raw }`, with `onItemClick(row)` for drill-down. `render` gets `{ rows, first, tokens, config, toLabel, onItemClick }`. Pure helper: `computeFunnelRows`.
29
+ - **`categoryGap`.** On `useWidgetData` and every data widget. It gives one row per category in the given order, or in the form's dropdown order with `"fromForm"`. Missing buckets get 0 for every series valueField, and values that are not listed are kept at the end. It runs after postOperations and widget filters, and before local search, column filters and sort. Pure helpers: `fillCategoryGaps`, `elementOptionValues`.
30
+ - **`FilterPanelWidget` options from a data source.**
31
+ - `filters[].optionsSource = { dataSource, labelField, valueField, sort?, allLabel? }` fetches the options in one request: a distinct-values aggregation over the whole index, with the source's filters applied server-side (`optionsDataSource`).
32
+ - Values restored from URL params match numeric options (`matchFilterOption`).
33
+ - `onFiltersChange(WidgetFilter[])` emits `{ field, operator: "IN", value: [v] }`, or nothing for "All".
34
+ - The `renderFilter` slot receives `{ control, options, value, setValue, loading, tokens }`.
35
+ - Pure helpers: `filterPanelToWidgetFilters`, `rowsToFilterOptions`.
36
+ - **New tests:** `kpiCard`, `unpivot`, `funnel`, `categoryGap`, `filterOptions`. `compat` now also snapshots rows and default markup. A missing snapshot now fails the test instead of being regenerated.
37
+
3
38
  ## 0.0.2
4
39
 
5
40
  Every new feature is opt-in. Configs that existed before this release produce
package/MIRROR_NOTES.md CHANGED
@@ -144,3 +144,13 @@ item below is what the client expects. Verify each one against a live index.
144
144
  - `dateDiff()`.
145
145
  - `aggregate(f, type, name, { percents, where })`.
146
146
  - `pivot()`, `formSource()` and `taskSource()` move `where` conditions into `pivot.filters` with `combine: "AND"`.
147
+
148
+ ---
149
+
150
+ ## 0.0.3: nothing to mirror
151
+
152
+ `unpivot`, `categoryGap`, the KPI card layout, `FunnelWidget` and filter
153
+ `optionsSource` all run in the browser after the query. They add no query
154
+ shapes and need no server change. `categoryGap: "fromForm"` reads the form
155
+ through the same `getSingleForm` call the widget's access check already makes.
156
+
package/README.md CHANGED
@@ -36,6 +36,7 @@ visual decision can be overridden.
36
36
  9. [Install, link, test](#9-install-link-test)
37
37
  10. [Rules and known limitations](#10-rules-and-known-limitations)
38
38
  11. [Forms](#11-forms)
39
+ 12. [Apps on stored dashboards: pages, access, writes](#12-apps-on-stored-dashboards-pages-access-writes)
39
40
 
40
41
  ---
41
42
 
@@ -545,6 +546,117 @@ KPI card with 20 numbers on one form is one call. `pivot()`, `formSource()` and
545
546
  `combine: "AND"`. Stored `pivot.filters` entries without that marker keep the
546
547
  original rule: the last entry per metric wins.
547
548
 
549
+ ### Reshaping rows after the query (0.0.3)
550
+
551
+ These run in the browser after the query returns. They never change what is
552
+ queried.
553
+
554
+ **One wide row → one row per category (`unpivot`).** A multi-number source
555
+ returns one row such as `{ Longlist: 4, Contacted: 4, … }`. Turn it into one
556
+ row per column, in your order:
557
+
558
+ ```tsx
559
+ const STAGES = ["Longlist", "Contacted", "Interviewed", "Offered", "Placed"];
560
+
561
+ <RankingBarWidget
562
+ dataSource={FUNNEL_ONE}
563
+ unpivot={{ columns: STAGES, extra: { "Contacted %": "step" } }}
564
+ configurations={{ ranking: { order: "none" } }} // keep stage order; ratio = value / max
565
+ />
566
+ ```
567
+
568
+ - Output rows are `{ label, value }`. Rename the keys with `labelField` / `valueField`.
569
+ - A missing column gives 0.
570
+ - `unpivot` runs after `postOperations`, so a `derive()` column can be a stage.
571
+ - With no declared `series`, x and y become `labelField` and `valueField`.
572
+ - `extra` copies a source column onto the stage row whose name it starts with. `"Contacted %"` lands on the "Contacted" row as `step`. A source column that matches no stage is copied onto every row.
573
+
574
+ **All categories, in dropdown order (`categoryGap`).** ES returns a bucket only
575
+ for values that have data. `categoryGap` adds a zero row for every missing
576
+ category and orders the rows:
577
+
578
+ ```tsx
579
+ <BarChartWidget
580
+ dataSource={MINI_FUNNEL}
581
+ categoryGap={{ field: "Pipeline Stage", values: "fromForm" }} // or an explicit list
582
+ widgetFilters={[{ field: "Assignment", operator: "IN", value: [title] }]}
583
+ render={({ rows }) => <MiniBars rows={rows} />}
584
+ />
585
+ ```
586
+
587
+ - `field` is the chart's argumentField.
588
+ - `"fromForm"` reads that field's dropdown options from the form schema, once per form; it reuses the widget's form lookup.
589
+ - Every series `valueField` is 0 in the added rows.
590
+ - Values that are not in the list are kept, after the listed ones.
591
+ - It runs after `postOperations` and widget filters, and before local search, column filters and sort.
592
+
593
+ ### Funnel
594
+
595
+ ```tsx
596
+ <FunnelWidget
597
+ dataSource={FUNNEL_ONE}
598
+ unpivot={{ columns: STAGES }}
599
+ onItemClick={(row) => setStage(row.label)}
600
+ />
601
+ ```
602
+
603
+ Each stage resolves to `{ label, value, ratioToFirst, stepPct, isFirst, index, raw }`:
604
+ - `stepPct` is value ÷ previous stage × 100. It is `null` for the first stage, and after a stage of 0.
605
+ - `ratioToFirst` is value ÷ first stage. It is 0 when the first stage is 0.
606
+
607
+ Stages are never re-sorted. `render` gets `{ rows, first, tokens, config, toLabel, onItemClick }`.
608
+
609
+ ### One KPI card from several numbers
610
+
611
+ ```tsx
612
+ <KpiWidget dataSource={cardSource} configurations={{ layout: "card", contents: [
613
+ { dataPoint: "value", role: "main", title: "Open mandates", template: "chart", seriesPoints: P1toP12 },
614
+ { dataPoint: "total", role: "sub", title: "total" },
615
+ { dataPoint: "atRisk", role: "sub", title: "at risk" },
616
+ { dataPoint: "P12", role: "chip", template: "trend", deltaSource: "datapoint", deltaPoint: "P8",
617
+ deltaMode: "absolute", subtitle: "vs 4 wk" },
618
+ ]}} />
619
+ ```
620
+
621
+ The default card puts the items in slots by `role`:
622
+ - `main` is the big value. Without a `main` role, it is the first item with no role.
623
+ - `sub` items are small inline values under it.
624
+ - `chip` is the change badge (its `delta`).
625
+ - `series` supplies the sparkline. Without it, the main item's series is used.
626
+
627
+ Card mode turns on only with `layout: "card"`, set either at the config root
628
+ or in `cardConfig`, where platform KPI payloads keep their items. To build the
629
+ card yourself, pass `renderCard={({ vms, byKey, roles, state, tokens }) => …}`;
630
+ `render` also works, but likewise only in card mode, so a `render` a host
631
+ passes to every widget never changes a KPI. `byKey` maps each `dataPoint` to
632
+ its resolved item; the first item wins when two share one. You can also
633
+ register a `"KPI_CARD:card"` template. `KpiSparkline` accepts
634
+ `width`, `height`, `area` (fill under the line) and `scale: "minmax" | "zero"`.
635
+
636
+ ### Filter options from another form
637
+
638
+ ```tsx
639
+ <FilterPanelWidget
640
+ configurations={{ filters: [{
641
+ key: "scope", field: "Assignment", type: "select",
642
+ optionsSource: {
643
+ dataSource: OPEN_MANDATES, // e.g. a formSource on Assignment with Status IN Open
644
+ labelField: "Assignment Title", valueField: "Assignment Title",
645
+ sort: "asc", allLabel: "Firm-wide",
646
+ },
647
+ }] }}
648
+ onFiltersChange={(filters) => setScopeFilters(filters)} // → other widgets' widgetFilters
649
+ renderFilter={({ options, value, setValue, loading }) => <MySelect … />}
650
+ />
651
+ ```
652
+
653
+ - **Fetch:** the options come from one request: a distinct-values (composite terms) aggregation over the **whole index**, with the source's own filters applied by Elasticsearch. It is not built from a page of rows. The cap is the composite size (50,000 distinct values, or `displayRowLimit`). Declare `labelField` and `valueField` in the source's `fields` so text fields aggregate on `.keyword`. A source that already has a `pivot` is used as is. The panel refetches only when `optionsSource.dataSource` changes.
654
+ - **Where the filter runs:** the emitted filter is applied in the target widget's ES query, so over the whole index. Only automation-row and legacy widget-id sources, which have no query, filter their loaded rows.
655
+ - **URL values:** values restored from URL params (strings) match numeric options, and the emitted filter carries the option's own typed value.
656
+ - **Output:** choosing a value emits `[{ field, operator: "IN", value: [v] }]`; "All" emits `[]`.
657
+ - **Lookup targets:** when `field` is a FORM_SEARCH lookup on the target form, the filter matches the lookup's `.view` value. So `valueField` must be the field named as the lookup's **ViewLabel**.
658
+ - **Without a slot:** the panel renders a plain `<select>` for these controls.
659
+
548
660
  ---
549
661
 
550
662
  ## 5. Controlling the UI
@@ -600,9 +712,10 @@ Never re-derive values from `vm.raw`.
600
712
 
601
713
  | Family | Components | Type strings | `render` receives |
602
714
  | --- | --- | --- | --- |
603
- | KPI | `KpiWidget` | `KPI` `KPI_CARD` `KPI_CARD_V2` | templates get `{ vm, index }` |
715
+ | KPI | `KpiWidget` | `KPI` `KPI_CARD` `KPI_CARD_V2` | templates get `{ vm, index }`; `render` / `"KPI_CARD:card"` get `{ vms, byKey, roles, state, tokens, config }` |
604
716
  | 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 }` |
605
- | Ranking | `RankingBarWidget` | `RANKING_BAR` | `{ rows: {rank,label,value,ratio,raw}[], max, … }` |
717
+ | Ranking | `RankingBarWidget` | `RANKING_BAR` | `{ rows: {rank,label,value,ratio,raw}[], max, … }` (`ranking.order: "none"` keeps row order) |
718
+ | Funnel | `FunnelWidget` | `FUNNEL` | `{ rows: {label,value,ratioToFirst,stepPct,isFirst,index,raw}[], first, tokens, config, toLabel, onItemClick }` |
606
719
  | Gauge | `GaugeMeterWidget` | `GAUGE_METER` | `{ value, min, max, ratio, color, band, thresholds, rows }` |
607
720
  | Heat map | `HeatMapWidget` | `HEAT_MAP` | `{ cells: {zone,count,color,band}[], grid }` |
608
721
  | 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) |
@@ -621,9 +734,10 @@ and HTML sanitization stay in the host app. The package never calls
621
734
  `dangerouslySetInnerHTML`.
622
735
 
623
736
  **`FilterPanelWidget` vs widget filters.** `FilterPanelWidget` is a
624
- *dashboard-level* control. It is controlled (`value` / `onChange`) and issues no
625
- queries. Feed its values into other widgets' `dashFilters`. A widget filter
626
- (§3) narrows only its own widget.
737
+ *dashboard-level* control. It is controlled (`value` / `onChange`), and it
738
+ issues no queries except one per `optionsSource`. Feed its values into other
739
+ widgets' `dashFilters`, or pass `onFiltersChange` output to their
740
+ `widgetFilters`. A widget filter (§3) narrows only its own widget.
627
741
 
628
742
  ---
629
743
 
@@ -726,6 +840,8 @@ same rules ZAI-Dashboard applies:
726
840
  | `dateRangeFor(token)`, `dashboardDefaultDateToken(ws)` | date presets, and the DATE_FILTER widget's default |
727
841
  | `cdateFiltersFor(w, ws)` | the per-widget `cdateFilters` (the fields a date range lands on) |
728
842
  | `buildDashboardTree(elements, isAdmin)` | `getDashboards()` → a sorted tree (`ChildReportIDs` nest), with hidden/mobile-only removed |
843
+ | `canViewDashboard(d, { isAdmin, groupId })` | ZAI-Dashboard's view rule: admins see all; otherwise `d.permissions` must hold `VIEW_PERMISSION` / `EDIT_PERMISSION` (`HideInMobileApp` needs `EDIT_PERMISSION`); without `permissions`, `ViewPerm` (group ids, `-1` = everyone) is checked |
844
+ | `visibleDashboardTree(elements, viewer)` | `canViewDashboard` filter + `buildDashboardTree`: the sidebar of an app |
729
845
 
730
846
  ```tsx
731
847
  const w = normalizeWidget(stored);
@@ -800,6 +916,7 @@ writes widget code. Without it, Claude tends to write its own data fetching.
800
916
  - **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.
801
917
  - **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.
802
918
  - **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.
919
+ - **A TABLE widget returns only its columns.** When `configuration.columns` is non-empty, the query asks Elasticsearch for `_source: ["id", ...columns.map(c => c.dataPoint)]` (`["uuid", …]` for tasks). A field that is not a column is not in the rows, even if it is in `dataSource.fields`. Store every field a host reads or writes back as a column (`hidden: true` hides it from the default table), or leave `columns` empty to get every field.
803
920
  - **Widget filters are AND-ed.** For OR logic, pass a host ZFilters payload through `localFilters`.
804
921
  - **0.0.2 query features need server and host support.** `interval` grouping, `dateDiff` metrics, percentiles and AND-ed per-metric filters produce ES shapes that the server's aggregation flattener and ZAI-Dashboard must also handle. See `MIRROR_NOTES.md`.
805
922
  - **Calendar / MeetingScheduler are simplified.** There is no drag-to-resize, month-grid layout or virtual scrolling. For full parity, wrap FullCalendar in `render`.
@@ -972,3 +1089,64 @@ Platform rules these helpers follow:
972
1089
  - **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.
973
1090
  - 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.
974
1091
  - 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.
1092
+
1093
+ ---
1094
+
1095
+ ## 12. Apps on stored dashboards: pages, access, writes
1096
+
1097
+ A custom app (e.g. a zapp app at `/v6/<app>/zapp/<id>`) can use the zapp's dashboards as its pages.
1098
+ The pattern below is what the `zvolv-zapp-app` skill's template does.
1099
+
1100
+ ### Pages = the dashboards the user may open
1101
+
1102
+ ```ts
1103
+ const res = await client.workflow.getDashboards(String(zappId));
1104
+ const elements = res?.data?.elements ?? res?.elements ?? [];
1105
+ const tree = visibleDashboardTree(elements, { isAdmin, groupId }); // sidebar, in SequenceID order, titles from Zvolv
1106
+ ```
1107
+
1108
+ - Tag each dashboard's Description (e.g. `app:screen:orders`) to pick the screen that draws it; draw an
1109
+ untagged one generically with `normalizeWidget` + `resolveDashboardLayout` + `<ZvolvWidget>`.
1110
+ - Edit rights on the zapp: `client.workflow.checkPermissions(zappId, ["EDIT_WORKFLOW_TYPE"])` returns the
1111
+ granted ops (ZAI-Dashboard's `hasDashboardEditAccess`).
1112
+ - `isAdmin`: the signed-in user's `profile.role_details.role === "admin"` (decrypt the session first:
1113
+ `decryptAuthObject(client.auth.userInstance)`). `groupId`: `userGroupId` on the same object.
1114
+
1115
+ ### Load per page, refresh per widget
1116
+
1117
+ On each visit to a page, call `getDashboard(dashId, zappId)` once and run only that page's widgets
1118
+ (`useWidgetData({ widgetId, widgetType, dataSource, configurations })` per widget, from
1119
+ `normalizeWidget`). Nothing is fetched for pages that are not open. A card's refresh button calls that
1120
+ widget's `refresh()`; nothing else refetches.
1121
+
1122
+ ### Writing records
1123
+
1124
+ The submission engine `<ZvolvForm>` uses is exported for in-place edits (a status select, a drag to
1125
+ another stage, an owner pick):
1126
+
1127
+ ```ts
1128
+ import { fetchForm, fetchSubmission, updateSubmission, createSubmission, submissionIdOf } from "@zestl/zvolv-widgets";
1129
+
1130
+ // update a few fields of one record, keeping every other stored value
1131
+ const { form, values } = await fetchSubmission(client, row.id); // row.id = the submission id
1132
+ const def = form ?? (await fetchForm(client, formId));
1133
+ const byId = { ...values };
1134
+ for (const [label, v] of Object.entries(patch)) byId[def.elements.find((e) => e.label === label)!.elementId] = v;
1135
+ await updateSubmission(client, def, row.id, def.elements, byId);
1136
+
1137
+ // create one
1138
+ const res = await createSubmission(client, def, def.elements, { [elementIdOf("Status")]: "Open" });
1139
+ const id = submissionIdOf(res);
1140
+ ```
1141
+
1142
+ | Field type | Value |
1143
+ | --- | --- |
1144
+ | text, number, date (`YYYY-MM-DD`), SPINNER | as is (the option text for SPINNER) |
1145
+ | CHECK_BOX | `true` / `false` |
1146
+ | AUTO_COMPLETE people picker | `[userId]` |
1147
+ | FORM_SEARCH / INPUT_TAG lookup | `{ view, select, extraLabels: [] }` |
1148
+
1149
+ - A row's identity is `row.id` (the submission id). Autonumber fields come back only if they are columns
1150
+ (see §10), so never key records on them.
1151
+ - After a write, refetch the widgets that read that form (`refresh()`), rather than patching rows locally.
1152
+ - The server re-evaluates formulas and enforces form permissions; show its error when a write is refused.
package/SKILL.md CHANGED
@@ -18,6 +18,8 @@ design tokens, render props and control slots. You never write data logic.
18
18
  6. **No colour/font/shadow literals** in templates or slots. Use `tokens` / `useTokens()`.
19
19
  7. Pass stored `dataSource` / `configurations` through **untouched**. Give widgets a sized parent.
20
20
  8. Never call `getZvolvClient()` at module scope. The runtime's `client` is a getter.
21
+ 9. **A TABLE returns only its columns.** Non-empty `configuration.columns` limits the query to `["id", ...columns]`. Store every field you read or write back as a column (`hidden: true` to hide it), or leave `columns` empty.
22
+ 10. **Rows are keyed by `row.id`** (the submission id). Edits go through `fetchSubmission` + `updateSubmission` (or `<ZvolvForm mode="EDIT">`), creates through `createSubmission` / `<ZvolvForm>`. Never keep edited records only in app state.
21
23
 
22
24
  ## Setup
23
25
 
@@ -123,6 +125,7 @@ For `getDashboard(dashId, zappId).elements[0].widgets`, never parse or lay out b
123
125
  `dashboardFilterDefs(ws)` + `buildDashFilters(values, range)` → `dashFilters` ·
124
126
  `cdateFiltersFor(w, ws)` → `cdateFilters` · `dateRangeFor(token)` · `buildDashboardTree(getDashboards().elements, isAdmin)`.
125
127
  Stored table columns are `{ label, dataPoint, type }` and are read as-is.
128
+ Sidebar of an app: `visibleDashboardTree(getDashboards().elements, { isAdmin, groupId })` — the access rule is `canViewDashboard` (admins all; else `permissions` has VIEW/EDIT_PERMISSION; else `ViewPerm` holds the group). Load a dashboard's widgets when its page opens, not before; a card refresh = that widget's `refresh()`. Zapp edit right: `workflow.checkPermissions(zappId, ["EDIT_WORKFLOW_TYPE"])`. Full pattern: README §12.
126
129
 
127
130
  ## Authoring a data source
128
131
 
@@ -191,9 +194,10 @@ KPI templates: `standard` `trend` `comparison` `progress` `financial` `status` `
191
194
 
192
195
  | Family | Components | `render` args |
193
196
  | --- | --- | --- |
194
- | KPI | `KpiWidget` | templates get `{ vm, index }`. `vm` is fully resolved; never read `vm.raw` |
197
+ | KPI | `KpiWidget` | templates get `{ vm, index }`. `vm` is fully resolved; never read `vm.raw`. Card: `layout: "card"` (root or `cardConfig`) + `contents[].role` (`main`/`sub`/`chip`/`series`); custom card via `renderCard` (only in card mode) |
195
198
  | Charts | `BarChartWidget` `LineChartWidget` `AreaChartWidget` `PieChartWidget` `DoughnutChartWidget` `ScatterChartWidget` | `{ rows, xKey, series, colorFor, toLabel, template, config, tokens, meta }` |
196
- | Ranking | `RankingBarWidget` | `{ rows: {rank,label,value,ratio}[], max }` |
199
+ | Ranking | `RankingBarWidget` | `{ rows: {rank,label,value,ratio}[], max }`; `ranking.order: "none"` keeps row order |
200
+ | Funnel | `FunnelWidget` (`FUNNEL`) | `{ rows: {label,value,ratioToFirst,stepPct,isFirst}[], first, tokens }`; `onItemClick(row)` |
197
201
  | Gauge | `GaugeMeterWidget` | `{ value, min, max, ratio, color, band }` |
198
202
  | Heat map | `HeatMapWidget` | `{ cells: {zone,count,color,band}[], grid }` |
199
203
  | Tables | `DashboardTableWidget` `DatabaseTableWidget` `TaskTrackerTableWidget` `ChildTableWidget` `PivotGridWidget` | `{ rows, columns, renderCell, toLabel, interactions, headerControls(col), getColumnOptions(key), enableSort, showColumnFilter, mode }` |
@@ -203,6 +207,17 @@ KPI templates: `standard` `trend` `comparison` `progress` `financial` `status` `
203
207
  | Display | `HtmlWidget` `RawHtmlWidget` `IframeWidget` `GoogleMapWidget` `TreeWidget` `ActionCardWidget` | see types |
204
208
  | Containers | `TabWidget` `FormWidget` `FilterPanelWidget` `ZaiChatWidget` | see types |
205
209
 
210
+ Reshaping after the query (0.0.3). Use these instead of re-shaping rows in React:
211
+
212
+ ```tsx
213
+ unpivot={{ columns: STAGES, extra: { "Contacted %": "step" } }} // one wide row → { label, value } per column, in order; missing → 0
214
+ categoryGap={{ field: "Pipeline Stage", values: "fromForm" }} // every category in dropdown order, 0 where no bucket
215
+ <FunnelWidget dataSource={ONE_ROW} unpivot={{ columns: STAGES }} onItemClick={(r) => …} />
216
+ <FilterPanelWidget configurations={{ filters: [{ key, field: "Assignment", type: "select",
217
+ optionsSource: { dataSource, labelField, valueField, allLabel: "Firm-wide" } }] }}
218
+ onFiltersChange={(f) => setWidgetFilters(f)} /> // valueField = the lookup's ViewLabel field for FORM_SEARCH targets
219
+ ```
220
+
206
221
  **`render` is REQUIRED** for `ZaiChatWidget`, `GoogleMapWidget` and `HtmlWidget` markup. `FormWidget` renders `<ZvolvForm>` by default (see Forms). The package never calls `dangerouslySetInnerHTML`.
207
222
 
208
223
  In chart `render`, always use `xKey` and `series[].valueField` from the args, pass axis and user values through `toLabel`, and colour with `colorFor(i, s)`.
@@ -257,6 +272,10 @@ When asked to build a dashboard that captures data:
257
272
  - `width` is a percentage, placed at the element root and in `attributes.Width`.
258
273
  - Rules go on the driving field; each target starts `hidden: true`.
259
274
 
275
+ ## Writing records
276
+
277
+ `fetchSubmission(client, row.id)` → `updateSubmission(client, form, row.id, form.elements, valuesByElementId)` keeps every other stored value. `createSubmission(client, form, form.elements, values)` + `submissionIdOf(res)` for a create. Values: people `[userId]`, lookups `{ view, select, extraLabels: [] }`, checkbox `true/false`, dates `YYYY-MM-DD`. Refetch the widgets of that form after a write. See README §12.
278
+
260
279
  ## Checklist before you finish
261
280
 
262
281
  - [ ] Every plotted, grouped, filtered, searched or sorted name exists as a `dataSource.fields` header, and series names equal the pivot titles/values.
@@ -264,3 +283,4 @@ When asked to build a dashboard that captures data:
264
283
  - [ ] Controlled interaction props each have their `on*Change`.
265
284
  - [ ] No hard-coded colours. `render` is supplied for ZaiChat, GoogleMap and Html markup, and a FormKit is provided when forms are shown.
266
285
  - [ ] Each widget sits in a sized container, and `ChildTableWidget` is width-clamped.
286
+ - [ ] Every change a user makes is written with `ZvolvForm` / `updateSubmission` / `createSubmission`; no records live only in app state or browser storage.