mirror of https://github.com/grafana/grafana
TableNG: Data links and actions tooltip (#101015)
parent
b16f34fb93
commit
cab652c4d1
@ -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, |
||||
}), |
||||
}; |
||||
}; |
Loading…
Reference in new issue