diff --git a/public/app/plugins/datasource/cloudwatch/__mocks__/CloudWatchDataSource.ts b/public/app/plugins/datasource/cloudwatch/__mocks__/CloudWatchDataSource.ts index 5ce973cca66..5b907055215 100644 --- a/public/app/plugins/datasource/cloudwatch/__mocks__/CloudWatchDataSource.ts +++ b/public/app/plugins/datasource/cloudwatch/__mocks__/CloudWatchDataSource.ts @@ -49,7 +49,9 @@ export function setupMockedDataSource({ } as any ); datasource.getVariables = () => ['test']; - datasource.getRegions = () => Promise.resolve([]); + + datasource.getNamespaces = jest.fn().mockResolvedValue([]); + datasource.getRegions = jest.fn().mockResolvedValue([]); const fetchMock = jest.fn().mockReturnValue(of({ data })); setBackendSrv({ fetch: fetchMock } as any); diff --git a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.test.tsx b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.test.tsx index 73dd9d21e2b..f74ddbf21d8 100644 --- a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.test.tsx @@ -10,7 +10,7 @@ import { CustomVariableModel, initialVariableModelState } from '../../../../feat import { CloudWatchDatasource } from '../datasource'; import { CloudWatchJsonData, MetricEditorMode, MetricQueryType } from '../types'; -import { MetricsQueryEditor, normalizeQuery, Props } from './MetricsQueryEditor'; +import { MetricsQueryEditor, Props } from './MetricsQueryEditor'; const setup = () => { const instanceSettings = { @@ -79,64 +79,6 @@ describe('QueryEditor', () => { }); }); - it('normalizes query on mount', async () => { - const { act } = renderer; - const props = setup(); - // This does not actually even conform to the prop type but this happens on initialisation somehow - props.query = { - queryMode: 'Metrics', - apiMode: 'Metrics', - refId: '', - expression: '', - matchExact: true, - metricQueryType: MetricQueryType.Search, - metricEditorMode: MetricEditorMode.Builder, - } as any; - await act(async () => { - renderer.create(); - }); - expect((props.onChange as jest.Mock).mock.calls[0][0]).toEqual({ - namespace: '', - metricName: '', - expression: '', - sqlExpression: '', - dimensions: {}, - region: 'default', - id: '', - alias: '', - statistic: 'Average', - period: '', - queryMode: 'Metrics', - apiMode: 'Metrics', - refId: '', - matchExact: true, - metricQueryType: MetricQueryType.Search, - metricEditorMode: MetricEditorMode.Builder, - }); - }); - - describe('should use correct default values', () => { - it('should normalize query with default values', () => { - expect(normalizeQuery({ refId: '42' } as any)).toEqual({ - namespace: '', - metricName: '', - expression: '', - sqlExpression: '', - dimensions: {}, - region: 'default', - id: '', - alias: '', - statistic: 'Average', - matchExact: true, - period: '', - queryMode: 'Metrics', - refId: '42', - metricQueryType: MetricQueryType.Search, - metricEditorMode: MetricEditorMode.Builder, - }); - }); - }); - describe('should handle editor modes correctly', () => { it('when metric query type is metric search and editor mode is builder', async () => { await act(async () => { diff --git a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.tsx index d4884fdeab3..c484dec6cbc 100644 --- a/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor.tsx @@ -1,4 +1,4 @@ -import React, { ChangeEvent, PureComponent } from 'react'; +import React, { ChangeEvent, useState } from 'react'; import { QueryEditorProps } from '@grafana/data'; import { EditorField, EditorRow, Space } from '@grafana/experimental'; @@ -16,181 +16,132 @@ import { } from '../types'; import QueryHeader from './QueryHeader'; +import usePreparedMetricsQuery from './usePreparedMetricsQuery'; import { Alias, MathExpressionQueryField, MetricStatEditor, SQLBuilderEditor, SQLCodeEditor } from './'; -export type Props = QueryEditorProps; - -interface State { - sqlCodeEditorIsDirty: boolean; +export interface Props extends QueryEditorProps { + query: CloudWatchMetricsQuery; } -export const normalizeQuery = ({ - namespace, - metricName, - expression, - dimensions, - region, - id, - alias, - statistic, - period, - sqlExpression, - metricQueryType, - metricEditorMode, - ...rest -}: CloudWatchMetricsQuery): CloudWatchMetricsQuery => { - const normalizedQuery = { - queryMode: 'Metrics' as const, - namespace: namespace ?? '', - metricName: metricName ?? '', - expression: expression ?? '', - dimensions: dimensions ?? {}, - region: region ?? 'default', - id: id ?? '', - alias: alias ?? '', - statistic: statistic ?? 'Average', - period: period ?? '', - metricQueryType: metricQueryType ?? MetricQueryType.Search, - metricEditorMode: metricEditorMode ?? MetricEditorMode.Builder, - sqlExpression: sqlExpression ?? '', - ...rest, - }; - return !rest.hasOwnProperty('matchExact') ? { ...normalizedQuery, matchExact: true } : normalizedQuery; -}; - -export class MetricsQueryEditor extends PureComponent { - state = { - sqlCodeEditorIsDirty: false, - }; +export const MetricsQueryEditor = (props: Props) => { + const { query, onRunQuery, datasource } = props; + const [sqlCodeEditorIsDirty, setSQLCodeEditorIsDirty] = useState(false); + const preparedQuery = usePreparedMetricsQuery(query, props.onChange); - componentDidMount = () => { - const metricsQuery = this.props.query as CloudWatchMetricsQuery; - const query = normalizeQuery(metricsQuery); - this.props.onChange(query); - }; - - onChange = (query: CloudWatchQuery) => { - const { onChange, onRunQuery } = this.props; + const onChange = (query: CloudWatchQuery) => { + const { onChange, onRunQuery } = props; onChange(query); onRunQuery(); }; - render() { - const { onRunQuery, datasource } = this.props; - const metricsQuery = this.props.query as CloudWatchMetricsQuery; - const query = normalizeQuery(metricsQuery); - - return ( - <> - { - if (isCloudWatchMetricsQuery(newQuery) && newQuery.metricEditorMode !== query.metricEditorMode) { - this.setState({ sqlCodeEditorIsDirty: false }); - } - this.onChange(newQuery); - }} - sqlCodeEditorIsDirty={this.state.sqlCodeEditorIsDirty} - /> - + return ( + <> + { + if (isCloudWatchMetricsQuery(newQuery) && newQuery.metricEditorMode !== query.metricEditorMode) { + setSQLCodeEditorIsDirty(false); + } + onChange(newQuery); + }} + sqlCodeEditorIsDirty={sqlCodeEditorIsDirty} + /> + + + {query.metricQueryType === MetricQueryType.Search && ( + <> + {query.metricEditorMode === MetricEditorMode.Builder && ( + props.onChange({ ...query, ...metricStat })} + > + )} + {query.metricEditorMode === MetricEditorMode.Code && ( + props.onChange({ ...query, expression })} + datasource={datasource} + > + )} + + )} + {query.metricQueryType === MetricQueryType.Query && ( + <> + {query.metricEditorMode === MetricEditorMode.Code && ( + { + if (!sqlCodeEditorIsDirty) { + setSQLCodeEditorIsDirty(true); + } + props.onChange({ ...preparedQuery, sqlExpression }); + }} + onRunQuery={onRunQuery} + datasource={datasource} + /> + )} - {query.metricQueryType === MetricQueryType.Search && ( - <> - {query.metricEditorMode === MetricEditorMode.Builder && ( - this.props.onChange({ ...query, ...metricStat })} - > - )} - {query.metricEditorMode === MetricEditorMode.Code && ( - + this.props.onChange({ ...query, expression })} datasource={datasource} - > - )} - - )} - {query.metricQueryType === MetricQueryType.Query && ( - <> - {query.metricEditorMode === MetricEditorMode.Code && ( - { - if (!this.state.sqlCodeEditorIsDirty) { - this.setState({ sqlCodeEditorIsDirty: true }); - } - this.props.onChange({ ...metricsQuery, sqlExpression }); - }} - onRunQuery={onRunQuery} - datasource={datasource} - /> - )} - - {query.metricEditorMode === MetricEditorMode.Builder && ( - <> - - - )} - - )} - - - - ) => - this.onChange({ ...metricsQuery, id: event.target.value }) - } - type="text" - value={query.id} - /> - - - - ) => - this.onChange({ ...metricsQuery, period: event.target.value }) - } - /> - - - - this.onChange({ ...metricsQuery, alias: value })} - /> - - - - ); - } -} + > + + )} + + )} + + + + ) => onChange({ ...preparedQuery, id: event.target.value })} + type="text" + value={query.id} + /> + + + + ) => + onChange({ ...preparedQuery, period: event.target.value }) + } + /> + + + + onChange({ ...preparedQuery, alias: value })} + /> + + + + ); +}; diff --git a/public/app/plugins/datasource/cloudwatch/components/PanelQueryEditor.test.tsx b/public/app/plugins/datasource/cloudwatch/components/PanelQueryEditor.test.tsx new file mode 100644 index 00000000000..eefeca932f7 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/PanelQueryEditor.test.tsx @@ -0,0 +1,133 @@ +import { act, render, screen } from '@testing-library/react'; +import React from 'react'; + +import { QueryEditorProps } from '@grafana/data'; + +import { setupMockedDataSource } from '../__mocks__/CloudWatchDataSource'; +import { CloudWatchDatasource } from '../datasource'; +import { CloudWatchQuery, CloudWatchJsonData, MetricEditorMode, MetricQueryType } from '../types'; + +import { PanelQueryEditor } from './PanelQueryEditor'; + +// the following three fields are added to legacy queries in the dashboard migrator +const migratedFields = { + statistic: 'Average', + metricEditorMode: MetricEditorMode.Builder, + metricQueryType: MetricQueryType.Query, +}; + +const props: QueryEditorProps = { + datasource: setupMockedDataSource().datasource, + onRunQuery: jest.fn(), + onChange: jest.fn(), + query: {} as CloudWatchQuery, +}; + +describe('PanelQueryEditor should render right editor', () => { + describe('when using grafana 6.3.0 metric query', () => { + it('should render the metrics query editor', async () => { + const query = { + ...migratedFields, + dimensions: { + InstanceId: 'i-123', + }, + expression: '', + highResolution: false, + id: '', + metricName: 'CPUUtilization', + namespace: 'AWS/EC2', + period: '', + refId: 'A', + region: 'default', + returnData: false, + }; + await act(async () => { + render(); + }); + expect(screen.getByText('Metric name')).toBeInTheDocument(); + }); + }); + + describe('when using grafana 7.0.0 style metrics query', () => { + it('should render the metrics query editor', async () => { + const query = { + ...migratedFields, + alias: '', + apiMode: 'Logs', + dimensions: { + InstanceId: 'i-123', + }, + expression: '', + id: '', + logGroupNames: [], + matchExact: true, + metricName: 'CPUUtilization', + namespace: 'AWS/EC2', + period: '', + queryMode: 'Logs', + refId: 'A', + region: 'ap-northeast-2', + statistics: 'Average', + } as any; + await act(async () => { + render(); + }); + expect(screen.getByText('Choose Log Groups')).toBeInTheDocument(); + }); + }); + + describe('when using grafana 7.0.0 style logs query', () => { + it('should render the metrics query editor', async () => { + const query = { + ...migratedFields, + alias: '', + apiMode: 'Logs', + dimensions: { + InstanceId: 'i-123', + }, + expression: '', + id: '', + logGroupNames: [], + matchExact: true, + metricName: 'CPUUtilization', + namespace: 'AWS/EC2', + period: '', + queryMode: 'Logs', + refId: 'A', + region: 'ap-northeast-2', + statistic: 'Average', + } as any; + await act(async () => { + render(); + }); + expect(screen.getByText('Log Groups')).toBeInTheDocument(); + }); + }); + + describe('when using grafana query from curated ec2 dashboard', () => { + it('should render the metrics query editor', async () => { + const query = { + ...migratedFields, + + alias: 'Inbound', + dimensions: { + InstanceId: '*', + }, + expression: + "SUM(REMOVE_EMPTY(SEARCH('{AWS/EC2,InstanceId} MetricName=\"NetworkIn\"', 'Sum', $period)))/$period", + id: '', + matchExact: true, + metricName: 'NetworkOut', + namespace: 'AWS/EC2', + period: '$period', + refId: 'B', + region: '$region', + statistic: 'Average', + } as any; + await act(async () => { + render(); + }); + expect(screen.getByText('Metric name')).toBeInTheDocument(); + }); + }); +}); diff --git a/public/app/plugins/datasource/cloudwatch/components/PanelQueryEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/PanelQueryEditor.tsx index 3c9938b98df..079fdae5671 100644 --- a/public/app/plugins/datasource/cloudwatch/components/PanelQueryEditor.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/PanelQueryEditor.tsx @@ -1,8 +1,9 @@ import React, { PureComponent } from 'react'; -import { QueryEditorProps, ExploreMode } from '@grafana/data'; +import { QueryEditorProps } from '@grafana/data'; import { CloudWatchDatasource } from '../datasource'; +import { isCloudWatchMetricsQuery } from '../guards'; import { CloudWatchJsonData, CloudWatchQuery } from '../types'; import LogsQueryEditor from './LogsQueryEditor'; @@ -13,14 +14,13 @@ export type Props = QueryEditorProps { render() { const { query } = this.props; - const apiMode = query.queryMode ?? 'Metrics'; return ( <> - {apiMode === ExploreMode.Logs ? ( - + {isCloudWatchMetricsQuery(query) ? ( + ) : ( - + )} ); diff --git a/public/app/plugins/datasource/cloudwatch/components/usePreparedMetricsQuery.test.ts b/public/app/plugins/datasource/cloudwatch/components/usePreparedMetricsQuery.test.ts new file mode 100644 index 00000000000..8f179549d0a --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/usePreparedMetricsQuery.test.ts @@ -0,0 +1,76 @@ +import { renderHook } from '@testing-library/react-hooks'; + +import { CloudWatchMetricsQuery, MetricEditorMode, MetricQueryType } from '../types'; + +import usePreparedMetricsQuery, { DEFAULT_QUERY } from './usePreparedMetricsQuery'; + +interface TestScenario { + name: string; + query: any; + expectedQuery: CloudWatchMetricsQuery; +} + +const baseQuery: CloudWatchMetricsQuery = { + refId: 'A', + id: '', + region: 'us-east-2', + namespace: 'AWS/EC2', + dimensions: { InstanceId: 'x-123' }, +}; + +describe('usePrepareMetricsQuery', () => { + describe('when an incomplete query is provided', () => { + const testTable: TestScenario[] = [ + { name: 'Empty query', query: { refId: 'A' }, expectedQuery: { ...DEFAULT_QUERY, refId: 'A' } }, + { + name: 'Match exact is not part of the query', + query: { ...baseQuery }, + expectedQuery: { ...DEFAULT_QUERY, ...baseQuery, matchExact: true }, + }, + { + name: 'Match exact is part of the query', + query: { ...baseQuery, matchExact: false }, + expectedQuery: { ...DEFAULT_QUERY, ...baseQuery, matchExact: false }, + }, + { + name: 'When editor mode and builder mode different from default is specified', + query: { ...baseQuery, metricQueryType: MetricQueryType.Query, metricEditorMode: MetricEditorMode.Code }, + expectedQuery: { + ...DEFAULT_QUERY, + ...baseQuery, + metricQueryType: MetricQueryType.Query, + metricEditorMode: MetricEditorMode.Code, + }, + }, + ]; + describe.each(testTable)('scenario %#: $name', (scenario) => { + it('should set the default values and trigger onChangeQuery', async () => { + const onChangeQuery = jest.fn(); + const { result } = renderHook(() => usePreparedMetricsQuery(scenario.query, onChangeQuery)); + expect(onChangeQuery).toHaveBeenLastCalledWith(result.current); + expect(result.current).toEqual(scenario.expectedQuery); + }); + }); + }); + + describe('when a complete query is provided', () => { + it('should not change the query and should not call onChangeQuery', async () => { + const onChangeQuery = jest.fn(); + const completeQuery: CloudWatchMetricsQuery = { + ...baseQuery, + expression: '', + queryMode: 'Metrics', + metricName: '', + statistic: 'Sum', + period: '300', + metricQueryType: MetricQueryType.Query, + metricEditorMode: MetricEditorMode.Code, + sqlExpression: 'SELECT 1', + matchExact: false, + }; + const { result } = renderHook(() => usePreparedMetricsQuery(completeQuery, onChangeQuery)); + expect(onChangeQuery).not.toHaveBeenCalled(); + expect(result.current).toEqual(completeQuery); + }); + }); +}); diff --git a/public/app/plugins/datasource/cloudwatch/components/usePreparedMetricsQuery.ts b/public/app/plugins/datasource/cloudwatch/components/usePreparedMetricsQuery.ts new file mode 100644 index 00000000000..a52d5e277e9 --- /dev/null +++ b/public/app/plugins/datasource/cloudwatch/components/usePreparedMetricsQuery.ts @@ -0,0 +1,48 @@ +import deepEqual from 'fast-deep-equal'; +import { useEffect, useMemo } from 'react'; + +import { CloudWatchMetricsQuery, MetricEditorMode, MetricQueryType } from '../types'; + +export const DEFAULT_QUERY: Omit = { + queryMode: 'Metrics', + namespace: '', + metricName: '', + expression: '', + dimensions: {}, + region: 'default', + id: '', + statistic: 'Average', + period: '', + metricQueryType: MetricQueryType.Search, + metricEditorMode: MetricEditorMode.Builder, + sqlExpression: '', + matchExact: true, +}; + +const prepareQuery = (query: CloudWatchMetricsQuery) => { + const withDefaults = { ...DEFAULT_QUERY, ...query }; + + // If we didn't make any changes to the object, then return the original object to keep the + // identity the same, and not trigger any other useEffects or anything. + return deepEqual(withDefaults, query) ? query : withDefaults; +}; + +/** + * Returns queries with some defaults + migrations, and calls onChange function to notify if it changes + */ +const usePreparedMetricsQuery = ( + query: CloudWatchMetricsQuery, + onChangeQuery: (newQuery: CloudWatchMetricsQuery) => void +) => { + const preparedQuery = useMemo(() => prepareQuery(query), [query]); + + useEffect(() => { + if (preparedQuery !== query) { + onChangeQuery(preparedQuery); + } + }, [preparedQuery, query, onChangeQuery]); + + return preparedQuery; +}; + +export default usePreparedMetricsQuery; diff --git a/public/app/plugins/datasource/cloudwatch/guards.ts b/public/app/plugins/datasource/cloudwatch/guards.ts index 8f7a3f4ea3a..b5346df3c25 100644 --- a/public/app/plugins/datasource/cloudwatch/guards.ts +++ b/public/app/plugins/datasource/cloudwatch/guards.ts @@ -6,7 +6,7 @@ export const isCloudWatchLogsQuery = (cloudwatchQuery: CloudWatchQuery): cloudwa cloudwatchQuery.queryMode === 'Logs'; export const isCloudWatchMetricsQuery = (cloudwatchQuery: CloudWatchQuery): cloudwatchQuery is CloudWatchMetricsQuery => - cloudwatchQuery.queryMode === 'Metrics'; + cloudwatchQuery.queryMode === 'Metrics' || !cloudwatchQuery.hasOwnProperty('queryMode'); // in early versions of this plugin, queryMode wasn't defined in a CloudWatchMetricsQuery export const isCloudWatchAnnotationQuery = ( cloudwatchQuery: CloudWatchQuery