Table Panel: Add ability to set background colors for entire rows (#83939)

* Table: hacky poc for colored rows

* update

* Update options editor

* Refactor out styling

* Make sure option applies

* Start unifying coloring logic

* Support various options

* Cleanups

* Fix up hover behavior

* Update label

* Fix bug with sorting and make cleanups

* Ensure text color also applies to whole row

* New treatment for cell inspect

* Update cell inspect spacing

* codeincarnate/table-row-background-selected-riffs/ run linter

* Fix hover colors

* Fix coloring

* The other part of color application

* Another color fix

* codeincarnate/table-row-background-selected-riffs/ lint

* Update cell value inspect

* Prettier

* Update cell value inspect coloring

* Fix text coloring

* Update inspect style

* Linting and betterer

* Fix tests

* i18n

---------

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
Co-authored-by: jev forsberg <jev.forsberg@grafana.com>
This commit is contained in:
Kyle Cunningham
2024-03-19 23:23:12 +07:00
committed by GitHub
co-authored by Torkel Ödegaard jev forsberg
parent 5b1b280798
commit 2a6a1fb3b3
12 changed files with 145 additions and 50 deletions
@@ -792,6 +792,7 @@ export interface TableSparklineCellOptions extends GraphFieldConfig {
* Colored background cell options
*/
export interface TableColoredBackgroundCellOptions {
applyToRow?: boolean;
mode?: TableCellBackgroundDisplayMode;
type: TableCellDisplayMode.ColorBackground;
}
@@ -71,6 +71,7 @@ TableSparklineCellOptions: {
TableColoredBackgroundCellOptions: {
type: TableCellDisplayMode & "color-background"
mode?: TableCellBackgroundDisplayMode
applyToRow?: bool
} @cuetsy(kind="interface")
// Height of a table cell
@@ -48,7 +48,7 @@ export function CellActions({ field, cell, previewMode, showFilters, onCellFilte
return (
<>
<div className={`cellActions ${isRightAligned ? 'cellActionsLeft' : ''}`}>
<div className={`cellActions${isRightAligned ? ' cellActionsLeft' : ''}`}>
<HorizontalGroup spacing="xs">
{inspectEnabled && (
<IconButton
@@ -1,22 +1,21 @@
import { cx } from '@emotion/css';
import React, { ReactElement, useState } from 'react';
import tinycolor from 'tinycolor2';
import { DisplayValue, formattedValueToString } from '@grafana/data';
import { TableCellBackgroundDisplayMode, TableCellDisplayMode } from '@grafana/schema';
import { TableCellDisplayMode } from '@grafana/schema';
import { useStyles2 } from '../../themes';
import { getCellLinks, getTextColorForAlphaBackground } from '../../utils';
import { getCellLinks } from '../../utils';
import { clearLinkButtonStyles } from '../Button';
import { DataLinksContextMenu } from '../DataLinks/DataLinksContextMenu';
import { CellActions } from './CellActions';
import { TableStyles } from './styles';
import { TableCellProps, CustomCellRendererProps, TableCellOptions } from './types';
import { getCellOptions } from './utils';
import { getCellColors, getCellOptions } from './utils';
export const DefaultCell = (props: TableCellProps) => {
const { field, cell, tableStyles, row, cellProps, frame } = props;
const { field, cell, tableStyles, row, cellProps, frame, rowStyled } = props;
const inspectEnabled = Boolean(field.config.custom?.inspect);
const displayValue = field.display!(cell.value);
@@ -53,7 +52,15 @@ export const DefaultCell = (props: TableCellProps) => {
// Text should wrap when the content length is less than or equal to the length of an OG tweet and it contains whitespace
const textShouldWrap = displayValue.text.length <= OG_TWEET_LENGTH && /\s/.test(displayValue.text);
const cellStyle = getCellStyle(tableStyles, cellOptions, displayValue, inspectEnabled, isStringValue, textShouldWrap);
const cellStyle = getCellStyle(
tableStyles,
cellOptions,
displayValue,
inspectEnabled,
isStringValue,
textShouldWrap,
rowStyled
);
if (isStringValue) {
let justifyContent = cellProps.style?.justifyContent;
@@ -104,31 +111,17 @@ function getCellStyle(
displayValue: DisplayValue,
disableOverflowOnHover = false,
isStringValue = false,
shouldWrapText = false
shouldWrapText = false,
rowStyled = false
) {
// How much to darken elements depends upon if we're in dark mode
const darkeningFactor = tableStyles.theme.isDark ? 1 : -0.7;
// Setup color variables
let textColor: string | undefined = undefined;
let bgColor: string | undefined = undefined;
if (cellOptions.type === TableCellDisplayMode.ColorText) {
textColor = displayValue.color;
} else if (cellOptions.type === TableCellDisplayMode.ColorBackground) {
const mode = cellOptions.mode ?? TableCellBackgroundDisplayMode.Gradient;
if (mode === TableCellBackgroundDisplayMode.Basic) {
textColor = getTextColorForAlphaBackground(displayValue.color!, tableStyles.theme.isDark);
bgColor = tinycolor(displayValue.color).toRgbString();
} else if (mode === TableCellBackgroundDisplayMode.Gradient) {
const bgColor2 = tinycolor(displayValue.color)
.darken(10 * darkeningFactor)
.spin(5);
textColor = getTextColorForAlphaBackground(displayValue.color!, tableStyles.theme.isDark);
bgColor = `linear-gradient(120deg, ${bgColor2.toRgbString()}, ${displayValue.color})`;
}
}
// Get colors
const colors = getCellColors(tableStyles, cellOptions, displayValue);
textColor = colors.textColor;
bgColor = colors.bgColor;
// If we have definied colors return those styles
// Otherwise we return default styles
@@ -137,7 +130,8 @@ function getCellStyle(
bgColor,
!disableOverflowOnHover,
isStringValue,
shouldWrapText
shouldWrapText,
rowStyled
);
}
@@ -13,7 +13,7 @@ import {
TimeRange,
hasTimeField,
} from '@grafana/data';
import { TableCellHeight } from '@grafana/schema';
import { TableCellDisplayMode, TableCellHeight } from '@grafana/schema';
import { useTheme2 } from '../../themes';
import CustomScrollbar from '../CustomScrollbar/CustomScrollbar';
@@ -22,8 +22,8 @@ import { usePanelContext } from '../PanelChrome';
import { ExpandedRow, getExpandedRowHeight } from './ExpandedRow';
import { TableCell } from './TableCell';
import { TableStyles } from './styles';
import { TableFilterActionCallback } from './types';
import { calculateAroundPointThreshold, isPointTimeValAroundTableTimeVal } from './utils';
import { CellColors, TableFieldOptions, TableFilterActionCallback } from './types';
import { calculateAroundPointThreshold, getCellColors, isPointTimeValAroundTableTimeVal } from './utils';
interface RowsListProps {
data: DataFrame;
@@ -201,12 +201,30 @@ export const RowsList = (props: RowsListProps) => {
[tableState.pageIndex, tableState.pageSize]
);
let rowBg: Function | undefined = undefined;
for (const field of data.fields) {
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
const fieldOptions = field.config.custom as TableFieldOptions;
if (
fieldOptions !== undefined &&
fieldOptions.cellOptions !== undefined &&
fieldOptions.cellOptions.type === TableCellDisplayMode.ColorBackground &&
fieldOptions.cellOptions.applyToRow
) {
rowBg = (rowIndex: number): CellColors => {
const display = field.display!(field.values.get(rowIndex));
const colors = getCellColors(tableStyles, fieldOptions.cellOptions, display);
return colors;
};
}
}
const RenderRow = useCallback(
({ index, style, rowHighlightIndex }: { index: number; style: CSSProperties; rowHighlightIndex?: number }) => {
const indexForPagination = rowIndexForPagination(index);
const row = rows[indexForPagination];
let additionalProps: React.HTMLAttributes<HTMLDivElement> = {};
prepareRow(row);
const expandedRowStyle = tableState.expanded[row.id] ? css({ '&:hover': { background: 'inherit' } }) : {};
@@ -217,6 +235,13 @@ export const RowsList = (props: RowsListProps) => {
'aria-selected': 'true',
};
}
if (rowBg) {
const { bgColor, textColor } = rowBg(row.index);
style.background = bgColor;
style.color = textColor;
}
return (
<div
{...row.getRowProps({ style, ...additionalProps })}
@@ -244,6 +269,7 @@ export const RowsList = (props: RowsListProps) => {
columnCount={row.cells.length}
timeRange={timeRange}
frame={data}
rowStyled={rowBg !== undefined}
/>
))}
</div>
@@ -264,6 +290,7 @@ export const RowsList = (props: RowsListProps) => {
theme.components.table.rowHoverBackground,
timeRange,
width,
rowBg,
]
);
@@ -15,9 +15,10 @@ export interface Props {
timeRange?: TimeRange;
userProps?: object;
frame: DataFrame;
rowStyled?: boolean;
}
export const TableCell = ({ cell, tableStyles, onCellFilterAdded, timeRange, userProps, frame }: Props) => {
export const TableCell = ({ cell, tableStyles, onCellFilterAdded, timeRange, userProps, frame, rowStyled }: Props) => {
const cellProps = cell.getCellProps();
const field = (cell.column as unknown as GrafanaTableColumn).field;
@@ -43,6 +44,7 @@ export const TableCell = ({ cell, tableStyles, onCellFilterAdded, timeRange, use
timeRange,
userProps,
frame,
rowStyled,
})}
</>
);
@@ -16,7 +16,8 @@ export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCell
background?: string,
overflowOnHover?: boolean,
asCellText?: boolean,
textShouldWrap?: boolean
textShouldWrap?: boolean,
rowStyled?: boolean
) => {
return css({
label: overflowOnHover ? 'cellContainerOverflow' : 'cellContainerNoOverflow',
@@ -39,8 +40,8 @@ export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCell
alignItems: 'center',
borderRight: `1px solid ${borderColor}`,
color: color ?? undefined,
background: background ?? undefined,
color: rowStyled ? 'inherit' : color ?? undefined,
background: rowStyled ? undefined : background ?? undefined,
backgroundClip: 'padding-box',
'&:last-child:not(:only-child)': {
@@ -55,12 +56,14 @@ export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCell
wordBreak: textShouldWrap ? 'break-word' : undefined,
whiteSpace: textShouldWrap && overflowOnHover ? 'normal' : 'nowrap',
boxShadow: overflowOnHover ? `0 0 2px ${theme.colors.primary.main}` : undefined,
background: overflowOnHover ? background ?? theme.components.table.rowHoverBackground : undefined,
background: rowStyled ? 'inherit' : background ?? undefined,
zIndex: 1,
'.cellActions': {
color: '#FFF',
visibility: 'visible',
opacity: 1,
width: 'auto',
background: 'rgba(0, 0, 0, 0.6)',
},
},
@@ -71,7 +74,7 @@ export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCell
'.cellActions': {
display: 'flex',
position: overflowOnHover ? undefined : 'absolute',
top: overflowOnHover ? undefined : 0,
top: overflowOnHover ? undefined : '1px',
right: overflowOnHover ? undefined : 0,
margin: overflowOnHover ? theme.spacing(0, -0.5, 0, 0.5) : 'auto',
visibility: 'hidden',
@@ -79,8 +82,8 @@ export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCell
width: 0,
alignItems: 'center',
height: '100%',
padding: theme.spacing(1, 0.5, 1, 0.5),
background: background ? 'none' : theme.colors.emphasize(theme.colors.background.primary, 0.03),
padding: theme.spacing(1, 0.5, 1, 1),
background: background ? 'none' : 'rgba(0, 0, 0, 0.5)',
svg: {
color,
@@ -140,3 +140,10 @@ export type TableFieldOptions = Omit<schema.TableFieldOptions, 'cellOptions'> &
cellOptions: TableCellOptions;
headerComponent?: React.ComponentType<CustomHeaderRendererProps>;
};
// Cell background and text colors
// Can also be used for table rows
export interface CellColors {
textColor?: string;
bgColor?: string;
}
@@ -2,6 +2,7 @@ import { Property } from 'csstype';
import { clone } from 'lodash';
import memoize from 'micro-memoize';
import { Row } from 'react-table';
import tinycolor from 'tinycolor2';
import {
DataFrame,
@@ -27,6 +28,8 @@ import {
TableCellDisplayMode,
} from '@grafana/schema';
import { getTextColorForAlphaBackground } from '../../utils';
import { BarGaugeCell } from './BarGaugeCell';
import { DataLinksCell } from './DataLinksCell';
import { DefaultCell } from './DefaultCell';
@@ -36,6 +39,7 @@ import { ImageCell } from './ImageCell';
import { JSONViewCell } from './JSONViewCell';
import { RowExpander } from './RowExpander';
import { SparklineCell } from './SparklineCell';
import { TableStyles } from './styles';
import {
CellComponent,
TableCellOptions,
@@ -43,6 +47,7 @@ import {
FooterItem,
GrafanaTableColumn,
TableFooterCalc,
CellColors,
} from './types';
export const EXPANDER_WIDTH = 50;
@@ -581,3 +586,46 @@ export function calculateAroundPointThreshold(timeField: Field): number {
return (max - min) / timeField.values.length;
}
/**
* Retrieve colors for a table cell (or table row).
*
* @param tableStyles
* Styles for the table
* @param cellOptions
* Table cell configuration options
* @param displayValue
* The value that will be displayed
* @returns CellColors
*/
export function getCellColors(
tableStyles: TableStyles,
cellOptions: TableCellOptions,
displayValue: DisplayValue
): CellColors {
// How much to darken elements depends upon if we're in dark mode
const darkeningFactor = tableStyles.theme.isDark ? 1 : -0.7;
// Setup color variables
let textColor: string | undefined = undefined;
let bgColor: string | undefined = undefined;
if (cellOptions.type === TableCellDisplayMode.ColorText) {
textColor = displayValue.color;
} else if (cellOptions.type === TableCellDisplayMode.ColorBackground) {
const mode = cellOptions.mode ?? TableCellBackgroundDisplayMode.Gradient;
if (mode === TableCellBackgroundDisplayMode.Basic) {
textColor = getTextColorForAlphaBackground(displayValue.color!, tableStyles.theme.isDark);
bgColor = tinycolor(displayValue.color).setAlpha(1).toRgbString();
} else if (mode === TableCellBackgroundDisplayMode.Gradient) {
const bgColor2 = tinycolor(displayValue.color)
.darken(10 * darkeningFactor)
.spin(5);
textColor = getTextColorForAlphaBackground(displayValue.color!, tableStyles.theme.isDark);
bgColor = `linear-gradient(120deg, ${bgColor2.toRgbString()}, ${displayValue.color})`;
}
}
return { textColor, bgColor };
}
@@ -2,7 +2,7 @@ import React from 'react';
import { SelectableValue } from '@grafana/data';
import { TableCellBackgroundDisplayMode, TableColoredBackgroundCellOptions } from '@grafana/schema';
import { Field, RadioButtonGroup } from '@grafana/ui';
import { Field, RadioButtonGroup, Switch } from '@grafana/ui';
import { TableCellEditorProps } from '../TableCellOptionEditor';
@@ -21,13 +21,27 @@ export const ColorBackgroundCellOptionsEditor = ({
onChange(cellOptions);
};
// Handle row coloring changes
const onColorRowChange = () => {
cellOptions.applyToRow = !cellOptions.applyToRow;
onChange(cellOptions);
};
return (
<Field label="Background display mode">
<RadioButtonGroup
value={cellOptions?.mode ?? TableCellBackgroundDisplayMode.Gradient}
onChange={onCellOptionsChange}
options={colorBackgroundOpts}
/>
</Field>
<>
<Field label="Background display mode">
<RadioButtonGroup
value={cellOptions?.mode ?? TableCellBackgroundDisplayMode.Gradient}
onChange={onCellOptionsChange}
options={colorBackgroundOpts}
/>
</Field>
<Field
label="Apply to entire row"
description="If selected the entire row will be colored as this cell would be."
>
<Switch value={cellOptions.applyToRow} onChange={onColorRowChange} />
</Field>
</>
);
};
-1
View File
@@ -369,7 +369,6 @@
"title-label": "Title"
},
"json-editor": {
"apply-button": "Apply changes",
"save-button": "Save changes",
"subtitle": "The JSON model below is the data structure that defines the dashboard. This includes dashboard settings, panel settings, layout, queries, and so on.",
"title": "JSON Model"
@@ -369,7 +369,6 @@
"title-label": "Ŧįŧľę"
},
"json-editor": {
"apply-button": "Åppľy čĥäʼnģęş",
"save-button": "Ŝävę čĥäʼnģęş",
"subtitle": "Ŧĥę ĴŜØŃ mőđęľ þęľőŵ įş ŧĥę đäŧä şŧřūčŧūřę ŧĥäŧ đęƒįʼnęş ŧĥę đäşĥþőäřđ. Ŧĥįş įʼnčľūđęş đäşĥþőäřđ şęŧŧįʼnģş, päʼnęľ şęŧŧįʼnģş, ľäyőūŧ, qūęřįęş, äʼnđ şő őʼn.",
"title": "ĴŜØŃ Mőđęľ"