TransformationFilter: Implement RefID multi picker (#96841)

* implement refID multi picker for transformation filter

* regexp-foo

* reword comment

* lint

---------

Co-authored-by: Leon Sorokin <leeoniya@gmail.com>
This commit is contained in:
Sergej-Vlasov
2024-11-22 09:24:59 +00:00
committed by GitHub
co-authored by Leon Sorokin
parent 53245e2742
commit 446895ce3f
4 changed files with 199 additions and 5 deletions
@@ -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<Props>) => {
const editorProps = { ...props, ...testProps };
return render(<RefIDPicker {...editorProps} />);
};
const multiSetup = (testProps?: Partial<MultiProps>) => {
const editorProps = { ...multiProps, ...testProps };
return render(<RefIDMultiPicker {...editorProps} />);
};
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']);
});
});
@@ -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<SelectableValue<string>>,
oldRefIds: Array<SelectableValue<string>>,
previousValue: Array<SelectableValue<string>> | undefined
): Array<SelectableValue<string>> | 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<SelectableValue<string>> | undefined;
}>({
refIds: [],
value: undefined,
});
const currentValue = useMemo(() => {
let extractedRefIds = new Set<string>();
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<SelectableValue<string>>) => {
onChange(v.map((v) => v.value!));
},
[onChange]
);
if (listOfRefIds !== priorSelectionState.refIds || currentValue?.length !== priorSelectionState.value?.length) {
updatePriorSelectionState({
refIds: listOfRefIds,
value: currentValue,
});
}
return (
<MultiSelect
options={listOfRefIds}
onChange={onFilterChange}
isClearable={true}
placeholder={placeholder ?? 'Select query refId'}
value={currentValue}
/>
);
}
function getListOfQueryRefIds(data: DataFrame[]): Array<SelectableValue<string>> {
const queries = new Map<string, DataFrame[]>();
@@ -127,3 +216,28 @@ export const fieldsByFrameRefIdItem: FieldMatcherUIRegistryItem<string> = {
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(/(?<!\\)\|/g)
// unescape remaining escaped chars
.map((string) => string.replace(/\\(.)/g, '$1'))
);
};
/** @internal */
export const stringsToRegexp = (strings: string[]) => {
return `/^(?:${strings.map((string) => escapeRegExp(string)).join('|')})$/`;
};
@@ -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 && (
<FrameSelectionEditor
<FrameMultiSelectionEditor
value={config.filter!}
context={opts.context}
// eslint-disable-next-line
@@ -1,7 +1,11 @@
import { useCallback } from 'react';
import { FrameMatcherID, MatcherConfig, StandardEditorProps } from '@grafana/data';
import { RefIDPicker } from '@grafana/ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher';
import {
RefIDMultiPicker,
RefIDPicker,
stringsToRegexp,
} from '@grafana/ui/src/components/MatchersUI/FieldsByFrameRefIdMatcher';
type Props = StandardEditorProps<MatcherConfig>;
@@ -24,3 +28,28 @@ export const FrameSelectionEditor = ({ value, context, onChange }: Props) => {
<RefIDPicker value={value?.options} onChange={onFilterChange} data={context.data} placeholder="Change filter" />
);
};
export const FrameMultiSelectionEditor = ({ value, context, onChange }: Props) => {
const onFilterChange = useCallback(
(v: string[]) => {
onChange(
v?.length
? {
id: FrameMatcherID.byRefId,
options: stringsToRegexp(v),
}
: undefined
);
},
[onChange]
);
return (
<RefIDMultiPicker
value={value?.options}
onChange={onFilterChange}
data={context.data}
placeholder="Change filter"
/>
);
};