Skip to content

Add Cube pivoting and a CubeGrid component - #4797

Open
lbwexler wants to merge 167 commits into
developfrom
pivot-grid-fold
Open

lbwexler wants to merge 167 commits into
developfrom
pivot-grid-fold

Conversation

@lbwexler

@lbwexler lbwexler commented Oct 8, 2026 •

Copy link
Copy Markdown
Member

Supersedes #4574. Pivoting is an option on Query rather than a parallel PivotQuery and PivotView pair. A pivoted View updates incrementally like any other, and an app toggles pivoting with updateQuery().

Why

Apps need to slice Cube measures across a second axis of dimensions and read the result as columns. A cube-backed grid also took hand wiring of a GridModel, a Store and createView({stores}), pivoted or not.

What changed

Data layer

  • QueryConfig.pivot takes a PivotSpec: dimensions to slice into columns, measures to aggregate at each intersection, and options to exclude empty values and cap the path count. Omit it for an ordinary view.
  • Pivot dimensions must be isDimension fields. Measures must have an aggregator and must not be dimensions. Cube.dimensions and the new Cube.measures list the candidates.
  • A pivoted View builds one cell row per populated group and path pair inside the aggregation network. Every aggregator works unmodified and ticks propagate incrementally. The View writes each cell onto its group row as a synthetic Store field.
  • ViewResult carries paths and cellFields, empty when not pivoted. A PivotPath holds its dimension, raw value, key and isEmpty. Display text is the grid's job. View.createStore mints a Store with the cell fields declared and kept in sync.

Grid, in cmp/cubegrid

  • CubeGridModel and CubeGrid render any cube-backed grid, pivoted or not. Create the model from a view, or from cube and query. It owns the Store, tree mode and the label column.
  • columns is the app's fixed region, written as for a GridModel. When the query pivots, the model appends one column group per pivot path to the right.
  • Pivot options are set through pivotConfig and live, bindable, on cubeGridModel.pivotModel, a CubeGridPivotModel, as gridConfig and gridModel do for the grid.
  • dimensions lists the dimensions the grid can pivot on, with settings for each over dimensionDefaults: sortBy, collapsible, showSummary, summaryLabel, showCollapsedLabel, valueRenderer, emptyLabel, valueClass and summaryClass. A dimension's settings follow it to whatever level the query pivots it at.
  • measures lists the measures in display order, each with a column config applied across every path, over measureDefaults. availableDimensions and availableMeasures give a pivot picker its options: the listed fields, or all of the cube's when none are listed.
  • One column group spans the region, titled by the pivot dimensions, for example Region › Sector. Each dimension has a summary that aggregates its values: the first's is the row summary the region collapses to, and each later one's is the summary its parent groups collapse to. showSummary keeps a summary visible while expanded, on the side it names. Summary cells and headers carry xh-cube-grid__summary-cell and xh-cube-grid__summary-header.
  • measuresOutermost puts each measure outermost, with the paths beneath it, to compare one measure across paths. Each measure is its own root group, one header level shallower.
  • expandLevel sets the initial header level, as the grid's expandLevel does for rows, and pivotModel.expandToLevel() resets it. The state is the grid's own columnGroupState, keyed on the path, so it survives a rebuild and persists.
  • With linkedSizing (the default), every pivot column of a measure shares one width: the measure's widest data, plus an even share of any room a displayed label needs, capped at headerFitMaxWidth (default 140px). Longer labels truncate, with a tooltip. A manual resize moves all of a measure's columns. linkedSizing: false sizes each column on its own.
  • Pivot columns are neither hideable nor movable, and stay out of the column chooser. The two regions never interleave. Column state and sort persist through gridConfig.persistWith.
  • The grid header offers "Pin Column" only for a movable column. Column group headers show their expand control right after the label rather than at the header's far edge.

