Chore: Convert prometheus query field to a functional component (#101515)

* convert it to functional component

* useReducer

* usePromQueryFieldEffects

* clean up the code

* remove localStorage provider

* introduce usePromQueryFieldEffects.test.ts

* simpler state management

* remove mocks

* linting + betterer

* remove unnecessary check

* use range

* remove unused languageProvider

* prettier
This commit is contained in:
ismail simsek
2025-04-04 17:56:31 +02:00
committed by GitHub
parent a93e618102
commit 23e0f63790
14 changed files with 571 additions and 337 deletions

View File

@ -24,7 +24,6 @@ jest.mock('./monaco-query-field/MonacoQueryFieldLazy', () => {
function setup(app: CoreApp): { onRunQuery: jest.Mock } {
const dataSource = {
getInitHints: () => [],
getPrometheusTime: jest.fn((date, roundup) => 123),
getQueryHints: jest.fn(() => []),
getDebounceTimeInMilliseconds: jest.fn(() => 300),

View File

@ -1,12 +1,12 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/components/PromQueryField.test.tsx
import { getByTestId, render, screen, waitFor } from '@testing-library/react';
import { getByTestId, render, screen } from '@testing-library/react';
// @ts-ignore
import userEvent from '@testing-library/user-event';
import { CoreApp, DataFrame, LoadingState, PanelData } from '@grafana/data';
import { CoreApp, DataFrame, dateTime, LoadingState, PanelData } from '@grafana/data';
import { PrometheusDatasource } from '../datasource';
import PromQlLanguageProvider from '../language_provider';
import * as queryHints from '../query_hints';
import { PromQueryField } from './PromQueryField';
import { Props } from './monaco-query-field/MonacoQueryFieldProps';
@ -31,7 +31,6 @@ const defaultProps = {
getLabelKeys: () => [],
metrics: [],
},
getInitHints: () => [],
} as unknown as PrometheusDatasource,
query: {
expr: '',
@ -40,6 +39,14 @@ const defaultProps = {
onRunQuery: () => {},
onChange: () => {},
history: [],
range: {
from: dateTime('2022-01-01T00:00:00Z'),
to: dateTime('2022-01-02T00:00:00Z'),
raw: {
from: 'now-1d',
to: 'now',
},
},
};
describe('PromQueryField', () => {
@ -48,6 +55,10 @@ describe('PromQueryField', () => {
window.getSelection = () => {};
});
beforeEach(() => {
jest.clearAllMocks();
});
it('renders metrics chooser regularly if lookups are not disabled in the datasource settings', async () => {
const queryField = render(<PromQueryField {...defaultProps} />);
@ -72,7 +83,9 @@ describe('PromQueryField', () => {
it('renders an initial hint if no data and initial hint provided', async () => {
const props = defaultProps;
props.datasource.lookupsDisabled = true;
props.datasource.getInitHints = () => [{ label: 'Initial hint', type: 'INFO' }];
jest.spyOn(queryHints, 'getInitHints').mockReturnValue([{ label: 'Initial hint', type: 'INFO' }]);
render(<PromQueryField {...props} />);
// wait for component to render
@ -84,7 +97,6 @@ describe('PromQueryField', () => {
it('renders query hint if data, query hint and initial hint provided', async () => {
const props = defaultProps;
props.datasource.lookupsDisabled = true;
props.datasource.getInitHints = () => [{ label: 'Initial hint', type: 'INFO' }];
props.datasource.getQueryHints = () => [{ label: 'Query hint', type: 'INFO' }];
render(
<PromQueryField
@ -105,51 +117,6 @@ describe('PromQueryField', () => {
expect(screen.queryByText('Initial hint')).not.toBeInTheDocument();
});
it('refreshes metrics when the data source changes', async () => {
const defaultProps = {
query: { expr: '', refId: '' },
onRunQuery: () => {},
onChange: () => {},
history: [],
};
const metrics = ['foo', 'bar'];
const queryField = render(
<PromQueryField
datasource={
{
languageProvider: makeLanguageProvider({ metrics: [metrics] }),
getInitHints: () => [],
} as unknown as PrometheusDatasource
}
{...defaultProps}
/>
);
// wait for component to render
await screen.findByRole('button');
const changedMetrics = ['baz', 'moo'];
queryField.rerender(
<PromQueryField
// @ts-ignore
datasource={{
languageProvider: makeLanguageProvider({ metrics: [changedMetrics] }),
}}
{...defaultProps}
/>
);
// If we check the label browser right away it should be in loading state
let labelBrowser = screen.getByRole('button');
expect(labelBrowser).toHaveTextContent('Loading');
// wait for component to rerender
labelBrowser = await screen.findByRole('button');
await waitFor(() => {
expect(labelBrowser).toHaveTextContent('Metrics browser');
});
});
it('should not run query onBlur', async () => {
const onRunQuery = jest.fn();
const { container } = render(<PromQueryField {...defaultProps} app={CoreApp.Explore} onRunQuery={onRunQuery} />);
@ -166,18 +133,3 @@ describe('PromQueryField', () => {
expect(onRunQuery).not.toHaveBeenCalled();
});
});
function makeLanguageProvider(options: { metrics: string[][] }) {
const metricsStack = [...options.metrics];
return {
histogramMetrics: [],
metrics: [],
metricsMetadata: {},
lookupsDisabled: false,
getLabelKeys: () => [],
start() {
this.metrics = metricsStack.shift();
return Promise.resolve([]);
},
} as any as PromQlLanguageProvider;
}

View File

@ -1,118 +1,96 @@
// Core Grafana history https://github.com/grafana/grafana/blob/v11.0.0-preview/public/app/plugins/datasource/prometheus/components/PromQueryField.tsx
import { css, cx } from '@emotion/css';
import { PureComponent, ReactNode } from 'react';
import { MutableRefObject, ReactNode, useCallback, useState } from 'react';
import {
getDefaultTimeRange,
isDataFrame,
LocalStorageValueProvider,
QueryEditorProps,
QueryHint,
TimeRange,
toLegacyResponseData,
} from '@grafana/data';
import { getDefaultTimeRange, isDataFrame, QueryEditorProps, QueryHint, toLegacyResponseData } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { reportInteraction } from '@grafana/runtime';
import { clearButtonStyles, Icon, Themeable2, withTheme2 } from '@grafana/ui';
import { clearButtonStyles, Icon, useTheme2 } from '@grafana/ui';
import { PrometheusDatasource } from '../datasource';
import { roundMsToMin } from '../language_utils';
import { getInitHints } from '../query_hints';
import { PromOptions, PromQuery } from '../types';
import { PrometheusMetricsBrowser } from './PrometheusMetricsBrowser';
import { CancelablePromise, isCancelablePromiseRejection, makePromiseCancelable } from './cancelable-promise';
import { MonacoQueryFieldWrapper } from './monaco-query-field/MonacoQueryFieldWrapper';
import { useMetricsState } from './useMetricsState';
import { usePromQueryFieldEffects } from './usePromQueryFieldEffects';
const LAST_USED_LABELS_KEY = 'grafana.datasources.prometheus.browser.labels';
function getChooserText(metricsLookupDisabled: boolean, hasSyntax: boolean, hasMetrics: boolean) {
if (metricsLookupDisabled) {
return '(Disabled)';
}
if (!hasSyntax) {
return 'Loading metrics...';
}
if (!hasMetrics) {
return '(No metrics found)';
}
return 'Metrics browser';
}
interface PromQueryFieldProps extends QueryEditorProps<PrometheusDatasource, PromQuery, PromOptions>, Themeable2 {
interface PromQueryFieldProps extends QueryEditorProps<PrometheusDatasource, PromQuery, PromOptions> {
ExtraFieldElement?: ReactNode;
'data-testid'?: string;
}
interface PromQueryFieldState {
labelBrowserVisible: boolean;
syntaxLoaded: boolean;
hint: QueryHint | null;
}
export const PromQueryField = (props: PromQueryFieldProps) => {
const {
app,
datasource,
datasource: { languageProvider },
query,
ExtraFieldElement,
history = [],
data,
range,
onChange,
onRunQuery,
} = props;
class PromQueryFieldClass extends PureComponent<PromQueryFieldProps, PromQueryFieldState> {
declare languageProviderInitializationPromise: CancelablePromise<any>;
const theme = useTheme2();
constructor(props: PromQueryFieldProps) {
super(props);
const [syntaxLoaded, setSyntaxLoaded] = useState(false);
const [hint, setHint] = useState<QueryHint | null>(null);
const [labelBrowserVisible, setLabelBrowserVisible] = useState(false);
this.state = {
labelBrowserVisible: false,
syntaxLoaded: false,
hint: null,
};
}
componentDidMount() {
if (this.props.datasource.languageProvider) {
this.refreshMetrics();
const updateLanguage = useCallback(() => {
if (languageProvider.metrics) {
setSyntaxLoaded(true);
}
this.refreshHint();
}
}, [languageProvider]);
componentWillUnmount() {
if (this.languageProviderInitializationPromise) {
this.languageProviderInitializationPromise.cancel();
}
}
const refreshMetrics = useCallback(
async (languageProviderInitRef: MutableRefObject<CancelablePromise<any> | null>) => {
// Cancel any existing initialization using the ref
if (languageProviderInitRef.current) {
languageProviderInitRef.current.cancel();
}
componentDidUpdate(prevProps: PromQueryFieldProps) {
const {
data,
datasource: { languageProvider },
range,
} = this.props;
if (!languageProvider || !range) {
return;
}
if (languageProvider !== prevProps.datasource.languageProvider) {
// We reset this only on DS change so we do not flesh loading state on every rangeChange which happens on every
// query run if using relative range.
this.setState({
syntaxLoaded: false,
});
}
try {
const initialization = makePromiseCancelable(languageProvider.start(range));
languageProviderInitRef.current = initialization;
const changedRangeToRefresh = this.rangeChangedToRefresh(range, prevProps.range);
// We want to refresh metrics when language provider changes and/or when range changes (we round up intervals to a minute)
if (languageProvider !== prevProps.datasource.languageProvider || changedRangeToRefresh) {
this.refreshMetrics();
}
const remainingTasks = await initialization.promise;
if (data && prevProps.data && prevProps.data.series !== data.series) {
this.refreshHint();
}
}
// If there are remaining tasks, wait for them
if (Array.isArray(remainingTasks) && remainingTasks.length > 0) {
await Promise.all(remainingTasks);
}
refreshHint = () => {
const { datasource, query, data } = this.props;
const initHints = datasource.getInitHints();
const initHint = initHints.length > 0 ? initHints[0] : null;
updateLanguage();
} catch (err) {
if (isCancelablePromiseRejection(err) && err.isCanceled) {
// do nothing, promise was canceled
} else {
throw err;
}
} finally {
languageProviderInitRef.current = null;
}
},
[languageProvider, range, updateLanguage]
);
if (!data || data.series.length === 0) {
this.setState({
hint: initHint,
});
const refreshHint = useCallback(() => {
const initHints = getInitHints(datasource);
const initHint = initHints[0] ?? null;
// If no data or empty series, use default hint
if (!data?.series?.length) {
setHint(initHint);
return;
}
@ -120,180 +98,108 @@ class PromQueryFieldClass extends PureComponent<PromQueryFieldProps, PromQueryFi
const queryHints = datasource.getQueryHints(query, result);
let queryHint = queryHints.length > 0 ? queryHints[0] : null;
this.setState({ hint: queryHint ?? initHint });
};
setHint(queryHint ?? initHint);
}, [data, datasource, query]);
refreshMetrics = async () => {
const {
range,
datasource: { languageProvider },
} = this.props;
this.languageProviderInitializationPromise = makePromiseCancelable(languageProvider.start(range));
try {
const remainingTasks = await this.languageProviderInitializationPromise.promise;
await Promise.all(remainingTasks);
this.onUpdateLanguage();
} catch (err) {
if (isCancelablePromiseRejection(err) && err.isCanceled) {
// do nothing, promise was canceled
} else {
throw err;
}
const onChangeQuery = (value: string, override?: boolean) => {
if (!onChange) {
return;
}
};
rangeChangedToRefresh(range?: TimeRange, prevRange?: TimeRange): boolean {
if (range && prevRange) {
const sameMinuteFrom = roundMsToMin(range.from.valueOf()) === roundMsToMin(prevRange.from.valueOf());
const sameMinuteTo = roundMsToMin(range.to.valueOf()) === roundMsToMin(prevRange.to.valueOf());
// If both are same, don't need to refresh.
return !(sameMinuteFrom && sameMinuteTo);
}
return false;
}
/**
* TODO #33976: Remove this, add histogram group (query = `histogram_quantile(0.95, sum(rate(${metric}[5m])) by (le))`;)
*/
onChangeLabelBrowser = (selector: string) => {
this.onChangeQuery(selector, true);
this.setState({ labelBrowserVisible: false });
};
onChangeQuery = (value: string, override?: boolean) => {
// Send text change to parent
const { query, onChange, onRunQuery } = this.props;
if (onChange) {
const nextQuery: PromQuery = { ...query, expr: value };
onChange(nextQuery);
const nextQuery: PromQuery = { ...query, expr: value };
onChange(nextQuery);
if (override && onRunQuery) {
onRunQuery();
}
if (override && onRunQuery) {
onRunQuery();
}
};
onClickChooserButton = () => {
this.setState((state) => ({ labelBrowserVisible: !state.labelBrowserVisible }));
const onChangeLabelBrowser = (selector: string) => {
onChangeQuery(selector, true);
setLabelBrowserVisible(false);
};
const onClickChooserButton = () => {
setLabelBrowserVisible(!labelBrowserVisible);
reportInteraction('user_grafana_prometheus_metrics_browser_clicked', {
editorMode: this.state.labelBrowserVisible ? 'metricViewClosed' : 'metricViewOpen',
app: this.props?.app ?? '',
editorMode: labelBrowserVisible ? 'metricViewClosed' : 'metricViewOpen',
app: app ?? '',
});
};
onClickHintFix = () => {
const { datasource, query, onChange, onRunQuery } = this.props;
const { hint } = this.state;
const onClickHintFix = () => {
if (hint?.fix?.action) {
onChange(datasource.modifyQuery(query, hint.fix.action));
}
onRunQuery();
};
onUpdateLanguage = () => {
const {
datasource: { languageProvider },
} = this.props;
const { metrics } = languageProvider;
// Use our custom effects hook
usePromQueryFieldEffects(range, data?.series, refreshMetrics, refreshHint);
if (!metrics) {
return;
}
const { chooserText, buttonDisabled } = useMetricsState(datasource, languageProvider, syntaxLoaded);
this.setState({ syntaxLoaded: true });
};
return (
<>
<div
className="gf-form-inline gf-form-inline--xs-view-flex-column flex-grow-1"
data-testid={props['data-testid']}
>
<button
className="gf-form-label query-keyword pointer"
onClick={onClickChooserButton}
disabled={buttonDisabled}
type="button"
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.openButton}
>
{chooserText}
<Icon name={labelBrowserVisible ? 'angle-down' : 'angle-right'} />
</button>
render() {
const {
datasource,
datasource: { languageProvider },
query,
ExtraFieldElement,
history = [],
theme,
} = this.props;
const { labelBrowserVisible, syntaxLoaded, hint } = this.state;
const hasMetrics = languageProvider.metrics.length > 0;
const chooserText = getChooserText(datasource.lookupsDisabled, syntaxLoaded, hasMetrics);
const buttonDisabled = !(syntaxLoaded && hasMetrics);
return (
<LocalStorageValueProvider<string[]> storageKey={LAST_USED_LABELS_KEY} defaultValue={[]}>
{(lastUsedLabels, onLastUsedLabelsSave, onLastUsedLabelsDelete) => {
return (
<>
<div
className="gf-form-inline gf-form-inline--xs-view-flex-column flex-grow-1"
data-testid={this.props['data-testid']}
<div className="flex-grow-1 min-width-15">
<MonacoQueryFieldWrapper
languageProvider={languageProvider}
history={history}
onChange={onChangeQuery}
onRunQuery={onRunQuery}
initialValue={query.expr ?? ''}
placeholder="Enter a PromQL query…"
datasource={datasource}
timeRange={range ?? getDefaultTimeRange()}
/>
</div>
</div>
{labelBrowserVisible && (
<div className="gf-form">
<PrometheusMetricsBrowser
languageProvider={languageProvider}
onChange={onChangeLabelBrowser}
timeRange={range}
/>
</div>
)}
{ExtraFieldElement}
{hint ? (
<div
className={css({
flexBasis: '100%',
})}
>
<div className="text-warning">
{hint.label}{' '}
{hint.fix ? (
<button
type="button"
className={cx(clearButtonStyles(theme), 'text-link', 'muted')}
onClick={onClickHintFix}
>
<button
className="gf-form-label query-keyword pointer"
onClick={this.onClickChooserButton}
disabled={buttonDisabled}
type="button"
data-testid={selectors.components.DataSource.Prometheus.queryEditor.code.metricsBrowser.openButton}
>
{chooserText}
<Icon name={labelBrowserVisible ? 'angle-down' : 'angle-right'} />
</button>
<div className="flex-grow-1 min-width-15">
<MonacoQueryFieldWrapper
languageProvider={languageProvider}
history={history}
onChange={this.onChangeQuery}
onRunQuery={this.props.onRunQuery}
initialValue={query.expr ?? ''}
placeholder="Enter a PromQL query…"
datasource={datasource}
timeRange={this.props.range ?? getDefaultTimeRange()}
/>
</div>
</div>
{labelBrowserVisible && (
<div className="gf-form">
<PrometheusMetricsBrowser
languageProvider={languageProvider}
onChange={this.onChangeLabelBrowser}
lastUsedLabels={lastUsedLabels || []}
storeLastUsedLabels={onLastUsedLabelsSave}
deleteLastUsedLabels={onLastUsedLabelsDelete}
timeRange={this.props.range}
/>
</div>
)}
{ExtraFieldElement}
{hint ? (
<div
className={css({
flexBasis: '100%',
})}
>
<div className="text-warning">
{hint.label}{' '}
{hint.fix ? (
<button
type="button"
className={cx(clearButtonStyles(theme), 'text-link', 'muted')}
onClick={this.onClickHintFix}
>
{hint.fix.label}
</button>
) : null}
</div>
</div>
) : null}
</>
);
}}
</LocalStorageValueProvider>
);
}
}
export const PromQueryField = withTheme2(PromQueryFieldClass);
{hint.fix.label}
</button>
) : null}
</div>
</div>
) : null}
</>
);
};

View File

@ -184,9 +184,6 @@ describe('PrometheusMetricsBrowser', () => {
onChange: () => {},
autoSelect: 0,
languageProvider: mockLanguageProvider as unknown as PromQlLanguageProvider,
lastUsedLabels: [],
storeLastUsedLabels: () => {},
deleteLastUsedLabels: () => {},
timeRange: getDefaultTimeRange(),
};

View File

@ -25,6 +25,7 @@ import { isValidLegacyName, utf8Support } from '../utf8_support';
const EMPTY_SELECTOR = '{}';
const METRIC_LABEL = '__name__';
const LIST_ITEM_SIZE = 25;
const LAST_USED_LABELS_KEY = 'grafana.datasources.prometheus.browser.labels';
export interface BrowserProps {
languageProvider: PromQlLanguageProvider;
@ -32,9 +33,6 @@ export interface BrowserProps {
theme: GrafanaTheme2;
autoSelect?: number;
hide?: () => void;
lastUsedLabels: string[];
storeLastUsedLabels: (labels: string[]) => void;
deleteLastUsedLabels: () => void;
timeRange?: TimeRange;
}
@ -274,7 +272,7 @@ export class UnthemedPrometheusMetricsBrowser extends React.Component<BrowserPro
valueSearchTerm: '',
};
});
this.props.deleteLastUsedLabels();
localStorage.removeItem(LAST_USED_LABELS_KEY);
// Get metrics
this.fetchValues(METRIC_LABEL, EMPTY_SELECTOR);
};
@ -347,9 +345,9 @@ export class UnthemedPrometheusMetricsBrowser extends React.Component<BrowserPro
}
componentDidMount() {
const { languageProvider, lastUsedLabels } = this.props;
const { languageProvider } = this.props;
if (languageProvider) {
const selectedLabels: string[] = lastUsedLabels;
const selectedLabels: string[] = JSON.parse(localStorage.getItem(LAST_USED_LABELS_KEY) ?? `[]`) ?? [];
languageProvider.start(this.props.timeRange).then(() => {
let rawLabels: string[] = languageProvider.getLabelKeys();
// Get metrics
@ -378,7 +376,8 @@ export class UnthemedPrometheusMetricsBrowser extends React.Component<BrowserPro
return;
}
const selectedLabels = this.state.labels.filter((label) => label.selected).map((label) => label.name);
this.props.storeLastUsedLabels(selectedLabels);
localStorage.setItem(LAST_USED_LABELS_KEY, JSON.stringify(selectedLabels));
if (label.selected) {
// Refetch values for newly selected label...
if (!label.values) {

View File

@ -144,7 +144,6 @@ describe('PromVariableQueryEditor', () => {
getLabelValues: jest.fn().mockImplementation(() => ['that']),
fetchLabelsWithMatch: jest.fn().mockImplementation(() => Promise.resolve({ those: 'those' })),
} as Partial<PrometheusLanguageProvider> as PrometheusLanguageProvider,
getInitHints: () => [],
getDebounceTimeInMilliseconds: jest.fn(),
getTagKeys: jest.fn().mockImplementation(() => Promise.resolve(['this'])),
getVariables: jest.fn().mockImplementation(() => []),

View File

@ -0,0 +1,113 @@
import { renderHook } from '@testing-library/react';
import { PrometheusDatasource } from '../datasource';
import PromQlLanguageProvider from '../language_provider';
import { useMetricsState } from './useMetricsState';
// Mock implementations
const createMockLanguageProvider = (metrics: string[] = []): PromQlLanguageProvider =>
({
metrics,
}) as unknown as PromQlLanguageProvider;
const createMockDatasource = (lookupsDisabled = false): PrometheusDatasource =>
({
lookupsDisabled,
}) as unknown as PrometheusDatasource;
describe('useMetricsState', () => {
describe('chooserText', () => {
it('should return disabled message when lookups are disabled', () => {
const datasource = createMockDatasource(true);
const languageProvider = createMockLanguageProvider([]);
const { result } = renderHook(() => useMetricsState(datasource, languageProvider, true));
expect(result.current.chooserText).toBe('(Disabled)');
});
it('should return loading message when syntax is not loaded', () => {
const datasource = createMockDatasource();
const languageProvider = createMockLanguageProvider(['metric1']);
const { result } = renderHook(() => useMetricsState(datasource, languageProvider, false));
expect(result.current.chooserText).toBe('Loading metrics...');
});
it('should return no metrics message when no metrics are found', () => {
const datasource = createMockDatasource();
const languageProvider = createMockLanguageProvider([]);
const { result } = renderHook(() => useMetricsState(datasource, languageProvider, true));
expect(result.current.chooserText).toBe('(No metrics found)');
});
it('should return metrics browser text when metrics are available', () => {
const datasource = createMockDatasource();
const languageProvider = createMockLanguageProvider(['metric1']);
const { result } = renderHook(() => useMetricsState(datasource, languageProvider, true));
expect(result.current.chooserText).toBe('Metrics browser');
});
});
describe('buttonDisabled', () => {
it('should be disabled when syntax is not loaded', () => {
const datasource = createMockDatasource();
const languageProvider = createMockLanguageProvider(['metric1']);
const { result } = renderHook(() => useMetricsState(datasource, languageProvider, false));
expect(result.current.buttonDisabled).toBe(true);
});
it('should be disabled when no metrics are available', () => {
const datasource = createMockDatasource();
const languageProvider = createMockLanguageProvider([]);
const { result } = renderHook(() => useMetricsState(datasource, languageProvider, true));
expect(result.current.buttonDisabled).toBe(true);
});
it('should be enabled when syntax is loaded and metrics are available', () => {
const datasource = createMockDatasource();
const languageProvider = createMockLanguageProvider(['metric1']);
const { result } = renderHook(() => useMetricsState(datasource, languageProvider, true));
expect(result.current.buttonDisabled).toBe(false);
});
});
describe('hasMetrics', () => {
it('should be false when no metrics are available', () => {
const datasource = createMockDatasource();
const languageProvider = createMockLanguageProvider([]);
const { result } = renderHook(() => useMetricsState(datasource, languageProvider, true));
expect(result.current.hasMetrics).toBe(false);
});
it('should be true when metrics are available', () => {
const datasource = createMockDatasource();
const languageProvider = createMockLanguageProvider(['metric1']);
const { result } = renderHook(() => useMetricsState(datasource, languageProvider, true));
expect(result.current.hasMetrics).toBe(true);
});
});
describe('memoization', () => {
it('should return same values when dependencies have not changed', () => {
const datasource = createMockDatasource();
const languageProvider = createMockLanguageProvider(['metric1']);
const { result, rerender } = renderHook(() => useMetricsState(datasource, languageProvider, true));
const firstResult = result.current;
rerender();
expect(result.current).toBe(firstResult);
});
it('should update when datasource lookupsDisabled changes', () => {
const initialDatasource = createMockDatasource(false);
const languageProvider = createMockLanguageProvider(['metric1']);
const { result, rerender } = renderHook(({ ds }) => useMetricsState(ds, languageProvider, true), {
initialProps: { ds: initialDatasource },
});
const firstResult = result.current;
const updatedDatasource = createMockDatasource(true);
rerender({ ds: updatedDatasource });
expect(result.current).not.toBe(firstResult);
});
});
});

View File

@ -0,0 +1,38 @@
import { useMemo } from 'react';
import { PrometheusDatasource } from '../datasource';
import PromQlLanguageProvider from '../language_provider';
function getChooserText(metricsLookupDisabled: boolean, hasSyntax: boolean, hasMetrics: boolean) {
if (metricsLookupDisabled) {
return '(Disabled)';
}
if (!hasSyntax) {
return 'Loading metrics...';
}
if (!hasMetrics) {
return '(No metrics found)';
}
return 'Metrics browser';
}
export function useMetricsState(
datasource: PrometheusDatasource,
languageProvider: PromQlLanguageProvider,
syntaxLoaded: boolean
) {
return useMemo(() => {
const hasMetrics = languageProvider.metrics.length > 0;
const chooserText = getChooserText(datasource.lookupsDisabled, syntaxLoaded, hasMetrics);
const buttonDisabled = !(syntaxLoaded && hasMetrics);
return {
hasMetrics,
chooserText,
buttonDisabled,
};
}, [languageProvider.metrics, datasource.lookupsDisabled, syntaxLoaded]);
}

View File

@ -0,0 +1,182 @@
import { renderHook } from '@testing-library/react';
import { DataFrame, dateTime, TimeRange } from '@grafana/data';
import PromQlLanguageProvider from '../language_provider';
import { usePromQueryFieldEffects } from './usePromQueryFieldEffects';
type TestProps = {
languageProvider: PromQlLanguageProvider;
range: TimeRange | undefined;
series: DataFrame[];
};
describe('usePromQueryFieldEffects', () => {
const mockLanguageProvider = {
start: jest.fn().mockResolvedValue([]),
histogramMetrics: [],
timeRange: {},
metrics: ['metric1'],
startTask: Promise.resolve(),
datasource: {},
lookupsDisabled: false,
syntax: jest.fn(),
getLabelKeys: jest.fn(),
cleanText: jest.fn(),
hasLookupsDisabled: jest.fn(),
getBeginningCompletionItems: jest.fn(),
getLabelCompletionItems: jest.fn(),
getMetricCompletionItems: jest.fn(),
getTermCompletionItems: jest.fn(),
request: jest.fn(),
importQueries: jest.fn(),
labelKeys: [],
labelFetchTs: 0,
getDefaultCacheHeaders: jest.fn(),
loadMetricsMetadata: jest.fn(),
loadMetrics: jest.fn(),
loadLabelKeys: jest.fn(),
loadLabelValues: jest.fn(),
modifyQuery: jest.fn(),
} as unknown as PromQlLanguageProvider;
const mockRange: TimeRange = {
from: dateTime('2022-01-01T00:00:00Z'),
to: dateTime('2022-01-02T00:00:00Z'),
raw: {
from: 'now-1d',
to: 'now',
},
};
const mockNewRange: TimeRange = {
from: dateTime('2022-01-02T00:00:00Z'),
to: dateTime('2022-01-03T00:00:00Z'),
raw: {
from: 'now-1d',
to: 'now',
},
};
let refreshMetricsMock: jest.Mock;
let refreshHintMock: jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
refreshMetricsMock = jest.fn().mockImplementation(() => Promise.resolve());
refreshHintMock = jest.fn();
});
it('should call refreshMetrics and refreshHint on initial render', async () => {
renderHook(() => usePromQueryFieldEffects(mockRange, [], refreshMetricsMock, refreshHintMock));
expect(refreshMetricsMock).toHaveBeenCalledTimes(1);
expect(refreshHintMock).toHaveBeenCalledTimes(2);
});
it('should call refreshMetrics when the time range changes', async () => {
const { rerender } = renderHook(
(props: TestProps) => usePromQueryFieldEffects(props.range, props.series, refreshMetricsMock, refreshHintMock),
{
initialProps: {
languageProvider: mockLanguageProvider,
range: mockRange,
series: [] as DataFrame[],
},
}
);
// Initial render already called refreshMetrics once
expect(refreshMetricsMock).toHaveBeenCalledTimes(1);
// Change the range
rerender({
languageProvider: mockLanguageProvider,
range: mockNewRange,
series: [] as DataFrame[],
});
expect(refreshMetricsMock).toHaveBeenCalledTimes(2);
});
it('should not call refreshMetrics when the time range is the same', () => {
const { rerender } = renderHook(
(props: TestProps) => usePromQueryFieldEffects(props.range, props.series, refreshMetricsMock, refreshHintMock),
{
initialProps: {
languageProvider: mockLanguageProvider,
range: mockRange,
series: [] as DataFrame[],
},
}
);
// Initial render already called refreshMetrics once
expect(refreshMetricsMock).toHaveBeenCalledTimes(1);
// Rerender with the same range
rerender({
languageProvider: mockLanguageProvider,
range: { ...mockRange }, // create a new object with the same values
series: [] as DataFrame[],
});
// Should still be called only once (from initial render)
expect(refreshMetricsMock).toHaveBeenCalledTimes(1);
});
it('should call refreshHint when series changes', () => {
const mockSeries = [{ name: 'new series', fields: [], length: 0 }] as DataFrame[];
const { rerender } = renderHook(
(props: TestProps) => usePromQueryFieldEffects(props.range, props.series, refreshMetricsMock, refreshHintMock),
{
initialProps: {
languageProvider: mockLanguageProvider,
range: mockRange,
series: [] as DataFrame[],
},
}
);
// Initial render already called refreshHint once
expect(refreshHintMock).toHaveBeenCalledTimes(2);
refreshHintMock.mockClear();
// Change the series
rerender({
languageProvider: mockLanguageProvider,
range: mockRange,
series: mockSeries,
});
expect(refreshHintMock).toHaveBeenCalledTimes(1);
});
it('should not call refreshHint when series is the same', () => {
const series = [] as DataFrame[];
const { rerender } = renderHook(
(props: TestProps) => usePromQueryFieldEffects(props.range, props.series, refreshMetricsMock, refreshHintMock),
{
initialProps: {
languageProvider: mockLanguageProvider,
range: mockRange,
series,
},
}
);
// Initial render already called refreshHint once
refreshHintMock.mockClear();
// Rerender with the same series
rerender({
languageProvider: mockLanguageProvider,
range: mockRange,
series, // same empty array
});
expect(refreshHintMock).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,60 @@
import { MutableRefObject, useEffect, useRef } from 'react';
import { DataFrame, DateTime, TimeRange } from '@grafana/data';
import { roundMsToMin } from '../language_utils';
import { CancelablePromise } from './cancelable-promise';
export function usePromQueryFieldEffects(
range: TimeRange | undefined,
series: DataFrame[] | undefined,
refreshMetrics: (languageProviderInitRef: MutableRefObject<CancelablePromise<unknown> | null>) => Promise<void>,
refreshHint: () => void
) {
const lastRangeRef = useRef<{ from: DateTime; to: DateTime } | null>(null);
const languageProviderInitRef = useRef<CancelablePromise<unknown> | null>(null);
// Effect for initial load
useEffect(() => {
refreshMetrics(languageProviderInitRef);
refreshHint();
return () => {
if (languageProviderInitRef.current) {
languageProviderInitRef.current.cancel();
languageProviderInitRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Effect for time range changes
useEffect(() => {
if (!range) {
return;
}
const currentFrom = roundMsToMin(range.from.valueOf());
const currentTo = roundMsToMin(range.to.valueOf());
if (!lastRangeRef.current) {
lastRangeRef.current = { from: range.from, to: range.to };
}
const lastFrom = roundMsToMin(lastRangeRef.current.from.valueOf());
const lastTo = roundMsToMin(lastRangeRef.current.to.valueOf());
if (currentFrom !== lastFrom || currentTo !== lastTo) {
lastRangeRef.current = { from: range.from, to: range.to };
refreshMetrics(languageProviderInitRef);
}
}, [range, refreshMetrics]);
// Effect for data changes (refreshing hints)
useEffect(() => {
refreshHint();
}, [series, refreshHint]);
return languageProviderInitRef;
}