diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 2ed224e222d..0cfe9907c6a 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -1141,6 +1141,16 @@ export const versionedComponents = { [MIN_GRAFANA_VERSION]: 'Data link', }, }, + DataLinksActionsTooltip: { + tooltipWrapper: { + '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/Cells/BarGaugeCell.tsx b/packages/grafana-ui/src/components/Table/Cells/BarGaugeCell.tsx index 3ab8121c8d2..95388046c3b 100644 --- a/packages/grafana-ui/src/components/Table/Cells/BarGaugeCell.tsx +++ b/packages/grafana-ui/src/components/Table/Cells/BarGaugeCell.tsx @@ -1,12 +1,18 @@ import { isFunction } from 'lodash'; +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 { DataLinksContextMenu, DataLinksContextMenuApi } from '../../DataLinks/DataLinksContextMenu'; +import { DataLinksActionsTooltip, renderSingleLink } from '../DataLinksActionsTooltip'; import { TableCellProps } from '../types'; -import { getAlignmentFactor, getCellOptions } from '../utils'; +import { + DataLinksActionsTooltipCoords, + getAlignmentFactor, + getCellOptions, + getDataLinksActionsTooltipUtils, +} from '../utils'; const defaultScale: ThresholdsConfig = { mode: ThresholdsMode.Absolute, @@ -54,12 +60,9 @@ export const BarGaugeCell = (props: TableCellProps) => { return field.getLinks({ valueRowIndex: row.index }); }; - const hasLinks = Boolean(getLinks().length); const alignmentFactors = getAlignmentFactor(field, displayValue, cell.row.index); - const renderComponent = (menuProps: DataLinksContextMenuApi) => { - const { openMenu, targetClassName } = menuProps; - + const renderComponent = () => { return ( { orientation={VizOrientation.Horizontal} theme={tableStyles.theme} alignmentFactors={alignmentFactors} - onClick={openMenu} - className={targetClassName} itemSpacing={1} lcdCellWidth={8} displayMode={barGaugeMode} @@ -81,14 +82,33 @@ export const BarGaugeCell = (props: TableCellProps) => { ); }; + const [tooltipCoords, setTooltipCoords] = useState(); + const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(getLinks()); + const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined; + + const links = getLinks(); + return ( -
- {hasLinks ? ( - - {(api) => renderComponent(api)} - + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions +
{ + setTooltipCoords({ clientX, clientY }); + }} + > + {shouldShowLink ? ( + renderSingleLink(links[0], renderComponent()) + ) : shouldShowTooltip ? ( + setTooltipCoords(undefined)} + /> ) : ( - renderComponent({}) + renderComponent() )}
); diff --git a/packages/grafana-ui/src/components/Table/Cells/DefaultCell.tsx b/packages/grafana-ui/src/components/Table/Cells/DefaultCell.tsx index 8dbfabc32f3..2b32fa77773 100644 --- a/packages/grafana-ui/src/components/Table/Cells/DefaultCell.tsx +++ b/packages/grafana-ui/src/components/Table/Cells/DefaultCell.tsx @@ -1,19 +1,21 @@ -import { cx } from '@emotion/css'; -import { ReactElement } from 'react'; +import { ReactElement, useState } from 'react'; import * as React from 'react'; import { DisplayValue, formattedValueToString } from '@grafana/data'; import { TableCellDisplayMode } from '@grafana/schema'; -import { useStyles2 } from '../../../themes'; import { getCellLinks } from '../../../utils'; -import { clearLinkButtonStyles } from '../../Button'; -import { DataLinksContextMenu } from '../../DataLinks/DataLinksContextMenu'; import { CellActions } from '../CellActions'; +import { DataLinksActionsTooltip, renderSingleLink } from '../DataLinksActionsTooltip'; import { TableCellInspectorMode } from '../TableCellInspector'; import { TableStyles } from '../TableRT/styles'; import { TableCellProps, CustomCellRendererProps, TableCellOptions } from '../types'; -import { getCellColors, getCellOptions } from '../utils'; +import { + DataLinksActionsTooltipCoords, + getCellColors, + getCellOptions, + getDataLinksActionsTooltipUtils, +} from '../utils'; export const DefaultCell = (props: TableCellProps) => { const { field, cell, tableStyles, row, cellProps, frame, rowStyled, rowExpanded, textWrapped, height } = props; @@ -23,9 +25,6 @@ export const DefaultCell = (props: TableCellProps) => { const showFilters = props.onCellFilterAdded && field.config.filterable; const showActions = (showFilters && cell.value !== undefined) || inspectEnabled; const cellOptions = getCellOptions(field); - const cellLinks = getCellLinks(field, row); - const hasLinks = cellLinks?.some((link) => link.href || link.onClick != null); - const clearButtonStyle = useStyles2(clearLinkButtonStyles); let value: string | ReactElement; const OG_TWEET_LENGTH = 140; // 🙏 @@ -76,28 +75,32 @@ export const DefaultCell = (props: TableCellProps) => { } const { key, ...rest } = cellProps; + const links = getCellLinks(field, row) || []; + + const [tooltipCoords, setTooltipCoords] = useState(); + const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(links); + const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined; return ( -
- {hasLinks ? ( - getCellLinks(field, row)?.filter((link) => link.href || link.onClick != null) || []} - > - {(api) => { - if (api.openMenu) { - return ( - - ); - } else { - return
{value}
; - } - }} -
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions +
{ + setTooltipCoords({ clientX, clientY }); + }} + > + {shouldShowLink ? ( + renderSingleLink(links[0], value, getLinkStyle(tableStyles, cellOptions)) + ) : shouldShowTooltip ? ( + setTooltipCoords(undefined)} + /> ) : isStringValue ? ( `${value}` ) : ( @@ -109,6 +112,14 @@ export const DefaultCell = (props: TableCellProps) => { ); }; +const getLinkStyle = (tableStyles: TableStyles, cellOptions: TableCellOptions) => { + if (cellOptions.type === TableCellDisplayMode.Auto) { + return tableStyles.cellLink; + } + + return tableStyles.cellLinkForColoredCell; +}; + function getCellStyle( tableStyles: TableStyles, cellOptions: TableCellOptions, @@ -131,7 +142,7 @@ function getCellStyle( bgColor = colors.bgColor; bgHoverColor = colors.bgHoverColor; - // If we have definied colors return those styles + // If we have defined colors return those styles // Otherwise we return default styles return tableStyles.buildCellContainerStyle( textColor, @@ -145,11 +156,3 @@ function getCellStyle( rowExpanded ); } - -function getLinkStyle(tableStyles: TableStyles, cellOptions: TableCellOptions, targetClassName: string | undefined) { - if (cellOptions.type === TableCellDisplayMode.Auto) { - return cx(tableStyles.cellLink, targetClassName); - } - - return cx(tableStyles.cellLinkForColoredCell, targetClassName); -} diff --git a/packages/grafana-ui/src/components/Table/Cells/ImageCell.tsx b/packages/grafana-ui/src/components/Table/Cells/ImageCell.tsx index 12616308f24..1f2b5cf2a88 100644 --- a/packages/grafana-ui/src/components/Table/Cells/ImageCell.tsx +++ b/packages/grafana-ui/src/components/Table/Cells/ImageCell.tsx @@ -1,9 +1,9 @@ -import * as React from 'react'; +import { useState } from 'react'; import { getCellLinks } from '../../../utils'; -import { DataLinksContextMenu } from '../../DataLinks/DataLinksContextMenu'; +import { DataLinksActionsTooltip, renderSingleLink } from '../DataLinksActionsTooltip'; import { TableCellDisplayMode, TableCellProps } from '../types'; -import { getCellOptions } from '../utils'; +import { DataLinksActionsTooltipCoords, getCellOptions, getDataLinksActionsTooltipUtils } from '../utils'; const DATALINKS_HEIGHT_OFFSET = 10; @@ -13,7 +13,12 @@ export const ImageCell = (props: TableCellProps) => { const { title, alt } = cellOptions.type === TableCellDisplayMode.Image ? cellOptions : { title: undefined, alt: undefined }; const displayValue = field.display!(cell.value); - const hasLinks = Boolean(getCellLinks(field, row)?.length); + + const links = getCellLinks(field, row) || []; + + const [tooltipCoords, setTooltipCoords] = useState(); + const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(links); + const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined; // The image element const img = ( @@ -27,36 +32,26 @@ export const ImageCell = (props: TableCellProps) => { ); return ( -
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions +
{ + setTooltipCoords({ clientX, clientY }); + }} + > {/* If there are data links/actions, we render them with image */} {/* Otherwise we simply render the image */} - {hasLinks ? ( - getCellLinks(field, row) || []} - > - {(api) => { - if (api.openMenu) { - return ( -
{ - if (e.key === 'Enter' && api.openMenu) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions - api.openMenu(e as any); - } - }} - > - {img} -
- ); - } else { - return img; - } - }} -
+ {shouldShowLink ? ( + renderSingleLink(links[0], img) + ) : shouldShowTooltip ? ( + setTooltipCoords(undefined)} + /> ) : ( img )} diff --git a/packages/grafana-ui/src/components/Table/Cells/JSONViewCell.tsx b/packages/grafana-ui/src/components/Table/Cells/JSONViewCell.tsx index de62a86ddd9..591bcb66dac 100644 --- a/packages/grafana-ui/src/components/Table/Cells/JSONViewCell.tsx +++ b/packages/grafana-ui/src/components/Table/Cells/JSONViewCell.tsx @@ -1,13 +1,13 @@ import { css, cx } from '@emotion/css'; import { isString } from 'lodash'; +import { useState } from 'react'; -import { useStyles2 } from '../../../themes'; import { getCellLinks } from '../../../utils'; -import { Button, clearLinkButtonStyles } from '../../Button'; -import { DataLinksContextMenu } from '../../DataLinks/DataLinksContextMenu'; import { CellActions } from '../CellActions'; +import { DataLinksActionsTooltip, renderSingleLink } from '../DataLinksActionsTooltip'; import { TableCellInspectorMode } from '../TableCellInspector'; import { TableCellProps } from '../types'; +import { DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils } from '../utils'; export function JSONViewCell(props: TableCellProps): JSX.Element { const { cell, tableStyles, cellProps, field, row } = props; @@ -28,26 +28,28 @@ export function JSONViewCell(props: TableCellProps): JSX.Element { displayValue = JSON.stringify(value, null, ' '); } - const hasLinks = Boolean(getCellLinks(field, row)?.length); - const clearButtonStyle = useStyles2(clearLinkButtonStyles); + const links = getCellLinks(field, row) || []; + + const [tooltipCoords, setTooltipCoords] = useState(); + const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(links); + const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined; return (
-
- {hasLinks ? ( - getCellLinks(field, row) || []}> - {(api) => { - if (api.openMenu) { - return ( - - ); - } else { - return <>{displayValue}; - } - }} - + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */} +
setTooltipCoords({ clientX, clientY })} + > + {shouldShowLink ? ( + renderSingleLink(links[0], displayValue) + ) : shouldShowTooltip ? ( + setTooltipCoords(undefined)} + /> ) : (
{displayValue}
)} diff --git a/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.test.tsx b/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.test.tsx new file mode 100644 index 00000000000..53bb472f54b --- /dev/null +++ b/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.test.tsx @@ -0,0 +1,95 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { LinkModel, ActionModel } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; + +import { DataLinksActionsTooltip } from './DataLinksActionsTooltip'; + +describe('DataLinksActionsTooltip', () => { + const mockCoords = { clientX: 100, clientY: 100 }; + const mockLink: LinkModel = { + href: 'http://link1.com', + title: 'Data Link1', + target: '_blank', + onClick: jest.fn(), + origin: { ref: { uid: 'test' } }, + }; + + const mockAction: ActionModel = { + title: 'Action1', + onClick: jest.fn(), + style: { backgroundColor: '#ff0000' }, + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should not render when there is only one link', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('should render tooltip with multiple links', async () => { + const multipleLinks = [mockLink, { ...mockLink, title: 'Data Link2', href: 'http://link2.com' }]; + render(); + + expect(screen.getByTestId(selectors.components.DataLinksActionsTooltip.tooltipWrapper)).toBeInTheDocument(); + expect(await screen.findByText('Data Link1')).toBeInTheDocument(); + + const link = screen.getByText('Data Link1'); + await userEvent.click(link); + expect(mockLink.onClick).toHaveBeenCalledTimes(1); + }); + + it('should handle links click events', async () => { + const mockLinks = [mockLink, { ...mockLink, title: 'Data Link2', href: 'http://link2.com' }]; + + render(); + + const link = screen.getByText('Data Link1'); + await userEvent.click(link); + expect(mockLink.onClick).toHaveBeenCalledTimes(1); + }); + + it('should render when there is only one action', () => { + const { container } = render(); + expect(container).toBeInTheDocument(); + }); + + it('should render tooltip with actions', () => { + const mockActions = [mockAction, { ...mockAction, title: 'Action2' }]; + + render(); + + expect(screen.getByTestId(selectors.components.DataLinksActionsTooltip.tooltipWrapper)).toBeInTheDocument(); + + // Action button should be rendered + const actionButton = screen.getByText('Action1'); + expect(actionButton).toBeInTheDocument(); + }); + + it('should call onTooltipClose when tooltip is dismissed', async () => { + const onTooltipClose = jest.fn(); + render( + + ); + + // click outside the tooltip + await userEvent.click(document.body); + + expect(onTooltipClose).toHaveBeenCalledTimes(1); + }); + + it('should render custom value', () => { + const customValue =
Custom Value
; + render(); + + expect(screen.getByTestId('custom-value')).toBeInTheDocument(); + }); +}); diff --git a/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.tsx b/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.tsx new file mode 100644 index 00000000000..e918daffed1 --- /dev/null +++ b/packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.tsx @@ -0,0 +1,140 @@ +import { css } from '@emotion/css'; +import { flip, shift, useDismiss, useFloating, useInteractions } from '@floating-ui/react'; +import { ReactElement, useMemo } from 'react'; +import * as React from 'react'; + +import { ActionModel, GrafanaTheme2, LinkModel } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; + +import { useStyles2 } from '../../themes'; +import { Portal } from '../Portal/Portal'; +import { VizTooltipFooter } from '../VizTooltip/VizTooltipFooter'; +import { VizTooltipWrapper } from '../VizTooltip/VizTooltipWrapper'; + +import { DataLinksActionsTooltipCoords } from './utils'; + +interface Props { + links: LinkModel[]; + actions?: ActionModel[]; + value?: string | ReactElement; + coords: DataLinksActionsTooltipCoords; + onTooltipClose?: () => void; +} + +/** + * + * @internal + */ +export const DataLinksActionsTooltip = ({ links, actions, value, coords, onTooltipClose }: Props) => { + const styles = useStyles2(getStyles); + + // the order of middleware is important! + const middleware = [ + flip({ + fallbackAxisSideDirection: 'end', + // see https://floating-ui.com/docs/flip#combining-with-shift + crossAxis: false, + boundary: document.body, + }), + shift(), + ]; + + const virtual = useMemo(() => { + const { clientX, clientY } = coords; + + // https://floating-ui.com/docs/virtual-elements + return { + getBoundingClientRect() { + return { + width: 0, + height: 0, + x: clientX, + y: clientY, + top: clientY, + left: clientX, + right: clientX, + bottom: clientY, + }; + }, + }; + }, [coords]); + + const refCallback = (el: HTMLDivElement) => { + refs.setFloating(el); + refs.setReference(virtual); + }; + + const { context, refs, floatingStyles } = useFloating({ + open: true, + placement: 'right-start', + onOpenChange: onTooltipClose, + middleware, + // whileElementsMounted: autoUpdate, + }); + + const dismiss = useDismiss(context); + + const hasMultipleLinksOrActions = links.length > 1 || Boolean(actions?.length); + + const { getFloatingProps, getReferenceProps } = useInteractions([dismiss]); + + if (links.length === 0 && !Boolean(actions?.length)) { + return null; + } + + return ( + <> + {value} + {hasMultipleLinksOrActions && ( + +
e.stopPropagation() })} + {...getFloatingProps()} + style={floatingStyles} + className={styles.tooltipWrapper} + data-testid={selectors.components.DataLinksActionsTooltip.tooltipWrapper} + > + + + +
+
+ )} + + ); +}; + +export const renderSingleLink = ( + link: LinkModel, + children: string | React.JSX.Element, + className?: string +): React.JSX.Element => { + return ( + + {children} + + ); +}; + +const getStyles = (theme: GrafanaTheme2) => { + return { + tooltipWrapper: css({ + zIndex: theme.zIndex.portal, + whiteSpace: 'pre', + borderRadius: theme.shape.radius.default, + background: theme.colors.background.primary, + border: `1px solid ${theme.colors.border.weak}`, + boxShadow: theme.shadows.z3, + userSelect: 'text', + fontSize: theme.typography.bodySmall.fontSize, + }), + }; +}; 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 index f041887502b..1a1f8ec37ea 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.test.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.test.tsx @@ -1,6 +1,8 @@ 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 '@grafana/schema'; import AutoCell from './AutoCell'; @@ -32,7 +34,7 @@ describe('AutoCell', () => { }; }; - it('shows multiple datalinks in a context menu behind a button', () => { + 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, @@ -53,11 +55,17 @@ describe('AutoCell', () => { cellOptions={{ type: TableCellDisplayMode.Auto }} /> ); - const submitButton = screen.getByRole('button'); - expect(submitButton).toBeInTheDocument(); + + 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 button for menu for multiple links if one is invalid', () => { + 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, @@ -78,8 +86,11 @@ describe('AutoCell', () => { cellOptions={{ type: TableCellDisplayMode.Auto }} /> ); - const submitButton = screen.queryByRole('button'); - expect(submitButton).not.toBeInTheDocument(); + + 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 d40e363d569..5667ba7db24 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx @@ -1,45 +1,46 @@ -import { css, cx } from '@emotion/css'; +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 { TableCellDisplayMode, TableCellOptions } from '@grafana/schema'; import { useStyles2 } from '../../../../themes'; -import { clearLinkButtonStyles } from '../../../Button'; -import { DataLinksContextMenu } from '../../../DataLinks/DataLinksContextMenu'; +import { DataLinksActionsTooltip, renderSingleLink } from '../../DataLinksActionsTooltip'; +import { DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils } from '../../utils'; import { AutoCellProps } from '../types'; import { getCellLinks } from '../utils'; -export default function AutoCell({ value, field, justifyContent, rowIdx, cellOptions }: AutoCellProps) { +export default function AutoCell({ value, field, justifyContent, rowIdx, cellOptions, actions }: AutoCellProps) { const styles = useStyles2(getStyles, justifyContent); const displayValue = field.display!(value); const formattedValue = formattedValueToString(displayValue); - const cellLinks = getCellLinks(field, rowIdx); - const hasLinks = cellLinks?.some((link) => link.href || link.onClick != null); - const clearButtonStyle = useStyles2(clearLinkButtonStyles); + const links = getCellLinks(field, rowIdx) || []; + + const [tooltipCoords, setTooltipCoords] = useState(); + const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(links, actions); + const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined; return ( -
- {hasLinks ? ( - getCellLinks(field, rowIdx)?.filter((link) => link.href || link.onClick != null) || []} - > - {(api) => { - if (api.openMenu) { - return ( - - ); - } else { - return
{formattedValue}
; - } - }} -
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions +
setTooltipCoords({ clientX, clientY })} + style={{ cursor: hasMultipleLinksOrActions ? 'context-menu' : 'auto' }} + data-testid={selectors.components.TablePanel.autoCell} + > + {shouldShowLink ? ( + renderSingleLink(links[0], formattedValue, getLinkStyle(styles, cellOptions)) + ) : shouldShowTooltip ? ( + setTooltipCoords(undefined)} + /> ) : ( formattedValue )} @@ -47,16 +48,12 @@ export default function AutoCell({ value, field, justifyContent, rowIdx, cellOpt ); } -const getLinkStyle = ( - styles: ReturnType, - cellOptions: TableCellOptions, - targetClassName: string | undefined -) => { +const getLinkStyle = (styles: ReturnType, cellOptions: TableCellOptions) => { if (cellOptions.type === TableCellDisplayMode.Auto) { - return cx(styles.linkCell, targetClassName); + return styles.linkCell; } - return cx(styles.cellLinkForColoredCell, targetClassName); + return styles.cellLinkForColoredCell; }; const getStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent | undefined) => ({ @@ -83,12 +80,9 @@ const getStyles = (theme: GrafanaTheme2, justifyContent: Property.JustifyContent textOverflow: 'ellipsis', userSelect: 'text', whiteSpace: 'nowrap', - color: theme.colors.text.link, + color: `${theme.colors.text.link} !important`, fontWeight: theme.typography.fontWeightMedium, paddingRight: theme.spacing(1.5), - a: { - color: theme.colors.text.link, - }, '&:hover': { textDecoration: 'underline', color: theme.colors.text.link, 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 d18c4631ae0..b386ef29879 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/BarGaugeCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/BarGaugeCell.tsx @@ -1,8 +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 { DataLinksContextMenu, DataLinksContextMenuApi } from '../../../DataLinks/DataLinksContextMenu'; +import { DataLinksActionsTooltip, renderSingleLink } from '../../DataLinksActionsTooltip'; +import { DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils } from '../../utils'; import { BarGaugeCellProps } from '../types'; import { extractPixelValue, getCellOptions, getAlignmentFactor, getCellLinks } from '../utils'; @@ -20,7 +23,7 @@ const defaultScale: ThresholdsConfig = { ], }; -export const BarGaugeCell = ({ value, field, theme, height, width, rowIdx }: BarGaugeCellProps) => { +export const BarGaugeCell = ({ value, field, theme, height, width, rowIdx, actions }: BarGaugeCellProps) => { const displayValue = field.display!(value); const cellOptions = getCellOptions(field); const heightOffset = extractPixelValue(theme.spacing(1)); @@ -44,13 +47,14 @@ export const BarGaugeCell = ({ value, field, theme, height, width, rowIdx }: Bar cellOptions.valueDisplayMode !== undefined ? cellOptions.valueDisplayMode : BarGaugeValueMode.Text; } - const hasLinks = Boolean(getCellLinks(field, rowIdx)?.length); - const alignmentFactors = getAlignmentFactor(field, displayValue, rowIdx!); + const links = getCellLinks(field, rowIdx) || []; - const renderComponent = (menuProps: DataLinksContextMenuApi) => { - const { openMenu } = menuProps; + const [tooltipCoords, setTooltipCoords] = useState(); + const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(links, actions); + const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined; + const renderComponent = () => { return ( - {hasLinks ? ( - getCellLinks(field, rowIdx) || []} - style={{ display: 'flex', width: '100%' }} - > - {(api) => renderComponent(api)} - + // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions +
setTooltipCoords({ clientX, clientY })} + > + {shouldShowLink ? ( + renderSingleLink(links[0], renderComponent()) + ) : shouldShowTooltip ? ( + setTooltipCoords(undefined)} + /> ) : ( - renderComponent({}) + renderComponent() )} - +
); }; diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/DataLinksCell.test.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/DataLinksCell.test.tsx index a3584e48a98..ca68fbeb189 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/DataLinksCell.test.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/DataLinksCell.test.tsx @@ -70,7 +70,7 @@ describe('DataLinksCell', () => { expect(screen.getByRole('link', { name: link.title })).toHaveAttribute('href', link.href); } else { expect(screen.queryByRole('link', { name: link.title })).not.toBeInTheDocument(); - expect(screen.getByText(link.title)).toBeInTheDocument(); + expect(screen.queryByText(link.title)).not.toBeInTheDocument(); } }); }); 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 1165d960019..805259bae62 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx @@ -1,21 +1,26 @@ import { css } from '@emotion/css'; import { Property } from 'csstype'; -import * as React from 'react'; +import { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { TableCellDisplayMode } from '@grafana/schema'; import { useStyles2 } from '../../../../themes'; -import { DataLinksContextMenu } from '../../../DataLinks/DataLinksContextMenu'; +import { DataLinksActionsTooltip, renderSingleLink } from '../../DataLinksActionsTooltip'; +import { DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils } from '../../utils'; import { ImageCellProps } from '../types'; import { getCellLinks } from '../utils'; const DATALINKS_HEIGHT_OFFSET = 10; -export const ImageCell = ({ cellOptions, field, height, justifyContent, value, rowIdx }: ImageCellProps) => { +export const ImageCell = ({ cellOptions, field, height, justifyContent, value, rowIdx, actions }: ImageCellProps) => { const calculatedHeight = height - DATALINKS_HEIGHT_OFFSET; const styles = useStyles2(getStyles, calculatedHeight, justifyContent); - const hasLinks = Boolean(getCellLinks(field, rowIdx)?.length); + 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 } = @@ -23,33 +28,25 @@ export const ImageCell = ({ cellOptions, field, height, justifyContent, value, r const img = {alt}; - // TODO: Implement actions return ( -
- {hasLinks ? ( - getCellLinks(field, rowIdx) || []}> - {(api) => { - if (api.openMenu) { - return ( -
{ - if (e.key === 'Enter' && api.openMenu) { - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/consistent-type-assertions - api.openMenu(e as any); - } - }} - > - {img} -
- ); - } else { - return img; - } - }} -
+ // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions +
{ + setTooltipCoords({ clientX, clientY }); + }} + > + {shouldShowLink ? ( + renderSingleLink(links[0], img) + ) : shouldShowTooltip ? ( + setTooltipCoords(undefined)} + /> ) : ( img )} 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 d207d710448..8098c9e6afc 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/JSONCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/JSONCell.tsx @@ -1,17 +1,17 @@ -import { css, cx } from '@emotion/css'; +import { css } from '@emotion/css'; import { Property } from 'csstype'; +import { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { useStyles2 } from '../../../../themes'; -import { Button, clearLinkButtonStyles } from '../../../Button'; -import { DataLinksContextMenu } from '../../../DataLinks/DataLinksContextMenu'; +import { DataLinksActionsTooltip, renderSingleLink } from '../../DataLinksActionsTooltip'; +import { DataLinksActionsTooltipCoords, getDataLinksActionsTooltipUtils } from '../../utils'; import { JSONCellProps } from '../types'; import { getCellLinks } from '../utils'; -export const JSONCell = ({ value, justifyContent, field, rowIdx }: JSONCellProps) => { +export const JSONCell = ({ value, justifyContent, field, rowIdx, actions }: JSONCellProps) => { const styles = useStyles2(getStyles, justifyContent); - const clearButtonStyle = useStyles2(clearLinkButtonStyles); let displayValue = value; @@ -33,25 +33,29 @@ export const JSONCell = ({ value, justifyContent, field, rowIdx }: JSONCellProps } } - const hasLinks = Boolean(getCellLinks(field, rowIdx)?.length); + const links = getCellLinks(field, rowIdx) || []; + + const [tooltipCoords, setTooltipCoords] = useState(); + const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(links, actions); + const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined; - // TODO: Implement actions return ( -
- {hasLinks ? ( - getCellLinks(field, rowIdx) || []}> - {(api) => { - if (api.openMenu) { - return ( - - ); - } else { - return <>{displayValue}; - } - }} - + // eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions +
setTooltipCoords({ clientX, clientY })} + style={{ cursor: hasMultipleLinksOrActions ? 'context-menu' : 'auto' }} + > + {shouldShowLink ? ( + renderSingleLink(links[0], displayValue) + ) : shouldShowTooltip ? ( + setTooltipCoords(undefined)} + /> ) : ( displayValue )} diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/SparklineCell.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/SparklineCell.tsx index 5913b2cb64d..02cfff3e974 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/SparklineCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/SparklineCell.tsx @@ -100,6 +100,7 @@ export const SparklineCell = (props: SparklineCellProps) => { width: `${valueWidth - theme.spacing.gridSize}px`, textAlign: 'right', marginRight: theme.spacing(1), + marginLeft: theme.spacing(1), }} className={styles.valueContainer} value={displayValue} diff --git a/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx b/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx index ea5e6a25b29..f50c9a6d5e4 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx +++ b/packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx @@ -109,13 +109,22 @@ export function TableCellNG(props: TableCellNGProps) { case TableCellDisplayMode.BasicGauge: case TableCellDisplayMode.GradientGauge: case TableCellDisplayMode.LcdGauge: - cell = ; + cell = ( + + ); break; case TableCellDisplayMode.Image: - cell = ; + cell = ; break; case TableCellDisplayMode.JSONView: - cell = ; + cell = ; break; case TableCellDisplayMode.DataLinks: cell = ; @@ -137,12 +146,12 @@ export function TableCellNG(props: TableCellNGProps) { if (isDataFrame(firstValue) && isTimeSeriesFrame(firstValue)) { cell = ; } else { - cell = ; + cell = ; } } else if (field.type === FieldType.other) { - cell = ; + cell = ; } else { - cell = ; + cell = ; } break; } diff --git a/packages/grafana-ui/src/components/Table/TableNG/types.ts b/packages/grafana-ui/src/components/Table/TableNG/types.ts index 473f535f609..140c91f9e40 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/types.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/types.ts @@ -179,7 +179,7 @@ export interface SparklineCellProps { width: number; } -export interface BarGaugeCellProps { +export interface BarGaugeCellProps extends ActionCellProps { field: Field; height: number; rowIdx: number; @@ -189,7 +189,7 @@ export interface BarGaugeCellProps { timeRange: TimeRange; } -export interface ImageCellProps { +export interface ImageCellProps extends ActionCellProps { cellOptions: TableCellOptions; field: Field; height: number; @@ -198,7 +198,7 @@ export interface ImageCellProps { rowIdx: number; } -export interface JSONCellProps { +export interface JSONCellProps extends ActionCellProps { justifyContent: Property.JustifyContent; value: TableCellValue; field: Field; @@ -226,7 +226,7 @@ export interface CellColors { bgHoverColor?: string; } -export interface AutoCellProps { +export interface AutoCellProps extends ActionCellProps { value: TableCellValue; field: Field; justifyContent: Property.JustifyContent; diff --git a/packages/grafana-ui/src/components/Table/TableNG/utils.ts b/packages/grafana-ui/src/components/Table/TableNG/utils.ts index 190644304d0..8491e867b6c 100644 --- a/packages/grafana-ui/src/components/Table/TableNG/utils.ts +++ b/packages/grafana-ui/src/components/Table/TableNG/utils.ts @@ -423,7 +423,7 @@ export const getCellLinks = (field: Field, rowIdx: number) => { } } - return links; + return links.filter((link) => link.href || link.onClick != null); }; /* ----------------------------- Data grid sorting ---------------------------- */ diff --git a/packages/grafana-ui/src/components/Table/TableRT/styles.ts b/packages/grafana-ui/src/components/Table/TableRT/styles.ts index 24fcf5e17cc..3157e2934f2 100644 --- a/packages/grafana-ui/src/components/Table/TableRT/styles.ts +++ b/packages/grafana-ui/src/components/Table/TableRT/styles.ts @@ -184,7 +184,7 @@ export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCell textOverflow: 'ellipsis', userSelect: 'text', whiteSpace: 'nowrap', - color: theme.colors.text.link, + color: `${theme.colors.text.link} !important`, fontWeight: theme.typography.fontWeightMedium, paddingRight: theme.spacing(1.5), '&:hover': { diff --git a/packages/grafana-ui/src/components/Table/utils.ts b/packages/grafana-ui/src/components/Table/utils.ts index 4031d019e87..ae1db673ddf 100644 --- a/packages/grafana-ui/src/components/Table/utils.ts +++ b/packages/grafana-ui/src/components/Table/utils.ts @@ -5,6 +5,7 @@ import { HeaderGroup, Row } from 'react-table'; import tinycolor from 'tinycolor2'; import { + ActionModel, DataFrame, DisplayValue, DisplayValueAlignmentFactors, @@ -19,6 +20,7 @@ import { isDataFrame, isDataFrameWithValue, isTimeSeriesFrame, + LinkModel, reduceField, SelectableValue, } from '@grafana/data'; @@ -762,3 +764,15 @@ export function guessLongestField(fieldConfig: FieldConfigSource, data: DataFram return longestField; } + +export type DataLinksActionsTooltipCoords = { + clientX: number; + clientY: number; +}; + +export const getDataLinksActionsTooltipUtils = (links: LinkModel[], actions?: ActionModel[]) => { + const hasMultipleLinksOrActions = links.length > 1 || Boolean(actions?.length); + const shouldShowLink = links.length === 1 && !Boolean(actions?.length); + + return { shouldShowLink, hasMultipleLinksOrActions }; +}; diff --git a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx index fc666c36cb5..cb4aa330a63 100644 --- a/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx +++ b/packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx @@ -117,7 +117,7 @@ const getStyles = (theme: GrafanaTheme2) => ({ textDecoration: 'underline', background: 'none', }, - + padding: 0, height: 'auto', '& span': { whiteSpace: 'normal', diff --git a/packages/grafana-ui/src/utils/table.ts b/packages/grafana-ui/src/utils/table.ts index cc6ab27b1fe..506e873144a 100644 --- a/packages/grafana-ui/src/utils/table.ts +++ b/packages/grafana-ui/src/utils/table.ts @@ -6,7 +6,7 @@ import { Field, LinkModel } from '@grafana/data'; * @internal */ export const getCellLinks = (field: Field, row: Row) => { - let links: Array> | undefined; + let links: Array> | undefined; if (field.getLinks) { links = field.getLinks({ valueRowIndex: row.index, @@ -34,5 +34,5 @@ export const getCellLinks = (field: Field, row: Row) => { } } - return links; + return links.filter((link) => link.href || link.onClick != null); };