Table: Add actions support (#94578)

This commit is contained in:
Adela Almasan 2024-10-17 16:47:39 -06:00 committed by GitHub
parent 4c15266a77
commit 0d70dbe730
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 178 additions and 47 deletions

View File

@ -402,6 +402,7 @@ export const clearLinkButtonStyles = (theme: GrafanaTheme2) => {
fontFamily: 'inherit',
color: 'inherit',
height: '100%',
cursor: 'context-menu',
'&:hover': {
background: 'transparent',
color: 'inherit',

View File

@ -6,7 +6,7 @@ import { DataLinksContextMenu } from './DataLinksContextMenu';
const fakeAriaLabel = 'fake aria label';
describe('DataLinksContextMenu', () => {
it('renders context menu when there are more than one data links', () => {
it('renders context menu when there are more than one data links or actions', () => {
render(
<DataLinksContextMenu
links={() => [
@ -23,6 +23,7 @@ describe('DataLinksContextMenu', () => {
origin: {},
},
]}
actions={[{ title: 'Action1', onClick: () => {} }]}
>
{() => {
return <div aria-label="fake aria label" />;
@ -34,7 +35,43 @@ describe('DataLinksContextMenu', () => {
expect(screen.queryAllByLabelText(selectors.components.DataLinksContextMenu.singleLink)).toHaveLength(0);
});
it('renders link when there is a single data link', () => {
it('renders context menu when there are actions and one data link', () => {
render(
<DataLinksContextMenu
links={() => [
{
href: '/link1',
title: 'Link1',
target: '_blank',
origin: {},
},
]}
actions={[{ title: 'Action1', onClick: () => {} }]}
>
{() => {
return <div aria-label="fake aria label" />;
}}
</DataLinksContextMenu>
);
expect(screen.getByLabelText(fakeAriaLabel)).toBeInTheDocument();
expect(screen.queryAllByLabelText(selectors.components.DataLinksContextMenu.singleLink)).toHaveLength(0);
});
it('renders context menu when there are only actions', () => {
render(
<DataLinksContextMenu links={() => []} actions={[{ title: 'Action1', onClick: () => {} }]}>
{() => {
return <div aria-label="fake aria label" />;
}}
</DataLinksContextMenu>
);
expect(screen.getByLabelText(fakeAriaLabel)).toBeInTheDocument();
expect(screen.queryAllByLabelText(selectors.components.DataLinksContextMenu.singleLink)).toHaveLength(0);
});
it('renders link when there is a single data link and no actions', () => {
render(
<DataLinksContextMenu
links={() => [

View File

@ -2,10 +2,11 @@ import { css } from '@emotion/css';
import { CSSProperties } from 'react';
import * as React from 'react';
import { LinkModel } from '@grafana/data';
import { ActionModel, GrafanaTheme2, LinkModel } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { linkModelToContextMenuItems } from '../../utils/dataLinks';
import { useStyles2 } from '../../themes';
import { actionModelToContextMenuItems, linkModelToContextMenuItems } from '../../utils/dataLinks';
import { WithContextMenu } from '../ContextMenu/WithContextMenu';
import { MenuGroup, MenuItemsGroup } from '../Menu/MenuGroup';
import { MenuItem } from '../Menu/MenuItem';
@ -14,6 +15,7 @@ export interface DataLinksContextMenuProps {
children: (props: DataLinksContextMenuApi) => JSX.Element;
links: () => LinkModel[];
style?: CSSProperties;
actions?: ActionModel[];
}
export interface DataLinksContextMenuApi {
@ -21,8 +23,17 @@ export interface DataLinksContextMenuApi {
targetClassName?: string;
}
export const DataLinksContextMenu = ({ children, links, style }: DataLinksContextMenuProps) => {
const itemsGroup: MenuItemsGroup[] = [{ items: linkModelToContextMenuItems(links), label: 'Data links' }];
export const DataLinksContextMenu = ({ children, links, actions, style }: DataLinksContextMenuProps) => {
const styles = useStyles2(getStyles);
const itemsGroup: MenuItemsGroup[] = [
{ items: linkModelToContextMenuItems(links), label: Boolean(links().length) ? 'Data links' : '' },
];
const hasActions = Boolean(actions?.length);
if (hasActions) {
itemsGroup.push({ items: actionModelToContextMenuItems(actions!), label: 'Actions' });
}
const linksCounter = itemsGroup[0].items.length;
const renderMenuGroupItems = () => {
return itemsGroup.map((group, groupIdx) => (
@ -36,6 +47,7 @@ export const DataLinksContextMenu = ({ children, links, style }: DataLinksContex
icon={item.icon}
active={item.active}
onClick={item.onClick}
className={styles.itemWrapper}
/>
))}
</MenuGroup>
@ -47,7 +59,7 @@ export const DataLinksContextMenu = ({ children, links, style }: DataLinksContex
cursor: 'context-menu',
});
if (linksCounter > 1) {
if (linksCounter > 1 || hasActions) {
return (
<WithContextMenu renderMenuItems={renderMenuGroupItems}>
{({ openMenu }) => {
@ -71,3 +83,9 @@ export const DataLinksContextMenu = ({ children, links, style }: DataLinksContex
);
}
};
const getStyles = (theme: GrafanaTheme2) => ({
itemWrapper: css({
fontSize: 12,
}),
});

View File

@ -24,7 +24,7 @@ const defaultScale: ThresholdsConfig = {
};
export const BarGaugeCell = (props: TableCellProps) => {
const { field, innerWidth, tableStyles, cell, cellProps, row } = props;
const { field, innerWidth, tableStyles, cell, cellProps, row, actions } = props;
const displayValue = field.display!(cell.value);
const cellOptions = getCellOptions(field);
@ -56,6 +56,7 @@ export const BarGaugeCell = (props: TableCellProps) => {
};
const hasLinks = Boolean(getLinks().length);
const hasActions = Boolean(actions?.length);
const alignmentFactors = getAlignmentFactor(field, displayValue, cell.row.index);
const renderComponent = (menuProps: DataLinksContextMenuApi) => {
@ -84,12 +85,13 @@ export const BarGaugeCell = (props: TableCellProps) => {
return (
<div {...cellProps} className={tableStyles.cellContainer}>
{hasLinks && (
<DataLinksContextMenu links={getLinks} style={{ display: 'flex', width: '100%' }}>
{hasLinks || hasActions ? (
<DataLinksContextMenu links={getLinks} actions={actions} style={{ display: 'flex', width: '100%' }}>
{(api) => renderComponent(api)}
</DataLinksContextMenu>
) : (
renderComponent({})
)}
{!hasLinks && renderComponent({})}
</div>
);
};

View File

@ -17,8 +17,8 @@ import { TableCellProps, CustomCellRendererProps, TableCellOptions } from './typ
import { getCellColors, getCellOptions } from './utils';
export const DefaultCell = (props: TableCellProps) => {
const { field, cell, tableStyles, row, cellProps, frame, rowStyled, rowExpanded, textWrapped, height } = props;
const { field, cell, tableStyles, row, cellProps, frame, rowStyled, rowExpanded, textWrapped, height, actions } =
props;
const inspectEnabled = Boolean(field.config.custom?.inspect);
const displayValue = field.display!(cell.value);
@ -26,6 +26,7 @@ export const DefaultCell = (props: TableCellProps) => {
const showActions = (showFilters && cell.value !== undefined) || inspectEnabled;
const cellOptions = getCellOptions(field);
const hasLinks = Boolean(getCellLinks(field, row)?.length);
const hasActions = Boolean(actions?.length);
const clearButtonStyle = useStyles2(clearLinkButtonStyles);
const [hover, setHover] = useState(false);
let value: string | ReactElement;
@ -94,10 +95,8 @@ export const DefaultCell = (props: TableCellProps) => {
onMouseLeave={showActions ? onMouseLeave : undefined}
className={cellStyle}
>
{!hasLinks && (isStringValue ? `${value}` : <div className={tableStyles.cellText}>{value}</div>)}
{hasLinks && (
<DataLinksContextMenu links={() => getCellLinks(field, row) || []}>
{hasLinks || hasActions ? (
<DataLinksContextMenu links={() => getCellLinks(field, row) || []} actions={actions}>
{(api) => {
if (api.openMenu) {
return (
@ -113,6 +112,10 @@ export const DefaultCell = (props: TableCellProps) => {
}
}}
</DataLinksContextMenu>
) : isStringValue ? (
`${value}`
) : (
<div className={tableStyles.cellText}>{value}</div>
)}
{hover && showActions && (

View File

@ -9,12 +9,13 @@ import { getCellOptions } from './utils';
const DATALINKS_HEIGHT_OFFSET = 10;
export const ImageCell = (props: TableCellProps) => {
const { field, cell, tableStyles, row, cellProps } = props;
const { field, cell, tableStyles, row, cellProps, actions } = props;
const cellOptions = getCellOptions(field);
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 hasActions = Boolean(actions?.length);
// The image element
const img = (
@ -29,13 +30,13 @@ export const ImageCell = (props: TableCellProps) => {
return (
<div {...cellProps} className={tableStyles.cellContainer}>
{/* If there are no links we simply render the image */}
{!hasLinks && img}
{/* Otherwise render data links with image */}
{hasLinks && (
{/* If there are data links/actions, we render them with image */}
{/* Otherwise we simply render the image */}
{hasLinks || hasActions ? (
<DataLinksContextMenu
style={{ height: tableStyles.cellHeight - DATALINKS_HEIGHT_OFFSET, width: 'auto' }}
links={() => getCellLinks(field, row) || []}
actions={actions}
>
{(api) => {
if (api.openMenu) {
@ -59,6 +60,8 @@ export const ImageCell = (props: TableCellProps) => {
}
}}
</DataLinksContextMenu>
) : (
img
)}
</div>
);

View File

@ -11,7 +11,7 @@ import { TableCellInspectorMode } from './TableCellInspector';
import { TableCellProps } from './types';
export function JSONViewCell(props: TableCellProps): JSX.Element {
const { cell, tableStyles, cellProps, field, row } = props;
const { cell, tableStyles, cellProps, field, row, actions } = props;
const inspectEnabled = Boolean(field.config.custom?.inspect);
const txt = css({
cursor: 'pointer',
@ -30,14 +30,14 @@ export function JSONViewCell(props: TableCellProps): JSX.Element {
}
const hasLinks = Boolean(getCellLinks(field, row)?.length);
const hasActions = Boolean(actions?.length);
const clearButtonStyle = useStyles2(clearLinkButtonStyles);
return (
<div {...cellProps} className={inspectEnabled ? tableStyles.cellContainerNoOverflow : tableStyles.cellContainer}>
<div className={cx(tableStyles.cellText, txt)}>
{!hasLinks && <div className={tableStyles.cellText}>{displayValue}</div>}
{hasLinks && (
<DataLinksContextMenu links={() => getCellLinks(field, row) || []}>
{hasLinks || hasActions ? (
<DataLinksContextMenu links={() => getCellLinks(field, row) || []} actions={actions}>
{(api) => {
if (api.openMenu) {
return (
@ -50,6 +50,8 @@ export function JSONViewCell(props: TableCellProps): JSX.Element {
}
}}
</DataLinksContextMenu>
) : (
<div className={tableStyles.cellText}>{displayValue}</div>
)}
</div>
{inspectEnabled && <CellActions {...props} previewMode={TableCellInspectorMode.code} />}

View File

@ -23,7 +23,7 @@ import { usePanelContext } from '../PanelChrome';
import { ExpandedRow, getExpandedRowHeight } from './ExpandedRow';
import { TableCell } from './TableCell';
import { TableStyles } from './styles';
import { CellColors, TableFieldOptions, TableFilterActionCallback } from './types';
import { CellColors, GetActionsFunction, TableFieldOptions, TableFilterActionCallback } from './types';
import {
calculateAroundPointThreshold,
getCellColors,
@ -54,6 +54,7 @@ interface RowsListProps {
headerGroups: HeaderGroup[];
longestField?: Field;
textWrapField?: Field;
getActions?: GetActionsFunction;
}
export const RowsList = (props: RowsListProps) => {
@ -80,6 +81,7 @@ export const RowsList = (props: RowsListProps) => {
headerGroups,
longestField,
textWrapField,
getActions,
} = props;
const [rowHighlightIndex, setRowHighlightIndex] = useState<number | undefined>(initialRowIndex);
@ -334,32 +336,34 @@ export const RowsList = (props: RowsListProps) => {
rowExpanded={rowExpanded}
textWrapped={textWrapFinal !== undefined}
height={Number(style.height)}
getActions={getActions}
/>
))}
</div>
);
},
[
cellHeight,
data,
nestedDataField,
onCellFilterAdded,
onRowHover,
onRowLeave,
prepareRow,
rowIndexForPagination,
rows,
prepareRow,
tableState.expanded,
tableStyles,
textWrapFinal,
theme.components.table.rowSelected,
theme.typography.fontSize,
theme.typography.body.lineHeight,
timeRange,
width,
nestedDataField,
rowBg,
textWrapFinal,
tableStyles,
onRowLeave,
width,
cellHeight,
theme.components.table.rowSelected,
theme.typography.body.lineHeight,
theme.typography.fontSize,
data,
headerGroups,
osContext,
onRowHover,
onCellFilterAdded,
timeRange,
getActions,
]
);

View File

@ -59,6 +59,7 @@ export const Table = memo((props: Props) => {
enableSharedCrosshair = false,
initialRowIndex = undefined,
fieldConfig,
getActions,
} = props;
const listRef = useRef<VariableSizeList>(null);
@ -117,7 +118,7 @@ export const Table = memo((props: Props) => {
// React-table column definitions
const memoizedColumns = useMemo(
() => getColumns(data, width, columnMinWidth, hasNestedData, footerItems, isCountRowsSet),
[data, width, columnMinWidth, footerItems, hasNestedData, isCountRowsSet]
[data, width, columnMinWidth, hasNestedData, footerItems, isCountRowsSet]
);
// we need a ref to later store the `toggleAllRowsExpanded` function, returned by `useTable`.
@ -355,6 +356,7 @@ export const Table = memo((props: Props) => {
initialRowIndex={initialRowIndex}
longestField={longestField}
textWrapField={textWrapField}
getActions={getActions}
/>
</div>
) : (

View File

@ -3,7 +3,7 @@ import { Cell } from 'react-table';
import { TimeRange, DataFrame } from '@grafana/data';
import { TableStyles } from './styles';
import { GrafanaTableColumn, TableFilterActionCallback } from './types';
import { GetActionsFunction, GrafanaTableColumn, TableFilterActionCallback } from './types';
export interface Props {
cell: Cell;
@ -18,6 +18,7 @@ export interface Props {
rowExpanded?: boolean;
textWrapped?: boolean;
height?: number;
getActions?: GetActionsFunction;
}
export const TableCell = ({
@ -31,6 +32,7 @@ export const TableCell = ({
rowExpanded,
textWrapped,
height,
getActions,
}: Props) => {
const cellProps = cell.getCellProps();
const field = (cell.column as unknown as GrafanaTableColumn).field;
@ -56,6 +58,8 @@ export const TableCell = ({
let innerWidth = (typeof cell.column.width === 'number' ? cell.column.width : 24) - tableStyles.cellPadding * 2;
const actions = getActions ? getActions(frame, field) : [];
return (
<>
{cell.render('Cell', {
@ -71,6 +75,7 @@ export const TableCell = ({
rowExpanded,
textWrapped,
height,
actions,
})}
</>
);

View File

@ -179,6 +179,7 @@ export function useTableStyles(theme: GrafanaTheme2, cellHeightOption: TableCell
textOverflow: 'ellipsis',
userSelect: 'text',
whiteSpace: 'nowrap',
cursor: 'text',
}),
sortIcon: css({
marginLeft: theme.spacing(0.5),

View File

@ -2,7 +2,7 @@ import { Property } from 'csstype';
import { FC } from 'react';
import { CellProps, Column, Row, TableState, UseExpandedRowProps } from 'react-table';
import { DataFrame, Field, KeyValue, SelectableValue, TimeRange, FieldConfigSource } from '@grafana/data';
import { DataFrame, Field, KeyValue, SelectableValue, TimeRange, FieldConfigSource, ActionModel } from '@grafana/data';
import * as schema from '@grafana/schema';
import { TableStyles } from './styles';
@ -44,6 +44,7 @@ export interface TableCellProps extends CellProps<any> {
onCellFilterAdded?: TableFilterActionCallback;
innerWidth: number;
frame: DataFrame;
actions?: ActionModel[];
}
export type CellComponent = FC<TableCellProps>;
@ -106,6 +107,7 @@ export interface Props {
// The index of the field value that the table will initialize scrolled to
initialRowIndex?: number;
fieldConfig?: FieldConfigSource;
getActions?: GetActionsFunction;
}
/**
@ -154,3 +156,6 @@ export interface CellColors {
bgColor?: string;
bgHoverColor?: string;
}
// export type GetActionsFunction = (frame: DataFrame, field: Field, fieldScopedVars: any, replaceVariables: any, actions: Action[], config: any) => ActionModel[];
export type GetActionsFunction = (frame: DataFrame, field: Field) => ActionModel[];

View File

@ -1,4 +1,4 @@
import { LinkModel } from '@grafana/data';
import { ActionModel, LinkModel } from '@grafana/data';
import { MenuItemProps } from '../components/Menu/MenuItem';
@ -19,6 +19,17 @@ export const linkModelToContextMenuItems: (links: () => LinkModel[]) => MenuItem
});
};
export const actionModelToContextMenuItems: (actions: ActionModel[]) => MenuItemProps[] = (actions) => {
return actions.map((action) => {
return {
label: action.title,
ariaLabel: action.title,
icon: 'record-audio',
onClick: action.onClick,
};
});
};
export const isCompactUrl = (url: string) => {
const compactExploreUrlRegex = /\/explore\?.*&(left|right)=\[(.*\,){2,}(.*){1}\]/;
return compactExploreUrlRegex.test(url);

View File

@ -39,8 +39,8 @@ export const ActionListItem = ({ action, onEdit, onRemove, index, itemKey }: Act
</div>
</div>
<div className={styles.icons}>
<IconButton name="pen" onClick={onEdit} className={styles.icon} tooltip="Edit action title" />
<IconButton name="times" onClick={onRemove} className={styles.icon} tooltip="Remove action title" />
<IconButton name="pen" onClick={onEdit} className={styles.icon} tooltip="Edit action" />
<IconButton name="trash-alt" onClick={onRemove} className={styles.icon} tooltip="Remove action" />
<div className={styles.dragIcon} {...provided.dragHandleProps}>
<Icon name="draggabledots" size="lg" />
</div>

View File

@ -1,17 +1,22 @@
import { css } from '@emotion/css';
import {
ActionModel,
DashboardCursorSync,
DataFrame,
FieldMatcherID,
getFrameDisplayName,
InterpolateFunction,
PanelProps,
SelectableValue,
Field,
} from '@grafana/data';
import { config, PanelDataErrorView } from '@grafana/runtime';
import { Select, Table, usePanelContext, useTheme2 } from '@grafana/ui';
import { TableSortByFieldState } from '@grafana/ui/src/components/Table/types';
import { getActions } from '../../../features/actions/utils';
import { hasDeprecatedParentRowIndex, migrateFromParentRowIndexToNestedFrames } from './migrations';
import { Options } from './panelcfg.gen';
@ -63,6 +68,7 @@ export function TablePanel(props: Props) {
timeRange={timeRange}
enableSharedCrosshair={config.featureToggles.tableSharedCrosshair && enableSharedCrosshair}
fieldConfig={fieldConfig}
getActions={getCellActions}
/>
);
@ -136,6 +142,37 @@ function onChangeTableSelection(val: SelectableValue<number>, props: Props) {
});
}
// placeholder function; assuming the values are already interpolated
const replaceVars: InterpolateFunction = (value: string) => value;
const getCellActions = (dataFrame: DataFrame, field: Field) => {
if (!config.featureToggles?.vizActions) {
return [];
}
const actions: Array<ActionModel<Field>> = [];
const actionLookup = new Set<string>();
const actionsModel = getActions(
dataFrame,
field,
field.state!.scopedVars!,
replaceVars,
field.config.actions ?? [],
{}
);
actionsModel.forEach((action) => {
const key = `${action.title}`;
if (!actionLookup.has(key)) {
actions.push(action);
actionLookup.add(key);
}
});
return actions;
};
const tableStyles = {
wrapper: css`
display: flex;