Grid-bound components

  • GridLikeModel is a new interface in cmp/grid for a GridModel or a model wrapping one. It declares store, empty, the selection members and innerGridModel, the GridModel behind the model. GridModel, ZoneGridModel, DataViewModel and CubeGridModel implement it.
  • Every grid-bound field, button and label now takes a GridLikeModel by prop or from context and acts on its innerGridModel: StoreFilterField, GridFindField, GridCountLabel, ExportButton, ColChooserButton, ColAutosizeButton, ExpandToLevelButton and the mobile ExpandCollapseButton.
  • CubeGridModel requires this. Context lookup scans one layer of child models, so an app model holding a cubeGridModel exposes it, but not the gridModel inside it. A component that looked up a bare GridModel found nothing and disabled itself, as it already did for a ZoneGridModel or DataViewModel.
  • The filter and find fields bound to a DataViewModel search every field, as a DataView has no visible columns to narrow them.

Example

  • Admin › Activity › Tracking runs on one CubeGridModel in both modes. Its "Pivot by" and measure pickers read availableDimensions and availableMeasures. Environment, App Version and Client App are now dimensions, so they can be grouped or pivoted.

How to verify

  • Toolbox companion Add Cube pivoting and a CubeGrid component toolbox#929 adds a Cube Grid tab under Grid Variants. Under Admin › Tests it adds a 335-check correctness suite, a live grid harness and a benchmark. Its lint check stays red until this PR publishes a snapshot, because Toolbox CI resolves @xh/hoist from npm.
  • pnpm test data/cube cmp/cubegrid runs the unit specs. They check incremental results against a fresh build after each tick, and the planner against a brute-force reference.

Follow-ups

  • A reusable CubeGrid config panel.

Bucketing within the pivot axis is not supported. bucketSpecFn is group-axis only.

The full design history, including the longer list of ideas, is on the pivot-grid branch in docs/planning/pivot-grid.md.

…onent

Brings in a generalized PivotGrid written in a client app as the starting point for a first-class hoist-react component. Committed close to as-imported, with only the parts we know are not being kept stripped out.

- `PivotGridModel` builds the grid's column hierarchy from a pivot value tree and manages summary rows/columns and sorting. Removed the `debugMode` context menu, the hand-rolled `state`/`setState` persistence, the `summaryColumnConfigReaction` (redundant with `buildGridColumns`), the app-specific `right-border` / `summary-cell` styling and the `PivotGrid.scss` that disabled Hoist's own group borders, and the unused `valueColumns` getter.
- `PivotDataModel` is the prototype's pivoting engine, kept for now as a reference implementation and performance baseline. It widens each leaf record with a synthetic field per (pivotPath, valueField) pair and aggregates via a `Cube`. To be replaced - `Cube` aggregation is dense over fields while the widened data is sparse, so every aggregate row computes every synthetic field.

`docs/planning/pivot-grid.md` carries the plan and the review findings forward across sessions: a benchmark baseline first, then settling the pivot data API (leaning toward pivot dimensions as innermost cube dimensions with cells projected out of the row tree, though the factoring against `Query`/`View` is still open), then the data layer and `PivotGridModel` tracks in parallel.

Not yet exported from any package index.
…seline

Settles the vocabulary for the effort and captures the numbers the rewrite has to beat.

- Terminology section: groupings / pivot dimensions / value fields, with row totals, pivot totals and value totals replacing the prototype's "summary" vocabulary, which collided with `Store.summaryRecord` and `GridModel.showSummary`. Includes a mapping table from the prototype's config names.
- Acceptance criteria: seven profiles calibrated to real usage (pivots are low cardinality; the groupings carry it), each with a `+ Drill` unique-per-leaf variant. Gate is Typical - build <= 250ms, tick <= 30ms, heap <= 2x.
- Baseline for `PivotDataModel` as imported, measured via the new Toolbox harness.
- Phase 1 gains a pivot cardinality guard decision; phase 3 gains the API rename and pivot totals as net-new work.
- Adds `cmp/pivotgrid/index.ts` so the package imports as `@xh/hoist/cmp/pivotgrid`.

