TableNG: Extract links and actions tooltip into cell root renderer (#107667)

* TableNG: Extract links and actions tooltip into cell root renderer

* make TS happy

* fixes & tweaks

* lint

* skip datalinks and actions tooltip on those cell types

* add todo

* withTooltip lookup

* fix

* optional getActions

* fix

* kill cursor: 'context-menu'

* stop event propagation from TableCellActions

* update tests to move tooltip tests up to TableNG

* remove safety assertion

* add value back to DataLinksActionsTooltip

---------

Co-authored-by: Paul Marbach <paul.marbach@grafana.com>
This commit is contained in:
Leon Sorokin
2025-07-08 14:24:03 -04:00
committed by GitHub
co-authored by Paul Marbach
parent 412415ab39
commit 68ee251c5c
18 changed files with 291 additions and 362 deletions
@@ -1179,11 +1179,6 @@ export const versionedComponents = {
'12.1.0': 'data-testid Data links actions tooltip wrapper',
},
},
TablePanel: {
autoCell: {
'12.1.0': 'data-testid Table panel auto cell',
},
},
CodeEditor: {
container: {
'10.2.3': 'data-testid Code editor container',
@@ -74,8 +74,6 @@ export const DataLinksActionsTooltip = ({ links, actions, value, coords, onToolt
const dismiss = useDismiss(context);
const hasMultipleLinksOrActions = links.length > 1 || Boolean(actions?.length);
const { getFloatingProps, getReferenceProps } = useInteractions([dismiss]);
if (links.length === 0 && !Boolean(actions?.length)) {
@@ -84,23 +82,22 @@ export const DataLinksActionsTooltip = ({ links, actions, value, coords, onToolt
return (
<>
{/* TODO: we can remove `value` from this component when tableNextGen is fully rolled out */}
{value}
{hasMultipleLinksOrActions && (
<Portal>
<div
ref={refCallback}
{...getReferenceProps()}
{...getFloatingProps()}
style={floatingStyles}
className={styles.tooltipWrapper}
data-testid={selectors.components.DataLinksActionsTooltip.tooltipWrapper}
>
<VizTooltipWrapper>
<VizTooltipFooter dataLinks={links} actions={actions} />
</VizTooltipWrapper>
</div>
</Portal>
)}
<Portal>
<div
ref={refCallback}
{...getReferenceProps()}
{...getFloatingProps()}
style={floatingStyles}
className={styles.tooltipWrapper}
data-testid={selectors.components.DataLinksActionsTooltip.tooltipWrapper}
>
<VizTooltipWrapper>
<VizTooltipFooter dataLinks={links} actions={actions} />
</VizTooltipWrapper>
</div>
</Portal>
</>
);
};
@@ -1,4 +1,5 @@
import { css } from '@emotion/css';
import { useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
@@ -6,12 +7,16 @@ import { useStyles2 } from '../../../../themes/ThemeContext';
import { ActionButton } from '../../../Actions/ActionButton';
import { ActionCellProps } from '../types';
export const ActionsCell = ({ actions }: ActionCellProps) => {
export const ActionsCell = ({ field, rowIdx, getActions }: ActionCellProps) => {
const styles = useStyles2(getStyles);
const actions = useMemo(() => getActions(field, rowIdx), [getActions, field, rowIdx]);
return (
<div className={styles.buttonsGap}>
{actions && actions.map((action, i) => <ActionButton key={i} action={action} variant="secondary" />)}
{actions.map((action, i) => (
<ActionButton key={i} action={action} variant="secondary" />
))}
</div>
);
};
@@ -1,97 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Field, FieldType, LinkModel } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { TableCellDisplayMode } from '../../types';
import AutoCell from './AutoCell';
describe('AutoCell', () => {
describe('Displays data Links', () => {
const getFieldWithLinks = (links: LinkModel[]): Field => {
return {
name: 'Category',
type: FieldType.string,
values: ['A', 'B', 'A', 'B', 'A'],
config: {
custom: {
cellOptions: {
type: TableCellDisplayMode.Auto,
wrapText: false,
},
},
},
display: (value: unknown) => ({
text: String(value),
numeric: 0,
color: undefined,
prefix: undefined,
suffix: undefined,
}),
state: {},
getLinks: () => links,
};
};
it('shows multiple datalinks in the tooltip', async () => {
const linksForField = [
{ href: 'http://asdasd.com', title: 'Test Title' } as LinkModel,
{ href: 'http://asdasd2.com', title: 'Test Title2' } as LinkModel,
];
jest.mock('../utils', () => ({
getCellLinks: () => linksForField,
}));
const field = getFieldWithLinks(linksForField);
render(
<AutoCell
value="test"
field={field}
justifyContent="normal"
rowIdx={0}
cellOptions={{ type: TableCellDisplayMode.Auto }}
/>
);
const cell = screen.getByTestId(selectors.components.TablePanel.autoCell);
await userEvent.click(cell);
const tooltip = screen.getByTestId(selectors.components.DataLinksActionsTooltip.tooltipWrapper);
expect(tooltip).toBeInTheDocument();
expect(screen.getByText('Test Title')).toBeInTheDocument();
expect(screen.getByText('Test Title2')).toBeInTheDocument();
});
it('does not show tooltip for multiple links if one is invalid', async () => {
const linksForField = [
{ href: 'http://asdasd.com', title: 'Test Title' } as LinkModel,
{ title: 'Test Title2' } as LinkModel,
];
jest.mock('../utils', () => ({
getCellLinks: () => linksForField,
}));
const field = getFieldWithLinks(linksForField);
render(
<AutoCell
value="test"
field={field}
justifyContent="normal"
rowIdx={0}
cellOptions={{ type: TableCellDisplayMode.Auto }}
/>
);
const cell = screen.getByTestId(selectors.components.TablePanel.autoCell);
await userEvent.click(cell);
expect(screen.queryByTestId(selectors.components.DataLinksActionsTooltip.tooltipWrapper)).not.toBeInTheDocument();
});
});
});
@@ -1,49 +1,24 @@
import { css } from '@emotion/css';
import { Property } from 'csstype';
import { useState } from 'react';
import { GrafanaTheme2, formattedValueToString } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { useStyles2 } from '../../../../themes/ThemeContext';
import { DataLinksActionsTooltip, renderSingleLink } from '../../DataLinksActionsTooltip';
import { renderSingleLink } from '../../DataLinksActionsTooltip';
import { TableCellOptions, TableCellDisplayMode } from '../../types';
import { DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils, tooltipOnClickHandler } from '../../utils';
import { useSingleLink } from '../hooks';
import { AutoCellProps } from '../types';
import { getCellLinks } from '../utils';
export default function AutoCell({ value, field, justifyContent, rowIdx, cellOptions, actions }: AutoCellProps) {
export default function AutoCell({ value, field, justifyContent, rowIdx, cellOptions }: AutoCellProps) {
const styles = useStyles2(getStyles, justifyContent);
const displayValue = field.display!(value);
const formattedValue = formattedValueToString(displayValue);
const links = getCellLinks(field, rowIdx) || [];
const [tooltipCoords, setTooltipCoords] = useState<DataLinksActionsTooltipCoords>();
const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(links, actions);
const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined;
const link = useSingleLink(field, rowIdx);
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
<div
className={styles.cell}
onClick={tooltipOnClickHandler(setTooltipCoords)}
style={{ cursor: hasMultipleLinksOrActions ? 'context-menu' : 'auto' }}
data-testid={selectors.components.TablePanel.autoCell}
>
{shouldShowLink ? (
renderSingleLink(links[0], formattedValue, getLinkStyle(styles, cellOptions))
) : shouldShowTooltip ? (
<DataLinksActionsTooltip
links={links}
actions={actions}
value={formattedValue}
coords={tooltipCoords}
onTooltipClose={() => setTooltipCoords(undefined)}
/>
) : (
formattedValue
)}
<div className={styles.cell}>
{link == null ? formattedValue : renderSingleLink(link, formattedValue, getLinkStyle(styles, cellOptions))}
</div>
);
}
@@ -1,13 +1,11 @@
import { useState } from 'react';
import { ThresholdsConfig, ThresholdsMode, VizOrientation, getFieldConfigWithMinMax } from '@grafana/data';
import { BarGaugeDisplayMode, BarGaugeValueMode, TableCellDisplayMode } from '@grafana/schema';
import { BarGauge } from '../../../BarGauge/BarGauge';
import { DataLinksActionsTooltip, renderSingleLink } from '../../DataLinksActionsTooltip';
import { tooltipOnClickHandler, DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils } from '../../utils';
import { renderSingleLink } from '../../DataLinksActionsTooltip';
import { useSingleLink } from '../hooks';
import { BarGaugeCellProps } from '../types';
import { extractPixelValue, getCellOptions, getAlignmentFactor, getCellLinks } from '../utils';
import { extractPixelValue, getCellOptions, getAlignmentFactor } from '../utils';
const defaultScale: ThresholdsConfig = {
mode: ThresholdsMode.Absolute,
@@ -23,7 +21,7 @@ const defaultScale: ThresholdsConfig = {
],
};
export const BarGaugeCell = ({ value, field, theme, height, width, rowIdx, actions }: BarGaugeCellProps) => {
export const BarGaugeCell = ({ value, field, theme, height, width, rowIdx }: BarGaugeCellProps) => {
const displayValue = field.display!(value);
const cellOptions = getCellOptions(field);
const heightOffset = extractPixelValue(theme.spacing(1));
@@ -48,51 +46,26 @@ export const BarGaugeCell = ({ value, field, theme, height, width, rowIdx, actio
}
const alignmentFactors = getAlignmentFactor(field, displayValue, rowIdx!);
const links = getCellLinks(field, rowIdx) || [];
const [tooltipCoords, setTooltipCoords] = useState<DataLinksActionsTooltipCoords>();
const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(links, actions);
const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined;
const renderComponent = () => {
return (
<BarGauge
width={width}
height={height - heightOffset}
field={config}
display={field.display}
text={{ valueSize: 14 }}
value={displayValue}
orientation={VizOrientation.Horizontal}
theme={theme}
alignmentFactors={alignmentFactors}
itemSpacing={1}
lcdCellWidth={8}
displayMode={barGaugeMode}
valueDisplayMode={valueDisplayMode}
/>
);
};
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
<div
style={{ cursor: hasMultipleLinksOrActions ? 'context-menu' : 'auto' }}
onClick={tooltipOnClickHandler(setTooltipCoords)}
>
{shouldShowLink ? (
renderSingleLink(links[0], renderComponent())
) : shouldShowTooltip ? (
<DataLinksActionsTooltip
links={links}
actions={actions}
value={renderComponent()}
coords={tooltipCoords}
onTooltipClose={() => setTooltipCoords(undefined)}
/>
) : (
renderComponent()
)}
</div>
const barGaugeComponent = (
<BarGauge
width={width}
height={height - heightOffset}
field={config}
display={field.display}
text={{ valueSize: 14 }}
value={displayValue}
orientation={VizOrientation.Horizontal}
theme={theme}
alignmentFactors={alignmentFactors}
itemSpacing={1}
lcdCellWidth={8}
displayMode={barGaugeMode}
valueDisplayMode={valueDisplayMode}
/>
);
const link = useSingleLink(field, rowIdx);
return link == null ? barGaugeComponent : renderSingleLink(link, barGaugeComponent);
};
@@ -1,55 +1,28 @@
import { css } from '@emotion/css';
import { Property } from 'csstype';
import { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../../../themes/ThemeContext';
import { DataLinksActionsTooltip, renderSingleLink } from '../../DataLinksActionsTooltip';
import { renderSingleLink } from '../../DataLinksActionsTooltip';
import { TableCellDisplayMode } from '../../types';
import { DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils, tooltipOnClickHandler } from '../../utils';
import { useSingleLink } from '../hooks';
import { ImageCellProps } from '../types';
import { getCellLinks } from '../utils';
const DATALINKS_HEIGHT_OFFSET = 10;
export const ImageCell = ({ cellOptions, field, height, justifyContent, value, rowIdx, actions }: ImageCellProps) => {
export const ImageCell = ({ cellOptions, field, height, justifyContent, value, rowIdx }: ImageCellProps) => {
const calculatedHeight = height - DATALINKS_HEIGHT_OFFSET;
const styles = useStyles2(getStyles, calculatedHeight, justifyContent);
const links = getCellLinks(field, rowIdx) || [];
const [tooltipCoords, setTooltipCoords] = useState<DataLinksActionsTooltipCoords>();
const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(links, actions);
const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined;
const { text } = field.display!(value);
const { alt, title } =
cellOptions.type === TableCellDisplayMode.Image ? cellOptions : { alt: undefined, title: undefined };
const img = <img alt={alt} src={text} className={styles.image} title={title} />;
const link = useSingleLink(field, rowIdx);
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
<div
className={styles.imageContainer}
style={{ cursor: hasMultipleLinksOrActions ? 'context-menu' : 'auto' }}
onClick={tooltipOnClickHandler(setTooltipCoords)}
>
{shouldShowLink ? (
renderSingleLink(links[0], img)
) : shouldShowTooltip ? (
<DataLinksActionsTooltip
links={links}
actions={actions}
value={img}
coords={tooltipCoords}
onTooltipClose={() => setTooltipCoords(undefined)}
/>
) : (
img
)}
</div>
);
return <div className={styles.imageContainer}>{link == null ? img : renderSingleLink(link, img)}</div>;
};
const getStyles = (theme: GrafanaTheme2, height: number, justifyContent: Property.JustifyContent) => ({
@@ -1,16 +1,14 @@
import { css } from '@emotion/css';
import { Property } from 'csstype';
import { useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../../../themes/ThemeContext';
import { DataLinksActionsTooltip, renderSingleLink } from '../../DataLinksActionsTooltip';
import { tooltipOnClickHandler, DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils } from '../../utils';
import { renderSingleLink } from '../../DataLinksActionsTooltip';
import { useSingleLink } from '../hooks';
import { JSONCellProps } from '../types';
import { getCellLinks } from '../utils';
export const JSONCell = ({ value, justifyContent, field, rowIdx, actions }: JSONCellProps) => {
export const JSONCell = ({ value, justifyContent, field, rowIdx }: JSONCellProps) => {
const styles = useStyles2(getStyles, justifyContent);
let displayValue = value;
@@ -33,34 +31,9 @@ export const JSONCell = ({ value, justifyContent, field, rowIdx, actions }: JSON
}
}
const links = getCellLinks(field, rowIdx) || [];
const link = useSingleLink(field, rowIdx);
const [tooltipCoords, setTooltipCoords] = useState<DataLinksActionsTooltipCoords>();
const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(links, actions);
const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined;
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
<div
className={styles.jsonText}
onClick={tooltipOnClickHandler(setTooltipCoords)}
style={{ cursor: hasMultipleLinksOrActions ? 'context-menu' : 'auto' }}
>
{shouldShowLink ? (
renderSingleLink(links[0], displayValue)
) : shouldShowTooltip ? (
<DataLinksActionsTooltip
links={links}
actions={actions}
value={displayValue}
coords={tooltipCoords}
onTooltipClose={() => setTooltipCoords(undefined)}
/>
) : (
displayValue
)}
</div>
);
return <div className={styles.jsonText}>{link == null ? displayValue : renderSingleLink(link, displayValue)}</div>;
};
const getStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent) => ({
@@ -24,7 +24,10 @@ export function TableCellActions(props: TableCellActionsProps) {
} = props;
return (
<div className={className}>
// stopping propagation to prevent clicks within the actions menu from triggering the cell click events
// for things like the data links tooltip.
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div className={className} onClick={(ev) => ev.stopPropagation()}>
{cellInspect && (
<IconButton
name="eye"
@@ -25,7 +25,6 @@ const GAUGE_RENDERER: TableCellRenderer = (props) => (
height={props.height}
width={props.width}
rowIdx={props.rowIdx}
actions={props.actions}
/>
);
@@ -36,7 +35,6 @@ const AUTO_RENDERER: TableCellRenderer = (props) => (
justifyContent={props.justifyContent}
rowIdx={props.rowIdx}
cellOptions={props.cellOptions}
actions={props.actions}
/>
);
@@ -53,13 +51,7 @@ const SPARKLINE_RENDERER: TableCellRenderer = (props) => (
);
const JSON_RENDERER: TableCellRenderer = (props) => (
<JSONCell
justifyContent={props.justifyContent}
value={props.value}
field={props.field}
rowIdx={props.rowIdx}
actions={props.actions}
/>
<JSONCell justifyContent={props.justifyContent} value={props.value} field={props.field} rowIdx={props.rowIdx} />
);
const GEO_RENDERER: TableCellRenderer = (props) => (
@@ -74,13 +66,14 @@ const IMAGE_RENDERER: TableCellRenderer = (props) => (
justifyContent={props.justifyContent}
value={props.value}
rowIdx={props.rowIdx}
actions={props.actions}
/>
);
const DATA_LINKS_RENDERER: TableCellRenderer = (props) => <DataLinksCell field={props.field} rowIdx={props.rowIdx} />;
const ACTIONS_RENDERER: TableCellRenderer = (props) => <ActionsCell actions={props.actions} />;
const ACTIONS_RENDERER: TableCellRenderer = ({ field, rowIdx, getActions = () => [] }) => (
<ActionsCell field={field} rowIdx={rowIdx} getActions={getActions} />
);
const PILL_RENDERER: TableCellRenderer = (props) => <PillCell {...props} />;
@@ -1,7 +1,17 @@
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { applyFieldOverrides, createTheme, DataFrame, EventBus, FieldType, toDataFrame } from '@grafana/data';
import {
applyFieldOverrides,
createTheme,
DataFrame,
DataLink,
EventBus,
FieldType,
LinkModel,
toDataFrame,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { TableCellBackgroundDisplayMode } from '@grafana/schema';
import { PanelContext, PanelContextProvider } from '../../../components/PanelChrome';
@@ -1682,4 +1692,55 @@ describe('TableNG', () => {
expect(mockEventBus.publish).not.toHaveBeenCalled();
});
});
describe('Displays data Links', () => {
function toLinkModel(link: DataLink): LinkModel {
return {
href: link.url,
title: link.title,
target: link.targetBlank ? '_blank' : '_self',
origin: link.origin || 'panel',
};
}
it('shows multiple datalinks in the tooltip', async () => {
const dataFrame = createBasicDataFrame();
const links: DataLink[] = [
{ url: 'http://asdasd.com', title: 'Test Title' },
{ url: 'http://asdasd2.com', title: 'Test Title2' },
];
dataFrame.fields[0].config.links = links;
dataFrame.fields[0].getLinks = () => links.map(toLinkModel);
render(<TableNG enableVirtualization={false} data={dataFrame} width={800} height={600} />);
const cell = screen.getByText('A1');
await userEvent.click(cell);
const tooltip = screen.getByTestId(selectors.components.DataLinksActionsTooltip.tooltipWrapper);
expect(tooltip).toBeInTheDocument();
expect(screen.getByText('Test Title')).toBeInTheDocument();
expect(screen.getByText('Test Title2')).toBeInTheDocument();
});
it('does not show tooltip for a single link', async () => {
const dataFrame = createBasicDataFrame();
const links: DataLink[] = [{ url: 'http://asdasd.com', title: 'Test Title' }];
dataFrame.fields[0].config.links = links;
dataFrame.fields[0].getLinks = () => links.map(toLinkModel);
render(<TableNG enableVirtualization={false} data={dataFrame} width={800} height={600} />);
const cell = screen.getByText('A1');
// we need to click the parent since the cell itself is a link.
await userEvent.click(cell.parentElement!);
expect(screen.queryByTestId(selectors.components.DataLinksActionsTooltip.tooltipWrapper)).not.toBeInTheDocument();
});
});
});
@@ -1,7 +1,7 @@
import 'react-data-grid/lib/styles.css';
import { css, cx } from '@emotion/css';
import { Property } from 'csstype';
import { Key, ReactNode, useLayoutEffect, useMemo, useState } from 'react';
import { Key, ReactNode, useCallback, useLayoutEffect, useMemo, useState } from 'react';
import {
Cell,
CellRendererProps,
@@ -22,8 +22,10 @@ import { ContextMenu } from '../../ContextMenu/ContextMenu';
import { MenuItem } from '../../Menu/MenuItem';
import { Pagination } from '../../Pagination/Pagination';
import { PanelContext, usePanelContext } from '../../PanelChrome';
import { DataLinksActionsTooltip } from '../DataLinksActionsTooltip';
import { TableCellInspector, TableCellInspectorMode } from '../TableCellInspector';
import { CellColors, TableCellDisplayMode } from '../types';
import { DataLinksActionsTooltipState } from '../utils';
import { HeaderCell } from './Cells/HeaderCell';
import { RowExpander } from './Cells/RowExpander';
@@ -56,6 +58,8 @@ import {
getCellOptions,
shouldTextWrap,
isCellInspectEnabled,
getCellLinks,
withDataLinksActionsTooltip,
} from './utils';
type CellRootRenderer = (key: React.Key, props: CellRendererProps<TableRow, TableSummaryRow>) => React.ReactNode;
@@ -68,14 +72,13 @@ export function TableNG(props: TableNGProps) {
enableSharedCrosshair = false,
enableVirtualization,
footerOptions,
getActions,
getActions = () => [],
height,
initialSortBy,
noHeader,
onCellFilterAdded,
onColumnResize,
onSortByChange,
replaceVariables,
showTypeIcons,
structureRev,
width,
@@ -88,6 +91,11 @@ export function TableNG(props: TableNGProps) {
});
const panelContext = usePanelContext();
const getCellActions = useCallback(
(field: Field, rowIdx: number) => getActions(data, field, rowIdx),
[getActions, data]
);
const hasHeader = !noHeader;
const hasFooter = Boolean(footerOptions?.show && footerOptions.reducer?.length);
const isCountRowsSet = Boolean(
@@ -256,13 +264,15 @@ export function TableNG(props: TableNGProps) {
interface Schema {
columns: TableColumn[];
cellRootRenderers: Record<string, CellRootRenderer>;
colsWithTooltip: Record<string, boolean>;
}
const { columns, cellRootRenderers } = useMemo(() => {
const { columns, cellRootRenderers, colsWithTooltip } = useMemo(() => {
const fromFields = (f: Field[], widths: number[]) => {
const result: Schema = {
columns: [],
cellRootRenderers: {},
colsWithTooltip: {},
};
let lastRowIdx = -1;
@@ -280,7 +290,6 @@ export function TableNG(props: TableNGProps) {
const showFilters = Boolean(field.config.filterable && onCellFilterAdded != null);
const showActions = cellInspect || showFilters;
const width = widths[i];
const frame = data;
// helps us avoid string cx and emotion per-cell
const cellActionClassName = showActions
@@ -294,6 +303,9 @@ export function TableNG(props: TableNGProps) {
const cellType = cellOptions.type;
const shouldOverflow = shouldTextOverflow(field);
const shouldWrap = shouldTextWrap(field);
const withTooltip = withDataLinksActionsTooltip(field, cellType);
result.colsWithTooltip[displayName] = withTooltip;
// this fires first
const renderCellRoot = (key: Key, props: CellRendererProps<TableRow, TableSummaryRow>): ReactNode => {
@@ -317,7 +329,7 @@ export function TableNG(props: TableNGProps) {
colors = {};
}
const cellStyle = getCellStyles(theme, field, _rowHeight, shouldWrap, shouldOverflow, colors);
const cellStyle = getCellStyles(theme, field, _rowHeight, shouldWrap, shouldOverflow, withTooltip, colors);
return (
<Cell
@@ -335,14 +347,11 @@ export function TableNG(props: TableNGProps) {
const renderCellContent = (props: RenderCellProps<TableRow, TableSummaryRow>): JSX.Element => {
const rowIdx = props.row.__index;
const value = props.row[props.column.key];
// TODO: defer until click?
const actions = getActions?.(frame, field, props.row.__index, replaceVariables);
const frame = data;
return (
<>
{renderFieldCell({
actions,
cellOptions,
frame,
field,
@@ -354,6 +363,7 @@ export function TableNG(props: TableNGProps) {
width,
cellInspect,
showFilters,
getActions: getCellActions,
})}
{showActions && (
<TableCellActions
@@ -504,12 +514,10 @@ export function TableNG(props: TableNGProps) {
expandedRows,
filter,
footerCalcs,
getActions,
hasNestedFrames,
isCountRowsSet,
onCellFilterAdded,
panelContext,
replaceVariables,
rowHeight,
rows,
setFilter,
@@ -519,6 +527,7 @@ export function TableNG(props: TableNGProps) {
theme,
visibleFields,
widths,
getCellActions,
]);
// invalidate columns on every structureRev change. this supports width editing in the fieldConfig.
@@ -534,6 +543,8 @@ export function TableNG(props: TableNGProps) {
return cellRootRenderers[props.column.key](key, props);
};
const [tooltipState, setTooltipState] = useState<DataLinksActionsTooltipState>();
return (
<>
<DataGrid<TableRow, TableSummaryRow>
@@ -541,6 +552,24 @@ export function TableNG(props: TableNGProps) {
className={styles.grid}
columns={structureRevColumns}
rows={paginatedRows}
onCellClick={({ column, row }, { clientX, clientY, preventGridDefault }) => {
// Note: could be column.field; JS says yes, but TS says no!
const field = columns[column.idx].field;
if (colsWithTooltip[getDisplayName(field)]) {
const rowIdx = row.__index;
setTooltipState({
coords: {
clientX,
clientY,
},
links: getCellLinks(field, rowIdx),
actions: getCellActions(field, rowIdx),
});
preventGridDefault();
}
}}
onCellKeyDown={
hasNestedFrames
? (_, event) => {
@@ -577,6 +606,15 @@ export function TableNG(props: TableNGProps) {
</div>
)}
{tooltipState && (
<DataLinksActionsTooltip
links={tooltipState.links ?? []}
actions={tooltipState.actions}
coords={tooltipState.coords}
onTooltipClose={() => setTooltipState(undefined)}
/>
)}
{isContextMenuOpen && (
<ContextMenu
x={contextMenuProps?.left || 0}
@@ -770,31 +808,35 @@ const getCellStyles = (
rowHeight: number,
shouldWrap: boolean,
shouldOverflow: boolean,
hasTooltip: boolean,
colors: CellColors
) => ({
cell: css({
textOverflow: 'initial',
background: colors.bgColor ?? 'inherit',
alignContent: 'center',
justifyContent: getTextAlign(field),
paddingInline: TABLE.CELL_PADDING,
height: '100%',
minHeight: rowHeight, // min height interacts with the fit-content property on the overflow container
...(shouldWrap && { whiteSpace: 'pre-line' }),
'&:last-child': {
borderInlineEnd: 'none',
},
'&:hover': {
background: colors.bgHoverColor,
'.table-cell-actions': {
display: 'flex',
) => {
return {
cell: css({
textOverflow: 'initial',
background: colors.bgColor ?? 'inherit',
alignContent: 'center',
justifyContent: getTextAlign(field),
paddingInline: TABLE.CELL_PADDING,
height: '100%',
minHeight: rowHeight, // min height interacts with the fit-content property on the overflow container
...(shouldWrap && { whiteSpace: 'pre-line' }),
...(hasTooltip && { cursor: 'pointer' }),
'&:last-child': {
borderInlineEnd: 'none',
},
...(shouldOverflow && {
zIndex: theme.zIndex.tooltip - 2,
whiteSpace: 'pre-line',
height: 'fit-content',
minWidth: 'fit-content',
}),
},
}),
});
'&:hover': {
background: colors.bgHoverColor,
'.table-cell-actions': {
display: 'flex',
},
...(shouldOverflow && {
zIndex: theme.zIndex.tooltip - 2,
whiteSpace: 'pre-line',
height: 'fit-content',
minWidth: 'fit-content',
}),
},
}),
};
};
@@ -2,7 +2,7 @@ import { useState, useMemo, useEffect, useCallback, useRef, useLayoutEffect } fr
import { Column, DataGridProps, SortColumn } from 'react-data-grid';
import { varPreLine } from 'uwrap';
import { Field, fieldReducers, FieldType, formattedValueToString, reduceField } from '@grafana/data';
import { Field, fieldReducers, FieldType, formattedValueToString, LinkModel, reduceField } from '@grafana/data';
import { useTheme2 } from '../../../themes/ThemeContext';
import { TableCellDisplayMode, TableColumnResizeActionCallback } from '../types';
@@ -17,6 +17,7 @@ import {
getColumnTypes,
GetMaxWrapCellOptions,
getMaxWrapCell,
getCellLinks,
} from './utils';
// Helper function to get displayed value
@@ -597,3 +598,10 @@ export function useColumnResize(
return dataGridResizeHandler;
}
export function useSingleLink(field: Field, rowIdx: number): LinkModel | undefined {
const linksCount = field.config.links?.length ?? 0;
const actionsCount = field.config.actions?.length ?? 0;
const shouldShowLink = linksCount === 1 && actionsCount === 0;
return useMemo(() => (shouldShowLink ? (getCellLinks(field, rowIdx) ?? []) : [])[0], [field, shouldShowLink, rowIdx]);
}
@@ -10,7 +10,6 @@ import {
TimeRange,
FieldConfigSource,
ActionModel,
InterpolateFunction,
FieldType,
DataFrameWithValue,
SelectableValue,
@@ -30,12 +29,9 @@ export type TableColumnResizeActionCallback = (fieldDisplayName: string, width:
export type TableSortByActionCallback = (state: TableSortByFieldState[]) => void;
export type FooterItem = Array<KeyValue<string>> | string | undefined;
export type GetActionsFunction = (
frame: DataFrame,
field: Field,
rowIndex: number,
replaceVariables?: InterpolateFunction
) => ActionModel[];
export type GetActionsFunction = (frame: DataFrame, field: Field, rowIndex: number) => ActionModel[];
export type GetActionsFunctionLocal = (field: Field, rowIndex: number) => ActionModel[];
export type TableFieldOptionsType = Omit<TableFieldOptions, 'cellOptions'> & {
cellOptions: TableCellOptions;
@@ -142,7 +138,6 @@ export interface BaseTableProps {
initialRowIndex?: number;
fieldConfig?: FieldConfigSource;
getActions?: GetActionsFunction;
replaceVariables?: InterpolateFunction;
// Used solely for testing as RTL can't correctly render the table otherwise
enableVirtualization?: boolean;
}
@@ -151,7 +146,6 @@ export interface BaseTableProps {
export interface TableNGProps extends BaseTableProps {}
export interface TableCellRendererProps {
actions?: ActionModel[];
rowIdx: number;
frame: DataFrame;
timeRange?: TimeRange;
@@ -165,6 +159,7 @@ export interface TableCellRendererProps {
cellInspect: boolean;
showFilters: boolean;
justifyContent: Property.JustifyContent;
getActions?: GetActionsFunctionLocal;
}
export type ContextMenuProps = {
@@ -205,7 +200,7 @@ export interface SparklineCellProps {
width: number;
}
export interface BarGaugeCellProps extends ActionCellProps {
export interface BarGaugeCellProps {
field: Field;
height: number;
rowIdx: number;
@@ -214,7 +209,7 @@ export interface BarGaugeCellProps extends ActionCellProps {
width: number;
}
export interface ImageCellProps extends ActionCellProps {
export interface ImageCellProps {
cellOptions: TableCellOptions;
field: Field;
height: number;
@@ -223,7 +218,7 @@ export interface ImageCellProps extends ActionCellProps {
rowIdx: number;
}
export interface JSONCellProps extends ActionCellProps {
export interface JSONCellProps {
justifyContent: Property.JustifyContent;
value: TableCellValue;
field: Field;
@@ -241,24 +236,26 @@ export interface GeoCellProps {
height: number;
}
export interface ActionCellProps {
actions?: ActionModel[];
}
export interface CellColors {
textColor?: string;
bgColor?: string;
bgHoverColor?: string;
}
export interface AutoCellProps extends ActionCellProps {
value: TableCellValue;
export interface AutoCellProps {
field: Field;
value: TableCellValue;
justifyContent: Property.JustifyContent;
rowIdx: number;
cellOptions: TableCellOptions;
}
export interface ActionCellProps {
field: Field;
rowIdx: number;
getActions: GetActionsFunctionLocal;
}
// Comparator for sorting table values
export type Comparator = (a: TableCellValue, b: TableCellValue) => number;
@@ -172,6 +172,7 @@ const DEFAULT_CELL_OPTIONS = { type: TableCellDisplayMode.Auto } as const;
/**
* @internal
* Returns the cell options for a field, migrating from legacy displayMode if necessary.
* TODO: remove live migration in favor of doing it in dashboard or panel migrator
*/
export function getCellOptions(field: Field): TableCellOptions {
if (field.config.custom?.displayMode) {
@@ -613,3 +614,12 @@ export function getApplyToRowBgFn(fields: Field[], theme: GrafanaTheme2): ((rowI
}
}
}
/** @internal */
export function withDataLinksActionsTooltip(field: Field, cellType: TableCellDisplayMode) {
return (
cellType !== TableCellDisplayMode.DataLinks &&
cellType !== TableCellDisplayMode.Actions &&
(field.config.links?.length ?? 0) + (field.config.actions?.length ?? 0) > 1
);
}
@@ -57,7 +57,7 @@ export interface TableCellProps extends CellProps<any> {
onCellFilterAdded?: TableFilterActionCallback;
innerWidth: number;
frame: DataFrame;
actions?: ActionModel[];
actions?: ActionModel[]; // unused in NG
setInspectCell?: TableInspectCellCallback;
}
@@ -766,10 +766,16 @@ export function guessLongestField(fieldConfig: FieldConfigSource, data: DataFram
return longestField;
}
export type DataLinksActionsTooltipCoords = {
export interface DataLinksActionsTooltipState {
coords: DataLinksActionsTooltipCoords;
links?: LinkModel[];
actions?: ActionModel[];
}
export interface DataLinksActionsTooltipCoords {
clientX: number;
clientY: number;
};
}
export const getDataLinksActionsTooltipUtils = (links: LinkModel[], actions?: ActionModel[]) => {
const hasMultipleLinksOrActions = links.length > 1 || Boolean(actions?.length);
@@ -1,5 +1,5 @@
import { css } from '@emotion/css';
import { useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import {
ActionModel,
@@ -57,6 +57,11 @@ export function TablePanel(props: Props) {
const enableSharedCrosshair = panelContext.sync && panelContext.sync() !== DashboardCursorSync.Off;
const _getActions = useCallback(
(frame: DataFrame, field: Field, rowIndex: number) => getCellActions(frame, field, rowIndex, replaceVariables),
[replaceVariables]
);
const tableElement = (
<TableNG
height={tableHeight}
@@ -75,8 +80,7 @@ export function TablePanel(props: Props) {
timeRange={timeRange}
enableSharedCrosshair={config.featureToggles.tableSharedCrosshair && enableSharedCrosshair}
fieldConfig={fieldConfig}
getActions={getCellActions}
replaceVariables={replaceVariables}
getActions={_getActions}
structureRev={data.structureRev}
/>
);
@@ -159,28 +163,39 @@ const getCellActions = (
field: Field,
rowIndex: number,
replaceVariables: InterpolateFunction | undefined
) => {
const actions: Array<ActionModel<Field>> = [];
const actionLookup = new Set<string>();
): Array<ActionModel<Field>> => {
const numActions = field.config.actions?.length ?? 0;
const actionsModel = getActions(
dataFrame,
field,
field.state!.scopedVars!,
replaceVariables ?? replaceVars,
field.config.actions ?? [],
{ valueRowIndex: rowIndex }
);
if (numActions > 0) {
const actions = getActions(
dataFrame,
field,
field.state!.scopedVars!,
replaceVariables ?? replaceVars,
field.config.actions ?? [],
{ valueRowIndex: rowIndex }
);
actionsModel.forEach((action) => {
const key = `${action.title}`;
if (!actionLookup.has(key)) {
actions.push(action);
actionLookup.add(key);
if (actions.length === 1) {
return actions;
} else {
const actionsOut: Array<ActionModel<Field>> = [];
const actionLookup = new Set<string>();
actions.forEach((action) => {
const key = action.title;
if (!actionLookup.has(key)) {
actionsOut.push(action);
actionLookup.add(key);
}
});
return actionsOut;
}
});
}
return actions;
return [];
};
const tableStyles = {