mirror of https://github.com/grafana/grafana.git
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:
parent
4e5a51968f
commit
66eee1cb08
|
@ -299,6 +299,14 @@
|
|||
{
|
||||
"id": "custom.width",
|
||||
"value": 255
|
||||
},
|
||||
{
|
||||
"id": "custom.tooltip.field",
|
||||
"value": "State"
|
||||
},
|
||||
{
|
||||
"id": "custom.tooltip.placement",
|
||||
"value": "left"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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',
|
||||
},
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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),
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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",
|
||||
|
|
Loading…
Reference in New Issue