From 84f1ee44093d981bbfdaf8e2c5bca18f8f45a5b4 Mon Sep 17 00:00:00 2001 From: SBIN2010 Date: Tue, 17 Feb 2026 01:08:41 +0300 Subject: [PATCH] feat: added conditional formatting enhancements string to pivot table (#35863) --- .../src/plugin/controlPanel.tsx | 14 +- .../src/react-pivottable/TableRenderers.tsx | 69 ++- .../test/plugin/transformProps.test.ts | 499 ++++++++++-------- 3 files changed, 336 insertions(+), 246 deletions(-) diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/controlPanel.tsx index 30b81a1e5f3..e6b373a18ea 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/plugin/controlPanel.tsx @@ -31,6 +31,7 @@ import { QueryFormMetric, SMART_DATE_ID, validateNonEmpty, + QueryFormColumn, } from '@superset-ui/core'; import { MetricsLayoutEnum } from '../types'; @@ -403,10 +404,21 @@ const config: ControlPanelConfig = { renderTrigger: true, label: t('Conditional formatting'), description: t('Apply conditional color formatting to metrics'), + shouldMapStateToProps() { + return true; + }, mapStateToProps(explore, _, chart) { - const values = + const metrics = (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( 'verbose_map', ) diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx index 6e536641c39..15d25827963 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx +++ b/superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx @@ -174,6 +174,33 @@ function displayHeaderCell( ); } +function getCellColor( + keys: string[], + aggValue: string | number | null, + cellColorFormatters: Record | 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 { currentVal?: number; [key: string]: HierarchicalNode | number | undefined; @@ -717,6 +744,7 @@ export class TableRenderer extends Component< highlightHeaderCellsOnHover, omittedHighlightHeaderGroups = [], highlightedHeaderCells, + cellColorFormatters, dateFormatters, } = this.props.tableOptions; @@ -816,10 +844,17 @@ export class TableRenderer extends Component< }; const headerCellFormattedValue = dateFormatters?.[attrName]?.(colKey[attrIdx]) ?? colKey[attrIdx]; + const { backgroundColor } = getCellColor( + [attrName], + headerCellFormattedValue, + cellColorFormatters, + ); + const style = { backgroundColor }; attrValueCells.push( { - if (Array.isArray(cellColorFormatter)) { - keys.forEach(key => { - if (backgroundColor) { - return; - } - cellColorFormatter - .filter(formatter => formatter.column === key) - .forEach(formatter => { - const formatterResult = formatter.getColorFromValue(aggValue); - if (formatterResult) { - backgroundColor = formatterResult; - } - }); - }); - } - }); - } + + const { backgroundColor } = getCellColor( + keys, + aggValue, + cellColorFormatters, + ); const style = agg.isSubtotal ? { fontWeight: 'bold' } diff --git a/superset-frontend/plugins/plugin-chart-pivot-table/test/plugin/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-pivot-table/test/plugin/transformProps.test.ts index 7958ff2c2d3..5f243b04e4e 100644 --- a/superset-frontend/plugins/plugin-chart-pivot-table/test/plugin/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-pivot-table/test/plugin/transformProps.test.ts @@ -22,9 +22,52 @@ import { supersetTheme } from '@apache-superset/core/ui'; import transformProps from '../../src/plugin/transformProps'; import { MetricsLayoutEnum } from '../../src/types'; -describe('PivotTableChart transformProps', () => { - const setDataMask = jest.fn(); - const formData = { +const setDataMask = jest.fn(); +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({ + 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'], groupbyColumns: ['col1', 'col2'], metrics: ['metric1', 'metric2'], @@ -39,250 +82,256 @@ describe('PivotTableChart transformProps', () => { colTotals: true, rowTotals: true, valueFormat: 'SMART_NUMBER', + data: [{ name: 'Hulk', sum__num: 1, __timestamp: 599616000000 }], + setDataMask, + selectedFilters: {}, + verboseMap: {}, metricsLayout: MetricsLayoutEnum.COLUMNS, - viz_type: '', - datasource: '', - conditionalFormatting: [], - dateFormat: '', - legacy_order_by: 'count', - order_desc: true, + metricColorFormatters: [], + dateFormatters: {}, + emitCrossFilters: false, + columnFormats: {}, + currencyFormats: {}, 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({ - formData, + const autoChartProps = new ChartProps({ + formData: autoFormData, width: 800, height: 600, queriesData: [ { - data: [{ name: 'Hulk', sum__num: 1, __timestamp: 599616000000 }], - colnames: ['name', 'sum__num', '__timestamp'], - coltypes: [1, 0, 2], + data: [ + { country: 'USA', currency: 'USD', revenue: 100 }, + { country: 'Canada', currency: 'USD', revenue: 200 }, + { country: 'Mexico', currency: 'usd', revenue: 150 }, + ], + colnames: ['country', 'currency', 'revenue'], + coltypes: [1, 1, 0], }, ], hooks: { setDataMask }, filterState: { selectedFilters: {} }, - datasource: { verboseMap: {}, columnFormats: {} }, + datasource: { + verboseMap: {}, + columnFormats: {}, + currencyCodeColumn: 'currency', + }, theme: supersetTheme, }); - test('should transform chart props for viz', () => { - expect(transformProps(chartProps)).toEqual({ - width: 800, - height: 600, - 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', - data: [{ name: 'Hulk', sum__num: 1, __timestamp: 599616000000 }], - setDataMask, - selectedFilters: {}, + const result = transformProps(autoChartProps); + // AUTO mode should be preserved for per-cell detection in PivotTableChart + expect(result.currencyFormat).toEqual({ + symbol: 'AUTO', + symbolPosition: 'prefix', + }); + // currencyCodeColumn should be passed through for per-cell detection + expect(result.currencyCodeColumn).toBe('currency'); +}); + +test('should pass AUTO mode through for per-cell detection (mixed currency data)', () => { + const autoFormData = { + ...formData, + currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' }, + }; + const autoChartProps = new ChartProps({ + formData: autoFormData, + width: 800, + height: 600, + queriesData: [ + { + 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: {}, - metricsLayout: MetricsLayoutEnum.COLUMNS, - metricColorFormatters: [], - dateFormatters: {}, - emitCrossFilters: false, columnFormats: {}, - currencyFormats: {}, - currencyFormat: { symbol: 'USD', symbolPosition: 'prefix' }, - }); + currencyCodeColumn: 'currency', + }, + theme: supersetTheme, }); - describe('Per-cell currency detection (AUTO mode passes through)', () => { - test('should pass AUTO mode through for per-cell detection (single currency data)', () => { - const autoFormData = { - ...formData, - currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' }, - }; - const autoChartProps = new ChartProps({ - formData: autoFormData, - width: 800, - height: 600, - queriesData: [ - { - data: [ - { country: 'USA', currency: 'USD', revenue: 100 }, - { country: 'Canada', currency: 'USD', revenue: 200 }, - { country: 'Mexico', currency: 'usd', revenue: 150 }, - ], - colnames: ['country', 'currency', 'revenue'], - coltypes: [1, 1, 0], - }, + const result = transformProps(autoChartProps); + // AUTO mode should be preserved - per-cell detection happens in PivotTableChart + expect(result.currencyFormat).toEqual({ + symbol: 'AUTO', + 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({ + formData: autoFormData, + width: 800, + height: 600, + queriesData: [ + { + data: [ + { country: 'USA', revenue: 100 }, + { country: 'UK', revenue: 200 }, ], - hooks: { setDataMask }, - filterState: { selectedFilters: {} }, - datasource: { - verboseMap: {}, - columnFormats: {}, - currencyCodeColumn: 'currency', - }, - theme: supersetTheme, - }); + colnames: ['country', 'revenue'], + coltypes: [1, 0], + }, + ], + hooks: { setDataMask }, + filterState: { selectedFilters: {} }, + datasource: { + verboseMap: {}, + columnFormats: {}, + // No currencyCodeColumn defined + }, + theme: supersetTheme, + }); - const result = transformProps(autoChartProps); - // AUTO mode should be preserved for per-cell detection in PivotTableChart - expect(result.currencyFormat).toEqual({ - symbol: 'AUTO', - symbolPosition: 'prefix', - }); - // currencyCodeColumn should be passed through for per-cell detection - expect(result.currencyCodeColumn).toBe('currency'); - }); + 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 pass AUTO mode through for per-cell detection (mixed currency data)', () => { - const autoFormData = { - ...formData, - currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' }, - }; - const autoChartProps = new ChartProps({ - formData: autoFormData, - width: 800, - height: 600, - queriesData: [ - { - 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], - }, +test('should handle empty data gracefully in AUTO mode', () => { + const autoFormData = { + ...formData, + currencyFormat: { symbol: 'AUTO', symbolPosition: 'prefix' }, + }; + const autoChartProps = new ChartProps({ + 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({ + formData: staticFormData, + width: 800, + height: 600, + queriesData: [ + { + data: [ + { country: 'USA', currency: 'USD', revenue: 100 }, + { country: 'UK', currency: 'GBP', revenue: 200 }, ], - hooks: { setDataMask }, - filterState: { selectedFilters: {} }, - datasource: { - verboseMap: {}, - columnFormats: {}, - currencyCodeColumn: 'currency', - }, - theme: supersetTheme, - }); + colnames: ['country', 'currency', 'revenue'], + coltypes: [1, 1, 0], + }, + ], + hooks: { setDataMask }, + filterState: { selectedFilters: {} }, + datasource: { + verboseMap: {}, + columnFormats: {}, + currencyCodeColumn: 'currency', + }, + theme: supersetTheme, + }); - const result = transformProps(autoChartProps); - // AUTO mode should be preserved - per-cell detection happens in PivotTableChart - expect(result.currencyFormat).toEqual({ - symbol: 'AUTO', - 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({ - 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({ - 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({ - 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', - }); - }); + 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({ + 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'); +});