diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/SQLBuilderEditor/SQLBuilderEditor.test.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/SQLBuilderEditor/SQLBuilderEditor.test.tsx index 13bf64016d4..bfd11f2bc85 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/SQLBuilderEditor/SQLBuilderEditor.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/SQLBuilderEditor/SQLBuilderEditor.test.tsx @@ -95,12 +95,15 @@ describe('Cloudwatch SQLBuilderEditor', () => { render(); await waitFor(() => - expect(datasource.resources.getDimensionKeys).toHaveBeenCalledWith({ - namespace: 'AWS/EC2', - region: query.region, - dimensionFilters: { InstanceId: null }, - metricName: undefined, - }) + expect(datasource.resources.getDimensionKeys).toHaveBeenCalledWith( + { + namespace: 'AWS/EC2', + region: query.region, + dimensionFilters: { InstanceId: null }, + metricName: undefined, + }, + false + ) ); expect(screen.getByText('AWS/EC2')).toBeInTheDocument(); expect(screen.getByLabelText('With schema')).toBeChecked(); diff --git a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/SQLBuilderEditor/SQLFilter.tsx b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/SQLBuilderEditor/SQLFilter.tsx index 079c67d0efe..6c4cfc48706 100644 --- a/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/SQLBuilderEditor/SQLFilter.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/QueryEditor/MetricsQueryEditor/SQLBuilderEditor/SQLFilter.tsx @@ -1,9 +1,10 @@ +import { css } from '@emotion/css'; import React, { useMemo, useState } from 'react'; import { useAsyncFn } from 'react-use'; import { SelectableValue, toOption } from '@grafana/data'; import { AccessoryButton, EditorList, InputGroup } from '@grafana/experimental'; -import { Select } from '@grafana/ui'; +import { Alert, Select, useStyles2 } from '@grafana/ui'; import { CloudWatchDatasource } from '../../../../datasource'; import { @@ -11,7 +12,7 @@ import { QueryEditorOperatorExpression, QueryEditorPropertyType, } from '../../../../expressions'; -import { useDimensionKeys } from '../../../../hooks'; +import { useDimensionKeys, useEnsureVariableHasSingleSelection } from '../../../../hooks'; import { COMPARISON_OPERATORS, EQUALS } from '../../../../language/cloudwatch-sql/language'; import { CloudWatchMetricsQuery } from '../../../../types'; import { appendTemplateVariables } from '../../../../utils/utils'; @@ -101,6 +102,7 @@ interface FilterItemProps { const FilterItem = (props: FilterItemProps) => { const { datasource, query, filter, onChange, onDelete } = props; + const styles = useStyles2(getStyles); const sql = query.sql ?? {}; const namespace = getNamespaceFromExpression(sql.from); @@ -127,36 +129,58 @@ const FilterItem = (props: FilterItemProps) => { filter.property?.name, ]); + const propertyNameError = useEnsureVariableHasSingleSelection(datasource, filter.property?.name); + const operatorValueError = useEnsureVariableHasSingleSelection( + datasource, + typeof filter.operator?.value === 'string' ? filter.operator?.value : undefined + ); + return ( - - value && onChange(setOperatorExpressionProperty(filter, value))} + /> - value && onChange(setOperatorExpressionName(filter, value))} + /> - value && onChange(setOperatorExpressionValue(filter, value))} + /> - - + + + + {propertyNameError && ( + + )} + {operatorValueError && ( + + )} + ); }; + +const getStyles = () => ({ + container: css({ display: 'inline-block' }), + alert: css({ minWidth: '100%', width: 'min-content' }), +}); diff --git a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilter.test.tsx b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilter.test.tsx index b1050541aec..9f93308e595 100644 --- a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilter.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilter.test.tsx @@ -2,8 +2,14 @@ import { fireEvent, render, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; +import { setupMockedDataSource } from '../../__mocks__/CloudWatchDataSource'; + import { MultiFilter } from './MultiFilter'; +const ds = setupMockedDataSource({ + variables: [], +}); + describe('MultiFilters', () => { describe('when rendered with two existing multifilters', () => { it('should render two filter items', async () => { @@ -12,7 +18,7 @@ describe('MultiFilters', () => { InstanceGroup: ['Group1'], }; const onChange = jest.fn(); - render(); + render(); const filterItems = screen.getAllByTestId('cloudwatch-multifilter-item'); expect(filterItems.length).toBe(2); @@ -28,7 +34,7 @@ describe('MultiFilters', () => { it('it should add the new item but not call onChange', async () => { const filters = {}; const onChange = jest.fn(); - render(); + render(); await userEvent.click(screen.getByLabelText('Add')); expect(screen.getByTestId('cloudwatch-multifilter-item')).toBeInTheDocument(); @@ -40,7 +46,7 @@ describe('MultiFilters', () => { it('it should add the new item but not call onChange', async () => { const filters = {}; const onChange = jest.fn(); - render(); + render(); await userEvent.click(screen.getByLabelText('Add')); const filterItemElement = screen.getByTestId('cloudwatch-multifilter-item'); @@ -60,7 +66,7 @@ describe('MultiFilters', () => { it('it should add the new item and trigger onChange', async () => { const filters = {}; const onChange = jest.fn(); - render(); + render(); const label = await screen.findByLabelText('Add'); await userEvent.click(label); @@ -88,7 +94,7 @@ describe('MultiFilters', () => { it('it should change the key and call onChange', async () => { const filters = { 'my-key': ['my-value'] }; const onChange = jest.fn(); - render(); + render(); const filterItemElement = screen.getByTestId('cloudwatch-multifilter-item'); expect(filterItemElement).toBeInTheDocument(); diff --git a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilter.tsx b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilter.tsx index 863c6e56ea0..94668e1f9cf 100644 --- a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilter.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilter.tsx @@ -3,6 +3,7 @@ import React, { useEffect, useState } from 'react'; import { EditorList } from '@grafana/experimental'; +import { type CloudWatchDatasource } from '../../datasource'; import { MultiFilters } from '../../types'; import { MultiFilterItem } from './MultiFilterItem'; @@ -11,6 +12,7 @@ export interface Props { filters?: MultiFilters; onChange: (filters: MultiFilters) => void; keyPlaceholder?: string; + datasource: CloudWatchDatasource; } export interface MultiFilterCondition { @@ -32,7 +34,7 @@ const filterConditionsToMultiFilters = (filters: MultiFilterCondition[]) => { return res; }; -export const MultiFilter = ({ filters, onChange, keyPlaceholder }: Props) => { +export const MultiFilter = ({ filters, onChange, keyPlaceholder, datasource }: Props) => { const [items, setItems] = useState([]); useEffect(() => setItems(filters ? multiFiltersToFilterConditions(filters) : []), [filters]); const onFiltersChange = (newItems: Array>) => { @@ -46,10 +48,12 @@ export const MultiFilter = ({ filters, onChange, keyPlaceholder }: Props) => { } }; - return ; + return ( + + ); }; -function makeRenderFilter(keyPlaceholder?: string) { +function makeRenderFilter(datasource: CloudWatchDatasource, keyPlaceholder?: string) { function renderFilter( item: MultiFilterCondition, onChange: (item: MultiFilterCondition) => void, @@ -61,6 +65,7 @@ function makeRenderFilter(keyPlaceholder?: string) { onChange={(item) => onChange(item)} onDelete={onDelete} keyPlaceholder={keyPlaceholder} + datasource={datasource} /> ); } diff --git a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilterItem.tsx b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilterItem.tsx index 40cfd9f4134..f9480b4802c 100644 --- a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilterItem.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/MultiFilterItem.tsx @@ -3,7 +3,10 @@ import React, { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { AccessoryButton, InputGroup } from '@grafana/experimental'; -import { Input, useStyles2 } from '@grafana/ui'; +import { Alert, Input, useStyles2 } from '@grafana/ui'; + +import { type CloudWatchDatasource } from '../../datasource'; +import { useEnsureVariableHasSingleSelection } from '../../hooks'; import { MultiFilterCondition } from './MultiFilter'; @@ -12,11 +15,13 @@ export interface Props { onChange: (value: MultiFilterCondition) => void; onDelete: () => void; keyPlaceholder?: string; + datasource: CloudWatchDatasource; } -export const MultiFilterItem = ({ filter, onChange, onDelete, keyPlaceholder }: Props) => { +export const MultiFilterItem = ({ filter, onChange, onDelete, keyPlaceholder, datasource }: Props) => { const [localKey, setLocalKey] = useState(filter.key || ''); const [localValue, setLocalValue] = useState(filter.value?.join(', ') || ''); + const error = useEnsureVariableHasSingleSelection(datasource, filter.key); const styles = useStyles2(getOperatorStyles); return ( @@ -54,6 +59,7 @@ export const MultiFilterItem = ({ filter, onChange, onDelete, keyPlaceholder }: + {error && } ); }; diff --git a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.test.tsx b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.test.tsx index ae31f7b755b..d91dca40a29 100644 --- a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.test.tsx @@ -152,12 +152,15 @@ describe('VariableEditor', () => { select(keySelect, 'v4', { container: document.body, }); - expect(ds.datasource.resources.getDimensionKeys).toHaveBeenCalledWith({ - namespace: 'z2', - region: 'a1', - metricName: 'i3', - dimensionFilters: undefined, - }); + expect(ds.datasource.resources.getDimensionKeys).toHaveBeenCalledWith( + { + namespace: 'z2', + region: 'a1', + metricName: 'i3', + dimensionFilters: undefined, + }, + false + ); await waitFor(() => { expect(onChange).toHaveBeenCalledWith({ ...defaultQuery, diff --git a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.tsx b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.tsx index 862198fb332..f333cab6126 100644 --- a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryEditor.tsx @@ -6,7 +6,14 @@ import { config } from '@grafana/runtime'; import { InlineField } from '@grafana/ui'; import { CloudWatchDatasource } from '../../datasource'; -import { useAccountOptions, useDimensionKeys, useMetrics, useNamespaces, useRegions } from '../../hooks'; +import { + useAccountOptions, + useDimensionKeys, + useMetrics, + useNamespaces, + useRegions, + useEnsureVariableHasSingleSelection, +} from '../../hooks'; import { migrateVariableQuery } from '../../migrations/variableQueryMigrations'; import { CloudWatchJsonData, CloudWatchQuery, VariableQuery, VariableQueryType } from '../../types'; import { ALL_ACCOUNTS_OPTION } from '../shared/Account'; @@ -43,6 +50,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => { const metrics = useMetrics(datasource, { region, namespace }); const dimensionKeys = useDimensionKeys(datasource, { region, namespace, metricName }); const accountState = useAccountOptions(datasource.resources, query.region); + const dimensionKeyError = useEnsureVariableHasSingleSelection(datasource, dimensionKey); const newFormStylingEnabled = config.featureToggles.awsDatasourcesNewFormStyling; const onRegionChange = async (region: string) => { @@ -179,6 +187,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => { inputId={`variable-query-dimension-key-${query.refId}`} allowCustomValue newFormStylingEnabled={newFormStylingEnabled} + error={dimensionKeyError} /> {newFormStylingEnabled ? ( @@ -263,6 +272,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => { onChange({ ...parsedQuery, ec2Filters: filters }); }} keyPlaceholder="filter/tag" + datasource={datasource} /> ) : ( @@ -289,6 +299,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => { onChange({ ...parsedQuery, ec2Filters: filters }); }} keyPlaceholder="filter/tag" + datasource={datasource} /> )} @@ -310,6 +321,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => { onChange({ ...parsedQuery, tags: filters }); }} keyPlaceholder="tag" + datasource={datasource} /> ) : ( @@ -320,6 +332,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => { onChange({ ...parsedQuery, tags: filters }); }} keyPlaceholder="tag" + datasource={datasource} /> )} diff --git a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryField.tsx b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryField.tsx index ae4c94d2703..6ab887cefba 100644 --- a/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryField.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/VariableQueryEditor/VariableQueryField.tsx @@ -1,8 +1,9 @@ +import { css } from '@emotion/css'; import React from 'react'; -import { SelectableValue } from '@grafana/data'; +import { GrafanaTheme2, SelectableValue } from '@grafana/data'; import { EditorField } from '@grafana/experimental'; -import { InlineField, Select } from '@grafana/ui'; +import { Alert, InlineField, Select, useStyles2 } from '@grafana/ui'; import { VariableQueryType } from '../../types'; import { removeMarginBottom } from '../styles'; @@ -18,6 +19,7 @@ interface VariableQueryFieldProps { allowCustomValue?: boolean; isLoading?: boolean; newFormStylingEnabled?: boolean; + error?: string; } export const VariableQueryField = ({ @@ -29,31 +31,44 @@ export const VariableQueryField = ({ isLoading = false, inputId = label, newFormStylingEnabled, + error, }: VariableQueryFieldProps) => { + const styles = useStyles2(getStyles); return newFormStylingEnabled ? ( - - onChange(value!)} + options={options} + isLoading={isLoading} + inputId={inputId} + /> + + {error && } + ) : ( - - onChange(value!)} + options={options} + isLoading={isLoading} + inputId={inputId} + /> + + {error && } + ); }; + +const getStyles = (theme: GrafanaTheme2) => ({ + // width set to InlineField labelWidth + Select width + 0.5 for margin on the label + inlineFieldAlert: css({ maxWidth: theme.spacing(LABEL_WIDTH + 25 + 0.5) }), +}); diff --git a/public/app/plugins/datasource/cloudwatch/components/shared/Dimensions/FilterItem.test.tsx b/public/app/plugins/datasource/cloudwatch/components/shared/Dimensions/FilterItem.test.tsx index 64c658249c2..1ab3a166774 100644 --- a/public/app/plugins/datasource/cloudwatch/components/shared/Dimensions/FilterItem.test.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/shared/Dimensions/FilterItem.test.tsx @@ -42,12 +42,15 @@ describe('Dimensions', () => { /> ); await userEvent.click(screen.getByLabelText('Dimensions filter key')); - expect(getDimensionKeys).toHaveBeenCalledWith({ - namespace: q.namespace, - region: q.region, - metricName: q.metricName, - accountId: q.accountId, - dimensionFilters: { abc: ['xyz'] }, - }); + expect(getDimensionKeys).toHaveBeenCalledWith( + { + namespace: q.namespace, + region: q.region, + metricName: q.metricName, + accountId: q.accountId, + dimensionFilters: { abc: ['xyz'] }, + }, + false + ); }); }); diff --git a/public/app/plugins/datasource/cloudwatch/components/shared/Dimensions/FilterItem.tsx b/public/app/plugins/datasource/cloudwatch/components/shared/Dimensions/FilterItem.tsx index 7c5e65064c3..0610240aef8 100644 --- a/public/app/plugins/datasource/cloudwatch/components/shared/Dimensions/FilterItem.tsx +++ b/public/app/plugins/datasource/cloudwatch/components/shared/Dimensions/FilterItem.tsx @@ -4,10 +4,10 @@ import { useAsyncFn } from 'react-use'; import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data'; import { AccessoryButton, InputGroup } from '@grafana/experimental'; -import { Select, useStyles2 } from '@grafana/ui'; +import { Alert, Select, useStyles2 } from '@grafana/ui'; import { CloudWatchDatasource } from '../../../datasource'; -import { useDimensionKeys } from '../../../hooks'; +import { useDimensionKeys, useEnsureVariableHasSingleSelection } from '../../../hooks'; import { Dimensions, MetricStat } from '../../../types'; import { appendTemplateVariables } from '../../../utils/utils'; @@ -34,6 +34,7 @@ const excludeCurrentKey = (dimensions: Dimensions, currentKey: string | undefine export const FilterItem = ({ filter, metricStat, datasource, disableExpressions, onChange, onDelete }: Props) => { const { region, namespace, metricName, dimensions, accountId } = metricStat; + const error = useEnsureVariableHasSingleSelection(datasource, filter.key); const dimensionsExcludingCurrentKey = useMemo( () => excludeCurrentKey(dimensions ?? {}, filter.key), [dimensions, filter] @@ -76,7 +77,7 @@ export const FilterItem = ({ filter, metricStat, datasource, disableExpressions, const styles = useStyles2(getOperatorStyles); return ( -
+