TableNG: Data links and actions tooltip (#101015)

pull/105580/head^2
Adela Almasan 2 months ago committed by GitHub
parent b16f34fb93
commit cab652c4d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 10
      packages/grafana-e2e-selectors/src/selectors/components.ts
  2. 48
      packages/grafana-ui/src/components/Table/Cells/BarGaugeCell.tsx
  3. 77
      packages/grafana-ui/src/components/Table/Cells/DefaultCell.tsx
  4. 57
      packages/grafana-ui/src/components/Table/Cells/ImageCell.tsx
  5. 42
      packages/grafana-ui/src/components/Table/Cells/JSONViewCell.tsx
  6. 95
      packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.test.tsx
  7. 140
      packages/grafana-ui/src/components/Table/DataLinksActionsTooltip.tsx
  8. 23
      packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.test.tsx
  9. 68
      packages/grafana-ui/src/components/Table/TableNG/Cells/AutoCell.tsx
  10. 43
      packages/grafana-ui/src/components/Table/TableNG/Cells/BarGaugeCell.tsx
  11. 2
      packages/grafana-ui/src/components/Table/TableNG/Cells/DataLinksCell.test.tsx
  12. 51
      packages/grafana-ui/src/components/Table/TableNG/Cells/ImageCell.tsx
  13. 48
      packages/grafana-ui/src/components/Table/TableNG/Cells/JSONCell.tsx
  14. 1
      packages/grafana-ui/src/components/Table/TableNG/Cells/SparklineCell.tsx
  15. 21
      packages/grafana-ui/src/components/Table/TableNG/Cells/TableCellNG.tsx
  16. 8
      packages/grafana-ui/src/components/Table/TableNG/types.ts
  17. 2
      packages/grafana-ui/src/components/Table/TableNG/utils.ts
  18. 2
      packages/grafana-ui/src/components/Table/TableRT/styles.ts
  19. 14
      packages/grafana-ui/src/components/Table/utils.ts
  20. 2
      packages/grafana-ui/src/components/VizTooltip/VizTooltipFooter.tsx
  21. 4
      packages/grafana-ui/src/utils/table.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',

@ -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 (
<BarGauge
width={innerWidth}
@ -71,8 +74,6 @@ export const BarGaugeCell = (props: TableCellProps) => {
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<DataLinksActionsTooltipCoords>();
const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(getLinks());
const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined;
const links = getLinks();
return (
<div {...cellProps} className={tableStyles.cellContainer}>
{hasLinks ? (
<DataLinksContextMenu links={getLinks} style={{ display: 'flex', width: '100%' }}>
{(api) => renderComponent(api)}
</DataLinksContextMenu>
// eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions
<div
{...cellProps}
className={tableStyles.cellContainer}
style={{ ...cellProps.style, cursor: hasMultipleLinksOrActions ? 'context-menu' : 'auto' }}
onClick={({ clientX, clientY }) => {
setTooltipCoords({ clientX, clientY });
}}
>
{shouldShowLink ? (
renderSingleLink(links[0], renderComponent())
) : shouldShowTooltip ? (
<DataLinksActionsTooltip
links={links}
value={renderComponent()}
coords={tooltipCoords}
onTooltipClose={() => setTooltipCoords(undefined)}
/>
) : (
renderComponent({})
renderComponent()
)}
</div>
);

@ -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<DataLinksActionsTooltipCoords>();
const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(links);
const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined;
return (
<div key={key} {...rest} className={cellStyle}>
{hasLinks ? (
<DataLinksContextMenu
links={() => getCellLinks(field, row)?.filter((link) => link.href || link.onClick != null) || []}
>
{(api) => {
if (api.openMenu) {
return (
<button
className={cx(clearButtonStyle, getLinkStyle(tableStyles, cellOptions, api.targetClassName))}
onClick={api.openMenu}
>
{value}
</button>
);
} else {
return <div className={getLinkStyle(tableStyles, cellOptions, api.targetClassName)}>{value}</div>;
}
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
<div
key={key}
{...rest}
className={cellStyle}
style={{ ...cellProps.style, cursor: hasMultipleLinksOrActions ? 'context-menu' : 'auto' }}
onClick={({ clientX, clientY }) => {
setTooltipCoords({ clientX, clientY });
}}
</DataLinksContextMenu>
>
{shouldShowLink ? (
renderSingleLink(links[0], value, getLinkStyle(tableStyles, cellOptions))
) : shouldShowTooltip ? (
<DataLinksActionsTooltip
links={links}
value={value}
coords={tooltipCoords}
onTooltipClose={() => 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);
}

@ -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<DataLinksActionsTooltipCoords>();
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 (
<div {...cellProps} className={tableStyles.cellContainer}>
{/* If there are data links/actions, we render them with image */}
{/* Otherwise we simply render the image */}
{hasLinks ? (
<DataLinksContextMenu
style={{ height: tableStyles.cellHeight - DATALINKS_HEIGHT_OFFSET, width: 'auto' }}
links={() => getCellLinks(field, row) || []}
>
{(api) => {
if (api.openMenu) {
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
<div
onClick={api.openMenu}
role="button"
tabIndex={0}
onKeyDown={(e: React.KeyboardEvent) => {
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);
}
{...cellProps}
className={tableStyles.cellContainer}
style={{ ...cellProps.style, cursor: hasMultipleLinksOrActions ? 'context-menu' : 'auto' }}
onClick={({ clientX, clientY }) => {
setTooltipCoords({ clientX, clientY });
}}
>
{img}
</div>
);
} else {
return img;
}
}}
</DataLinksContextMenu>
{/* If there are data links/actions, we render them with image */}
{/* Otherwise we simply render the image */}
{shouldShowLink ? (
renderSingleLink(links[0], img)
) : shouldShowTooltip ? (
<DataLinksActionsTooltip
links={links}
value={img}
coords={tooltipCoords}
onTooltipClose={() => setTooltipCoords(undefined)}
/>
) : (
img
)}

@ -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<DataLinksActionsTooltipCoords>();
const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(links);
const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined;
return (
<div {...cellProps} className={inspectEnabled ? tableStyles.cellContainerNoOverflow : tableStyles.cellContainer}>
<div className={cx(tableStyles.cellText, txt)}>
{hasLinks ? (
<DataLinksContextMenu links={() => getCellLinks(field, row) || []}>
{(api) => {
if (api.openMenu) {
return (
<Button className={cx(clearButtonStyle)} onClick={api.openMenu}>
{displayValue}
</Button>
);
} else {
return <>{displayValue}</>;
}
}}
</DataLinksContextMenu>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions */}
<div
className={cx(tableStyles.cellText, txt)}
onClick={({ clientX, clientY }) => setTooltipCoords({ clientX, clientY })}
>
{shouldShowLink ? (
renderSingleLink(links[0], displayValue)
) : shouldShowTooltip ? (
<DataLinksActionsTooltip
links={links}
value={displayValue}
coords={tooltipCoords}
onTooltipClose={() => setTooltipCoords(undefined)}
/>
) : (
<div className={tableStyles.cellText}>{displayValue}</div>
)}

@ -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(<DataLinksActionsTooltip links={[mockLink]} actions={[]} coords={mockCoords} />);
expect(container).toBeEmptyDOMElement();
});
it('should render tooltip with multiple links', async () => {
const multipleLinks = [mockLink, { ...mockLink, title: 'Data Link2', href: 'http://link2.com' }];
render(<DataLinksActionsTooltip links={multipleLinks} coords={mockCoords} />);
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(<DataLinksActionsTooltip links={mockLinks} coords={mockCoords} />);
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(<DataLinksActionsTooltip links={[]} actions={[mockAction]} coords={mockCoords} />);
expect(container).toBeInTheDocument();
});
it('should render tooltip with actions', () => {
const mockActions = [mockAction, { ...mockAction, title: 'Action2' }];
render(<DataLinksActionsTooltip links={[]} actions={mockActions} coords={mockCoords} />);
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(
<DataLinksActionsTooltip
links={[mockLink, { ...mockLink, title: 'Data Link2', href: 'http://link2.com' }]}
coords={mockCoords}
onTooltipClose={onTooltipClose}
/>
);
// click outside the tooltip
await userEvent.click(document.body);
expect(onTooltipClose).toHaveBeenCalledTimes(1);
});
it('should render custom value', () => {
const customValue = <div data-testid="custom-value">Custom Value</div>;
render(<DataLinksActionsTooltip links={[mockLink]} coords={mockCoords} value={customValue} />);
expect(screen.getByTestId('custom-value')).toBeInTheDocument();
});
});

@ -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 && (
<Portal>
<div
ref={refCallback}
{...getReferenceProps({ onClick: (e) => e.stopPropagation() })}
{...getFloatingProps()}
style={floatingStyles}
className={styles.tooltipWrapper}
data-testid={selectors.components.DataLinksActionsTooltip.tooltipWrapper}
>
<VizTooltipWrapper>
<VizTooltipFooter dataLinks={links} actions={actions} />
</VizTooltipWrapper>
</div>
</Portal>
)}
</>
);
};
export const renderSingleLink = (
link: LinkModel,
children: string | React.JSX.Element,
className?: string
): React.JSX.Element => {
return (
<a
href={link.href}
onClick={link.onClick}
target={link.target}
title={link.title}
data-testid={selectors.components.DataLinksContextMenu.singleLink}
className={className}
>
{children}
</a>
);
};
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,
}),
};
};

@ -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();
});
});
});

