Collapse the column hierarchy from the toolbar

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoxNi8cFsQLPUSw3obb5NH
This commit is contained in:
Paul Trowbridge 2026-09-12 09:15:15 -04:00
parent a0d39d44c0
commit 4b9296abc1
2 changed files with 136 additions and 1 deletions

View File

@ -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.

View File

@ -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
))}
</div>
{splitFull.length > 0 && (
<>
<div className="w-px h-4 bg-gray-200 shrink-0" />
{/* Column hierarchy group — the split_by equivalent of Expand */}
<div className="flex items-center gap-1.5">
<span className="text-gray-400 uppercase tracking-wide" style={{fontSize:'10px'}}>Columns</span>
{Array.from({ length: splitFull.length + 1 }, (_, n) => {
const label = n === 0 ? 'Total' : splitFull[n - 1]
return (
<button key={n} onClick={() => applySplitDepth(n)}
title={n === 0
? 'Collapse the columns to a single total'
: `Show columns down to ${splitFull.slice(0, n).join(' ')}`}
className={`border rounded px-1.5 py-0.5 transition-colors max-w-[9rem] truncate
${splitDepth === n ? 'border-blue-300 text-blue-600 bg-blue-50' : 'border-gray-200 text-gray-500 hover:border-gray-400'}`}>
{label}
</button>
)
})}
</div>
</>
)}
<div className="w-px h-4 bg-gray-200 shrink-0" />
{/* Data group */}