diff --git a/.betterer.results b/.betterer.results index 0f05dfe3b07..360862b8111 100644 --- a/.betterer.results +++ b/.betterer.results @@ -4335,11 +4335,6 @@ exports[`better eslint`] = { [0, 0, 0, "No untranslated strings. Wrap text with ", "5"], [0, 0, 0, "No untranslated strings. Wrap text with ", "6"] ], - "public/app/features/explore/Logs/PopoverMenu.tsx:5381": [ - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "0"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "1"], - [0, 0, 0, "No untranslated strings in text props. Wrap text with or use t()", "2"] - ], "public/app/features/explore/MetaInfoText.tsx:5381": [ [0, 0, 0, "No untranslated strings. Wrap text with ", "0"] ], diff --git a/public/app/features/explore/Logs/PopoverMenu.test.tsx b/public/app/features/explore/Logs/PopoverMenu.test.tsx index 8c4f2ac4482..8ffa99a375c 100644 --- a/public/app/features/explore/Logs/PopoverMenu.test.tsx +++ b/public/app/features/explore/Logs/PopoverMenu.test.tsx @@ -8,7 +8,7 @@ import { PopoverMenu } from './PopoverMenu'; const row = createLogRow(); test('Does not render if the filter functions are not defined', () => { - render( {}} />); + render( {}} onDisable={() => {}} />); expect(screen.queryByText('Copy selection')).not.toBeInTheDocument(); }); @@ -16,7 +16,15 @@ test('Does not render if the filter functions are not defined', () => { test('Renders copy and line contains filter', async () => { const onClickFilterString = jest.fn(); render( - {}} onClickFilterString={onClickFilterString} /> + {}} + onDisable={() => {}} + onClickFilterString={onClickFilterString} + /> ); expect(screen.getByText('Copy selection')).toBeInTheDocument(); @@ -37,6 +45,7 @@ test('Renders copy and line does not contain filter', async () => { y={0} row={row} close={() => {}} + onDisable={() => {}} onClickFilterOutString={onClickFilterOutString} /> ); @@ -58,6 +67,7 @@ test('Renders copy, line contains filter, and line does not contain filter', () y={0} row={row} close={() => {}} + onDisable={() => {}} onClickFilterString={() => {}} onClickFilterOutString={() => {}} /> @@ -77,6 +87,7 @@ test('Can be dismissed with escape', async () => { y={0} row={row} close={close} + onDisable={() => {}} onClickFilterString={() => {}} onClickFilterOutString={() => {}} /> @@ -87,3 +98,24 @@ test('Can be dismissed with escape', async () => { await userEvent.keyboard('{Escape}'); expect(close).toHaveBeenCalledTimes(1); }); + +test('Can be disabled', async () => { + const onDisable = jest.fn(); + render( + {}} + onDisable={onDisable} + onClickFilterString={() => {}} + onClickFilterOutString={() => {}} + /> + ); + + expect(onDisable).not.toHaveBeenCalled(); + expect(screen.getByText('Disable menu')).toBeInTheDocument(); + await userEvent.click(screen.getByText('Disable menu')); + expect(onDisable).toHaveBeenCalledTimes(1); +}); diff --git a/public/app/features/explore/Logs/PopoverMenu.tsx b/public/app/features/explore/Logs/PopoverMenu.tsx index 641bae885b6..90ceb5f221f 100644 --- a/public/app/features/explore/Logs/PopoverMenu.tsx +++ b/public/app/features/explore/Logs/PopoverMenu.tsx @@ -1,9 +1,10 @@ import { css } from '@emotion/css'; -import { useEffect, useRef } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { GrafanaTheme2, LogRowModel } from '@grafana/data'; import { reportInteraction } from '@grafana/runtime'; import { Menu, useStyles2 } from '@grafana/ui'; +import { t } from 'app/core/internationalization'; import { copyText } from '../../logs/utils'; @@ -13,6 +14,7 @@ interface PopoverMenuProps { y: number; onClickFilterString?: (value: string, refId?: string) => void; onClickFilterOutString?: (value: string, refId?: string) => void; + onDisable: () => void; row: LogRowModel; close: () => void; } @@ -25,6 +27,7 @@ export const PopoverMenu = ({ selection, row, close, + ...props }: PopoverMenuProps) => { const containerRef = useRef(null); const styles = useStyles2(getStyles); @@ -42,6 +45,11 @@ export const PopoverMenu = ({ }; }, [close]); + const onDisable = useCallback(() => { + track('popover_menu_disabled', selection.length, row.datasourceType); + props.onDisable(); + }, [props, row.datasourceType, selection.length]); + const supported = onClickFilterString || onClickFilterOutString; if (!supported) { @@ -49,38 +57,42 @@ export const PopoverMenu = ({ } return ( -
- - { - copyText(selection, containerRef); - close(); - track('copy', selection.length, row.datasourceType); - }} - /> - {onClickFilterString && ( + <> +
+ { - onClickFilterString(selection, row.dataFrame.refId); + copyText(selection, containerRef); close(); - track('line_contains', selection.length, row.datasourceType); + track('copy', selection.length, row.datasourceType); }} /> - )} - {onClickFilterOutString && ( - { - onClickFilterOutString(selection, row.dataFrame.refId); - close(); - track('line_does_not_contain', selection.length, row.datasourceType); - }} - /> - )} - -
+ {onClickFilterString && ( + { + onClickFilterString(selection, row.dataFrame.refId); + close(); + track('line_contains', selection.length, row.datasourceType); + }} + /> + )} + {onClickFilterOutString && ( + { + onClickFilterOutString(selection, row.dataFrame.refId); + close(); + track('line_does_not_contain', selection.length, row.datasourceType); + }} + /> + )} + + +
+
+ ); }; diff --git a/public/app/features/logs/components/LogRows.test.tsx b/public/app/features/logs/components/LogRows.test.tsx index d892327b836..3c84d4406fd 100644 --- a/public/app/features/logs/components/LogRows.test.tsx +++ b/public/app/features/logs/components/LogRows.test.tsx @@ -3,9 +3,18 @@ import userEvent from '@testing-library/user-event'; import { LogRowModel, LogsDedupStrategy, LogsSortOrder } from '@grafana/data'; +import { disablePopoverMenu, enablePopoverMenu, isPopoverMenuDisabled } from '../utils'; + import { LogRows, Props } from './LogRows'; import { createLogRow } from './__mocks__/logRow'; +jest.mock('../utils', () => ({ + ...jest.requireActual('../utils'), + isPopoverMenuDisabled: jest.fn(), + disablePopoverMenu: jest.fn(), + enablePopoverMenu: jest.fn(), +})); + jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), config: { @@ -155,6 +164,9 @@ describe('Popover menu', () => { ); } let orgGetSelection: () => Selection | null; + beforeEach(() => { + jest.mocked(isPopoverMenuDisabled).mockReturnValue(false); + }); beforeAll(() => { orgGetSelection = document.getSelection; jest.spyOn(document, 'getSelection').mockReturnValue({ @@ -177,6 +189,27 @@ describe('Popover menu', () => { expect(screen.getByText('Add as line contains filter')).toBeInTheDocument(); expect(screen.getByText('Add as line does not contain filter')).toBeInTheDocument(); }); + it('Can be disabled', async () => { + setup(); + await userEvent.click(screen.getByText('log message 1')); + await userEvent.click(screen.getByText('Disable menu')); + await userEvent.click(screen.getByText('Confirm')); + expect(disablePopoverMenu).toHaveBeenCalledTimes(1); + }); + it('Does not appear when disabled', async () => { + jest.mocked(isPopoverMenuDisabled).mockReturnValue(true); + setup(); + await userEvent.click(screen.getByText('log message 1')); + expect(screen.queryByText('Copy selection')).not.toBeInTheDocument(); + }); + it('Can be re-enabled', async () => { + jest.mocked(isPopoverMenuDisabled).mockReturnValue(true); + const user = userEvent.setup(); + setup(); + await user.keyboard('[AltLeft>]'); // Press Alt (without releasing it) + await user.click(screen.getByText('log message 1')); + expect(enablePopoverMenu).toHaveBeenCalledTimes(1); + }); it('Does not appear when the props are not defined', async () => { setup({ onClickFilterOutString: undefined, diff --git a/public/app/features/logs/components/LogRows.tsx b/public/app/features/logs/components/LogRows.tsx index 13863170c75..206e3b88023 100644 --- a/public/app/features/logs/components/LogRows.tsx +++ b/public/app/features/logs/components/LogRows.tsx @@ -14,11 +14,12 @@ import { } from '@grafana/data'; import { config } from '@grafana/runtime'; import { DataQuery } from '@grafana/schema'; -import { PopoverContent, useTheme2 } from '@grafana/ui'; +import { ConfirmModal, Icon, PopoverContent, useTheme2 } from '@grafana/ui'; +import { t, Trans } from 'app/core/internationalization'; import { PopoverMenu } from '../../explore/Logs/PopoverMenu'; import { UniqueKeyMaker } from '../UniqueKeyMaker'; -import { sortLogRows, targetIsElement } from '../utils'; +import { disablePopoverMenu, enablePopoverMenu, isPopoverMenuDisabled, sortLogRows, targetIsElement } from '../utils'; //Components import { LogRow } from './LogRow'; @@ -112,6 +113,7 @@ export const LogRows = memo( selectedRow: null, popoverMenuCoordinates: { x: 0, y: 0 }, }); + const [showDisablePopoverOptions, setShowDisablePopoverOptions] = useState(false); const logRowsRef = useRef(null); const theme = useTheme2(); const styles = getLogRowStyles(theme); @@ -121,7 +123,6 @@ export const LogRows = memo( [dedupedRows] ); const showDuplicates = dedupStrategy !== LogsDedupStrategy.none && dedupCount > 0; - // Staged rendering const orderedRows = useMemo( () => (logsSortOrder ? sortLogRows(dedupedRows, logsSortOrder) : dedupedRows), [dedupedRows, logsSortOrder] @@ -130,7 +131,6 @@ export const LogRows = memo( const getRows = useMemo(() => () => orderedRows, [orderedRows]); const handleDeselectionRef = useRef<((e: Event) => void) | null>(null); const keyMaker = new UniqueKeyMaker(); - // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(() => { return () => { @@ -169,7 +169,7 @@ export const LogRows = memo( ); const popoverMenuSupported = useCallback(() => { - if (!config.featureToggles.logRowsPopoverMenu) { + if (!config.featureToggles.logRowsPopoverMenu || isPopoverMenuDisabled()) { return false; } return Boolean(onClickFilterOutString || onClickFilterString); @@ -209,6 +209,9 @@ export const LogRows = memo( if (!selection) { return false; } + if (e.altKey) { + enablePopoverMenu(); + } if (popoverMenuSupported() === false) { // This signals onRowClick inside LogRow to skip the event because the user is selecting text return selection ? true : false; @@ -236,6 +239,19 @@ export const LogRows = memo( [handleDeselection, popoverMenuSupported] ); + const onDisablePopoverMenu = useCallback(() => { + setShowDisablePopoverOptions(true); + }, []); + + const onDisableCancel = useCallback(() => { + setShowDisablePopoverOptions(false); + }, []); + + const onDisableConfirm = useCallback(() => { + disablePopoverMenu(); + setShowDisablePopoverOptions(false); + }, []); + return (
{popoverState.selection && popoverState.selectedRow && ( @@ -246,6 +262,29 @@ export const LogRows = memo( {...popoverState.popoverMenuCoordinates} onClickFilterString={onClickFilterString} onClickFilterOutString={onClickFilterOutString} + onDisable={onDisablePopoverMenu} + /> + )} + {showDisablePopoverOptions && ( + + + You are about to disable the logs filter menu. To re-enable it, select text in a log line while + holding the alt key. + +
+ + alt+select to enable again +
+ + } + confirmText={t('logs.log-rows.disable-popover.confirm', 'Confirm')} + icon="exclamation-triangle" + onConfirm={onDisableConfirm} + onDismiss={onDisableCancel} /> )} diff --git a/public/app/features/logs/components/getLogRowStyles.ts b/public/app/features/logs/components/getLogRowStyles.ts index eec5a40bfe4..9b965b65ea1 100644 --- a/public/app/features/logs/components/getLogRowStyles.ts +++ b/public/app/features/logs/components/getLogRowStyles.ts @@ -72,6 +72,15 @@ export const getLogRowStyles = memoizeOne((theme: GrafanaTheme2) => { logRows: css({ position: 'relative', }), + shortcut: css({ + display: 'inline-flex', + alignItems: 'center', + gap: theme.spacing(1), + color: theme.colors.text.secondary, + opacity: 0.7, + fontSize: theme.typography.bodySmall.fontSize, + marginTop: theme.spacing(1), + }), logsRowsTable: css({ label: 'logs-rows', fontFamily: theme.typography.fontFamilyMonospace, diff --git a/public/app/features/logs/utils.ts b/public/app/features/logs/utils.ts index 4d0ae113c70..a96a1d2c6b7 100644 --- a/public/app/features/logs/utils.ts +++ b/public/app/features/logs/utils.ts @@ -373,3 +373,16 @@ function getDataSourceLabelType(labelType: string, datasourceType: string) { return null; } } + +const POPOVER_STORAGE_KEY = 'logs.popover.disabled'; +export function disablePopoverMenu() { + localStorage.setItem(POPOVER_STORAGE_KEY, 'true'); +} + +export function enablePopoverMenu() { + localStorage.removeItem(POPOVER_STORAGE_KEY); +} + +export function isPopoverMenuDisabled() { + return Boolean(localStorage.getItem(POPOVER_STORAGE_KEY)); +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 72a4d46891e..cccc9867be9 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -1781,6 +1781,22 @@ "log-row-message": { "ellipsis": "… ", "more": "more" + }, + "log-rows": { + "disable-popover": { + "confirm": "Confirm", + "message": "You are about to disable the logs filter menu. To re-enable it, select text in a log line while holding the alt key.", + "title": "Disable menu" + }, + "disable-popover-message": { + "shortcut": "alt+select to enable again" + } + }, + "popover-menu": { + "copy": "Copy selection", + "disable-menu": "Disable menu", + "line-contains": "Add as line contains filter", + "line-contains-not": "Add as line does not contain filter" } }, "migrate-to-cloud": { diff --git a/public/locales/pseudo-LOCALE/grafana.json b/public/locales/pseudo-LOCALE/grafana.json index ae287806131..8b288f78bba 100644 --- a/public/locales/pseudo-LOCALE/grafana.json +++ b/public/locales/pseudo-LOCALE/grafana.json @@ -1781,6 +1781,22 @@ "log-row-message": { "ellipsis": "… ", "more": "mőřę" + }, + "log-rows": { + "disable-popover": { + "confirm": "Cőʼnƒįřm", + "message": "Ÿőū äřę äþőūŧ ŧő đįşäþľę ŧĥę ľőģş ƒįľŧęř męʼnū. Ŧő řę-ęʼnäþľę įŧ, şęľęčŧ ŧęχŧ įʼn ä ľőģ ľįʼnę ŵĥįľę ĥőľđįʼnģ ŧĥę äľŧ ĸęy.", + "title": "Đįşäþľę męʼnū" + }, + "disable-popover-message": { + "shortcut": "äľŧ+şęľęčŧ ŧő ęʼnäþľę äģäįʼn" + } + }, + "popover-menu": { + "copy": "Cőpy şęľęčŧįőʼn", + "disable-menu": "Đįşäþľę męʼnū", + "line-contains": "Åđđ äş ľįʼnę čőʼnŧäįʼnş ƒįľŧęř", + "line-contains-not": "Åđđ äş ľįʼnę đőęş ʼnőŧ čőʼnŧäįʼn ƒįľŧęř" } }, "migrate-to-cloud": {