From 446895ce3fdf6e50223a501a65d935834408656b Mon Sep 17 00:00:00 2001 From: Sergej-Vlasov <37613182+Sergej-Vlasov@users.noreply.github.com> Date: Fri, 22 Nov 2024 09:24:59 +0000 Subject: [PATCH] TransformationFilter: Implement RefID multi picker (#96841) * implement refID multi picker for transformation filter * regexp-foo * reword comment * lint --------- Co-authored-by: Leon Sorokin --- .../FieldsByFrameRefIdMatcher.test.tsx | 53 +++++++- .../MatchersUI/FieldsByFrameRefIdMatcher.tsx | 116 +++++++++++++++++- .../TransformationFilter.tsx | 4 +- .../geomap/editor/FrameSelectionEditor.tsx | 31 ++++- 4 files changed, 199 insertions(+), 5 deletions(-) diff --git a/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.test.tsx b/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.test.tsx index 0ff7c4e1d7a..67abc701345 100644 --- a/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.test.tsx +++ b/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.test.tsx @@ -3,7 +3,14 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { toDataFrame, FieldType } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { RefIDPicker, Props } from './FieldsByFrameRefIdMatcher'; +import { + RefIDPicker, + Props, + RefIDMultiPicker, + MultiProps, + stringsToRegexp, + regexpToStrings, +} from './FieldsByFrameRefIdMatcher'; beforeEach(() => { jest.clearAllMocks(); @@ -33,11 +40,21 @@ const props: Props = { onChange: mockOnChange, }; +const multiProps: MultiProps = { + data: [frame1, frame2, frame3], + onChange: mockOnChange, +}; + const setup = (testProps?: Partial) => { const editorProps = { ...props, ...testProps }; return render(); }; +const multiSetup = (testProps?: Partial) => { + const editorProps = { ...multiProps, ...testProps }; + return render(); +}; + describe('RefIDPicker', () => { it('Should be able to select frame', async () => { setup(); @@ -52,3 +69,37 @@ describe('RefIDPicker', () => { expect(selectOptions[1]).toHaveTextContent('Query: BFrames (1): Series B'); }); }); + +describe('RefIDMultiPicker', () => { + const namesRegexp = /^(?:a|b \(ttt\)|bla\.foo|zzz\|cow|\$dollar\[baz\])$/; + const namesArray = ['a', 'b (ttt)', 'bla.foo', 'zzz|cow', '$dollar[baz]']; + + it('creates regexp string from array of names', async () => { + const names = regexpToStrings(namesRegexp.toString()); + expect(names).toEqual(namesArray); + }); + + it('creates array of names from regexp string', async () => { + const regexpStr = stringsToRegexp(namesArray); + expect(regexpStr).toEqual(namesRegexp.toString()); + }); + + it('Should be able to select frame', async () => { + multiSetup(); + + const select = await screen.findByRole('combobox'); + fireEvent.keyDown(select, { keyCode: 40 }); + + const selectOptions = screen.getAllByTestId(selectors.components.Select.option); + + expect(selectOptions).toHaveLength(2); + expect(selectOptions[0]).toHaveTextContent('Query: AFrames (2): Series A, Second series'); + expect(selectOptions[1]).toHaveTextContent('Query: BFrames (1): Series B'); + + fireEvent.keyDown(select, { keyCode: 13 }); + fireEvent.keyDown(select, { keyCode: 40 }); + fireEvent.keyDown(select, { keyCode: 13 }); + + expect(mockOnChange).toHaveBeenLastCalledWith(['A', 'B']); + }); +}); diff --git a/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.tsx b/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.tsx index ef8f2835151..67d919a4770 100644 --- a/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.tsx +++ b/packages/grafana-ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher.tsx @@ -2,7 +2,7 @@ import { useMemo, useState, useCallback } from 'react'; import { DataFrame, getFrameDisplayName, FieldMatcherID, fieldMatchers, SelectableValue } from '@grafana/data'; -import { Select } from '../Select/Select'; +import { MultiSelect, Select } from '../Select/Select'; import { FieldMatcherUIRegistryItem, MatcherUIProps } from './types'; @@ -78,6 +78,95 @@ export function RefIDPicker({ value, data, onChange, placeholder }: Props) { ); } +const recoverMultiRefIdMissing = ( + newRefIds: Array>, + oldRefIds: Array>, + previousValue: Array> | undefined +): Array> | undefined => { + if (!previousValue || !previousValue.length) { + return; + } + // Previously selected value is missing from the new list. + // Find the value that is in the new list but isn't in the old list + const changedTo = newRefIds.filter((newRefId) => { + return oldRefIds.some((oldRefId) => { + return newRefId === oldRefId; + }); + }); + + if (changedTo.length) { + // Found the new value, we assume the old value changed to this one, so we'll use it + return changedTo; + } + return; +}; + +export interface MultiProps { + value?: string; // 1 or more refID in reqExp format /A|B|C/ + data: DataFrame[]; + onChange: (value: string[]) => void; + placeholder?: string; +} + +export function RefIDMultiPicker({ value, data, onChange, placeholder }: MultiProps) { + const listOfRefIds = useMemo(() => getListOfQueryRefIds(data), [data]); + + const [priorSelectionState, updatePriorSelectionState] = useState<{ + refIds: SelectableValue[]; + value: Array> | undefined; + }>({ + refIds: [], + value: undefined, + }); + + const currentValue = useMemo(() => { + let extractedRefIds = new Set(); + + if (value) { + if (value.startsWith('/^')) { + try { + extractedRefIds = new Set(regexpToStrings(value)); + } catch { + extractedRefIds.add(value); + } + } else { + extractedRefIds.add(value); + } + } + + const matchedRefIds = listOfRefIds.filter((refId) => extractedRefIds.has(refId.value || '')); + + if (matchedRefIds.length) { + return matchedRefIds; + } + + return recoverMultiRefIdMissing(listOfRefIds, priorSelectionState.refIds, priorSelectionState.value); + }, [value, listOfRefIds, priorSelectionState]); + + const onFilterChange = useCallback( + (v: Array>) => { + onChange(v.map((v) => v.value!)); + }, + [onChange] + ); + + if (listOfRefIds !== priorSelectionState.refIds || currentValue?.length !== priorSelectionState.value?.length) { + updatePriorSelectionState({ + refIds: listOfRefIds, + value: currentValue, + }); + } + return ( + + ); +} + function getListOfQueryRefIds(data: DataFrame[]): Array> { const queries = new Map(); @@ -127,3 +216,28 @@ export const fieldsByFrameRefIdItem: FieldMatcherUIRegistryItem = { description: 'Set properties for fields from a specific query', optionsToLabel: (options) => options, }; + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_expressions#escaping +function escapeRegExp(string: string) { + return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string +} + +// funcs below will parse/unparse a regexp like /^(?:foo|bar)$/ -> ["foo", "bar"] + +/** @internal */ +export const regexpToStrings = (regexp: string) => { + return ( + regexp + // strip /^(?:)$/ wrapper + .slice(5, -3) + // split on unescaped | + .split(/(? string.replace(/\\(.)/g, '$1')) + ); +}; + +/** @internal */ +export const stringsToRegexp = (strings: string[]) => { + return `/^(?:${strings.map((string) => escapeRegExp(string)).join('|')})$/`; +}; diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationFilter.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationFilter.tsx index f3dc7dfba93..893c1fc5770 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationFilter.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationFilter.tsx @@ -9,7 +9,7 @@ import { } from '@grafana/data'; import { DataTopic } from '@grafana/schema'; import { Field, Select, useStyles2 } from '@grafana/ui'; -import { FrameSelectionEditor } from 'app/plugins/panel/geomap/editor/FrameSelectionEditor'; +import { FrameMultiSelectionEditor } from 'app/plugins/panel/geomap/editor/FrameSelectionEditor'; import { TransformationData } from './TransformationsEditor'; @@ -56,7 +56,7 @@ export const TransformationFilter = ({ index, data, config, onChange }: Transfor /> )} {opts.showFilter && ( - ; @@ -24,3 +28,28 @@ export const FrameSelectionEditor = ({ value, context, onChange }: Props) => { ); }; + +export const FrameMultiSelectionEditor = ({ value, context, onChange }: Props) => { + const onFilterChange = useCallback( + (v: string[]) => { + onChange( + v?.length + ? { + id: FrameMatcherID.byRefId, + options: stringsToRegexp(v), + } + : undefined + ); + }, + [onChange] + ); + + return ( + + ); +};