Skip to content

Commit 0d70dbe

Browse files
Table: Add actions support (#94578)
1 parent 4c15266 commit 0d70dbe

File tree

15 files changed

+176
-45
lines changed

15 files changed

+176
-45
lines changed

packages/grafana-ui/src/components/Button/Button.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,7 @@ export const clearLinkButtonStyles = (theme: GrafanaTheme2) => {
402402
fontFamily: 'inherit',
403403
color: 'inherit',
404404
height: '100%',
405+
cursor: 'context-menu',
405406
'&:hover': {
406407
background: 'transparent',
407408
color: 'inherit',

packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.test.tsx

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { DataLinksContextMenu } from './DataLinksContextMenu';
66

77
const fakeAriaLabel = 'fake aria label';
88
describe('DataLinksContextMenu', () => {
9-
it('renders context menu when there are more than one data links', () => {
9+
it('renders context menu when there are more than one data links or actions', () => {
1010
render(
1111
<DataLinksContextMenu
1212
links={() => [
@@ -23,6 +23,7 @@ describe('DataLinksContextMenu', () => {
2323
origin: {},
2424
},
2525
]}
26+
actions={[{ title: 'Action1', onClick: () => {} }]}
2627
>
2728
{() => {
2829
return <div aria-label="fake aria label" />;
@@ -34,7 +35,43 @@ describe('DataLinksContextMenu', () => {
3435
expect(screen.queryAllByLabelText(selectors.components.DataLinksContextMenu.singleLink)).toHaveLength(0);
3536
});
3637

37-
it('renders link when there is a single data link', () => {
38+
it('renders context menu when there are actions and one data link', () => {
39+
render(
40+
<DataLinksContextMenu
41+
links={() => [
42+
{
43+
href: '/link1',
44+
title: 'Link1',
45+
target: '_blank',
46+
origin: {},
47+
},
48+
]}
49+
actions={[{ title: 'Action1', onClick: () => {} }]}
50+
>
51+
{() => {
52+
return <div aria-label="fake aria label" />;
53+
}}
54+
</DataLinksContextMenu>
55+
);
56+
57+
expect(screen.getByLabelText(fakeAriaLabel)).toBeInTheDocument();
58+
expect(screen.queryAllByLabelText(selectors.components.DataLinksContextMenu.singleLink)).toHaveLength(0);
59+
});
60+
61+
it('renders context menu when there are only actions', () => {
62+
render(
63+
<DataLinksContextMenu links={() => []} actions={[{ title: 'Action1', onClick: () => {} }]}>
64+
{() => {
65+
return <div aria-label="fake aria label" />;
66+
}}
67+
</DataLinksContextMenu>
68+
);
69+
70+
expect(screen.getByLabelText(fakeAriaLabel)).toBeInTheDocument();
71+
expect(screen.queryAllByLabelText(selectors.components.DataLinksContextMenu.singleLink)).toHaveLength(0);
72+
});
73+
74+
it('renders link when there is a single data link and no actions', () => {
3875
render(
3976
<DataLinksContextMenu
4077
links={() => [

packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,11 @@ import { css } from '@emotion/css';
22
import { CSSProperties } from 'react';
33
import * as React from 'react';
44

5-
import { LinkModel } from '@grafana/data';
5+
import { ActionModel, GrafanaTheme2, LinkModel } from '@grafana/data';
66
import { selectors } from '@grafana/e2e-selectors';
77

8-
import { linkModelToContextMenuItems } from '../../utils/dataLinks';
8+
import { useStyles2 } from '../../themes';
9+
import { actionModelToContextMenuItems, linkModelToContextMenuItems } from '../../utils/dataLinks';
910
import { WithContextMenu } from '../ContextMenu/WithContextMenu';
1011
import { MenuGroup, MenuItemsGroup } from '../Menu/MenuGroup';
1112
import { MenuItem } from '../Menu/MenuItem';
@@ -14,15 +15,25 @@ export interface DataLinksContextMenuProps {
1415
children: (props: DataLinksContextMenuApi) => JSX.Element;
1516
links: () => LinkModel[];
1617
style?: CSSProperties;
18+
actions?: ActionModel[];
1719
}
1820

1921
export interface DataLinksContextMenuApi {
2022
openMenu?: React.MouseEventHandler<HTMLOrSVGElement>;
2123
targetClassName?: string;
2224
}
2325

24-
export const DataLinksContextMenu = ({ children, links, style }: DataLinksContextMenuProps) => {
25-
const itemsGroup: MenuItemsGroup[] = [{ items: linkModelToContextMenuItems(links), label: 'Data links' }];
26+
export const DataLinksContextMenu = ({ children, links, actions, style }: DataLinksContextMenuProps) => {
27+
const styles = useStyles2(getStyles);
28+
29+
const itemsGroup: MenuItemsGroup[] = [
30+
{ items: linkModelToContextMenuItems(links), label: Boolean(links().length) ? 'Data links' : '' },
31+
];
32+
const hasActions = Boolean(actions?.length);
33+
if (hasActions) {
34+
itemsGroup.push({ items: actionModelToContextMenuItems(actions!), label: 'Actions' });
35+
}
36+
2637
const linksCounter = itemsGroup[0].items.length;
2738
const renderMenuGroupItems = () => {
2839
return itemsGroup.map((group, groupIdx) => (
@@ -36,6 +47,7 @@ export const DataLinksContextMenu = ({ children, links, style }: DataLinksContex
3647
icon={item.icon}
3748
active={item.active}
3849
onClick={item.onClick}
50+
className={styles.itemWrapper}
3951
/>
4052
))}
4153
</MenuGroup>
@@ -47,7 +59,7 @@ export const DataLinksContextMenu = ({ children, links, style }: DataLinksContex
4759
cursor: 'context-menu',
4860
});
4961

50-
if (linksCounter > 1) {
62+
if (linksCounter > 1 || hasActions) {
5163
return (
5264
<WithContextMenu renderMenuItems={renderMenuGroupItems}>
5365
{({ openMenu }) => {
@@ -71,3 +83,9 @@ export const DataLinksContextMenu = ({ children, links, style }: DataLinksContex
7183
);
7284
}
7385
};
86+
87+
const getStyles = (theme: GrafanaTheme2) => ({
88+
itemWrapper: css({
89+
fontSize: 12,
90+
}),
91+
});

packages/grafana-ui/src/components/Table/BarGaugeCell.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ const defaultScale: ThresholdsConfig = {
2424
};
2525

2626
export const BarGaugeCell = (props: TableCellProps) => {
27-
const { field, innerWidth, tableStyles, cell, cellProps, row } = props;
27+
const { field, innerWidth, tableStyles, cell, cellProps, row, actions } = props;
2828
const displayValue = field.display!(cell.value);
2929
const cellOptions = getCellOptions(field);
3030

@@ -56,6 +56,7 @@ export const BarGaugeCell = (props: TableCellProps) => {
5656
};
5757

5858
const hasLinks = Boolean(getLinks().length);
59+
const hasActions = Boolean(actions?.length);
5960
const alignmentFactors = getAlignmentFactor(field, displayValue, cell.row.index);
6061

6162
const renderComponent = (menuProps: DataLinksContextMenuApi) => {
@@ -84,12 +85,13 @@ export const BarGaugeCell = (props: TableCellProps) => {
8485

8586
return (
8687
<div {...cellProps} className={tableStyles.cellContainer}>
87-
{hasLinks && (
88-
<DataLinksContextMenu links={getLinks} style={{ display: 'flex', width: '100%' }}>
88+
{hasLinks || hasActions ? (
89+
<DataLinksContextMenu links={getLinks} actions={actions} style={{ display: 'flex', width: '100%' }}>
8990
{(api) => renderComponent(api)}
9091
</DataLinksContextMenu>
92+
) : (
93+
renderComponent({})
9194
)}
92-
{!hasLinks && renderComponent({})}
9395
</div>
9496
);
9597
};

packages/grafana-ui/src/components/Table/DefaultCell.tsx

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,15 +17,16 @@ import { TableCellProps, CustomCellRendererProps, TableCellOptions } from './typ
1717
import { getCellColors, getCellOptions } from './utils';
1818

1919
export const DefaultCell = (props: TableCellProps) => {
20-
const { field, cell, tableStyles, row, cellProps, frame, rowStyled, rowExpanded, textWrapped, height } = props;
21-
20+
const { field, cell, tableStyles, row, cellProps, frame, rowStyled, rowExpanded, textWrapped, height, actions } =
21+
props;
2222
const inspectEnabled = Boolean(field.config.custom?.inspect);
2323
const displayValue = field.display!(cell.value);
2424

2525
const showFilters = props.onCellFilterAdded && field.config.filterable;
2626
const showActions = (showFilters && cell.value !== undefined) || inspectEnabled;
2727
const cellOptions = getCellOptions(field);
2828
const hasLinks = Boolean(getCellLinks(field, row)?.length);
29+
const hasActions = Boolean(actions?.length);
2930
const clearButtonStyle = useStyles2(clearLinkButtonStyles);
3031
const [hover, setHover] = useState(false);
3132
let value: string | ReactElement;
@@ -94,10 +95,8 @@ export const DefaultCell = (props: TableCellProps) => {
9495
onMouseLeave={showActions ? onMouseLeave : undefined}
9596
className={cellStyle}
9697
>
97-
{!hasLinks && (isStringValue ? `${value}` : <div className={tableStyles.cellText}>{value}</div>)}
98-
99-
{hasLinks && (
100-
<DataLinksContextMenu links={() => getCellLinks(field, row) || []}>
98+
{hasLinks || hasActions ? (
99+
<DataLinksContextMenu links={() => getCellLinks(field, row) || []} actions={actions}>
101100
{(api) => {
102101
if (api.openMenu) {
103102
return (
@@ -113,6 +112,10 @@ export const DefaultCell = (props: TableCellProps) => {
113112
}
114113
}}
115114
</DataLinksContextMenu>
115+
) : isStringValue ? (
116+
`${value}`
117+
) : (
118+
<div className={tableStyles.cellText}>{value}</div>
116119
)}
117120

118121
{hover && showActions && (

packages/grafana-ui/src/components/Table/ImageCell.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,13 @@ import { getCellOptions } from './utils';
99
const DATALINKS_HEIGHT_OFFSET = 10;
1010

1111
export const ImageCell = (props: TableCellProps) => {
12-
const { field, cell, tableStyles, row, cellProps } = props;
12+
const { field, cell, tableStyles, row, cellProps, actions } = props;
1313
const cellOptions = getCellOptions(field);
1414
const { title, alt } =
1515
cellOptions.type === TableCellDisplayMode.Image ? cellOptions : { title: undefined, alt: undefined };
1616
const displayValue = field.display!(cell.value);
1717
const hasLinks = Boolean(getCellLinks(field, row)?.length);
18+
const hasActions = Boolean(actions?.length);
1819

1920
// The image element
2021
const img = (
@@ -29,13 +30,13 @@ export const ImageCell = (props: TableCellProps) => {
2930

3031
return (
3132
<div {...cellProps} className={tableStyles.cellContainer}>
32-
{/* If there are no links we simply render the image */}
33-
{!hasLinks && img}
34-
{/* Otherwise render data links with image */}
35-
{hasLinks && (
33+
{/* If there are data links/actions, we render them with image */}
34+
{/* Otherwise we simply render the image */}
35+
{hasLinks || hasActions ? (
3636
<DataLinksContextMenu
3737
style={{ height: tableStyles.cellHeight - DATALINKS_HEIGHT_OFFSET, width: 'auto' }}
3838
links={() => getCellLinks(field, row) || []}
39+
actions={actions}
3940
>
4041
{(api) => {
4142
if (api.openMenu) {
@@ -59,6 +60,8 @@ export const ImageCell = (props: TableCellProps) => {
5960
}
6061
}}
6162
</DataLinksContextMenu>
63+
) : (
64+
img
6265
)}
6366
</div>
6467
);

packages/grafana-ui/src/components/Table/JSONViewCell.tsx

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { TableCellInspectorMode } from './TableCellInspector';
1111
import { TableCellProps } from './types';
1212

1313
export function JSONViewCell(props: TableCellProps): JSX.Element {
14-
const { cell, tableStyles, cellProps, field, row } = props;
14+
const { cell, tableStyles, cellProps, field, row, actions } = props;
1515
const inspectEnabled = Boolean(field.config.custom?.inspect);
1616
const txt = css({
1717
cursor: 'pointer',
@@ -30,14 +30,14 @@ export function JSONViewCell(props: TableCellProps): JSX.Element {
3030
}
3131

3232
const hasLinks = Boolean(getCellLinks(field, row)?.length);
33+
const hasActions = Boolean(actions?.length);
3334
const clearButtonStyle = useStyles2(clearLinkButtonStyles);
3435

3536
return (
3637
<div {...cellProps} className={inspectEnabled ? tableStyles.cellContainerNoOverflow : tableStyles.cellContainer}>
3738
<div className={cx(tableStyles.cellText, txt)}>
38-
{!hasLinks && <div className={tableStyles.cellText}>{displayValue}</div>}
39-
{hasLinks && (
40-
<DataLinksContextMenu links={() => getCellLinks(field, row) || []}>
39+
{hasLinks || hasActions ? (
40+
<DataLinksContextMenu links={() => getCellLinks(field, row) || []} actions={actions}>
4141
{(api) => {
4242
if (api.openMenu) {
4343
return (
@@ -50,6 +50,8 @@ export function JSONViewCell(props: TableCellProps): JSX.Element {
5050
}
5151
}}
5252
</DataLinksContextMenu>
53+
) : (
54+
<div className={tableStyles.cellText}>{displayValue}</div>
5355
)}
5456
</div>
5557
{inspectEnabled && <CellActions {...props} previewMode={TableCellInspectorMode.code} />}

packages/grafana-ui/src/components/Table/RowsList.tsx

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { usePanelContext } from '../PanelChrome';
2323
import { ExpandedRow, getExpandedRowHeight } from './ExpandedRow';
2424
import { TableCell } from './TableCell';
2525
import { TableStyles } from './styles';
26-
import { CellColors, TableFieldOptions, TableFilterActionCallback } from './types';
26+
import { CellColors, GetActionsFunction, TableFieldOptions, TableFilterActionCallback } from './types';
2727
import {
2828
calculateAroundPointThreshold,
2929
getCellColors,
@@ -54,6 +54,7 @@ interface RowsListProps {
5454
headerGroups: HeaderGroup[];
5555
longestField?: Field;
5656
textWrapField?: Field;
57+
getActions?: GetActionsFunction;
5758
}
5859

5960
export const RowsList = (props: RowsListProps) => {
@@ -80,6 +81,7 @@ export const RowsList = (props: RowsListProps) => {
8081
headerGroups,
8182
longestField,
8283
textWrapField,
84+
getActions,
8385
} = props;
8486

8587
const [rowHighlightIndex, setRowHighlightIndex] = useState<number | undefined>(initialRowIndex);
@@ -334,32 +336,34 @@ export const RowsList = (props: RowsListProps) => {
334336
rowExpanded={rowExpanded}
335337
textWrapped={textWrapFinal !== undefined}
336338
height={Number(style.height)}
339+
getActions={getActions}
337340
/>
338341
))}
339342
</div>
340343
);
341344
},
342345
[
343-
cellHeight,
344-
data,
345-
nestedDataField,
346-
onCellFilterAdded,
347-
onRowHover,
348-
onRowLeave,
349-
prepareRow,
350346
rowIndexForPagination,
351347
rows,
348+
prepareRow,
352349
tableState.expanded,
353-
tableStyles,
350+
nestedDataField,
351+
rowBg,
354352
textWrapFinal,
353+
tableStyles,
354+
onRowLeave,
355+
width,
356+
cellHeight,
355357
theme.components.table.rowSelected,
356-
theme.typography.fontSize,
357358
theme.typography.body.lineHeight,
358-
timeRange,
359-
width,
360-
rowBg,
359+
theme.typography.fontSize,
360+
data,
361361
headerGroups,
362362
osContext,
363+
onRowHover,
364+
onCellFilterAdded,
365+
timeRange,
366+
getActions,
363367
]
364368
);
365369

packages/grafana-ui/src/components/Table/Table.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export const Table = memo((props: Props) => {
5959
enableSharedCrosshair = false,
6060
initialRowIndex = undefined,
6161
fieldConfig,
62+
getActions,
6263
} = props;
6364

6465
const listRef = useRef<VariableSizeList>(null);
@@ -117,7 +118,7 @@ export const Table = memo((props: Props) => {
117118
// React-table column definitions
118119
const memoizedColumns = useMemo(
119120
() => getColumns(data, width, columnMinWidth, hasNestedData, footerItems, isCountRowsSet),
120-
[data, width, columnMinWidth, footerItems, hasNestedData, isCountRowsSet]
121+
[data, width, columnMinWidth, hasNestedData, footerItems, isCountRowsSet]
121122
);
122123

123124
// we need a ref to later store the `toggleAllRowsExpanded` function, returned by `useTable`.
@@ -355,6 +356,7 @@ export const Table = memo((props: Props) => {
355356
initialRowIndex={initialRowIndex}
356357
longestField={longestField}
357358
textWrapField={textWrapField}
359+
getActions={getActions}
358360
/>
359361
</div>
360362
) : (

0 commit comments

Comments
 (0)