From 4b9296abc1a5cb8f488bb828bc9bb16450ccf121 Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Sat, 12 Sep 2026 09:15:15 -0400 Subject: [PATCH] Collapse the column hierarchy from the toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The row axis has had Expand 0/1/2/3 since the pivot landed; the column axis had nothing. With a year over month split there was no way to step back to whole years short of dragging split_by apart in the settings panel and putting it back afterwards. Perspective gives the two axes nothing in common here. Rows collapse because the GROUP BY ROLLUP view holds every level at once and view.set_depth() hides the deeper ones. For columns there is no equivalent: expand()/collapse() take a row index, ViewConfig has group_by_depth but no split_by_depth, and split_rollup_mode only chooses whether subtotal column groups are emitted — a view shape, not an interaction. So applySplitDepth() collapses by restoring a truncated split_by, which rebuilds the view. Three consequences of that rebuild, each handled: - Once collapsed, viewer.save() only reports the short split_by, so the full hierarchy is held separately (splitFullRef) and persisted into the layout as split_full. Without it, collapsing would be a one-way door: reload while collapsed and the deeper levels are gone. adoptSplit() is the single place it is set. - perspective-config-update fires for our own restore as well as for the user rearranging the pivot, and the two mean opposite things — one must adopt the new hierarchy, the other must not. collapsingRef separates them. - Row depth lives on the discarded view, so it is re-applied afterwards. The selection is cleared on each change: slices name the split_by dimensions they were cut from, and the highlight is keyed on grid coordinates. Neither survives a column axis that just changed shape. Buttons are named for the level they show — Total, then one per split_by column — rather than numbered like Expand, since the levels are named and a number would say nothing about what you are collapsing to. Whole-axis, not per-branch: Excel can collapse 2025 while 2026 stays expanded, and this cannot. `columns` selects which measures appear, not individual split combinations, so there is no way to hide one branch's leaves while keeping another's. Verified in the browser against cash/test with split_by Year x Reason: Reason -> Total -> Year -> Reason all render the expected column sets and the right button highlights; and a reload while collapsed to Year comes back collapsed with Reason still offered. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LoxNi8cFsQLPUSw3obb5NH --- CLAUDE.md | 37 ++++++++++++++ ui/src/views/Forecast.jsx | 100 +++++++++++++++++++++++++++++++++++++- 2 files changed, 136 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8df147c..3644750 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -95,6 +95,43 @@ Turning a region back into slices re-derives, per cell, the same filters Perspec --- +## Column hierarchy (collapse / expand) + +The two pivot axes collapse by completely different mechanisms, and the asymmetry is a +Perspective constraint, not a choice: + +- **Rows.** The `GROUP BY ROLLUP` view holds every level at once; `view.set_depth()` — which + lives on the view, not the config — hides the deeper ones. That is what the `EXPAND 0 1 2 3` + buttons drive, via `applyDepth()`. +- **Columns.** There is no equivalent. `expand()` / `collapse()` take a **row index**, + `ViewConfig` has `group_by_depth` but no `split_by_depth`, and `split_rollup_mode` + (`'flat' | 'rollup'`) only chooses whether subtotal column groups are *emitted* — it is a + view shape, not an interaction. So `applySplitDepth(n)` collapses by restoring a + **truncated `split_by`**, which rebuilds the view. + +Three things follow from the rebuild, and each is handled: + +1. The full hierarchy has to be remembered separately — once collapsed, `viewer.save()` + only reports the short `split_by`. `splitFullRef` / `splitFull` hold it, and it is + persisted into the saved layout as `split_full` so a reload while collapsed can still + expand back. `adoptSplit()` is the single place it is set. +2. `perspective-config-update` fires for our own restore as well as the user rearranging + the pivot. `collapsingRef` distinguishes them — without it, a collapse would overwrite + the full hierarchy with the truncated one and the deeper levels would be unreachable. +3. Row depth lives on the discarded view, so `applyDepth(expandDepthRef.current)` is + re-applied afterwards — the same wart as the refocus re-apply. + +The selection is cleared on every change: slices name the split_by dimensions they were +cut from, and the highlight is keyed on grid coordinates. Neither survives a column axis +that just changed shape. + +**Limitation:** this is whole-axis, not per-branch. Excel can collapse 2025 while 2026 +stays expanded; truncating `split_by` collapses every column group at that level together. +Per-branch is not reachable — `columns` selects which *measures* appear, not individual +split combinations. + +--- + ## Operation SQL patterns All three operations follow the same structure: insert a `pf.log` row in a CTE, then insert forecast rows referencing its id. `{{where_clause}}` is built from the slice; `{{exclude_clause}}` blocks `exclude_iters` rows. diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index 648586b..46fe05a 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -23,6 +23,8 @@ function cleanLayout(cfg, validCols) { if (c.columns) c.columns = c.columns.filter(col => col == null || ok(col)) if (c.group_by) c.group_by = c.group_by.filter(ok) if (c.split_by) c.split_by = c.split_by.filter(ok) + // the uncollapsed column hierarchy travels with the layout (see applySplitDepth) + if (c.split_full) c.split_full = c.split_full.filter(ok) if (c.sort) c.sort = c.sort.filter(([col]) => ok(col)) if (c.filter) c.filter = c.filter.filter(([col]) => ok(col)) return c @@ -42,6 +44,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const [saveAsName, setSaveAsName] = useState('') // operation panel — a selection is a LIST of slices; one entry is the common case + // The column hierarchy and how many of its levels are showing. Mirrored into + // splitFullRef for handlers registered once; held as state so the toolbar + // re-renders when either changes. + const [splitFull, setSplitFull] = useState([]) + const [splitDepth, setSplitDepth] = useState(null) + const [slices, setSlices] = useState([]) const [applyMode, setApplyMode] = useState('prorate') // 'prorate' | 'each' const [activeOp, setActiveOp] = useState('scale') @@ -167,6 +175,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const tableRef = useRef(null) const colMetaRef = useRef([]) const expandDepthRef = useRef(null) + // The column axis has no set_depth() — collapsing it means restoring a shorter + // split_by, so the full hierarchy has to be remembered separately to expand again. + const splitFullRef = useRef([]) + // set while our own restore is in flight, so the config-update listener can tell + // a collapse from the user rearranging split_by themselves + const collapsingRef = useRef(false) const initIdRef = useRef(0) const modifierRef = useRef(false) // the datagrid plugin element, for reading cell coordinates and driving its @@ -536,6 +550,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio setLoadProgress(null) setSlices([]) expandDepthRef.current = null + adoptSplit([], 0) try { const [dataResult, meta] = await Promise.all([ fetch(`/api/versions/${vid}/data`).then(async r => { @@ -616,6 +631,9 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const { table: _t, ...rest } = cleanLayout(JSON.parse(saved), validCols) const cfg = { ...rest, plugin_config: { ...(rest.plugin_config || {}), edit_mode: 'SELECT_REGION' } } await viewer.restore(cfg) + // split_full outlives split_by: a layout saved while collapsed still knows + // the levels it was collapsed from + adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length) if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth) } else { const sourceDefault = sources.find(s => String(s.id) === String(sid))?.default_layout @@ -633,12 +651,19 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio } } await viewer.restore(cfg) + adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length) } // auto-persist viewer state (formatting, columns, etc.) to the last-used cache if (viewer._pspUpdate) viewer.removeEventListener('perspective-config-update', viewer._pspUpdate) viewer._pspUpdate = async () => { try { + // A split_by change that is not ours is the user rearranging the pivot, and + // it redefines the hierarchy. Ours is a collapse, and must not overwrite it. + if (!collapsingRef.current) { + const live = await viewer.save() + adoptSplit(live.split_by || [], (live.split_by || []).length) + } const cfg = await captureConfig() if (cfg) await persistLayout(vid, cfg) } catch {} @@ -690,6 +715,54 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio } } + // Record the column hierarchy as the uncollapsed truth. Called whenever a layout + // arrives or the user rearranges split_by themselves — but never for our own + // collapse, which would otherwise overwrite the full list with the short one. + function adoptSplit(full, depth) { + const list = Array.isArray(full) ? full : [] + splitFullRef.current = list + setSplitFull(list) + setSplitDepth(depth == null ? list.length : Math.min(depth, list.length)) + } + + // Collapse or expand the column axis to `n` split_by levels. + // + // The row axis gets this for free: its GROUP BY ROLLUP view holds every level at + // once and view.set_depth() hides the deeper ones. The column axis has no + // equivalent — there is no split_by_depth in ViewConfig and expand()/collapse() + // take a row index — so collapsing means restoring a truncated split_by, which + // rebuilds the view. Two consequences fall out of that: the row depth has to be + // re-applied afterwards (it lives on the discarded view), and it is whole-axis, + // not per-branch — every column group collapses to the same level together. + async function applySplitDepth(n) { + const viewer = viewerRef.current + const full = splitFullRef.current + if (!viewer || !full.length) return + const depth = Math.max(0, Math.min(n, full.length)) + collapsingRef.current = true + try { + await viewer.restore({ split_by: full.slice(0, depth) }) + setSplitDepth(depth) + // restore() rebuilt the view, so the row depth that lived on the old one is gone + if (expandDepthRef.current != null) await applyDepth(expandDepthRef.current) + } catch (err) { + console.error('[applySplitDepth]', err) + flash(err.message || String(err), 'error') + return + } finally { + collapsingRef.current = false + } + // a slice names the split_by dimensions it was cut from, and the highlight is + // keyed on grid coordinates — neither survives a column axis that just changed + setSlices([]) + try { + const cfg = await captureConfig() + if (cfg) await persistLayout(versionId, cfg) + } catch (err) { + console.error('[applySplitDepth persist]', err) + } + } + async function applyDepth(d) { const viewer = viewerRef.current if (!viewer) return @@ -704,7 +777,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const viewer = viewerRef.current if (!viewer) return null const cfg = await viewer.save() - return { ...cfg, expand_depth: expandDepthRef.current } + return { ...cfg, expand_depth: expandDepthRef.current, split_full: splitFullRef.current } } async function persistLayout(vid, cfg) { @@ -762,6 +835,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio const cfg = cleanLayout(layout.config, validCols) cfg.plugin_config = { ...(cfg.plugin_config || {}), edit_mode: 'SELECT_REGION' } await viewer.restore(cfg) + adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by, (cfg.split_by || []).length) if (cfg.expand_depth != null) await applyDepth(cfg.expand_depth) setActiveLayoutId(layout.id) await persistLayout(versionId, cfg) @@ -1087,6 +1161,30 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio ))} + {splitFull.length > 0 && ( + <> +
+ + {/* Column hierarchy group — the split_by equivalent of Expand */} +
+ Columns + {Array.from({ length: splitFull.length + 1 }, (_, n) => { + const label = n === 0 ? 'Total' : splitFull[n - 1] + return ( + + ) + })} +
+ + )} +
{/* Data group */}