diff --git a/packages/grafana-data/src/types/icon.ts b/packages/grafana-data/src/types/icon.ts index 3c06ca2f3fe..d52de760053 100644 --- a/packages/grafana-data/src/types/icon.ts +++ b/packages/grafana-data/src/types/icon.ts @@ -154,6 +154,8 @@ export const availableIconsIndex = { 'list-ol': true, lock: true, 'map-marker': true, + 'map-marker-plus': true, + 'map-marker-minus': true, message: true, minus: true, 'minus-circle': true, diff --git a/public/app/features/logs/components/LogRow.tsx b/public/app/features/logs/components/LogRow.tsx index f28a511779f..eafd0f7551c 100644 --- a/public/app/features/logs/components/LogRow.tsx +++ b/public/app/features/logs/components/LogRow.tsx @@ -42,6 +42,9 @@ interface Props extends Themeable2 { styles: LogRowStyles; permalinkedRowId?: string; scrollIntoView?: (element: HTMLElement) => void; + onPinLine?: (row: LogRowModel) => void; + onUnpinLine?: (row: LogRowModel) => void; + pinned?: boolean; } interface State { @@ -236,6 +239,9 @@ class UnThemedLogRow extends PureComponent { onPermalinkClick={this.props.onPermalinkClick} app={app} styles={styles} + onPinLine={this.props.onPinLine} + onUnpinLine={this.props.onUnpinLine} + pinned={this.props.pinned} /> )} diff --git a/public/app/features/logs/components/LogRowMessage.test.tsx b/public/app/features/logs/components/LogRowMessage.test.tsx index 234706fd70d..a7133bdc999 100644 --- a/public/app/features/logs/components/LogRowMessage.test.tsx +++ b/public/app/features/logs/components/LogRowMessage.test.tsx @@ -63,12 +63,12 @@ describe('LogRowMessage', () => { }); describe('with permalinking', () => { - it('should show permalinking button when no `onPermalinkClick` is defined', () => { + it('should show permalinking button when `onPermalinkClick` is defined', () => { setup({ onPermalinkClick: jest.fn() }); expect(screen.queryByLabelText('Copy shortlink')).toBeInTheDocument(); }); - it('should not show permalinking button when `onPermalinkClick` is defined', () => { + it('should not show permalinking button when `onPermalinkClick` is not defined', () => { setup(); expect(screen.queryByLabelText('Copy shortlink')).not.toBeInTheDocument(); }); @@ -83,4 +83,61 @@ describe('LogRowMessage', () => { expect(permalinkClick).toHaveBeenCalledWith(props.row); }); }); + + describe('with pinning', () => { + describe('for `onPinLine`', () => { + it('should show pinning button when `onPinLine` is defined', () => { + setup({ onPinLine: jest.fn() }); + expect(screen.queryByLabelText('Pin line')).toBeInTheDocument(); + }); + + it('should not show pinning button when `onPinLine` and `pinned` is defined', () => { + setup({ onPinLine: jest.fn(), pinned: true }); + expect(screen.queryByLabelText('Pin line')).not.toBeInTheDocument(); + }); + + it('should not show pinning button when `onPinLine` is not defined', () => { + setup(); + expect(screen.queryByLabelText('Pin line')).not.toBeInTheDocument(); + }); + + it('should call `onPinLine` on click', async () => { + const onPinLine = jest.fn(); + setup({ onPinLine }); + const button = screen.getByLabelText('Pin line'); + + await userEvent.click(button); + + expect(onPinLine).toHaveBeenCalledTimes(1); + }); + }); + + describe('for `onUnpinLine`', () => { + it('should not show pinning button when `onUnpinLine` is defined', () => { + setup({ onUnpinLine: jest.fn() }); + expect(screen.queryByLabelText('Unpin line')).not.toBeInTheDocument(); + }); + + it('should show 2 pinning buttons when `onUnpinLine` and `pinned` is defined', () => { + // we show 2 because we now have an "always visible" menu, and a "hover" menu + setup({ onUnpinLine: jest.fn(), pinned: true }); + expect(screen.queryAllByLabelText('Unpin line').length).toBe(2); + }); + + it('should not show pinning button when `onUnpinLine` is not defined', () => { + setup(); + expect(screen.queryByLabelText('Unpin line')).not.toBeInTheDocument(); + }); + + it('should call `onUnpinLine` on click', async () => { + const onUnpinLine = jest.fn(); + setup({ onUnpinLine, pinned: true }); + const button = screen.getAllByLabelText('Unpin line')[0]; + + await userEvent.click(button); + + expect(onUnpinLine).toHaveBeenCalledTimes(1); + }); + }); + }); }); diff --git a/public/app/features/logs/components/LogRowMessage.tsx b/public/app/features/logs/components/LogRowMessage.tsx index 5f1213354d7..d3055a9b972 100644 --- a/public/app/features/logs/components/LogRowMessage.tsx +++ b/public/app/features/logs/components/LogRowMessage.tsx @@ -19,6 +19,9 @@ interface Props { showContextToggle?: (row?: LogRowModel) => boolean; onOpenContext: (row: LogRowModel) => void; onPermalinkClick?: (row: LogRowModel) => Promise; + onPinLine?: (row: LogRowModel) => void; + onUnpinLine?: (row: LogRowModel) => void; + pinned?: boolean; styles: LogRowStyles; } @@ -77,7 +80,17 @@ export class LogRowMessage extends PureComponent { }; render() { - const { row, wrapLogMessage, prettifyLogMessage, showContextToggle, styles, onPermalinkClick } = this.props; + 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; @@ -101,7 +114,20 @@ export class LogRowMessage extends PureComponent { - + {pinned && ( + + onUnpinLine && onUnpinLine(row)} + tooltip="Unpin line" + tooltipPlacement="top" + aria-label="Unpin line" + /> + + )} + {shouldShowContextToggle && ( { tooltip="Copy to clipboard" tooltipPlacement="top" /> + {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 && ( Array>; onClickShowField?: (key: string) => void; onClickHideField?: (key: string) => void; + onPinLine?: (row: LogRowModel) => void; + onUnpinLine?: (row: LogRowModel) => void; onLogRowHover?: (row?: LogRowModel) => void; onOpenContext?: (row: LogRowModel, onClose: () => void) => void; onPermalinkClick?: (row: LogRowModel) => Promise; permalinkedRowId?: string; scrollIntoView?: (element: HTMLElement) => void; + pinnedRowId?: string; } interface State { @@ -142,7 +145,7 @@ class UnThemedLogRows extends PureComponent { // React profiler becomes unusable if we pass all rows to all rows and their labels, using getter instead const getRows = this.makeGetRows(orderedRows); - const getLogRowProperties = (row: LogRowModel) => { + const getLogRowProperties = (row: LogRowModel): ComponentProps => { return { getRows: getRows, row: row, @@ -169,6 +172,9 @@ class UnThemedLogRows extends PureComponent { onPermalinkClick: this.props.onPermalinkClick, scrollIntoView: this.props.scrollIntoView, permalinkedRowId: this.props.permalinkedRowId, + onPinLine: this.props.onPinLine, + onUnpinLine: this.props.onUnpinLine, + pinned: this.props.pinnedRowId === row.uid, }; }; return ( diff --git a/public/app/features/logs/components/getLogRowStyles.ts b/public/app/features/logs/components/getLogRowStyles.ts index f83afd0a4a0..c586473e257 100644 --- a/public/app/features/logs/components/getLogRowStyles.ts +++ b/public/app/features/logs/components/getLogRowStyles.ts @@ -89,6 +89,10 @@ export const getLogRowStyles = memoizeOne((theme: GrafanaTheme2) => { z-index: 1; } + .log-row-menu-visible { + visibility: hidden; + } + background: ${hoverBgColor}; } @@ -248,7 +252,6 @@ export const getLogRowStyles = memoizeOne((theme: GrafanaTheme2) => { box-shadow: ${theme.shadows.z3}; padding: ${theme.spacing(0.5, 1, 0.5, 1)}; z-index: 100; - visibility: hidden; gap: ${theme.spacing(0.5)}; & > button { @@ -302,6 +305,14 @@ export const getLogRowStyles = memoizeOne((theme: GrafanaTheme2) => { padding-top: ${theme.spacing(0.5)}; } `, + hidden: css` + label: hidden; + visibility: hidden; + `, + unPinButton: css` + height: ${theme.spacing(3)}; + line-height: ${theme.spacing(2.5)}; + `, }; }); diff --git a/public/app/features/logs/components/log-context/LogRowContextModal.test.tsx b/public/app/features/logs/components/log-context/LogRowContextModal.test.tsx index aec1364f483..85cf8c0213a 100644 --- a/public/app/features/logs/components/log-context/LogRowContextModal.test.tsx +++ b/public/app/features/logs/components/log-context/LogRowContextModal.test.tsx @@ -334,4 +334,44 @@ describe('LogRowContextModal', () => { await waitFor(() => expect(dispatchMock).toHaveBeenCalledWith(splitOpenSym)); }); + + it('should make the center row sticky on load', async () => { + render( + {}} + getRowContext={getRowContext} + timeZone={timeZone} + logsSortOrder={LogsSortOrder.Descending} + /> + ); + + await waitFor(() => { + const rows = screen.getByTestId('entry-row'); + expect(rows).toHaveStyle('position: sticky'); + }); + }); + + it('should make the center row unsticky on unPinClick', async () => { + render( + {}} + getRowContext={getRowContext} + timeZone={timeZone} + logsSortOrder={LogsSortOrder.Descending} + /> + ); + + await waitFor(() => { + const rows = screen.getByTestId('entry-row'); + expect(rows).toHaveStyle('position: sticky'); + }); + const unpinButtons = screen.getAllByLabelText('Unpin line')[0]; + await userEvent.click(unpinButtons); + const rows = screen.getByTestId('entry-row'); + expect(rows).not.toHaveStyle('position: sticky'); + }); }); diff --git a/public/app/features/logs/components/log-context/LogRowContextModal.tsx b/public/app/features/logs/components/log-context/LogRowContextModal.tsx index 648e78665f7..226e03faa58 100644 --- a/public/app/features/logs/components/log-context/LogRowContextModal.tsx +++ b/public/app/features/logs/components/log-context/LogRowContextModal.tsx @@ -41,11 +41,13 @@ const getStyles = (theme: GrafanaTheme2) => { left: 50%; transform: translate(-50%, -50%); `, - entry: css` + sticky: css` position: sticky; z-index: 1; top: -1px; bottom: -1px; + `, + entry: css` & > td { padding: ${theme.spacing(1)} 0 ${theme.spacing(1)} 0; } @@ -54,6 +56,10 @@ const getStyles = (theme: GrafanaTheme2) => { & > table { margin-bottom: 0; } + + & .log-row-menu { + margin-top: -6px; + } `, datasourceUi: css` padding-bottom: ${theme.spacing(1.25)}; @@ -178,6 +184,8 @@ export const LogRowContextModal: React.FunctionComponent - + setSticky(false)} + onPinLine={() => setSticky(true)} + pinnedRowId={sticky ? row.uid : undefined} />