Let a total cell be selected inside a filtered pivot

A grand-total cell is its displayed amount like any other: under a column
it is that column's values, and inside a filtered pivot it is what the
filter leaves. Its slice can therefore be {}, which is accepted only
alongside a non-empty scope -- without one it would be the whole version,
and is still refused on both sides. buildWhereAny reads a union containing
{} as TRUE, the scope ANDed on afterwards. The panel names it "Total".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-28 21:05:51 -04:00
parent 6b2ecbbcd6
commit 0c9746d3be
5 changed files with 42 additions and 23 deletions

View File

@ -676,7 +676,7 @@ When the user clicks a pivot cell, `perspective-click` fires. The handler in `Fo
Dragging across a block of cells selects a region. The datagrid runs in `edit_mode: SELECT_REGION` (forced on restore, so a saved layout can't switch it off) and reports the region as a `perspective-select` event carrying a Perspective **ViewWindow** — `{ start_row, end_row, start_col, end_col }`, *not* the per-row `insertConfigs` payload an older API used. It fires on every mouseover as the region grows, so the handler only records the latest window and a window-level `mouseup` commits it. A single-cell region is ignored there: `perspective-click` already owns plain and modifier clicks, and handling it in both places would undo a ctrl-click toggle.
Turning a region back into slices re-derives, per cell, the same filters Perspective attaches to a click — row dimensions from the view's `__ROW_PATH__` (raw values, so dates stay epoch millis rather than whatever the grid formatted them as), column dimensions from the split_by segments of the column name. The grand-total row resolves to no dimension at all and is skipped; that would mean "the whole version".
Turning a region back into slices re-derives, per cell, the same filters Perspective attaches to a click — row dimensions from the view's `__ROW_PATH__` (raw values, so dates stay epoch millis rather than whatever the grid formatted them as), column dimensions from the split_by segments of the column name. **A total cell is its displayed amount.** The grand-total row resolves to no row dimension, so its slice is whatever the column axis and the pivot's `==` filters leave — possibly `{}`, meaning *everything the pivot's filter shows*. An empty slice is accepted only alongside a non-empty `scope` (`normalizeSlices()` / `assertSelective()` in `routes/operations.js`); with no filter at all it would be the whole version, and is refused on both sides. `buildWhereAny()` reads a union containing `{}` as `TRUE`, the scope then ANDed on — dropping it would narrow the write to the other cells.
**Selection highlight.** The datagrid highlights whatever sits in its own `model._selection_state.selected_areas`, and wipes that list on every mousedown — so a multi-slice selection built up over several ctrl-clicks would only ever show the last cell. `Forecast.jsx` keeps `areasRef`, a `sliceKey -> rectangles` map parallel to `slices`, and an effect pushes the full set back and redraws after every change. Deselecting anywhere (ctrl-click, the panel's ×, Clear selection) prunes the map by live slice key, so the grid and the panel can't disagree.

View File

@ -727,8 +727,11 @@ function buildTerritoryClause(territory, territoryCol, alias = '') {
// {Region:East, State:NY} and {Region:West, State:CA} would become
// Region IN (East,West) AND State IN (NY,CA), which also matches East/CA.
function buildWhereAny(slices, dimCols, versionId) {
const list = (slices || []).filter(s => s && Object.keys(s).length > 0);
if (list.length === 0) return 'TRUE';
const list = (slices || []).filter(Boolean);
// An empty slice is a total cell, and the union with a total is the total.
// Dropping it instead would narrow the write to the other cells. The caller
// only lets one through under a scope, which is ANDed on afterwards.
if (list.length === 0 || list.some(s => Object.keys(s).length === 0)) return 'TRUE';
if (list.length === 1) return buildWhere(list[0], dimCols, versionId);
const groups = list

View File

@ -42,11 +42,14 @@ module.exports = function(pool) {
return (client || pool).query(sql);
}
// accept either the legacy single `slice` object or the newer `slices` array,
// and drop any empty entries so an empty selection can never widen to TRUE
// accept either the legacy single `slice` object or the newer `slices` array.
// An empty slice is a total cell -- everything the pivot's filter shows -- so
// it is kept only when a scope came with it; without one it would be the
// whole version, and is dropped so an empty selection can never widen to TRUE
function normalizeSlices(body) {
const raw = Array.isArray(body.slices) && body.slices.length ? body.slices : [body.slice];
return raw.filter(s => s && typeof s === 'object' && Object.keys(s).length > 0);
const scoped = Array.isArray(body.scope) && body.scope.length > 0;
return raw.filter(s => s && typeof s === 'object' && (scoped || Object.keys(s).length > 0));
}
// Stamp the tag onto the log entry the operation just created.
@ -127,9 +130,13 @@ module.exports = function(pool) {
// column. buildWhere silently drops unknown keys, so {"typo": "x"} would
// otherwise reduce to TRUE and apply the operation to the whole version.
// Refuse rather than let a malformed selection rewrite every row.
function assertSelective(slices, ctx) {
// An empty slice passes only under a scope (see normalizeSlices), which
// buildScopeClause refuses to let reduce to nothing.
function assertSelective(slices, ctx, scope) {
const allowed = new Set([...ctx.filterCols, ...Object.keys(COMPUTED_SLICE_COLS)]);
const scoped = Array.isArray(scope) && scope.length > 0;
slices.forEach((sl, i) => {
if (scoped && Object.keys(sl).length === 0) return;
const hits = Object.keys(sl).filter(k => allowed.has(k));
if (hits.length === 0) {
const err = new Error(
@ -828,7 +835,7 @@ module.exports = function(pool) {
try {
const ctx = await getContext(parseInt(req.params.id), 'scale');
if (!guardOpen(ctx.version, res)) return;
assertSelective(slices, ctx);
assertSelective(slices, ctx, req.body.scope);
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
@ -939,7 +946,7 @@ module.exports = function(pool) {
try {
const ctx = await getContext(parseInt(req.params.id), 'recode');
if (!guardOpen(ctx.version, res)) return;
assertSelective(slices, ctx);
assertSelective(slices, ctx, req.body.scope);
if (!assertMayRecodeTerritory(req, ctx, set, res)) return;
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
@ -1033,7 +1040,7 @@ module.exports = function(pool) {
try {
const ctx = await getContext(parseInt(req.params.id), 'clone');
if (!guardOpen(ctx.version, res)) return;
assertSelective(slices, ctx);
assertSelective(slices, ctx, req.body.scope);
const scaleFactor = (scale != null) ? parseFloat(scale) : 1.0;
const dateOffset = (date_offset || '0 days').trim() || '0 days';

View File

@ -39,7 +39,7 @@ function dispVal(v) {
function sliceLabel(s) {
const entries = Object.entries(s)
return entries.length ? entries.map(([k, v]) => `${k}=${dispVal(v)}`).join(' · ') : '—'
return entries.length ? entries.map(([k, v]) => `${k}=${dispVal(v)}`).join(' · ') : 'Total'
}
// A light grouping with an optional caption — a rule between blocks in the
@ -275,6 +275,10 @@ function SelectionList({ slices, viewScope = [], distinctSlices, currentTotals,
? <span className="font-mono text-gray-700">{sliceLabel(s)}</span>
: (
<div className="flex flex-col gap-0.5">
{/* a total cell: everything the pivot's filter shows */}
{!Object.keys(s).length && (
<span className="font-medium text-gray-700">Total{scopeLine ? ' within the filter' : ''}</span>
)}
{Object.entries(s).map(([k, v]) => (
<div key={k} className="whitespace-nowrap">
<span className="text-gray-600">{k}</span>

View File

@ -397,7 +397,6 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
if (!viewer) return []
const cfg = await viewer.save()
const view = await viewer.getView()
const groupBy = cfg.group_by || []
const base = cfg.filter || []
const rows = await view.to_json({ start_row: win.start_row, end_row: win.end_row })
if (!rows.length) return []
@ -406,18 +405,15 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
const c1 = win.end_col ?? userKeys.length
const out = []
for (let i = 0; i < rows.length; i++) {
const path = rows[i].__ROW_PATH__ || []
// the grand-total row resolves to no dimension at all, which would mean
// "the whole version" — never what dragging over it is asking for
if (groupBy.length && !path.length) continue
for (let c = c0; c < c1; c++) {
const key = userKeys[c]
if (!key) continue
const { rowFilters, colFilters } = cellFilters(cfg, rows[i], key)
if (groupBy.length && !rowFilters.length) continue
const slice = sliceFromFilters([...base, ...rowFilters, ...colFilters],
(cfg.columns || []).filter(Boolean))
if (!Object.keys(slice).length) continue
// a total cell counts when its column or the pivot's filter narrows it;
// one nothing narrows would mean the whole version (see perspective-click)
if (!Object.keys(slice).length && !base.length) continue
const y = win.start_row + i
out.push({ slice, area: { x0: c, x1: c, y0: y, y1: y } })
}
@ -1119,12 +1115,16 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
let s
if (cell) {
const { rowFilters, colFilters } = cellFilters(config, detail.row, colKey)
if (!rowFilters.length) return
s = sliceFromFilters([...(config.filter || []), ...rowFilters, ...colFilters], measures)
} else {
s = sliceFromFilters((detail.config || {}).filter || [], measures)
}
if (!Object.keys(s).length) return
// A total cell is its displayed amount like any other: the grand-total
// row under a column is that column's values, and inside a filtered
// pivot it is what the filter leaves -- which travels as `scope`, not in
// the slice. Only a cell nothing narrows at all is refused, since that
// would be the whole version.
if (!Object.keys(s).length && !(config.filter || []).length) return
// the CustomEvent carries no modifier flags, so read them off the
// mousedown that produced it (captured on window below)
const additive = modifierRef.current
@ -1803,8 +1803,10 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
// equality: a view filtered to sseas_e <= 2027 has no slice form at all.
const body = buildPayload(op)
if (!body) return
if (body.slices.some(sl => !Object.keys(sl).length)) {
flash('No dimension or date columns in slice — check col_meta', 'error'); return
// an empty slice is a total cell, and means "everything the pivot's filter
// shows" -- so it needs that filter, or it would be the whole version
if (!body.scope && body.slices.some(sl => !Object.keys(sl).length)) {
flash('That total covers the whole forecast — filter the pivot or pick a narrower cell', 'error'); return
}
if (op === 'scale') {
const has = ['target_value','target_units','target_price',
@ -2761,7 +2763,10 @@ function fmtSliceLines(slice) {
const one = (sl) => Object.entries(sl)
.map(([k, v]) => `${k} = ${fmtSliceValue(v)}`)
.join(' · ')
if (!Array.isArray(slice)) return Object.keys(slice).length ? [one(slice)] : []
// an empty slice is a total cell: everything the pivot's filter showed
const TOTAL = 'Total within the pivot filter'
if (!Array.isArray(slice)) return [Object.keys(slice).length ? one(slice) : TOTAL]
if (slice.some(sl => sl && !Object.keys(sl).length)) return [TOTAL]
const list = slice.filter(sl => sl && Object.keys(sl).length)
if (list.length <= 1) return list.map(one)