Drop the measure name from a collapsed column's slice

Clicking a bucket subtotal with the month level collapsed produced
{"customer": "...", "pf_bucket": "04 - Forecast", "smon_e": "sales_usd"} --
a month equal to a measure, matching nothing, so the ledger came back empty
and an operation would have had no rows to act on.

Perspective maps split_by positionally over the column name, and a collapsed
axis has fewer segments than there are split_by levels, so the measure lands
on the first hidden dimension. Both slice paths now drop any == filter whose
value is one of the view's measures; the region path additionally takes the
measure off the end of the column name before mapping, which is the same
error made in our own code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Trowbridge 2026-09-18 08:16:09 -04:00
parent 50a0bb42aa
commit f94aa4ec99

View File

@ -323,13 +323,23 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
for (let c = c0; c < c1; c++) {
const key = userKeys[c]
if (!key) continue
// A column name is dimension values joined by | with the measure last, and
// only as many values as the axis currently shows -- collapse the column
// hierarchy and the deeper levels are simply absent. Mapping split_by
// positionally over every segment therefore read the measure as a value
// for the first collapsed dimension: a bucket subtotal came back as
// smon_e = 'sales_usd', which matches no row, so the operation silently
// had nothing to act on. Drop the measure, then map over what is left.
const segs = key.split('|').slice(0, -1)
const colFilters = splitBy
.slice(0, segs.length)
.map((col, ix) => {
const v = key.split('|')[ix]
const v = segs[ix]
return (v && !META_COL_RE.test(v)) ? [col, '==', v] : null
})
.filter(Boolean)
const slice = sliceFromFilters([...base, ...rowFilters, ...colFilters])
const slice = sliceFromFilters([...base, ...rowFilters, ...colFilters],
(cfg.columns || []).filter(Boolean))
if (!Object.keys(slice).length) continue
const y = win.start_row + i
out.push({ slice, area: { x0: c, x1: c, y0: y, y1: y } })
@ -770,7 +780,8 @@ export default function Forecast({ sources = [], sourceId, versions = [], versio
if (!detail.row) return
const config = await viewer.save()
if (!(config.group_by || []).length) return
const s = sliceFromFilters((detail.config || {}).filter || [])
const s = sliceFromFilters((detail.config || {}).filter || [],
(config.columns || []).filter(Boolean))
if (!Object.keys(s).length) return
// the CustomEvent carries no modifier flags, so read them off the
// mousedown that produced it (captured on window below)
@ -1948,12 +1959,22 @@ function LogCell({ entry, field, placeholder, editing, setEditing, onSave, listI
const META_COL_RE = /^__(?:ROW_PATH(?:_\d+)?|ID|GROUPING_ID)__$/
// Perspective encodes a clicked/selected row position as [col, '==', value] triples
function sliceFromFilters(filters) {
// `measures` is the view's `columns` list. Clicking a cell whose column axis is
// collapsed makes Perspective emit the measure name as the value of the first
// hidden split_by dimension -- a bucket subtotal arrives as
// ["smon_e", "==", "sales_usd"] -- because the engine maps split_by positionally
// over a column name that no longer has that many segments. Left in, the slice
// asks for a month equal to a measure, matches nothing, and the operation
// silently has no rows to act on.
function sliceFromFilters(filters, measures = []) {
const measureSet = new Set(measures)
const s = {}
for (const f of filters) {
if (!Array.isArray(f)) continue
const [col, op, val] = f
if (op === '==' && val != null) s[col] = String(val)
if (op !== '==' || val == null) continue
if (measureSet.has(String(val))) continue
s[col] = String(val)
}
return s
}