From eabb5bdf7d974030ab90f161d33e943fe891d72c Mon Sep 17 00:00:00 2001 From: Edison Liem Date: Thu, 4 Dec 2025 12:53:49 -0500 Subject: [PATCH] feat(dashboard): implement boolean conditional formatting (#36338) Co-authored-by: Morris --- .../superset-ui-chart-controls/src/types.ts | 8 +- .../src/utils/getColorFormatters.ts | 39 +++++- .../test/utils/getColorFormatters.test.ts | 107 +++++++++++++++ .../plugin-chart-table/src/TableChart.tsx | 4 - .../plugin-chart-table/src/controlPanel.tsx | 3 +- .../test/TableChart.test.tsx | 126 +++++++++++++++++- .../plugin-chart-table/test/testData.ts | 26 ++++ .../FormattingPopoverContent.test.tsx | 21 +++ .../FormattingPopoverContent.tsx | 59 ++++++-- 9 files changed, 372 insertions(+), 21 deletions(-) diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts index eaca8133d44..02cb2ab5360 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/types.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/types.ts @@ -462,6 +462,10 @@ export enum Comparator { EndsWith = 'ends with', Containing = 'containing', NotContaining = 'not containing', + IsTrue = 'is true', + IsFalse = 'is false', + IsNull = 'is null', + IsNotNull = 'is not null', } export const MultipleValueComparators = [ @@ -486,7 +490,9 @@ export type ColorFormatters = { column: string; toAllRow?: boolean; toTextColor?: boolean; - getColorFromValue: (value: number | string) => string | undefined; + getColorFromValue: ( + value: number | string | boolean | null, + ) => string | undefined; }[]; export default {}; diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/utils/getColorFormatters.ts b/superset-frontend/packages/superset-ui-chart-controls/src/utils/getColorFormatters.ts index dfa48efdf40..ccf16208721 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/src/utils/getColorFormatters.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/src/utils/getColorFormatters.ts @@ -32,7 +32,7 @@ const MIN_OPACITY_BOUNDED = 0.05; const MIN_OPACITY_UNBOUNDED = 0; const MAX_OPACITY = 1; export const getOpacity = ( - value: number | string, + value: number | string | boolean | null, cutoffPoint: number | string, extremeValue: number | string, minOpacity = MIN_OPACITY_BOUNDED, @@ -70,15 +70,15 @@ export const getColorFunction = ( targetValueRight, colorScheme, }: ConditionalFormattingConfig, - columnValues: number[] | string[], + columnValues: number[] | string[] | (boolean | null)[], alpha?: boolean, ) => { let minOpacity = MIN_OPACITY_BOUNDED; const maxOpacity = MAX_OPACITY; let comparatorFunction: ( - value: number | string, - allValues: number[] | string[], + value: number | string | boolean | null, + allValues: number[] | string[] | (boolean | null)[], ) => false | { cutoffValue: number | string; extremeValue: number | string }; if (operator === undefined || colorScheme === undefined) { return () => undefined; @@ -221,13 +221,38 @@ export const getColorFunction = ( !value?.toLowerCase().includes((targetValue as string).toLowerCase()) ? { cutoffValue: targetValue!, extremeValue: targetValue! } : false; + + break; + case Comparator.IsTrue: + comparatorFunction = (value: boolean | null) => + isBoolean(value) && value + ? { cutoffValue: targetValue!, extremeValue: targetValue! } + : false; + break; + case Comparator.IsFalse: + comparatorFunction = (value: boolean | null) => + isBoolean(value) && !value + ? { cutoffValue: targetValue!, extremeValue: targetValue! } + : false; + break; + case Comparator.IsNull: + comparatorFunction = (value: boolean | null) => + value === null + ? { cutoffValue: targetValue!, extremeValue: targetValue! } + : false; + break; + case Comparator.IsNotNull: + comparatorFunction = (value: boolean | null) => + isBoolean(value) && value !== null + ? { cutoffValue: targetValue!, extremeValue: targetValue! } + : false; break; default: comparatorFunction = () => false; break; } - return (value: number | string) => { + return (value: number | string | boolean | null) => { const compareResult = comparatorFunction(value, columnValues); if (compareResult === false) return undefined; const { cutoffValue, extremeValue } = compareResult; @@ -289,3 +314,7 @@ export const getColorFormatters = memoizeOne( function isString(value: unknown) { return typeof value === 'string'; } + +function isBoolean(value: unknown) { + return typeof value === 'boolean'; +} diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/utils/getColorFormatters.test.ts b/superset-frontend/packages/superset-ui-chart-controls/test/utils/getColorFormatters.test.ts index c7d6c99b2cf..3ceab82ef3c 100644 --- a/superset-frontend/packages/superset-ui-chart-controls/test/utils/getColorFormatters.test.ts +++ b/superset-frontend/packages/superset-ui-chart-controls/test/utils/getColorFormatters.test.ts @@ -35,6 +35,9 @@ const countValues = mockData.map(row => row.count); const strData = [{ name: 'Brian' }, { name: 'Carlos' }, { name: 'Diana' }]; const strValues = strData.map(row => row.name); +const boolData = [{ isMember: true }, { isMember: false }, { isMember: null }]; +const boolValues = boolData.map(row => row.isMember); + test('round', () => { expect(round(1)).toEqual(1); expect(round(1, 2)).toEqual(1); @@ -443,6 +446,66 @@ test('getColorFunction None', () => { expect(colorFunction('Brian')).toEqual('#FF0000FF'); }); +test('getColorFunction IsTrue', () => { + const colorFunction = getColorFunction( + { + operator: Comparator.IsTrue, + targetValue: '', + colorScheme: '#FF0000', + column: 'isMember', + }, + boolValues, + ); + expect(colorFunction(true)).toEqual('#FF0000FF'); + expect(colorFunction(false)).toBeUndefined(); + expect(colorFunction(null)).toBeUndefined(); +}); + +test('getColorFunction IsFalse', () => { + const colorFunction = getColorFunction( + { + operator: Comparator.IsFalse, + targetValue: '', + colorScheme: '#FF0000', + column: 'isMember', + }, + boolValues, + ); + expect(colorFunction(true)).toBeUndefined(); + expect(colorFunction(false)).toEqual('#FF0000FF'); + expect(colorFunction(null)).toBeUndefined(); +}); + +test('getColorFunction IsNull', () => { + const colorFunction = getColorFunction( + { + operator: Comparator.IsNull, + targetValue: '', + colorScheme: '#FF0000', + column: 'isMember', + }, + boolValues, + ); + expect(colorFunction(true)).toBeUndefined(); + expect(colorFunction(false)).toBeUndefined(); + expect(colorFunction(null)).toEqual('#FF0000FF'); +}); + +test('getColorFunction IsNotNull', () => { + const colorFunction = getColorFunction( + { + operator: Comparator.IsNotNull, + targetValue: '', + colorScheme: '#FF0000', + column: 'isMember', + }, + boolValues, + ); + expect(colorFunction(true)).toEqual('#FF0000FF'); + expect(colorFunction(false)).toEqual('#FF0000FF'); + expect(colorFunction(null)).toBeUndefined(); +}); + test('correct column config', () => { const columnConfig = [ { @@ -532,3 +595,47 @@ test('correct column string config', () => { expect(colorFormatters[3].column).toEqual('name'); expect(colorFormatters[3].getColorFromValue('Carlos')).toEqual('#FF0000FF'); }); + +test('correct column boolean config', () => { + const columnConfigBoolean = [ + { + operator: Comparator.IsTrue, + targetValue: '', + colorScheme: '#FF0000', + column: 'isMember', + }, + { + operator: Comparator.IsFalse, + targetValue: '', + colorScheme: '#FF0000', + column: 'isMember', + }, + { + operator: Comparator.IsNull, + targetValue: '', + colorScheme: '#FF0000', + column: 'isMember', + }, + { + operator: Comparator.IsNotNull, + targetValue: '', + colorScheme: '#FF0000', + column: 'isMember', + }, + ]; + const colorFormatters = getColorFormatters(columnConfigBoolean, boolData); + expect(colorFormatters.length).toEqual(4); + + expect(colorFormatters[0].column).toEqual('isMember'); + expect(colorFormatters[0].getColorFromValue(true)).toEqual('#FF0000FF'); + + expect(colorFormatters[1].column).toEqual('isMember'); + expect(colorFormatters[1].getColorFromValue(false)).toEqual('#FF0000FF'); + + expect(colorFormatters[2].column).toEqual('isMember'); + expect(colorFormatters[2].getColorFromValue(null)).toEqual('#FF0000FF'); + + expect(colorFormatters[3].column).toEqual('isMember'); + expect(colorFormatters[3].getColorFromValue(true)).toEqual('#FF0000FF'); + expect(colorFormatters[3].getColorFromValue(false)).toEqual('#FF0000FF'); +}); diff --git a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx index c2061d738d1..52018864c21 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/TableChart.tsx @@ -907,10 +907,6 @@ export default function TableChart( formatter: ColorFormatters[number], valueToFormat: any, ) => { - const hasValue = - valueToFormat !== undefined && valueToFormat !== null; - if (!hasValue) return; - const formatterResult = formatter.getColorFromValue(valueToFormat); if (!formatterResult) return; diff --git a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx index 8d4006e2067..ba00d26e05e 100644 --- a/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx +++ b/superset-frontend/plugins/plugin-chart-table/src/controlPanel.tsx @@ -797,7 +797,8 @@ const config: ControlPanelConfig = { if ( coltypes[index] === GenericDataType.Numeric || (!explore?.controls?.time_compare?.value && - coltypes[index] === GenericDataType.String) + (coltypes[index] === GenericDataType.String || + coltypes[index] === GenericDataType.Boolean)) ) { acc.push({ value: colname, diff --git a/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx b/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx index d849f75d583..495c5f5cf22 100644 --- a/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx +++ b/superset-frontend/plugins/plugin-chart-table/test/TableChart.test.tsx @@ -613,7 +613,9 @@ describe('plugin-chart-table', () => { expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe( '', ); - expect(getComputedStyle(screen.getByText('N/A')).background).toBe(''); + expect(getComputedStyle(screen.getByText('N/A')).background).toBe( + 'rgba(172, 225, 196, 1)', + ); }); test('should display original label in grouped headers', () => { const props = transformProps(testData.comparison); @@ -986,6 +988,128 @@ describe('plugin-chart-table', () => { ); }); + test('render color with boolean column color formatter (operator is true)', () => { + render( + ProviderWrapper({ + children: ( + + ), + }), + ); + expect(getComputedStyle(screen.getByText('true')).background).toBe( + 'rgba(172, 225, 196, 1)', + ); + expect(getComputedStyle(screen.getByText('false')).background).toBe(''); + }); + + test('render color with boolean column color formatter (operator is false)', () => { + render( + ProviderWrapper({ + children: ( + + ), + }), + ); + expect(getComputedStyle(screen.getByText('false')).background).toBe( + 'rgba(172, 225, 196, 1)', + ); + expect(getComputedStyle(screen.getByText('true')).background).toBe(''); + }); + + test('render color with boolean column color formatter (operator is null)', () => { + render( + ProviderWrapper({ + children: ( + + ), + }), + ); + expect(getComputedStyle(screen.getByText('N/A')).background).toBe( + 'rgba(172, 225, 196, 1)', + ); + expect(getComputedStyle(screen.getByText('true')).background).toBe(''); + expect(getComputedStyle(screen.getByText('false')).background).toBe(''); + }); + + test('render color with boolean column color formatter (operator is not null)', () => { + render( + ProviderWrapper({ + children: ( + + ), + }), + ); + const trueElements = screen.getAllByText('true'); + const falseElements = screen.getAllByText('false'); + expect(getComputedStyle(trueElements[0]).background).toBe( + 'rgba(172, 225, 196, 1)', + ); + expect(getComputedStyle(falseElements[0]).background).toBe( + 'rgba(172, 225, 196, 1)', + ); + expect(getComputedStyle(screen.getByText('N/A')).background).toBe(''); + }); + test('render color with column color formatter to entire row', () => { render( ProviderWrapper({ diff --git a/superset-frontend/plugins/plugin-chart-table/test/testData.ts b/superset-frontend/plugins/plugin-chart-table/test/testData.ts index 02d88e021e2..ca3ed52e334 100644 --- a/superset-frontend/plugins/plugin-chart-table/test/testData.ts +++ b/superset-frontend/plugins/plugin-chart-table/test/testData.ts @@ -370,6 +370,31 @@ const bigint = { ], }; +const nameAndBoolean: TableChartProps = { + ...new ChartProps(basicChartProps), + queriesData: [ + { + ...basicQueryResult, + colnames: ['name', 'is_adult'], + coltypes: [GenericDataType.String, GenericDataType.Boolean], + data: [ + { + name: 'Alice', + is_adult: true, + }, + { + name: 'Bob', + is_adult: false, + }, + { + name: 'Carl', + is_adult: null, + }, + ], + }, + ], +}; + export default { basic, advanced, @@ -379,4 +404,5 @@ export default { empty, raw, bigint, + nameAndBoolean, }; diff --git a/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/FormattingPopoverContent.test.tsx b/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/FormattingPopoverContent.test.tsx index aaa17cd67d3..29b9fe7b26f 100644 --- a/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/FormattingPopoverContent.test.tsx +++ b/superset-frontend/src/explore/components/controls/ConditionalFormattingControl/FormattingPopoverContent.test.tsx @@ -40,6 +40,11 @@ const columnsStringType = [ { label: 'Column 2', value: 'column2', dataType: GenericDataType.String }, ]; +const columnsBooleanType = [ + { label: 'Column 1', value: 'column1', dataType: GenericDataType.Boolean }, + { label: 'Column 2', value: 'column2', dataType: GenericDataType.Boolean }, +]; + const extraColorChoices = [ { value: ColorSchemeEnum.Green, @@ -148,6 +153,22 @@ test('displays the correct input fields based on the selected string type operat expect(await screen.findByLabelText('Target value')).toBeInTheDocument(); }); +test('does not display the input fields when selected a boolean type operator', async () => { + render( + , + ); + + fireEvent.change(screen.getAllByLabelText('Operator')[0], { + target: { value: Comparator.IsTrue }, + }); + fireEvent.click(await screen.findByTitle('is true')); + expect(await screen.queryByLabelText('Target value')).toBeNull(); +}); + test('displays the toAllRow and toTextColor flags based on the selected numeric type operator', () => { render( boolean, @@ -157,10 +164,17 @@ const renderOperator = ({ showOnlyNone, columnType, }: { showOnlyNone?: boolean; columnType?: GenericDataType } = {}) => { - const options = - columnType === GenericDataType.String - ? stringOperatorOptions - : operatorOptions; + let options; + switch (columnType) { + case GenericDataType.String: + options = stringOperatorOptions; + break; + case GenericDataType.Boolean: + options = booleanOperatorOptions; + break; + default: + options = operatorOptions; + } return ( { const columnTypeString = columnType === GenericDataType.String; - const operatorColSpan = columnTypeString ? 8 : 6; + const columnTypeBoolean = columnType === GenericDataType.Boolean; + const operatorColSpan = columnTypeString || columnTypeBoolean ? 8 : 6; const valueColSpan = columnTypeString ? 16 : 18; + if (columnTypeBoolean) { + return ( + + {renderOperator({ columnType })} + + + ); + } + return isOperatorNone(getFieldValue('operator')) ? ( {renderOperator({ columnType })} @@ -304,10 +335,20 @@ export const FormattingPopoverContent = ({ const handleColumnChange = (value: string) => { const newColumnType = columns.find(item => item.value === value)?.dataType; if (newColumnType !== previousColumnType) { - const defaultOperator = - newColumnType === GenericDataType.String - ? stringOperatorOptions[0].value - : operatorOptions[0].value; + let defaultOperator: Comparator; + + switch (newColumnType) { + case GenericDataType.String: + defaultOperator = stringOperatorOptions[0].value; + break; + + case GenericDataType.Boolean: + defaultOperator = booleanOperatorOptions[0].value; + break; + + default: + defaultOperator = operatorOptions[0].value; + } form.setFieldsValue({ operator: defaultOperator,