Skip to content

Commit a2f6a55

Browse files
rusackasclaude
andcommitted
fix(pivot-table): fix combineMetric denominator pruning and multi-metric fraction cross-talk
Three follow-ups from review on the show-values-as-fraction feature: - `showValuesAs` was `renderTrigger: true`, but it changes which rollup levels `buildGroupbyCombinations` requests for non-additive metrics, so switching it needs a real requery, not a client-side-only re-render. - The `combineMetric` filter in `buildGroupbyCombinations` stripped the percent-mode denominator level back out whenever it landed on the opposite axis from the kept metrics-layout level, blanking percentages with Combine metrics enabled. - With multiple metrics sharing an axis, `fractionOf`'s grand/row/column total lookup hit the same metric-blind "Metric-collapse totals" slot for every metric, so all but the last metric divided by the wrong total. `fractionOf` now keeps the pushing record's own metric key in the selector so the lookup resolves to that metric's own total. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f4a5010 commit a2f6a55

4 files changed

Lines changed: 147 additions & 6 deletions

File tree

superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/controlPanel.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -250,7 +250,10 @@ const config: ControlPanelConfig = {
250250
type: 'SelectControl',
251251
label: t('Show values as'),
252252
default: ShowValuesAsEnum.ACTUAL,
253-
renderTrigger: true,
253+
// Not a renderTrigger: for non-additive metrics, a percent choice
254+
// here can require a rollup level (see `buildGroupbyCombinations`)
255+
// that a prior query never fetched, so switching it needs a real
256+
// requery rather than a client-side-only re-render.
254257
choices: [
255258
[ShowValuesAsEnum.ACTUAL, t('Actual values')],
256259
[ShowValuesAsEnum.PERCENT_OF_ROW, t('% of row total')],

superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/utilities.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -250,13 +250,25 @@ export default function buildGroupbyCombinations(
250250
);
251251

252252
if (formData.combineMetric) {
253+
// A forced-in percent-mode denominator level (above) is collapsed on the
254+
// opposite axis from a "normal" subtotal, so it can be mistaken for one
255+
// and stripped back out here. Exempt it explicitly so combining metrics
256+
// doesn't blank out the percent denominator.
257+
const isForcedDenominatorLevel = (combination: Groupby): boolean =>
258+
(needsRowsCollapsed && combination.rows.length === 0) ||
259+
(needsColumnsCollapsed && combination.columns.length === 0);
260+
253261
if (formData.metricsLayout === MetricsLayoutEnum.ROWS) {
254262
groupbyCombinations = groupbyCombinations.filter(
255-
combination => combination.rows.length === rows.length,
263+
combination =>
264+
combination.rows.length === rows.length ||
265+
isForcedDenominatorLevel(combination),
256266
);
257267
} else {
258268
groupbyCombinations = groupbyCombinations.filter(
259-
combination => combination.columns.length === columns.length,
269+
combination =>
270+
combination.columns.length === columns.length ||
271+
isForcedDenominatorLevel(combination),
260272
);
261273
}
262274
}

superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/utilities.ts

Lines changed: 47 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -748,7 +748,39 @@ const baseAggregatorTemplates = {
748748
type
749749
],
750750
inner: wrapped(...Array.from(x || []))(data, rowKey, colKey),
751+
// The metric this cell belongs to, and which axis carries it (see the
752+
// "Metric" pseudo-dimension in PivotTableChart). Captured from the
753+
// first pushed record. With multiple metrics, the axis holding the
754+
// metric is never actually empty, so collapsing it to `[]` (as the
755+
// `selector` above does) would route every metric's lookup to the
756+
// same shared total slot -- see `processRecord`'s "Metric-collapse
757+
// totals". Keeping the metric's own key segment instead routes the
758+
// lookup to the per-metric total that's already correctly split out.
759+
metricAxis: undefined as
760+
| { axis: 'row' | 'col'; value: string }
761+
| null
762+
| undefined,
751763
push(record: PivotRecord) {
764+
if (this.metricAxis === undefined) {
765+
const metricDim = record.__metricKey as unknown as
766+
| string
767+
| undefined;
768+
const cols = data.props.cols as string[] | undefined;
769+
const rows = data.props.rows as string[] | undefined;
770+
if (metricDim && cols?.includes(metricDim)) {
771+
this.metricAxis = {
772+
axis: 'col',
773+
value: String(record[metricDim]),
774+
};
775+
} else if (metricDim && rows?.includes(metricDim)) {
776+
this.metricAxis = {
777+
axis: 'row',
778+
value: String(record[metricDim]),
779+
};
780+
} else {
781+
this.metricAxis = null;
782+
}
783+
}
752784
this.inner.push(record);
753785
},
754786
format: fmtNonString(formatter),
@@ -758,9 +790,21 @@ const baseAggregatorTemplates = {
758790
// back to `null` (rendered blank) instead of throwing if it is
759791
// ever missing -- e.g. a denominator aggregator with no matching
760792
// rows in the response.
761-
const denominatorAggregator = data.getAggregator(
762-
...Array.from(this.selector || []),
763-
);
793+
let [selRow, selCol] = (this.selector || [[], []]) as [
794+
string[],
795+
string[],
796+
];
797+
if (this.metricAxis) {
798+
if (this.metricAxis.axis === 'col' && selCol.length === 0) {
799+
selCol = [this.metricAxis.value];
800+
} else if (
801+
this.metricAxis.axis === 'row' &&
802+
selRow.length === 0
803+
) {
804+
selRow = [this.metricAxis.value];
805+
}
806+
}
807+
const denominatorAggregator = data.getAggregator(selRow, selCol);
764808
if (!denominatorAggregator.inner) {
765809
return null;
766810
}

superset-frontend/plugins/plugin-chart-pivot-table/test/react-pivottable/tableRenders.test.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -837,6 +837,88 @@ test('TableRenderer shows values as a percentage of the column total', () => {
837837
expect(getCellTexts('pvtTotal')).toEqual(expect.arrayContaining(['100.0%']));
838838
});
839839

840+
/**
841+
* Regression guard: when the metric pseudo-dimension collapses to the only
842+
* thing on an axis (the grand-total level), each metric's own grand total
843+
* must be used as the `showValuesAs` denominator -- not whichever metric's
844+
* record was pushed last into the shared "Metric-collapse totals" slot (see
845+
* `processRecord` in ../../src/react-pivottable/utilities.ts).
846+
*/
847+
const TAGGED_MULTI_METRIC_ON_COLUMNS = [
848+
// leaf cells for metric m1 (grand total 30)
849+
{
850+
color: 'blue',
851+
Metric: 'm1',
852+
value: 10,
853+
__rows: ['color'],
854+
__columns: ['Metric'],
855+
__metricKey: 'Metric',
856+
},
857+
{
858+
color: 'red',
859+
Metric: 'm1',
860+
value: 20,
861+
__rows: ['color'],
862+
__columns: ['Metric'],
863+
__metricKey: 'Metric',
864+
},
865+
// leaf cells for metric m2 (grand total 300) -- a different ratio so a
866+
// cross-metric mixup produces a distinctly wrong percentage.
867+
{
868+
color: 'blue',
869+
Metric: 'm2',
870+
value: 250,
871+
__rows: ['color'],
872+
__columns: ['Metric'],
873+
__metricKey: 'Metric',
874+
},
875+
{
876+
color: 'red',
877+
Metric: 'm2',
878+
value: 50,
879+
__rows: ['color'],
880+
__columns: ['Metric'],
881+
__metricKey: 'Metric',
882+
},
883+
// grand total level: rows = [], columns = [Metric]. m2 is pushed last.
884+
{
885+
Metric: 'm1',
886+
value: 30,
887+
__rows: [],
888+
__columns: ['Metric'],
889+
__metricKey: 'Metric',
890+
},
891+
{
892+
Metric: 'm2',
893+
value: 300,
894+
__rows: [],
895+
__columns: ['Metric'],
896+
__metricKey: 'Metric',
897+
},
898+
];
899+
900+
test("TableRenderer divides percent_total by each metric's own grand total", () => {
901+
const props = buildDefaultProps({
902+
data: TAGGED_MULTI_METRIC_ON_COLUMNS,
903+
rows: ['color'],
904+
cols: ['Metric'],
905+
vals: ['value'],
906+
tableOptions: { rowTotals: true, colTotals: true },
907+
showValuesAs: 'percent_total',
908+
});
909+
renderWithTheme(<TableRenderer {...props} />);
910+
911+
const cellTexts = getCellTexts('pvtVal');
912+
// m1: 10/30 and 20/30 -- correct only if m1's own grand total (30) is used.
913+
expect(cellTexts).toEqual(expect.arrayContaining(['33.3%', '66.7%']));
914+
// m2: 250/300 and 50/300.
915+
expect(cellTexts).toEqual(expect.arrayContaining(['83.3%', '16.7%']));
916+
// A "last metric wins" bug would divide m1's cells by m2's grand total
917+
// (300) instead, producing 3.3%/6.7%.
918+
expect(cellTexts).not.toEqual(expect.arrayContaining(['3.3%']));
919+
expect(cellTexts).not.toEqual(expect.arrayContaining(['6.7%']));
920+
});
921+
840922
test('TableRenderer shows actual values when showValuesAs is unset (default)', () => {
841923
const props = buildDefaultProps({
842924
data: TAGGED_COUNT_DATA,

0 commit comments

Comments
 (0)