From aa72d800a18ded69a1899cf8e3a9c3be3638546d Mon Sep 17 00:00:00 2001 From: Ivan Ortega Alba Date: Wed, 14 May 2025 11:44:43 +0200 Subject: [PATCH] FilterByValueTransformer: Suggest variable regex formatter (#105272) --------- Co-authored-by: Sam Jewell --- .../ValueMatchers/BasicMatcherEditor.test.tsx | 58 +++++++++++++ .../ValueMatchers/NoopMatcherEditor.test.tsx | 10 +++ .../ValueMatchers/RangeMatcherEditor.test.tsx | 82 +++++++++++++++++++ .../ValueMatchers/RegexMatcherEditor.test.tsx | 61 ++++++++++++++ .../ValueMatchers/RegexMatcherEditor.tsx | 17 +++- public/locales/en-US/grafana.json | 3 +- 6 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.test.tsx create mode 100644 public/app/features/transformers/FilterByValueTransformer/ValueMatchers/NoopMatcherEditor.test.tsx create mode 100644 public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.test.tsx create mode 100644 public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RegexMatcherEditor.test.tsx diff --git a/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.test.tsx b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.test.tsx new file mode 100644 index 00000000000..fdbfd450a6f --- /dev/null +++ b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/BasicMatcherEditor.test.tsx @@ -0,0 +1,58 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +import { FieldType, TypedVariableModel } from '@grafana/data'; +import { getTemplateSrv } from '@grafana/runtime'; + +import { basicMatcherEditor } from './BasicMatcherEditor'; + +jest.mock('@grafana/runtime', () => ({ + getTemplateSrv: jest.fn(), +})); + +describe('BasicMatcherEditor', () => { + it('shows variable suggestions', async () => { + // Mock template service variables + const mockVariables: TypedVariableModel[] = [ + { name: 'var1', label: 'Variable 1', type: 'custom' } as TypedVariableModel, + { name: 'var2', label: 'Variable 2', type: 'custom' } as TypedVariableModel, + ]; + + const mockTemplateSrv = { + getVariables: () => mockVariables, + replace: jest.fn(), + containsTemplate: jest.fn(), + updateTimeRange: jest.fn(), + }; + + jest.mocked(getTemplateSrv).mockReturnValue(mockTemplateSrv); + + const onChangeMock = jest.fn(); + const options = { value: '' }; + const field = { + name: 'test', + type: FieldType.string, + config: {}, + values: [], + }; + + const Editor = basicMatcherEditor({ validator: () => true }); + render(); + + // Focus the input and press $ to trigger suggestions + const input = screen.getByPlaceholderText('Value or variable'); + fireEvent.focus(input); + fireEvent.keyDown(input, { key: '$' }); + + // Wait for suggestions to appear and verify + await waitFor(() => { + const suggestions = screen.getAllByRole('menuitem'); + // Verify exact number of suggestions + expect(suggestions).toHaveLength(mockVariables.length); + + mockVariables.forEach((variable) => { + const suggestion = suggestions.find((s) => s.textContent?.includes(variable.label as string)); + expect(suggestion).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/NoopMatcherEditor.test.tsx b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/NoopMatcherEditor.test.tsx new file mode 100644 index 00000000000..753e77855e1 --- /dev/null +++ b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/NoopMatcherEditor.test.tsx @@ -0,0 +1,10 @@ +import { render } from '@testing-library/react'; + +import { NoopMatcherEditor } from './NoopMatcherEditor'; + +describe('NoopMatcherEditor', () => { + it('renders nothing', () => { + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.test.tsx b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.test.tsx new file mode 100644 index 00000000000..eb9bf4c8f8e --- /dev/null +++ b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RangeMatcherEditor.test.tsx @@ -0,0 +1,82 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +import { FieldType, TypedVariableModel } from '@grafana/data'; +import { getTemplateSrv } from '@grafana/runtime'; + +import { rangeMatcherEditor } from './RangeMatcherEditor'; + +jest.mock('@grafana/runtime', () => ({ + getTemplateSrv: jest.fn(), +})); + +describe('RangeMatcherEditor', () => { + it('shows variable suggestions for both from and to inputs', async () => { + // Mock template service variables + const mockVariables: TypedVariableModel[] = [ + { name: 'var1', label: 'Variable 1', type: 'custom' } as TypedVariableModel, + { name: 'var2', label: 'Variable 2', type: 'custom' } as TypedVariableModel, + ]; + + const mockTemplateSrv = { + getVariables: () => mockVariables, + replace: jest.fn(), + containsTemplate: jest.fn(), + updateTimeRange: jest.fn(), + }; + + jest.mocked(getTemplateSrv).mockReturnValue(mockTemplateSrv); + + const onChangeMock = jest.fn(); + const options = { from: '', to: '' }; + const field = { + name: 'test', + type: FieldType.string, + config: {}, + values: [], + }; + + const Editor = rangeMatcherEditor({ validator: () => true }); + render(); + + // Test "from" input + const fromInput = screen.getByPlaceholderText('From'); + fireEvent.focus(fromInput); + fireEvent.keyDown(fromInput, { key: '$' }); + + // Wait for suggestions to appear and verify for "from" input + await waitFor(() => { + const menus = screen.getAllByRole('menu'); + const fromMenu = menus[0]; // First menu is for the "from" input + const fromSuggestions = fromMenu.querySelectorAll('[role="menuitem"]'); + // Verify exact number of suggestions for "from" input + expect(fromSuggestions).toHaveLength(mockVariables.length); + + mockVariables.forEach((variable) => { + const suggestion = Array.from(fromSuggestions).find((s) => s.textContent?.includes(variable.label as string)); + expect(suggestion).toBeInTheDocument(); + }); + }); + + // Clear suggestions + fireEvent.blur(fromInput); + + // Test "to" input + const toInput = screen.getByPlaceholderText('To'); + fireEvent.focus(toInput); + fireEvent.keyDown(toInput, { key: '$' }); + + // Wait for suggestions to appear and verify for "to" input + await waitFor(() => { + const menus = screen.getAllByRole('menu'); + const toMenu = menus[1]; + const toSuggestions = toMenu.querySelectorAll('[role="menuitem"]'); + // Verify exact number of suggestions for "to" input + expect(toSuggestions).toHaveLength(mockVariables.length); + + mockVariables.forEach((variable) => { + const suggestion = Array.from(toSuggestions).find((s) => s.textContent?.includes(variable.label as string)); + expect(suggestion).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RegexMatcherEditor.test.tsx b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RegexMatcherEditor.test.tsx new file mode 100644 index 00000000000..af9765cecd6 --- /dev/null +++ b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RegexMatcherEditor.test.tsx @@ -0,0 +1,61 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; + +import { FieldType, TypedVariableModel } from '@grafana/data'; +import { getTemplateSrv } from '@grafana/runtime'; + +import { regexMatcherEditor } from './RegexMatcherEditor'; + +jest.mock('@grafana/runtime', () => ({ + getTemplateSrv: jest.fn(), +})); + +describe('RegexMatcherEditor', () => { + it('adds :regex suffix to variable suggestions', async () => { + // Mock template service variables + const mockVariables: TypedVariableModel[] = [ + { name: 'var1', label: 'Variable 1', type: 'custom' } as TypedVariableModel, + { name: 'var2', label: 'Variable 2', type: 'custom' } as TypedVariableModel, + ]; + + const mockTemplateSrv = { + getVariables: () => mockVariables, + replace: jest.fn(), + containsTemplate: jest.fn(), + updateTimeRange: jest.fn(), + }; + + jest.mocked(getTemplateSrv).mockReturnValue(mockTemplateSrv); + + const onChangeMock = jest.fn(); + const options = { value: '' }; + const field = { + name: 'test', + type: FieldType.string, + config: {}, + values: [], + }; + + const Editor = regexMatcherEditor({ validator: () => true }); + render(); + + // Focus the input and press $ to trigger suggestions + const input = screen.getByPlaceholderText('Value or variable'); + fireEvent.focus(input); + fireEvent.keyDown(input, { key: '$' }); + + // Wait for suggestions to appear and verify + await waitFor(() => { + const suggestions = screen.getAllByRole('menuitem'); + // Verify exact number of suggestions (each variable has both regular and regex versions) + expect(suggestions).toHaveLength(mockVariables.length * 2); + + mockVariables.forEach((variable) => { + const regularSuggestion = suggestions.find((s) => s.textContent?.includes(variable.label as string)); + const regexSuggestion = suggestions.find((s) => s.textContent?.includes(`${variable.label as string}:regex`)); + + expect(regularSuggestion).toBeInTheDocument(); + expect(regexSuggestion).toBeInTheDocument(); + }); + }); + }); +}); diff --git a/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RegexMatcherEditor.tsx b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RegexMatcherEditor.tsx index 09fc5936cda..e3e5a559770 100644 --- a/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RegexMatcherEditor.tsx +++ b/public/app/features/transformers/FilterByValueTransformer/ValueMatchers/RegexMatcherEditor.tsx @@ -1,7 +1,7 @@ import { useCallback, useState } from 'react'; import * as React from 'react'; -import { ValueMatcherID, BasicValueMatcherOptions } from '@grafana/data'; +import { ValueMatcherID, BasicValueMatcherOptions, VariableSuggestion } from '@grafana/data'; import { t } from 'app/core/internationalization'; import { SuggestionsInput } from '../../suggestionsInput/SuggestionsInput'; @@ -16,6 +16,19 @@ export function regexMatcherEditor( const { validator } = config; const { value } = options; const [isInvalid, setInvalid] = useState(!validator(value)); + const variableSuggestions = getVariableSuggestions().reduce((acc, v) => { + acc.push(v); + acc.push({ + ...v, + documentation: t( + 'transformers.regex-matcher-editor.variable-regex-documentation', + 'Formats multi-value variable into a regex string' + ), + label: v.label.concat(':regex'), + value: v.value.concat(':regex'), + }); + return acc; + }, []); const onChangeVariableValue = useCallback( (value: string) => { @@ -34,7 +47,7 @@ export function regexMatcherEditor( value={value} onChange={onChangeVariableValue} placeholder={t('transformers.regex-matcher-editor.placeholder-value-or-variable', 'Value or variable')} - suggestions={getVariableSuggestions()} + suggestions={variableSuggestions} /> ); }; diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 1e96a13e794..19f5eb75a63 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -9019,7 +9019,8 @@ "placeholder-choose-stat": "Choose stat" }, "regex-matcher-editor": { - "placeholder-value-or-variable": "Value or variable" + "placeholder-value-or-variable": "Value or variable", + "variable-regex-documentation": "Formats multi-value variable into a regex string" }, "regression-transformer-editor": { "label-degree": "Degree",