@zestl/zvolv-widgets 0.0.2 → 0.0.3

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,30 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.0.3
4
+
5
+ These are all client-side enhancements that run after the query; none changes
6
+ what is queried. Existing configs give byte-identical queries, rows and default
7
+ markup, which `test/compat.mjs` checks against snapshots captured from 0.0.2.
8
+ There is nothing for ZAI-Dashboard or the server to mirror.
9
+
10
+ ### Added
11
+ - **KPI card layout.**
12
+ - `config.layout: "card"` (or `cardConfig.layout`) renders every item inside one card, through a `"KPI_CARD:card"` template override or the default `KpiCard`.
13
+ - `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.
14
+ - `KpiContentConfig.role` (`"main" | "sub" | "chip" | "series"`) places an item in the card; `resolveKpiRoles` does this sorting.
15
+ - `KpiSparkline` gains `width`, `height`, `area` and `scale: "minmax" | "zero"`.
16
+ - **`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`.
17
+ - **`ranking.order: "none"`.** `RankingBarWidget` keeps the incoming row order.
18
+ - **`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`.
19
+ - **`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`.
20
+ - **`FilterPanelWidget` options from a data source.**
21
+ - `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`).
22
+ - Values restored from URL params match numeric options (`matchFilterOption`).
23
+ - `onFiltersChange(WidgetFilter[])` emits `{ field, operator: "IN", value: [v] }`, or nothing for "All".
24
+ - The `renderFilter` slot receives `{ control, options, value, setValue, loading, tokens }`.
25
+ - Pure helpers: `filterPanelToWidgetFilters`, `rowsToFilterOptions`.
26
+ - **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.
27
+
3
28
  ## 0.0.2
4
29
 
5
30
  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
@@ -545,6 +545,117 @@ KPI card with 20 numbers on one form is one call. `pivot()`, `formSource()` and
545
545
  `combine: "AND"`. Stored `pivot.filters` entries without that marker keep the
546
546
  original rule: the last entry per metric wins.
547
547
 
548
+ ### Reshaping rows after the query (0.0.3)
549
+
550
+ These run in the browser after the query returns. They never change what is
551
+ queried.
552
+
553
+ **One wide row → one row per category (`unpivot`).** A multi-number source
554
+ returns one row such as `{ Longlist: 4, Contacted: 4, … }`. Turn it into one
555
+ row per column, in your order:
556
+
557
+ ```tsx
558
+ const STAGES = ["Longlist", "Contacted", "Interviewed", "Offered", "Placed"];
559
+
560
+ <RankingBarWidget
561
+ dataSource={FUNNEL_ONE}
562
+ unpivot={{ columns: STAGES, extra: { "Contacted %": "step" } }}
563
+ configurations={{ ranking: { order: "none" } }} // keep stage order; ratio = value / max
564
+ />
565
+ ```
566
+
567
+ - Output rows are `{ label, value }`. Rename the keys with `labelField` / `valueField`.
568
+ - A missing column gives 0.
569
+ - `unpivot` runs after `postOperations`, so a `derive()` column can be a stage.
570
+ - With no declared `series`, x and y become `labelField` and `valueField`.
571
+ - `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.
572
+
573
+ **All categories, in dropdown order (`categoryGap`).** ES returns a bucket only
574
+ for values that have data. `categoryGap` adds a zero row for every missing
575
+ category and orders the rows:
576
+
577
+ ```tsx
578
+ <BarChartWidget
579
+ dataSource={MINI_FUNNEL}
580
+ categoryGap={{ field: "Pipeline Stage", values: "fromForm" }} // or an explicit list
581
+ widgetFilters={[{ field: "Assignment", operator: "IN", value: [title] }]}
582
+ render={({ rows }) => <MiniBars rows={rows} />}
583
+ />
584
+ ```
585
+
586
+ - `field` is the chart's argumentField.
587
+ - `"fromForm"` reads that field's dropdown options from the form schema, once per form; it reuses the widget's form lookup.
588
+ - Every series `valueField` is 0 in the added rows.
589
+ - Values that are not in the list are kept, after the listed ones.
590
+ - It runs after `postOperations` and widget filters, and before local search, column filters and sort.
591
+
592
+ ### Funnel
593
+
594
+ ```tsx
595
+ <FunnelWidget
596
+ dataSource={FUNNEL_ONE}
597
+ unpivot={{ columns: STAGES }}
598
+ onItemClick={(row) => setStage(row.label)}
599
+ />
600
+ ```
601
+
602
+ Each stage resolves to `{ label, value, ratioToFirst, stepPct, isFirst, index, raw }`:
603
+ - `stepPct` is value ÷ previous stage × 100. It is `null` for the first stage, and after a stage of 0.
604
+ - `ratioToFirst` is value ÷ first stage. It is 0 when the first stage is 0.
605
+
606
+ Stages are never re-sorted. `render` gets `{ rows, first, tokens, config, toLabel, onItemClick }`.
607
+
608
+ ### One KPI card from several numbers
609
+
610
+ ```tsx
611
+ <KpiWidget dataSource={cardSource} configurations={{ layout: "card", contents: [
612
+ { dataPoint: "value", role: "main", title: "Open mandates", template: "chart", seriesPoints: P1toP12 },
613
+ { dataPoint: "total", role: "sub", title: "total" },
614
+ { dataPoint: "atRisk", role: "sub", title: "at risk" },
615
+ { dataPoint: "P12", role: "chip", template: "trend", deltaSource: "datapoint", deltaPoint: "P8",
616
+ deltaMode: "absolute", subtitle: "vs 4 wk" },
617
+ ]}} />
618
+ ```
619
+
620
+ The default card puts the items in slots by `role`:
621
+ - `main` is the big value. Without a `main` role, it is the first item with no role.
622
+ - `sub` items are small inline values under it.
623
+ - `chip` is the change badge (its `delta`).
624
+ - `series` supplies the sparkline. Without it, the main item's series is used.
625
+
626
+ Card mode turns on only with `layout: "card"`, set either at the config root
627
+ or in `cardConfig`, where platform KPI payloads keep their items. To build the
628
+ card yourself, pass `renderCard={({ vms, byKey, roles, state, tokens }) => …}`;
629
+ `render` also works, but likewise only in card mode, so a `render` a host
630
+ passes to every widget never changes a KPI. `byKey` maps each `dataPoint` to
631
+ its resolved item; the first item wins when two share one. You can also
632
+ register a `"KPI_CARD:card"` template. `KpiSparkline` accepts
633
+ `width`, `height`, `area` (fill under the line) and `scale: "minmax" | "zero"`.
634
+
635
+ ### Filter options from another form
636
+
637
+ ```tsx
638
+ <FilterPanelWidget
639
+ configurations={{ filters: [{
640
+ key: "scope", field: "Assignment", type: "select",
641
+ optionsSource: {
642
+ dataSource: OPEN_MANDATES, // e.g. a formSource on Assignment with Status IN Open
643
+ labelField: "Assignment Title", valueField: "Assignment Title",
644
+ sort: "asc", allLabel: "Firm-wide",
645
+ },
646
+ }] }}
647
+ onFiltersChange={(filters) => setScopeFilters(filters)} // → other widgets' widgetFilters
648
+ renderFilter={({ options, value, setValue, loading }) => <MySelect … />}
649
+ />
650
+ ```
651
+
652
+ - **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.
653
+ - **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.
654
+ - **URL values:** values restored from URL params (strings) match numeric options, and the emitted filter carries the option's own typed value.
655
+ - **Output:** choosing a value emits `[{ field, operator: "IN", value: [v] }]`; "All" emits `[]`.
656
+ - **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**.
657
+ - **Without a slot:** the panel renders a plain `<select>` for these controls.
658
+
548
659
  ---
