CloudWatch: add generic filter component to variable editor (#47907)

* CloudWatch: add generic filter component to variable editor

* remove multi-text-select object

* remove hidden

* andres comments

* migration between 8.5 and this

* add waitFors to tests

* more await tweaks

* actually fix tests

* use popoverContent tooltip

* fix template variable handling

* prettier fix

* fix prettier 2

* feat: make tooltip links in query variable editor clickable

* fix template stuff

Co-authored-by: Adam Simpson <adam@adamsimpson.net>
This commit is contained in:
Isabella Siu
2022-04-29 16:42:59 -04:00
committed by GitHub
parent a96510d03c
commit 74c2c2ccf0
16 changed files with 528 additions and 69 deletions

View File

@@ -0,0 +1,109 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
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]).getByDisplayValue('a, b')).toBeInTheDocument();
expect(within(filterItems[1]).getByDisplayValue('InstanceGroup')).toBeInTheDocument();
expect(within(filterItems[1]).getByDisplayValue('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} />);
await 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();
render(<MultiFilter filters={filters} onChange={onChange} />);
await userEvent.click(screen.getByLabelText('Add'));
const filterItemElement = screen.getByTestId('cloudwatch-multifilter-item');
expect(filterItemElement).toBeInTheDocument();
const keyElement = screen.getByTestId('cloudwatch-multifilter-item-key');
expect(keyElement).toBeInTheDocument();
await userEvent.type(keyElement!, 'my-key');
fireEvent.blur(keyElement!);
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();
render(<MultiFilter filters={filters} onChange={onChange} />);
const label = await screen.findByLabelText('Add');
await userEvent.click(label);
const filterItemElement = screen.getByTestId('cloudwatch-multifilter-item');
expect(filterItemElement).toBeInTheDocument();
const keyElement = screen.getByTestId('cloudwatch-multifilter-item-key');
expect(keyElement).toBeInTheDocument();
await userEvent.type(keyElement!, 'my-key');
fireEvent.blur(keyElement!);
expect(within(filterItemElement).getByDisplayValue('my-key')).toBeInTheDocument();
expect(onChange).not.toHaveBeenCalled();
const valueElement = screen.getByTestId('cloudwatch-multifilter-item-value');
expect(valueElement).toBeInTheDocument();
await userEvent.type(valueElement!, 'my-value1,my-value2');
fireEvent.blur(valueElement!);
expect(within(filterItemElement).getByDisplayValue('my-value1, my-value2')).toBeInTheDocument();
expect(onChange).toHaveBeenCalledWith({
'my-key': ['my-value1', 'my-value2'],
});
});
});
describe('when editing an existing filter item key', () => {
it('it should change the key and call onChange', async () => {
const filters = { 'my-key': ['my-value'] };
const onChange = jest.fn();
render(<MultiFilter filters={filters} onChange={onChange} />);
const filterItemElement = screen.getByTestId('cloudwatch-multifilter-item');
expect(filterItemElement).toBeInTheDocument();
expect(within(filterItemElement).getByDisplayValue('my-key')).toBeInTheDocument();
expect(within(filterItemElement).getByDisplayValue('my-value')).toBeInTheDocument();
const keyElement = screen.getByTestId('cloudwatch-multifilter-item-key');
expect(keyElement).toBeInTheDocument();
await userEvent.type(keyElement!, '2');
fireEvent.blur(keyElement!);
expect(within(filterItemElement).getByDisplayValue('my-key2')).toBeInTheDocument();
expect(onChange).toHaveBeenCalledWith({
'my-key2': ['my-value'],
});
});
});
});

View File

