mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
@@ -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'],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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',
|
||||
}),
|
||||
}));
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user