feat(dashboard): implement boolean conditional formatting (#36338)

Co-authored-by: Morris <morrisho215215@gmail.com>
This commit is contained in:
Edison Liem
2025-12-04 12:53:49 -05:00
committed by GitHub
parent e5da6d3183
commit eabb5bdf7d
9 changed files with 372 additions and 21 deletions

View File

@@ -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 {};

View File

@@ -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';
}

View File

@@ -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');
});

View File

@@ -907,10 +907,6 @@ export default function TableChart<D extends DataRecord = DataRecord>(
formatter: ColorFormatters[number],
valueToFormat: any,
) => {
const hasValue =
valueToFormat !== undefined && valueToFormat !== null;
if (!hasValue) return;
const formatterResult =
formatter.getColorFromValue(valueToFormat);
if (!formatterResult) return;

View File

@@ -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,

View File

@@ -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: (
<TableChart
{...transformProps({
...testData.nameAndBoolean,
rawFormData: {
...testData.nameAndBoolean.rawFormData,
conditional_formatting: [
{
colorScheme: '#ACE1C4',
column: 'is_adult',
operator: 'is true',
targetValue: '',
},
],
},
})}
/>
),
}),
);
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: (
<TableChart
{...transformProps({
...testData.nameAndBoolean,
rawFormData: {
...testData.nameAndBoolean.rawFormData,
conditional_formatting: [
{
colorScheme: '#ACE1C4',
column: 'is_adult',
operator: 'is false',
targetValue: '',
},
],
},
})}
/>
),
}),
);
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: (
<TableChart
{...transformProps({
...testData.nameAndBoolean,
rawFormData: {
...testData.nameAndBoolean.rawFormData,
conditional_formatting: [
{
colorScheme: '#ACE1C4',
column: 'is_adult',
operator: 'is null',
targetValue: '',
},
],
},
})}
/>
),
}),
);
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: (
<TableChart
{...transformProps({
...testData.nameAndBoolean,
rawFormData: {
...testData.nameAndBoolean.rawFormData,
conditional_formatting: [
{
colorScheme: '#ACE1C4',
column: 'is_adult',
operator: 'is not null',
targetValue: '',
},
],
},
})}
/>
),
}),
);
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({

View File

@@ -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,
};

View File

@@ -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(
<FormattingPopoverContent
onChange={mockOnChange}
columns={columnsBooleanType}
extraColorChoices={extraColorChoices}
/>,
);
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(
<FormattingPopoverContent

View File

@@ -91,6 +91,13 @@ const stringOperatorOptions = [
{ value: Comparator.NotContaining, label: t('not containing') },
];
const booleanOperatorOptions = [
{ value: Comparator.IsNull, label: t('is null') },
{ value: Comparator.IsTrue, label: t('is true') },
{ value: Comparator.IsFalse, label: t('is false') },
{ value: Comparator.IsNotNull, label: t('is not null') },
];
const targetValueValidator =
(
compare: (targetValue: number, compareValue: number) => 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 (
<FormItem
@@ -182,9 +196,26 @@ const renderOperatorFields = (
columnType?: GenericDataType,
) => {
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 (
<Row gutter={12}>
<Col span={operatorColSpan}>{renderOperator({ columnType })}</Col>
<Col span={valueColSpan}>
<FormItem
name="targetValue"
label={t('Target value')}
initialValue={''}
hidden
/>
</Col>
</Row>
);
}
return isOperatorNone(getFieldValue('operator')) ? (
<Row gutter={12}>
<Col span={operatorColSpan}>{renderOperator({ columnType })}</Col>
@@ -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,