mirror of https://github.com/grafana/grafana
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 testpull/71144/head
parent
724e46a8a7
commit
b8fbeb084a
@ -0,0 +1,122 @@ |
||||
import React, { SyntheticEvent, useCallback } from 'react'; |
||||
|
||||
import { LogRowModel } from '@grafana/data'; |
||||
import { ClipboardButton, IconButton } from '@grafana/ui'; |
||||
|
||||
import { LogRowStyles } from './getLogRowStyles'; |
||||
|
||||
interface Props { |
||||
logText: string; |
||||
row: LogRowModel; |
||||
showContextToggle?: (row?: LogRowModel) => boolean; |
||||
onOpenContext: (row: LogRowModel) => void; |
||||
onPermalinkClick?: (row: LogRowModel) => Promise<void>; |
||||
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<HTMLElement, Event>) => { |
||||
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
|
||||
<span className={`log-row-menu log-row-menu-visible ${styles.rowMenu}`} onClick={onLogRowClick}> |
||||
<IconButton |
||||
className={styles.unPinButton} |
||||
size="md" |
||||
name="gf-pin" |
||||
onClick={() => onUnpinLine && onUnpinLine(row)} |
||||
tooltip="Unpin line" |
||||
tooltipPlacement="top" |
||||
aria-label="Unpin line" |
||||
/> |
||||
</span> |
||||
)} |
||||
{/* TODO: fix keyboard a11y */} |
||||
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} |
||||
<span className={`log-row-menu ${styles.rowMenu} ${styles.hidden}`} onClick={onLogRowClick}> |
||||
{shouldShowContextToggle && ( |
||||
<IconButton |
||||
size="md" |
||||
name="gf-show-context" |
||||
onClick={onShowContextClick} |
||||
tooltip="Show context" |
||||
tooltipPlacement="top" |
||||
aria-label="Show context" |
||||
/> |
||||
)} |
||||
<ClipboardButton |
||||
className={styles.copyLogButton} |
||||
icon="copy" |
||||
variant="secondary" |
||||
fill="text" |
||||
size="md" |
||||
getText={getLogText} |
||||
tooltip="Copy to clipboard" |
||||
tooltipPlacement="top" |
||||
/> |
||||
{pinned && onUnpinLine && ( |
||||
<IconButton |
||||
className={styles.unPinButton} |
||||
size="md" |
||||
name="gf-pin" |
||||
onClick={() => onUnpinLine && onUnpinLine(row)} |
||||
tooltip="Unpin line" |
||||
tooltipPlacement="top" |
||||
aria-label="Unpin line" |
||||
/> |
||||
)} |
||||
{!pinned && onPinLine && ( |
||||
<IconButton |
||||
className={styles.unPinButton} |
||||
size="md" |
||||
name="gf-pin" |
||||
onClick={() => onPinLine && onPinLine(row)} |
||||
tooltip="Pin line" |
||||
tooltipPlacement="top" |
||||
aria-label="Pin line" |
||||
/> |
||||
)} |
||||
{onPermalinkClick && row.uid && ( |
||||
<IconButton |
||||
tooltip="Copy shortlink" |
||||
aria-label="Copy shortlink" |
||||
tooltipPlacement="top" |
||||
size="md" |
||||
name="share-alt" |
||||
onClick={() => onPermalinkClick(row)} |
||||
/> |
||||
)} |
||||
</span> |
||||
</> |
||||
); |
||||
} |
||||
); |
||||
|
||||
LogRowMenuCell.displayName = 'LogRowMenuCell'; |
@ -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<Props> = {}, 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( |
||||
<table> |
||||
<tbody> |
||||
<tr> |
||||
<LogRowMessageDisplayedFields {...props} /> |
||||
</tr> |
||||
</tbody> |
||||
</table> |
||||
); |
||||
|
||||
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(); |
||||
}); |
||||
}); |
@ -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<LinkModel<Field>>; |
||||
styles: LogRowStyles; |
||||
showContextToggle?: (row?: LogRowModel) => boolean; |
||||
onOpenContext: (row: LogRowModel) => void; |
||||
onPermalinkClick?: (row: LogRowModel) => Promise<void>; |
||||
onPinLine?: (row: LogRowModel) => void; |
||||
onUnpinLine?: (row: LogRowModel) => void; |
||||
pinned?: boolean; |
||||
} |
||||
|
||||
class UnThemedLogRowMessageDisplayedFields extends PureComponent<Props> { |
||||
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; |
||||
}); |
||||
|
||||
if (field !== undefined && field !== null) { |
||||
return `${parsedKey}=${field.values}`; |
||||
} |
||||
|
||||
if (row.labels[parsedKey] !== undefined && row.labels[parsedKey] !== null) { |
||||
return `${parsedKey}=${row.labels[parsedKey]}`; |
||||
} |
||||
|
||||
return null; |
||||
}) |
||||
.filter((s) => s !== null) |
||||
.join(' '); |
||||
|
||||
return <td className={wrapClassName}>{line}</td>; |
||||
} |
||||
} |
||||
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 (row.labels[parsedKey] !== undefined && row.labels[parsedKey] !== null) { |
||||
return `${parsedKey}=${row.labels[parsedKey]}`; |
||||
} |
||||
|
||||
return null; |
||||
}) |
||||
.filter((s) => s !== null) |
||||
.join(' '); |
||||
|
||||
return ( |
||||
<> |
||||
<td className={styles.logsRowMessage}> |
||||
<div className={wrapClassName}>{line}</div> |
||||
</td> |
||||
<td className={`log-row-menu-cell ${styles.logRowMenuCell}`}> |
||||
<LogRowMenuCell logText={line} row={row} styles={styles} {...rest} /> |
||||
</td> |
||||
</> |
||||
); |
||||
}); |
||||
|
||||
const displayedFieldsStyles = { |
||||
noWrap: css` |
||||
white-space: nowrap; |
||||
`,
|
||||
}; |
||||
|
||||
export const LogRowMessageDisplayedFields = withTheme2(UnThemedLogRowMessageDisplayedFields); |
||||
LogRowMessageDisplayedFields.displayName = 'LogRowMessageDisplayedFields'; |
||||
|
Loading…
Reference in new issue