From f94aa4ec99c3ce2de0f345859b10daad85b9ef2a Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Fri, 18 Sep 2026 08:16:09 -0400 Subject: [PATCH] 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) --- ui/src/views/Forecast.jsx | 31 ++++++++++++++++++++++++++----- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/ui/src/views/Forecast.jsx b/ui/src/views/Forecast.jsx index dc8f382..5982bfd 100644 --- a/ui/src/views/Forecast.jsx +++ b/ui/src/views/Forecast.jsx @@ -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 }