549
660
 
550
661
  ## 5. Controlling the UI
@@ -600,9 +711,10 @@ Never re-derive values from `vm.raw`.
600
711
 
601
712
  | Family | Components | Type strings | `render` receives |
602
713
  | --- | --- | --- | --- |
603
- | KPI | `KpiWidget` | `KPI` `KPI_CARD` `KPI_CARD_V2` | templates get `{ vm, index }` |
714
+ | KPI | `KpiWidget` | `KPI` `KPI_CARD` `KPI_CARD_V2` | templates get `{ vm, index }`; `render` / `"KPI_CARD:card"` get `{ vms, byKey, roles, state, tokens, config }` |
604
715
  | 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, … }` |
716
+ | Ranking | `RankingBarWidget` | `RANKING_BAR` | `{ rows: {rank,label,value,ratio,raw}[], max, … }` (`ranking.order: "none"` keeps row order) |
717
+ | Funnel | `FunnelWidget` | `FUNNEL` | `{ rows: {label,value,ratioToFirst,stepPct,isFirst,index,raw}[], first, tokens, config, toLabel, onItemClick }` |
606
718
  | Gauge | `GaugeMeterWidget` | `GAUGE_METER` | `{ value, min, max, ratio, color, band, thresholds, rows }` |
607
719
  | Heat map | `HeatMapWidget` | `HEAT_MAP` | `{ cells: {zone,count,color,band}[], grid }` |
608
720
  | 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 +733,10 @@ and HTML sanitization stay in the host app. The package never calls
621
733
  `dangerouslySetInnerHTML`.
622
734
 
623
735
  **`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.
