mirror of
https://github.com/apache/superset.git
synced 2026-04-07 18:35:15 +00:00
feat(dashboard): implement boolean conditional formatting (#36338)
Co-authored-by: Morris <morrisho215215@gmail.com>
This commit is contained in:
@@ -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 {};
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user