Logs Panel: Added support to pass custom options to the log rows menu (#95330)

* LogsPanel: add props to prepend or append icons to the log row menu

* LogsPanel: add test and type guard

* LogsPanel: add test

* Fix addonBefore position

* Refactor to be an array of ReactNode

* Remove comment

* chore: add docs

* Linting issues
This commit is contained in:
Matias Chomicki 2024-10-28 17:39:12 +01:00 committed by GitHub
parent d68b5d222a
commit d6efd6d606
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 149 additions and 7 deletions

View File

@ -17,6 +17,8 @@ export interface Options {
displayedFields?: Array<string>; displayedFields?: Array<string>;
enableLogDetails: boolean; enableLogDetails: boolean;
isFilterLabelActive?: unknown; isFilterLabelActive?: unknown;
logRowMenuIconsAfter?: unknown;
logRowMenuIconsBefore?: unknown;
/** /**
* TODO: figure out how to define callbacks * TODO: figure out how to define callbacks
*/ */

View File

@ -2,7 +2,7 @@ import { cx } from '@emotion/css';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import memoizeOne from 'memoize-one'; import memoizeOne from 'memoize-one';
import * as React from 'react'; import * as React from 'react';
import { MouseEvent, PureComponent } from 'react'; import { MouseEvent, PureComponent, ReactNode } from 'react';
import { import {
CoreApp, CoreApp,
@ -65,6 +65,8 @@ interface Props extends Themeable2 {
pinned?: boolean; pinned?: boolean;
containerRendered?: boolean; containerRendered?: boolean;
handleTextSelection?: (e: MouseEvent<HTMLTableRowElement>, row: LogRowModel) => boolean; handleTextSelection?: (e: MouseEvent<HTMLTableRowElement>, row: LogRowModel) => boolean;
logRowMenuIconsBefore?: ReactNode[];
logRowMenuIconsAfter?: ReactNode[];
} }
interface State { interface State {
@ -210,6 +212,8 @@ class UnThemedLogRow extends PureComponent<Props, State> {
styles, styles,
getRowContextQuery, getRowContextQuery,
pinned, pinned,
logRowMenuIconsBefore,
logRowMenuIconsAfter,
} = this.props; } = this.props;
const { showDetails, showingContext, permalinked } = this.state; const { showDetails, showingContext, permalinked } = this.state;
@ -314,6 +318,8 @@ class UnThemedLogRow extends PureComponent<Props, State> {
mouseIsOver={this.state.mouseIsOver} mouseIsOver={this.state.mouseIsOver}
onBlur={this.onMouseLeave} onBlur={this.onMouseLeave}
expanded={this.state.showDetails} expanded={this.state.showDetails}
logRowMenuIconsBefore={logRowMenuIconsBefore}
logRowMenuIconsAfter={logRowMenuIconsAfter}
/> />
)} )}
</tr> </tr>

View File

@ -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 { LogRowContextOptions, LogRowModel, getDefaultTimeRange, locationUtil, urlUtil } from '@grafana/data';
import { DataQuery } from '@grafana/schema'; import { DataQuery } from '@grafana/schema';
@ -26,6 +36,8 @@ interface Props {
mouseIsOver: boolean; mouseIsOver: boolean;
onBlur: () => void; onBlur: () => void;
onPinToContentOutlineClick?: (row: LogRowModel, onOpenContext: (row: LogRowModel) => void) => void; onPinToContentOutlineClick?: (row: LogRowModel, onOpenContext: (row: LogRowModel) => void) => void;
addonBefore?: ReactNode[];
addonAfter?: ReactNode[];
} }
export const LogRowMenuCell = memo( export const LogRowMenuCell = memo(
@ -43,13 +55,18 @@ export const LogRowMenuCell = memo(
mouseIsOver, mouseIsOver,
onBlur, onBlur,
getRowContextQuery, getRowContextQuery,
addonBefore,
addonAfter,
}: Props) => { }: Props) => {
const shouldShowContextToggle = showContextToggle ? showContextToggle(row) : false; const shouldShowContextToggle = useMemo(
() => (showContextToggle ? showContextToggle(row) : false),
[row, showContextToggle]
);
const onLogRowClick = useCallback((e: SyntheticEvent) => { const onLogRowClick = useCallback((e: SyntheticEvent) => {
e.stopPropagation(); e.stopPropagation();
}, []); }, []);
const onShowContextClick = useCallback( const onShowContextClick = useCallback(
async (event: SyntheticEvent<HTMLButtonElement, MouseEvent>) => { async (event: MouseEvent<HTMLButtonElement>) => {
event.stopPropagation(); event.stopPropagation();
// if ctrl or meta key is pressed, open query in new Explore tab // if ctrl or meta key is pressed, open query in new Explore tab
if ( if (
@ -90,6 +107,21 @@ export const LogRowMenuCell = memo(
[onBlur] [onBlur]
); );
const getLogText = useCallback(() => logText, [logText]); 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 ( return (
// We keep this click listener here to prevent the row from being selected when clicking on the menu. // 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 // 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 && ( {mouseIsOver && (
<> <>
{beforeContent}
{shouldShowContextToggle && ( {shouldShowContextToggle && (
<IconButton <IconButton
size="md" size="md"
@ -165,6 +198,7 @@ export const LogRowMenuCell = memo(
tabIndex={0} tabIndex={0}
/> />
)} )}
{afterContent}
</> </>
)} )}
</span> </span>
@ -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<HTMLElement>) => {
onClick(event, row);
},
key: index,
});
}
return node;
});
}
LogRowMenuCell.displayName = 'LogRowMenuCell'; LogRowMenuCell.displayName = 'LogRowMenuCell';

