mirror of https://github.com/grafana/grafana
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 * prettierpull/101703/head
parent
a93e618102
commit
23e0f63790
@ -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); |
||||
}); |
||||
}); |
||||
}); |
||||
@ -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]); |
||||
} |
||||
@ -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(); |
||||
}); |
||||
}); |
||||
@ -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; |
||||
} |
||||
Loading…
Reference in new issue