From b8fbeb084a491166739ff73736b3937a3a62008f Mon Sep 17 00:00:00 2001 From: Matias Chomicki Date: Tue, 11 Jul 2023 14:50:53 +0200 Subject: [PATCH] Logs: Display log row menu cell on displayed fields (#71300) * LogRowMenuCell: create component * LogRowMessage: use new LogRowMenuCell component * LogRowMessage: turn into functional component * LogRowMenuCell: memoize component * LogRowMessage: remove cx * LogMessage: create component from function * LogRowMessageDisplayedFields: turn into component * LogRowMessageDisplayedFields: add LogRowMenuCell * LogRowMessageDisplayedFields: rename prop and pass missing context prop * LogRowMessageDisplayedFields: add unit test --- .../app/features/logs/components/LogRow.tsx | 9 +- .../logs/components/LogRowMenuCell.tsx | 122 +++++++++++ .../logs/components/LogRowMessage.tsx | 205 ++++++------------ .../LogRowMessageDisplayedFields.test.tsx | 46 ++++ .../LogRowMessageDisplayedFields.tsx | 84 ++++--- 5 files changed, 287 insertions(+), 179 deletions(-) create mode 100644 public/app/features/logs/components/LogRowMenuCell.tsx create mode 100644 public/app/features/logs/components/LogRowMessageDisplayedFields.test.tsx diff --git a/public/app/features/logs/components/LogRow.tsx b/public/app/features/logs/components/LogRow.tsx index eafd0f7551c..2872ed72753 100644 --- a/public/app/features/logs/components/LogRow.tsx +++ b/public/app/features/logs/components/LogRow.tsx @@ -225,9 +225,16 @@ class UnThemedLogRow extends PureComponent { {displayedFields && displayedFields.length > 0 ? ( ) : ( boolean; + onOpenContext: (row: LogRowModel) => void; + onPermalinkClick?: (row: LogRowModel) => Promise; + onPinLine?: (row: LogRowModel) => void; + onUnpinLine?: (row: LogRowModel) => void; + pinned?: boolean; + styles: LogRowStyles; +} + +export const LogRowMenuCell = React.memo( + ({ + logText, + onOpenContext, + onPermalinkClick, + onPinLine, + onUnpinLine, + pinned, + row, + showContextToggle, + styles, + }: Props) => { + const shouldShowContextToggle = showContextToggle ? showContextToggle(row) : false; + const onLogRowClick = useCallback((e: SyntheticEvent) => { + e.stopPropagation(); + }, []); + const onShowContextClick = useCallback( + (e: SyntheticEvent) => { + e.stopPropagation(); + onOpenContext(row); + }, + [onOpenContext, row] + ); + const getLogText = useCallback(() => logText, [logText]); + return ( + <> + {pinned && ( + // TODO: fix keyboard a11y + // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions + + onUnpinLine && onUnpinLine(row)} + tooltip="Unpin line" + tooltipPlacement="top" + aria-label="Unpin line" + /> + + )} + {/* TODO: fix keyboard a11y */} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} + + {shouldShowContextToggle && ( + + )} + + {pinned && onUnpinLine && ( + onUnpinLine && onUnpinLine(row)} + tooltip="Unpin line" + tooltipPlacement="top" + aria-label="Unpin line" + /> + )} + {!pinned && onPinLine && ( + onPinLine && onPinLine(row)} + tooltip="Pin line" + tooltipPlacement="top" + aria-label="Pin line" + /> + )} + {onPermalinkClick && row.uid && ( + onPermalinkClick(row)} + /> + )} + + + ); + } +); + +LogRowMenuCell.displayName = 'LogRowMenuCell'; diff --git a/public/app/features/logs/components/LogRowMessage.tsx b/public/app/features/logs/components/LogRowMessage.tsx index 95bbe28d380..206cd636968 100644 --- a/public/app/features/logs/components/LogRowMessage.tsx +++ b/public/app/features/logs/components/LogRowMessage.tsx @@ -1,12 +1,10 @@ -import { cx } from '@emotion/css'; -import memoizeOne from 'memoize-one'; -import React, { PureComponent } from 'react'; +import React, { useMemo } from 'react'; import Highlighter from 'react-highlight-words'; import { CoreApp, findHighlightChunksInText, LogRowModel } from '@grafana/data'; -import { ClipboardButton, IconButton } from '@grafana/ui'; import { LogMessageAnsi } from './LogMessageAnsi'; +import { LogRowMenuCell } from './LogRowMenuCell'; import { LogRowStyles } from './getLogRowStyles'; export const MAX_CHARACTERS = 100000; @@ -25,17 +23,19 @@ interface Props { styles: LogRowStyles; } -function renderLogMessage( - hasAnsi: boolean, - entry: string, - highlights: string[] | undefined, - highlightClassName: string -) { +interface LogMessageProps { + hasAnsi: boolean; + entry: string; + highlights: string[] | undefined; + styles: LogRowStyles; +} + +const LogMessage = ({ hasAnsi, entry, highlights, styles }: LogMessageProps) => { const needsHighlighter = highlights && highlights.length > 0 && highlights[0] && highlights[0].length > 0 && entry.length < MAX_CHARACTERS; const searchWords = highlights ?? []; if (hasAnsi) { - const highlight = needsHighlighter ? { searchWords, highlightClassName } : undefined; + const highlight = needsHighlighter ? { searchWords, highlightClassName: styles.logsRowMatchHighLight } : undefined; return ; } else if (needsHighlighter) { return ( @@ -43,15 +43,14 @@ function renderLogMessage( textToHighlight={entry} searchWords={searchWords} findChunks={findHighlightChunksInText} - highlightClassName={highlightClassName} + highlightClassName={styles.logsRowMatchHighLight} /> ); - } else { - return entry; } -} + return <>{entry}; +}; -const restructureLog = memoizeOne((line: string, prettifyLogMessage: boolean): string => { +const restructureLog = (line: string, prettifyLogMessage: boolean): string => { if (prettifyLogMessage) { try { return JSON.stringify(JSON.parse(line), undefined, 2); @@ -60,133 +59,51 @@ const restructureLog = memoizeOne((line: string, prettifyLogMessage: boolean): s } } return line; +}; + +export const LogRowMessage = React.memo((props: Props) => { + const { + row, + wrapLogMessage, + prettifyLogMessage, + showContextToggle, + styles, + onOpenContext, + onPermalinkClick, + onUnpinLine, + onPinLine, + pinned, + } = props; + const { hasAnsi, raw } = row; + const restructuredEntry = useMemo(() => restructureLog(raw, prettifyLogMessage), [raw, prettifyLogMessage]); + return ( + <> + { + // When context is open, the position has to be NOT relative. // Setting the postion as inline-style to + // overwrite the more sepecific style definition from `styles.logsRowMessage`. + } + +
+ +
+ + + + + + ); }); -export class LogRowMessage extends PureComponent { - onShowContextClick = (e: React.SyntheticEvent) => { - const { onOpenContext } = this.props; - e.stopPropagation(); - onOpenContext(this.props.row); - }; - - onLogRowClick = (e: React.SyntheticEvent) => { - e.stopPropagation(); - }; - - getLogText = () => { - const { row, prettifyLogMessage } = this.props; - const { raw } = row; - return restructureLog(raw, prettifyLogMessage); - }; - - render() { - const { - row, - wrapLogMessage, - prettifyLogMessage, - showContextToggle, - styles, - onPermalinkClick, - onUnpinLine, - onPinLine, - pinned, - } = this.props; - const { hasAnsi, raw } = row; - const restructuredEntry = restructureLog(raw, prettifyLogMessage); - const shouldShowContextToggle = showContextToggle ? showContextToggle(row) : false; - - return ( - <> - { - // When context is open, the position has to be NOT relative. // Setting the postion as inline-style to - // overwrite the more sepecific style definition from `styles.logsRowMessage`. - } - -
- -
- - - {pinned && ( - // TODO: fix keyboard a11y - // eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions - - onUnpinLine && onUnpinLine(row)} - tooltip="Unpin line" - tooltipPlacement="top" - aria-label="Unpin line" - /> - - )} - {/* TODO: fix keyboard a11y */} - {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} - - {shouldShowContextToggle && ( - - )} - - {pinned && onUnpinLine && ( - onUnpinLine && onUnpinLine(row)} - tooltip="Unpin line" - tooltipPlacement="top" - aria-label="Unpin line" - /> - )} - {!pinned && onPinLine && ( - onPinLine && onPinLine(row)} - tooltip="Pin line" - tooltipPlacement="top" - aria-label="Pin line" - /> - )} - {onPermalinkClick && row.uid && ( - onPermalinkClick(row)} - /> - )} - - - - ); - } -} +LogRowMessage.displayName = 'LogRowMessage'; diff --git a/public/app/features/logs/components/LogRowMessageDisplayedFields.test.tsx b/public/app/features/logs/components/LogRowMessageDisplayedFields.test.tsx new file mode 100644 index 00000000000..9cf3d94d166 --- /dev/null +++ b/public/app/features/logs/components/LogRowMessageDisplayedFields.test.tsx @@ -0,0 +1,46 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import { createTheme, LogLevel } from '@grafana/data'; + +import { LogRowMessageDisplayedFields, Props } from './LogRowMessageDisplayedFields'; +import { createLogRow } from './__mocks__/logRow'; +import { getLogRowStyles } from './getLogRowStyles'; + +const setup = (propOverrides: Partial = {}, detectedFields = ['place', 'planet']) => { + const theme = createTheme(); + const styles = getLogRowStyles(theme); + const labels = { + place: 'Earth', + planet: 'Mars', + }; + const props: Props = { + wrapLogMessage: false, + row: createLogRow({ entry: 'Logs are wonderful', logLevel: LogLevel.error, timeEpochMs: 1546297200000, labels }), + onOpenContext: () => {}, + styles, + detectedFields, + ...propOverrides, + }; + + render( + + + + + + +
+ ); + + return props; +}; + +describe('LogRowMessageDisplayedFields', () => { + it('renders diplayed fields from a log row', () => { + setup(); + expect(screen.queryByText('Logs are wonderful')).not.toBeInTheDocument(); + expect(screen.getByText(/place=Earth/)).toBeInTheDocument(); + expect(screen.getByText(/planet=Mars/)).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx b/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx index cf42745a187..d8ed4f29ea4 100644 --- a/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx +++ b/public/app/features/logs/components/LogRowMessageDisplayedFields.tsx @@ -1,51 +1,67 @@ import { css } from '@emotion/css'; -import React, { PureComponent } from 'react'; +import React from 'react'; import { LogRowModel, Field, LinkModel, DataFrame } from '@grafana/data'; -import { withTheme2, Themeable2 } from '@grafana/ui'; +import { LogRowMenuCell } from './LogRowMenuCell'; +import { LogRowStyles } from './getLogRowStyles'; import { getAllFields } from './logParser'; -export interface Props extends Themeable2 { +export interface Props { row: LogRowModel; - showDetectedFields: string[]; + detectedFields: string[]; wrapLogMessage: boolean; getFieldLinks?: (field: Field, rowIndex: number, dataFrame: DataFrame) => Array>; + styles: LogRowStyles; + showContextToggle?: (row?: LogRowModel) => boolean; + onOpenContext: (row: LogRowModel) => void; + onPermalinkClick?: (row: LogRowModel) => Promise; + onPinLine?: (row: LogRowModel) => void; + onUnpinLine?: (row: LogRowModel) => void; + pinned?: boolean; } -class UnThemedLogRowMessageDisplayedFields extends PureComponent { - render() { - const { row, showDetectedFields, getFieldLinks, wrapLogMessage } = this.props; - const fields = getAllFields(row, getFieldLinks); - const wrapClassName = wrapLogMessage - ? '' - : css` - white-space: nowrap; - `; - // only single key/value rows are filterable, so we only need the first field key for filtering - const line = showDetectedFields - .map((parsedKey) => { - const field = fields.find((field) => { - const { keys } = field; - return keys[0] === parsedKey; - }); +export const LogRowMessageDisplayedFields = React.memo((props: Props) => { + const { row, detectedFields, getFieldLinks, wrapLogMessage, styles, ...rest } = props; + const fields = getAllFields(row, getFieldLinks); + const wrapClassName = wrapLogMessage ? '' : displayedFieldsStyles.noWrap; + // only single key/value rows are filterable, so we only need the first field key for filtering + const line = detectedFields + .map((parsedKey) => { + const field = fields.find((field) => { + const { keys } = field; + return keys[0] === parsedKey; + }); - if (field !== undefined && field !== null) { - return `${parsedKey}=${field.values}`; - } + if (field !== undefined && field !== null) { + return `${parsedKey}=${field.values}`; + } - if (row.labels[parsedKey] !== undefined && row.labels[parsedKey] !== null) { - return `${parsedKey}=${row.labels[parsedKey]}`; - } + if (row.labels[parsedKey] !== undefined && row.labels[parsedKey] !== null) { + return `${parsedKey}=${row.labels[parsedKey]}`; + } - return null; - }) - .filter((s) => s !== null) - .join(' '); + return null; + }) + .filter((s) => s !== null) + .join(' '); - return {line}; - } -} + return ( + <> + +
{line}
+ + + + + + ); +}); + +const displayedFieldsStyles = { + noWrap: css` + white-space: nowrap; + `, +}; -export const LogRowMessageDisplayedFields = withTheme2(UnThemedLogRowMessageDisplayedFields); LogRowMessageDisplayedFields.displayName = 'LogRowMessageDisplayedFields';