@@ -0,0 +1,68 @@
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;
keyPlaceholder?: string;
}
export interface MultiFilterCondition {
key?: string;
operator?: string;
value?: string[];
}
const multiFiltersToFilterConditions = (filters: MultiFilters) =>
Object.keys(filters).map((key) => ({ key, value: filters[key], operator: '=' }));
const filterConditionsToMultiFilters = (filters: MultiFilterCondition[]) => {
const res: MultiFilters = {};
filters.forEach(({ key, value }) => {
if (key && value) {
res[key] = value;
}
});
return res;
};
export const MultiFilter: React.FC<Props> = ({ filters, onChange, keyPlaceholder }) => {
const [items, setItems] = useState<MultiFilterCondition[]>([]);
useEffect(() => setItems(filters ? 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={makeRenderFilter(keyPlaceholder)} />;
};
function makeRenderFilter(keyPlaceholder?: string) {
function renderFilter(
item: MultiFilterCondition,
onChange: (item: MultiFilterCondition) => void,
onDelete: () => void
) {
return (
<MultiFilterItem
filter={item}
onChange={(item) => onChange(item)}
onDelete={onDelete}
keyPlaceholder={keyPlaceholder}
/>
);
}
return renderFilter;
}

View File

@@ -0,0 +1,67 @@
import { css, cx } from '@emotion/css';
import React, { FunctionComponent, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { AccessoryButton, InputGroup } from '@grafana/experimental';
import { Input, stylesFactory, useTheme2 } from '@grafana/ui';
import { MultiFilterCondition } from './MultiFilter';
export interface Props {
filter: MultiFilterCondition;
onChange: (value: MultiFilterCondition) => void;
onDelete: () => void;
keyPlaceholder?: string;
}
export const MultiFilterItem: FunctionComponent<Props> = ({ filter, onChange, onDelete, keyPlaceholder }) => {
const [localKey, setLocalKey] = useState(filter.key || '');
const [localValue, setLocalValue] = useState(filter.value?.join(', ') || '');
const theme = useTheme2();
const styles = getOperatorStyles(theme);
return (
<div data-testid="cloudwatch-multifilter-item">
<InputGroup>
<Input
data-testid="cloudwatch-multifilter-item-key"
aria-label="Filter key"
value={localKey}
placeholder={keyPlaceholder ?? 'key'}
onChange={(e) => setLocalKey(e.currentTarget.value)}
onBlur={() => {
if (localKey && localKey !== filter.key) {
onChange({ ...filter, key: localKey });
}
}}
/>
<span className={cx(styles.root)}>=</span>
<Input
data-testid="cloudwatch-multifilter-item-value"
aria-label="Filter value"
value={localValue}
placeholder="value1, value2,..."
onChange={(e) => setLocalValue(e.currentTarget.value)}
onBlur={() => {
const newValues = localValue.split(',').map((v) => v.trim());
if (localValue && newValues !== filter.value) {
onChange({ ...filter, value: newValues });
}
setLocalValue(newValues.join(', '));
}}
/>
<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',
}),
}));

View File

@@ -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.getByDisplayValue('Tags.blah')).toBeInTheDocument();
});
const filterItem = screen.getByTestId('cloudwatch-multifilter-item');
expect(filterItem).toBeInTheDocument();
expect(within(filterItem).getByDisplayValue('foo, bar')).toBeInTheDocument();
// set filter value
const valueElement = screen.getByTestId('cloudwatch-multifilter-item-value');
expect(valueElement).toBeInTheDocument();
await userEvent.type(valueElement!, ',baz');
fireEvent.blur(valueElement!);
expect(screen.getByDisplayValue('foo, bar, baz')).toBeInTheDocument();
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 () => {

View File

@@ -9,6 +9,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';
@@ -165,14 +166,44 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
placeholder="attribute name"
onBlur={(value: string) => onQueryChange({ ...parsedQuery, attributeName: value })}
label="Attribute Name"
interactive={true}
tooltip={
<>
{'Attribute or tag to query on. Tags should be formatted "Tags.<name>". '}
<a
href="https://grafana.com/docs/grafana/latest/datasources/aws-cloudwatch/template-queries-cloudwatch/#selecting-attributes"
target="_blank"
rel="noreferrer"
>
See the documentation for more details
</a>
</>
}
/>
<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={
<>
<a
href="https://grafana.com/docs/grafana/latest/datasources/aws-cloudwatch/template-queries-cloudwatch/#selecting-attributes"
target="_blank"
rel="noreferrer"
>
Pre-defined ec2:DescribeInstances filters/tags
</a>
{' and the values to filter on. Tags should be formatted tag:<name>.'}
</>
}
>
<MultiFilter
filters={parsedQuery.ec2Filters}
onChange={(filters) => {
onChange({ ...parsedQuery, ec2Filters: filters });
}}
keyPlaceholder="filter/tag"
/>
</InlineField>
</>
)}
{parsedQuery.queryType === VariableQueryType.ResourceArns && (
@@ -183,12 +214,15 @@ 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 });
}}
keyPlaceholder="tag"
/>
</InlineField>
</>
)}
</>

View File

@@ -1,6 +1,6 @@
import React, { FC, useState } from 'react';
import { InlineField, Input } from '@grafana/ui';
import { InlineField, Input, PopoverContent } from '@grafana/ui';
const LABEL_WIDTH = 20;
@@ -9,13 +9,21 @@ interface VariableTextFieldProps {
placeholder: string;
value: string;
label: string;
tooltip?: string;
tooltip?: PopoverContent;
interactive?: boolean;
}
export const VariableTextField: FC<VariableTextFieldProps> = ({ label, onBlur, placeholder, value, tooltip }) => {
export const VariableTextField: FC<VariableTextFieldProps> = ({
interactive,
label,
onBlur,
placeholder,
value,
tooltip,
}) => {
const [localValue, setLocalValue] = useState(value);
return (
<InlineField label={label} labelWidth={LABEL_WIDTH} tooltip={tooltip} grow>
<InlineField interactive={interactive} label={label} labelWidth={LABEL_WIDTH} tooltip={tooltip} grow>
<Input
aria-label={label}
placeholder={placeholder}