CloudWatch: add dimensions component to variable editor (#47596)

This commit is contained in:
Isabella Siu
2022-04-19 10:50:18 -04:00
committed by GitHub
parent 2d8d9bc137
commit 992c0604f9
15 changed files with 338 additions and 63 deletions

View File

@@ -1,13 +1,13 @@
import { isEqual } from 'lodash';
import React, { useMemo, useState } from 'react';
import React, { useEffect, useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { EditorList } from '@grafana/experimental';
import { CloudWatchDatasource } from '../../datasource';
import { CloudWatchMetricsQuery, Dimensions as DimensionsType } from '../../types';
import { Dimensions as DimensionsType, DimensionsQuery } from '../../types';
import { FilterItem } from './FilterItem';
export interface Props {
query: CloudWatchMetricsQuery;
query: DimensionsQuery;
onChange: (dimensions: DimensionsType) => void;
datasource: CloudWatchDatasource;
dimensionKeys: Array<SelectableValue<string>>;
@@ -43,8 +43,8 @@ const filterConditionsToDimensions = (filters: DimensionFilterCondition[]) => {
};
export const Dimensions: React.FC<Props> = ({ query, datasource, dimensionKeys, disableExpressions, onChange }) => {
const dimensionFilters = useMemo(() => dimensionsToFilterConditions(query.dimensions), [query.dimensions]);
const [items, setItems] = useState<DimensionFilterCondition[]>(dimensionFilters);
const [items, setItems] = useState<DimensionFilterCondition[]>([]);
useEffect(() => setItems(dimensionsToFilterConditions(query.dimensions)), [query.dimensions]);
const onDimensionsChange = (newItems: Array<Partial<DimensionFilterCondition>>) => {
setItems(newItems);
@@ -67,7 +67,7 @@ export const Dimensions: React.FC<Props> = ({ query, datasource, dimensionKeys,
function makeRenderFilter(
datasource: CloudWatchDatasource,
query: CloudWatchMetricsQuery,
query: DimensionsQuery,
dimensionKeys: Array<SelectableValue<string>>,
disableExpressions: boolean
) {

View File

@@ -5,12 +5,12 @@ import { GrafanaTheme2, SelectableValue, toOption } from '@grafana/data';
import { InputGroup, AccessoryButton } from '@grafana/experimental';
import { Select, stylesFactory, useTheme2 } from '@grafana/ui';
import { CloudWatchDatasource } from '../../datasource';
import { CloudWatchMetricsQuery, Dimensions } from '../../types';
import { Dimensions, DimensionsQuery } from '../../types';
import { appendTemplateVariables } from '../../utils/utils';
import { DimensionFilterCondition } from './Dimensions';
export interface Props {
query: CloudWatchMetricsQuery;
query: DimensionsQuery;
datasource: CloudWatchDatasource;
filter: DimensionFilterCondition;
dimensionKeys: Array<SelectableValue<string>>;
@@ -96,7 +96,7 @@ export const FilterItem: FunctionComponent<Props> = ({
}
}}
/>
<AccessoryButton aria-label="remove" icon="times" variant="secondary" onClick={onDelete} />
<AccessoryButton aria-label="remove" icon="times" variant="secondary" onClick={onDelete} type="button" />
</InputGroup>
</div>
);

View File

@@ -1,7 +1,7 @@
import { render, screen, waitFor } from '@testing-library/react';
import { render, screen, waitFor, within } from '@testing-library/react';
import React from 'react';
import { select } from 'react-select-event';
import { VariableQueryType } from '../../types';
import { Dimensions, VariableQueryType } from '../../types';
import { setupMockedDataSource } from '../../__mocks__/CloudWatchDataSource';
import { VariableQueryEditor, Props } from './VariableQueryEditor';
@@ -11,7 +11,6 @@ const defaultQuery = {
region: '',
metricName: '',
dimensionKey: '',
dimensionFilters: '',
ec2Filters: '',
instanceID: '',
attributeName: '',
@@ -37,26 +36,42 @@ ds.datasource.getMetrics = jest.fn().mockResolvedValue([
{ label: 'i3', value: 'i3' },
{ label: 'j3', value: 'j3' },
]);
ds.datasource.getDimensionKeys = jest.fn().mockImplementation((namespace: string, region: string) => {
if (region === 'a1') {
return Promise.resolve([
{ label: 'q4', value: 'q4' },
{ label: 'r4', value: 'r4' },
{ label: 's4', value: 's4' },
]);
}
return Promise.resolve([{ label: 't4', value: 't4' }]);
});
ds.datasource.getDimensionKeys = jest
.fn()
.mockImplementation((namespace: string, region: string, dimensionFilters?: Dimensions) => {
if (!!dimensionFilters) {
return Promise.resolve([
{ label: 's4', value: 's4' },
{ label: 'v4', value: 'v4' },
]);
}
if (region === 'a1') {
return Promise.resolve([
{ label: 'q4', value: 'q4' },
{ label: 'r4', value: 'r4' },
{ label: 's4', value: 's4' },
]);
}
return Promise.resolve([{ label: 't4', value: 't4' }]);
});
ds.datasource.getDimensionValues = jest.fn().mockResolvedValue([
{ label: 'foo', value: 'foo' },
{ label: 'bar', value: 'bar' },
]);
ds.datasource.getVariables = jest.fn().mockReturnValue([]);
const onChange = jest.fn();
const defaultProps: Props = {
onChange: jest.fn(),
onChange: onChange,
query: defaultQuery,
datasource: ds.datasource,
onRunQuery: () => {},
};
describe('VariableEditor', () => {
beforeEach(() => {
onChange.mockClear();
});
describe('and a new variable is created', () => {
it('should trigger a query using the first query type in the array', async () => {
const props = defaultProps;
@@ -100,6 +115,56 @@ describe('VariableEditor', () => {
expect(metricSelect).not.toBeInTheDocument();
});
});
it('should parse dimensionFilters correctly', async () => {
const props = defaultProps;
props.query = {
...defaultQuery,
queryType: VariableQueryType.DimensionValues,
namespace: 'z2',
region: 'a1',
metricName: 'i3',
dimensionKey: 's4',
dimensionFilters: { s4: 'foo' },
};
render(<VariableQueryEditor {...props} />);
const filterItem = screen.getByTestId('cloudwatch-dimensions-filter-item');
expect(filterItem).toBeInTheDocument();
expect(within(filterItem).getByText('s4')).toBeInTheDocument();
expect(within(filterItem).getByText('foo')).toBeInTheDocument();
// change filter key
const keySelect = screen.getByRole('combobox', { name: 'Dimensions filter key' });
// confirms getDimensionKeys was called with filter and that the element uses keysForDimensionFilter
await select(keySelect, 'v4', {
container: document.body,
});
expect(ds.datasource.getDimensionKeys).toHaveBeenCalledWith('z2', 'a1', {}, '');
expect(onChange).toHaveBeenCalledWith({
...defaultQuery,
queryType: VariableQueryType.DimensionValues,
namespace: 'z2',
region: 'a1',
metricName: 'i3',
dimensionKey: 's4',
dimensionFilters: { v4: undefined },
});
// set filter value
const valueSelect = screen.getByRole('combobox', { name: 'Dimensions filter value' });
await select(valueSelect, 'bar', {
container: document.body,
});
expect(onChange).toHaveBeenCalledWith({
...defaultQuery,
queryType: VariableQueryType.DimensionValues,
namespace: 'z2',
region: 'a1',
metricName: 'i3',
dimensionKey: 's4',
dimensionFilters: { v4: 'bar' },
});
});
});
describe('and a different region is selected', () => {
it('should clear invalid fields', async () => {
@@ -111,6 +176,7 @@ describe('VariableEditor', () => {
region: 'a1',
metricName: 'i3',
dimensionKey: 's4',
dimensionFilters: { s4: 'foo' },
};
render(<VariableQueryEditor {...props} />);
@@ -118,7 +184,6 @@ describe('VariableEditor', () => {
expect(querySelect).toBeInTheDocument();
expect(screen.queryByText('Dimension Values')).toBeInTheDocument();
const regionSelect = screen.getByRole('combobox', { name: 'Region' });
regionSelect.click();
await select(regionSelect, 'b1', {
container: document.body,
});
@@ -133,8 +198,9 @@ describe('VariableEditor', () => {
region: 'b1',
// metricName i3 exists in the new region and should not be removed
metricName: 'i3',
// dimensionKey s4 does not exist in the new region and should be removed
// dimensionKey s4 and valueDimension do not exist in the new region and should be removed
dimensionKey: '',
dimensionFilters: {},
});
});
});

View File

@@ -7,6 +7,8 @@ import { useDimensionKeys, useMetrics, useNamespaces, useRegions } from '../../h
import { CloudWatchJsonData, CloudWatchQuery, VariableQuery, VariableQueryType } from '../../types';
import { migrateVariableQuery } from '../../migrations';
import { VariableQueryField } from './VariableQueryField';
import { Dimensions } from '..';
import { InlineField } from '@grafana/ui';
export type Props = QueryEditorProps<CloudWatchDatasource, CloudWatchQuery, CloudWatchJsonData, VariableQuery>;
@@ -25,11 +27,12 @@ const queryTypes: Array<{ value: string; label: string }> = [
export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
const parsedQuery = migrateVariableQuery(query);
const { region, namespace, metricName, dimensionKey } = parsedQuery;
const { region, namespace, metricName, dimensionKey, dimensionFilters } = parsedQuery;
const [regions, regionIsLoading] = useRegions(datasource);
const namespaces = useNamespaces(datasource);
const metrics = useMetrics(datasource, region, namespace);
const dimensionKeys = useDimensionKeys(datasource, region, namespace, metricName);
const keysForDimensionFilter = useDimensionKeys(datasource, region, namespace, metricName, dimensionFilters ?? {});
const onRegionChange = async (region: string) => {
const validatedQuery = await sanitizeQuery({
@@ -48,7 +51,10 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
};
const onQueryChange = (newQuery: VariableQuery) => {
onChange({ ...newQuery, refId: 'CloudWatchVariableQueryEditor-VariableQuery' });
onChange({
...newQuery,
refId: 'CloudWatchVariableQueryEditor-VariableQuery',
});
};
// Reset dimensionValue parameters if namespace or region change
@@ -58,7 +64,6 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
await datasource.getMetrics(namespace, region).then((result: Array<SelectableValue<string>>) => {
if (!result.find((metric) => metric.value === metricName)) {
metricName = '';
dimensionFilters = '';
}
});
}
@@ -66,7 +71,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
await datasource.getDimensionKeys(namespace, region).then((result: Array<SelectableValue<string>>) => {
if (!result.find((key) => key.value === dimensionKey)) {
dimensionKey = '';
dimensionFilters = '';
dimensionFilters = {};
}
});
}
@@ -86,7 +91,6 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
VariableQueryType.DimensionKeys,
VariableQueryType.DimensionValues,
].includes(parsedQuery.queryType);
return (
<>
<VariableQueryField
@@ -94,6 +98,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
options={queryTypes}
onChange={(value: VariableQueryType) => onQueryChange({ ...parsedQuery, queryType: value })}
label="Query Type"
inputId={`variable-query-type-${query.refId}`}
/>
{hasRegionField && (
<VariableQueryField
@@ -102,6 +107,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
onChange={(value: string) => onRegionChange(value)}
label="Region"
isLoading={regionIsLoading}
inputId={`variable-query-region-${query.refId}`}
/>
)}
{hasNamespaceField && (
@@ -110,6 +116,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
options={namespaces}
onChange={(value: string) => onNamespaceChange(value)}
label="Namespace"
inputId={`variable-query-namespace-${query.refId}`}
/>
)}
{parsedQuery.queryType === VariableQueryType.DimensionValues && (
@@ -119,20 +126,26 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
options={metrics}
onChange={(value: string) => onQueryChange({ ...parsedQuery, metricName: value })}
label="Metric"
inputId={`variable-query-metric-${query.refId}`}
/>
<VariableQueryField
value={dimensionKey || null}
options={dimensionKeys}
onChange={(value: string) => onQueryChange({ ...parsedQuery, dimensionKey: value })}
label="Dimension Key"
inputId={`variable-query-dimension-key-${query.refId}`}
/>
<VariableTextField
value={query.dimensionFilters}
tooltip='A JSON object representing dimensions and the values to filter on. Ex. { "filter_name1": [ "filter_value1" ], "filter_name2": [ "*" ] }'
placeholder='{"key":["value"]}'
onBlur={(value: string) => onQueryChange({ ...parsedQuery, dimensionFilters: value })}
label="Filters"
/>
<InlineField label="Dimensions" labelWidth={20} tooltip="Dimensions to filter the returned values on">
<Dimensions
query={{ ...parsedQuery, dimensions: parsedQuery.dimensionFilters }}
onChange={(dimensions) => {
onChange({ ...parsedQuery, dimensionFilters: dimensions });
}}
dimensionKeys={keysForDimensionFilter}
disableExpressions={true}
datasource={datasource}
/>
</InlineField>
</>
)}
{parsedQuery.queryType === VariableQueryType.EBSVolumeIDs && (

View File

@@ -10,9 +10,9 @@ interface VariableQueryFieldProps<T> {
options: SelectableValue[];
value: T | null;
label: string;
inputId?: string;
allowCustomValue?: boolean;
isLoading?: boolean;
inputId?: string;
}
export const VariableQueryField = <T extends string | VariableQueryType>({
@@ -22,9 +22,10 @@ export const VariableQueryField = <T extends string | VariableQueryType>({
options,
allowCustomValue = false,
isLoading = false,
inputId = label,
}: VariableQueryFieldProps<T>) => {
return (
<InlineField label={label} labelWidth={LABEL_WIDTH} htmlFor={'inline-field'}>
<InlineField label={label} labelWidth={LABEL_WIDTH} htmlFor={inputId}>
<Select
menuShouldPortal
aria-label={label}
@@ -34,7 +35,7 @@ export const VariableQueryField = <T extends string | VariableQueryType>({
onChange={({ value }) => onChange(value!)}
options={options}
isLoading={isLoading}
inputId="inline-field"
inputId={inputId}
/>
</InlineField>
);

View File

@@ -2,7 +2,6 @@ import { InlineField, Input } from '@grafana/ui';
import React, { FC, useState } from 'react';
const LABEL_WIDTH = 20;
const TEXT_WIDTH = 100;
interface VariableTextFieldProps {
onBlur: (value: string) => void;
@@ -15,14 +14,13 @@ interface VariableTextFieldProps {
export const VariableTextField: FC<VariableTextFieldProps> = ({ label, onBlur, placeholder, value, tooltip }) => {
const [localValue, setLocalValue] = useState(value);
return (
<InlineField label={label} labelWidth={LABEL_WIDTH} tooltip={tooltip}>
<InlineField label={label} labelWidth={LABEL_WIDTH} tooltip={tooltip} grow>
<Input
aria-label={label}
placeholder={placeholder}
value={localValue}
onChange={(e) => setLocalValue(e.currentTarget.value)}
onBlur={() => onBlur(localValue)}
width={TEXT_WIDTH}
/>
</InlineField>
);

View File

@@ -1,4 +1,4 @@
export { Dimensions } from './MetricStatEditor/Dimensions';
export { Dimensions } from './Dimensions/Dimensions';
export { QueryInlineField, QueryField } from './Forms';
export { Alias } from './Alias';
export { PanelQueryEditor } from './PanelQueryEditor';