Headline: the prototype passes the build target and misses the tick target by 4-7x, because a tick is a full rebuild regardless of how little changed. Heap is worse than expected - 773MB on Heavy+Drill, and that is the clean figure, excluding the known per-update Cube leak. Pathological pivots (5,000 paths) degrade to 27s builds but do not crash, which argues the phase 1 guard can be a soft cap rather than a thrown error.
Design only - no implementation. The contract now lives in a "Pivot data design" section of the plan doc, which phases 2 and 3 build to.

Headline: phase 0's working direction was wrong. Pivot cells cannot be extra innermost Cube dimensions - grouping is hierarchical, so a trailing pivot dimension only materializes cells beneath the *innermost* group row, leaving every group row above it with nothing to project from (and pivots outermost fails symmetrically, losing the row totals). The corrected model hangs a pivot subtree off every node of the row hierarchy, decomposed down the group axis above the innermost level and down the pivot axis below it. Those cells are real rows in the aggregation network, so every aggregator and the existing incremental `replace` machinery work unmodified, and they are never in `children` - so they never reach `getVisibleDatas` or a connected store.

Decisions recorded:

- Factoring is `PivotView extends View` / `PivotQuery extends Query`. Composition and fold-in rejected, with reasons.
- The open question about concatenating row-group and pivot dimensions dissolved: `Query.dimensions` keeps its exact existing meaning and nothing is concatenated anywhere.
- Query interface: `dimensions` plus `pivotDimensions`, `valueFields`, `emptyPathLabel`, `excludeEmptyPivotValues`, `maxPivotPaths`.
- Result shape: `rows` / `leafMap` unchanged, plus a `PivotPath` tree and `cellFields`, both identity-stable while the structure holds - which is the structural-change signal.
- Cells are copied onto published row data as flat synthetic fields; the leaner `getValueFn` alternative is rejected for v1 as it puts value columns outside the Store field model.
- Null pivot values get their own `(empty)` segment; exclusion is expressed as a filter, the only formulation under which the totals invariant survives.
- One `PivotView.PATH_DELIMITER` with injective escaping - nothing ever parses a cell field name.
- `maxPivotPaths` (default 1000) throws; soft `(other)` bucketing deferred as a real top-N feature rather than a guard.
- Row totals, pivot totals, and value totals are one mechanism at three depths, making pivot totals near-free for phase 3.
- Correctness tests will live in the Toolbox harness - hoist-react has no test framework and `data/cube` is not standalone-importable.

Also corrects two phase 0 claims: the populated cell set is *not* far smaller on every profile (for Typical it is essentially dense, and the win there is the tick, not the build), and the build targets are tightened per phase 0's own rule since the prototype passed them - Typical 250 -> 140ms, Typical+Drill 750 -> 290ms.

Phase 2's row-truncation item is removed as unnecessary; phase 2 and phase 3 are now parallelizable.
Collapsed facts that had 3-5 homes down to one each, deleted the completed phase 0/1 checklists and the prototype-only findings the phase 2 rewrite resolves, and cut prose that only confirmed the design was correct.

One substantive fix buried in the prose churn: baseline finding 5 argued the cardinality guard could be a soft cap or warning, contradicting phase 1's settled decision to throw. It now points at that decision instead.

Kept the checkboxes and session log deliberately - a plan is short-lived scaffolding and holding status is its job, so only the untickable items came out. Every remaining box can actually be ticked.
Factors the pivot combinatorics out of PivotView into a dependency-free module over integer indices, exercised directly by `npx tsx data/cube/impl/PivotLattice.spec.ts`.

