diff --git a/packages/grafana-ui/src/components/Select/SelectBase.tsx b/packages/grafana-ui/src/components/Select/SelectBase.tsx index bcc267afdb0..1123828a8c9 100644 --- a/packages/grafana-ui/src/components/Select/SelectBase.tsx +++ b/packages/grafana-ui/src/components/Select/SelectBase.tsx @@ -138,6 +138,7 @@ export function SelectBase({ value, width, isValidNewOption, + formatOptionLabel, }: SelectBaseProps) { if (menuShouldPortal === false) { deprecationWarning('SelectBase', 'menuShouldPortal={false}', 'menuShouldPortal={true}'); @@ -237,6 +238,7 @@ export function SelectBase({ onKeyDown, onMenuClose: onCloseMenu, onMenuOpen: onOpenMenu, + formatOptionLabel, openMenuOnFocus, options, placeholder, diff --git a/packages/grafana-ui/src/components/Select/types.ts b/packages/grafana-ui/src/components/Select/types.ts index 989d293bfdd..3ac60d376ae 100644 --- a/packages/grafana-ui/src/components/Select/types.ts +++ b/packages/grafana-ui/src/components/Select/types.ts @@ -23,6 +23,7 @@ export interface SelectCommonProps { defaultValue?: any; disabled?: boolean; filterOption?: (option: SelectableValue, searchQuery: string) => boolean; + formatOptionLabel?: (item: SelectableValue, formatOptionMeta: FormatOptionLabelMeta) => React.ReactNode; /** Function for formatting the text that is displayed when creating a new value*/ formatCreateLabel?: (input: string) => string; getOptionLabel?: (item: SelectableValue) => React.ReactNode; @@ -127,3 +128,5 @@ export interface SelectableOptGroup { export type SelectOptions = | SelectableValue | Array | SelectableOptGroup | Array>>; + +export type FormatOptionLabelMeta = { context: string; inputValue: string; selectValue: Array> }; diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.test.tsx new file mode 100644 index 00000000000..afcc3056f17 --- /dev/null +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.test.tsx @@ -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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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(); + 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); +} diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx index 3951a1a30f2..eccd302aaa4 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx @@ -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>; isLoading?: boolean; }>({}); + const customFilterOption = useCallback((option: SelectableValue, 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, meta: FormatOptionLabelMeta) => { + // For newly created custom value we don't want to add highlight + if (option['__isNew__']) { + return option.label; + } + + return ( + + ); + }, + [styles.hightlight] + ); + return ( @@ -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); + `, }); diff --git a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.test.tsx b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.test.tsx index c1c960626ee..022c7fab92e 100644 --- a/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.test.tsx +++ b/public/app/plugins/datasource/prometheus/querybuilder/components/PromQueryBuilder.test.tsx @@ -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(); - return { languageProvider, datasource }; + const { container } = render(); + 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) {