Prometheus: Add multi-word search and higlight to metric selector (#44906)

* Prometheus: Add multi-word search and higlight to metric selector

* Remove redundant test

* Remove redundant test id

* Update PromQueryBuilder test

* Match only words split with space
This commit is contained in:
Ivana Huckova 2022-02-07 15:18:17 +01:00 committed by GitHub
parent 94820e1f29
commit 2250c229d6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 160 additions and 23 deletions

View File

@ -138,6 +138,7 @@ export function SelectBase<T>({
value,
width,
isValidNewOption,
formatOptionLabel,
}: SelectBaseProps<T>) {
if (menuShouldPortal === false) {
deprecationWarning('SelectBase', 'menuShouldPortal={false}', 'menuShouldPortal={true}');
@ -237,6 +238,7 @@ export function SelectBase<T>({
onKeyDown,
onMenuClose: onCloseMenu,
onMenuOpen: onOpenMenu,
formatOptionLabel,
openMenuOnFocus,
options,
placeholder,

View File

@ -23,6 +23,7 @@ export interface SelectCommonProps<T> {
defaultValue?: any;
disabled?: boolean;
filterOption?: (option: SelectableValue<T>, searchQuery: string) => boolean;
formatOptionLabel?: (item: SelectableValue<T>, formatOptionMeta: FormatOptionLabelMeta<T>) => React.ReactNode;
/** Function for formatting the text that is displayed when creating a new value*/
formatCreateLabel?: (input: string) => string;
getOptionLabel?: (item: SelectableValue<T>) => React.ReactNode;
@ -127,3 +128,5 @@ export interface SelectableOptGroup<T = any> {
export type SelectOptions<T = any> =
| SelectableValue<T>
| Array<SelectableValue<T> | SelectableOptGroup<T> | Array<SelectableOptGroup<T>>>;
export type FormatOptionLabelMeta<T> = { context: string; inputValue: string; selectValue: Array<SelectableValue<T>> };

View File

@ -0,0 +1,96 @@
import React from 'react';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MetricSelect } from './MetricSelect';
const props = {
query: {
metric: '',
labels: [],
operations: [],
},
onChange: jest.fn(),
onGetMetrics: jest
.fn()
.mockResolvedValue([{ label: 'random_metric' }, { label: 'unique_metric' }, { label: 'more_unique_metric' }]),
};
describe('MetricSelect', () => {
it('shows all metric options', async () => {
render(<MetricSelect {...props} />);
await openMetricSelect();
await waitFor(() => expect(screen.getByText('random_metric')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('unique_metric')).toBeInTheDocument());
await waitFor(() => expect(screen.getByText('more_unique_metric')).toBeInTheDocument());
await waitFor(() => expect(screen.getAllByLabelText('Select option')).toHaveLength(3));
});
it('shows option to create metric when typing', async () => {
render(<MetricSelect {...props} />);
await openMetricSelect();
const input = screen.getByRole('combobox');
userEvent.type(input, 'new');
await waitFor(() => expect(screen.getByText('Create: new')).toBeInTheDocument());
});
it('shows searched options when typing', async () => {
render(<MetricSelect {...props} />);
await openMetricSelect();
const input = screen.getByRole('combobox');
userEvent.type(input, 'unique');
await waitFor(() => expect(screen.getAllByLabelText('Select option')).toHaveLength(3));
});
it('searches on split words', async () => {
render(<MetricSelect {...props} />);
await openMetricSelect();
const input = screen.getByRole('combobox');
userEvent.type(input, 'more unique');
await waitFor(() => expect(screen.getAllByLabelText('Select option')).toHaveLength(2));
});
it('searches on multiple split words', async () => {
render(<MetricSelect {...props} />);
await openMetricSelect();
const input = screen.getByRole('combobox');
userEvent.type(input, 'more unique metric');
await waitFor(() => expect(screen.getAllByLabelText('Select option')).toHaveLength(2));
});
it('highlihts matching string', async () => {
const { container } = render(<MetricSelect {...props} />);
await openMetricSelect();
const input = screen.getByRole('combobox');
userEvent.type(input, 'more');
await waitFor(() => expect(container.querySelectorAll('mark')).toHaveLength(1));
});
it('highlihts multiple matching strings in 1 input row', async () => {
const { container } = render(<MetricSelect {...props} />);
await openMetricSelect();
const input = screen.getByRole('combobox');
userEvent.type(input, 'more metric');
await waitFor(() => expect(container.querySelectorAll('mark')).toHaveLength(2));
});
it('highlihts multiple matching strings in multiple input rows', async () => {
const { container } = render(<MetricSelect {...props} />);
await openMetricSelect();
const input = screen.getByRole('combobox');
userEvent.type(input, 'unique metric');
await waitFor(() => expect(container.querySelectorAll('mark')).toHaveLength(4));
});
it('does not highlight matching string in create option', async () => {
const { container } = render(<MetricSelect {...props} />);
await openMetricSelect();
const input = screen.getByRole('combobox');
userEvent.type(input, 'new');
await waitFor(() => expect(container.querySelector('mark')).not.toBeInTheDocument());
});
});
async function openMetricSelect() {
const select = await screen.getByText('Select metric').parentElement!;
userEvent.click(select);
}

View File

@ -1,9 +1,13 @@
import { Select } from '@grafana/ui';
import React, { useState } from 'react';
import { Select, FormatOptionLabelMeta, useStyles2 } from '@grafana/ui';
import React, { useCallback, useState } from 'react';
import { PromVisualQuery } from '../types';
import { SelectableValue, toOption } from '@grafana/data';
import { SelectableValue, toOption, GrafanaTheme2 } from '@grafana/data';
import { EditorField, EditorFieldGroup } from '@grafana/experimental';
import { css } from '@emotion/css';
import Highlighter from 'react-highlight-words';
// We are matching words split with space
const splitSeparator = ' ';
export interface Props {
query: PromVisualQuery;
@ -12,12 +16,39 @@ export interface Props {
}
export function MetricSelect({ query, onChange, onGetMetrics }: Props) {
const styles = getStyles();
const styles = useStyles2(getStyles);
const [state, setState] = useState<{
metrics?: Array<SelectableValue<any>>;
isLoading?: boolean;
}>({});
const customFilterOption = useCallback((option: SelectableValue<any>, searchQuery: string) => {
const label = option.label ?? option.value;
if (!label) {
return false;
}
const searchWords = searchQuery.split(splitSeparator);
return searchWords.reduce((acc, cur) => acc && label.toLowerCase().includes(cur.toLowerCase()), true);
}, []);
const formatOptionLabel = useCallback(
(option: SelectableValue<any>, meta: FormatOptionLabelMeta<any>) => {
// For newly created custom value we don't want to add highlight
if (option['__isNew__']) {
return option.label;
}
return (
<Highlighter
searchWords={meta.inputValue.split(splitSeparator)}
textToHighlight={option.label ?? ''}
highlightClassName={styles.hightlight}
/>
);
},
[styles.hightlight]
);
return (
<EditorFieldGroup>
<EditorField label="Metric">
@ -27,6 +58,8 @@ export function MetricSelect({ query, onChange, onGetMetrics }: Props) {
value={query.metric ? toOption(query.metric) : undefined}
placeholder="Select metric"
allowCustomValue
formatOptionLabel={formatOptionLabel}
filterOption={customFilterOption}
onOpenMenu={async () => {
setState({ isLoading: true });
const metrics = await onGetMetrics();
@ -45,8 +78,15 @@ export function MetricSelect({ query, onChange, onGetMetrics }: Props) {
);
}
const getStyles = () => ({
const getStyles = (theme: GrafanaTheme2) => ({
select: css`
min-width: 125px;
`,
hightlight: css`
label: select__match-highlight;
background: inherit;
padding: inherit;
color: ${theme.colors.warning.main};
background-color: rgba(${theme.colors.warning.main}, 0.1);
`,
});

View File

@ -1,5 +1,5 @@
import React from 'react';
import { render, screen, getByRole, getByText } from '@testing-library/react';
import { render, screen, getByText } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { PromQueryBuilder } from './PromQueryBuilder';
import { PrometheusDatasource } from '../../datasource';
@ -68,24 +68,24 @@ describe('PromQueryBuilder', () => {
});
it('tries to load metrics without labels', async () => {
const { languageProvider } = setup();
openMetricSelect();
const { languageProvider, container } = setup();
openMetricSelect(container);
expect(languageProvider.getLabelValues).toBeCalledWith('__name__');
});
it('tries to load metrics with labels', async () => {
const { languageProvider } = setup({
const { languageProvider, container } = setup({
...defaultQuery,
labels: [{ label: 'label_name', op: '=', value: 'label_value' }],
});
openMetricSelect();
openMetricSelect(container);
expect(languageProvider.getSeries).toBeCalledWith('{label_name="label_value"}', true);
});
it('tries to load variables in metric field', async () => {
const { datasource } = setup();
const { datasource, container } = setup();
datasource.getVariables = jest.fn().mockReturnValue([]);
openMetricSelect();
openMetricSelect(container);
expect(datasource.getVariables).toBeCalled();
});
@ -142,19 +142,15 @@ function setup(query: PromVisualQuery = defaultQuery) {
onChange: () => {},
};
render(<PromQueryBuilder {...props} query={query} />);
return { languageProvider, datasource };
const { container } = render(<PromQueryBuilder {...props} query={query} />);
return { languageProvider, datasource, container };
}
function getMetricSelect() {
const metricSelect = screen.getAllByText('random_metric')[0].parentElement!;
// We need to return specifically input element otherwise clicks don't seem to work
return getByRole(metricSelect, 'combobox');
}
function openMetricSelect() {
const select = getMetricSelect();
userEvent.click(select);
function openMetricSelect(container: HTMLElement) {
const select = container.querySelector('#prometheus-metric-select');
if (select) {
userEvent.click(select);
}
}
function openLabelNameSelect(index = 0) {