mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
94820e1f29
commit
2250c229d6
@ -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,
|
||||
|
@ -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>> };
|
||||
|
@ -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);
|
||||
}
|
@ -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);
|
||||
`,
|
||||
});
|
||||
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user