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>;
enableLogDetails: boolean;
isFilterLabelActive?: unknown;
logRowMenuIconsAfter?: unknown;
logRowMenuIconsBefore?: unknown;
/**
* TODO: figure out how to define callbacks
*/

View File

@ -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<HTMLTableRowElement>, row: LogRowModel) => boolean;
logRowMenuIconsBefore?: ReactNode[];
logRowMenuIconsAfter?: ReactNode[];
}
interface State {
@ -210,6 +212,8 @@ class UnThemedLogRow extends PureComponent<Props, State> {
styles,
getRowContextQuery,
pinned,
logRowMenuIconsBefore,
logRowMenuIconsAfter,
} = this.props;
const { showDetails, showingContext, permalinked } = this.state;
@ -314,6 +318,8 @@ class UnThemedLogRow extends PureComponent<Props, State> {
mouseIsOver={this.state.mouseIsOver}
onBlur={this.onMouseLeave}
expanded={this.state.showDetails}
logRowMenuIconsBefore={logRowMenuIconsBefore}
logRowMenuIconsAfter={logRowMenuIconsAfter}
/>
)}
</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 { 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<HTMLButtonElement, MouseEvent>) => {
async (event: MouseEvent<HTMLButtonElement>) => {
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 && (
<IconButton
size="md"
@ -165,6 +198,7 @@ export const LogRowMenuCell = memo(
tabIndex={0}
/>
)}
{afterContent}
</>
)}
</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';

View File

@ -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 = [
<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 { 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}
/>
)}
</td>

View File

@ -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 {

View File

@ -262,6 +262,33 @@ describe('LogsPanel', () => {
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', () => {
@ -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,

View File

@ -38,6 +38,7 @@ import {
isOnClickFilterString,
isOnClickHideField,
isOnClickShowField,
isReactNodeArray,
Options,
} from './types';
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.
* 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()}
</div>

View File

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

View File

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

View File

@ -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);
}