Table: Tooltip from Field (#109428)

* Table: Tooltip by Field

* add colorization support

* more progress on customizing the tooltip based on cell customization

* configurable as an option

* tooltip triggered from a chip in the corner, pinning tooltip

* i18n

* use enum where appropriate

* correctly orient the tooltip to the cell - but at what cost

* clean up some console.logs

* e2e test for the tooltip

* fix global click stuff

* remove console.log

* refine the style of the caret

* caret placement on same-side as alignment to avoid collision with inspect hover

* some updates from self-review

* increase hit target of tooltip caret

* fix width and height auto-sizing especially for dynamic height

* reorganize to pre-calc the per-field stuff

* use linear gradient for triangle

* update e2e to reflect current behavior, that clicking caret multiple times doesn't toggle pinning

* clean up event handlers a bit

* restore test for toggle click

* alright, re-remove the toggle case

* cursor pointer

* remove optional root from Popover for now

* remove this ridiculous autogenerated file

* update some of the text

* kill the cellRefMatrix

* remove unused import

* extract a util for the predicateByName part

* skip the intermediary step for getCellColors
This commit is contained in:
Paul Marbach 2025-08-18 10:11:43 -04:00 committed by GitHub
parent 4e5a51968f
commit 66eee1cb08
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 562 additions and 68 deletions

View File

@ -299,6 +299,14 @@
{
"id": "custom.width",
"value": 255
},
{
"id": "custom.tooltip.field",
"value": "State"
},
{
"id": "custom.tooltip.placement",
"value": "left"
}
]
},

View File

@ -353,6 +353,69 @@ test.describe('Panels test: Table - Kitchen Sink', { tag: ['@panels', '@table']
// TODO -- saving for another day.
});
test('Tests tooltip interactions', async ({ gotoDashboardPage, selectors }) => {
const dashboardPage = await gotoDashboardPage({
uid: DASHBOARD_UID,
queryParams: new URLSearchParams({ editPanel: '1' }),
});
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink'))
).toBeVisible();
const firstCaret = dashboardPage
.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Tooltip.Caret)
.first();
// test hovering over and blurring the caret, and whether the tooltip appears and disappears as expected.
await firstCaret.hover();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper)
).toBeVisible();
await dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')).hover();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper)
).not.toBeVisible();
// when a pinned tooltip is open, clicking outside of it should close it.
await firstCaret.click();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper)
).toBeVisible();
await dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')).click();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper)
).not.toBeVisible();
// when a pinned tooltip is open, clicking inside of it should NOT close it.
await firstCaret.click();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper)
).toBeVisible();
const tooltip = dashboardPage.getByGrafanaSelector(
selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper
);
await tooltip.click();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper)
).toBeVisible();
await dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Table - Kitchen Sink')).click();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper)
).not.toBeVisible();
});
test('Empty Table panel', async ({ gotoDashboardPage, selectors }) => {
const dashboardPage = await gotoDashboardPage({
uid: DASHBOARD_UID,

View File

@ -493,6 +493,14 @@ export const versionedComponents = {
'12.1.0': 'data-testid tableng filter select-all',
},
},
Tooltip: {
Wrapper: {
'12.2.0': 'data-testid tableng tooltip wrapper',
},
Caret: {
'12.2.0': 'data-testid tableng tooltip caret',
},
},
},
},
},

View File

