diff --git a/devenv/dev-dashboards/panel-table/table_kitchen_sink.json b/devenv/dev-dashboards/panel-table/table_kitchen_sink.json index 2e2fb5ccd8f..6e806785038 100644 --- a/devenv/dev-dashboards/panel-table/table_kitchen_sink.json +++ b/devenv/dev-dashboards/panel-table/table_kitchen_sink.json @@ -299,6 +299,14 @@ { "id": "custom.width", "value": 255 + }, + { + "id": "custom.tooltip.field", + "value": "State" + }, + { + "id": "custom.tooltip.placement", + "value": "left" } ] }, diff --git a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts index 237ea5272a9..739646813f8 100644 --- a/e2e-playwright/panels-suite/table-kitchenSink.spec.ts +++ b/e2e-playwright/panels-suite/table-kitchenSink.spec.ts @@ -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, diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 9ac23a5b24c..3b3ccab5cf0 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -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', + }, + }, }, }, }, diff --git a/packages/grafana-schema/src/common/common.gen.ts b/packages/grafana-schema/src/common/common.gen.ts index 62346b070f6..5be227ba4b9 100644 --- a/packages/grafana-schema/src/common/common.gen.ts +++ b/packages/grafana-schema/src/common/common.gen.ts @@ -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 diff --git a/packages/grafana-schema/src/common/table.cue b/packages/grafana-schema/src/common/table.cue index 9fe71322a31..27229571a19 100644 --- a/packages/grafana-schema/src/common/table.cue +++ b/packages/grafana-schema/src/common/table.cue @@ -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") diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/MarkdownCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/MarkdownCell.tsx index 4bd252ff9d3..7d3e36e3f65 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/MarkdownCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/MarkdownCell.tsx @@ -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 (
@@ -26,20 +33,17 @@ export const getStyles: TableCellStyles = (theme) => '.markdown-container': { width: '100%', + // 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. + '> *:last-child': { + marginBottom: 0, + }, }, - '& ol, & ul': { - paddingLeft: theme.spacing(1.5), + 'ol, ul': { + paddingLeft: theme.spacing(2.5), }, - '& p': { + 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': { - marginBottom: 0, - }, }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx index 74648953454..c0e2801d7f7 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/renderers.tsx @@ -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) => ( ([ ]); /** @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; diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index b0f720fbe10..84132fc64cc 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -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): JSX.Element => { + const renderBasicCellContent = (props: RenderCellProps): 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>; + + renderCellContent = (props: RenderCellProps): 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 ( + + {renderBasicCellContent(props)} + + ); + }; + } + } + const column: TableColumn = { field, key: displayName, diff --git a/packages/grafana-ui/src/components/Table/TableNG/components/TableCellTooltip.tsx b/packages/grafana-ui/src/components/Table/TableNG/components/TableCellTooltip.tsx new file mode 100644 index 00000000000..a81f083a2f1 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/TableNG/components/TableCellTooltip.tsx @@ -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; + className?: string; + data: DataFrame; + disableSanitizeHtml?: boolean; + field: Field; + getActions: (field: Field, rowIdx: number) => ActionModel[]; + gridRef: RefObject; + 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(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('.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 && ( + 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 */} +
setPinned((prev) => !prev)} + onMouseLeave={onMouseLeave} + onMouseEnter={onMouseEnter} + onBlur={onMouseLeave} + onFocus={onMouseEnter} + /> + + {children} + + ); +} diff --git a/packages/grafana-ui/src/components/Table/TableNG/styles.ts b/packages/grafana-ui/src/components/Table/TableNG/styles.ts index 983372aeb0b..76de0e3e1bb 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/styles.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/styles.ts @@ -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), + }, + }), +}); diff --git a/packages/grafana-ui/src/components/Table/TableNG/types.ts b/packages/grafana-ui/src/components/Table/TableNG/types.ts index 8a2b25bc986..7d61ac6200a 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/types.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/types.ts @@ -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; diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts index e159c9933cb..5ebd14336b9 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.test.ts @@ -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); + }); + }); }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.ts index 9ba4a5eddca..3c36e150a3c 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.ts @@ -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; diff --git a/packages/grafana-ui/src/components/Tooltip/Popover.tsx b/packages/grafana-ui/src/components/Tooltip/Popover.tsx index cb4b5468707..369f3b46698 100644 --- a/packages/grafana-ui/src/components/Tooltip/Popover.tsx +++ b/packages/grafana-ui/src/components/Tooltip/Popover.tsx @@ -25,6 +25,7 @@ interface Props extends Omit, '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} diff --git a/public/app/plugins/panel/table/table-new/module.tsx b/public/app/plugins/panel/table/table-new/module.tsx index bde9b5c7c40..0e17f6c1072 100644 --- a/public/app/plugins/panel/table/table-new/module.tsx +++ b/public/app/plugins/panel/table/table-new/module.tsx @@ -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(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, }); }, }) diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index bdc8663bbd9..b0299f1ee32 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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",