feat: added conditional formatting enhancements string to pivot table (#35863)

This commit is contained in:
SBIN2010
2026-02-17 01:08:41 +03:00
committed by GitHub
parent 3e3c9686de
commit 84f1ee4409
3 changed files with 336 additions and 246 deletions

View File

@@ -31,6 +31,7 @@ import {
QueryFormMetric, QueryFormMetric,
SMART_DATE_ID, SMART_DATE_ID,
validateNonEmpty, validateNonEmpty,
QueryFormColumn,
} from '@superset-ui/core'; } from '@superset-ui/core';
import { MetricsLayoutEnum } from '../types'; import { MetricsLayoutEnum } from '../types';
@@ -403,10 +404,21 @@ const config: ControlPanelConfig = {
renderTrigger: true, renderTrigger: true,
label: t('Conditional formatting'), label: t('Conditional formatting'),
description: t('Apply conditional color formatting to metrics'), description: t('Apply conditional color formatting to metrics'),
shouldMapStateToProps() {
return true;
},
mapStateToProps(explore, _, chart) { mapStateToProps(explore, _, chart) {
const values = const metrics =
(explore?.controls?.metrics?.value as QueryFormMetric[]) ?? (explore?.controls?.metrics?.value as QueryFormMetric[]) ??
[]; [];
const columns =
(explore?.controls?.groupbyColumns
?.value as QueryFormColumn[]) ?? [];
const rows =
(explore?.controls?.groupbyRows
?.value as QueryFormColumn[]) ?? [];
const values = [...new Set([...metrics, ...columns, ...rows])];
const verboseMap = explore?.datasource?.hasOwnProperty( const verboseMap = explore?.datasource?.hasOwnProperty(
'verbose_map', 'verbose_map',
) )

View File

@@ -174,6 +174,33 @@ function displayHeaderCell(
); );
} }
function getCellColor(
keys: string[],
aggValue: string | number | null,
cellColorFormatters: Record<string, CellColorFormatter[]> | undefined,
): { backgroundColor: string | undefined } {
if (!cellColorFormatters) return { backgroundColor: undefined };
let backgroundColor: string | undefined;
for (const cellColorFormatter of Object.values(cellColorFormatters)) {
if (!Array.isArray(cellColorFormatter)) continue;
for (const key of keys) {
for (const formatter of cellColorFormatter) {
if (formatter.column === key) {
const result = formatter.getColorFromValue(aggValue);
if (result) {
backgroundColor = result;
}
}
}
}
}
return { backgroundColor };
}
interface HierarchicalNode { interface HierarchicalNode {
currentVal?: number; currentVal?: number;
[key: string]: HierarchicalNode | number | undefined; [key: string]: HierarchicalNode | number | undefined;
@@ -717,6 +744,7 @@ export class TableRenderer extends Component<
highlightHeaderCellsOnHover, highlightHeaderCellsOnHover,
omittedHighlightHeaderGroups = [], omittedHighlightHeaderGroups = [],
highlightedHeaderCells, highlightedHeaderCells,
cellColorFormatters,
dateFormatters, dateFormatters,
} = this.props.tableOptions; } = this.props.tableOptions;
@@ -816,10 +844,17 @@ export class TableRenderer extends Component<
}; };
const headerCellFormattedValue = const headerCellFormattedValue =
dateFormatters?.[attrName]?.(colKey[attrIdx]) ?? colKey[attrIdx]; dateFormatters?.[attrName]?.(colKey[attrIdx]) ?? colKey[attrIdx];
const { backgroundColor } = getCellColor(
[attrName],
headerCellFormattedValue,
cellColorFormatters,
);
const style = { backgroundColor };
attrValueCells.push( attrValueCells.push(
<th <th
className={colLabelClass} className={colLabelClass}
key={`colKey-${flatColKey}`} key={`colKey-${flatColKey}`}
style={style}
colSpan={colSpan} colSpan={colSpan}
rowSpan={rowSpan} rowSpan={rowSpan}
role="columnheader button" role="columnheader button"
@@ -1044,10 +1079,18 @@ export class TableRenderer extends Component<
const headerCellFormattedValue = const headerCellFormattedValue =
dateFormatters?.[rowAttrs[i]]?.(r) ?? r; dateFormatters?.[rowAttrs[i]]?.(r) ?? r;
const { backgroundColor } = getCellColor(
[rowAttrs[i]],
headerCellFormattedValue,
cellColorFormatters,
);
const style = { backgroundColor };
return ( return (
<th <th
key={`rowKeyLabel-${i}`} key={`rowKeyLabel-${i}`}
className={valueCellClassName} className={valueCellClassName}
style={style}
rowSpan={rowSpan} rowSpan={rowSpan}
colSpan={colSpan} colSpan={colSpan}
role="columnheader button" role="columnheader button"
@@ -1108,26 +1151,12 @@ export class TableRenderer extends Component<
const aggValue = agg.value(); const aggValue = agg.value();
const keys = [...rowKey, ...colKey]; const keys = [...rowKey, ...colKey];
let backgroundColor: string | undefined;
if (cellColorFormatters) { const { backgroundColor } = getCellColor(
Object.values(cellColorFormatters).forEach(cellColorFormatter => { keys,
if (Array.isArray(cellColorFormatter)) { aggValue,
keys.forEach(key => { cellColorFormatters,
if (backgroundColor) { );
return;
}
cellColorFormatter
.filter(formatter => formatter.column === key)
.forEach(formatter => {
const formatterResult = formatter.getColorFromValue(aggValue);
if (formatterResult) {
backgroundColor = formatterResult;
}
});
});
}
});
}
const style = agg.isSubtotal const style = agg.isSubtotal
? { fontWeight: 'bold' } ? { fontWeight: 'bold' }

View File

@@ -22,9 +22,52 @@ import { supersetTheme } from '@apache-superset/core/ui';
import transformProps from '../../src/plugin/transformProps'; import transformProps from '../../src/plugin/transformProps';
import { MetricsLayoutEnum } from '../../src/types'; import { MetricsLayoutEnum } from '../../src/types';
describe('PivotTableChart transformProps', () => { const setDataMask = jest.fn();
const setDataMask = jest.fn(); const formData = {
const formData = { groupbyRows: ['row1', 'row2'],
groupbyColumns: ['col1', 'col2'],
metrics: ['metric1', 'metric2'],
tableRenderer: 'Table With Subtotal',
colOrder: 'key_a_to_z',
rowOrder: 'key_a_to_z',
aggregateFunction: 'Sum',
transposePivot: true,
combineMetric: true,
rowSubtotalPosition: true,
colSubtotalPosition: true,
colTotals: true,
rowTotals: true,
valueFormat: 'SMART_NUMBER',
metricsLayout: MetricsLayoutEnum.COLUMNS,
viz_type: '',
datasource: '',
conditionalFormatting: [],
dateFormat: '',
legacy_order_by: 'count',
order_desc: true,
currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' },
};
const chartProps = new ChartProps<QueryFormData>({
formData,
width: 800,
height: 600,
queriesData: [
{
data: [{ name: 'Hulk', sum__num: 1, __timestamp: 599616000000 }],
colnames: ['name', 'sum__num', '__timestamp'],
coltypes: [1, 0, 2],
},
],
hooks: { setDataMask },
filterState: { selectedFilters: {} },
datasource: { verboseMap: {}, columnFormats: {} },
theme: supersetTheme,
});
test('should transform chart props for viz', () => {
expect(transformProps(chartProps)).toEqual({
width: 800,
height: 600,
groupbyRows: ['row1', 'row2'], groupbyRows: ['row1', 'row2'],
groupbyColumns: ['col1', 'col2'], groupbyColumns: ['col1', 'col2'],
metrics: ['metric1', 'metric2'], metrics: ['metric1', 'metric2'],
@@ -39,250 +82,256 @@ describe('PivotTableChart transformProps', () => {
colTotals: true, colTotals: true,
rowTotals: true, rowTotals: true,
valueFormat: 'SMART_NUMBER', valueFormat: 'SMART_NUMBER',
data: [{ name: 'Hulk', sum__num: 1, __timestamp: 599616000000 }],
setDataMask,
selectedFilters: {},
verboseMap: {},
metricsLayout: MetricsLayoutEnum.COLUMNS, metricsLayout: MetricsLayoutEnum.COLUMNS,
viz_type: '', metricColorFormatters: [],
datasource: '', dateFormatters: {},
conditionalFormatting: [], emitCrossFilters: false,
dateFormat: '', columnFormats: {},
legacy_order_by: 'count', currencyFormats: {},
order_desc: true,
currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' }, currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' },
});
});
test('should pass AUTO mode through for per-cell detection (single currency data)', () => {
const autoFormData = {
...formData,
currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' },
}; };
const chartProps = new ChartProps<QueryFormData>({ const autoChartProps = new ChartProps<QueryFormData>({
formData, formData: autoFormData,
width: 800, width: 800,
height: 600, height: 600,
queriesData: [ queriesData: [
{ {
data: [{ name: 'Hulk', sum__num: 1, __timestamp: 599616000000 }], data: [
colnames: ['name', 'sum__num', '__timestamp'], { country: 'USA', currency: 'USD', revenue: 100 },
coltypes: [1, 0, 2], { country: 'Canada', currency: 'USD', revenue: 200 },
{ country: 'Mexico', currency: 'usd', revenue: 150 },
],
colnames: ['country', 'currency', 'revenue'],
coltypes: [1, 1, 0],
}, },
], ],
hooks: { setDataMask }, hooks: { setDataMask },
filterState: { selectedFilters: {} }, filterState: { selectedFilters: {} },
datasource: { verboseMap: {}, columnFormats: {} }, datasource: {
verboseMap: {},
columnFormats: {},
currencyCodeColumn: 'currency',
},
theme: supersetTheme, theme: supersetTheme,
}); });
test('should transform chart props for viz', () => { const result = transformProps(autoChartProps);
expect(transformProps(chartProps)).toEqual({ // AUTO mode should be preserved for per-cell detection in PivotTableChart
width: 800, expect(result.currencyFormat).toEqual({
height: 600, symbol: 'AUTO',
groupbyRows: ['row1', 'row2'], symbolPosition: 'prefix',
groupbyColumns: ['col1', 'col2'], });
metrics: ['metric1', 'metric2'], // currencyCodeColumn should be passed through for per-cell detection
tableRenderer: 'Table With Subtotal', expect(result.currencyCodeColumn).toBe('currency');
colOrder: 'key_a_to_z', });
rowOrder: 'key_a_to_z',
aggregateFunction: 'Sum', test('should pass AUTO mode through for per-cell detection (mixed currency data)', () => {
transposePivot: true, const autoFormData = {
combineMetric: true, ...formData,
rowSubtotalPosition: true, currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' },
colSubtotalPosition: true, };
colTotals: true, const autoChartProps = new ChartProps<QueryFormData>({
rowTotals: true, formData: autoFormData,
valueFormat: 'SMART_NUMBER', width: 800,
data: [{ name: 'Hulk', sum__num: 1, __timestamp: 599616000000 }], height: 600,
setDataMask, queriesData: [
selectedFilters: {}, {
data: [
{ country: 'USA', currency: 'USD', revenue: 100 },
{ country: 'UK', currency: 'GBP', revenue: 200 },
{ country: 'France', currency: 'EUR', revenue: 150 },
],
colnames: ['country', 'currency', 'revenue'],
coltypes: [1, 1, 0],
},
],
hooks: { setDataMask },
filterState: { selectedFilters: {} },
datasource: {
verboseMap: {}, verboseMap: {},
metricsLayout: MetricsLayoutEnum.COLUMNS,
metricColorFormatters: [],
dateFormatters: {},
emitCrossFilters: false,
columnFormats: {}, columnFormats: {},
currencyFormats: {}, currencyCodeColumn: 'currency',
currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' }, },
}); theme: supersetTheme,
}); });
describe('Per-cell currency detection (AUTO mode passes through)', () => { const result = transformProps(autoChartProps);
test('should pass AUTO mode through for per-cell detection (single currency data)', () => { // AUTO mode should be preserved - per-cell detection happens in PivotTableChart
const autoFormData = { expect(result.currencyFormat).toEqual({
...formData, symbol: 'AUTO',
currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' }, symbolPosition: 'prefix',
}; });
const autoChartProps = new ChartProps<QueryFormData>({ expect(result.currencyCodeColumn).toBe('currency');
formData: autoFormData, });
width: 800,
height: 600, test('should pass AUTO mode through when no currency column is defined', () => {
queriesData: [ const autoFormData = {
{ ...formData,
data: [ currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' },
{ country: 'USA', currency: 'USD', revenue: 100 }, };
{ country: 'Canada', currency: 'USD', revenue: 200 }, const autoChartProps = new ChartProps<QueryFormData>({
{ country: 'Mexico', currency: 'usd', revenue: 150 }, formData: autoFormData,
], width: 800,
colnames: ['country', 'currency', 'revenue'], height: 600,
coltypes: [1, 1, 0], queriesData: [
}, {
data: [
{ country: 'USA', revenue: 100 },
{ country: 'UK', revenue: 200 },
], ],
hooks: { setDataMask }, colnames: ['country', 'revenue'],
filterState: { selectedFilters: {} }, coltypes: [1, 0],
datasource: { },
verboseMap: {}, ],
columnFormats: {}, hooks: { setDataMask },
currencyCodeColumn: 'currency', filterState: { selectedFilters: {} },
}, datasource: {
theme: supersetTheme, verboseMap: {},
}); columnFormats: {},
// No currencyCodeColumn defined
},
theme: supersetTheme,
});
const result = transformProps(autoChartProps); const result = transformProps(autoChartProps);
// AUTO mode should be preserved for per-cell detection in PivotTableChart expect(result.currencyFormat).toEqual({
expect(result.currencyFormat).toEqual({ symbol: 'AUTO',
symbol: 'AUTO', symbolPosition: 'prefix',
symbolPosition: 'prefix', });
}); // currencyCodeColumn should be undefined when not configured
// currencyCodeColumn should be passed through for per-cell detection expect(result.currencyCodeColumn).toBeUndefined();
expect(result.currencyCodeColumn).toBe('currency'); });
});
test('should pass AUTO mode through for per-cell detection (mixed currency data)', () => { test('should handle empty data gracefully in AUTO mode', () => {
const autoFormData = { const autoFormData = {
...formData, ...formData,
currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' }, currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' },
}; };
const autoChartProps = new ChartProps<QueryFormData>({ const autoChartProps = new ChartProps<QueryFormData>({
formData: autoFormData, formData: autoFormData,
width: 800, width: 800,
height: 600, height: 600,
queriesData: [ queriesData: [
{ {
data: [ data: [],
{ country: 'USA', currency: 'USD', revenue: 100 }, colnames: ['country', 'currency', 'revenue'],
{ country: 'UK', currency: 'GBP', revenue: 200 }, coltypes: [1, 1, 0],
{ country: 'France', currency: 'EUR', revenue: 150 }, },
], ],
colnames: ['country', 'currency', 'revenue'], hooks: { setDataMask },
coltypes: [1, 1, 0], filterState: { selectedFilters: {} },
}, datasource: {
verboseMap: {},
columnFormats: {},
currencyCodeColumn: 'currency',
},
theme: supersetTheme,
});
const result = transformProps(autoChartProps);
expect(result.currencyFormat).toEqual({
symbol: 'AUTO',
symbolPosition: 'prefix',
});
expect(result.currencyCodeColumn).toBe('currency');
});
test('should preserve static currency format when not using AUTO mode', () => {
const staticFormData = {
...formData,
currencyFormat: { symbol: 'EUR', symbolPosition: 'suffix' },
};
const staticChartProps = new ChartProps<QueryFormData>({
formData: staticFormData,
width: 800,
height: 600,
queriesData: [
{
data: [
{ country: 'USA', currency: 'USD', revenue: 100 },
{ country: 'UK', currency: 'GBP', revenue: 200 },
], ],
hooks: { setDataMask }, colnames: ['country', 'currency', 'revenue'],
filterState: { selectedFilters: {} }, coltypes: [1, 1, 0],
datasource: { },
verboseMap: {}, ],
columnFormats: {}, hooks: { setDataMask },
currencyCodeColumn: 'currency', filterState: { selectedFilters: {} },
}, datasource: {
theme: supersetTheme, verboseMap: {},
}); columnFormats: {},
currencyCodeColumn: 'currency',
},
theme: supersetTheme,
});
const result = transformProps(autoChartProps); const result = transformProps(staticChartProps);
// AUTO mode should be preserved - per-cell detection happens in PivotTableChart expect(result.currencyFormat).toEqual({
expect(result.currencyFormat).toEqual({ symbol: 'EUR',
symbol: 'AUTO', symbolPosition: 'suffix',
symbolPosition: 'prefix',
});
expect(result.currencyCodeColumn).toBe('currency');
});
test('should pass AUTO mode through when no currency column is defined', () => {
const autoFormData = {
...formData,
currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' },
};
const autoChartProps = new ChartProps<QueryFormData>({
formData: autoFormData,
width: 800,
height: 600,
queriesData: [
{
data: [
{ country: 'USA', revenue: 100 },
{ country: 'UK', revenue: 200 },
],
colnames: ['country', 'revenue'],
coltypes: [1, 0],
},
],
hooks: { setDataMask },
filterState: { selectedFilters: {} },
datasource: {
verboseMap: {},
columnFormats: {},
// No currencyCodeColumn defined
},
theme: supersetTheme,
});
const result = transformProps(autoChartProps);
expect(result.currencyFormat).toEqual({
symbol: 'AUTO',
symbolPosition: 'prefix',
});
// currencyCodeColumn should be undefined when not configured
expect(result.currencyCodeColumn).toBeUndefined();
});
test('should handle empty data gracefully in AUTO mode', () => {
const autoFormData = {
...formData,
currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' },
};
const autoChartProps = new ChartProps<QueryFormData>({
formData: autoFormData,
width: 800,
height: 600,
queriesData: [
{
data: [],
colnames: ['country', 'currency', 'revenue'],
coltypes: [1, 1, 0],
},
],
hooks: { setDataMask },
filterState: { selectedFilters: {} },
datasource: {
verboseMap: {},
columnFormats: {},
currencyCodeColumn: 'currency',
},
theme: supersetTheme,
});
const result = transformProps(autoChartProps);
expect(result.currencyFormat).toEqual({
symbol: 'AUTO',
symbolPosition: 'prefix',
});
expect(result.currencyCodeColumn).toBe('currency');
});
test('should preserve static currency format when not using AUTO mode', () => {
const staticFormData = {
...formData,
currencyFormat: { symbol: 'EUR', symbolPosition: 'suffix' },
};
const staticChartProps = new ChartProps<QueryFormData>({
formData: staticFormData,
width: 800,
height: 600,
queriesData: [
{
data: [
{ country: 'USA', currency: 'USD', revenue: 100 },
{ country: 'UK', currency: 'GBP', revenue: 200 },
],
colnames: ['country', 'currency', 'revenue'],
coltypes: [1, 1, 0],
},
],
hooks: { setDataMask },
filterState: { selectedFilters: {} },
datasource: {
verboseMap: {},
columnFormats: {},
currencyCodeColumn: 'currency',
},
theme: supersetTheme,
});
const result = transformProps(staticChartProps);
expect(result.currencyFormat).toEqual({
symbol: 'EUR',
symbolPosition: 'suffix',
});
});
}); });
}); });
test('should map conditional formatting rules to metricColorFormatters with correct colors', () => {
const formattingFormData = {
...formData,
conditionalFormatting: [
{
colorScheme: '#ACE1C4',
column: 'country',
operator: '=',
targetValue: 'country',
},
{
colorScheme: '#5ac189',
column: 'revenue',
operator: '=',
targetValue: 'revenue',
},
],
};
const formattingChartProps = new ChartProps<QueryFormData>({
formData: formattingFormData,
width: 800,
height: 600,
queriesData: [
{
data: [
{ country: 'USA', currency: 'USD', revenue: 100 },
{ country: 'UK', currency: 'GBP', revenue: 200 },
],
colnames: ['country', 'currency', 'revenue'],
coltypes: [1, 1, 0],
},
],
hooks: { setDataMask },
filterState: { selectedFilters: {} },
datasource: {
verboseMap: {},
columnFormats: {},
currencyCodeColumn: 'currency',
},
theme: supersetTheme,
});
const result = transformProps(formattingChartProps);
const column1Formatting = result.metricColorFormatters[0].column;
const column2Formatting = result.metricColorFormatters[1].column;
expect(
result.metricColorFormatters[0].getColorFromValue(column1Formatting),
).toEqual('#ACE1C4FF');
expect(
result.metricColorFormatters[1].getColorFromValue(column2Formatting),
).toEqual('#5ac189FF');
});