mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
d68b5d222a
commit
d6efd6d606
@ -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
|
||||
*/
|
||||
|
@ -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>
|
||||
|
@ -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';
|
||||
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -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>
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
|
@ -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>
|
||||
|
@ -43,6 +43,8 @@ composableKinds: PanelCfg: {
|
||||
onClickFilterOutString?: _
|
||||
onClickShowField?: _
|
||||
onClickHideField?: _
|
||||
logRowMenuIconsBefore?: _
|
||||
logRowMenuIconsAfter?: _
|
||||
displayedFields?: [...string]
|
||||
} @cuetsy(kind="interface")
|
||||
}
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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);
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user