@ -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<DataLinksActionsTooltipCoords>();
const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(links, actions);
const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined;
return (
<div className={styles.cell}>
{hasLinks ? (
<DataLinksContextMenu
links={() => getCellLinks(field, rowIdx)?.filter((link) => link.href || link.onClick != null) || []}
>
{(api) => {
if (api.openMenu) {
return (
<button
className={cx(clearButtonStyle, getLinkStyle(styles, cellOptions, api.targetClassName))}
onClick={api.openMenu}
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
<div
className={styles.cell}
onClick={({ clientX, clientY }) => setTooltipCoords({ clientX, clientY })}
style={{ cursor: hasMultipleLinksOrActions ? 'context-menu' : 'auto' }}
data-testid={selectors.components.TablePanel.autoCell}
>
{formattedValue}
</button>
);
} else {
return <div className={getLinkStyle(styles, cellOptions, api.targetClassName)}>{formattedValue}</div>;
}
}}
</DataLinksContextMenu>
{shouldShowLink ? (
renderSingleLink(links[0], formattedValue, getLinkStyle(styles, cellOptions))
) : shouldShowTooltip ? (
<DataLinksActionsTooltip
links={links}
actions={actions}
value={formattedValue}
coords={tooltipCoords}
onTooltipClose={() => setTooltipCoords(undefined)}
/>
) : (
formattedValue
)}
@ -47,16 +48,12 @@ export default function AutoCell({ value, field, justifyContent, rowIdx, cellOpt
);
}
const getLinkStyle = (
styles: ReturnType<typeof getStyles>,
cellOptions: TableCellOptions,
targetClassName: string | undefined
) => {
const getLinkStyle = (styles: ReturnType<typeof getStyles>, 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,

@ -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<DataLinksActionsTooltipCoords>();
const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(links, actions);
const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined;
const renderComponent = () => {
return (
<BarGauge
width={width}
@ -62,7 +66,6 @@ export const BarGaugeCell = ({ value, field, theme, height, width, rowIdx }: Bar
orientation={VizOrientation.Horizontal}
theme={theme}
alignmentFactors={alignmentFactors}
onClick={openMenu}
itemSpacing={1}
lcdCellWidth={8}
displayMode={barGaugeMode}
@ -71,19 +74,25 @@ export const BarGaugeCell = ({ value, field, theme, height, width, rowIdx }: Bar
);
};
// @TODO: Actions
return (
<>
{hasLinks ? (
<DataLinksContextMenu
links={() => getCellLinks(field, rowIdx) || []}
style={{ display: 'flex', width: '100%' }}
// 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={({ clientX, clientY }) => setTooltipCoords({ clientX, clientY })}
>
{(api) => renderComponent(api)}
</DataLinksContextMenu>
{shouldShowLink ? (
renderSingleLink(links[0], renderComponent())
) : shouldShowTooltip ? (
<DataLinksActionsTooltip
links={links}
actions={actions}
value={renderComponent()}
coords={tooltipCoords}
onTooltipClose={() => setTooltipCoords(undefined)}
/>
) : (
renderComponent({})
renderComponent()
)}
</>
</div>
);
};

@ -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();
}
});
});

@ -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<DataLinksActionsTooltipCoords>();
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 = <img alt={alt} src={text} className={styles.image} title={title} />;
// TODO: Implement actions
return (
<div className={styles.imageContainer}>
{hasLinks ? (
<DataLinksContextMenu links={() => getCellLinks(field, rowIdx) || []}>
{(api) => {
if (api.openMenu) {
return (
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
<div
onClick={api.openMenu}
role="button"
tabIndex={0}
onKeyDown={(e: React.KeyboardEvent) => {
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);
}
className={styles.imageContainer}
style={{ cursor: hasMultipleLinksOrActions ? 'context-menu' : 'auto' }}
onClick={({ clientX, clientY }) => {
setTooltipCoords({ clientX, clientY });
}}
>
{img}
</div>
);
} else {
return img;
}
}}
</DataLinksContextMenu>
{shouldShowLink ? (
renderSingleLink(links[0], img)
) : shouldShowTooltip ? (
<DataLinksActionsTooltip
links={links}
actions={actions}
value={img}
coords={tooltipCoords}
onTooltipClose={() => setTooltipCoords(undefined)}
/>
) : (
img
)}

@ -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<DataLinksActionsTooltipCoords>();
const { shouldShowLink, hasMultipleLinksOrActions } = getDataLinksActionsTooltipUtils(links, actions);
const shouldShowTooltip = hasMultipleLinksOrActions && tooltipCoords !== undefined;
// TODO: Implement actions
return (
<div className={styles.jsonText}>
{hasLinks ? (
<DataLinksContextMenu links={() => getCellLinks(field, rowIdx) || []}>
{(api) => {
if (api.openMenu) {
return (
<Button className={cx(clearButtonStyle)} onClick={api.openMenu}>
{displayValue}
</Button>
);
} else {
return <>{displayValue}</>;
}
}}
</DataLinksContextMenu>
// eslint-disable-next-line jsx-a11y/click-events-have-key-events,jsx-a11y/no-static-element-interactions
<div
className={styles.jsonText}
onClick={({ clientX, clientY }) => setTooltipCoords({ clientX, clientY })}
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
)}

@ -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}

@ -109,13 +109,22 @@ export function TableCellNG(props: TableCellNGProps) {
case TableCellDisplayMode.BasicGauge:
case TableCellDisplayMode.GradientGauge:
case TableCellDisplayMode.LcdGauge:
cell = <BarGaugeCell {...commonProps} theme={theme} timeRange={timeRange} height={height} width={divWidth} />;
cell = (
<BarGaugeCell
{...commonProps}
theme={theme}
timeRange={timeRange}
height={height}
width={divWidth}
actions={actions}
/>
);
break;
case TableCellDisplayMode.Image:
cell = <ImageCell {...commonProps} cellOptions={cellOptions} height={height} />;
cell = <ImageCell {...commonProps} cellOptions={cellOptions} height={height} actions={actions} />;
break;
case TableCellDisplayMode.JSONView:
cell = <JSONCell {...commonProps} />;
cell = <JSONCell {...commonProps} actions={actions} />;
break;
case TableCellDisplayMode.DataLinks:
cell = <DataLinksCell field={field} rowIdx={rowIdx} />;
@ -137,12 +146,12 @@ export function TableCellNG(props: TableCellNGProps) {
if (isDataFrame(firstValue) && isTimeSeriesFrame(firstValue)) {
cell = <SparklineCell {...commonProps} theme={theme} timeRange={timeRange} width={divWidth} />;
} else {
cell = <JSONCell {...commonProps} />;
cell = <JSONCell {...commonProps} actions={actions} />;
}
} else if (field.type === FieldType.other) {
cell = <JSONCell {...commonProps} />;
cell = <JSONCell {...commonProps} actions={actions} />;
} else {
cell = <AutoCell {...commonProps} cellOptions={cellOptions} />;
cell = <AutoCell {...commonProps} cellOptions={cellOptions} actions={actions} />;
}
break;
}

@ -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;

@ -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 ---------------------------- */

@ -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': {

@ -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 };
};

@ -117,7 +117,7 @@ const getStyles = (theme: GrafanaTheme2) => ({
textDecoration: 'underline',
background: 'none',
},
padding: 0,
height: 'auto',
'& span': {
whiteSpace: 'normal',

@ -6,7 +6,7 @@ import { Field, LinkModel } from '@grafana/data';
* @internal
*/
export const getCellLinks = (field: Field, row: Row) => {
let links: Array<LinkModel<unknown>> | undefined;
let links: Array<LinkModel<Field>> | 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);
};

Loading…
Cancel
Save