fix(table): improve conditional formatting text contrast (#38705)

This commit is contained in:
João Pedro Alves Barbosa
2026-03-22 18:59:15 -03:00
committed by GitHub
parent 361afff798
commit 02ffb52f4a
14 changed files with 1698 additions and 82 deletions

View File

@@ -51108,7 +51108,8 @@
"dependencies": {
"@apache-superset/core": "*",
"@types/react": "*",
"lodash": "^4.17.23"
"lodash": "^4.17.23",
"tinycolor2": "*"
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",

View File

@@ -26,7 +26,8 @@
"dependencies": {
"@apache-superset/core": "*",
"@types/react": "*",
"lodash": "^4.17.23"
"lodash": "^4.17.23",
"tinycolor2": "*"
},
"peerDependencies": {
"@ant-design/icons": "^5.2.6",

View File

@@ -507,6 +507,11 @@ export type ColorFormatters = {
) => string | undefined;
}[];
export type ResolvedColorFormatterResult = {
backgroundColor?: string;
color?: string;
};
export default {};
export function isColumnMeta(column: AnyDict): column is ColumnMeta {

View File

@@ -20,11 +20,13 @@ import memoizeOne from 'memoize-one';
import { isString, isBoolean } from 'lodash';
import { isBlank } from '@apache-superset/core/utils';
import { addAlpha, DataRecord } from '@superset-ui/core';
import tinycolor from 'tinycolor2';
import {
ColorFormatters,
Comparator,
ConditionalFormattingConfig,
MultipleValueComparators,
ResolvedColorFormatterResult,
} from '../types';
export const round = (num: number, precision = 0) =>
@@ -33,6 +35,11 @@ export const round = (num: number, precision = 0) =>
const MIN_OPACITY_BOUNDED = 0.05;
const MIN_OPACITY_UNBOUNDED = 0;
const MAX_OPACITY = 1;
const READABLE_TEXT_COLORS = [
{ r: 0, g: 0, b: 0 },
{ r: 255, g: 255, b: 255 },
];
export const getOpacity = (
value: number | string | boolean | null,
cutoffPoint: number | string,
@@ -325,3 +332,59 @@ export const getColorFormatters = memoizeOne(
[],
) ?? [],
);
export const getReadableTextColor = (
backgroundColor: string | undefined,
surfaceColor: string,
): string | undefined => {
if (!backgroundColor) {
return undefined;
}
const background = tinycolor(backgroundColor);
const surface = tinycolor(surfaceColor);
if (!background.isValid() || !surface.isValid()) {
return undefined;
}
const { r: bgR, g: bgG, b: bgB, a: bgAlpha } = background.toRgb();
const { r: surfaceR, g: surfaceG, b: surfaceB } = surface.toRgb();
const alpha = bgAlpha ?? 1;
const compositeColor = tinycolor({
r: bgR * alpha + surfaceR * (1 - alpha),
g: bgG * alpha + surfaceG * (1 - alpha),
b: bgB * alpha + surfaceB * (1 - alpha),
});
return tinycolor
.mostReadable(compositeColor, READABLE_TEXT_COLORS, {
includeFallbackColors: true,
level: 'AA',
size: 'small',
})
.toRgbString();
};
export const getNormalizedTextColor = (
color: string | undefined,
): string | undefined => {
if (!color) {
return undefined;
}
const parsedColor = tinycolor(color);
if (!parsedColor.isValid()) {
return color;
}
return parsedColor.setAlpha(1).toRgbString();
};
export const getTextColorForBackground = (
result: ResolvedColorFormatterResult,
surfaceColor: string,
): string | undefined =>
getNormalizedTextColor(result.color) ??
getReadableTextColor(result.backgroundColor, surfaceColor);

View File

@@ -24,6 +24,11 @@ import {
getColorFormatters,
getColorFunction,
} from '../../src';
import {
getReadableTextColor,
getNormalizedTextColor,
getTextColorForBackground,
} from '../../src/utils/getColorFormatters';
configure();
const mockData = [
@@ -107,6 +112,55 @@ test('getColorFunction LESS_THAN', () => {
expect(colorFunction(50)).toEqual('#FF0000FF');
});
test('getReadableTextColor returns white for dark backgrounds', () => {
expect(getReadableTextColor('#111111', '#ffffff')).toBe('rgb(255, 255, 255)');
});
test('getReadableTextColor returns black for light backgrounds', () => {
expect(getReadableTextColor('#f5f5f5', '#ffffff')).toBe('rgb(0, 0, 0)');
});
test('getReadableTextColor blends alpha over the provided surface', () => {
expect(getReadableTextColor('rgba(0, 0, 0, 0.6)', '#ffffff')).toBe(
'rgb(255, 255, 255)',
);
expect(getReadableTextColor('rgba(255, 255, 255, 0.6)', '#000000')).toBe(
'rgb(0, 0, 0)',
);
});
test('getTextColorForBackground prefers explicit text color', () => {
expect(
getTextColorForBackground(
{ backgroundColor: '#111111', color: '#ace1c4ff' },
'#ffffff',
),
).toBe('rgb(172, 225, 196)');
});
test('getNormalizedTextColor removes alpha from explicit text colors', () => {
expect(getNormalizedTextColor('#ace1c40d')).toBe('rgb(172, 225, 196)');
expect(getNormalizedTextColor('rgba(172, 225, 196, 0.2)')).toBe(
'rgb(172, 225, 196)',
);
});
test('getTextColorForBackground normalizes explicit text color alpha', () => {
expect(
getTextColorForBackground(
{ backgroundColor: '#111111', color: '#ace1c40d' },
'#ffffff',
),
).toBe('rgb(172, 225, 196)');
});
test('getTextColorForBackground falls back to adaptive contrast', () => {
expect(
getTextColorForBackground({ backgroundColor: '#111111' }, '#ffffff'),
).toBe('rgb(255, 255, 255)');
expect(getTextColorForBackground({}, '#ffffff')).toBeUndefined();
});
test('getColorFunction GREATER_OR_EQUAL', () => {
const colorFunction = getColorFunction(
{

View File

@@ -309,6 +309,17 @@ export const StyledChartContainer = styled.div<{
height: auto;
}
.ag-cell {
color: var(--ag-cell-value-color, inherit);
}
.ag-row-hover .ag-cell {
color: var(
--ag-cell-value-hover-color,
var(--ag-cell-value-color, inherit)
);
}
.ag-container {
border-radius: 0px;
border: var(--ag-wrapper-border);

View File

@@ -17,7 +17,11 @@
* under the License.
*/
import { ColorFormatters } from '@superset-ui/chart-controls';
import {
ColorFormatters,
getTextColorForBackground,
ObjectFormattingEnum,
} from '@superset-ui/chart-controls';
import { CellClassParams } from '@superset-ui/core/components/ThemedAgGridReact';
import { BasicColorFormatterType, InputColumn } from '../types';
@@ -29,6 +33,8 @@ type CellStyleParams = CellClassParams & {
[Key: string]: BasicColorFormatterType;
}[];
col: InputColumn;
cellSurfaceColor: string;
hoverCellSurfaceColor: string;
};
const getCellStyle = (params: CellStyleParams) => {
@@ -42,8 +48,11 @@ const getCellStyle = (params: CellStyleParams) => {
columnColorFormatters,
col,
node,
cellSurfaceColor,
hoverCellSurfaceColor,
} = params;
let backgroundColor;
let color;
if (hasColumnColorFormatters) {
columnColorFormatters!
.filter(formatter => {
@@ -56,7 +65,16 @@ const getCellStyle = (params: CellStyleParams) => {
const formatterResult =
value || value === 0 ? formatter.getColorFromValue(value) : false;
if (formatterResult) {
backgroundColor = formatterResult;
if (
formatter.objectFormatting === ObjectFormattingEnum.TEXT_COLOR ||
formatter.toTextColor
) {
color = formatterResult;
} else if (
formatter.objectFormatting !== ObjectFormattingEnum.CELL_BAR
) {
backgroundColor = formatterResult;
}
}
});
}
@@ -72,9 +90,20 @@ const getCellStyle = (params: CellStyleParams) => {
const textAlign =
col?.config?.horizontalAlign || (col?.isNumeric ? 'right' : 'left');
const resolvedTextColor = getTextColorForBackground(
{ backgroundColor, color },
cellSurfaceColor,
);
const hoverResolvedTextColor = getTextColorForBackground(
{ backgroundColor, color },
hoverCellSurfaceColor,
);
return {
backgroundColor: backgroundColor || '',
color: '',
'--ag-cell-value-color': resolvedTextColor || '',
'--ag-cell-value-hover-color': hoverResolvedTextColor || '',
textAlign,
};
};

View File

@@ -21,6 +21,7 @@ import { ColDef } from '@superset-ui/core/components/ThemedAgGridReact';
import { useCallback, useMemo } from 'react';
import { DataRecord, DataRecordValue } from '@superset-ui/core';
import { GenericDataType } from '@apache-superset/core/common';
import { useTheme } from '@apache-superset/core/theme';
import { ColorFormatters } from '@superset-ui/chart-controls';
import { extent as d3Extent, max as d3Max } from 'd3-array';
import {
@@ -225,6 +226,7 @@ export const useColDefs = ({
alignPositiveNegative,
slice_id,
}: UseColDefsProps) => {
const theme = useTheme();
const getCommonColProps = useCallback(
(
col: InputColumn,
@@ -280,15 +282,30 @@ export const useColDefs = ({
headerName: getHeaderLabel(col),
valueFormatter: p => valueFormatter(p, col),
valueGetter: p => valueGetter(p, col),
cellStyle: p =>
getCellStyle({
cellStyle: p => {
const cellSurfaceColor =
p.node?.rowPinned != null
? theme.colorBgBase
: p.rowIndex % 2 === 0
? theme.colorBgBase
: theme.colorFillQuaternary;
const hoverCellSurfaceColor =
p.node?.rowPinned != null
? cellSurfaceColor
: theme.colorFillSecondary;
const cellStyleParams = {
...p,
hasColumnColorFormatters,
columnColorFormatters,
hasBasicColorFormatters,
basicColorFormatters,
col,
}),
cellSurfaceColor,
hoverCellSurfaceColor,
} as Parameters<typeof getCellStyle>[0];
return getCellStyle(cellStyleParams);
},
cellClass: p =>
getCellClass({
...p,
@@ -385,6 +402,9 @@ export const useColDefs = ({
allowRearrangeColumns,
serverPagination,
alignPositiveNegative,
theme.colorBgBase,
theme.colorFillSecondary,
theme.colorFillQuaternary,
],
);

View File

@@ -18,9 +18,19 @@
*/
import { renderHook } from '@testing-library/react-hooks';
import { GenericDataType } from '@apache-superset/core/common';
import {
supersetTheme,
ThemeProvider,
type SupersetTheme,
} from '@apache-superset/core/theme';
import { ObjectFormattingEnum } from '@superset-ui/chart-controls';
import tinycolor from 'tinycolor2';
import { createElement, type ComponentProps, ReactNode } from 'react';
import { useColDefs } from '../../src/utils/useColDefs';
import { InputColumn } from '../../src/types';
type TestCellStyleFunc = (params: unknown) => unknown;
function makeColumn(overrides: Partial<InputColumn> = {}): InputColumn {
return {
key: 'test_col',
@@ -34,6 +44,81 @@ function makeColumn(overrides: Partial<InputColumn> = {}): InputColumn {
};
}
function getCellStyleFunction(cellStyle: unknown): TestCellStyleFunc {
expect(typeof cellStyle).toBe('function');
return cellStyle as TestCellStyleFunc;
}
function getCellStyleResult(
cellStyle: TestCellStyleFunc,
overrides: Record<string, unknown> = {},
) {
return cellStyle({
value: 42,
colDef: { field: 'count' },
rowIndex: 0,
node: {},
...overrides,
} as never);
}
function getExpectedTextColor(
result: { backgroundColor?: string; color?: string },
surfaceColor: string,
) {
if (result.color) {
const parsedColor = tinycolor(result.color);
return parsedColor.isValid()
? parsedColor.setAlpha(1).toRgbString()
: result.color;
}
if (!result.backgroundColor) {
return undefined;
}
const background = tinycolor(result.backgroundColor);
const surface = tinycolor(surfaceColor);
if (!background.isValid() || !surface.isValid()) {
return undefined;
}
const { r: bgR, g: bgG, b: bgB, a: bgAlpha } = background.toRgb();
const { r: surfaceR, g: surfaceG, b: surfaceB } = surface.toRgb();
const alpha = bgAlpha ?? 1;
return tinycolor
.mostReadable(
tinycolor({
r: bgR * alpha + surfaceR * (1 - alpha),
g: bgG * alpha + surfaceG * (1 - alpha),
b: bgB * alpha + surfaceB * (1 - alpha),
}),
[
{ r: 0, g: 0, b: 0 },
{ r: 255, g: 255, b: 255 },
],
{
includeFallbackColors: true,
level: 'AA',
size: 'small',
},
)
.toRgbString();
}
function makeThemeWrapper(theme: SupersetTheme) {
return function ThemeWrapper({ children }: { children?: ReactNode }) {
return createElement(
ThemeProvider,
{ theme } as ComponentProps<typeof ThemeProvider>,
children,
);
};
}
const defaultThemeWrapper = makeThemeWrapper(supersetTheme);
const defaultProps = {
data: [{ test_col: 'value' }],
serverPagination: false,
@@ -58,12 +143,14 @@ test('boolean columns use agCheckboxCellRenderer', () => {
dataType: GenericDataType.Boolean,
});
const { result } = renderHook(() =>
useColDefs({
...defaultProps,
columns: [booleanCol],
data: [{ is_active: true }, { is_active: false }],
}),
const { result } = renderHook(
() =>
useColDefs({
...defaultProps,
columns: [booleanCol],
data: [{ is_active: true }, { is_active: false }],
}),
{ wrapper: defaultThemeWrapper },
);
const colDef = result.current[0];
@@ -79,12 +166,14 @@ test('string columns use custom TextCellRenderer', () => {
dataType: GenericDataType.String,
});
const { result } = renderHook(() =>
useColDefs({
...defaultProps,
columns: [stringCol],
data: [{ name: 'Alice' }],
}),
const { result } = renderHook(
() =>
useColDefs({
...defaultProps,
columns: [stringCol],
data: [{ name: 'Alice' }],
}),
{ wrapper: defaultThemeWrapper },
);
const colDef = result.current[0];
@@ -101,12 +190,14 @@ test('numeric columns use custom NumericCellRenderer', () => {
isMetric: true,
});
const { result } = renderHook(() =>
useColDefs({
...defaultProps,
columns: [numericCol],
data: [{ count: 42 }],
}),
const { result } = renderHook(
() =>
useColDefs({
...defaultProps,
columns: [numericCol],
data: [{ count: 42 }],
}),
{ wrapper: defaultThemeWrapper },
);
const colDef = result.current[0];
@@ -121,15 +212,619 @@ test('temporal columns use custom TextCellRenderer', () => {
dataType: GenericDataType.Temporal,
});
const { result } = renderHook(() =>
useColDefs({
...defaultProps,
columns: [temporalCol],
data: [{ created_at: '2024-01-01' }],
}),
const { result } = renderHook(
() =>
useColDefs({
...defaultProps,
columns: [temporalCol],
data: [{ created_at: '2024-01-01' }],
}),
{ wrapper: defaultThemeWrapper },
);
const colDef = result.current[0];
expect(colDef.cellRenderer).toBeInstanceOf(Function);
expect(colDef.cellDataType).toBe('date');
});
test('cellStyle derives readable text color from dark background formatting', () => {
const numericCol = makeColumn({
key: 'count',
label: 'Count',
dataType: GenericDataType.Numeric,
isNumeric: true,
isMetric: true,
});
const { result } = renderHook(
() =>
useColDefs({
...defaultProps,
columns: [numericCol],
data: [{ count: 42 }],
columnColorFormatters: [
{
column: 'count',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: (value: unknown) =>
value === 42 ? '#111111' : undefined,
},
],
}),
{ wrapper: defaultThemeWrapper },
);
const colDef = result.current[0];
const cellStyle = getCellStyleFunction(colDef.cellStyle);
expect(
cellStyle({
value: 42,
colDef: { field: 'count' },
rowIndex: 0,
node: {},
} as never),
).toMatchObject({
backgroundColor: '#111111',
color: '',
'--ag-cell-value-color': 'rgb(255, 255, 255)',
textAlign: 'right',
});
});
test('cellStyle keeps explicit text color over adaptive contrast', () => {
const numericCol = makeColumn({
key: 'count',
label: 'Count',
dataType: GenericDataType.Numeric,
isNumeric: true,
isMetric: true,
});
const { result } = renderHook(
() =>
useColDefs({
...defaultProps,
columns: [numericCol],
data: [{ count: 42 }],
columnColorFormatters: [
{
column: 'count',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: (value: unknown) =>
value === 42 ? '#111111' : undefined,
},
{
column: 'count',
objectFormatting: ObjectFormattingEnum.TEXT_COLOR,
getColorFromValue: (value: unknown) =>
value === 42 ? '#ace1c40d' : undefined,
},
],
}),
{ wrapper: defaultThemeWrapper },
);
const colDef = result.current[0];
const cellStyle = getCellStyleFunction(colDef.cellStyle);
expect(
cellStyle({
value: 42,
colDef: { field: 'count' },
rowIndex: 0,
node: {},
} as never),
).toMatchObject({
backgroundColor: '#111111',
color: '',
'--ag-cell-value-color': 'rgb(172, 225, 196)',
textAlign: 'right',
});
});
test('cellStyle treats legacy toTextColor formatters as text color', () => {
const numericCol = makeColumn({
key: 'count',
label: 'Count',
dataType: GenericDataType.Numeric,
isNumeric: true,
isMetric: true,
});
const { result } = renderHook(
() =>
useColDefs({
...defaultProps,
columns: [numericCol],
data: [{ count: 42 }],
columnColorFormatters: [
{
column: 'count',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: (value: unknown) =>
value === 42 ? '#111111' : undefined,
},
{
column: 'count',
toTextColor: true,
getColorFromValue: (value: unknown) =>
value === 42 ? '#ace1c40d' : undefined,
},
],
}),
{ wrapper: defaultThemeWrapper },
);
const colDef = result.current[0];
const cellStyle = getCellStyleFunction(colDef.cellStyle);
expect(getCellStyleResult(cellStyle)).toMatchObject({
backgroundColor: '#111111',
color: '',
'--ag-cell-value-color': 'rgb(172, 225, 196)',
textAlign: 'right',
});
});
test('cellStyle uses caller-provided surface color for adaptive contrast', () => {
const numericCol = makeColumn({
key: 'count',
label: 'Count',
dataType: GenericDataType.Numeric,
isNumeric: true,
isMetric: true,
});
const backgroundColor = 'rgba(0, 0, 0, 0.4)';
const { result: lightResult } = renderHook(
() =>
useColDefs({
...defaultProps,
columns: [numericCol],
data: [{ count: 42 }],
columnColorFormatters: [
{
column: 'count',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: (value: unknown) =>
value === 42 ? backgroundColor : undefined,
},
],
}),
{
wrapper: makeThemeWrapper({
...supersetTheme,
colorBgBase: '#ffffff',
}),
},
);
const { result: darkResult } = renderHook(
() =>
useColDefs({
...defaultProps,
columns: [numericCol],
data: [{ count: 42 }],
columnColorFormatters: [
{
column: 'count',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: (value: unknown) =>
value === 42 ? backgroundColor : undefined,
},
],
}),
{
wrapper: makeThemeWrapper({
...supersetTheme,
colorBgBase: '#000000',
}),
},
);
const lightCellStyle = getCellStyleFunction(lightResult.current[0].cellStyle);
const darkCellStyle = getCellStyleFunction(darkResult.current[0].cellStyle);
expect(getCellStyleResult(lightCellStyle)).toMatchObject({
backgroundColor,
color: '',
'--ag-cell-value-color': getExpectedTextColor(
{ backgroundColor },
'#ffffff',
),
});
expect(getCellStyleResult(darkCellStyle)).toMatchObject({
backgroundColor,
color: '',
'--ag-cell-value-color': getExpectedTextColor(
{ backgroundColor },
'#000000',
),
});
expect(getCellStyleResult(lightCellStyle)).not.toEqual(
getCellStyleResult(darkCellStyle),
);
});
test('cellStyle uses striped odd-row surface for adaptive contrast', () => {
const numericCol = makeColumn({
key: 'count',
label: 'Count',
dataType: GenericDataType.Numeric,
isNumeric: true,
isMetric: true,
});
const backgroundColor = 'rgba(0, 0, 0, 0.4)';
const { result } = renderHook(
() =>
useColDefs({
...defaultProps,
columns: [numericCol],
data: [{ count: 42 }, { count: 43 }],
columnColorFormatters: [
{
column: 'count',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: (value: unknown) =>
typeof value === 'number' ? backgroundColor : undefined,
},
],
}),
{
wrapper: makeThemeWrapper({
...supersetTheme,
colorBgBase: '#ffffff',
colorFillQuaternary: '#000000',
}),
},
);
const cellStyle = getCellStyleFunction(result.current[0].cellStyle);
expect(
getCellStyleResult(cellStyle, {
rowIndex: 0,
}),
).toMatchObject({
backgroundColor,
color: '',
'--ag-cell-value-color': getExpectedTextColor(
{ backgroundColor },
'#ffffff',
),
});
expect(
getCellStyleResult(cellStyle, {
rowIndex: 1,
}),
).toMatchObject({
backgroundColor,
color: '',
'--ag-cell-value-color': getExpectedTextColor(
{ backgroundColor },
'#000000',
),
});
});
test('cellStyle exposes hover-specific adaptive contrast for formatted cells', () => {
const numericCol = makeColumn({
key: 'count',
label: 'Count',
dataType: GenericDataType.Numeric,
isNumeric: true,
isMetric: true,
});
const backgroundColor = 'rgba(0, 0, 0, 0.4)';
const { result } = renderHook(
() =>
useColDefs({
...defaultProps,
columns: [numericCol],
data: [{ count: 42 }],
columnColorFormatters: [
{
column: 'count',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: (value: unknown) =>
value === 42 ? backgroundColor : undefined,
},
],
}),
{
wrapper: makeThemeWrapper({
...supersetTheme,
colorBgBase: '#ffffff',
colorFillSecondary: '#000000',
}),
},
);
const cellStyle = getCellStyleFunction(result.current[0].cellStyle);
expect(getCellStyleResult(cellStyle)).toMatchObject({
backgroundColor,
color: '',
'--ag-cell-value-color': getExpectedTextColor(
{ backgroundColor },
'#ffffff',
),
'--ag-cell-value-hover-color': getExpectedTextColor(
{ backgroundColor },
'#000000',
),
});
});
test('cellStyle resets inline text color variables when no formatter matches', () => {
const numericCol = makeColumn({
key: 'count',
label: 'Count',
dataType: GenericDataType.Numeric,
isNumeric: true,
isMetric: true,
});
const { result } = renderHook(
() =>
useColDefs({
...defaultProps,
columns: [numericCol],
data: [{ count: 42 }],
columnColorFormatters: [
{
column: 'count',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: () => undefined,
},
],
}),
{
wrapper: makeThemeWrapper({
...supersetTheme,
colorPrimaryText: '#123456',
}),
},
);
const cellStyle = getCellStyleFunction(result.current[0].cellStyle);
const cellStyleResult = getCellStyleResult(cellStyle) as {
backgroundColor: string;
color?: string;
textAlign: string;
};
expect(cellStyleResult).toMatchObject({
backgroundColor: '',
color: '',
'--ag-cell-value-color': '',
'--ag-cell-value-hover-color': '',
textAlign: 'right',
});
});
test('cellStyle preserves invalid explicit text color', () => {
const numericCol = makeColumn({
key: 'count',
label: 'Count',
dataType: GenericDataType.Numeric,
isNumeric: true,
isMetric: true,
});
const { result } = renderHook(
() =>
useColDefs({
...defaultProps,
columns: [numericCol],
data: [{ count: 42 }],
columnColorFormatters: [
{
column: 'count',
objectFormatting: ObjectFormattingEnum.TEXT_COLOR,
getColorFromValue: (value: unknown) =>
value === 42 ? 'not-a-color' : undefined,
},
],
}),
{ wrapper: defaultThemeWrapper },
);
const cellStyle = getCellStyleFunction(result.current[0].cellStyle);
expect(getCellStyleResult(cellStyle)).toMatchObject({
backgroundColor: '',
color: '',
'--ag-cell-value-color': 'not-a-color',
'--ag-cell-value-hover-color': 'not-a-color',
});
});
test('cellStyle ignores cell-bar formatters for text and background resolution', () => {
const numericCol = makeColumn({
key: 'count',
label: 'Count',
dataType: GenericDataType.Numeric,
isNumeric: true,
isMetric: true,
});
const { result } = renderHook(
() =>
useColDefs({
...defaultProps,
columns: [numericCol],
data: [{ count: 42 }],
columnColorFormatters: [
{
column: 'count',
objectFormatting: ObjectFormattingEnum.CELL_BAR,
getColorFromValue: (value: unknown) =>
value === 42 ? '#11111199' : undefined,
},
],
}),
{
wrapper: makeThemeWrapper({
...supersetTheme,
colorPrimaryText: '#654321',
}),
},
);
const cellStyle = getCellStyleFunction(result.current[0].cellStyle);
const cellStyleResult = getCellStyleResult(cellStyle) as {
backgroundColor: string;
color?: string;
};
expect(cellStyleResult).toMatchObject({
backgroundColor: '',
color: '',
'--ag-cell-value-color': '',
'--ag-cell-value-hover-color': '',
});
});
test('cellStyle lets basic color formatters override column formatter background', () => {
const numericCol = makeColumn({
key: 'count',
label: 'Count',
dataType: GenericDataType.Numeric,
isNumeric: true,
isMetric: true,
metricName: 'sum__count',
});
const { result } = renderHook(
() =>
useColDefs({
...defaultProps,
columns: [numericCol],
data: [{ count: 42 }],
isUsingTimeComparison: true,
columnColorFormatters: [
{
column: 'count',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: (value: unknown) =>
value === 42 ? '#111111' : undefined,
},
],
basicColorFormatters: [
{
sum__count: {
backgroundColor: '#abcdef',
arrowColor: 'Green',
mainArrow: 'up',
},
},
],
}),
{ wrapper: defaultThemeWrapper },
);
const cellStyle = getCellStyleFunction(result.current[0].cellStyle);
expect(getCellStyleResult(cellStyle)).toMatchObject({
backgroundColor: '#abcdef',
color: '',
'--ag-cell-value-color': getExpectedTextColor(
{ backgroundColor: '#abcdef' },
'#ffffff',
),
});
});
test('cellStyle ignores basic color formatters for pinned bottom rows', () => {
const numericCol = makeColumn({
key: 'count',
label: 'Count',
dataType: GenericDataType.Numeric,
isNumeric: true,
isMetric: true,
metricName: 'sum__count',
});
const { result } = renderHook(
() =>
useColDefs({
...defaultProps,
columns: [numericCol],
data: [{ count: 42 }],
isUsingTimeComparison: true,
basicColorFormatters: [
{
sum__count: {
backgroundColor: '#abcdef',
arrowColor: 'Green',
mainArrow: 'up',
},
},
],
}),
{ wrapper: defaultThemeWrapper },
);
const cellStyle = getCellStyleFunction(result.current[0].cellStyle);
expect(
getCellStyleResult(cellStyle, {
node: { rowPinned: 'bottom' },
}),
).toMatchObject({
backgroundColor: '',
});
});
test('cellStyle defaults non-numeric columns to left alignment', () => {
const stringCol = makeColumn({
key: 'name',
label: 'Name',
dataType: GenericDataType.String,
});
const { result } = renderHook(
() =>
useColDefs({
...defaultProps,
columns: [stringCol],
data: [{ name: 'Alice' }],
}),
{ wrapper: defaultThemeWrapper },
);
const cellStyle = getCellStyleFunction(result.current[0].cellStyle);
expect(
cellStyle({
value: 'Alice',
colDef: { field: 'name' },
rowIndex: 0,
node: {},
} as never),
).toMatchObject({
textAlign: 'left',
});
});
test('cellStyle respects explicit horizontal alignment overrides', () => {
const numericCol = makeColumn({
key: 'count',
label: 'Count',
dataType: GenericDataType.Numeric,
isNumeric: true,
isMetric: true,
config: {
horizontalAlign: 'center',
},
});
const { result } = renderHook(
() =>
useColDefs({
...defaultProps,
columns: [numericCol],
data: [{ count: 42 }],
}),
{ wrapper: defaultThemeWrapper },
);
const cellStyle = getCellStyleFunction(result.current[0].cellStyle);
expect(getCellStyleResult(cellStyle)).toMatchObject({
textAlign: 'center',
});
});

View File

@@ -576,6 +576,9 @@ export default function PivotTableChart(props: PivotTableProps) {
omittedHighlightHeaderGroups: [METRIC_KEY],
cellColorFormatters: { [METRIC_KEY]: metricColorFormatters },
dateFormatters,
cellBackgroundColor: theme.colorBgBase,
cellTextColor: theme.colorPrimaryText,
activeHeaderBackgroundColor: theme.colorPrimaryBg,
}),
[
colTotals,
@@ -586,6 +589,9 @@ export default function PivotTableChart(props: PivotTableProps) {
rowTotals,
rowSubTotals,
selectedFilters,
theme.colorBgBase,
theme.colorPrimaryBg,
theme.colorPrimaryText,
toggleFilter,
],
);

View File

@@ -20,18 +20,20 @@
import { Component, ReactNode, MouseEvent } from 'react';
import { safeHtmlSpan } from '@superset-ui/core';
import { t } from '@apache-superset/core/translation';
import { supersetTheme } from '@apache-superset/core/theme';
import PropTypes from 'prop-types';
import { FaSort } from 'react-icons/fa';
import { FaSortDown as FaSortDesc } from 'react-icons/fa';
import { FaSortUp as FaSortAsc } from 'react-icons/fa';
import {
ColorFormatters,
getTextColorForBackground,
ObjectFormattingEnum,
ResolvedColorFormatterResult,
} from '@superset-ui/chart-controls';
import { PivotData, flatKey } from './utilities';
import { Styles } from './Styles';
interface CellColorFormatter {
column: string;
getColorFromValue(value: unknown): string | undefined;
}
type ClickCallback = (
e: MouseEvent,
value: unknown,
@@ -59,8 +61,11 @@ interface TableOptions {
highlightHeaderCellsOnHover?: boolean;
omittedHighlightHeaderGroups?: string[];
highlightedHeaderCells?: Record<string, unknown[]>;
cellColorFormatters?: Record<string, CellColorFormatter[]>;
cellColorFormatters?: Record<string, ColorFormatters>;
dateFormatters?: Record<string, ((val: unknown) => string) | undefined>;
cellBackgroundColor?: string;
cellTextColor?: string;
activeHeaderBackgroundColor?: string;
}
interface SubtotalDisplay {
@@ -174,14 +179,19 @@ function displayHeaderCell(
);
}
function getCellColor(
export function getCellColor(
keys: string[],
aggValue: string | number | null,
cellColorFormatters: Record<string, CellColorFormatter[]> | undefined,
): { backgroundColor: string | undefined } {
cellColorFormatters: Record<string, ColorFormatters> | undefined,
cellBackgroundColor = supersetTheme.colorBgBase,
): ResolvedColorFormatterResult {
if (!cellColorFormatters) return { backgroundColor: undefined };
let backgroundColor: string | undefined;
let color: string | undefined;
const isTextColorFormatter = (formatter: ColorFormatters[number]) =>
formatter.objectFormatting === ObjectFormattingEnum.TEXT_COLOR ||
formatter.toTextColor;
for (const cellColorFormatter of Object.values(cellColorFormatters)) {
if (!Array.isArray(cellColorFormatter)) continue;
@@ -191,14 +201,26 @@ function getCellColor(
if (formatter.column === key) {
const result = formatter.getColorFromValue(aggValue);
if (result) {
backgroundColor = result;
if (isTextColorFormatter(formatter)) {
color = result;
} else if (
formatter.objectFormatting !== ObjectFormattingEnum.CELL_BAR
) {
backgroundColor = result;
}
}
}
}
}
}
return { backgroundColor };
return {
backgroundColor,
color: getTextColorForBackground(
{ backgroundColor, color },
cellBackgroundColor,
),
};
}
interface HierarchicalNode {
@@ -746,6 +768,8 @@ export class TableRenderer extends Component<
highlightedHeaderCells,
cellColorFormatters,
dateFormatters,
cellBackgroundColor = supersetTheme.colorBgBase,
activeHeaderBackgroundColor = supersetTheme.colorPrimaryBg,
} = this.props.tableOptions;
if (!visibleColKeys || !colAttrSpans) {
@@ -812,6 +836,7 @@ export class TableRenderer extends Component<
) {
colLabelClass += ' active';
}
const isActiveHeader = colLabelClass.includes('active');
const maxRowIndex = pivotSettings.maxRowVisible!;
const mColVisible = pivotSettings.maxColVisible!;
const visibleSortIcon = mColVisible - 1 === attrIdx;
@@ -844,12 +869,16 @@ export class TableRenderer extends Component<
};
const headerCellFormattedValue =
dateFormatters?.[attrName]?.(colKey[attrIdx]) ?? colKey[attrIdx];
const { backgroundColor } = getCellColor(
const { backgroundColor, color } = getCellColor(
[attrName],
headerCellFormattedValue,
cellColorFormatters,
isActiveHeader ? activeHeaderBackgroundColor : cellBackgroundColor,
);
const style = { backgroundColor };
const style = {
backgroundColor,
...(color ? { color } : {}),
};
attrValueCells.push(
<th
className={colLabelClass}
@@ -1044,6 +1073,9 @@ export class TableRenderer extends Component<
highlightedHeaderCells,
cellColorFormatters,
dateFormatters,
cellBackgroundColor = supersetTheme.colorBgBase,
cellTextColor = supersetTheme.colorPrimaryText,
activeHeaderBackgroundColor = supersetTheme.colorPrimaryBg,
} = this.props.tableOptions;
const flatRowKey = flatKey(rowKey);
@@ -1067,6 +1099,7 @@ export class TableRenderer extends Component<
) {
valueCellClassName += ' active';
}
const isActiveHeader = valueCellClassName.includes('active');
const rowSpan = rowAttrSpans![rowIdx][i];
if (rowSpan > 0) {
const flatRowKey = flatKey(rowKey.slice(0, i + 1));
@@ -1080,12 +1113,16 @@ export class TableRenderer extends Component<
const headerCellFormattedValue =
dateFormatters?.[rowAttrs[i]]?.(r) ?? r;
const { backgroundColor } = getCellColor(
const { backgroundColor, color } = getCellColor(
[rowAttrs[i]],
headerCellFormattedValue,
cellColorFormatters,
isActiveHeader ? activeHeaderBackgroundColor : cellBackgroundColor,
);
const style = { backgroundColor };
const style = {
backgroundColor,
...(color ? { color } : {}),
};
return (
<th
key={`rowKeyLabel-${i}`}
@@ -1152,15 +1189,20 @@ export class TableRenderer extends Component<
const keys = [...rowKey, ...colKey];
const { backgroundColor } = getCellColor(
const { backgroundColor, color } = getCellColor(
keys,
aggValue,
cellColorFormatters,
cellBackgroundColor,
);
const style = agg.isSubtotal
? { fontWeight: 'bold' }
: { backgroundColor };
? {
backgroundColor,
fontWeight: 'bold',
color: color ?? cellTextColor,
}
: { backgroundColor, color: color ?? cellTextColor };
return (
<td

View File

@@ -16,21 +16,32 @@
* specific language governing permissions and limitations
* under the License.
*/
import { TableRenderer } from '../../src/react-pivottable/TableRenderers';
import { Children, isValidElement, type ReactElement } from 'react';
import {
getTextColorForBackground,
ObjectFormattingEnum,
} from '@superset-ui/chart-controls';
import {
getCellColor,
TableRenderer,
} from '../../src/react-pivottable/TableRenderers';
import type { PivotData } from '../../src/react-pivottable/utilities';
let tableRenderer: TableRenderer;
let mockGetAggregatedData: jest.Mock;
let mockSortAndCacheData: jest.Mock;
type RenderColHeaderRowSettings = Parameters<
TableRenderer['renderColHeaderRow']
>[2];
type RenderTableRowSettings = Parameters<TableRenderer['renderTableRow']>[2];
type TableRendererStateStub = TableRenderer['state'];
type CachedBasePivotSettings = NonNullable<
TableRenderer['cachedBasePivotSettings']
>;
const columnIndex = 0;
const visibleColKeys = [['col1'], ['col2']];
const pivotData = {
subtotals: {
rowEnabled: true,
rowPartialOnTop: false,
},
} as any;
const maxRowIndex = 2;
const mockProps = {
@@ -56,6 +67,97 @@ const mockProps = {
colPartialOnTop: false,
};
const toPivotData = (value: Partial<PivotData>): PivotData =>
value as unknown as PivotData;
const toCachedBasePivotSettings = (
value: Partial<CachedBasePivotSettings>,
): CachedBasePivotSettings => value as unknown as CachedBasePivotSettings;
const createActiveHeaderTableOptions = (
activeHeaderBackgroundColor: string,
overrides: Record<string, unknown> = {},
): TableRenderer['props']['tableOptions'] =>
({
...overrides,
activeHeaderBackgroundColor,
}) as unknown as TableRenderer['props']['tableOptions'];
const createPivotDataStub = (
aggregatorValue = 200,
isSubtotal = false,
): PivotData =>
toPivotData({
getAggregator: () => ({
push: jest.fn(),
value: () => aggregatorValue,
format: (value: number) => String(value),
isSubtotal,
}),
});
const pivotData = toPivotData({
subtotals: {
rowEnabled: true,
rowPartialOnTop: false,
},
});
const createColHeaderRowSettings = (
overrides: Partial<RenderColHeaderRowSettings> = {},
): RenderColHeaderRowSettings =>
({
rowAttrs: [],
colAttrs: ['region'],
visibleColKeys: [['EMEA']],
colAttrSpans: [[1]],
colKeys: [['EMEA']],
colSubtotalDisplay: {
displayOnTop: false,
enabled: false,
hideOnExpand: false,
},
rowSubtotalDisplay: {
displayOnTop: false,
enabled: false,
hideOnExpand: false,
},
maxColVisible: 1,
maxRowVisible: 0,
pivotData: createPivotDataStub(),
namesMapping: {},
allowRenderHtml: false,
arrowExpanded: null,
arrowCollapsed: null,
rowTotals: false,
colTotals: false,
...overrides,
}) as RenderColHeaderRowSettings;
const createTableRowSettings = (
overrides: Partial<RenderTableRowSettings> = {},
): RenderTableRowSettings =>
({
rowAttrs: ['metric'],
colAttrs: [],
rowAttrSpans: [[1]],
visibleColKeys: [[]],
pivotData: createPivotDataStub(),
rowTotals: false,
rowSubtotalDisplay: {
displayOnTop: false,
enabled: false,
hideOnExpand: false,
},
arrowExpanded: null,
arrowCollapsed: null,
cellCallbacks: {},
rowTotalCallbacks: {},
namesMapping: {},
allowRenderHtml: false,
...overrides,
}) as RenderTableRowSettings;
beforeEach(() => {
tableRenderer = new TableRenderer(mockProps);
@@ -65,24 +167,24 @@ beforeEach(() => {
tableRenderer.getAggregatedData = mockGetAggregatedData;
tableRenderer.sortAndCacheData = mockSortAndCacheData;
tableRenderer.cachedBasePivotSettings = {
pivotData: {
tableRenderer.cachedBasePivotSettings = toCachedBasePivotSettings({
pivotData: toPivotData({
subtotals: {
rowEnabled: true,
rowPartialOnTop: false,
colEnabled: false,
colPartialOnTop: false,
},
},
}),
rowKeys: [['A'], ['B'], ['C']],
} as any;
});
tableRenderer.state = {
sortingOrder: [],
activeSortColumn: null,
collapsedRows: {},
collapsedCols: {},
} as any;
} as TableRendererStateStub;
});
const mockGroups = {
@@ -103,13 +205,15 @@ const mockGroups = {
},
};
const createMockPivotData = (rowData: Record<string, number>) =>
({
const createMockPivotData = (rowData: Record<string, number>): PivotData =>
toPivotData({
rowKeys: Object.keys(rowData).map(key => key.split('.')),
getAggregator: (rowKey: string[], colName: string) => ({
getAggregator: (rowKey: string[]) => ({
push: jest.fn(),
value: () => rowData[rowKey.join('.')],
format: (value: number) => String(value),
}),
}) as unknown as PivotData;
});
test('should set initial ascending sort when no active sort column', () => {
mockGetAggregatedData.mockReturnValue({
@@ -211,15 +315,15 @@ test('should check second call in sequence', () => {
activeSortColumn: 0,
collapsedRows: {},
collapsedCols: {},
} as any;
} as TableRendererStateStub;
tableRenderer.sortData(columnIndex, visibleColKeys, pivotData, maxRowIndex);
tableRenderer.state = {
sortingOrder: ['asc' as never],
activeSortColumn: 0 as any,
activeSortColumn: 0,
collapsedRows: {},
collapsedCols: {},
} as any;
} as TableRendererStateStub;
tableRenderer.sortData(columnIndex, visibleColKeys, pivotData, maxRowIndex);
expect(mockSortAndCacheData).toHaveBeenCalledTimes(2);
@@ -324,22 +428,23 @@ test('should sort hierarchical data in ascending order', () => {
test('should calculate groups from pivot data', () => {
tableRenderer = new TableRenderer(mockProps);
const mockAggregator = (value: number) => ({
push: jest.fn(),
value: () => value,
format: jest.fn(),
isSubtotal: false,
});
const mockPivotData = {
const mockPivotData = toPivotData({
rowKeys: [['A'], ['B'], ['C']],
getAggregator: jest
.fn()
.mockReturnValueOnce(mockAggregator(30))
.mockReturnValueOnce(mockAggregator(10))
.mockReturnValueOnce(mockAggregator(20)),
};
});
const result = tableRenderer.getAggregatedData(
mockPivotData as any,
mockPivotData,
['col1'],
false,
);
@@ -589,3 +694,359 @@ test('values from the 3rd level of the hierarchy with a subtotal at the to
},
});
});
test('getCellColor derives readable text from the winning background', () => {
expect(
getCellColor(
['revenue'],
200,
{
metric: [
{
column: 'revenue',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: (value: unknown) =>
value === 200 ? '#111111' : undefined,
},
],
},
'#ffffff',
),
).toEqual({
backgroundColor: '#111111',
color: 'rgb(255, 255, 255)',
});
});
test('getCellColor keeps explicit text color over adaptive contrast', () => {
expect(
getCellColor(
['revenue'],
200,
{
metric: [
{
column: 'revenue',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: (value: unknown) =>
value === 200 ? '#111111' : undefined,
},
{
column: 'revenue',
objectFormatting: ObjectFormattingEnum.TEXT_COLOR,
getColorFromValue: (value: unknown) =>
value === 200 ? '#ace1c40d' : undefined,
},
],
},
'#ffffff',
),
).toEqual({
backgroundColor: '#111111',
color: 'rgb(172, 225, 196)',
});
});
test('getCellColor treats legacy toTextColor formatters as text color', () => {
expect(
getCellColor(
['revenue'],
200,
{
metric: [
{
column: 'revenue',
getColorFromValue: (value: unknown) =>
value === 200 ? '#111111' : undefined,
},
{
column: 'revenue',
toTextColor: true,
getColorFromValue: (value: unknown) =>
value === 200 ? '#ace1c40d' : undefined,
},
],
},
'#ffffff',
),
).toEqual({
backgroundColor: '#111111',
color: 'rgb(172, 225, 196)',
});
});
test('getCellColor ignores cell-bar rules when resolving text color', () => {
expect(
getCellColor(
['revenue'],
200,
{
metric: [
{
column: 'revenue',
objectFormatting: ObjectFormattingEnum.CELL_BAR,
getColorFromValue: (value: unknown) =>
value === 200 ? '#11111199' : undefined,
},
],
},
'#ffffff',
),
).toEqual({
backgroundColor: undefined,
color: undefined,
});
});
test('renderTableRow keeps subtotal background and readable text in sync', () => {
tableRenderer = new TableRenderer({
...mockProps,
tableOptions: {
cellColorFormatters: {
metric: [
{
column: 'revenue',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: (value: unknown) =>
value === 200 ? '#111111' : undefined,
},
],
},
cellBackgroundColor: '#ffffff',
cellTextColor: '#000000',
},
});
const row = tableRenderer.renderTableRow(
['revenue'],
0,
createTableRowSettings({
pivotData: createPivotDataStub(200, true),
}),
) as ReactElement;
const cells = Children.toArray(row.props.children);
const valueCell = cells.find(
child => isValidElement(child) && child.props.className === 'pvtVal',
) as ReactElement;
expect(valueCell.props.style).toEqual({
backgroundColor: '#111111',
color: 'rgb(255, 255, 255)',
fontWeight: 'bold',
});
});
test('renderColAttrsHeader applies readable text color to formatted headers', () => {
tableRenderer = new TableRenderer({
...mockProps,
tableOptions: {
cellColorFormatters: {
metric: [
{
column: 'region',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: (value: unknown) =>
value === 'EMEA' ? '#111111' : undefined,
},
],
},
cellBackgroundColor: '#ffffff',
cellTextColor: '#000000',
},
});
const row = tableRenderer.renderColHeaderRow(
'region',
0,
createColHeaderRowSettings(),
) as ReactElement;
const cells = Children.toArray(row.props.children);
const headerCell = cells.find(
child => isValidElement(child) && child.props.className === 'pvtColLabel',
) as ReactElement;
expect(headerCell.props.style).toEqual({
backgroundColor: '#111111',
color: 'rgb(255, 255, 255)',
});
});
test('renderColAttrsHeader uses active header surface for adaptive contrast', () => {
const activeHeaderBackgroundColor = '#102a43';
tableRenderer = new TableRenderer({
...mockProps,
tableOptions: createActiveHeaderTableOptions(activeHeaderBackgroundColor, {
highlightedHeaderCells: {
region: ['EMEA'],
},
cellColorFormatters: {
metric: [
{
column: 'region',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: (value: unknown) =>
value === 'EMEA' ? 'rgba(0, 0, 0, 0.4)' : undefined,
},
],
},
cellBackgroundColor: '#ffffff',
cellTextColor: '#000000',
}),
});
const row = tableRenderer.renderColHeaderRow(
'region',
0,
createColHeaderRowSettings(),
) as ReactElement;
const cells = Children.toArray(row.props.children);
const headerCell = cells.find(
child =>
isValidElement(child) && child.props.className === 'pvtColLabel active',
) as ReactElement;
expect(headerCell.props.style).toEqual({
backgroundColor: 'rgba(0, 0, 0, 0.4)',
color: getTextColorForBackground(
{ backgroundColor: 'rgba(0, 0, 0, 0.4)' },
activeHeaderBackgroundColor,
),
});
});
test('renderColHeaderRow preserves default header text color without formatting', () => {
tableRenderer = new TableRenderer({
...mockProps,
tableOptions: {
cellColorFormatters: { metric: [] },
cellBackgroundColor: '#ffffff',
cellTextColor: '#ff00aa',
},
});
const row = tableRenderer.renderColHeaderRow(
'region',
0,
createColHeaderRowSettings(),
) as ReactElement;
const cells = Children.toArray(row.props.children);
const headerCell = cells.find(
child => isValidElement(child) && child.props.className === 'pvtColLabel',
) as ReactElement;
expect(headerCell.props.style).toEqual({
backgroundColor: undefined,
color: undefined,
});
});
test('renderTableRow preserves default row-header text color without formatting', () => {
tableRenderer = new TableRenderer({
...mockProps,
tableOptions: {
cellColorFormatters: { metric: [] },
cellBackgroundColor: '#ffffff',
cellTextColor: '#ff00aa',
},
});
const row = tableRenderer.renderTableRow(
['revenue'],
0,
createTableRowSettings(),
) as ReactElement;
const cells = Children.toArray(row.props.children);
const headerCell = cells.find(
child => isValidElement(child) && child.props.className === 'pvtRowLabel',
) as ReactElement;
expect(headerCell.props.style).toEqual({
backgroundColor: undefined,
color: undefined,
});
});
test('renderTableRow applies readable text color to formatted row headers', () => {
tableRenderer = new TableRenderer({
...mockProps,
tableOptions: {
cellColorFormatters: {
metric: [
{
column: 'metric',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: (value: unknown) =>
value === 'revenue' ? '#111111' : undefined,
},
],
},
cellBackgroundColor: '#ffffff',
cellTextColor: '#000000',
},
});
const row = tableRenderer.renderTableRow(
['revenue'],
0,
createTableRowSettings(),
) as ReactElement;
const cells = Children.toArray(row.props.children);
const headerCell = cells.find(
child => isValidElement(child) && child.props.className === 'pvtRowLabel',
) as ReactElement;
expect(headerCell.props.style).toEqual({
backgroundColor: '#111111',
color: 'rgb(255, 255, 255)',
});
});
test('renderTableRow uses active header surface for adaptive contrast', () => {
const activeHeaderBackgroundColor = '#102a43';
tableRenderer = new TableRenderer({
...mockProps,
tableOptions: createActiveHeaderTableOptions(activeHeaderBackgroundColor, {
highlightedHeaderCells: {
metric: ['revenue'],
},
cellColorFormatters: {
metric: [
{
column: 'metric',
objectFormatting: ObjectFormattingEnum.BACKGROUND_COLOR,
getColorFromValue: (value: unknown) =>
value === 'revenue' ? 'rgba(0, 0, 0, 0.4)' : undefined,
},
],
},
cellBackgroundColor: '#ffffff',
cellTextColor: '#000000',
}),
});
const row = tableRenderer.renderTableRow(
['revenue'],
0,
createTableRowSettings(),
) as ReactElement;
const cells = Children.toArray(row.props.children);
const headerCell = cells.find(
child =>
isValidElement(child) && child.props.className === 'pvtRowLabel active',
) as ReactElement;
expect(headerCell.props.style).toEqual({
backgroundColor: 'rgba(0, 0, 0, 0.4)',
color: getTextColorForBackground(
{ backgroundColor: 'rgba(0, 0, 0, 0.4)' },
activeHeaderBackgroundColor,
),
});
});

View File

@@ -75,6 +75,7 @@ import {
import { isEmpty, debounce, isEqual } from 'lodash';
import {
ColorFormatters,
getTextColorForBackground,
ObjectFormattingEnum,
ColorSchemeEnum,
} from '@superset-ui/chart-controls';
@@ -944,9 +945,11 @@ export default function TableChart<D extends DataRecord = DataRecord>(
if (!formatterResult) return;
if (
formatter.objectFormatting === ObjectFormattingEnum.TEXT_COLOR
formatter.objectFormatting ===
ObjectFormattingEnum.TEXT_COLOR ||
formatter.toTextColor
) {
color = formatterResult.slice(0, -2);
color = formatterResult;
} else if (
formatter.objectFormatting === ObjectFormattingEnum.CELL_BAR
) {
@@ -997,8 +1000,13 @@ export default function TableChart<D extends DataRecord = DataRecord>(
? basicColorColumnFormatters[row.index][column.key]?.mainArrow
: '';
}
const rowSurfaceColor =
row.index % 2 === 0 ? theme.colorBgLayout : theme.colorBgBase;
const resolvedTextColor = getTextColorForBackground(
{ backgroundColor, color },
rowSurfaceColor,
);
const StyledCell = styled.td`
color: ${color ? `${color}FF` : theme.colorText};
text-align: ${sharedStyle.textAlign};
white-space: ${value instanceof Date ? 'nowrap' : undefined};
position: relative;
@@ -1097,6 +1105,9 @@ export default function TableChart<D extends DataRecord = DataRecord>(
: '',
isActiveFilterValue(key, value) ? ' dt-is-active-filter' : '',
].join(' '),
style: resolvedTextColor
? ({ color: resolvedTextColor } as CSSProperties)
: undefined,
tabIndex: 0,
};
if (html) {

View File

@@ -17,7 +17,11 @@
* under the License.
*/
import '@testing-library/jest-dom';
import { ObjectFormattingEnum } from '@superset-ui/chart-controls';
import {
getTextColorForBackground,
ObjectFormattingEnum,
} from '@superset-ui/chart-controls';
import { supersetTheme } from '@apache-superset/core/theme';
import {
render,
screen,
@@ -670,6 +674,52 @@ describe('plugin-chart-table', () => {
);
expect(getComputedStyle(screen.getByText('N/A')).background).toBe('');
});
test('preserves muted null styling when no formatter resolves text color', () => {
const dataWithEmptyCell = cloneDeep(testData.advanced.queriesData[0]);
dataWithEmptyCell.data.push({
__timestamp: null,
name: 'Noah',
sum__num: null,
'%pct_nice': 0.643,
'abc.com': 'bazzinga',
});
render(
ProviderWrapper({
children: (
<TableChart
{...transformProps({
...testData.advanced,
queriesData: [dataWithEmptyCell],
rawFormData: {
...testData.advanced.rawFormData,
conditional_formatting: [
{
colorScheme: '#ACE1C4',
column: 'sum__num',
operator: '<',
targetValue: 12342,
},
],
},
})}
/>
),
}),
);
const noahRow = screen.getByText('Noah').closest('tr');
expect(noahRow).not.toBeNull();
const nullCell = noahRow?.querySelector('td.dt-is-null');
expect(nullCell).not.toBeNull();
expect((nullCell as HTMLElement).style.color).toBe('');
expect(getComputedStyle(nullCell as Element).color).toBe(
'rgba(0, 0, 0, 0.45)',
);
});
test('should display original label in grouped headers', () => {
const props = transformProps(testData.comparison);
@@ -1351,11 +1401,9 @@ describe('plugin-chart-table', () => {
);
expect(getComputedStyle(screen.getByTitle('2467063')).color).toBe(
'rgba(172, 225, 196, 1)',
);
expect(getComputedStyle(screen.getByTitle('2467')).color).toBe(
'rgba(0, 0, 0, 0.88)',
'rgb(172, 225, 196)',
);
expect((screen.getByTitle('2467') as HTMLElement).style.color).toBe('');
});
test('display text color using column color formatter for entire row', () => {
@@ -1385,13 +1433,182 @@ describe('plugin-chart-table', () => {
);
expect(getComputedStyle(screen.getByText('Michael')).color).toBe(
'rgba(172, 225, 196, 1)',
'rgb(172, 225, 196)',
);
expect(getComputedStyle(screen.getByTitle('2467063')).color).toBe(
'rgba(172, 225, 196, 1)',
'rgb(172, 225, 196)',
);
expect(getComputedStyle(screen.getByTitle('0.123456')).color).toBe(
'rgba(172, 225, 196, 1)',
'rgb(172, 225, 196)',
);
});
test('derive readable text color from dark background formatting', () => {
render(
ProviderWrapper({
children: (
<TableChart
{...transformProps({
...testData.advanced,
rawFormData: {
...testData.advanced.rawFormData,
conditional_formatting: [
{
colorScheme: '#111111',
column: 'sum__num',
operator: '>',
targetValue: 2467,
useGradient: false,
},
],
},
})}
/>
),
}),
);
expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe(
'rgb(17, 17, 17)',
);
expect(getComputedStyle(screen.getByTitle('2467063')).color).toBe(
'rgb(255, 255, 255)',
);
});
test('keep explicit text color over adaptive contrast', () => {
render(
ProviderWrapper({
children: (
<TableChart
{...transformProps({
...testData.advanced,
rawFormData: {
...testData.advanced.rawFormData,
conditional_formatting: [
{
colorScheme: '#111111',
column: 'sum__num',
operator: '>',
targetValue: 2467,
useGradient: false,
},
{
colorScheme: '#ACE1C4',
column: 'sum__num',
operator: '>',
targetValue: 2467,
objectFormatting: ObjectFormattingEnum.TEXT_COLOR,
},
],
},
})}
/>
),
}),
);
expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe(
'rgb(17, 17, 17)',
);
expect(getComputedStyle(screen.getByTitle('2467063')).color).toBe(
'rgb(172, 225, 196)',
);
});
test('support legacy toTextColor formatters', () => {
render(
ProviderWrapper({
children: (
<TableChart
{...transformProps({
...testData.advanced,
rawFormData: {
...testData.advanced.rawFormData,
conditional_formatting: [
{
colorScheme: '#111111',
column: 'sum__num',
operator: '>',
targetValue: 2467,
useGradient: false,
},
{
colorScheme: '#ACE1C4',
column: 'sum__num',
operator: '>',
targetValue: 2467,
toTextColor: true,
},
],
},
})}
/>
),
}),
);
expect(getComputedStyle(screen.getByTitle('2467063')).background).toBe(
'rgb(17, 17, 17)',
);
expect(getComputedStyle(screen.getByTitle('2467063')).color).toBe(
'rgb(172, 225, 196)',
);
});
test('use striped row surface when deriving adaptive text color', () => {
const backgroundColor = Array.from(
{ length: 0xff },
(_, index) => `#000000${(index + 1).toString(16).padStart(2, '0')}`,
).find(candidate => {
const baseColor = getTextColorForBackground(
{ backgroundColor: candidate },
supersetTheme.colorBgBase,
);
const layoutColor = getTextColorForBackground(
{ backgroundColor: candidate },
supersetTheme.colorBgLayout,
);
return baseColor !== layoutColor;
});
expect(backgroundColor).toBeDefined();
render(
ProviderWrapper({
children: (
<TableChart
{...transformProps({
...testData.advanced,
rawFormData: {
...testData.advanced.rawFormData,
conditional_formatting: [
{
colorScheme: backgroundColor,
column: 'sum__num',
operator: '>',
targetValue: 2000,
useGradient: false,
},
],
},
})}
/>
),
}),
);
expect(getComputedStyle(screen.getByTitle('2467063')).color).toBe(
getTextColorForBackground(
{ backgroundColor },
supersetTheme.colorBgLayout,
),
);
expect(getComputedStyle(screen.getByTitle('2467')).color).toBe(
getTextColorForBackground(
{ backgroundColor },
supersetTheme.colorBgBase,
),
);
});