TableNG: Avoid render-time emotion calls for AutoCell (and colored cells) (#108048)

Co-authored-by: Paul Marbach <paul.marbach@grafana.com>
This commit is contained in:
Leon Sorokin
2025-07-15 14:42:06 -05:00
committed by GitHub
co-authored by Paul Marbach
parent cd1f086d33
commit e42c45c30f
9 changed files with 118 additions and 159 deletions
+1
View File
@@ -84,6 +84,7 @@
"@types/react-table": "7.7.20",
"calculate-size": "1.1.1",
"classnames": "2.5.1",
"clsx": "^2.1.1",
"d3": "7.9.0",
"date-fns": "4.1.0",
"downshift": "^9.0.6",
@@ -1,65 +1,13 @@
import { css } from '@emotion/css';
import { Property } from 'csstype';
import { formattedValueToString } from '@grafana/data';
import { GrafanaTheme2, formattedValueToString } from '@grafana/data';
import { useStyles2 } from '../../../../themes/ThemeContext';
import { renderSingleLink } from '../../DataLinksActionsTooltip';
import { TableCellOptions, TableCellDisplayMode } from '../../types';
import { useSingleLink } from '../hooks';
import { AutoCellProps } from '../types';
export default function AutoCell({ value, field, justifyContent, rowIdx, cellOptions }: AutoCellProps) {
const styles = useStyles2(getStyles, justifyContent);
export default function AutoCell({ value, field, rowIdx }: AutoCellProps) {
const displayValue = field.display!(value);
const formattedValue = formattedValueToString(displayValue);
const link = useSingleLink(field, rowIdx);
return (
<div className={styles.cell}>
{link == null ? formattedValue : renderSingleLink(link, formattedValue, getLinkStyle(styles, cellOptions))}
</div>
);
return link != null ? renderSingleLink(link, formattedValue) : formattedValue;
}
const getLinkStyle = (styles: ReturnType<typeof getStyles>, cellOptions: TableCellOptions) => {
if (cellOptions.type === TableCellDisplayMode.Auto) {
return styles.linkCell;
}
return styles.cellLinkForColoredCell;
};
const getStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent | undefined) => ({
cell: css({
display: 'flex',
justifyContent: justifyContent,
a: {
color: 'inherit',
},
}),
cellLinkForColoredCell: css({
cursor: 'pointer',
overflow: 'hidden',
textOverflow: 'ellipsis',
userSelect: 'text',
whiteSpace: 'nowrap',
fontWeight: theme.typography.fontWeightMedium,
textDecoration: 'underline',
}),
linkCell: css({
cursor: 'pointer',
overflow: 'hidden',
textOverflow: 'ellipsis',
userSelect: 'text',
whiteSpace: 'nowrap',
color: `${theme.colors.text.link} !important`,
fontWeight: theme.typography.fontWeightMedium,
paddingRight: theme.spacing(1.5),
'&:hover': {
textDecoration: 'underline',
color: theme.colors.text.link,
},
}),
});
@@ -16,7 +16,7 @@ export const DataLinksCell = ({ field, rowIdx }: DataLinksCellProps) => {
{links &&
links.map((link, idx) => {
return !link.href && link.onClick == null ? (
<span key={idx} className={styles.cellLinkEmpty}>
<span key={idx} className={styles.linkCell}>
{link.title}
</span>
) : (
@@ -34,25 +34,6 @@ export const DataLinksCell = ({ field, rowIdx }: DataLinksCellProps) => {
const getStyles = (theme: GrafanaTheme2) => ({
linkCell: css({
cursor: 'pointer',
overflow: 'hidden',
textOverflow: 'ellipsis',
userSelect: 'text',
whiteSpace: 'nowrap',
color: theme.colors.text.link,
fontWeight: theme.typography.fontWeightMedium,
paddingRight: theme.spacing(1.5),
a: {
color: theme.colors.text.link,
},
'&:hover': {
textDecoration: 'underline',
color: theme.colors.text.link,
},
}),
cellLinkEmpty: css({
overflow: 'hidden',
textOverflow: 'ellipsis',
userSelect: 'text',
whiteSpace: 'nowrap',
fontWeight: theme.typography.fontWeightMedium,
@@ -29,13 +29,7 @@ const GAUGE_RENDERER: TableCellRenderer = (props) => (
);
const AUTO_RENDERER: TableCellRenderer = (props) => (
<AutoCell
value={props.value}
field={props.field}
justifyContent={props.justifyContent}
rowIdx={props.rowIdx}
cellOptions={props.cellOptions}
/>
<AutoCell value={props.value} field={props.field} rowIdx={props.rowIdx} />
);
const SPARKLINE_RENDERER: TableCellRenderer = (props) => (
@@ -1,7 +1,8 @@
import 'react-data-grid/lib/styles.css';
import { css, cx } from '@emotion/css';
import { css } from '@emotion/css';
import { clsx } from 'clsx';
import { Property } from 'csstype';
import { Key, ReactNode, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { CSSProperties, Key, ReactNode, useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
import {
Cell,
CellRendererProps,
@@ -34,7 +35,7 @@ import { Pagination } from '../../Pagination/Pagination';
import { PanelContext, usePanelContext } from '../../PanelChrome';
import { DataLinksActionsTooltip } from '../DataLinksActionsTooltip';
import { TableCellInspector, TableCellInspectorMode } from '../TableCellInspector';
import { CellColors, TableCellDisplayMode } from '../types';
import { TableCellDisplayMode } from '../types';
import { DataLinksActionsTooltipState } from '../utils';
import { HeaderCell } from './Cells/HeaderCell';
@@ -279,6 +280,11 @@ export function TableNG(props: TableNGProps) {
let lastRowIdx = -1;
let _rowHeight = 0;
// shared when whole row will be styled by a single cell's color
let rowCellStyle: Partial<CSSProperties> = {
color: undefined,
background: undefined,
};
f.forEach((field, i) => {
const cellOptions = getCellOptions(field);
@@ -304,7 +310,7 @@ export function TableNG(props: TableNGProps) {
const justifyContent = getTextAlign(field);
const footerStyles = getFooterStyles(justifyContent);
const displayName = getDisplayName(field);
const headerCellClass = getHeaderCellStyles(theme, justifyContent).headerCell;
const headerCellClass = getHeaderCellStyles(theme, justifyContent);
const renderFieldCell = getCellRenderer(field, cellOptions);
const cellInspect = isCellInspectEnabled(field);
@@ -314,7 +320,7 @@ export function TableNG(props: TableNGProps) {
// helps us avoid string cx and emotion per-cell
const cellActionClassName = showActions
? cx(
? clsx(
'table-cell-actions',
styles.cellActions,
justifyContent === 'flex-end' ? styles.cellActionsEnd : styles.cellActionsStart
@@ -324,41 +330,63 @@ export function TableNG(props: TableNGProps) {
const shouldOverflow = shouldTextOverflow(field);
const shouldWrap = shouldTextWrap(field);
const withTooltip = withDataLinksActionsTooltip(field, cellType);
const canBeColorized =
cellType === TableCellDisplayMode.ColorBackground || cellType === TableCellDisplayMode.ColorText;
result.colsWithTooltip[displayName] = withTooltip;
// get static cell class based on col props
let cellClass = '';
switch (cellType) {
case TableCellDisplayMode.Auto:
case TableCellDisplayMode.ColorBackground:
case TableCellDisplayMode.ColorText:
case TableCellDisplayMode.DataLinks:
cellClass = getCellStyles(theme, justifyContent, shouldWrap, shouldOverflow, withTooltip, canBeColorized);
break;
}
// TODO: in future extend this to ensure a non-classic color scheme is set with AutoCell
// this fires first
const renderCellRoot = (key: Key, props: CellRendererProps<TableRow, TableSummaryRow>): ReactNode => {
const rowIdx = props.row.__index;
const value = props.row[props.column.key];
// meh, this should be cached by the renderRow() call?
if (rowIdx !== lastRowIdx) {
_rowHeight = typeof rowHeight === 'function' ? rowHeight(props.row) : rowHeight;
lastRowIdx = rowIdx;
rowCellStyle.color = undefined;
rowCellStyle.background = undefined;
// generate shared styles for whole row
if (applyToRowBgFn != null) {
let { textColor, bgColor } = applyToRowBgFn(rowIdx);
rowCellStyle.color = textColor;
rowCellStyle.background = bgColor;
}
}
let colors: CellColors;
let style: CSSProperties | undefined;
if (applyToRowBgFn != null) {
colors = applyToRowBgFn(props.rowIdx);
} else if (cellType !== TableCellDisplayMode.Auto) {
if (rowCellStyle.color != null || rowCellStyle.background != null) {
style = rowCellStyle;
}
// apply background for cell types which can have a background and have proper
else if (canBeColorized) {
const value = props.row[props.column.key];
const displayValue = field.display!(value); // this fires here to get colors, then again to get rendered value?
colors = getCellColors(theme, cellOptions, displayValue);
} else {
colors = {};
let { textColor, bgColor } = getCellColors(theme, cellOptions, displayValue);
style = {
color: textColor,
background: bgColor,
};
}
const cellStyle = getCellStyles(theme, field, _rowHeight, shouldWrap, shouldOverflow, withTooltip, colors);
return (
<Cell
key={key}
{...props}
className={cx(props.className, cellStyle.cell)}
style={{ color: colors.textColor ?? 'inherit' }}
/>
);
return <Cell key={key} {...props} className={clsx(props.className, cellClass)} style={style} />; // TODO: remove expensive concat
};
result.cellRootRenderers[displayName] = renderCellRoot;
@@ -518,8 +546,8 @@ export function TableNG(props: TableNGProps) {
return (
<DataGrid<TableRow, TableSummaryRow>
{...commonDataGridProps}
className={cx(styles.grid, styles.gridNested)}
headerRowClass={cx(styles.headerRow, { [styles.displayNone]: !hasNestedHeaders })}
className={clsx(styles.grid, styles.gridNested)}
headerRowClass={clsx(styles.headerRow, { [styles.displayNone]: !hasNestedHeaders })}
headerRowHeight={hasNestedHeaders ? defaultHeaderHeight : 0}
columns={nestedColumns}
rows={expandedRecords}
@@ -584,7 +612,7 @@ export function TableNG(props: TableNGProps) {
className={styles.grid}
columns={structureRevColumns}
rows={paginatedRows}
headerRowClass={cx(styles.headerRow, { [styles.displayNone]: noHeader })}
headerRowClass={clsx(styles.headerRow, { [styles.displayNone]: noHeader })}
headerRowHeight={headerHeight}
onCellClick={({ column, row }, { clientX, clientY, preventGridDefault }) => {
// Note: could be column.field; JS says yes, but TS says no!
@@ -836,8 +864,8 @@ const getFooterStyles = (justifyContent: Property.JustifyContent) => ({
}),
});
const getHeaderCellStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent) => ({
headerCell: css({
const getHeaderCellStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent) =>
css({
display: 'flex',
gap: theme.spacing(0.5),
zIndex: theme.zIndex.tooltip - 1,
@@ -847,45 +875,56 @@ const getHeaderCellStyles = (theme: GrafanaTheme2, justifyContent: Property.Just
'&:last-child': {
borderInlineEnd: 'none',
},
}),
});
});
const getCellStyles = (
theme: GrafanaTheme2,
field: Field,
rowHeight: number,
justifyContent: Property.JustifyContent,
shouldWrap: boolean,
shouldOverflow: boolean,
hasTooltip: boolean,
colors: CellColors
) => {
return {
cell: css({
textOverflow: 'initial',
background: colors.bgColor ?? 'inherit',
alignContent: 'center',
justifyContent: getTextAlign(field),
paddingInline: TABLE.CELL_PADDING,
height: '100%',
minHeight: rowHeight, // min height interacts with the fit-content property on the overflow container
...(shouldWrap && { whiteSpace: 'pre-line' }),
...(hasTooltip && { cursor: 'pointer' }),
'&:last-child': {
borderInlineEnd: 'none',
isColorized: boolean
) =>
css({
display: 'flex',
alignItems: 'center',
justifyContent,
paddingInline: TABLE.CELL_PADDING,
minHeight: '100%',
backgroundClip: 'padding-box !important', // helps when cells have a bg color
...(shouldWrap && { whiteSpace: 'pre-line' }),
...(hasTooltip && { cursor: 'pointer' }),
'&:last-child': {
borderInlineEnd: 'none',
},
// should omit if no cell actions, and no shouldOverflow
'&:hover': {
'.table-cell-actions': {
display: 'flex',
},
'&:hover': {
background: colors.bgHoverColor,
'.table-cell-actions': {
display: 'flex',
},
...(shouldOverflow && {
zIndex: theme.zIndex.tooltip - 2,
whiteSpace: 'pre-line',
height: 'fit-content',
minWidth: 'fit-content',
paddingBlock: (rowHeight - TABLE.LINE_HEIGHT) / 2 - 1,
}),
},
}),
};
};
...(shouldOverflow && {
zIndex: theme.zIndex.tooltip - 2,
whiteSpace: 'pre-line',
height: 'fit-content',
minWidth: 'fit-content',
}),
},
[hasTooltip ? '&' : 'a']: {
cursor: 'pointer',
...(isColorized
? {
color: 'inherit',
textDecoration: 'underline',
}
: {
color: theme.colors.text.link,
textDecoration: 'none',
'&:hover': {
textDecoration: 'underline',
},
}),
},
});
@@ -246,9 +246,7 @@ export interface CellColors {
export interface AutoCellProps {
field: Field;
value: TableCellValue;
justifyContent: Property.JustifyContent;
rowIdx: number;
cellOptions: TableCellOptions;
}
export interface ActionCellProps {
@@ -188,7 +188,6 @@ describe('TableNG utils', () => {
const colors = getCellColors(theme, field, displayValue);
expect(colors.bgColor).toBe('rgb(255, 0, 0)');
expect(colors.textColor).toBe('rgb(247, 248, 250)');
expect(colors.bgHoverColor).toBe('rgb(255, 36, 36)');
});
it('should handle color background gradient mode', () => {
@@ -206,7 +205,6 @@ describe('TableNG utils', () => {
const colors = getCellColors(theme, field, displayValue);
expect(colors.bgColor).toBe('linear-gradient(120deg, rgb(255, 54, 36), #ff0000)');
expect(colors.textColor).toBe('rgb(247, 248, 250)');
expect(colors.bgHoverColor).toBe('linear-gradient(120deg, rgb(255, 54, 36), rgb(255, 54, 54))');
});
});
@@ -230,7 +230,6 @@ export function getAlignmentFactor(
/* ------------------------- Cell color calculation ------------------------- */
const CELL_COLOR_DARKENING_MULTIPLIER = 10;
const CELL_GRADIENT_DARKENING_MULTIPLIER = 15;
const CELL_GRADIENT_HUE_ROTATION_DEGREES = 5;
/**
@@ -248,7 +247,7 @@ export function getCellColors(
// Setup color variables
let textColor: string | undefined = undefined;
let bgColor: string | undefined = undefined;
let bgHoverColor: string | undefined = undefined;
// let bgHoverColor: string | undefined = undefined;
if (cellOptions.type === TableCellDisplayMode.ColorText) {
textColor = displayValue.color;
@@ -258,23 +257,23 @@ export function getCellColors(
if (mode === TableCellBackgroundDisplayMode.Basic) {
textColor = getTextColorForAlphaBackground(displayValue.color!, theme.isDark);
bgColor = tinycolor(displayValue.color).toRgbString();
bgHoverColor = tinycolor(displayValue.color)
.darken(CELL_COLOR_DARKENING_MULTIPLIER * darkeningFactor)
.toRgbString();
// bgHoverColor = tinycolor(displayValue.color)
// .darken(CELL_COLOR_DARKENING_MULTIPLIER * darkeningFactor)
// .toRgbString();
} else if (mode === TableCellBackgroundDisplayMode.Gradient) {
const hoverColor = tinycolor(displayValue.color)
.darken(CELL_GRADIENT_DARKENING_MULTIPLIER * darkeningFactor)
.toRgbString();
// const hoverColor = tinycolor(displayValue.color)
// .darken(CELL_GRADIENT_DARKENING_MULTIPLIER * darkeningFactor)
// .toRgbString();
const bgColor2 = tinycolor(displayValue.color)
.darken(CELL_COLOR_DARKENING_MULTIPLIER * darkeningFactor)
.spin(CELL_GRADIENT_HUE_ROTATION_DEGREES);
textColor = getTextColorForAlphaBackground(displayValue.color!, theme.isDark);
bgColor = `linear-gradient(120deg, ${bgColor2.toRgbString()}, ${displayValue.color})`;
bgHoverColor = `linear-gradient(120deg, ${bgColor2.toRgbString()}, ${hoverColor})`;
// bgHoverColor = `linear-gradient(120deg, ${bgColor2.toRgbString()}, ${hoverColor})`;
}
}
return { textColor, bgColor, bgHoverColor };
return { textColor, bgColor };
}
/**
+1
View File
@@ -3747,6 +3747,7 @@ __metadata:
calculate-size: "npm:1.1.1"
chance: "npm:^1.1.13"
classnames: "npm:2.5.1"
clsx: "npm:^2.1.1"
common-tags: "npm:1.8.2"
core-js: "npm:3.40.0"
css-loader: "npm:7.1.2"