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:
parent
6b2ecbbcd6
commit
0c9746d3be
@ -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.
|
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.
|
**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.
|
||||||
|
|
||||||
|
|||||||
@ -727,8 +727,11 @@ function buildTerritoryClause(territory, territoryCol, alias = '') {
|
|||||||
// {Region:East, State:NY} and {Region:West, State:CA} would become
|
// {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.
|
// Region IN (East,West) AND State IN (NY,CA), which also matches East/CA.
|
||||||
function buildWhereAny(slices, dimCols, versionId) {
|
function buildWhereAny(slices, dimCols, versionId) {
|
||||||
const list = (slices || []).filter(s => s && Object.keys(s).length > 0);
|
const list = (slices || []).filter(Boolean);
|
||||||
if (list.length === 0) return 'TRUE';
|
// 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);
|
if (list.length === 1) return buildWhere(list[0], dimCols, versionId);
|
||||||
|
|
||||||
const groups = list
|
const groups = list
|
||||||
|
|||||||
@ -42,11 +42,14 @@ module.exports = function(pool) {
|
|||||||
return (client || pool).query(sql);
|
return (client || pool).query(sql);
|
||||||
}
|
}
|
||||||
|
|
||||||
// accept either the legacy single `slice` object or the newer `slices` array,
|
// 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
|
// 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) {
|
function normalizeSlices(body) {
|
||||||
const raw = Array.isArray(body.slices) && body.slices.length ? body.slices : [body.slice];
|
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.
|
// 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
|
// column. buildWhere silently drops unknown keys, so {"typo": "x"} would
|
||||||
// otherwise reduce to TRUE and apply the operation to the whole version.
|
// otherwise reduce to TRUE and apply the operation to the whole version.
|
||||||
// Refuse rather than let a malformed selection rewrite every row.
|
// 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 allowed = new Set([...ctx.filterCols, ...Object.keys(COMPUTED_SLICE_COLS)]);
|
||||||
|
const scoped = Array.isArray(scope) && scope.length > 0;
|
||||||
slices.forEach((sl, i) => {
|
slices.forEach((sl, i) => {
|
||||||
|
if (scoped && Object.keys(sl).length === 0) return;
|
||||||
const hits = Object.keys(sl).filter(k => allowed.has(k));
|
const hits = Object.keys(sl).filter(k => allowed.has(k));
|
||||||
if (hits.length === 0) {
|
if (hits.length === 0) {
|
||||||
const err = new Error(
|
const err = new Error(
|
||||||
@ -828,7 +835,7 @@ module.exports = function(pool) {
|
|||||||
try {
|
try {
|
||||||
const ctx = await getContext(parseInt(req.params.id), 'scale');
|
const ctx = await getContext(parseInt(req.params.id), 'scale');
|
||||||
if (!guardOpen(ctx.version, res)) return;
|
if (!guardOpen(ctx.version, res)) return;
|
||||||
assertSelective(slices, ctx);
|
assertSelective(slices, ctx, req.body.scope);
|
||||||
|
|
||||||
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
|
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
|
||||||
|
|
||||||
@ -939,7 +946,7 @@ module.exports = function(pool) {
|
|||||||
try {
|
try {
|
||||||
const ctx = await getContext(parseInt(req.params.id), 'recode');
|
const ctx = await getContext(parseInt(req.params.id), 'recode');
|
||||||
if (!guardOpen(ctx.version, res)) return;
|
if (!guardOpen(ctx.version, res)) return;
|
||||||
assertSelective(slices, ctx);
|
assertSelective(slices, ctx, req.body.scope);
|
||||||
if (!assertMayRecodeTerritory(req, ctx, set, res)) return;
|
if (!assertMayRecodeTerritory(req, ctx, set, res)) return;
|
||||||
|
|
||||||
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
|
const excludeClause = buildExcludeClause(ctx.version.exclude_iters);
|
||||||
@ -1033,7 +1040,7 @@ module.exports = function(pool) {
|
|||||||
try {
|
try {
|
||||||
const ctx = await getContext(parseInt(req.params.id), 'clone');
|
const ctx = await getContext(parseInt(req.params.id), 'clone');
|
||||||
if (!guardOpen(ctx.version, res)) return;
|
if (!guardOpen(ctx.version, res)) return;
|
||||||
assertSelective(slices, ctx);
|
assertSelective(slices, ctx, req.body.scope);
|
||||||
|
|
||||||
const scaleFactor = (scale != null) ? parseFloat(scale) : 1.0;
|
const scaleFactor = (scale != null) ? parseFloat(scale) : 1.0;
|
||||||
const dateOffset = (date_offset || '0 days').trim() || '0 days';
|
const dateOffset = (date_offset || '0 days').trim() || '0 days';
|
||||||
|
|||||||
@ -39,7 +39,7 @@ function dispVal(v) {
|
|||||||
|
|
||||||
function sliceLabel(s) {
|
function sliceLabel(s) {
|
||||||
const entries = Object.entries(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
|
// 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>
|
? <span className="font-mono text-gray-700">{sliceLabel(s)}</span>
|
||||||
: (
|
: (
|
||||||
<div className="flex flex-col gap-0.5">
|
<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]) => (
|
{Object.entries(s).map(([k, v]) => (
|
||||||
<div key={k} className="whitespace-nowrap">
|
<div key={k} className="whitespace-nowrap">
|
||||||
<span className="text-gray-600">{k}</span>
|
<span className="text-gray-600">{k}</span>
|
||||||
|
|||||||
@ -397,7 +397,6 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
if (!viewer) return []
|
if (!viewer) return []
|
||||||
const cfg = await viewer.save()
|
const cfg = await viewer.save()
|
||||||
const view = await viewer.getView()
|
const view = await viewer.getView()
|
||||||
const groupBy = cfg.group_by || []
|
|
||||||
const base = cfg.filter || []
|
const base = cfg.filter || []
|
||||||
const rows = await view.to_json({ start_row: win.start_row, end_row: win.end_row })
|
const rows = await view.to_json({ start_row: win.start_row, end_row: win.end_row })
|
||||||
if (!rows.length) return []
|
if (!rows.length) return []
|
||||||
@ -406,18 +405,15 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
|
|||||||
const c1 = win.end_col ?? userKeys.length
|
const c1 = win.end_col ?? userKeys.length
|
||||||
const out = []
|
const out = []
|
||||||
for (let i = 0; i < rows.length; i++) {
|
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++) {
|
for (let c = c0; c < c1; c++) {
|
||||||
const key = userKeys[c]
|
const key = userKeys[c]
|
||||||
if (!key) continue
|
if (!key) continue
|
||||||
const { rowFilters, colFilters } = cellFilters(cfg, rows[i], key)
|
const { rowFilters, colFilters } = cellFilters(cfg, rows[i], key)
|
||||||
if (groupBy.length && !rowFilters.length) continue
|
|
||||||
const slice = sliceFromFilters([...base, ...rowFilters, ...colFilters],
|
const slice = sliceFromFilters([...base, ...rowFilters, ...colFilters],
|
||||||
(cfg.columns || []).filter(Boolean))
|
(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
|
const y = win.start_row + i
|
||||||
out.push({ slice, area: { x0: c, x1: c, y0: y, y1: y } })
|
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
|
let s
|
||||||
if (cell) {
|
if (cell) {
|
||||||
const { rowFilters, colFilters } = cellFilters(config, detail.row, colKey)
|
const { rowFilters, colFilters } = cellFilters(config, detail.row, colKey)
|
||||||
if (!rowFilters.length) return
|
|
||||||
s = sliceFromFilters([...(config.filter || []), ...rowFilters, ...colFilters], measures)
|
s = sliceFromFilters([...(config.filter || []), ...rowFilters, ...colFilters], measures)
|
||||||
} else {
|
} else {
|
||||||
s = sliceFromFilters((detail.config || {}).filter || [], measures)
|
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
|
// the CustomEvent carries no modifier flags, so read them off the
|
||||||
// mousedown that produced it (captured on window below)
|
// mousedown that produced it (captured on window below)
|
||||||
const additive = modifierRef.current
|
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.
|
// equality: a view filtered to sseas_e <= 2027 has no slice form at all.
|
||||||
const body = buildPayload(op)
|
const body = buildPayload(op)
|
||||||
if (!body) return
|
if (!body) return
|
||||||
if (body.slices.some(sl => !Object.keys(sl).length)) {
|
// an empty slice is a total cell, and means "everything the pivot's filter
|
||||||
flash('No dimension or date columns in slice — check col_meta', 'error'); return
|
// 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') {
|
if (op === 'scale') {
|
||||||
const has = ['target_value','target_units','target_price',
|
const has = ['target_value','target_units','target_price',
|
||||||
@ -2761,7 +2763,10 @@ function fmtSliceLines(slice) {
|
|||||||
const one = (sl) => Object.entries(sl)
|
const one = (sl) => Object.entries(sl)
|
||||||
.map(([k, v]) => `${k} = ${fmtSliceValue(v)}`)
|
.map(([k, v]) => `${k} = ${fmtSliceValue(v)}`)
|
||||||
.join(' · ')
|
.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)
|
const list = slice.filter(sl => sl && Object.keys(sl).length)
|
||||||
if (list.length <= 1) return list.map(one)
|
if (list.length <= 1) return list.map(one)
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user