CloudWatch: add generic filter component to variable editor

This commit is contained in:
Isabella Siu
2022-04-22 17:26:23 -04:00
parent 2b2f275a08
commit 5b151894cf
14 changed files with 376 additions and 46 deletions
@@ -4,9 +4,13 @@ import { Icon } from '../Icon/Icon';
interface DropdownIndicatorProps {
isOpen: boolean;
hidden?: boolean;
}
export const DropdownIndicator: React.FC<DropdownIndicatorProps> = ({ isOpen }) => {
export const DropdownIndicator: React.FC<DropdownIndicatorProps> = ({ isOpen, hidden = false }) => {
if (hidden) {
return <></>;
}
const icon = isOpen ? 'angle-up' : 'angle-down';
return <Icon name={icon} />;
};
@@ -102,6 +102,7 @@ export function SelectBase<T>({
formatCreateLabel,
getOptionLabel,
getOptionValue,
hideIndicator,
inputValue,
invalid,
isClearable = false,
@@ -332,7 +333,7 @@ export function SelectBase<T>({
);
},
DropdownIndicator(props: any) {
return <DropdownIndicator isOpen={props.selectProps.menuIsOpen} />;
return <DropdownIndicator isOpen={props.selectProps.menuIsOpen} hidden={hideIndicator} />;
},
SingleValue(props: any) {
return <SingleValue {...props} disabled={disabled} />;
@@ -29,6 +29,7 @@ export interface SelectCommonProps<T> {
formatCreateLabel?: (input: string) => string;
getOptionLabel?: (item: SelectableValue<T>) => React.ReactNode;
getOptionValue?: (item: SelectableValue<T>) => string;
hideIndicator?: boolean;
inputValue?: string;
invalid?: boolean;
isClearable?: boolean;
@@ -0,0 +1,87 @@
import React from 'react';
import { fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MultiFilter } from './MultiFilter';
describe('MultiFilters', () => {
describe('when rendered with two existing multifilters', () => {
it('should render two filter items', async () => {
const filters = {
InstanceId: ['a', 'b'],
InstanceGroup: ['Group1'],
};
const onChange = jest.fn();
render(<MultiFilter filters={filters} onChange={onChange} />);
const filterItems = screen.getAllByTestId('cloudwatch-multifilter-item');
expect(filterItems.length).toBe(2);
expect(within(filterItems[0]).getByDisplayValue('InstanceId')).toBeInTheDocument();
expect(within(filterItems[0]).getByText('a')).toBeInTheDocument();
expect(within(filterItems[0]).getByText('b')).toBeInTheDocument();
expect(within(filterItems[1]).getByDisplayValue('InstanceGroup')).toBeInTheDocument();
expect(within(filterItems[1]).getByText('Group1')).toBeInTheDocument();
});
});
describe('when adding a new filter item', () => {
it('it should add the new item but not call onChange', async () => {
const filters = {};
const onChange = jest.fn();
render(<MultiFilter filters={filters} onChange={onChange} />);
userEvent.click(screen.getByLabelText('Add'));
expect(screen.getByTestId('cloudwatch-multifilter-item')).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
});
describe('when adding a new filter item with key', () => {
it('it should add the new item but not call onChange', async () => {
const filters = {};
const onChange = jest.fn();
const { container } = render(<MultiFilter filters={filters} onChange={onChange} />);
userEvent.click(screen.getByLabelText('Add'));
const filterItemElement = screen.getByTestId('cloudwatch-multifilter-item');
expect(filterItemElement).toBeInTheDocument();
const keyElement = container.querySelector('#cloudwatch-multifilter-item-key');
expect(keyElement).toBeInTheDocument();
userEvent.type(keyElement!, 'my-key');
fireEvent.keyDown(keyElement!, { keyCode: 13 });
expect(within(filterItemElement).getByDisplayValue('my-key')).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
});
});
describe('when adding a new filter item with key and value', () => {
it('it should add the new item and trigger onChange', async () => {
const filters = {};
const onChange = jest.fn();
const { container } = render(<MultiFilter filters={filters} onChange={onChange} />);
const label = await screen.findByLabelText('Add');
userEvent.click(label);
const filterItemElement = screen.getByTestId('cloudwatch-multifilter-item');
expect(filterItemElement).toBeInTheDocument();
const keyElement = container.querySelector('#cloudwatch-multifilter-item-key');
expect(keyElement).toBeInTheDocument();
userEvent.type(keyElement!, 'my-key');
fireEvent.keyDown(keyElement!, { keyCode: 13 });
expect(within(filterItemElement).getByDisplayValue('my-key')).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
const valueElement = container.querySelector('#cloudwatch-multifilter-item-value');
expect(valueElement).toBeInTheDocument();
userEvent.type(valueElement!, 'my-value');
fireEvent.keyDown(valueElement!, { keyCode: 13 });
expect(within(filterItemElement).getByText('my-value')).toBeInTheDocument();
expect(onChange).toHaveBeenCalledWith({
'my-key': ['my-value'],
});
});
});
});
@@ -0,0 +1,63 @@
import { isEqual } from 'lodash';
import React, { useEffect, useState } from 'react';
import { EditorList } from '@grafana/experimental';
import { MultiFilters } from '../../types';
import { MultiFilterItem } from './MultiFilterItem';
export interface Props {
filters?: MultiFilters;
onChange: (filters: MultiFilters) => void;
}
export interface MultiFilterCondition {
key?: string;
operator?: string;
value?: string[];
}
const multiFiltersToFilterConditions = (filters: MultiFilters | undefined) =>
Object.entries(filters ?? {}).reduce<MultiFilterCondition[]>((acc, [key, value]) => {
if (value && typeof value === 'object') {
const filter = {
key,
value,
operator: '=',
};
return [...acc, filter];
}
return acc;
}, []);
const filterConditionsToMultiFilters = (filters: MultiFilterCondition[]) => {
return filters.reduce<MultiFilters>((acc, { key, value }) => {
if (key && value) {
return { ...acc, [key]: value };
}
return acc;
}, {});
};
export const MultiFilter: React.FC<Props> = ({ filters, onChange }) => {
const [items, setItems] = useState<MultiFilterCondition[]>([]);
useEffect(() => setItems(multiFiltersToFilterConditions(filters)), [filters]);
const onFiltersChange = (newItems: Array<Partial<MultiFilterCondition>>) => {
setItems(newItems);
// The onChange event should only be triggered in the case there is a complete dimension object.
// So when a new key is added that does not yet have a value, it should not trigger an onChange event.
const newMultifilters = filterConditionsToMultiFilters(newItems);
if (!isEqual(newMultifilters, filters)) {
onChange(newMultifilters);
}
};
return <EditorList items={items} onChange={onFiltersChange} renderItem={renderFilter} />;
};
function renderFilter(
item: MultiFilterCondition,
onChange: (item: MultiFilterCondition) => void,
onDelete: () => void
) {
return <MultiFilterItem filter={item} onChange={(item) => onChange(item)} onDelete={onDelete} />;
}
@@ -0,0 +1,76 @@
import { css, cx } from '@emotion/css';
import { GrafanaTheme2, toOption } from '@grafana/data';
import { AccessoryButton, InputGroup } from '@grafana/experimental';
import { Input, MultiSelect, stylesFactory, useTheme2 } from '@grafana/ui';
import React, { FunctionComponent, useState } from 'react';
import { MultiFilterCondition } from './MultiFilter';
export interface Props {
filter: MultiFilterCondition;
onChange: (value: MultiFilterCondition) => void;
onDelete: () => void;
}
export const MultiFilterItem: FunctionComponent<Props> = ({ filter, onChange, onDelete }) => {
const [localKey, setLocalKey] = useState(filter.key || '');
const [inputValue, setInputValue] = useState('');
const theme = useTheme2();
const styles = getOperatorStyles(theme);
const handleInputChange = (inputValue: string) => {
setInputValue(inputValue);
};
const handleKeyDown = (keyEvent: React.KeyboardEvent) => {
if (['Tab', 'Enter'].includes(keyEvent.key)) {
onChange({ ...filter, value: filter.value ? [...filter.value, inputValue] : [inputValue] });
setInputValue('');
keyEvent.preventDefault();
}
};
return (
<div data-testid="cloudwatch-multifilter-item">
<InputGroup>
<Input
aria-label="Filter key"
id="cloudwatch-multifilter-item-key"
value={localKey}
placeholder="key"
onChange={(e) => setLocalKey(e.currentTarget.value)}
onBlur={() => {
if (localKey && localKey !== filter.key) {
onChange({ key: localKey, value: undefined });
}
}}
/>
<span className={cx(styles.root)}>=</span>
<MultiSelect
aria-label="Filter value"
value={filter.value ? filter.value.map(toOption) : []}
inputId="cloudwatch-multifilter-item-value"
inputValue={inputValue}
allowCustomValue
onChange={(items) => {
onChange({ ...filter, value: items.map((item) => item.value ?? '') });
}}
onInputChange={handleInputChange}
onKeyDown={handleKeyDown}
isOpen={false}
hideIndicator
options={filter.value ? filter.value.map(toOption) : []}
placeholder="value"
/>
<AccessoryButton aria-label="remove" icon="times" variant="secondary" onClick={onDelete} type="button" />
</InputGroup>
</div>
);
};
const getOperatorStyles = stylesFactory((theme: GrafanaTheme2) => ({
root: css({
padding: theme.spacing(0, 1),
alignSelf: 'center',
}),
}));
@@ -1,4 +1,5 @@
import { render, screen, waitFor, within } from '@testing-library/react';
import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { select } from 'react-select-event';
@@ -13,11 +14,9 @@ const defaultQuery = {
region: '',
metricName: '',
dimensionKey: '',
ec2Filters: '',
instanceID: '',
attributeName: '',
resourceType: '',
tags: '',
refId: '',
};
@@ -40,7 +39,7 @@ ds.datasource.getMetrics = jest.fn().mockResolvedValue([
]);
ds.datasource.getDimensionKeys = jest
.fn()
.mockImplementation((namespace: string, region: string, dimensionFilters?: Dimensions) => {
.mockImplementation((_namespace: string, region: string, dimensionFilters?: Dimensions) => {
if (!!dimensionFilters) {
return Promise.resolve([
{ label: 's4', value: 's4' },
@@ -61,6 +60,7 @@ ds.datasource.getDimensionValues = jest.fn().mockResolvedValue([
{ label: 'bar', value: 'bar' },
]);
ds.datasource.getVariables = jest.fn().mockReturnValue([]);
ds.datasource.getEc2InstanceAttribute = jest.fn().mockReturnValue([]);
const onChange = jest.fn();
const defaultProps: Props = {
@@ -167,6 +167,40 @@ describe('VariableEditor', () => {
dimensionFilters: { v4: 'bar' },
});
});
it('should parse multiFilters correctly', async () => {
const props = defaultProps;
props.query = {
...defaultQuery,
queryType: VariableQueryType.EC2InstanceAttributes,
region: 'a1',
attributeName: 'Tags.blah',
ec2Filters: { s4: ['foo', 'bar'] },
};
render(<VariableQueryEditor {...props} />);
await waitFor(() => {
expect(screen.getByText('Tags.blah')).toBeInTheDocument();
});
const filterItem = screen.getByTestId('cloudwatch-multifilter-item');
expect(filterItem).toBeInTheDocument();
expect(within(filterItem).getByText('foo')).toBeInTheDocument();
expect(within(filterItem).getByText('bar')).toBeInTheDocument();
// set filter value
const valueElement = filterItem.querySelector('#cloudwatch-multifilter-item-value');
expect(valueElement).toBeInTheDocument();
userEvent.type(valueElement!, 'baz');
fireEvent.keyDown(valueElement!, { keyCode: 13 });
expect(onChange).toHaveBeenCalledWith({
...defaultQuery,
queryType: VariableQueryType.EC2InstanceAttributes,
region: 'a1',
attributeName: 'Tags.blah',
ec2Filters: { s4: ['foo', 'bar', 'baz'] },
});
});
});
describe('and a different region is selected', () => {
it('should clear invalid fields', async () => {
@@ -1,6 +1,7 @@
import { union } from 'lodash';
import React from 'react';
import { QueryEditorProps, SelectableValue } from '@grafana/data';
import { QueryEditorProps, SelectableValue, toOption } from '@grafana/data';
import { InlineField } from '@grafana/ui';
import { Dimensions } from '..';
@@ -9,6 +10,7 @@ import { useDimensionKeys, useMetrics, useNamespaces, useRegions } from '../../h
import { migrateVariableQuery } from '../../migrations';
import { CloudWatchJsonData, CloudWatchQuery, VariableQuery, VariableQueryType } from '../../types';
import { MultiFilter } from './MultiFilter';
import { VariableQueryField } from './VariableQueryField';
import { VariableTextField } from './VariableTextField';
@@ -160,19 +162,29 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
)}
{parsedQuery.queryType === VariableQueryType.EC2InstanceAttributes && (
<>
<VariableTextField
<VariableQueryField
value={parsedQuery.attributeName}
placeholder="attribute name"
onBlur={(value: string) => onQueryChange({ ...parsedQuery, attributeName: value })}
options={
parsedQuery.attributeName ? union(ec2Attributes, [toOption(parsedQuery.attributeName)]) : ec2Attributes
}
onChange={(value: string) => onQueryChange({ ...parsedQuery, attributeName: value })}
label="Attribute Name"
inputId={`variable-query-attribute-name-${query.refId}`}
allowCustomValue
tooltip='Create a "Tags.<name>" value to select a tag'
/>
<VariableTextField
value={parsedQuery.ec2Filters}
tooltip='A JSON object representing dimensions/tags and the values to filter on. Ex. { "filter_name": [ "filter_value" ], "tag:name": [ "*" ] }'
placeholder='{"key":["value"]}'
onBlur={(value: string) => onQueryChange({ ...parsedQuery, ec2Filters: value })}
<InlineField
label="Filters"
/>
labelWidth={20}
tooltip='Pre-defined ec2:DescribeInstances filters and tags to filter the returned values on. Tags should be formatted "tag:<name>" '
>
<MultiFilter
filters={parsedQuery.ec2Filters}
onChange={(filters) => {
onChange({ ...parsedQuery, ec2Filters: filters });
}}
/>
</InlineField>
</>
)}
{parsedQuery.queryType === VariableQueryType.ResourceArns && (
@@ -183,14 +195,47 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
onBlur={(value: string) => onQueryChange({ ...parsedQuery, resourceType: value })}
label="Resource Type"
/>
<VariableTextField
value={parsedQuery.tags}
placeholder='{"tag":["value"]}'
onBlur={(value: string) => onQueryChange({ ...parsedQuery, tags: value })}
label="Tags"
/>
<InlineField label="Tags" labelWidth={20} tooltip="Tags to filter the returned values on.">
<MultiFilter
filters={parsedQuery.tags}
onChange={(filters) => {
onChange({ ...parsedQuery, tags: filters });
}}
/>
</InlineField>
</>
)}
</>
);
};
const ec2Attributes = [
'AmiLaunchIndex',
'Architecture',
'ClientToken',
'EbsOptimized',
'EnaSupport',
'Hypervisor',
'IamInstanceProfile',
'ImageId',
'InstanceId',
'InstanceLifecycle',
'InstanceType',
'KernelId',
'KeyName',
'LaunchTime',
'Platform',
'PrivateDnsName',
'PrivateIpAddress',
'PublicDnsName',
'PublicIpAddress',
'RamdiskId',
'RootDeviceName',
'RootDeviceType',
'SourceDestCheck',
'SpotInstanceRequestId',
'SriovNetSupport',
'SubnetId',
'VirtualizationType',
'VpcId',
].map(toOption);
@@ -15,6 +15,7 @@ interface VariableQueryFieldProps<T> {
inputId?: string;
allowCustomValue?: boolean;
isLoading?: boolean;
tooltip?: string;
}
export const VariableQueryField = <T extends string | VariableQueryType>({
@@ -25,9 +26,10 @@ export const VariableQueryField = <T extends string | VariableQueryType>({
allowCustomValue = false,
isLoading = false,
inputId = label,
tooltip,
}: VariableQueryFieldProps<T>) => {
return (
<InlineField label={label} labelWidth={LABEL_WIDTH} htmlFor={inputId}>
<InlineField label={label} labelWidth={LABEL_WIDTH} htmlFor={inputId} tooltip={tooltip}>
<Select
menuShouldPortal
aria-label={label}
@@ -227,11 +227,22 @@ describe('migration', () => {
});
describe('when resource_arns query is used', () => {
it('should parse the query', () => {
const query = migrateVariableQuery('resource_arns(us-east-1,rds:db,{"environment":["$environment"]})');
const query = migrateVariableQuery(
'resource_arns(eu-west-1,elasticloadbalancing:loadbalancer,{"elasticbeanstalk:environment-name":["myApp-dev","myApp-prod"]})'
);
expect(query.queryType).toBe(VariableQueryType.ResourceArns);
expect(query.region).toBe('eu-west-1');
expect(query.resourceType).toBe('elasticloadbalancing:loadbalancer');
expect(query.tags).toStrictEqual({ 'elasticbeanstalk:environment-name': ['myApp-dev', 'myApp-prod'] });
});
});
describe('when ec2_instance_attribute query is used', () => {
it('should parse the query', () => {
const query = migrateVariableQuery('ec2_instance_attribute(us-east-1,rds:db,{"environment":["$environment"]})');
expect(query.queryType).toBe(VariableQueryType.EC2InstanceAttributes);
expect(query.region).toBe('us-east-1');
expect(query.resourceType).toBe('rds:db');
expect(query.tags).toBe('{"environment":["$environment"]}');
expect(query.attributeName).toBe('rds:db');
expect(query.ec2Filters).toStrictEqual({ environment: ['$environment'] });
});
});
});
@@ -82,11 +82,11 @@ export function migrateVariableQuery(rawQuery: string | VariableQuery): Variable
metricName: '',
dimensionKey: '',
dimensionFilters: {},
ec2Filters: '',
ec2Filters: {},
instanceID: '',
attributeName: '',
resourceType: '',
tags: '',
tags: {},
};
if (rawQuery === '') {
return newQuery;
@@ -147,7 +147,13 @@ export function migrateVariableQuery(rawQuery: string | VariableQuery): Variable
newQuery.queryType = VariableQueryType.EC2InstanceAttributes;
newQuery.region = ec2InstanceAttributeQuery[1];
newQuery.attributeName = ec2InstanceAttributeQuery[2];
newQuery.ec2Filters = ec2InstanceAttributeQuery[3] || '';
if (!!ec2InstanceAttributeQuery[3]) {
try {
newQuery.ec2Filters = JSON.parse(ec2InstanceAttributeQuery[3]);
} catch {
throw new Error(`unable to migrate poorly formed filters: ${ec2InstanceAttributeQuery[3]}`);
}
}
return newQuery;
}
@@ -156,7 +162,13 @@ export function migrateVariableQuery(rawQuery: string | VariableQuery): Variable
newQuery.queryType = VariableQueryType.ResourceArns;
newQuery.region = resourceARNsQuery[1];
newQuery.resourceType = resourceARNsQuery[2];
newQuery.tags = resourceARNsQuery[3] || '';
if (!!resourceARNsQuery[3]) {
try {
newQuery.tags = JSON.parse(resourceARNsQuery[3]);
} catch {
throw new Error(`unable to migrate poorly formed filters: ${resourceARNsQuery[3]}`);
}
}
return newQuery;
}
@@ -5,6 +5,10 @@ export interface Dimensions {
[key: string]: string | string[];
}
export interface MultiFilters {
[key: string]: string[];
}
import {
QueryEditorArrayExpression,
QueryEditorFunctionExpression,
@@ -394,9 +398,9 @@ export interface VariableQuery extends DataQuery {
metricName: string;
dimensionKey: string;
dimensionFilters?: Dimensions;
ec2Filters: string;
ec2Filters?: MultiFilters;
instanceID: string;
attributeName: string;
resourceType: string;
tags: string;
tags?: MultiFilters;
}
@@ -8,11 +8,9 @@ const defaultQuery: VariableQuery = {
region: 'bar',
metricName: '',
dimensionKey: '',
ec2Filters: '',
instanceID: '',
attributeName: '',
resourceType: '',
tags: '',
refId: '',
};
@@ -114,7 +112,7 @@ describe('variables', () => {
...defaultQuery,
queryType: VariableQueryType.EC2InstanceAttributes,
attributeName: 'abc',
ec2Filters: '{"a":["b"]}',
ec2Filters: { a: ['b'] },
};
beforeEach(() => {
ds.datasource.getEc2InstanceAttribute = getEc2InstanceAttribute;
@@ -139,7 +137,7 @@ describe('variables', () => {
...defaultQuery,
queryType: VariableQueryType.ResourceArns,
resourceType: 'abc',
tags: '{"a":["b"]}',
tags: { a: ['b'] },
};
beforeEach(() => {
ds.datasource.getResourceARNs = getResourceARNs;
@@ -122,11 +122,7 @@ export class CloudWatchVariableSupport extends CustomVariableSupport<CloudWatchD
if (!attributeName) {
return [];
}
let filterJson = {};
if (ec2Filters) {
filterJson = JSON.parse(ec2Filters);
}
const values = await this.datasource.getEc2InstanceAttribute(region, attributeName, filterJson);
const values = await this.datasource.getEc2InstanceAttribute(region, attributeName, ec2Filters ?? {});
return values.map((s: { label: string; value: string }) => ({
text: s.label,
value: s.value,
@@ -138,11 +134,7 @@ export class CloudWatchVariableSupport extends CustomVariableSupport<CloudWatchD
if (!resourceType) {
return [];
}
let tagJson = {};
if (tags) {
tagJson = JSON.parse(tags);
}
const keys = await this.datasource.getResourceARNs(region, resourceType, tagJson);
const keys = await this.datasource.getResourceARNs(region, resourceType, tags ?? {});
return keys.map((s: { label: string; value: string }) => ({
text: s.label,
value: s.value,