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