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
|
published layout with no owner. That route is gone; the column remains, migrated
|
||||||
and read by nothing.
|
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
|
`cleanLayout()` guards `aggregates` along with the axes — including the weight
|
||||||
column of the multi-arg form — and drops the offending *entry* rather than the
|
column of the multi-arg form — and drops the offending *entry* rather than the
|
||||||
layout, since `restore()` is all-or-nothing.
|
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
|
// 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.
|
// layout cannot impose its author's theme on everyone who applies it.
|
||||||
delete c.theme
|
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
|
// Dead expressions go before the axis filter, not after: dropping them from
|
||||||
// `expressions` is what makes `ok()` reject them everywhere else.
|
// `expressions` is what makes `ok()` reject them everywhere else.
|
||||||
if (DEAD_ORDER_EXPRS.some(n => c.expressions?.[n] !== undefined)) {
|
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.
|
// names a column that no longer exists.
|
||||||
const DEAD_ORDER_EXPRS = ['pf_bucket_ord', 'pf_segment_ord', 'Bucket', 'Segment']
|
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 }) {
|
export default function Forecast({ sources = [], sourceId, versions = [], versionId, refreshSources }) {
|
||||||
const { dark } = useTheme()
|
const { dark } = useTheme()
|
||||||
const { user } = useAuth()
|
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_scale_plug_v2', scalePlug) }, [scalePlug])
|
||||||
useEffect(() => { localStorage.setItem('pf_panel_open', panelOpen ? 'open' : 'closed') }, [panelOpen])
|
useEffect(() => { localStorage.setItem('pf_panel_open', panelOpen ? 'open' : 'closed') }, [panelOpen])
|
||||||
|
|
||||||
// Whether the grand-total row is drawn. Per-browser, like the panel state: it
|
// Whether the grand-total row and grand-total column are drawn. Part of the
|
||||||
// is how you like to read the grid, not a property of the layout.
|
// layout (see PF_TOTALS), so the ref is what captureConfig reads -- it has to
|
||||||
const [showTotalRow, setShowTotalRow] = useState(() => localStorage.getItem('pf_total_row') !== 'hidden')
|
// be current the moment a toggle persists, before React has re-rendered.
|
||||||
const showTotalRowRef = useRef(showTotalRow)
|
const [totals, setTotals] = useState(DEFAULT_TOTALS)
|
||||||
useEffect(() => {
|
const totalsRef = useRef(DEFAULT_TOTALS)
|
||||||
localStorage.setItem('pf_total_row', showTotalRow ? 'shown' : 'hidden')
|
|
||||||
showTotalRowRef.current = showTotalRow
|
|
||||||
gridRef.current?.regular_table?.draw()
|
|
||||||
}, [showTotalRow])
|
|
||||||
|
|
||||||
// Esc closes the panel — the usual way out of the floating window
|
// Esc closes the panel — the usual way out of the floating window
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -1002,9 +1015,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
// localStorage and comes back when there are rows again.
|
// localStorage and comes back when there are rows again.
|
||||||
const saved = rowCount > 0 ? localStorage.getItem(LAYOUT_KEY(vid)) : null
|
const saved = rowCount > 0 ? localStorage.getItem(LAYOUT_KEY(vid)) : null
|
||||||
if (rowCount === 0) {
|
if (rowCount === 0) {
|
||||||
|
adoptTotals(DEFAULT_TOTALS)
|
||||||
await viewer.restore({ settings: false, plugin_config: { edit_mode: 'SELECT_REGION' } })
|
await viewer.restore({ settings: false, plugin_config: { edit_mode: 'SELECT_REGION' } })
|
||||||
} else if (saved) {
|
} 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' } }
|
const cfg = { ...rest, plugin_config: { ...(rest.plugin_config || {}), edit_mode: 'SELECT_REGION' } }
|
||||||
await viewer.restore(cfg)
|
await viewer.restore(cfg)
|
||||||
// split_full is the legacy key, from when collapsing truncated split_by
|
// 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 defaultRow = defaults.find(l => l.version_id != null) || defaults[0]
|
||||||
const sourceDefault = defaultRow?.config
|
const sourceDefault = defaultRow?.config
|
||||||
let cfg
|
let cfg
|
||||||
|
adoptTotals(totalsOf(sourceDefault))
|
||||||
if (sourceDefault && Object.keys(sourceDefault).length > 0) {
|
if (sourceDefault && Object.keys(sourceDefault).length > 0) {
|
||||||
const { table: _t, ...rest } = cleanLayout(sourceDefault, validCols)
|
const { table: _t, ...rest } = cleanLayout(sourceDefault, validCols)
|
||||||
cfg = { ...rest, plugin_config: { ...(rest.plugin_config || {}), edit_mode: 'SELECT_REGION' } }
|
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
|
// dirty dot has something to compare against. Read back rather than
|
||||||
// reusing cfg: restore() normalises, so the live config is what a later
|
// reusing cfg: restore() normalises, so the live config is what a later
|
||||||
// save() will be compared with.
|
// 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
|
// 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 head = meta?.row_header
|
||||||
const isTotal = meta?.y === 0 && Array.isArray(head) &&
|
const isTotal = meta?.y === 0 && Array.isArray(head) &&
|
||||||
String(head[0]) === 'TOTAL' && !head.slice(1).some(notBlank)
|
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')
|
const body = table.querySelectorAll('tbody td')
|
||||||
// The deepest column path is a leaf; anything shorter is an aggregate of
|
// 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)
|
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
|
let prevGroup = null
|
||||||
for (const [td, meta] of metas) {
|
for (const [td, meta] of metas) {
|
||||||
td.classList.remove('pf-group-start', 'pf-subtotal')
|
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 }
|
if (!Array.isArray(path) || !path.length) { prevGroup = null; continue }
|
||||||
const named = path.filter(notBlank)
|
const named = path.filter(notBlank)
|
||||||
const group = named[0]
|
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')
|
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;
|
white-space: nowrap !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
th.psp-split-total:not(.psp-split-subtotal),
|
/* The grand-total column, hidden unless the layout asks for it
|
||||||
td.psp-split-total:not(.psp-split-subtotal) {
|
* (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;
|
visibility: hidden !important;
|
||||||
width: 0 !important;
|
width: 0 !important;
|
||||||
min-width: 0 !important;
|
min-width: 0 !important;
|
||||||
@ -1613,10 +1644,40 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
const viewer = viewerRef.current
|
const viewer = viewerRef.current
|
||||||
if (!viewer) return null
|
if (!viewer) return null
|
||||||
const cfg = await viewer.save()
|
const cfg = await viewer.save()
|
||||||
// Both depths are already in cfg, straight from save(). Nothing of ours needs
|
// Both depths are already in cfg, straight from save(). The one thing of
|
||||||
// to travel beside it any more -- split_full existed only because collapsing
|
// ours that travels beside it is the totals choice, which the engine has no
|
||||||
// truncated split_by and the discarded levels had to be remembered.
|
// field for (see PF_TOTALS). split_full used to as well, only because
|
||||||
return cfg
|
// 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) {
|
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' }
|
cfg.plugin_config = { ...(cfg.plugin_config || {}), edit_mode: 'SELECT_REGION' }
|
||||||
pivotBusy(true)
|
pivotBusy(true)
|
||||||
try {
|
try {
|
||||||
|
adoptTotals(totalsOf(layout.config))
|
||||||
await viewer.restore(cfg)
|
await viewer.restore(cfg)
|
||||||
await viewer.flush()
|
await viewer.flush()
|
||||||
adoptSplit(cfg.split_full?.length ? cfg.split_full : cfg.split_by,
|
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() {
|
function resetLayout() {
|
||||||
localStorage.removeItem(LAYOUT_KEY(versionId))
|
localStorage.removeItem(LAYOUT_KEY(versionId))
|
||||||
markClean(null, null)
|
markClean(null, null)
|
||||||
|
adoptTotals(DEFAULT_TOTALS)
|
||||||
const viewer = viewerRef.current
|
const viewer = viewerRef.current
|
||||||
if (viewer) viewer.restore({ settings: true })
|
if (viewer) viewer.restore({ settings: true })
|
||||||
}
|
}
|
||||||
@ -2151,8 +2214,8 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
onPick={applyDepth} totalLabel="Total" />
|
onPick={applyDepth} totalLabel="Total" />
|
||||||
<label className="flex items-center gap-1 text-gray-600 cursor-pointer select-none"
|
<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">
|
title="Show the grand-total row at the top of the pivot">
|
||||||
<input type="checkbox" checked={showTotalRow}
|
<input type="checkbox" checked={totals.row}
|
||||||
onChange={e => setShowTotalRow(e.target.checked)} />
|
onChange={e => toggleTotal('row', e.target.checked)} />
|
||||||
Total row
|
Total row
|
||||||
</label>
|
</label>
|
||||||
</>
|
</>
|
||||||
@ -2163,6 +2226,12 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
<div className="pf-group-rule" />
|
<div className="pf-group-rule" />
|
||||||
<DepthButtons label="Columns" levels={splitFull} depth={splitDepth}
|
<DepthButtons label="Columns" levels={splitFull} depth={splitDepth}
|
||||||
onPick={applySplitDepth} totalLabel="Total" />
|
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