Save the grand totals with the layout; add a total-column toggle

Both totals travel in the layout as pf_totals { row, col }: added by
captureConfig, stripped by cleanLayout before restore(). The grand-total
column, previously always hidden by GRID_CSS, now hides only without
data-pf-col-total. Layouts saved before the key get row shown, column
hidden.

The group rule is skipped when every column group is one column wide --
one split level and one measure -- where it separated nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-25 01:08:15 -04:00
parent 061fc9fff4
commit 816ff7ab4c
2 changed files with 97 additions and 21 deletions

View File

@ -600,6 +600,13 @@ anyone else, gone on another machine. The one server-side layout was
published layout with no owner. That route is gone; the column remains, migrated
and read by nothing.
**Grand totals ride in the layout under `pf_totals`** — `{ row, col }`, the
toolbar's *Total row* / *Total column* toggles. The engine has no rollup mode
for "subtotals but no grand total", so both are hidden in the grid's DOM rather
than in `ViewConfig`; `captureConfig()` adds the key and `cleanLayout()` strips
it before `restore()`. A layout without it gets the old behaviour: row shown,
column hidden.
`cleanLayout()` guards `aggregates` along with the axes — including the weight
column of the multi-arg form — and drops the offending *entry* rather than the
layout, since `restore()` is all-or-nothing.

View File

