mirror of https://github.com/grafana/grafana
Chore: Remove prometheusUsesCombobox feature toggle (#103940)
* remove prometheusUsesCombobox feature toggle
* betterer
* fix the unit test
* create MetricsLabelsSection unit tests
* fix unit tests
* fix unit tests in PromQueryBuilder.test.tsx
* prettier
* remove timeouts
* Revert "remove timeouts"
This reverts commit 84af1fd46b.
pull/104375/head
parent
576bf66e03
commit
8ef8471b23
@ -1,198 +0,0 @@ |
||||
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.test.tsx
|
||||
import { render, screen, waitFor } from '@testing-library/react'; |
||||
import userEvent from '@testing-library/user-event'; |
||||
|
||||
import { DataSourceInstanceSettings, MetricFindValue } from '@grafana/data'; |
||||
import { selectors } from '@grafana/e2e-selectors'; |
||||
|
||||
import { PrometheusDatasource } from '../../datasource'; |
||||
import { PromOptions } from '../../types'; |
||||
|
||||
import { |
||||
formatPrometheusLabelFilters, |
||||
formatPrometheusLabelFiltersToString, |
||||
MetricSelect, |
||||
MetricSelectProps, |
||||
} from './MetricSelect'; |
||||
|
||||
const instanceSettings = { |
||||
url: 'proxied', |
||||
id: 1, |
||||
user: 'test', |
||||
password: 'mupp', |
||||
jsonData: { httpMethod: 'GET' }, |
||||
} as unknown as DataSourceInstanceSettings<PromOptions>; |
||||
|
||||
const dataSourceMock = new PrometheusDatasource(instanceSettings); |
||||
const mockValues = [{ label: 'random_metric' }, { label: 'unique_metric' }, { label: 'more_unique_metric' }]; |
||||
// Mock metricFindQuery which will call backend API
|
||||
//@ts-ignore
|
||||
dataSourceMock.metricFindQuery = jest.fn((query: string) => { |
||||
// Use the label values regex to get the values inside the label_values function call
|
||||
const labelValuesRegex = /^label_values\((?:(.+),\s*)?([a-zA-Z_][a-zA-Z0-9_]*)\)\s*$/; |
||||
const queryValueArray = query.match(labelValuesRegex) as RegExpMatchArray; |
||||
const queryValueRaw = queryValueArray[1] as string; |
||||
|
||||
// Remove the wrapping regex
|
||||
const queryValue = queryValueRaw.substring(queryValueRaw.indexOf('".*') + 3, queryValueRaw.indexOf('.*"')); |
||||
|
||||
// Run the regex that we'd pass into prometheus API against the strings in the test
|
||||
return Promise.resolve( |
||||
mockValues |
||||
.filter((value) => value.label.match(queryValue)) |
||||
.map((result) => { |
||||
return { |
||||
text: result.label, |
||||
}; |
||||
}) as MetricFindValue[] |
||||
); |
||||
}); |
||||
|
||||
const props: MetricSelectProps = { |
||||
labelsFilters: [], |
||||
datasource: dataSourceMock, |
||||
query: { |
||||
metric: '', |
||||
labels: [], |
||||
operations: [], |
||||
}, |
||||
onChange: jest.fn(), |
||||
onGetMetrics: jest.fn().mockResolvedValue(mockValues), |
||||
metricLookupDisabled: false, |
||||
}; |
||||
|
||||
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.getByText('Metrics explorer')).toBeInTheDocument()); |
||||
await waitFor(() => expect(screen.getAllByTestId(selectors.components.Select.option)).toHaveLength(4)); |
||||
}); |
||||
|
||||
it('truncates list of metrics to 1000', async () => { |
||||
const manyMockValues = [...Array(1001).keys()].map((idx: number) => { |
||||
return { label: 'random_metric' + idx }; |
||||
}); |
||||
|
||||
props.onGetMetrics = jest.fn().mockResolvedValue(manyMockValues); |
||||
|
||||
render(<MetricSelect {...props} />); |
||||
await openMetricSelect(); |
||||
// the metrics explorer is added as a custom option
|
||||
const optionsLength = screen.getAllByTestId(selectors.components.Select.option).length; |
||||
const optionsLengthMinusMetricsExplorer = optionsLength - 1; |
||||
await waitFor(() => expect(optionsLengthMinusMetricsExplorer).toBe(1000)); |
||||
}); |
||||
|
||||
it('shows option to set custom value when typing', async () => { |
||||
render(<MetricSelect {...props} />); |
||||
await openMetricSelect(); |
||||
const input = screen.getByRole('combobox'); |
||||
await userEvent.type(input, 'custom value'); |
||||
await waitFor(() => expect(screen.getByText('custom value')).toBeInTheDocument()); |
||||
}); |
||||
|
||||
it('shows searched options when typing', async () => { |
||||
render(<MetricSelect {...props} />); |
||||
await openMetricSelect(); |
||||
const input = screen.getByRole('combobox'); |
||||
await userEvent.type(input, 'unique'); |
||||
const optionsLength = mockValues.length + 1; // the metrics explorer is added as a custom option
|
||||
await waitFor(() => expect(screen.getAllByTestId(selectors.components.Select.option)).toHaveLength(optionsLength)); |
||||
}); |
||||
|
||||
it('searches on split words', async () => { |
||||
render(<MetricSelect {...props} />); |
||||
await openMetricSelect(); |
||||
const input = screen.getByRole('combobox'); |
||||
await userEvent.type(input, 'more unique'); |
||||
// the metrics explorer is added as a custom option
|
||||
await waitFor(() => expect(screen.getAllByTestId(selectors.components.Select.option)).toHaveLength(3)); |
||||
}); |
||||
|
||||
it('searches on multiple split words', async () => { |
||||
render(<MetricSelect {...props} />); |
||||
await openMetricSelect(); |
||||
const input = screen.getByRole('combobox'); |
||||
await userEvent.type(input, 'more unique metric'); |
||||
// the metrics explorer is added as a custom option
|
||||
await waitFor(() => expect(screen.getAllByTestId(selectors.components.Select.option)).toHaveLength(3)); |
||||
}); |
||||
|
||||
it('highlights matching string', async () => { |
||||
render(<MetricSelect {...props} />); |
||||
await openMetricSelect(); |
||||
const input = screen.getByRole('combobox'); |
||||
await userEvent.type(input, 'more'); |
||||
await waitFor(() => expect(document.querySelectorAll('mark')).toHaveLength(1)); |
||||
}); |
||||
|
||||
it('highlights multiple matching strings in 1 input row', async () => { |
||||
render(<MetricSelect {...props} />); |
||||
await openMetricSelect(); |
||||
const input = screen.getByRole('combobox'); |
||||
await userEvent.type(input, 'more metric'); |
||||
await waitFor(() => expect(document.querySelectorAll('mark')).toHaveLength(2)); |
||||
}); |
||||
|
||||
it('highlights multiple matching strings in multiple input rows', async () => { |
||||
render(<MetricSelect {...props} />); |
||||
await openMetricSelect(); |
||||
const input = screen.getByRole('combobox'); |
||||
await userEvent.type(input, 'unique metric'); |
||||
await waitFor(() => expect(document.querySelectorAll('mark')).toHaveLength(4)); |
||||
}); |
||||
|
||||
it('does not highlight matching string in create option', async () => { |
||||
render(<MetricSelect {...props} />); |
||||
await openMetricSelect(); |
||||
const input = screen.getByRole('combobox'); |
||||
await userEvent.type(input, 'new'); |
||||
await waitFor(() => expect(document.querySelector('mark')).not.toBeInTheDocument()); |
||||
}); |
||||
|
||||
it('label filters properly join', () => { |
||||
const query = formatPrometheusLabelFilters([ |
||||
{ |
||||
value: 'value', |
||||
label: 'label', |
||||
op: '=', |
||||
}, |
||||
{ |
||||
value: 'value2', |
||||
label: 'label2', |
||||
op: '=', |
||||
}, |
||||
]); |
||||
query.forEach((label) => { |
||||
expect(label.includes(',', 0)); |
||||
}); |
||||
}); |
||||
it('label filter creation', () => { |
||||
const labels = [ |
||||
{ |
||||
value: 'value', |
||||
label: 'label', |
||||
op: '=', |
||||
}, |
||||
{ |
||||
value: 'value2', |
||||
label: 'label2', |
||||
op: '=', |
||||
}, |
||||
]; |
||||
|
||||
const queryString = formatPrometheusLabelFiltersToString('query', labels); |
||||
queryString.split(',').forEach((queryChunk) => { |
||||
expect(queryChunk.length).toBeGreaterThan(1); // must be longer then ','
|
||||
}); |
||||
}); |
||||
}); |
||||
|
||||
async function openMetricSelect() { |
||||
const select = screen.getByText('Select metric').parentElement!; |
||||
await userEvent.click(select); |
||||
} |
||||
@ -1,401 +0,0 @@ |
||||
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/querybuilder/components/MetricSelect.tsx
|
||||
import { css } from '@emotion/css'; |
||||
import debounce from 'debounce-promise'; |
||||
import { RefCallback, useCallback, useState } from 'react'; |
||||
import * as React from 'react'; |
||||
import Highlighter from 'react-highlight-words'; |
||||
|
||||
import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data'; |
||||
import { selectors } from '@grafana/e2e-selectors'; |
||||
import { EditorField, EditorFieldGroup } from '@grafana/plugin-ui'; |
||||
import { |
||||
AsyncSelect, |
||||
Button, |
||||
FormatOptionLabelMeta, |
||||
getSelectStyles, |
||||
Icon, |
||||
InlineField, |
||||
InlineFieldRow, |
||||
ScrollContainer, |
||||
SelectMenuOptions, |
||||
useStyles2, |
||||
useTheme2, |
||||
} from '@grafana/ui'; |
||||
|
||||
import { PrometheusDatasource } from '../../datasource'; |
||||
import { truncateResult } from '../../language_utils'; |
||||
import { regexifyLabelValuesQueryString } from '../parsingUtils'; |
||||
import { QueryBuilderLabelFilter } from '../shared/types'; |
||||
import { PromVisualQuery } from '../types'; |
||||
|
||||
import { MetricsModal } from './metrics-modal/MetricsModal'; |
||||
import { tracking } from './metrics-modal/state/helpers'; |
||||
|
||||
// We are matching words split with space
|
||||
const splitSeparator = ' '; |
||||
|
||||
export interface MetricSelectProps { |
||||
metricLookupDisabled: boolean; |
||||
query: PromVisualQuery; |
||||
onChange: (query: PromVisualQuery) => void; |
||||
onGetMetrics: () => Promise<SelectableValue[]>; |
||||
datasource: PrometheusDatasource; |
||||
labelsFilters: QueryBuilderLabelFilter[]; |
||||
onBlur?: () => void; |
||||
variableEditor?: boolean; |
||||
} |
||||
|
||||
export const PROMETHEUS_QUERY_BUILDER_MAX_RESULTS = 1000; |
||||
|
||||
export function MetricSelect({ |
||||
datasource, |
||||
query, |
||||
onChange, |
||||
onGetMetrics, |
||||
labelsFilters, |
||||
metricLookupDisabled, |
||||
onBlur, |
||||
variableEditor, |
||||
}: Readonly<MetricSelectProps>) { |
||||
const styles = useStyles2(getStyles); |
||||
const [state, setState] = useState<{ |
||||
metrics?: SelectableValue[]; |
||||
isLoading?: boolean; |
||||
metricsModalOpen?: boolean; |
||||
initialMetrics?: string[]; |
||||
resultsTruncated?: boolean; |
||||
}>({}); |
||||
|
||||
const metricsModalOption: SelectableValue[] = [ |
||||
{ |
||||
value: 'BrowseMetrics', |
||||
label: 'Metrics explorer', |
||||
description: 'Browse and filter all metrics and metadata with a fuzzy search', |
||||
}, |
||||
]; |
||||
|
||||
const customFilterOption = useCallback((option: SelectableValue, searchQuery: string) => { |
||||
const label = option.label ?? option.value; |
||||
if (!label) { |
||||
return false; |
||||
} |
||||
|
||||
// custom value is not a string label but a react node
|
||||
if (!label.toLowerCase) { |
||||
return true; |
||||
} |
||||
|
||||
const searchWords = searchQuery.split(splitSeparator); |
||||
|
||||
return searchWords.reduce((acc, cur) => { |
||||
const matcheSearch = label.toLowerCase().includes(cur.toLowerCase()); |
||||
const browseOption = label === 'Metrics explorer'; |
||||
return acc && (matcheSearch || browseOption); |
||||
}, true); |
||||
}, []); |
||||
|
||||
const formatOptionLabel = useCallback( |
||||
(option: SelectableValue, meta: FormatOptionLabelMeta<any>) => { |
||||
// For newly created custom value we don't want to add highlight
|
||||
if (option['__isNew__']) { |
||||
return option.label; |
||||
} |
||||
// only matches on input, does not match on regex
|
||||
// look into matching for regex input
|
||||
return ( |
||||
<Highlighter |
||||
searchWords={meta.inputValue.split(splitSeparator)} |
||||
textToHighlight={option.label ?? ''} |
||||
highlightClassName={styles.highlight} |
||||
/> |
||||
); |
||||
}, |
||||
[styles.highlight] |
||||
); |
||||
|
||||
/** |
||||
* Reformat the query string and label filters to return all valid results for current query editor state |
||||
*/ |
||||
const formatKeyValueStringsForLabelValuesQuery = ( |
||||
query: string, |
||||
labelsFilters?: QueryBuilderLabelFilter[] |
||||
): string => { |
||||
const queryString = regexifyLabelValuesQueryString(query); |
||||
|
||||
return formatPrometheusLabelFiltersToString(queryString, labelsFilters); |
||||
}; |
||||
|
||||
/** |
||||
* Gets label_values response from prometheus API for current autocomplete query string and any existing labels filters |
||||
*/ |
||||
const getMetricLabels = (query: string) => { |
||||
// Since some customers can have millions of metrics, whenever the user changes the autocomplete text we want to call the backend and request all metrics that match the current query string
|
||||
const results = datasource.metricFindQuery(formatKeyValueStringsForLabelValuesQuery(query, labelsFilters)); |
||||
return results.then((results) => { |
||||
const resultsLength = results.length; |
||||
truncateResult(results); |
||||
|
||||
if (resultsLength > results.length) { |
||||
setState({ ...state, resultsTruncated: true }); |
||||
} else { |
||||
setState({ ...state, resultsTruncated: false }); |
||||
} |
||||
|
||||
const resultsOptions = results.map((result) => { |
||||
return { |
||||
label: result.text, |
||||
value: result.text, |
||||
}; |
||||
}); |
||||
|
||||
return [...metricsModalOption, ...resultsOptions]; |
||||
}); |
||||
}; |
||||
|
||||
// When metric and label lookup is disabled we won't request labels
|
||||
const metricLookupDisabledSearch = () => Promise.resolve([]); |
||||
|
||||
const debouncedSearch = debounce( |
||||
(query: string) => getMetricLabels(query), |
||||
datasource.getDebounceTimeInMilliseconds() |
||||
); |
||||
|
||||
// No type found for the common select props so typing as any
|
||||
// https://github.com/grafana/grafana/blob/main/packages/grafana-ui/src/components/Select/SelectBase.tsx/#L212-L263
|
||||
// eslint-disable-next-line
|
||||
const CustomOption = (props: any) => { |
||||
const option = props.data; |
||||
|
||||
if (option.value === 'BrowseMetrics') { |
||||
const isFocused = props.isFocused ? styles.focus : ''; |
||||
|
||||
return ( |
||||
// TODO: fix keyboard a11y
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div |
||||
{...props.innerProps} |
||||
ref={props.innerRef} |
||||
className={`${styles.customOptionWidth} metric-encyclopedia-open`} |
||||
data-testid={selectors.components.Select.option} |
||||
onKeyDown={(e) => { |
||||
// if there is no metric and the m.e. is enabled, open the modal
|
||||
if (e.code === 'Enter') { |
||||
setState({ ...state, metricsModalOpen: true }); |
||||
} |
||||
}} |
||||
> |
||||
{ |
||||
<div className={`${styles.customOption} ${isFocused} metric-encyclopedia-open`}> |
||||
<div> |
||||
<div className="metric-encyclopedia-open">{option.label}</div> |
||||
<div className={`${styles.customOptionDesc} metric-encyclopedia-open`}>{option.description}</div> |
||||
</div> |
||||
<Button |
||||
fill="text" |
||||
size="sm" |
||||
variant="secondary" |
||||
onClick={() => setState({ ...state, metricsModalOpen: true })} |
||||
className="metric-encyclopedia-open" |
||||
> |
||||
Open |
||||
<Icon name="arrow-right" /> |
||||
</Button> |
||||
</div> |
||||
} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
return SelectMenuOptions(props); |
||||
}; |
||||
|
||||
interface SelectMenuProps { |
||||
maxHeight: number; |
||||
innerRef: RefCallback<HTMLDivElement>; |
||||
innerProps: {}; |
||||
} |
||||
|
||||
const CustomMenu = ({ children, maxHeight, innerRef, innerProps }: React.PropsWithChildren<SelectMenuProps>) => { |
||||
const theme = useTheme2(); |
||||
const stylesMenu = getSelectStyles(theme); |
||||
|
||||
// Show the results trucated warning only if the options are loaded and the results are truncated
|
||||
// The children are a react node(options loading node) or an array(not a valid element)
|
||||
const optionsLoaded = !React.isValidElement(children) && state.resultsTruncated; |
||||
|
||||
return ( |
||||
<div |
||||
{...innerProps} |
||||
className={`${stylesMenu.menu} ${styles.customMenuContainer}`} |
||||
style={{ maxHeight: Math.round(maxHeight * 0.9) }} |
||||
aria-label="Select options menu" |
||||
> |
||||
<ScrollContainer ref={innerRef} showScrollIndicators> |
||||
{children} |
||||
</ScrollContainer> |
||||
{optionsLoaded && ( |
||||
<div className={styles.customMenuFooter}> |
||||
<div> |
||||
Only the top 1000 metrics are displayed in the metric select. Use the metrics explorer to view all |
||||
metrics. |
||||
</div> |
||||
</div> |
||||
)} |
||||
</div> |
||||
); |
||||
}; |
||||
|
||||
const asyncSelect = () => { |
||||
return ( |
||||
<AsyncSelect |
||||
data-testid={selectors.components.DataSource.Prometheus.queryEditor.builder.metricSelect} |
||||
isClearable={true} |
||||
inputId="prometheus-metric-select" |
||||
className={styles.select} |
||||
value={query.metric ? toOption(query.metric) : undefined} |
||||
placeholder={'Select metric'} |
||||
allowCustomValue |
||||
formatOptionLabel={formatOptionLabel} |
||||
filterOption={customFilterOption} |
||||
minMenuHeight={250} |
||||
onOpenMenu={async () => { |
||||
if (metricLookupDisabled) { |
||||
return; |
||||
} |
||||
setState({ isLoading: true }); |
||||
const metrics = await onGetMetrics(); |
||||
const initialMetrics: string[] = metrics.map((m) => m.value); |
||||
const resultsLength = metrics.length; |
||||
|
||||
if (metrics.length > PROMETHEUS_QUERY_BUILDER_MAX_RESULTS) { |
||||
truncateResult(metrics); |
||||
} |
||||
|
||||
setState({ |
||||
// add the modal button option to the options
|
||||
metrics: [...metricsModalOption, ...metrics], |
||||
isLoading: undefined, |
||||
// pass the initial metrics into the metrics explorer
|
||||
initialMetrics: initialMetrics, |
||||
resultsTruncated: resultsLength > metrics.length, |
||||
}); |
||||
}} |
||||
loadOptions={metricLookupDisabled ? metricLookupDisabledSearch : debouncedSearch} |
||||
isLoading={state.isLoading} |
||||
defaultOptions={state.metrics ?? Array.from(new Array(25), () => ({ value: '' }))} // We need empty values when `state.metrics` is falsy in order for the select to correctly determine top/bottom placement
|
||||
onChange={(input) => { |
||||
const value = input?.value; |
||||
if (value) { |
||||
// if there is no metric and the value is the custom m.e. option, open the modal
|
||||
if (value === 'BrowseMetrics') { |
||||
tracking('grafana_prometheus_metric_encyclopedia_open', null, '', query); |
||||
setState({ ...state, metricsModalOpen: true }); |
||||
} else { |
||||
onChange({ ...query, metric: value }); |
||||
} |
||||
} else { |
||||
onChange({ ...query, metric: '' }); |
||||
} |
||||
}} |
||||
components={{ Option: CustomOption, MenuList: CustomMenu }} |
||||
onBlur={onBlur} |
||||
/> |
||||
); |
||||
}; |
||||
|
||||
return ( |
||||
<> |
||||
{!datasource.lookupsDisabled && state.metricsModalOpen && ( |
||||
<MetricsModal |
||||
datasource={datasource} |
||||
isOpen={state.metricsModalOpen} |
||||
onClose={() => setState({ ...state, metricsModalOpen: false })} |
||||
query={query} |
||||
onChange={onChange} |
||||
initialMetrics={state.initialMetrics ?? []} |
||||
/> |
||||
)} |
||||
{/* format the ui for either the query editor or the variable editor */} |
||||
{variableEditor ? ( |
||||
<InlineFieldRow> |
||||
<InlineField |
||||
label="Metric" |
||||
labelWidth={20} |
||||
tooltip={<div>Optional: returns a list of label values for the label name in the specified metric.</div>} |
||||
> |
||||
{asyncSelect()} |
||||
</InlineField> |
||||
</InlineFieldRow> |
||||
) : ( |
||||
<EditorFieldGroup> |
||||
<EditorField label="Metric">{asyncSelect()}</EditorField> |
||||
</EditorFieldGroup> |
||||
)} |
||||
</> |
||||
); |
||||
} |
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({ |
||||
select: css({ |
||||
minWidth: '125px', |
||||
}), |
||||
highlight: css({ |
||||
label: 'select__match-highlight', |
||||
background: 'inherit', |
||||
padding: 'inherit', |
||||
color: theme.colors.warning.contrastText, |
||||
backgroundColor: theme.colors.warning.main, |
||||
}), |
||||
customOption: css({ |
||||
padding: '8px', |
||||
display: 'flex', |
||||
justifyContent: 'space-between', |
||||
cursor: 'pointer', |
||||
':hover': { |
||||
backgroundColor: theme.colors.emphasize(theme.colors.background.primary, 0.1), |
||||
}, |
||||
}), |
||||
customOptionlabel: css({ |
||||
color: theme.colors.text.primary, |
||||
}), |
||||
customOptionDesc: css({ |
||||
color: theme.colors.text.secondary, |
||||
fontSize: theme.typography.size.xs, |
||||
opacity: '50%', |
||||
}), |
||||
focus: css({ |
||||
backgroundColor: theme.colors.emphasize(theme.colors.background.primary, 0.1), |
||||
}), |
||||
customOptionWidth: css({ |
||||
minWidth: '400px', |
||||
}), |
||||
customMenuFooter: css({ |
||||
flex: 0, |
||||
display: 'flex', |
||||
justifyContent: 'space-between', |
||||
padding: theme.spacing(1.5), |
||||
borderTop: `1px solid ${theme.colors.border.weak}`, |
||||
color: theme.colors.text.secondary, |
||||
}), |
||||
customMenuContainer: css({ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
background: theme.colors.background.primary, |
||||
boxShadow: theme.shadows.z3, |
||||
}), |
||||
}); |
||||
|
||||
export const formatPrometheusLabelFiltersToString = ( |
||||
queryString: string, |
||||
labelsFilters: QueryBuilderLabelFilter[] | undefined |
||||
): string => { |
||||
const filterArray = labelsFilters ? formatPrometheusLabelFilters(labelsFilters) : []; |
||||
|
||||
return `label_values({__name__=~".*${queryString}"${filterArray ? filterArray.join('') : ''}},__name__)`; |
||||
}; |
||||
|
||||
export const formatPrometheusLabelFilters = (labelsFilters: QueryBuilderLabelFilter[]): string[] => { |
||||
return labelsFilters.map((label) => { |
||||
return `,${label.label}="${label.value}"`; |
||||
}); |
||||
}; |
||||
@ -0,0 +1,391 @@ |
||||
import { render, screen } from '@testing-library/react'; |
||||
|
||||
import { dateTime } from '@grafana/data'; |
||||
|
||||
import { PrometheusDatasource } from '../../datasource'; |
||||
import { PromVisualQuery } from '../types'; |
||||
|
||||
import { MetricsLabelsSection } from './MetricsLabelsSection'; |
||||
|
||||
// Mock dependencies
|
||||
jest.mock('./MetricCombobox', () => ({ |
||||
MetricCombobox: jest.fn(() => <div data-testid="metric-combobox">Metric Combobox</div>), |
||||
})); |
||||
|
||||
jest.mock('./LabelFilters', () => ({ |
||||
LabelFilters: jest.fn(() => <div data-testid="label-filters">Label Filters</div>), |
||||
})); |
||||
|
||||
// Create mock for PrometheusDatasource
|
||||
const createMockDatasource = () => { |
||||
const datasource = { |
||||
uid: 'prometheus', |
||||
getVariables: jest.fn().mockReturnValue(['$var1', '$var2']), |
||||
interpolateString: jest.fn((str) => str), |
||||
hasLabelsMatchAPISupport: jest.fn().mockReturnValue(true), |
||||
getDebounceTimeInMilliseconds: jest.fn().mockReturnValue(300), |
||||
lookupsDisabled: false, |
||||
languageProvider: { |
||||
fetchLabels: jest.fn().mockResolvedValue({}), |
||||
getLabelKeys: jest.fn().mockReturnValue(['label1', 'label2']), |
||||
fetchLabelsWithMatch: jest.fn().mockResolvedValue({ label1: [], label2: [] }), |
||||
fetchSeries: jest.fn().mockResolvedValue([{ label1: 'value1' }]), |
||||
fetchSeriesValuesWithMatch: jest.fn().mockResolvedValue(['value1', 'value2']), |
||||
getLabelValues: jest.fn().mockResolvedValue(['value1', 'value2']), |
||||
getSeries: jest.fn().mockResolvedValue({ __name__: ['metric1', 'metric2'] }), |
||||
loadMetricsMetadata: jest.fn().mockResolvedValue({}), |
||||
metricsMetadata: { metric1: { type: 'counter', help: 'help text' } }, |
||||
}, |
||||
}; |
||||
return datasource as unknown as PrometheusDatasource; |
||||
}; |
||||
|
||||
const defaultQuery: PromVisualQuery = { |
||||
metric: 'metric1', |
||||
labels: [{ label: 'label1', op: '=', value: 'value1' }], |
||||
operations: [], |
||||
}; |
||||
|
||||
const defaultTimeRange = { |
||||
from: dateTime(1000), |
||||
to: dateTime(2000), |
||||
raw: { |
||||
from: 'now-1h', |
||||
to: 'now', |
||||
}, |
||||
}; |
||||
|
||||
describe('MetricsLabelsSection', () => { |
||||
beforeEach(() => { |
||||
jest.clearAllMocks(); |
||||
}); |
||||
|
||||
it('should render MetricCombobox and LabelFilters', () => { |
||||
const onChange = jest.fn(); |
||||
const datasource = createMockDatasource(); |
||||
|
||||
render( |
||||
<MetricsLabelsSection |
||||
query={defaultQuery} |
||||
datasource={datasource} |
||||
onChange={onChange} |
||||
timeRange={defaultTimeRange} |
||||
/> |
||||
); |
||||
|
||||
expect(screen.getByTestId('metric-combobox')).toBeInTheDocument(); |
||||
expect(screen.getByTestId('label-filters')).toBeInTheDocument(); |
||||
}); |
||||
|
||||
it('should pass correct props to MetricCombobox', async () => { |
||||
const onChange = jest.fn(); |
||||
const onBlur = jest.fn(); |
||||
const datasource = createMockDatasource(); |
||||
const { MetricCombobox } = require('./MetricCombobox'); |
||||
|
||||
render( |
||||
<MetricsLabelsSection |
||||
query={defaultQuery} |
||||
datasource={datasource} |
||||
onChange={onChange} |
||||
onBlur={onBlur} |
||||
timeRange={defaultTimeRange} |
||||
/> |
||||
); |
||||
|
||||
// Check that MetricCombobox was called with correct props
|
||||
expect(MetricCombobox).toHaveBeenCalledWith( |
||||
expect.objectContaining({ |
||||
query: defaultQuery, |
||||
onChange: onChange, |
||||
datasource: datasource, |
||||
labelsFilters: defaultQuery.labels, |
||||
metricLookupDisabled: false, |
||||
onBlur: onBlur, |
||||
variableEditor: undefined, |
||||
}), |
||||
expect.anything() |
||||
); |
||||
}); |
||||
|
||||
it('should pass correct props to LabelFilters', async () => { |
||||
const onChange = jest.fn(); |
||||
const datasource = createMockDatasource(); |
||||
const { LabelFilters } = require('./LabelFilters'); |
||||
|
||||
render( |
||||
<MetricsLabelsSection |
||||
query={defaultQuery} |
||||
datasource={datasource} |
||||
onChange={onChange} |
||||
timeRange={defaultTimeRange} |
||||
/> |
||||
); |
||||
|
||||
// Check that LabelFilters was called with correct props
|
||||
expect(LabelFilters).toHaveBeenCalledWith( |
||||
expect.objectContaining({ |
||||
debounceDuration: 300, |
||||
labelsFilters: defaultQuery.labels, |
||||
variableEditor: undefined, |
||||
}), |
||||
expect.anything() |
||||
); |
||||
}); |
||||
|
||||
it('should handle onChangeLabels correctly', async () => { |
||||
const onChange = jest.fn(); |
||||
const datasource = createMockDatasource(); |
||||
const { LabelFilters } = require('./LabelFilters'); |
||||
|
||||
render( |
||||
<MetricsLabelsSection |
||||
query={defaultQuery} |
||||
datasource={datasource} |
||||
onChange={onChange} |
||||
timeRange={defaultTimeRange} |
||||
/> |
||||
); |
||||
|
||||
// Extract the onChangeLabels callback
|
||||
const onChangeLabelsCallback = LabelFilters.mock.calls[0][0].onChange; |
||||
|
||||
// Call it with new labels
|
||||
const newLabels = [{ label: 'newLabel', op: '=', value: 'newValue' }]; |
||||
onChangeLabelsCallback(newLabels); |
||||
|
||||
// Check that onChange was called with updated query
|
||||
expect(onChange).toHaveBeenCalledWith({ |
||||
...defaultQuery, |
||||
labels: newLabels, |
||||
}); |
||||
}); |
||||
|
||||
it('should handle withTemplateVariableOptions correctly', async () => { |
||||
const onChange = jest.fn(); |
||||
const datasource = createMockDatasource(); |
||||
const { LabelFilters } = require('./LabelFilters'); |
||||
|
||||
render( |
||||
<MetricsLabelsSection |
||||
query={defaultQuery} |
||||
datasource={datasource} |
||||
onChange={onChange} |
||||
timeRange={defaultTimeRange} |
||||
/> |
||||
); |
||||
|
||||
// Extract the onGetLabelNames callback
|
||||
const onGetLabelNamesCallback = LabelFilters.mock.calls[0][0].onGetLabelNames; |
||||
|
||||
// Prepare a test label filter
|
||||
const forLabel = { label: 'test', op: '=', value: '' }; |
||||
|
||||
// Call it
|
||||
const result = await onGetLabelNamesCallback(forLabel); |
||||
|
||||
// Check that variables were included in the result
|
||||
expect(result).toContainEqual(expect.objectContaining({ label: '$var1', value: '$var1' })); |
||||
expect(result).toContainEqual(expect.objectContaining({ label: '$var2', value: '$var2' })); |
||||
}); |
||||
|
||||
it('should handle onGetLabelNames with no metric correctly', async () => { |
||||
const onChange = jest.fn(); |
||||
const datasource = createMockDatasource(); |
||||
const { LabelFilters } = require('./LabelFilters'); |
||||
const queryWithoutMetric = { ...defaultQuery, metric: '' }; |
||||
|
||||
render( |
||||
<MetricsLabelsSection |
||||
query={queryWithoutMetric} |
||||
datasource={datasource} |
||||
onChange={onChange} |
||||
timeRange={defaultTimeRange} |
||||
/> |
||||
); |
||||
|
||||
// Extract the onGetLabelNames callback
|
||||
const onGetLabelNamesCallback = LabelFilters.mock.calls[0][0].onGetLabelNames; |
||||
|
||||
// Call it
|
||||
await onGetLabelNamesCallback({}); |
||||
|
||||
// Check that fetchLabels was called
|
||||
expect(datasource.languageProvider.fetchLabels).toHaveBeenCalledWith(defaultTimeRange); |
||||
// Check that getLabelKeys was called
|
||||
expect(datasource.languageProvider.getLabelKeys).toHaveBeenCalled(); |
||||
}); |
||||
|
||||
it('should handle onGetLabelNames with metric correctly', async () => { |
||||
const onChange = jest.fn(); |
||||
const datasource = createMockDatasource(); |
||||
const { LabelFilters } = require('./LabelFilters'); |
||||
|
||||
render( |
||||
<MetricsLabelsSection |
||||
query={defaultQuery} |
||||
datasource={datasource} |
||||
onChange={onChange} |
||||
timeRange={defaultTimeRange} |
||||
/> |
||||
); |
||||
|
||||
// Extract the onGetLabelNames callback
|
||||
const onGetLabelNamesCallback = LabelFilters.mock.calls[0][0].onGetLabelNames; |
||||
|
||||
// Call it
|
||||
await onGetLabelNamesCallback({}); |
||||
|
||||
// Check that fetchLabelsWithMatch was called
|
||||
expect(datasource.languageProvider.fetchLabelsWithMatch).toHaveBeenCalled(); |
||||
}); |
||||
|
||||
it('should handle getLabelValuesAutocompleteSuggestions correctly', async () => { |
||||
const onChange = jest.fn(); |
||||
const datasource = createMockDatasource(); |
||||
const { LabelFilters } = require('./LabelFilters'); |
||||
|
||||
render( |
||||
<MetricsLabelsSection |
||||
query={defaultQuery} |
||||
datasource={datasource} |
||||
onChange={onChange} |
||||
timeRange={defaultTimeRange} |
||||
/> |
||||
); |
||||
|
||||
// Extract the getLabelValuesAutofillSuggestions callback
|
||||
const getLabelValuesCallback = LabelFilters.mock.calls[0][0].getLabelValuesAutofillSuggestions; |
||||
|
||||
// Call it
|
||||
await getLabelValuesCallback('val', 'label1'); |
||||
|
||||
// Check that fetchSeriesValuesWithMatch was called (since hasLabelsMatchAPISupport is true)
|
||||
expect(datasource.languageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled(); |
||||
}); |
||||
|
||||
it('should handle onGetLabelValues with no metric correctly', async () => { |
||||
const onChange = jest.fn(); |
||||
const datasource = createMockDatasource(); |
||||
const { LabelFilters } = require('./LabelFilters'); |
||||
const queryWithoutMetric = { ...defaultQuery, metric: '' }; |
||||
|
||||
render( |
||||
<MetricsLabelsSection |
||||
query={queryWithoutMetric} |
||||
datasource={datasource} |
||||
onChange={onChange} |
||||
timeRange={defaultTimeRange} |
||||
/> |
||||
); |
||||
|
||||
// Extract the onGetLabelValues callback
|
||||
const onGetLabelValuesCallback = LabelFilters.mock.calls[0][0].onGetLabelValues; |
||||
|
||||
// Call it
|
||||
await onGetLabelValuesCallback({ label: 'label1' }); |
||||
|
||||
// Check that getLabelValues was called
|
||||
expect(datasource.languageProvider.getLabelValues).toHaveBeenCalledWith(defaultTimeRange, 'label1'); |
||||
}); |
||||
|
||||
it('should handle onGetLabelValues with metric correctly', async () => { |
||||
const onChange = jest.fn(); |
||||
const datasource = createMockDatasource(); |
||||
const { LabelFilters } = require('./LabelFilters'); |
||||
|
||||
render( |
||||
<MetricsLabelsSection |
||||
query={defaultQuery} |
||||
datasource={datasource} |
||||
onChange={onChange} |
||||
timeRange={defaultTimeRange} |
||||
/> |
||||
); |
||||
|
||||
// Extract the onGetLabelValues callback
|
||||
const onGetLabelValuesCallback = LabelFilters.mock.calls[0][0].onGetLabelValues; |
||||
|
||||
// Call it
|
||||
await onGetLabelValuesCallback({ label: 'label1' }); |
||||
|
||||
// Check that fetchSeriesValuesWithMatch was called (since hasLabelsMatchAPISupport is true)
|
||||
expect(datasource.languageProvider.fetchSeriesValuesWithMatch).toHaveBeenCalled(); |
||||
}); |
||||
|
||||
it('should handle onGetLabelValues with no label correctly', async () => { |
||||
const onChange = jest.fn(); |
||||
const datasource = createMockDatasource(); |
||||
const { LabelFilters } = require('./LabelFilters'); |
||||
|
||||
render( |
||||
<MetricsLabelsSection |
||||
query={defaultQuery} |
||||
datasource={datasource} |
||||
onChange={onChange} |
||||
timeRange={defaultTimeRange} |
||||
/> |
||||
); |
||||
|
||||
// Extract the onGetLabelValues callback
|
||||
const onGetLabelValuesCallback = LabelFilters.mock.calls[0][0].onGetLabelValues; |
||||
|
||||
// Call it with no label
|
||||
const result = await onGetLabelValuesCallback({}); |
||||
|
||||
// In reality, the component has already added the template variables to the result
|
||||
// Let's check that the result includes the template variables
|
||||
expect(result).toContainEqual(expect.objectContaining({ label: '$var1', value: '$var1' })); |
||||
expect(result).toContainEqual(expect.objectContaining({ label: '$var2', value: '$var2' })); |
||||
}); |
||||
|
||||
it('should handle onGetMetrics correctly', async () => { |
||||
const onChange = jest.fn(); |
||||
const datasource = createMockDatasource(); |
||||
const { MetricCombobox } = require('./MetricCombobox'); |
||||
|
||||
render( |
||||
<MetricsLabelsSection |
||||
query={defaultQuery} |
||||
datasource={datasource} |
||||
onChange={onChange} |
||||
timeRange={defaultTimeRange} |
||||
/> |
||||
); |
||||
|
||||
// Extract the onGetMetrics callback
|
||||
const onGetMetricsCallback = MetricCombobox.mock.calls[0][0].onGetMetrics; |
||||
|
||||
// Call it
|
||||
const result = await onGetMetricsCallback(); |
||||
|
||||
// Check that we get back variables and metrics
|
||||
expect(result).toContainEqual(expect.objectContaining({ label: '$var1', value: '$var1' })); |
||||
expect(result).toContainEqual(expect.objectContaining({ label: '$var2', value: '$var2' })); |
||||
// Metrics should be included too, but they come from the mocked getSeries or getLabelValues
|
||||
}); |
||||
|
||||
it('should load metrics metadata if not present', async () => { |
||||
const onChange = jest.fn(); |
||||
const datasource = createMockDatasource(); |
||||
datasource.languageProvider.metricsMetadata = undefined; |
||||
|
||||
render( |
||||
<MetricsLabelsSection |
||||
query={defaultQuery} |
||||
datasource={datasource} |
||||
onChange={onChange} |
||||
timeRange={defaultTimeRange} |
||||
/> |
||||
); |
||||
|
||||
const { MetricCombobox } = require('./MetricCombobox'); |
||||
const onGetMetricsCallback = MetricCombobox.mock.calls[0][0].onGetMetrics; |
||||
|
||||
// Call it
|
||||
await onGetMetricsCallback(); |
||||
|
||||
// loadMetricsMetadata should be called
|
||||
expect(datasource.languageProvider.loadMetricsMetadata).toHaveBeenCalled(); |
||||
}); |
||||
}); |
||||
|
Loading…
Reference in new issue