The boundary is integer arrays in / integer arrays out, deliberately: `data/cube` cannot be loaded outside a bundler (the `@xh/hoist/core` barrel reaches XH and the service layer, and Hoist's `@persist` reads `descriptor.initializer`, a babel legacy-decorator shape that esbuild does not emit), so anything importing View is untestable without a browser. Indices also avoid a plan object per cell, which would fight the phase 2 heap budget at 100k+ cells.

Rejects a group node carrying both leaf and group children. Such a node would give a cell two update routes into the same parent and silently double count. Reachable today via `bucketSpecFn` bucketing leaves under `includeLeaves`; PivotQuery must validate against it.

The suite was mutation-tested - six deliberate defects, five caught. The sixth was an equivalent mutant, which surfaced that "C(G, rootPath) is G, never a cell" was load-bearing but unasserted; now checked directly.
…e changes to support them

`View` implementation members widen to `protected`, generated root rows are retained so a subclass can walk the network, and result assembly moves behind `createResult()`. `Query.clone` now constructs via `this.constructor` with an extendable `cloneConfig`.

`BaseRow` gains a second aggregation parent, `pivotParent`. Both routes share a `propagateUpdate` helper because `applyDataUpdate` rewrites each RowUpdate's oldValue/newValue in place and `Aggregator.replace` reads them - so a row with two parents must hand the second route its own copies, or it applies the first route's delta.

`initAggregate` splits, exposing `initAggregateData` for cell rows: their children do not uniformly treat them as their group-axis parent, so they cannot inherit its child-parent wiring. Both take an optional field list, letting cell rows aggregate only the value fields plus `CubeFieldSpec.dependsOn`.

The incremental collector becomes `Set<BaseRow>` rather than `Set<PlainObject>`, since PivotView needs the rows themselves to tell cells from group rows.

PivotView's implementation fields use `declare` deliberately: initializers on a View subclass run after super()'s constructor-time fullUpdate() and would wipe everything it generated.

Retires the prototype's duplicate `PivotFieldSpec.dependsOn` in favor of the new `CubeFieldSpec.dependsOn`.
A drilled-down leaf now carries its own value in its own pivot column and null elsewhere, so the row reads as a single populated column rather than a blank one. A leaf's own full-path cell is exactly its `pivotParent`, so this needs no extra bookkeeping. Hidden leaves are skipped - their data is a shared reference to Cube record data.
Populated paths move from a Set per group to sorted arrays allocated only where non-empty, and the union walk relies on a new documented precondition - parents precede their children - which removes an explicit depth computation and sort.

`canAggregate` maps are now shared across every cell on a pivot path, via an optional argument to `initAggregateData` and a `computeCanAggregate` split out of it.

Both were aimed at the build regression on profiles with a unique-per-leaf innermost grouping, and both are smaller wins than expected: Typical+Drill went 577 -> 562 -> 529ms against a 290ms target. The remaining cost is the ~51k cell row objects themselves (id string, data object, aggregate call). Most are innermost single-leaf-path cells whose value is just the leaf's own - not materializing those is the next thing to try.
`View.updateQuery` retains `_rowCache` for a filter-only change with simple aggregators, and a cell's cache key (`ownerRow.id` + path key + children identity) pins what its `ownerRow` and `path` *name*, not which instance names it. A cell could therefore be reused across a rebuild that minted a new owner row, projecting its values onto the discarded instance - and `pathIdx` could point at a different path entirely once a newly-admitted pivot value shifted the sorted path order.

- `ownerRow` / `path` are no longer readonly; `buildCellRows` rebinds them on every build, as it already did for `parent` / `pivotParent`.
- `pathIdx` is gone. `_cellFieldNames` is keyed on path identity, so a stale index is no longer representable.
`excludeEmptyPivotValues` folded into the query filter two ways that broke `Query.equals`, so `View.updateQuery`'s no-op short-circuit could never fire and the filter tree grew a node per clone.

- `cloneConfig` carried `super`'s already-augmented `filter`, so each clone re-augmented it. Carry the raw config filter instead.
- `FunctionFilter.equals` compares `testFn` by reference and ignores `key`, so a per-construction closure was never equal to its own clone. Use one `FieldFilter` per pivot dimension - `!= [null]`, which FieldFilter tests as null / '' / [] alike. Also makes the filter serializable; `FunctionFilter.toJSON()` throws.
- `appendFilter` in place of `isEmpty(filter) ? … : [].concat(…)`, which flattens into an existing AND and stops `isEmpty` silently dropping a bare `FilterTestFn`.
Tripwire for the invariant the cell-row rebind establishes. Not reachable today - every route that could strand a `pivotParent` also rebuilds `_leafMap` empty, and the incremental path reads that first - but a new `clearCells` early return or cache-retention path in `updateQuery` would break it silently.

Message is a literal rather than interpolated: `throwIf` evaluates its argument eagerly, and this runs once per cell per build.
The memo was never invalidated, so `updateQuery({valueFields})` left cell rows aggregating the previous measure set while `syncPaths` and `projectCell` read the new one - the new measure's columns projected `undefined`. Neither `_rowCache.clear()` nor `clearCells()` reached it.

Read once per build, so the memo saved one Set and one filter over `fields`. Recomputing removes the staleness class outright rather than adding an invalidation hook to remember.
Inherited as `Partial<QueryConfig>`, so `pivotDimensions` and `valueFields` failed excess-property checking from an object literal - including emptying `pivotDimensions` to degenerate to plain View behavior, which `PivotQueryConfig` documents as supported. The runtime path already worked.
`mergeSorted` documents ascending, duplicate-free inputs, but a group's own array was still the raw prefix pushes when a child merged into it. Sorting every array up front makes the precondition true, and drops a redundant re-sort - a parent was sorted again at its own iteration even though merged-in content already arrived sorted.

No behavior change: the old ordering was repaired downstream by the parent's own `sortedUnique`, confirmed by mutation-testing an order-breaking `mergeSorted` fast path against the pre-change code.
`data/cube/README.md` has no pivot content - the pointers on `PivotQueryConfig` and `PivotView` were written against a section that phase 4 has yet to add.
- Broaden the mutation-test rule to both tiers, and record why the Toolbox tier has to exercise query transitions: a filter-only change with simple aggregators is the one path retaining `_rowCache`, and every bug the review found lived there.
- Flag the benchmark Result table as superseded by `da632efb9`, which changed the group-axis merge on the build path. Re-measure before attacking the drill-down regression.
- Add a How to measure section. The benchmark procedure was documented against the phase 0 harness only, and the two harnesses measure different things; the trap it now names is that plain `yarn start` benchmarks the published `@xh/hoist` rather than the branch.
- Record the heap gate as unverified and currently unverifiable - `PivotViewBenchModel` has no heap column, so `≤ 2×` has no runner.
- Fill in what each remaining item leaves to judgment: the open number behind restating the tick gate, the routing question the drill-down fix has to settle first, which tier new aggregator coverage belongs in, the sequence that reproduces the Wide+Drill stall, a method for the plain-`View` proof, and the phase 3 dependency blocking `PivotDataModel`'s retirement.
The 2026-08-04 benchmark was taken in a hidden tab. Chrome throttles the harness's chained `await wait(50)` to 1/sec, then 1/min after five minutes, and `whileAsync` swaps to its synchronous fallback once `XH.pageIsVisible` goes false - so a backgrounded run silently inflates every figure rather than erroring.

Confirmed by A/B rather than assumption: `data/cube` checked out at `7116572c6~1`, the exact code that table was measured against, reproduces HEAD's numbers and not the recorded ones. The five commits since are perf-neutral.

Consequences: phase 2 clears both build gates and the delta-tick gate on every profile; the cell-row elision item is cancelled, having been justified entirely by the bad numbers; and the unexplained drill-profile tick stall was the same throttling.
Covers AverageStrict, SumStrict, Unique and ChildCount plus lenient controls, over both the aggregators' own replace semantics and full bottom-up aggregation across the lattice's CSR children. 28 -> 49 checks.

Mutation-tested: 19/23 aggregator mutants and 4/4 lattice mutants killed. The four survivors are the strict aggregators' null short-circuits in replace, which are semantically equivalent - falling through re-aggregates to the same answer. What makes them safe is now asserted directly rather than left implicit.

Characterizes a pre-existing SumAggregator.replace defect rather than working around it: once the last non-null constituent goes null, the incremental path lands on 0 where a rebuild reports null. Excluded from the property matrices via lenientSumZeroesOut; delete that predicate when the aggregator is fixed.
castArray(null) yields [null], and the stores = [] default at construction only covers undefined, so createView({stores: null}) threw on s.reuseRecords. null is Hoist's no-value sentinel, so passing it for "no stores" is idiomatic and should work.
RowCache.endGeneration() sweeps and logs, and ran at the end of View.generateRows() - before PivotView's override generated its cell rows. Cells land in the same cache but were created outside the generation, so they counted toward the sweep's `size` while being absent from its `live` count.

Measured on Typical+Drill: 37,211 group rows + 35,000 leaves = 72,211 counted live, against a cache of 123,128 once 50,917 cells are added. That is 1.7x, past the 1.5x sweep trigger, so a steady-state pivot view swept on every single build. With the call relocated, size == live == 123,128 and it does not sweep.
The restamp added during the store-simple rebase is dead code: BaseRow.applyDataUpdate pushes an update per aggregatable field without testing whether the value moved, so every group-axis ancestor of a changed leaf - every cell owner included - is always in updatedRows and already stamped. Deleting it leaves the suite green, including 2,886 post-tick cell values read back out of a Store. Kept as insurance should that propagation become conditional, with the comment corrected to say so.
A centered label on a wide group sits out of the viewport. Left-aligned, the label and its expand control stay at the group's leading edge.
…rid does

- A collapsed pivot group shows its summary value columns directly under its own label, one header row up, with no Total group. pivotValueSummary and pivotGroupSummary keep them visible while expanded.
- Column group headers show the expand control before the label.
The label sizes to its text instead of filling the header, and the header's alignment moves from text-align on the label to justify-content on the flex row.
Default true. Off, no pivot group carries a show mode, so none is expandable, and summary columns appear only where pivotValueSummary and pivotGroupSummary ask for them.
pivotExpandLevel replaces the collapsed flag: 0 collapses the region, 1 shows the top-level groups collapsed, -1 expands all. expandPivotToLevel() resets it, with expandAllPivotGroups() and collapseAllPivotGroups() as shortcuts, and later rebuilds mint new groups at the current level.
The chevron points where the content goes on click, as panel collapse toggles and ag-Grid's own column group icons do. A down chevron describes a vertical disclosure, which a column group is not.
A summary shown while expanded sits under a centered Total group. The bare columns shown while collapsed are a separate collapsed>> set kept out of the chooser. Leaf path groups over the measures are centered, while wide parent and region groups stay left-aligned.
One option governs the Total columns while any pivot group is expanded, the region's row totals and each parent group's subtotals alike, as showSummary governs the totals row. The region's summary group is pivot:total.
Matches tree rows: the chevron shows open or closed state rather than the direction the columns move.
The pivot region is the query's: its columns already cannot move or pin, and hiding one by path left row totals disagreeing with the visible cells and persisted state keyed on data values. Drop a measure from pivot.valueFields or filter the query to remove columns. hideable and excludeFromChooser join RESERVED_PIVOT_VALUE_KEYS, and a rebuild carries width only.
Mirrors the leaf header: when no app headerTooltip is set, hovering a truncated group label sets a native title with the full text.
…ErrorMessage in CubeGrid

A routine HoistException thrown in the View's result pipeline no longer propagates. The View catches it, empties its rows, paths and connected stores, and publishes it on the result. Any other exception is a bug and still propagates. CubeGrid renders an ErrorMessage in place of the grid while the result is declined.

- The maxPivotPaths cap throws a routine exception, with the dimension, count and cap attached, and its default drops from 1000 to 100.
- getSimpleUpdates forces a full update while a result is declined.
- Specs cover decline, recovery by query and by data, and a non-routine aggregator error propagating.
…ide summaries to CubeGridModel

- pivotValueSpecs, a map keyed by measure, becomes pivotColumns, an array of column specs whose field names the measure - the same shape as columns, so one array can serve both regions. Entry order sets the order of measure columns within each path group.
- pivotColumnSpecFn and pivotColumnGroupSpecFn return overrides for one pivot column or group, given the path, kind and the spec being overridden.
- Pivot level groups carry their full path as headerTooltip.
- showPivotSummaries: true now places summaries on the left, so a summary keeps its collapsed position and the group opens to its right.
- Pivot types and reserved key lists move to cmp/cubegrid/Types.ts.
…umns on restoreDefaults, and mark summary columns with classes

- Each summary path now gets one `Total` group that stays in the column tree whether its parent is expanded or collapsed, shown only while collapsed when `showPivotSummaries` is off. The separate `collapsed>>` column set and the `collapsed` kind of `PivotColumnContext` are gone, so collapsing a group no longer changes the header depth or the summary colIds.
- `GridModel.restoreDefaultsAsync()` restored the columns of construction, before the view had paths, and dropped the pivot region. `CubeGridModel` now rebuilds the pivot columns at the current paths after the grid restores its own defaults, re-running managed autosize and then any app `restoreDefaultsFn`.
- Summary cells and headers carry `xh-cube-grid__total-cell` and `xh-cube-grid__total-header`, prepended to any class the app supplies, unstyled by default.
@lbwexler
lbwexler marked this pull request as ready for review November 5, 2026 22:36
… view

- Each pivot column exports under its path labels and header joined - `US › Tech › P&L`, `Total › P&L` - so a pivot with several paths no longer repeats one header. `exportName` is reserved on pivot columns, as one shared with the fixed columns would repeat.
- `gridConfig.filterModel` binds to the view by default, or by `bind: 'view'`, so a column filter filters the source records through the query and re-aggregates. `bind: 'store'` filters the group rows by their aggregates instead. Field specs default to the query's fields in both modes, leaving out the store's cell fields.
- Pivot columns are never filterable - a cell is an aggregate no query filter can express - and `filterable` is reserved alongside.
An exposed leaf under a pivoted View answered only its full-depth cell and the bare total, leaving each ancestor-path cell null - so a leaf's region subtotal showed blank under a collapsed region group in a CubeGrid. A leaf belongs to one path at every depth, so its getters now walk the path's parent chain, via the new `PivotCells.pathParentIdx`, and answer for each ancestor with the leaf's own value.

lbwexler commented Nov 6, 2026

Copy link
Copy Markdown
Member Author

Follow-up: truncated pivot group headers

Dimension values (e.g. Global Macro Fixed Income) head a group whose width is just the sum of its measure columns. With one measure such as P&L, that is a single number-width column, so the label truncates badly. The full-path headerTooltip helps, but it is not enough.

Causes:

  • Autosize measures leaf columns only (GridAutosizeService, ColumnWidthCalculator.calcHeaderWidth). Group headers are never measured.
  • agOptions is reserved on pivot groups, so apps can't turn on AG Grid's wrapHeaderText / autoHeaderHeight, which ColGroupDef supports.

Options considered:

  • Fixed minWidth on pivotColumns. Works today, but every column pays for the longest label, it multiplies by the number of measures, and longer labels still truncate. Stopgap only.
  • Wrap headers. Common practice in Power BI, Excel and Tableau, but on its own it gives 4-5 lines in a narrow column. Needs sizing too, and a 2-line cap.
  • Size to each group header. Gives jagged widths, which make columns harder to scan and compare.

Proposal:

  1. Size pivot leaf columns to fit the group headers, with one uniform width per pivot level and measure (the max needed, capped at about 180px) rather than per column. The grid stays regular.
  2. Labels over the cap wrap to at most 2 header lines, then truncate with the tooltip. Needs CSS on Hoist's ColumnGroupHeader. Not yet checked with the custom header.
  3. Apps can still shorten labels via pivotColumnGroupSpecFn → headerName.

Consider building the group-header measurement into GridAutosizeService as an option, so it isn't CubeGrid-only.


Generated by Claude Code

# Conflicts:
#	CHANGELOG.md
#	docs/planning/docs-roadmap.md
…llapsed Total header option

- Moved pivot options off CubeGridConfig into `pivotConfig`, held by a new `CubeGridPivotModel` (`cubeGridModel.pivotModel`), which also builds, sorts, sizes and expands the pivot region. Options drop their `pivot` prefix and are bindable there; expand methods are `expandToLevel`, `expandAll`, `collapseAll`.
- Added `columnSizing`: every pivot column of a measure shares one width - its widest data plus an even share of any room a displayed group label needs, up to `headerFitMaxWidth` (default 140). Follows expand/collapse; a manual resize moves all of a measure's columns; changing it re-autosizes a managed grid. `ColumnWidthCalculator.calcHeaderTextWidth` measures labels, with the expand chevron.
- Added `collapsedTotalHeader`: false drops the `Total` header over a collapsed group's summary, which then uses its own `collapsed>>` colIds (excluded from export when the `total>>` copy also shows).
- `PivotSort` accepts a `PivotPath` comparator.
- Updated specs, README, docs index and CHANGELOG, and the Admin Activity Tracking grid.
@lbwexler

lbwexler commented Nov 6, 2026

Copy link
Copy Markdown
Member Author

Pivot column sizing is in (215ae7b), following up the truncated group header proposal above.

How widths are set

  • Every pivot column of a measure, Total columns included, shares one width.
  • That width is the measure's widest data, plus any room the displayed group labels need. A label shorter than its columns' data claims nothing. A longer one shares its shortfall evenly across the columns under it, so one narrow column is not ballooned.
  • A label claims at most headerFitMaxWidth, default 140px, and truncates past it with a tooltip. Data can still make columns wider.
  • Widths follow expand and collapse: a collapsed group's label sits over its summary alone, so it may widen the measure, and expanding releases it.
  • A manual resize moves all of the measure's columns and holds until the next autosize.

Config

All under pivotConfig.columnSizing (bindable on cubeGridModel.pivotModel):

  • true (default): uniform widths, labels capped at 140px.
  • {headerFitMaxWidth: n}: a different cap. 0 sizes for data alone.
  • false: each column sizes on its own, as in any grid.

Changing it re-autosizes a grid in managed autosize mode. Labels are measured with the real header font and expand chevron, through a new ColumnWidthCalculator.calcHeaderTextWidth.

Related, in the same commit

  • collapsedTotalHeader: false drops the Total header over a collapsed group's summary, so its measure headers stretch up to the group label. Those columns get their own collapsed>> colIds, as AG Grid 36 reuses a column's old Total group when the column moves out of it at runtime.
  • Pivot options moved off CubeGridConfig into pivotConfig, held by a new CubeGridPivotModel.
  • PivotSort accepts a PivotPath comparator.

Toolbox companion: the Admin › Tests › CubeGrid panel has Uniform widths, Header fit max, Long labels and Total header controls to try it.

- Renamed pivot value fields to measures across the Cube and CubeGrid: `PivotSpec.measures`, `PivotCellField.measure`, `measuresOutermost`. A pivot measure must have an aggregator and not be a dimension. Added `Cube.measures`.
- Moved pivot display settings onto `pivotConfig.dimensions` / `dimensionDefaults`: `sortBy`, `collapsible`, `showSummary`, `summaryLabel`, `showCollapsedLabel`, `valueRenderer`, `emptyLabel`, `valueClass`, `summaryClass`. Each summary belongs to the dimension it aggregates.
- `pivotConfig.measures` / `measureDefaults` hold a `column` config per measure. `availableDimensions` / `availableMeasures` give a pivot picker its lists.
- Renamed totals to summaries throughout: classes, colId prefixes and group ids. `columnSizing` is now `linkedSizing`.
- Removed `columnSpecFn`, `columnGroupSpecFn`, `PivotColumnSpec`, `PivotSpec.emptyPathLabel` and `PivotPath.label`.
- Activity Tracking: pivot pickers read from the pivot model; Environment, App Version and Client App are now dimensions.
A dimension with one value only repeats the row summary as a pivot.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants