diff --git a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts index 5e31328c325..1fbf39c775d 100644 --- a/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts +++ b/packages/grafana-schema/src/raw/composable/logs/panelcfg/x/LogsPanelCfg_types.gen.ts @@ -17,6 +17,8 @@ export interface Options { displayedFields?: Array; enableLogDetails: boolean; isFilterLabelActive?: unknown; + logRowMenuIconsAfter?: unknown; + logRowMenuIconsBefore?: unknown; /** * TODO: figure out how to define callbacks */ diff --git a/public/app/features/logs/components/LogRow.tsx b/public/app/features/logs/components/LogRow.tsx index 2db64630c27..686f49e19a4 100644 --- a/public/app/features/logs/components/LogRow.tsx +++ b/public/app/features/logs/components/LogRow.tsx @@ -2,7 +2,7 @@ import { cx } from '@emotion/css'; import { debounce } from 'lodash'; import memoizeOne from 'memoize-one'; import * as React from 'react'; -import { MouseEvent, PureComponent } from 'react'; +import { MouseEvent, PureComponent, ReactNode } from 'react'; import { CoreApp, @@ -65,6 +65,8 @@ interface Props extends Themeable2 { pinned?: boolean; containerRendered?: boolean; handleTextSelection?: (e: MouseEvent, row: LogRowModel) => boolean; + logRowMenuIconsBefore?: ReactNode[]; + logRowMenuIconsAfter?: ReactNode[]; } interface State { @@ -210,6 +212,8 @@ class UnThemedLogRow extends PureComponent { styles, getRowContextQuery, pinned, + logRowMenuIconsBefore, + logRowMenuIconsAfter, } = this.props; const { showDetails, showingContext, permalinked } = this.state; @@ -314,6 +318,8 @@ class UnThemedLogRow extends PureComponent { mouseIsOver={this.state.mouseIsOver} onBlur={this.onMouseLeave} expanded={this.state.showDetails} + logRowMenuIconsBefore={logRowMenuIconsBefore} + logRowMenuIconsAfter={logRowMenuIconsAfter} /> )} diff --git a/public/app/features/logs/components/LogRowMenuCell.tsx b/public/app/features/logs/components/LogRowMenuCell.tsx index 5d3ac4be79a..ec9a2bd2aaf 100644 --- a/public/app/features/logs/components/LogRowMenuCell.tsx +++ b/public/app/features/logs/components/LogRowMenuCell.tsx @@ -1,4 +1,14 @@ -import { memo, FocusEvent, SyntheticEvent, useCallback } from 'react'; +import { + memo, + FocusEvent, + SyntheticEvent, + useCallback, + ReactNode, + useMemo, + cloneElement, + isValidElement, + MouseEvent, +} from 'react'; import { LogRowContextOptions, LogRowModel, getDefaultTimeRange, locationUtil, urlUtil } from '@grafana/data'; import { DataQuery } from '@grafana/schema'; @@ -26,6 +36,8 @@ interface Props { mouseIsOver: boolean; onBlur: () => void; onPinToContentOutlineClick?: (row: LogRowModel, onOpenContext: (row: LogRowModel) => void) => void; + addonBefore?: ReactNode[]; + addonAfter?: ReactNode[]; } export const LogRowMenuCell = memo( @@ -43,13 +55,18 @@ export const LogRowMenuCell = memo( mouseIsOver, onBlur, getRowContextQuery, + addonBefore, + addonAfter, }: Props) => { - const shouldShowContextToggle = showContextToggle ? showContextToggle(row) : false; + const shouldShowContextToggle = useMemo( + () => (showContextToggle ? showContextToggle(row) : false), + [row, showContextToggle] + ); const onLogRowClick = useCallback((e: SyntheticEvent) => { e.stopPropagation(); }, []); const onShowContextClick = useCallback( - async (event: SyntheticEvent) => { + async (event: MouseEvent) => { event.stopPropagation(); // if ctrl or meta key is pressed, open query in new Explore tab if ( @@ -90,6 +107,21 @@ export const LogRowMenuCell = memo( [onBlur] ); const getLogText = useCallback(() => logText, [logText]); + + const beforeContent = useMemo(() => { + if (!addonBefore) { + return null; + } + return addClickListenersToNode(addonBefore, row); + }, [addonBefore, row]); + + const afterContent = useMemo(() => { + if (!addonAfter) { + return null; + } + return addClickListenersToNode(addonAfter, row); + }, [addonAfter, row]); + return ( // We keep this click listener here to prevent the row from being selected when clicking on the menu. // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions @@ -108,6 +140,7 @@ export const LogRowMenuCell = memo( )} {mouseIsOver && ( <> + {beforeContent} {shouldShowContextToggle && ( )} + {afterContent} )} @@ -172,4 +206,24 @@ export const LogRowMenuCell = memo( } ); +type AddonOnClickListener = (event: MouseEvent, row: LogRowModel) => void | undefined; +function addClickListenersToNode(nodes: ReactNode[], row: LogRowModel) { + return nodes.map((node, index) => { + if (isValidElement(node)) { + const onClick: AddonOnClickListener = node.props.onClick; + if (!onClick) { + return node; + } + return cloneElement(node, { + // @ts-expect-error + onClick: (event: MouseEvent) => { + onClick(event, row); + }, + key: index, + }); + } + return node; + }); +} + LogRowMenuCell.displayName = 'LogRowMenuCell'; diff --git a/public/app/features/logs/components/LogRowMessage.test.tsx b/public/app/features/logs/components/LogRowMessage.test.tsx index a09d888a157..52ea726dcce 100644 --- a/public/app/features/logs/components/LogRowMessage.test.tsx +++ b/public/app/features/logs/components/LogRowMessage.test.tsx @@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event'; import { ComponentProps } from 'react'; import { CoreApp, createTheme, LogLevel, LogRowModel } from '@grafana/data'; +import { IconButton } from '@grafana/ui'; import { LogRowMessage } from './LogRowMessage'; import { createLogRow } from './__mocks__/logRow'; @@ -197,4 +198,26 @@ line3`; expect(screen.queryByText(singleLineEntry)).not.toBeInTheDocument(); }); }); + + describe('With custom buttons', () => { + it('supports custom buttons before and after the default options', async () => { + const onBefore = jest.fn(); + const logRowMenuIconsBefore = [ + , + ]; + const onAfter = jest.fn(); + const logRowMenuIconsAfter = [ + , + ]; + + const { row } = setup({ logRowMenuIconsBefore, logRowMenuIconsAfter }); + + await userEvent.hover(screen.getByText('test123')); + await userEvent.click(screen.getByLabelText('Addon before')); + await userEvent.click(screen.getByLabelText('Addon after')); + + expect(onBefore).toHaveBeenCalledWith(expect.anything(), row); + expect(onAfter).toHaveBeenCalledWith(expect.anything(), row); + }); + }); }); diff --git a/public/app/features/logs/components/LogRowMessage.tsx b/public/app/features/logs/components/LogRowMessage.tsx index 278854465f5..6202c4af3ed 100644 --- a/public/app/features/logs/components/LogRowMessage.tsx +++ b/public/app/features/logs/components/LogRowMessage.tsx @@ -1,4 +1,4 @@ -import { memo, useMemo } from 'react'; +import { memo, ReactNode, useMemo } from 'react'; import Highlighter from 'react-highlight-words'; import { CoreApp, findHighlightChunksInText, LogRowContextOptions, LogRowModel } from '@grafana/data'; @@ -32,6 +32,8 @@ interface Props { mouseIsOver: boolean; onBlur: () => void; expanded?: boolean; + logRowMenuIconsBefore?: ReactNode[]; + logRowMenuIconsAfter?: ReactNode[]; } interface LogMessageProps { @@ -96,6 +98,8 @@ export const LogRowMessage = memo((props: Props) => { onBlur, getRowContextQuery, expanded, + logRowMenuIconsBefore, + logRowMenuIconsAfter, } = props; const { hasAnsi, raw } = row; const restructuredEntry = useMemo( @@ -132,6 +136,8 @@ export const LogRowMessage = memo((props: Props) => { styles={styles} mouseIsOver={mouseIsOver} onBlur={onBlur} + addonBefore={logRowMenuIconsBefore} + addonAfter={logRowMenuIconsAfter} /> )} diff --git a/public/app/features/logs/components/LogRows.tsx b/public/app/features/logs/components/LogRows.tsx index 6902d2175aa..96d1bd2e71e 100644 --- a/public/app/features/logs/components/LogRows.tsx +++ b/public/app/features/logs/components/LogRows.tsx @@ -1,6 +1,6 @@ import { cx } from '@emotion/css'; import memoizeOne from 'memoize-one'; -import { PureComponent, MouseEvent, createRef } from 'react'; +import { PureComponent, MouseEvent, createRef, ReactNode } from 'react'; import { TimeZone, @@ -72,6 +72,8 @@ export interface Props extends Themeable2 { overflowingContent?: boolean; onClickFilterString?: (value: string, refId?: string) => void; onClickFilterOutString?: (value: string, refId?: string) => void; + logRowMenuIconsBefore?: ReactNode[]; + logRowMenuIconsAfter?: ReactNode[]; } interface State { diff --git a/public/app/plugins/panel/logs/LogsPanel.test.tsx b/public/app/plugins/panel/logs/LogsPanel.test.tsx index 12520a140a6..fa7acd3afa9 100644 --- a/public/app/plugins/panel/logs/LogsPanel.test.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.test.tsx @@ -262,6 +262,33 @@ describe('LogsPanel', () => { expect(showContextDs.getLogRowContext).toBeCalled(); }); }); + + it('supports adding custom options to the log row menu', async () => { + const logRowMenuIconsBefore = [ + , + ]; + const logRowMenuIconsAfter = [ + , + ]; + + setup( + { + data: { + series, + }, + }, + { + logRowMenuIconsBefore, + logRowMenuIconsAfter, + } + ); + + await waitFor(async () => { + await userEvent.hover(screen.getByText(/logline text/i)); + expect(screen.getByLabelText('Addon before')).toBeInTheDocument(); + expect(screen.getByLabelText('Addon after')).toBeInTheDocument(); + }); + }); }); describe('Performance regressions', () => { @@ -571,7 +598,7 @@ describe('LogsPanel', () => { }); }); -const setup = (propsOverrides?: {}) => { +const setup = (propsOverrides?: {}, optionOverrides?: {}) => { const props: LogsPanelProps = { data: { error: undefined, @@ -604,6 +631,7 @@ const setup = (propsOverrides?: {}) => { dedupStrategy: LogsDedupStrategy.none, enableLogDetails: true, showLogContextToggle: false, + ...optionOverrides, }, title: 'Logs panel', id: 1, diff --git a/public/app/plugins/panel/logs/LogsPanel.tsx b/public/app/plugins/panel/logs/LogsPanel.tsx index a6fcd2eb53d..9d1db742554 100644 --- a/public/app/plugins/panel/logs/LogsPanel.tsx +++ b/public/app/plugins/panel/logs/LogsPanel.tsx @@ -38,6 +38,7 @@ import { isOnClickFilterString, isOnClickHideField, isOnClickShowField, + isReactNodeArray, Options, } from './types'; import { useDatasourcesFromTargets } from './useDatasourcesFromTargets'; @@ -67,6 +68,12 @@ interface LogsPanelProps extends PanelProps { * * Called from the "eye" icon in Log Details to request hiding the displayed field. If ommited, a default implementation is used. * onClickHideField?: (key: string) => void; + * + * Passed to the LogRowMenuCell component to be rendered before the default actions in the menu. + * logRowMenuIconsBefore?: ReactNode[]; + * + * Passed to the LogRowMenuCell component to be rendered after the default actions in the menu. + * logRowMenuIconsAfter?: ReactNode[]; */ } interface LogsPermalinkUrlState { @@ -96,6 +103,8 @@ export const LogsPanel = ({ onClickFilterOutString, onClickFilterString, isFilterLabelActive, + logRowMenuIconsBefore, + logRowMenuIconsAfter, ...options }, id, @@ -389,6 +398,8 @@ export const LogsPanel = ({ displayedFields={displayedFields} onClickShowField={displayedFields !== undefined ? onClickShowField : undefined} onClickHideField={displayedFields !== undefined ? onClickHideField : undefined} + logRowMenuIconsBefore={isReactNodeArray(logRowMenuIconsBefore) ? logRowMenuIconsBefore : undefined} + logRowMenuIconsAfter={isReactNodeArray(logRowMenuIconsAfter) ? logRowMenuIconsAfter : undefined} /> {showCommonLabels && isAscending && renderCommonLabels()} diff --git a/public/app/plugins/panel/logs/panelcfg.cue b/public/app/plugins/panel/logs/panelcfg.cue index f843bdb5706..1a9a4c4ffcf 100644 --- a/public/app/plugins/panel/logs/panelcfg.cue +++ b/public/app/plugins/panel/logs/panelcfg.cue @@ -43,6 +43,8 @@ composableKinds: PanelCfg: { onClickFilterOutString?: _ onClickShowField?: _ onClickHideField?: _ + logRowMenuIconsBefore?: _ + logRowMenuIconsAfter?: _ displayedFields?: [...string] } @cuetsy(kind="interface") } diff --git a/public/app/plugins/panel/logs/panelcfg.gen.ts b/public/app/plugins/panel/logs/panelcfg.gen.ts index b8139c88826..51b5c87741b 100644 --- a/public/app/plugins/panel/logs/panelcfg.gen.ts +++ b/public/app/plugins/panel/logs/panelcfg.gen.ts @@ -15,6 +15,8 @@ export interface Options { displayedFields?: Array; enableLogDetails: boolean; isFilterLabelActive?: unknown; + logRowMenuIconsAfter?: unknown; + logRowMenuIconsBefore?: unknown; /** * TODO: figure out how to define callbacks */ diff --git a/public/app/plugins/panel/logs/types.ts b/public/app/plugins/panel/logs/types.ts index 6fc10e9970e..4f4c62f722f 100644 --- a/public/app/plugins/panel/logs/types.ts +++ b/public/app/plugins/panel/logs/types.ts @@ -1,3 +1,5 @@ +import React, { ReactNode } from 'react'; + import { DataFrame } from '@grafana/data'; export { Options } from './panelcfg.gen'; @@ -37,3 +39,7 @@ export function isOnClickShowField(callback: unknown): callback is isOnClickShow export function isOnClickHideField(callback: unknown): callback is isOnClickHideFieldType { return typeof callback === 'function'; } + +export function isReactNodeArray(node: unknown): node is ReactNode[] { + return Array.isArray(node) && node.every(React.isValidElement); +}