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:
co-authored by
Leon Sorokin
parent
53245e2742
commit
446895ce3f
@@ -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('|')})$/`;
|
||||
};
|
||||
|
||||
+2
-2
@@ -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"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user