diff --git a/src/Components/ServiceScene/LineFilterIcon.tsx b/src/Components/ServiceScene/LineFilterIconButton.tsx similarity index 62% rename from src/Components/ServiceScene/LineFilterIcon.tsx rename to src/Components/ServiceScene/LineFilterIconButton.tsx index 4f9fa6ca..5bca56a4 100644 --- a/src/Components/ServiceScene/LineFilterIcon.tsx +++ b/src/Components/ServiceScene/LineFilterIconButton.tsx @@ -8,26 +8,23 @@ interface Props { caseSensitive: boolean; } -export const LineFilterIcon = (props: Props) => { +export const LineFilterIconButton = (props: Props) => { const theme = useTheme2(); const fill = props.caseSensitive ? theme.colors.text.maxContrast : theme.colors.text.disabled; const styles = getStyles(theme, fill); return ( - - props.onCaseSensitiveToggle(props.caseSensitive ? 'insensitive' : 'sensitive')} - fill={fill} - width="16" - height="16" - viewBox="0 0 16 16" - xmlns="http://www.w3.org/2000/svg" - > + + ); }; @@ -38,6 +35,10 @@ const getStyles = (theme: GrafanaTheme2, fill: string) => { justifyContent: 'center', marginLeft: theme.spacing.x0_5, cursor: 'pointer', + appearance: 'none', + border: 'none', + background: 'none', + padding: 0, }), }; }; diff --git a/src/Components/ServiceScene/LineFilterScene.test.tsx b/src/Components/ServiceScene/LineFilterScene.test.tsx index 3cf30a2e..b9b93b03 100644 --- a/src/Components/ServiceScene/LineFilterScene.test.tsx +++ b/src/Components/ServiceScene/LineFilterScene.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { act, render, screen } from '@testing-library/react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; import { LineFilterScene } from './LineFilterScene'; import userEvent from '@testing-library/user-event'; import { CustomVariable, SceneVariableSet } from '@grafana/scenes'; @@ -14,38 +14,169 @@ jest.mock('lodash', () => ({ describe('LineFilter', () => { let scene: LineFilterScene; let lineFilterVariable: CustomVariable; - beforeEach(() => { - lineFilterVariable = new CustomVariable({ name: VAR_LINE_FILTER, value: '', hide: VariableHide.hideVariable }); - scene = new LineFilterScene({ - $variables: new SceneVariableSet({ - variables: [lineFilterVariable], - }), + + describe('case insensitive, no regex', () => { + beforeEach(() => { + lineFilterVariable = new CustomVariable({ name: VAR_LINE_FILTER, value: '', hide: VariableHide.hideVariable }); + scene = new LineFilterScene({ + caseSensitive: false, + $variables: new SceneVariableSet({ + variables: [lineFilterVariable], + }), + }); + }); + + test('Updates the variable with the user input', async () => { + render(); + + await act(() => userEvent.type(screen.getByPlaceholderText('Search in log lines'), 'some text')); + + expect(await screen.findByDisplayValue('some text')).toBeInTheDocument(); + expect(lineFilterVariable.getValue()).toBe('|~ `(?i)some text`'); + }); + + test('Escapes the regular expression in the variable', async () => { + render(); + + await act(() => userEvent.type(screen.getByPlaceholderText('Search in log lines'), '(characters')); + + expect(await screen.findByDisplayValue('(characters')).toBeInTheDocument(); + expect(lineFilterVariable.getValue()).toBe('|~ `(?i)\\(characters`'); + }); + + test('Unescapes the regular expression from the variable value', async () => { + lineFilterVariable.changeValueTo('|~ `(?i)\\(characters`'); + + render(); + + expect(await screen.findByDisplayValue('(characters')).toBeInTheDocument(); }); }); + describe('case sensitive, no regex', () => { + beforeEach(() => { + lineFilterVariable = new CustomVariable({ name: VAR_LINE_FILTER, value: '', hide: VariableHide.hideVariable }); + scene = new LineFilterScene({ + caseSensitive: true, + $variables: new SceneVariableSet({ + variables: [lineFilterVariable], + }), + }); + }); + + test('Updates the variable with the user input', async () => { + render(); + + await act(() => userEvent.type(screen.getByPlaceholderText('Search in log lines'), 'some text')); + + expect(await screen.findByDisplayValue('some text')).toBeInTheDocument(); + expect(lineFilterVariable.getValue()).toBe('|= `some text`'); + }); + + test('Escapes the regular expression in the variable', async () => { + render(); + + await act(() => userEvent.type(screen.getByPlaceholderText('Search in log lines'), '(characters')); + + expect(await screen.findByDisplayValue('(characters')).toBeInTheDocument(); + expect(lineFilterVariable.getValue()).toBe('|= `\\(characters`'); + }); - test('Updates the variable with the user input', async () => { - render(); + test('Unescapes the regular expression from the variable value', async () => { + lineFilterVariable.changeValueTo('|~ `(?i)\\(characters`'); - await act(() => userEvent.type(screen.getByPlaceholderText('Search in log lines'), 'some text')); + render(); - expect(await screen.findByDisplayValue('some text')).toBeInTheDocument(); - expect(lineFilterVariable.getValue()).toBe('|~ `(?i)some text`'); + expect(await screen.findByDisplayValue('(characters')).toBeInTheDocument(); + }); }); + describe('case insensitive, regex', () => { + beforeEach(() => { + lineFilterVariable = new CustomVariable({ name: VAR_LINE_FILTER, value: '', hide: VariableHide.hideVariable }); + scene = new LineFilterScene({ + caseSensitive: false, + regex: true, + $variables: new SceneVariableSet({ + variables: [lineFilterVariable], + }), + }); + }); + + test('Updates the variable with the user input', async () => { + render(); + + const string = `((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}`; + const input = screen.getByPlaceholderText('Search in log lines'); + // Jest can't type regex apparently + await act(() => fireEvent.change(input, { target: { value: string } })); + + expect(await screen.findByDisplayValue(string)).toBeInTheDocument(); + expect(lineFilterVariable.getValue()).toBe(`|~ \`(?i)${string}\``); + }); - test('Escapes the regular expression in the variable', async () => { - render(); + test('Does not escape the regular expression', async () => { + render(); - await act(() => userEvent.type(screen.getByPlaceholderText('Search in log lines'), '(characters')); + const string = `((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}`; + const input = screen.getByPlaceholderText('Search in log lines'); + // Jest can't type regex apparently + await act(() => fireEvent.change(input, { target: { value: string } })); - expect(await screen.findByDisplayValue('(characters')).toBeInTheDocument(); - expect(lineFilterVariable.getValue()).toBe('|~ `(?i)\\(characters`'); + expect(await screen.findByDisplayValue(string)).toBeInTheDocument(); + expect(lineFilterVariable.getValue()).toBe(`|~ \`(?i)${string}\``); + }); + + test('Unescapes the regular expression from the variable value', async () => { + const string = `((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}`; + lineFilterVariable.changeValueTo(`|~ \`(?i)${string}\``); + + render(); + + expect(await screen.findByDisplayValue(string)).toBeInTheDocument(); + }); }); + describe('case sensitive, regex', () => { + beforeEach(() => { + lineFilterVariable = new CustomVariable({ name: VAR_LINE_FILTER, value: '', hide: VariableHide.hideVariable }); + scene = new LineFilterScene({ + caseSensitive: true, + regex: true, + $variables: new SceneVariableSet({ + variables: [lineFilterVariable], + }), + }); + }); + + test('Updates the variable with the user input', async () => { + render(); + + const string = `((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}`; + const input = screen.getByPlaceholderText('Search in log lines'); + // Jest can't type regex apparently + await act(() => fireEvent.change(input, { target: { value: string } })); + + expect(await screen.findByDisplayValue(string)).toBeInTheDocument(); + expect(lineFilterVariable.getValue()).toBe(`|~ \`${string}\``); + }); - test('Unescapes the regular expression from the variable value', async () => { - lineFilterVariable.changeValueTo('|~ `(?i)\\(characters`'); + test('Does not escape the regular expression', async () => { + render(); - render(); + const string = `((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}`; + const input = screen.getByPlaceholderText('Search in log lines'); + // Jest can't type regex apparently + await act(() => fireEvent.change(input, { target: { value: string } })); - expect(await screen.findByDisplayValue('(characters')).toBeInTheDocument(); + expect(await screen.findByDisplayValue(string)).toBeInTheDocument(); + expect(lineFilterVariable.getValue()).toBe(`|~ \`${string}\``); + }); + + test('Unescapes the regular expression from the variable value', async () => { + const string = `((25[0-5]|(2[0-4]|1\\d|[1-9]|)\\d)\\.?\\b){4}`; + lineFilterVariable.changeValueTo(`|~ \`${string}\``); + + render(); + + expect(await screen.findByDisplayValue(string)).toBeInTheDocument(); + }); }); }); diff --git a/src/Components/ServiceScene/LineFilterScene.tsx b/src/Components/ServiceScene/LineFilterScene.tsx index e007a339..9266e679 100644 --- a/src/Components/ServiceScene/LineFilterScene.tsx +++ b/src/Components/ServiceScene/LineFilterScene.tsx @@ -1,18 +1,21 @@ import { css } from '@emotion/css'; import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; import { Field } from '@grafana/ui'; -import { debounce, escapeRegExp } from 'lodash'; +import { debounce, escape, escapeRegExp } from 'lodash'; import React, { ChangeEvent, KeyboardEvent } from 'react'; import { testIds } from 'services/testIds'; import { reportAppInteraction, USER_EVENTS_ACTIONS, USER_EVENTS_PAGES } from 'services/analytics'; import { SearchInput } from './Breakdowns/SearchInput'; -import { LineFilterIcon } from './LineFilterIcon'; +import { LineFilterIconButton } from './LineFilterIconButton'; import { getLineFilterVariable } from '../../services/variableGetters'; -import { getLineFilterCase, setLineFilterCase } from '../../services/store'; +import { getLineFilterCase, getLineFilterRegex, setLineFilterCase, setLineFilterRegex } from '../../services/store'; +import { RegexIconButton, RegexInputValue } from './RegexIconButton'; +import { logger } from '../../services/logger'; interface LineFilterState extends SceneObjectState { lineFilter: string; caseSensitive: boolean; + regex: boolean; } export class LineFilterScene extends SceneObjectBase { @@ -22,6 +25,7 @@ export class LineFilterScene extends SceneObjectBase { super({ lineFilter: state?.lineFilter || '', caseSensitive: getLineFilterCase(false), + regex: getLineFilterRegex(false), ...state, }); this.addActivationHandler(this.onActivate); @@ -34,15 +38,42 @@ export class LineFilterScene extends SceneObjectBase { return; } const caseSensitive = lineFilterString.includes('|='); - const matches = caseSensitive ? lineFilterString.match(/\|=.`(.+?)`/) : lineFilterString.match(/`\(\?i\)(.+)`/); - if (!matches || matches.length !== 2) { + const caseSensitiveMatches = caseSensitive + ? lineFilterString.match(/\|=.`(.+?)`/) + : lineFilterString.match(/`\(\?i\)(.+)`/); + + // If the existing query is case sensitive, overwrite the users options for case sensitivity + if (caseSensitiveMatches && caseSensitiveMatches.length === 2) { + // If the current state is not regex, remove escape chars + if (!this.state.regex) { + this.setState({ + lineFilter: caseSensitiveMatches[1].replace(/\\(.)/g, '$1'), + caseSensitive, + }); + return; + } else { + // If regex, don't remove escape chars + this.setState({ + lineFilter: caseSensitiveMatches[1], + caseSensitive, + }); + } return; } - this.setState({ - lineFilter: matches[1].replace(/\\(.)/g, '$1'), - caseSensitive, - }); + + const regexMatches = lineFilterString.match(/\|~.+\`(.*?)\`/); + if (regexMatches?.length === 2) { + this.setState({ + lineFilter: regexMatches[1], + }); + + return; + } else { + const error = new Error(`Unable to parse line filter: ${lineFilterString}`); + logger.error(error, { msg: `Unable to parse line filter: ${lineFilterString}` }); + throw error; + } }; updateFilter(lineFilter: string, debounced = true) { @@ -80,6 +111,20 @@ export class LineFilterScene extends SceneObjectBase { this.updateFilter(this.state.lineFilter, false); }; + onRegexToggle = (newState: RegexInputValue) => { + const regex = newState === 'regex'; + + // Set value to scene state + this.setState({ + regex, + }); + + // Set value in local storage + setLineFilterRegex(regex); + + this.updateFilter(this.state.lineFilter, false); + }; + updateVariableDebounced = debounce((search: string) => { this.updateVariable(search); }, 1000); @@ -89,8 +134,12 @@ export class LineFilterScene extends SceneObjectBase { if (search === '') { variable.changeValueTo(''); } else { - if (this.state.caseSensitive) { + if (this.state.caseSensitive && !this.state.regex) { variable.changeValueTo(`|= \`${escapeRegExp(search)}\``); + } else if (this.state.caseSensitive && this.state.regex) { + variable.changeValueTo(`|~ \`${escape(search)}\``); + } else if (!this.state.caseSensitive && this.state.regex) { + variable.changeValueTo(`|~ \`(?i)${escape(search)}\``); } else { variable.changeValueTo(`|~ \`(?i)${escapeRegExp(search)}\``); } @@ -108,7 +157,7 @@ export class LineFilterScene extends SceneObjectBase { } function LineFilterRenderer({ model }: SceneComponentProps) { - const { lineFilter, caseSensitive } = model.useState(); + const { lineFilter, caseSensitive, regex } = model.useState(); return ( ) { value={lineFilter} className={styles.input} onChange={model.handleChange} - suffix={} + suffix={ + <> + + + + } placeholder="Search in log lines" onClear={() => { model.updateFilter('', false); diff --git a/src/Components/ServiceScene/RegexIconButton.tsx b/src/Components/ServiceScene/RegexIconButton.tsx new file mode 100644 index 00000000..192bc37c --- /dev/null +++ b/src/Components/ServiceScene/RegexIconButton.tsx @@ -0,0 +1,45 @@ +import { useTheme2 } from '@grafana/ui'; +import React from 'react'; +import { GrafanaTheme2 } from '@grafana/data'; +import { css } from '@emotion/css'; + +export type RegexInputValue = 'regex' | 'match'; +interface Props { + onRegexToggle: (state: RegexInputValue) => void; + regex: boolean; +} + +export const RegexIconButton = (props: Props) => { + const theme = useTheme2(); + const fill = props.regex ? theme.colors.text.maxContrast : theme.colors.text.disabled; + const styles = getStyles(theme, fill); + + return ( + + ); +}; + +const getStyles = (theme: GrafanaTheme2, fill: string) => { + return { + container: css({ + display: 'flex', + justifyContent: 'center', + marginLeft: theme.spacing.x0_5, + cursor: 'pointer', + appearance: 'none', + border: 'none', + background: 'none', + padding: 0, + }), + }; +}; diff --git a/src/services/store.ts b/src/services/store.ts index 7b0b9be6..0fd52b4e 100644 --- a/src/services/store.ts +++ b/src/services/store.ts @@ -247,11 +247,25 @@ export function setLineFilterCase(caseSensitive: boolean) { localStorage.setItem(`${LINE_FILTER_OPTIONS_LOCALSTORAGE_KEY}.caseSensitive`, storedValue); } +export function setLineFilterRegex(regex: boolean) { + let storedValue = regex.toString(); + if (!regex) { + storedValue = ''; + } + + localStorage.setItem(`${LINE_FILTER_OPTIONS_LOCALSTORAGE_KEY}.regex`, storedValue); +} + export function getLineFilterCase(defaultValue: boolean): boolean { const storedValue = localStorage.getItem(`${LINE_FILTER_OPTIONS_LOCALSTORAGE_KEY}.caseSensitive`); return storedValue === 'true' ? true : defaultValue; } +export function getLineFilterRegex(defaultValue: boolean): boolean { + const storedValue = localStorage.getItem(`${LINE_FILTER_OPTIONS_LOCALSTORAGE_KEY}.regex`); + return storedValue === 'true' ? true : defaultValue; +} + // Panel options const PANEL_OPTIONS_LOCALSTORAGE_KEY = `${pluginJson.id}.panel.option`; export interface PanelOptions {