From 68ee251c5c94044a7e10882942177d54f8122ddd Mon Sep 17 00:00:00 2001 From: Leon Sorokin Date: Tue, 8 Jul 2025 13:24:03 -0500 Subject: [PATCH] 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 --- .../src/selectors/components.ts | 5 - .../Table/DataLinksActionsTooltip.tsx | 33 +++-- .../Table/TableNG/Cells/ActionsCell.tsx | 9 +- .../Table/TableNG/Cells/AutoCell.test.tsx | 97 -------------- .../Table/TableNG/Cells/AutoCell.tsx | 37 +----- .../Table/TableNG/Cells/BarGaugeCell.tsx | 75 ++++------- .../Table/TableNG/Cells/ImageCell.tsx | 37 +----- .../Table/TableNG/Cells/JSONCell.tsx | 37 +----- .../Table/TableNG/Cells/TableCellActions.tsx | 5 +- .../Table/TableNG/Cells/renderers.tsx | 15 +-- .../components/Table/TableNG/TableNG.test.tsx | 63 +++++++++- .../src/components/Table/TableNG/TableNG.tsx | 118 ++++++++++++------ .../src/components/Table/TableNG/hooks.ts | 10 +- .../src/components/Table/TableNG/types.ts | 33 +++-- .../src/components/Table/TableNG/utils.ts | 10 ++ .../grafana-ui/src/components/Table/types.ts | 2 +- .../grafana-ui/src/components/Table/utils.ts | 10 +- .../panel/table/table-new/TablePanel.tsx | 57 +++++---- 18 files changed, 291 insertions(+), 362 deletions(-) delete mode 100644 packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.test.tsx diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 0ff43b6807e..088caf6dfa1 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -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', diff --git a/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.tsx b/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.tsx index 1bd8774479b..5b2e28c646f 100644 --- a/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.tsx +++ b/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.tsx @@ -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 && ( - -
- - - -
-
- )} + +
+ + + +
+
); }; diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/ActionsCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/ActionsCell.tsx index 9616baa5a76..087efa47ecc 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/ActionsCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/ActionsCell.tsx @@ -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 (
- {actions && actions.map((action, i) => )} + {actions.map((action, i) => ( + + ))}
); }; diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.test.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.test.tsx deleted file mode 100644 index b65fa0e0297..00000000000 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.test.tsx +++ /dev/null @@ -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( - - ); - - 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( - - ); - - const cell = screen.getByTestId(selectors.components.TablePanel.autoCell); - await userEvent.click(cell); - - expect(screen.queryByTestId(selectors.components.DataLinksActionsTooltip.tooltipWrapper)).not.toBeInTheDocument(); - }); - }); -}); diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx index 3fdcfc85491..71293bbc06e 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx @@ -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(); - 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 -
- {shouldShowLink ? ( - renderSingleLink(links[0], formattedValue, getLinkStyle(styles, cellOptions)) - ) : shouldShowTooltip ? ( - setTooltipCoords(undefined)} - /> - ) : ( - formattedValue - )} +
+ {link == null ? formattedValue : renderSingleLink(link, formattedValue, getLinkStyle(styles, cellOptions))}
); } diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/BarGaugeCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/BarGaugeCell.tsx index 710a8c65726..69ebfac671c 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/BarGaugeCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/BarGaugeCell.tsx @@ -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(); - const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(links, actions); - const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined; - - const renderComponent = () => { - return ( - - ); - }; - - return ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions -
- {shouldShowLink ? ( - renderSingleLink(links[0], renderComponent()) - ) : shouldShowTooltip ? ( - setTooltipCoords(undefined)} - /> - ) : ( - renderComponent() - )} -
+ const barGaugeComponent = ( + ); + + const link = useSingleLink(field, rowIdx); + + return link == null ? barGaugeComponent : renderSingleLink(link, barGaugeComponent); }; diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx index bc62e266b12..e0c1050396a 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx @@ -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(); - 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 = {alt}; + const link = useSingleLink(field, rowIdx); - return ( - // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions -
- {shouldShowLink ? ( - renderSingleLink(links[0], img) - ) : shouldShowTooltip ? ( - setTooltipCoords(undefined)} - /> - ) : ( - img - )} -
- ); + return
{link == null ? img : renderSingleLink(link, img)}
; }; const getStyles = (theme: GrafanaTheme2, height: number, justifyContent: Property.JustifyContent) => ({ diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/JSONCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/JSONCell.tsx index 2506255cb03..dc195cbcd67 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/JSONCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/JSONCell.tsx @@ -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(); - 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 -
- {shouldShowLink ? ( - renderSingleLink(links[0], displayValue) - ) : shouldShowTooltip ? ( - setTooltipCoords(undefined)} - /> - ) : ( - displayValue - )} -
- ); + return
{link == null ? displayValue : renderSingleLink(link, displayValue)}
; }; const getStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent) => ({ diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx index eb67a369cb2..558d1c7978d 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellActions.tsx @@ -24,7 +24,10 @@ export function TableCellActions(props: TableCellActionsProps) { } = props; return ( -
+ // 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 +
ev.stopPropagation()}> {cellInspect && ( ( 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) => ( - + ); 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) => ; -const ACTIONS_RENDERER: TableCellRenderer = (props) => ; +const ACTIONS_RENDERER: TableCellRenderer = ({ field, rowIdx, getActions = () => [] }) => ( + +); const PILL_RENDERER: TableCellRenderer = (props) => ; diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.test.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.test.tsx index dd1d4364128..9dfe1a2719b 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.test.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.test.tsx @@ -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(); + + 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(); + + 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(); + }); + }); }); diff --git a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx index 5a0a2fdc036..aa35b99ae9c 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/TableNG.tsx @@ -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) => 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; + colsWithTooltip: Record; } - 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): 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 ( ): 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 && ( (); + return ( <> @@ -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) {
)} + {tooltipState && ( + setTooltipState(undefined)} + /> + )} + {isContextMenuOpen && ( ({ - 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', + }), + }, + }), + }; +}; diff --git a/packages/grafana-ui/src/components/Table/TableNG/hooks.ts b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts index 1540d1e0383..33c89162939 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/hooks.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/hooks.ts @@ -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]); +} diff --git a/packages/grafana-ui/src/components/Table/TableNG/types.ts b/packages/grafana-ui/src/components/Table/TableNG/types.ts index 2946923db8f..04caa62c85c 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/types.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/types.ts @@ -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> | 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 & { 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; diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.ts index 78f5442f234..90004837874 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.ts @@ -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 + ); +} diff --git a/packages/grafana-ui/src/components/Table/types.ts b/packages/grafana-ui/src/components/Table/types.ts index e2c5b5b8099..1d029ae7099 100644 --- a/packages/grafana-ui/src/components/Table/types.ts +++ b/packages/grafana-ui/src/components/Table/types.ts @@ -57,7 +57,7 @@ export interface TableCellProps extends CellProps { onCellFilterAdded?: TableFilterActionCallback; innerWidth: number; frame: DataFrame; - actions?: ActionModel[]; + actions?: ActionModel[]; // unused in NG setInspectCell?: TableInspectCellCallback; } diff --git a/packages/grafana-ui/src/components/Table/utils.ts b/packages/grafana-ui/src/components/Table/utils.ts index 16f5ccac9d2..77dc0cec078 100644 --- a/packages/grafana-ui/src/components/Table/utils.ts +++ b/packages/grafana-ui/src/components/Table/utils.ts @@ -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); diff --git a/public/app/plugins/panel/table/table-new/TablePanel.tsx b/public/app/plugins/panel/table/table-new/TablePanel.tsx index 903a74f65c3..d5bf34dd869 100644 --- a/public/app/plugins/panel/table/table-new/TablePanel.tsx +++ b/public/app/plugins/panel/table/table-new/TablePanel.tsx @@ -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 = ( ); @@ -159,28 +163,39 @@ const getCellActions = ( field: Field, rowIndex: number, replaceVariables: InterpolateFunction | undefined -) => { - const actions: Array> = []; - const actionLookup = new Set(); +): Array> => { + 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> = []; + const actionLookup = new Set(); + + actions.forEach((action) => { + const key = action.title; + + if (!actionLookup.has(key)) { + actionsOut.push(action); + actionLookup.add(key); + } + }); + + return actionsOut; } - }); + } - return actions; + return []; }; const tableStyles = {