View File

@ -3,6 +3,7 @@ import userEvent from '@testing-library/user-event';
import { ComponentProps } from 'react'; import { ComponentProps } from 'react';
import { CoreApp, createTheme, LogLevel, LogRowModel } from '@grafana/data'; import { CoreApp, createTheme, LogLevel, LogRowModel } from '@grafana/data';
import { IconButton } from '@grafana/ui';
import { LogRowMessage } from './LogRowMessage'; import { LogRowMessage } from './LogRowMessage';
import { createLogRow } from './__mocks__/logRow'; import { createLogRow } from './__mocks__/logRow';
@ -197,4 +198,26 @@ line3`;
expect(screen.queryByText(singleLineEntry)).not.toBeInTheDocument(); 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 = [
<IconButton name="eye-slash" onClick={onBefore} tooltip="Addon before" aria-label="Addon before" key={1} />,
];
const onAfter = jest.fn();
const logRowMenuIconsAfter = [
<IconButton name="rss" onClick={onAfter} tooltip="Addon after" aria-label="Addon after" key={1} />,
];
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);
});
});
}); });

View File

@ -1,4 +1,4 @@
import { memo, useMemo } from 'react'; import { memo, ReactNode, useMemo } from 'react';
import Highlighter from 'react-highlight-words'; import Highlighter from 'react-highlight-words';
import { CoreApp, findHighlightChunksInText, LogRowContextOptions, LogRowModel } from '@grafana/data'; import { CoreApp, findHighlightChunksInText, LogRowContextOptions, LogRowModel } from '@grafana/data';
@ -32,6 +32,8 @@ interface Props {
mouseIsOver: boolean; mouseIsOver: boolean;
onBlur: () => void; onBlur: () => void;
expanded?: boolean; expanded?: boolean;
logRowMenuIconsBefore?: ReactNode[];
logRowMenuIconsAfter?: ReactNode[];
} }
interface LogMessageProps { interface LogMessageProps {
@ -96,6 +98,8 @@ export const LogRowMessage = memo((props: Props) => {
onBlur, onBlur,
getRowContextQuery, getRowContextQuery,
expanded, expanded,
logRowMenuIconsBefore,
logRowMenuIconsAfter,
} = props; } = props;
const { hasAnsi, raw } = row; const { hasAnsi, raw } = row;
const restructuredEntry = useMemo( const restructuredEntry = useMemo(
@ -132,6 +136,8 @@ export const LogRowMessage = memo((props: Props) => {
styles={styles} styles={styles}
mouseIsOver={mouseIsOver} mouseIsOver={mouseIsOver}
onBlur={onBlur} onBlur={onBlur}
addonBefore={logRowMenuIconsBefore}
addonAfter={logRowMenuIconsAfter}
/> />
)} )}
</td> </td>

View File

@ -1,6 +1,6 @@
import { cx } from '@emotion/css'; import { cx } from '@emotion/css';
import memoizeOne from 'memoize-one'; import memoizeOne from 'memoize-one';
import { PureComponent, MouseEvent, createRef } from 'react'; import { PureComponent, MouseEvent, createRef, ReactNode } from 'react';
import { import {
TimeZone, TimeZone,
@ -72,6 +72,8 @@ export interface Props extends Themeable2 {
overflowingContent?: boolean; overflowingContent?: boolean;
onClickFilterString?: (value: string, refId?: string) => void; onClickFilterString?: (value: string, refId?: string) => void;
onClickFilterOutString?: (value: string, refId?: string) => void; onClickFilterOutString?: (value: string, refId?: string) => void;
logRowMenuIconsBefore?: ReactNode[];
logRowMenuIconsAfter?: ReactNode[];
} }
interface State { interface State {

View File

@ -262,6 +262,33 @@ describe('LogsPanel', () => {
expect(showContextDs.getLogRowContext).toBeCalled(); expect(showContextDs.getLogRowContext).toBeCalled();
}); });
}); });
it('supports adding custom options to the log row menu', async () => {
const logRowMenuIconsBefore = [
<grafanaUI.IconButton name="eye-slash" tooltip="Addon before" aria-label="Addon before" key={1} />,
];
const logRowMenuIconsAfter = [
<grafanaUI.IconButton name="rss" tooltip="Addon after" aria-label="Addon after" key={1} />,
];
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', () => { describe('Performance regressions', () => {
@ -571,7 +598,7 @@ describe('LogsPanel', () => {
}); });
}); });
const setup = (propsOverrides?: {}) => { const setup = (propsOverrides?: {}, optionOverrides?: {}) => {
const props: LogsPanelProps = { const props: LogsPanelProps = {
data: { data: {
error: undefined, error: undefined,
@ -604,6 +631,7 @@ const setup = (propsOverrides?: {}) => {
dedupStrategy: LogsDedupStrategy.none, dedupStrategy: LogsDedupStrategy.none,
enableLogDetails: true, enableLogDetails: true,
showLogContextToggle: false, showLogContextToggle: false,
...optionOverrides,
}, },
title: 'Logs panel', title: 'Logs panel',
id: 1, id: 1,

View File

@ -38,6 +38,7 @@ import {
isOnClickFilterString, isOnClickFilterString,
isOnClickHideField, isOnClickHideField,
isOnClickShowField, isOnClickShowField,
isReactNodeArray,
Options, Options,
} from './types'; } from './types';
import { useDatasourcesFromTargets } from './useDatasourcesFromTargets'; import { useDatasourcesFromTargets } from './useDatasourcesFromTargets';
@ -67,6 +68,12 @@ interface LogsPanelProps extends PanelProps<Options> {
* *
* Called from the "eye" icon in Log Details to request hiding the displayed field. If ommited, a default implementation is used. * 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; * 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 { interface LogsPermalinkUrlState {
@ -96,6 +103,8 @@ export const LogsPanel = ({
onClickFilterOutString, onClickFilterOutString,
onClickFilterString, onClickFilterString,
isFilterLabelActive, isFilterLabelActive,
logRowMenuIconsBefore,
logRowMenuIconsAfter,
...options ...options
}, },
id, id,
@ -389,6 +398,8 @@ export const LogsPanel = ({
displayedFields={displayedFields} displayedFields={displayedFields}
onClickShowField={displayedFields !== undefined ? onClickShowField : undefined} onClickShowField={displayedFields !== undefined ? onClickShowField : undefined}
onClickHideField={displayedFields !== undefined ? onClickHideField : undefined} onClickHideField={displayedFields !== undefined ? onClickHideField : undefined}
logRowMenuIconsBefore={isReactNodeArray(logRowMenuIconsBefore) ? logRowMenuIconsBefore : undefined}
logRowMenuIconsAfter={isReactNodeArray(logRowMenuIconsAfter) ? logRowMenuIconsAfter : undefined}
/> />
{showCommonLabels && isAscending && renderCommonLabels()} {showCommonLabels && isAscending && renderCommonLabels()}
</div> </div>

View File

@ -43,6 +43,8 @@ composableKinds: PanelCfg: {
onClickFilterOutString?: _ onClickFilterOutString?: _
onClickShowField?: _ onClickShowField?: _
onClickHideField?: _ onClickHideField?: _
logRowMenuIconsBefore?: _
logRowMenuIconsAfter?: _
displayedFields?: [...string] displayedFields?: [...string]
} @cuetsy(kind="interface") } @cuetsy(kind="interface")
} }

View File

@ -15,6 +15,8 @@ export interface Options {
displayedFields?: Array<string>; displayedFields?: Array<string>;
enableLogDetails: boolean; enableLogDetails: boolean;
isFilterLabelActive?: unknown; isFilterLabelActive?: unknown;
logRowMenuIconsAfter?: unknown;
logRowMenuIconsBefore?: unknown;
/** /**
* TODO: figure out how to define callbacks * TODO: figure out how to define callbacks
*/ */

View File

@ -1,3 +1,5 @@
import React, { ReactNode } from 'react';
import { DataFrame } from '@grafana/data'; import { DataFrame } from '@grafana/data';
export { Options } from './panelcfg.gen'; 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 { export function isOnClickHideField(callback: unknown): callback is isOnClickHideFieldType {
return typeof callback === 'function'; return typeof callback === 'function';
} }
export function isReactNodeArray(node: unknown): node is ReactNode[] {
return Array.isArray(node) && node.every(React.isValidElement);
}