@ -976,6 +976,14 @@ export type TableCellOptions = (TableAutoCellOptions | TableSparklineCellOptions
type: TableCellDisplayMode.Geo
});
export enum TableCellTooltipPlacement {
Auto = 'auto',
Bottom = 'bottom',
Left = 'left',
Right = 'right',
Top = 'top',
}
/**
* Field options for each field within a table (e.g 10, "The String", 64.20, etc.)
* Generally defines alignment, filtering capabilties, display options, etc.
@ -995,6 +1003,19 @@ export interface TableFieldOptions {
hideHeader?: boolean;
inspect: boolean;
minWidth?: number;
/**
* Selecting or hovering this field will show a tooltip containing the content within the target field
*/
tooltip?: {
/**
* The name of the field to get the tooltip content from
*/
field: string;
/**
* placement of the tooltip
*/
placement?: TableCellTooltipPlacement;
};
width?: number;
/**
* Enables text wrapping for column headers

View File

@ -107,10 +107,21 @@ TableGeoCellOptions: {
// Height of a table cell
TableCellHeight: "sm" | "md" | "lg" | "auto" @cuetsy(kind="enum")
// Table cell options. Each cell has a display mode
// and other potential options for that display.
TableCellOptions: TableAutoCellOptions | TableSparklineCellOptions | TableBarGaugeCellOptions | TableColoredBackgroundCellOptions | TableColorTextCellOptions | TableImageCellOptions | TablePillCellOptions | TableDataLinksCellOptions | TableActionsCellOptions | TableJsonViewCellOptions | TableMarkdownCellOptions | TableGeoCellOptions @cuetsy(kind="type")
TableCellTooltipPlacement: "top" | "bottom" | "left" | "right" | "auto" @cuetsy(kind="enum")
TableCellTooltipOptions: {
// The name of the field to get the tooltip content from
field: string
// placement of the tooltip
placement?: TableCellTooltipPlacement & (*"auto" | _)
}
// Field options for each field within a table (e.g 10, "The String", 64.20, etc.)
// Generally defines alignment, filtering capabilties, display options, etc.
TableFieldOptions: {
@ -127,4 +138,6 @@ TableFieldOptions: {
hideHeader?: bool
// Enables text wrapping for column headers
wrapHeaderText?: bool
// Selecting or hovering this field will show a tooltip containing the content within the target field
tooltip?: TableCellTooltipOptions
} @cuetsy(kind="interface")

View File

@ -6,12 +6,19 @@ import { MaybeWrapWithLink } from '../MaybeWrapWithLink';
import { MarkdownCellProps, TableCellStyles } from '../types';
export function MarkdownCell({ field, rowIdx, disableSanitizeHtml }: MarkdownCellProps) {
const rawValue = field.values[rowIdx];
if (rawValue == null) {
return null;
}
const renderValue = field.display!(rawValue);
return (
<MaybeWrapWithLink field={field} rowIdx={rowIdx}>
<div
className="markdown-container"
dangerouslySetInnerHTML={{
__html: renderMarkdown(field.values[rowIdx], { noSanitize: disableSanitizeHtml }).trim(),
__html: renderMarkdown(renderValue.text, { noSanitize: disableSanitizeHtml }).trim(),
}}
/>
</MaybeWrapWithLink>
@ -26,20 +33,17 @@ export const getStyles: TableCellStyles = (theme) =>
'.markdown-container': {
width: '100%',
},
'& ol, & ul': {
paddingLeft: theme.spacing(1.5),
},
'& p': {
whiteSpace: 'pre-line',
},
'& a': {
color: theme.colors.primary.text,
},
// for elements like `p`, `h*`, etc. which have an inherent margin,
// we want to remove the bottom margin for the last one in the container.
'& > .markdown-container > *:last-child': {
'> *:last-child': {
marginBottom: 0,
},
},
'ol, ul': {
paddingLeft: theme.spacing(2.5),
},
p: {
whiteSpace: 'pre-line',
},
});

View File

@ -1,10 +1,10 @@
import { clsx } from 'clsx';
import { ReactNode } from 'react';
import { Field, FieldType, GrafanaTheme2, isDataFrame, isTimeSeriesFrame } from '@grafana/data';
import { TableCellDisplayMode, TableCellOptions, TableCustomCellOptions } from '../../types';
import { TableCellRendererProps, TableCellStyleOptions, TableCellStyles } from '../types';
import { TableCellRenderer, TableCellStyleOptions, TableCellStyles } from '../types';
import { getCellOptions } from '../utils';
import { ActionsCell, getStyles as getActionsCellStyles } from './ActionsCell';
import { AutoCell, getStyles as getAutoCellStyles, getJsonCellStyles } from './AutoCell';
@ -16,8 +16,6 @@ import { MarkdownCell, getStyles as getMarkdownCellStyles } from './MarkdownCell
import { PillCell, getStyles as getPillStyles } from './PillCell';
import { SparklineCell, getStyles as getSparklineCellStyles } from './SparklineCell';
export type TableCellRenderer = (props: TableCellRendererProps) => ReactNode;
const GAUGE_RENDERER: TableCellRenderer = (props) => (
<BarGaugeCell
field={props.field}
@ -141,7 +139,10 @@ const STRING_ONLY_RENDERERS = new Set<TableCellOptions['type']>([
]);
/** @internal */
export function getCellRenderer(field: Field, cellOptions: TableCellOptions): TableCellRenderer {
export function getCellRenderer(
field: Field,
cellOptions: TableCellOptions = getCellOptions(field)
): TableCellRenderer {
const cellType = cellOptions?.type ?? TableCellDisplayMode.Auto;
if (cellType === TableCellDisplayMode.Auto) {
return CELL_RENDERERS[getAutoRendererDisplayMode(field)].renderer;

View File

@ -24,7 +24,7 @@ import {
ReducerID,
} from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import { FieldColorModeId } from '@grafana/schema';
import { FieldColorModeId, TableCellTooltipPlacement } from '@grafana/schema';
import { useStyles2, useTheme2 } from '../../../themes/ThemeContext';
import { ContextMenu } from '../../ContextMenu/ContextMenu';
@ -40,6 +40,7 @@ import { getCellRenderer, getCellSpecificStyles } from './Cells/renderers';
import { HeaderCell } from './components/HeaderCell';
import { RowExpander } from './components/RowExpander';
import { TableCellActions } from './components/TableCellActions';
import { TableCellTooltip } from './components/TableCellTooltip';
import { COLUMN, TABLE } from './constants';
import {
useColumnResize,
@ -51,10 +52,18 @@ import {
useScrollbarWidth,
useSortedRows,
} from './hooks';
import { getDefaultCellStyles, getFooterStyles, getGridStyles, getHeaderCellStyles, getLinkStyles } from './styles';
import {
getDefaultCellStyles,
getFooterStyles,
getGridStyles,
getHeaderCellStyles,
getLinkStyles,
getTooltipStyles,
} from './styles';
import { TableNGProps, TableRow, TableSummaryRow, TableColumn, ContextMenuProps, TableCellStyleOptions } from './types';
import {
applySort,
canFieldBeColorized,
computeColWidths,
createTypographyContext,
displayJsonValue,
@ -62,7 +71,7 @@ import {
frameToRecords,
getAlignment,
getApplyToRowBgFn,
getCellColors,
getCellColorInlineStyles,
getCellLinks,
getCellOptions,
getDefaultRowHeight,
@ -71,6 +80,7 @@ import {
getJustifyContent,
getVisibleFields,
isCellInspectEnabled,
predicateByName,
shouldTextOverflow,
shouldTextWrap,
withDataLinksActionsTooltip,
@ -379,10 +389,7 @@ export function TableNG(props: TableNGProps) {
const shouldOverflow = rowHeight !== 'auto' && shouldTextOverflow(field);
const textWrap = rowHeight === 'auto' || shouldTextWrap(field);
const withTooltip = withDataLinksActionsTooltip(field, cellType);
const canBeColorized =
cellType === TableCellDisplayMode.ColorBackground ||
cellType === TableCellDisplayMode.ColorText ||
Boolean(applyToRowBgFn);
const canBeColorized = canFieldBeColorized(cellType, applyToRowBgFn);
const cellStyleOptions: TableCellStyleOptions = { textAlign, textWrap, shouldOverflow };
result.colsWithTooltip[displayName] = withTooltip;
@ -406,9 +413,7 @@ export function TableNG(props: TableNGProps) {
// generate shared styles for whole row
if (applyToRowBgFn != null) {
let { textColor, bgColor } = applyToRowBgFn(rowIdx);
rowCellStyle.color = textColor;
rowCellStyle.background = bgColor;
rowCellStyle = { ...rowCellStyle, ...applyToRowBgFn(rowIdx) };
}
}
@ -416,16 +421,10 @@ export function TableNG(props: TableNGProps) {
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) {
} 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?
let { textColor, bgColor } = getCellColors(theme, cellOptions, displayValue);
style = {
color: textColor,
background: bgColor,
};
style = getCellColorInlineStyles(theme, cellOptions, displayValue);
}
return (
@ -440,8 +439,7 @@ export function TableNG(props: TableNGProps) {
result.cellRootRenderers[displayName] = renderCellRoot;
// this fires second
const renderCellContent = (props: RenderCellProps<TableRow, TableSummaryRow>): JSX.Element => {
const renderBasicCellContent = (props: RenderCellProps<TableRow, TableSummaryRow>): JSX.Element => {
const rowIdx = props.row.__index;
const value = props.row[props.column.key];
// TODO: it would be nice to get rid of passing height down as a prop. but this value
@ -486,6 +484,77 @@ export function TableNG(props: TableNGProps) {
);
};
// renderCellContent fires second.
let renderCellContent = renderBasicCellContent;
const tooltipFieldName = field.config.custom?.tooltip?.field;
if (tooltipFieldName) {
const tooltipField = data.fields.find(predicateByName(tooltipFieldName));
if (tooltipField) {
const tooltipDisplayName = getDisplayName(tooltipField);
const tooltipCellOptions = getCellOptions(tooltipField);
const tooltipFieldRenderer = getCellRenderer(tooltipField, tooltipCellOptions);
const tooltipCellStyleOptions = {
textAlign: getAlignment(tooltipField),
textWrap: shouldTextWrap(tooltipField),
shouldOverflow: false,
} satisfies TableCellStyleOptions;
const tooltipCanBeColorized = canFieldBeColorized(tooltipCellOptions.type, applyToRowBgFn);
const tooltipDefaultStyles = getDefaultCellStyles(theme, tooltipCellStyleOptions);
const tooltipSpecificStyles = getCellSpecificStyles(
tooltipCellOptions.type,
tooltipField,
theme,
tooltipCellStyleOptions
);
const tooltipLinkStyles = getLinkStyles(theme, tooltipCanBeColorized);
const tooltipClasses = getTooltipStyles(theme, textAlign);
const placement = field.config.custom?.tooltip?.placement ?? TableCellTooltipPlacement.Auto;
const tooltipWidth =
placement === TableCellTooltipPlacement.Left || placement === TableCellTooltipPlacement.Right
? tooltipField.config.custom?.width
: width;
const tooltipProps = {
cellOptions: tooltipCellOptions,
classes: tooltipClasses,
className: clsx(
tooltipClasses.tooltipContent,
tooltipDefaultStyles,
tooltipSpecificStyles,
tooltipLinkStyles
),
data,
disableSanitizeHtml,
field: tooltipField,
getActions: getCellActions,
gridRef,
placement,
renderer: tooltipFieldRenderer,
tooltipField,
theme,
width: tooltipWidth,
} satisfies Partial<React.ComponentProps<typeof TableCellTooltip>>;
renderCellContent = (props: RenderCellProps<TableRow, TableSummaryRow>): JSX.Element => {
// cached so we don't care about multiple calls.
const height = rowHeightFn(props.row);
let tooltipStyle: CSSProperties | undefined;
if (tooltipCanBeColorized) {
const tooltipDisplayValue = tooltipField.display!(props.row[tooltipDisplayName]); // this is yet another call to field.display() for the tooltip field
tooltipStyle = getCellColorInlineStyles(theme, tooltipCellOptions, tooltipDisplayValue);
}
return (
<TableCellTooltip {...tooltipProps} height={height} rowIdx={props.rowIdx} style={tooltipStyle}>
{renderBasicCellContent(props)}
</TableCellTooltip>
);
};
}
}
const column: TableColumn = {
field,
key: displayName,

View File

@ -0,0 +1,159 @@
import { CSSProperties, ReactElement, useMemo, useState, useRef, useEffect, RefObject } from 'react';
import { DataGridHandle } from 'react-data-grid';
import { ActionModel, DataFrame, Field, GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { TableCellTooltipPlacement } from '@grafana/schema';
import { Popover } from '../../../Tooltip/Popover';
import { TableCellOptions } from '../../types';
import { getTooltipStyles } from '../styles';
import { TableCellRenderer, TableCellRendererProps } from '../types';
export interface Props {
cellOptions: TableCellOptions;
children: ReactElement;
classes: ReturnType<typeof getTooltipStyles>;
className?: string;
data: DataFrame;
disableSanitizeHtml?: boolean;
field: Field;
getActions: (field: Field, rowIdx: number) => ActionModel[];
gridRef: RefObject<DataGridHandle>;
height: number;
placement?: TableCellTooltipPlacement;
renderer: TableCellRenderer;
rowIdx: number;
style?: CSSProperties;
tooltipField: Field;
theme: GrafanaTheme2;
width?: number;
}
export function TableCellTooltip({
cellOptions,
children,
classes,
className,
data,
disableSanitizeHtml,
field,
getActions,
gridRef,
height,
placement,
renderer,
rowIdx,
style,
theme,
tooltipField,
width = 300,
}: Props) {
const rawValue = field.values[rowIdx];
const tooltipCaretRef = useRef<HTMLDivElement>(null);
const [hovered, setHovered] = useState(false);
const [pinned, setPinned] = useState(false);
const show = hovered || pinned;
const dynamicHeight = tooltipField.config.custom?.cellOptions?.dynamicHeight;
useEffect(() => {
if (pinned) {
const gridRoot = gridRef.current?.element;
const windowListener = (ev: Event) => {
if (ev.target === tooltipCaretRef.current) {
return;
}
setPinned(false);
window.removeEventListener('click', windowListener);
};
window.addEventListener('click', windowListener);
// right now, we kill the pinned tooltip on any form of scrolling to avoid awkward rendering
// where the tooltip bumps up against the edge of the scrollable container. we could try to
// kill the tooltip when it hits these boundaries rather than when scrolling starts.
const scrollListener = () => {
setPinned(false);
};
gridRoot?.addEventListener('scroll', scrollListener, { once: true });
return () => {
window.removeEventListener('click', windowListener);
gridRoot?.removeEventListener('scroll', scrollListener);
};
}
return;
}, [pinned, gridRef]);
const rendererProps = useMemo(
() =>
({
cellInspect: false,
cellOptions,
disableSanitizeHtml,
field,
frame: data,
getActions,
height,
rowIdx,
showFilters: false,
theme,
value: rawValue,
width,
}) satisfies TableCellRendererProps,
[cellOptions, data, disableSanitizeHtml, field, getActions, height, rawValue, rowIdx, theme, width]
);
const cellElement = tooltipCaretRef.current?.closest<HTMLElement>('.rdg-cell');
if (rawValue === null || rawValue === undefined) {
return children;
}
const body = <>{renderer(rendererProps)}</>;
// TODO: perist the hover if you mouse out of the trigger and into the popover
const onMouseLeave = () => setHovered(false);
const onMouseEnter = () => setHovered(true);
return (
<>
{cellElement && (
<Popover
content={body}
show={show}
placement={placement}
wrapperClassName={classes.tooltipWrapper}
className={className}
style={{ ...style, minWidth: width, ...(!dynamicHeight && { height }) }}
referenceElement={cellElement}
onMouseLeave={onMouseLeave}
onMouseEnter={onMouseEnter}
onClick={(ev) => ev.stopPropagation()} // prevent click from bubbling to the global click listener for un-pinning
data-testid={selectors.components.Panels.Visualization.TableNG.Tooltip.Wrapper}
/>
)}
{/* TODO: figure out an accessible way to trigger the tooltip. */}
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div
className={classes.tooltipCaret}
ref={tooltipCaretRef}
data-testid={selectors.components.Panels.Visualization.TableNG.Tooltip.Caret}
aria-pressed={pinned}
onClick={() => setPinned((prev) => !prev)}
onMouseLeave={onMouseLeave}
onMouseEnter={onMouseEnter}
onBlur={onMouseLeave}
onFocus={onMouseEnter}
/>
{children}
</>
);
}

View File

@ -5,7 +5,7 @@ import { GrafanaTheme2, colorManipulator } from '@grafana/data';
import { COLUMN, TABLE } from './constants';
import { TableCellStyles } from './types';
import { getJustifyContent } from './utils';
import { getJustifyContent, TextAlign } from './utils';
export const getGridStyles = (
theme: GrafanaTheme2,
@ -25,6 +25,8 @@ export const getGridStyles = (
'--rdg-summary-border-color': borderColor,
'--rdg-summary-border-width': '1px',
'--rdg-selection-color': theme.colors.info.transparent,
// note: this cannot have any transparency since default cells that
// overlay/overflow on hover inherit this background and need to occlude cells below
'--rdg-row-background-color': bgColor,
@ -174,3 +176,34 @@ export const getLinkStyles = (theme: GrafanaTheme2, canBeColorized: boolean) =>
}),
},
});
const caretTriangle = (direction: 'left' | 'right', bgColor: string) =>
`linear-gradient(to top ${direction}, transparent 62.5%, ${bgColor} 50%)`;
export const getTooltipStyles = (theme: GrafanaTheme2, textAlign: TextAlign) => ({
tooltipContent: css({
height: '100%',
width: '100%',
}),
tooltipWrapper: css({
background: theme.colors.background.primary,
border: `1px solid ${theme.colors.border.weak}`,
borderRadius: theme.shape.radius.default,
boxShadow: theme.shadows.z3,
overflow: 'hidden',
padding: theme.spacing(1),
width: 'inherit',
}),
tooltipCaret: css({
cursor: 'pointer',
position: 'absolute',
top: theme.spacing(0.25),
[textAlign === 'right' ? 'right' : 'left']: theme.spacing(0.25),
width: theme.spacing(1.75),
height: theme.spacing(1.75),
background: caretTriangle(textAlign === 'right' ? 'right' : 'left', theme.colors.border.medium),
'&:hover, &[aria-pressed=true]': {
background: caretTriangle(textAlign === 'right' ? 'right' : 'left', theme.colors.border.strong),
},
}),
});

View File

@ -1,4 +1,4 @@
import { SyntheticEvent } from 'react';
import { ReactNode, SyntheticEvent } from 'react';
import { Column } from 'react-data-grid';
import {
@ -150,6 +150,8 @@ export interface BaseTableProps {
/* ---------------------------- Table cell props ---------------------------- */
export interface TableNGProps extends BaseTableProps {}
export type TableCellRenderer = (props: TableCellRendererProps) => ReactNode;
export interface TableCellRendererProps {
rowIdx: number;
frame: DataFrame;
@ -229,12 +231,6 @@ export interface GeoCellProps {
height: number;
}
export interface CellColors {
textColor?: string;
bgColor?: string;
bgHoverColor?: string;
}
export interface AutoCellProps {
field: Field;
value: TableCellValue;

View File

@ -23,7 +23,7 @@ import {
extractPixelValue,
frameToRecords,
getAlignmentFactor,
getCellColors,
getCellColorInlineStyles,
getCellLinks,
getCellOptions,
getComparator,
@ -44,6 +44,8 @@ import {
getDataLinksCounter,
getPillLineCounter,
getDefaultRowHeight,
getDisplayName,
predicateByName,
} from './utils';
describe('TableNG utils', () => {
@ -110,9 +112,9 @@ describe('TableNG utils', () => {
const displayValue = { text: '100', numeric: 100, color: '#ff0000' };
const colors = getCellColors(theme, field, displayValue);
expect(colors.bgColor).toBe('rgb(255, 0, 0)');
expect(colors.textColor).toBe('rgb(247, 248, 250)');
const colors = getCellColorInlineStyles(theme, field, displayValue);
expect(colors.background).toBe('rgb(255, 0, 0)');
expect(colors.color).toBe('rgb(247, 248, 250)');
});
it('should handle color background gradient mode', () => {
@ -123,9 +125,9 @@ describe('TableNG utils', () => {
const displayValue = { text: '100', numeric: 100, color: '#ff0000' };
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)');
const colors = getCellColorInlineStyles(theme, field, displayValue);
expect(colors.background).toBe('linear-gradient(120deg, rgb(255, 54, 36), #ff0000)');
expect(colors.color).toBe('rgb(247, 248, 250)');
});
});
@ -1268,4 +1270,54 @@ describe('TableNG utils', () => {
]);
});
});
describe('getDisplayName', () => {
it('should return the display name if set', () => {
const field: Field = {
name: 'test',
type: FieldType.string,
config: {},
state: { displayName: 'Test Display Name' },
values: [],
};
expect(getDisplayName(field)).toBe('Test Display Name');
});
it('should return the field name if no display name is set', () => {
const field: Field = {
name: 'test',
type: FieldType.string,
state: {},
config: {},
values: [],
};
expect(getDisplayName(field)).toBe('test');
});
});
describe('predicateByName', () => {
it('should return true for matching field names', () => {
const field: Field = { name: 'test', type: FieldType.string, config: {}, values: [] };
const predicate = predicateByName('test');
expect(predicate(field)).toBe(true);
});
it('should return true for a matching display name', () => {
const field: Field = {
name: 'test',
type: FieldType.string,
config: {},
state: { displayName: 'Test Display Name' },
values: [],
};
const predicate = predicateByName('Test Display Name');
expect(predicate(field)).toBe(true);
});
it('should return false for non-matching field names', () => {
const field: Field = { name: 'test', type: FieldType.string, config: {}, values: [] };
const predicate = predicateByName('other');
expect(predicate(field)).toBe(false);
});
});
});

View File

@ -29,7 +29,6 @@ import { TableCellOptions } from '../types';
import { inferPills } from './Cells/PillCell';
import { COLUMN, TABLE } from './constants';
import {
CellColors,
TableRow,
ColumnTypes,
FrameToRowsConverter,
@ -489,18 +488,17 @@ const CELL_GRADIENT_HUE_ROTATION_DEGREES = 5;
* @internal
* Returns the text and background colors for a table cell based on its options and display value.
*/
export function getCellColors(
export function getCellColorInlineStyles(
theme: GrafanaTheme2,
cellOptions: TableCellOptions,
displayValue: DisplayValue
): CellColors {
): CSSProperties {
// How much to darken elements depends upon if we're in dark mode
const darkeningFactor = theme.isDark ? 1 : -0.7;
// Setup color variables
let textColor: string | undefined = undefined;
let bgColor: string | undefined = undefined;
// let bgHoverColor: string | undefined = undefined;
if (cellOptions.type === TableCellDisplayMode.ColorText) {
textColor = displayValue.color;
@ -510,23 +508,16 @@ 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();
} else if (mode === TableCellBackgroundDisplayMode.Gradient) {
// 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})`;
}
}
return { textColor, bgColor };
return { color: textColor, background: bgColor };
}
/**
@ -814,6 +805,11 @@ export const getDisplayName = (field: Field): string => {
return field.state?.displayName ?? field.name;
};
/**
* @internal given a field name or display name, returns a predicate function that checks if a field matches that name.
*/
export const predicateByName = (name: string) => (f: Field) => f.name === name || getDisplayName(f) === name;
/**
* @internal
* returns only fields that are not nested tables and not explicitly hidden
@ -874,7 +870,7 @@ export function computeColWidths(fields: Field[], availWidth: number) {
* @internal
* if applyToRow is true in any field, return a function that gets the row background color
*/
export function getApplyToRowBgFn(fields: Field[], theme: GrafanaTheme2): ((rowIndex: number) => CellColors) | void {
export function getApplyToRowBgFn(fields: Field[], theme: GrafanaTheme2): ((rowIndex: number) => CSSProperties) | void {
for (const field of fields) {
const cellOptions = getCellOptions(field);
const fieldDisplay = field.display;
@ -883,7 +879,7 @@ export function getApplyToRowBgFn(fields: Field[], theme: GrafanaTheme2): ((rowI
cellOptions.type === TableCellDisplayMode.ColorBackground &&
cellOptions.applyToRow === true
) {
return (rowIndex: number) => getCellColors(theme, cellOptions, fieldDisplay(field.values[rowIndex]));
return (rowIndex: number) => getCellColorInlineStyles(theme, cellOptions, fieldDisplay(field.values[rowIndex]));
}
}
}
@ -897,6 +893,18 @@ export function withDataLinksActionsTooltip(field: Field, cellType: TableCellDis
);
}
/** @internal */
export function canFieldBeColorized(
cellType: TableCellDisplayMode,
applyToRowBgFn?: (rowIndex: number) => CSSProperties
) {
return (
cellType === TableCellDisplayMode.ColorBackground ||
cellType === TableCellDisplayMode.ColorText ||
Boolean(applyToRowBgFn)
);
}
export const displayJsonValue: DisplayProcessor = (value: unknown): DisplayValue => {
let displayValue: string;

View File

@ -25,6 +25,7 @@ interface Props extends Omit<React.HTMLAttributes<HTMLDivElement>, 'content'> {
wrapperClassName?: string;
renderArrow?: boolean;
hidePopper?: () => void;
style?: React.CSSProperties;
}
export function Popover({
@ -36,6 +37,7 @@ export function Popover({
referenceElement,
renderArrow,
hidePopper,
style: styleOverrides,
...rest
}: Props) {
const theme = useTheme2();
@ -89,6 +91,7 @@ export function Popover({
style={{
...floatingStyles,
...placementStyles,
...styleOverrides,
}}
className={wrapperClassName}
{...rest}

View File

@ -9,7 +9,13 @@ import {
FieldConfigProperty,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { TableCellOptions, TableCellDisplayMode, defaultTableFieldOptions, TableCellHeight } from '@grafana/schema';
import {
TableCellOptions,
TableCellDisplayMode,
defaultTableFieldOptions,
TableCellHeight,
TableCellTooltipPlacement,
} from '@grafana/schema';
import { PaginationEditor } from './PaginationEditor';
import { TableCellOptionEditor } from './TableCellOptionEditor';
@ -115,6 +121,46 @@ export const plugin = new PanelPlugin<Options, FieldConfig>(TablePanel)
category,
defaultValue: undefined,
hideFromDefaults: true,
})
.addFieldNamePicker({
path: 'tooltip.field',
name: t('table-new.name-tooltip-from-field', 'Tooltip from field'),
description: t(
'table-new.description-tooltip-from-field',
'Render a cell from a field (hidden or visible) in a tooltip'
),
category: cellCategory,
})
.addSelect({
path: 'tooltip.placement',
name: t('table-new.name-tooltip-placement', 'Tooltip placement'),
category: cellCategory,
settings: {
options: [
{
label: t('table-new.tooltip-placement-options.label-auto', 'Auto'),
value: TableCellTooltipPlacement.Auto,
},
{
label: t('table-new.tooltip-placement-options.label-top', 'Top'),
value: TableCellTooltipPlacement.Top,
},
{
label: t('table-new.tooltip-placement-options.label-right', 'Right'),
value: TableCellTooltipPlacement.Right,
},
{
label: t('table-new.tooltip-placement-options.label-bottom', 'Bottom'),
value: TableCellTooltipPlacement.Bottom,
},
{
label: t('table-new.tooltip-placement-options.label-left', 'Left'),
value: TableCellTooltipPlacement.Left,
},
],
},
defaultValue: 'auto',
showIf: (cfg) => cfg.tooltip?.field !== undefined,
});
},
})

View File

@ -12790,6 +12790,7 @@
"description-fields": "Select the fields that should be calculated",
"description-frozen-columns": "Columns are frozen from the left side of the table",
"description-min-column-width": "The minimum width for column auto resizing",
"description-tooltip-from-field": "Render a cell from a field (hidden or visible) in a tooltip",
"name-calculation": "Calculation",
"name-cell-height": "Cell height",
"name-cell-type": "Cell type",
@ -12805,8 +12806,17 @@
"name-min-column-width": "Minimum column width",
"name-show-table-footer": "Show table footer",
"name-show-table-header": "Show table header",
"name-tooltip-from-field": "Tooltip from field",
"name-tooltip-placement": "Tooltip placement",
"placeholder-column-width": "auto",
"placeholder-fields": "All Numeric Fields"
"placeholder-fields": "All Numeric Fields",
"tooltip-placement-options": {
"label-auto": "Auto",
"label-bottom": "Bottom",
"label-left": "Left",
"label-right": "Right",
"label-top": "Top"
}
},
"tag-filter": {
"clear-button": "Clear tags",