From 23e0f63790258cb0da76b2b5d38a6fe1857fe5e8 Mon Sep 17 00:00:00 2001 From: ismail simsek Date: Fri, 4 Apr 2025 17:56:31 +0200 Subject: [PATCH] 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 --- .betterer.results | 3 - .../components/PromQueryEditorByApp.test.tsx | 1 - .../src/components/PromQueryField.test.tsx | 84 +--- .../src/components/PromQueryField.tsx | 390 +++++++----------- .../PrometheusMetricsBrowser.test.tsx | 3 - .../components/PrometheusMetricsBrowser.tsx | 13 +- .../components/VariableQueryEditor.test.tsx | 1 - .../src/components/useMetricsState.test.ts | 113 +++++ .../src/components/useMetricsState.ts | 38 ++ .../usePromQueryFieldEffects.test.ts | 182 ++++++++ .../components/usePromQueryFieldEffects.ts | 60 +++ packages/grafana-prometheus/src/datasource.ts | 6 +- .../components/PromQueryBuilder.test.tsx | 11 +- .../components/PromQueryBuilder.tsx | 3 +- 14 files changed, 571 insertions(+), 337 deletions(-) create mode 100644 packages/grafana-prometheus/src/components/useMetricsState.test.ts create mode 100644 packages/grafana-prometheus/src/components/useMetricsState.ts create mode 100644 packages/grafana-prometheus/src/components/usePromQueryFieldEffects.test.ts create mode 100644 packages/grafana-prometheus/src/components/usePromQueryFieldEffects.ts diff --git a/.betterer.results b/.betterer.results index 9ac27bdc479..05ba88854e4 100644 --- a/.betterer.results +++ b/.betterer.results @@ -401,9 +401,6 @@ exports[`better eslint`] = { "packages/grafana-o11y-ds-frontend/src/createNodeGraphFrames.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "packages/grafana-prometheus/src/components/PromQueryField.test.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"] - ], "packages/grafana-prometheus/src/components/PromQueryField.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], diff --git a/packages/grafana-prometheus/src/components/PromQueryEditorByApp.test.tsx b/packages/grafana-prometheus/src/components/PromQueryEditorByApp.test.tsx index 798bf833421..ed268f86621 100644 --- a/packages/grafana-prometheus/src/components/PromQueryEditorByApp.test.tsx +++ b/packages/grafana-prometheus/src/components/PromQueryEditorByApp.test.tsx @@ -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), diff --git a/packages/grafana-prometheus/src/components/PromQueryField.test.tsx b/packages/grafana-prometheus/src/components/PromQueryField.test.tsx index 36ffb5556e6..44482da9a28 100644 --- a/packages/grafana-prometheus/src/components/PromQueryField.test.tsx +++ b/packages/grafana-prometheus/src/components/PromQueryField.test.tsx @@ -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(); @@ -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(); // 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( { 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( - [], - } as unknown as PrometheusDatasource - } - {...defaultProps} - /> - ); - - // wait for component to render - await screen.findByRole('button'); - - const changedMetrics = ['baz', 'moo']; - queryField.rerender( - - ); - - // 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(); @@ -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; -} diff --git a/packages/grafana-prometheus/src/components/PromQueryField.tsx b/packages/grafana-prometheus/src/components/PromQueryField.tsx index 05cdda9864c..6670563ea8a 100644 --- a/packages/grafana-prometheus/src/components/PromQueryField.tsx +++ b/packages/grafana-prometheus/src/components/PromQueryField.tsx @@ -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, Themeable2 { +interface PromQueryFieldProps extends QueryEditorProps { 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 { - declare languageProviderInitializationPromise: CancelablePromise; + const theme = useTheme2(); - constructor(props: PromQueryFieldProps) { - super(props); + const [syntaxLoaded, setSyntaxLoaded] = useState(false); + const [hint, setHint] = useState(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 | 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 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 ( + <> +
+ - 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 ( - storageKey={LAST_USED_LABELS_KEY} defaultValue={[]}> - {(lastUsedLabels, onLastUsedLabelsSave, onLastUsedLabelsDelete) => { - return ( - <> -
+ +
+
+ {labelBrowserVisible && ( +
+ +
+ )} + {ExtraFieldElement} + {hint ? ( +
+
+ {hint.label}{' '} + {hint.fix ? ( + - -
- -
-
- {labelBrowserVisible && ( -
- -
- )} - - {ExtraFieldElement} - {hint ? ( -
-
- {hint.label}{' '} - {hint.fix ? ( - - ) : null} -
-
- ) : null} - - ); - }} - - ); - } -} - -export const PromQueryField = withTheme2(PromQueryFieldClass); + {hint.fix.label} + + ) : null} +
+ + ) : null} + + ); +}; diff --git a/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.test.tsx b/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.test.tsx index 602aeca8f6a..240f849fb05 100644 --- a/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.test.tsx +++ b/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.test.tsx @@ -184,9 +184,6 @@ describe('PrometheusMetricsBrowser', () => { onChange: () => {}, autoSelect: 0, languageProvider: mockLanguageProvider as unknown as PromQlLanguageProvider, - lastUsedLabels: [], - storeLastUsedLabels: () => {}, - deleteLastUsedLabels: () => {}, timeRange: getDefaultTimeRange(), }; diff --git a/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.tsx b/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.tsx index db3ec6c60b4..5898a649ad5 100644 --- a/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.tsx +++ b/packages/grafana-prometheus/src/components/PrometheusMetricsBrowser.tsx @@ -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 { let rawLabels: string[] = languageProvider.getLabelKeys(); // Get metrics @@ -378,7 +376,8 @@ export class UnthemedPrometheusMetricsBrowser extends React.Component 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) { diff --git a/packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx b/packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx index cb0f96ef54d..5e925ee928e 100644 --- a/packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx +++ b/packages/grafana-prometheus/src/components/VariableQueryEditor.test.tsx @@ -144,7 +144,6 @@ describe('PromVariableQueryEditor', () => { getLabelValues: jest.fn().mockImplementation(() => ['that']), fetchLabelsWithMatch: jest.fn().mockImplementation(() => Promise.resolve({ those: 'those' })), } as Partial as PrometheusLanguageProvider, - getInitHints: () => [], getDebounceTimeInMilliseconds: jest.fn(), getTagKeys: jest.fn().mockImplementation(() => Promise.resolve(['this'])), getVariables: jest.fn().mockImplementation(() => []), diff --git a/packages/grafana-prometheus/src/components/useMetricsState.test.ts b/packages/grafana-prometheus/src/components/useMetricsState.test.ts new file mode 100644 index 00000000000..a483d707ec1 --- /dev/null +++ b/packages/grafana-prometheus/src/components/useMetricsState.test.ts @@ -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); + }); + }); +}); diff --git a/packages/grafana-prometheus/src/components/useMetricsState.ts b/packages/grafana-prometheus/src/components/useMetricsState.ts new file mode 100644 index 00000000000..a85e7980f40 --- /dev/null +++ b/packages/grafana-prometheus/src/components/useMetricsState.ts @@ -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]); +} diff --git a/packages/grafana-prometheus/src/components/usePromQueryFieldEffects.test.ts b/packages/grafana-prometheus/src/components/usePromQueryFieldEffects.test.ts new file mode 100644 index 00000000000..84ef8ef7304 --- /dev/null +++ b/packages/grafana-prometheus/src/components/usePromQueryFieldEffects.test.ts @@ -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(); + }); +}); diff --git a/packages/grafana-prometheus/src/components/usePromQueryFieldEffects.ts b/packages/grafana-prometheus/src/components/usePromQueryFieldEffects.ts new file mode 100644 index 00000000000..643b0fe314f --- /dev/null +++ b/packages/grafana-prometheus/src/components/usePromQueryFieldEffects.ts @@ -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 | null>) => Promise, + refreshHint: () => void +) { + const lastRangeRef = useRef<{ from: DateTime; to: DateTime } | null>(null); + const languageProviderInitRef = useRef | 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; +} diff --git a/packages/grafana-prometheus/src/datasource.ts b/packages/grafana-prometheus/src/datasource.ts index 082e391ad9b..f871c4b626b 100644 --- a/packages/grafana-prometheus/src/datasource.ts +++ b/packages/grafana-prometheus/src/datasource.ts @@ -56,7 +56,7 @@ import { getRangeSnapInterval, } from './language_utils'; import { PrometheusMetricFindQuery } from './metric_find_query'; -import { getInitHints, getQueryHints } from './query_hints'; +import { getQueryHints } from './query_hints'; import { promQueryModeller } from './querybuilder/PromQueryModeller'; import { QueryBuilderLabelFilter, QueryEditorMode } from './querybuilder/shared/types'; import { CacheRequestInfo, defaultPrometheusQueryOverlapWindow, QueryCache } from './querycache/QueryCache'; @@ -777,10 +777,6 @@ export class PrometheusDatasource return getQueryHints(query.expr ?? '', result, this); } - getInitHints() { - return getInitHints(this); - } - async loadRules() { try { const res = await this.metadataRequest('/api/v1/rules', {}, { showErrorAlert: false }); diff --git a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.test.tsx b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.test.tsx index 372873eebb1..dc0e1cb4511 100644 --- a/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.test.tsx +++ b/packages/grafana-prometheus/src/querybuilder/components/PromQueryBuilder.test.tsx @@ -8,7 +8,6 @@ import { LoadingState, MutableDataFrame, PanelData, - QueryHint, TimeRange, } from '@grafana/data'; import { TemplateSrv } from '@grafana/runtime'; @@ -16,6 +15,7 @@ import { TemplateSrv } from '@grafana/runtime'; import { PrometheusDatasource } from '../../datasource'; import PromQlLanguageProvider from '../../language_provider'; import { EmptyLanguageProviderMock } from '../../language_provider.mock'; +import * as queryHints from '../../query_hints'; import { PromApplication, PromOptions } from '../../types'; import { getLabelSelects } from '../testUtils'; import { PromVisualQuery } from '../types'; @@ -239,12 +239,7 @@ describe('PromQueryBuilder', () => { it('renders hint if initial hint provided', async () => { const { datasource } = createDatasource(); - datasource.getInitHints = (): QueryHint[] => [ - { - label: 'Initial hint', - type: 'warning', - }, - ]; + jest.spyOn(queryHints, 'getInitHints').mockReturnValue([{ label: 'Initial hint', type: 'warning' }]); const props = createProps(datasource); render( { it('renders no hint if no initial hint provided', async () => { const { datasource } = createDatasource(); - datasource.getInitHints = (): QueryHint[] => []; + jest.spyOn(queryHints, 'getInitHints').mockReturnValue([]); const props = createProps(datasource); render( ((props) => { const lang = { grammar: promqlGrammar, name: 'promql' }; - const initHints = datasource.getInitHints(); + const initHints = getInitHints(datasource); return ( <>