diff --git a/packages/grafana-ui/src/components/Button/Button.tsx b/packages/grafana-ui/src/components/Button/Button.tsx index 3f8e1b6f642..0dba93067c5 100644 --- a/packages/grafana-ui/src/components/Button/Button.tsx +++ b/packages/grafana-ui/src/components/Button/Button.tsx @@ -402,6 +402,7 @@ export const clearLinkButtonStyles = (theme: GrafanaTheme2) => { fontFamily: 'inherit', color: 'inherit', height: '100%', + cursor: 'context-menu', '&:hover': { background: 'transparent', color: 'inherit', diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.test.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.test.tsx index 64d7049d8d4..cb7870abfad 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.test.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.test.tsx @@ -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( [ @@ -23,6 +23,7 @@ describe('DataLinksContextMenu', () => { origin: {}, }, ]} + actions={[{ title: 'Action1', onClick: () => {} }]} > {() => { return
; @@ -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( + [ + { + href: '/link1', + title: 'Link1', + target: '_blank', + origin: {}, + }, + ]} + actions={[{ title: 'Action1', onClick: () => {} }]} + > + {() => { + return
; + }} + + ); + + expect(screen.getByLabelText(fakeAriaLabel)).toBeInTheDocument(); + expect(screen.queryAllByLabelText(selectors.components.DataLinksContextMenu.singleLink)).toHaveLength(0); + }); + + it('renders context menu when there are only actions', () => { + render( + []} actions={[{ title: 'Action1', onClick: () => {} }]}> + {() => { + return
; + }} + + ); + + 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( [ diff --git a/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx b/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx index afd3c8855b5..8633ade3318 100644 --- a/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx +++ b/packages/grafana-ui/src/components/DataLinks/DataLinksContextMenu.tsx @@ -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} /> ))} @@ -47,7 +59,7 @@ export const DataLinksContextMenu = ({ children, links, style }: DataLinksContex cursor: 'context-menu', }); - if (linksCounter > 1) { + if (linksCounter > 1 || hasActions) { return ( {({ openMenu }) => { @@ -71,3 +83,9 @@ export const DataLinksContextMenu = ({ children, links, style }: DataLinksContex ); } }; + +const getStyles = (theme: GrafanaTheme2) => ({ + itemWrapper: css({ + fontSize: 12, + }), +}); diff --git a/packages/grafana-ui/src/components/Table/BarGaugeCell.tsx b/packages/grafana-ui/src/components/Table/BarGaugeCell.tsx index 9bc9e199afb..f22643b3ba8 100644 --- a/packages/grafana-ui/src/components/Table/BarGaugeCell.tsx +++ b/packages/grafana-ui/src/components/Table/BarGaugeCell.tsx @@ -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 (
- {hasLinks && ( - + {hasLinks || hasActions ? ( + {(api) => renderComponent(api)} + ) : ( + renderComponent({}) )} - {!hasLinks && renderComponent({})}
); }; diff --git a/packages/grafana-ui/src/components/Table/DefaultCell.tsx b/packages/grafana-ui/src/components/Table/DefaultCell.tsx index 77a30a7c051..d37f37d5abc 100644 --- a/packages/grafana-ui/src/components/Table/DefaultCell.tsx +++ b/packages/grafana-ui/src/components/Table/DefaultCell.tsx @@ -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}` :
{value}
)} - - {hasLinks && ( - getCellLinks(field, row) || []}> + {hasLinks || hasActions ? ( + getCellLinks(field, row) || []} actions={actions}> {(api) => { if (api.openMenu) { return ( @@ -113,6 +112,10 @@ export const DefaultCell = (props: TableCellProps) => { } }} + ) : isStringValue ? ( + `${value}` + ) : ( +
{value}
)} {hover && showActions && ( diff --git a/packages/grafana-ui/src/components/Table/ImageCell.tsx b/packages/grafana-ui/src/components/Table/ImageCell.tsx index 4e9543659ec..65fce6214d8 100644 --- a/packages/grafana-ui/src/components/Table/ImageCell.tsx +++ b/packages/grafana-ui/src/components/Table/ImageCell.tsx @@ -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 (
- {/* 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 ? ( getCellLinks(field, row) || []} + actions={actions} > {(api) => { if (api.openMenu) { @@ -59,6 +60,8 @@ export const ImageCell = (props: TableCellProps) => { } }} + ) : ( + img )}
); diff --git a/packages/grafana-ui/src/components/Table/JSONViewCell.tsx b/packages/grafana-ui/src/components/Table/JSONViewCell.tsx index d013d6dc6ad..ea2a3092576 100644 --- a/packages/grafana-ui/src/components/Table/JSONViewCell.tsx +++ b/packages/grafana-ui/src/components/Table/JSONViewCell.tsx @@ -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 (
- {!hasLinks &&
{displayValue}
} - {hasLinks && ( - getCellLinks(field, row) || []}> + {hasLinks || hasActions ? ( + getCellLinks(field, row) || []} actions={actions}> {(api) => { if (api.openMenu) { return ( @@ -50,6 +50,8 @@ export function JSONViewCell(props: TableCellProps): JSX.Element { } }} + ) : ( +
{displayValue}
)}
{inspectEnabled && } diff --git a/packages/grafana-ui/src/components/Table/RowsList.tsx b/packages/grafana-ui/src/components/Table/RowsList.tsx index 977af857af4..47bd3b0efcd 100644 --- a/packages/grafana-ui/src/components/Table/RowsList.tsx +++ b/packages/grafana-ui/src/components/Table/RowsList.tsx @@ -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(initialRowIndex); @@ -334,32 +336,34 @@ export const RowsList = (props: RowsListProps) => { rowExpanded={rowExpanded} textWrapped={textWrapFinal !== undefined} height={Number(style.height)} + getActions={getActions} /> ))}
); }, [ - 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, ] ); diff --git a/packages/grafana-ui/src/components/Table/Table.tsx b/packages/grafana-ui/src/components/Table/Table.tsx index b8538f4bae3..3002400db66 100644 --- a/packages/grafana-ui/src/components/Table/Table.tsx +++ b/packages/grafana-ui/src/components/Table/Table.tsx @@ -59,6 +59,7 @@ export const Table = memo((props: Props) => { enableSharedCrosshair = false, initialRowIndex = undefined, fieldConfig, + getActions, } = props; const listRef = useRef(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} />
) : ( diff --git a/packages/grafana-ui/src/components/Table/TableCell.tsx b/packages/grafana-ui/src/components/Table/TableCell.tsx index 5c4b9f455dc..af7b9716408 100644 --- a/packages/grafana-ui/src/components/Table/TableCell.tsx +++ b/packages/grafana-ui/src/components/Table/TableCell.tsx @@ -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, })} ); diff --git a/packages/grafana-ui/src/components/Table/styles.ts b/packages/grafana-ui/src/components/Table/styles.ts index df34ce6656f..a83d698f774 100644 --- a/packages/grafana-ui/src/components/Table/styles.ts +++ b/packages/grafana-ui/src/components/Table/styles.ts @@ -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), diff --git a/packages/grafana-ui/src/components/Table/types.ts b/packages/grafana-ui/src/components/Table/types.ts index dda99074bd3..58c5079508d 100644 --- a/packages/grafana-ui/src/components/Table/types.ts +++ b/packages/grafana-ui/src/components/Table/types.ts @@ -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 { onCellFilterAdded?: TableFilterActionCallback; innerWidth: number; frame: DataFrame; + actions?: ActionModel[]; } export type CellComponent = FC; @@ -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[]; diff --git a/packages/grafana-ui/src/utils/dataLinks.ts b/packages/grafana-ui/src/utils/dataLinks.ts index e5111f90851..35fe1cffdf7 100644 --- a/packages/grafana-ui/src/utils/dataLinks.ts +++ b/packages/grafana-ui/src/utils/dataLinks.ts @@ -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); diff --git a/public/app/features/actions/ActionsListItem.tsx b/public/app/features/actions/ActionsListItem.tsx index 5d69abfeeb7..a8288d70df7 100644 --- a/public/app/features/actions/ActionsListItem.tsx +++ b/public/app/features/actions/ActionsListItem.tsx @@ -39,8 +39,8 @@ export const ActionListItem = ({ action, onEdit, onRemove, index, itemKey }: Act
- - + +
diff --git a/public/app/plugins/panel/table/TablePanel.tsx b/public/app/plugins/panel/table/TablePanel.tsx index 1e27e99af32..55de542b01b 100644 --- a/public/app/plugins/panel/table/TablePanel.tsx +++ b/public/app/plugins/panel/table/TablePanel.tsx @@ -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, 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> = []; + const actionLookup = new Set(); + + 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;