736
+ *dashboard-level* control. It is controlled (`value` / `onChange`), and it
737
+ issues no queries except one per `optionsSource`. Feed its values into other
738
+ widgets' `dashFilters`, or pass `onFiltersChange` output to their
739
+ `widgetFilters`. A widget filter (§3) narrows only its own widget.
627
740
 
628
741
  ---
629
742
 
package/SKILL.md CHANGED
@@ -191,9 +191,10 @@ KPI templates: `standard` `trend` `comparison` `progress` `financial` `status` `
191
191
 
192
192
  | Family | Components | `render` args |
193
193
  | --- | --- | --- |
194
- | KPI | `KpiWidget` | templates get `{ vm, index }`. `vm` is fully resolved; never read `vm.raw` |
194
+ | 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
195
  | 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 }` |
196
+ | Ranking | `RankingBarWidget` | `{ rows: {rank,label,value,ratio}[], max }`; `ranking.order: "none"` keeps row order |
197
+ | Funnel | `FunnelWidget` (`FUNNEL`) | `{ rows: {label,value,ratioToFirst,stepPct,isFirst}[], first, tokens }`; `onItemClick(row)` |
197
198
  | Gauge | `GaugeMeterWidget` | `{ value, min, max, ratio, color, band }` |
198
199
  | Heat map | `HeatMapWidget` | `{ cells: {zone,count,color,band}[], grid }` |
199
200
  | Tables | `DashboardTableWidget` `DatabaseTableWidget` `TaskTrackerTableWidget` `ChildTableWidget` `PivotGridWidget` | `{ rows, columns, renderCell, toLabel, interactions, headerControls(col), getColumnOptions(key), enableSort, showColumnFilter, mode }` |
@@ -203,6 +204,17 @@ KPI templates: `standard` `trend` `comparison` `progress` `financial` `status` `
203
204
  | Display | `HtmlWidget` `RawHtmlWidget` `IframeWidget` `GoogleMapWidget` `TreeWidget` `ActionCardWidget` | see types |
204
205
  | Containers | `TabWidget` `FormWidget` `FilterPanelWidget` `ZaiChatWidget` | see types |
205
206
 
207
+ Reshaping after the query (0.0.3). Use these instead of re-shaping rows in React:
208
+
209
+ ```tsx
210
+ unpivot={{ columns: STAGES, extra: { "Contacted %": "step" } }} // one wide row → { label, value } per column, in order; missing → 0
211
+ categoryGap={{ field: "Pipeline Stage", values: "fromForm" }} // every category in dropdown order, 0 where no bucket
212
+ <FunnelWidget dataSource={ONE_ROW} unpivot={{ columns: STAGES }} onItemClick={(r) => …} />
213
+ <FilterPanelWidget configurations={{ filters: [{ key, field: "Assignment", type: "select",
214
+ optionsSource: { dataSource, labelField, valueField, allLabel: "Firm-wide" } }] }}
215
+ onFiltersChange={(f) => setWidgetFilters(f)} /> // valueField = the lookup's ViewLabel field for FORM_SEARCH targets
216
+ ```
217
+
206
218
  **`render` is REQUIRED** for `ZaiChatWidget`, `GoogleMapWidget` and `HtmlWidget` markup. `FormWidget` renders `<ZvolvForm>` by default (see Forms). The package never calls `dangerouslySetInnerHTML`.
207
219
 
208
220
  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)`.