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:
parent
061fc9fff4
commit
816ff7ab4c
@ -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.
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user