@ -36,6 +36,9 @@ function cleanLayout(cfg, validCols) {
// the app's `dark` state the only thing that sets it, and means a published
// layout cannot impose its author's theme on everyone who applies it.
delete c.theme
// Ours, not the engine's: read off the layout with totalsOf() before this
// runs, and kept out of restore(), which knows nothing of it.
delete c[PF_TOTALS]
// Dead expressions go before the axis filter, not after: dropping them from
// `expressions` is what makes `ok()` reject them everywhere else.
if (DEAD_ORDER_EXPRS.some(n => c.expressions?.[n] !== undefined)) {
@ -76,6 +79,20 @@ function cleanLayout(cfg, validCols) {
// names a column that no longer exists.
const DEAD_ORDER_EXPRS = ['pf_bucket_ord', 'pf_segment_ord', 'Bucket', 'Segment']
// Whether the grand-total row and column are shown. Neither has a ViewConfig
// field -- the engine's rollup modes are rollup, flat and total, none of them
// "subtotals but no grand total" -- so both are hidden in the grid's DOM, and
// the choice travels beside the engine's config under this key: added by
// captureConfig, stripped by cleanLayout. A layout saved before the key existed
// gets the defaults, which are what the grid showed then: the row, and not the
// column.
const PF_TOTALS = 'pf_totals'
const DEFAULT_TOTALS = { row: true, col: false }
const totalsOf = (cfg) => ({
row: cfg?.[PF_TOTALS]?.row ?? DEFAULT_TOTALS.row,
col: cfg?.[PF_TOTALS]?.col ?? DEFAULT_TOTALS.col,
})
export default function Forecast({ sources = [], sourceId, versions = [], versionId, refreshSources }) {
const { dark } = useTheme()
const { user } = useAuth()
@ -180,15 +197,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
useEffect(() => { localStorage.setItem('pf_scale_plug_v2', scalePlug) }, [scalePlug])
useEffect(() => { localStorage.setItem('pf_panel_open', panelOpen ? 'open' : 'closed') }, [panelOpen])
// Whether the grand-total row is drawn. Per-browser, like the panel state: it
// is how you like to read the grid, not a property of the layout.
const [showTotalRow, setShowTotalRow] = useState(() => localStorage.getItem('pf_total_row') !== 'hidden')
const showTotalRowRef = useRef(showTotalRow)
useEffect(() => {
localStorage.setItem('pf_total_row', showTotalRow ? 'shown' : 'hidden')
showTotalRowRef.current = showTotalRow
gridRef.current?.regular_table?.draw()
}, [showTotalRow])
// Whether the grand-total row and grand-total column are drawn. Part of the
// layout (see PF_TOTALS), so the ref is what captureConfig reads -- it has to
// be current the moment a toggle persists, before React has re-rendered.
const [totals, setTotals] = useState(DEFAULT_TOTALS)
const totalsRef = useRef(DEFAULT_TOTALS)
// Esc closes the panel — the usual way out of the floating window
useEffect(() => {
@ -1002,9 +1015,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// localStorage and comes back when there are rows again.
const saved = rowCount > 0 ? localStorage.getItem(LAYOUT_KEY(vid)) : null
if (rowCount === 0) {
adoptTotals(DEFAULT_TOTALS)
await viewer.restore({ settings: false, plugin_config: { edit_mode: 'SELECT_REGION' } })
} else if (saved) {
const { table: _t, ...rest } = cleanLayout(JSON.parse(saved), validCols)
const parsed = JSON.parse(saved)
adoptTotals(totalsOf(parsed))
const { table: _t, ...rest } = cleanLayout(parsed, validCols)
const cfg = { ...rest, plugin_config: { ...(rest.plugin_config || {}), edit_mode: 'SELECT_REGION' } }
await viewer.restore(cfg)
// split_full is the legacy key, from when collapsing truncated split_by
@ -1027,6 +1043,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const defaultRow = defaults.find(l => l.version_id != null) || defaults[0]
const sourceDefault = defaultRow?.config
let cfg
adoptTotals(totalsOf(sourceDefault))
if (sourceDefault && Object.keys(sourceDefault).length > 0) {
const { table: _t, ...rest } = cleanLayout(sourceDefault, validCols)
cfg = { ...rest, plugin_config: { ...(rest.plugin_config || {}), edit_mode: 'SELECT_REGION' } }
@ -1046,7 +1063,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// dirty dot has something to compare against. Read back rather than
// reusing cfg: restore() normalises, so the live config is what a later
// save() will be compared with.
if (defaultRow) markClean(await viewer.save(), defaultRow.id)
if (defaultRow) markClean(await captureConfig(), defaultRow.id)
}
// Its own try: the restore above has one, but this sits outside it, and a
@ -1250,8 +1267,11 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const head = meta?.row_header
const isTotal = meta?.y === 0 && Array.isArray(head) &&
String(head[0]) === 'TOTAL' && !head.slice(1).some(notBlank)
tr.classList.toggle('pf-total-row', isTotal && !showTotalRowRef.current)
tr.classList.toggle('pf-total-row', isTotal && !totalsRef.current.row)
}
// The column's switch, for GRID_CSS. Re-asserted per draw because the
// datagrid can hand us a fresh table element.
table.toggleAttribute('data-pf-col-total', totalsRef.current.col)
const body = table.querySelectorAll('tbody td')
// The deepest column path is a leaf; anything shorter is an aggregate of
@ -1271,6 +1291,15 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
if (Array.isArray(path)) depth = Math.max(depth, path.filter(notBlank).length)
}
// A rule only means something when a group spans several columns. A path
// ends with the measure, so depth counts split levels plus one: with a
// single split level (or the axis collapsed to one) and a single measure,
// every group is one column and the rule would sit on every column,
// separating nothing. Measures are counted, not read off the config,
// because two of them under one split value do make a group worth ruling.
const measures = new Set(metas.map(([, m]) => m?.column_header?.at?.(-1)).filter(notBlank))
const ruled = depth > 2 || (depth === 2 && measures.size > 1)
let prevGroup = null
for (const [td, meta] of metas) {
td.classList.remove('pf-group-start', 'pf-subtotal')
@ -1278,7 +1307,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
if (!Array.isArray(path) || !path.length) { prevGroup = null; continue }
const named = path.filter(notBlank)
const group = named[0]
if (group !== prevGroup) { td.classList.add('pf-group-start'); prevGroup = group }
if (group !== prevGroup) { if (ruled) td.classList.add('pf-group-start'); prevGroup = group }
if (named.length < depth) td.classList.add('pf-subtotal')
}
})
@ -1471,8 +1500,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
white-space: nowrap !important;
}
th.psp-split-total:not(.psp-split-subtotal),
td.psp-split-total:not(.psp-split-subtotal) {
/* The grand-total column, hidden unless the layout asks for it
* (data-pf-col-total, set by adoptTotals and the style listener). */
regular-table:not([data-pf-col-total]) th.psp-split-total:not(.psp-split-subtotal),
regular-table:not([data-pf-col-total]) td.psp-split-total:not(.psp-split-subtotal) {
visibility: hidden !important;
width: 0 !important;
min-width: 0 !important;
@ -1613,10 +1644,40 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const viewer = viewerRef.current
if (!viewer) return null
const cfg = await viewer.save()
// Both depths are already in cfg, straight from save(). Nothing of ours needs
// to travel beside it any more -- split_full existed only because collapsing
// truncated split_by and the discarded levels had to be remembered.
return cfg
// Both depths are already in cfg, straight from save(). The one thing of
// ours that travels beside it is the totals choice, which the engine has no
// field for (see PF_TOTALS). split_full used to as well, only because
// collapsing truncated split_by and the discarded levels had to be
// remembered.
return { ...cfg, [PF_TOTALS]: { ...totalsRef.current } }
}
// Show or hide the grand totals. Both are DOM-side, so this is a redraw, not
// a rebuild: the row is tagged in the style listener, the column hidden by
// GRID_CSS unless the table carries data-pf-col-total. The attribute is set
// here as well as per draw so the draw that follows already sizes the column.
function adoptTotals(t) {
totalsRef.current = t
setTotals(t)
const table = gridRef.current?.regular_table
if (!table) return
table.toggleAttribute('data-pf-col-total', t.col)
table.draw()
}
// A toolbar toggle. It changes the layout without touching the engine, so no
// perspective-config-update follows -- the persist and the dirty dot that
// handler would have done are done here.
async function toggleTotal(axis, on) {
adoptTotals({ ...totalsRef.current, [axis]: on })
try {
const cfg = await captureConfig()
if (!cfg) return
await persistLayout(versionId, cfg)
setLayoutDirty(!!activeConfigRef.current && !sameConfig(cfg, activeConfigRef.current))
} catch (err) {
console.error('[toggleTotal]', err)
}
}
async function persistLayout(vid, cfg) {
@ -1693,6 +1754,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
cfg.plugin_config = { ...(cfg.plugin_config || {}), edit_mode: 'SELECT_REGION' }
pivotBusy(true)
try {
adoptTotals(totalsOf(layout.config))
await viewer.restore(cfg)
await viewer.flush()
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by,
@ -1726,6 +1788,7 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
function resetLayout() {
localStorage.removeItem(LAYOUT_KEY(versionId))
markClean(null, null)
adoptTotals(DEFAULT_TOTALS)
const viewer = viewerRef.current
if (viewer) viewer.restore({ settings: true })
}
@ -2151,8 +2214,8 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
onPick={applyDepth} totalLabel="Total" />
<label className="flex items-center gap-1 text-gray-600 cursor-pointer select-none"
title="Show the grand-total row at the top of the pivot">
<input type="checkbox" checked={showTotalRow}
onChange={e => setShowTotalRow(e.target.checked)} />
<input type="checkbox" checked={totals.row}
onChange={e => toggleTotal('row', e.target.checked)} />
Total row
</label>
</>
@ -2163,6 +2226,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
<div className="pf-group-rule" />
<DepthButtons label="Columns" levels={splitFull} depth={splitDepth}
onPick={applySplitDepth} totalLabel="Total" />
<label className="flex items-center gap-1 text-gray-600 cursor-pointer select-none"
title="Show the grand-total column across every column group">
<input type="checkbox" checked={totals.col}
onChange={e => toggleTotal('col', e.target.checked)} />
Total column
</label>
</>
)}