CloudWatch: Factor LogGroupSelector into a separate component (#50829)

This commit is contained in:
Isabella Siu 2022-06-23 14:40:43 -04:00 committed by GitHub
parent 8ba8e1df83
commit 497310a9cb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 344 additions and 507 deletions

View File

@ -101,9 +101,6 @@ exports[`no enzyme tests`] = {
"public/app/plugins/datasource/cloudwatch/components/ConfigEditor.test.tsx:227258837": [
[0, 19, 13, "RegExp match", "2409514259"]
],
"public/app/plugins/datasource/cloudwatch/components/LogsQueryField.test.tsx:1501504663": [
[2, 19, 13, "RegExp match", "2409514259"]
],
"public/app/plugins/datasource/elasticsearch/configuration/ConfigEditor.test.tsx:3481855642": [
[0, 26, 13, "RegExp match", "2409514259"]
],
@ -3886,27 +3883,16 @@ exports[`no type assertions`] = {
"public/app/plugins/datasource/cloudwatch/components/ConfigEditor.tsx:1350761016": [
[102, 22, 34, "Do not use any type assertions.", "1692406735"]
],
"public/app/plugins/datasource/cloudwatch/components/LogsQueryEditor.tsx:1998970825": [
[53, 33, 28, "Do not use any type assertions.", "1081347704"]
"public/app/plugins/datasource/cloudwatch/components/LogsQueryEditor.tsx:3592370976": [
[52, 33, 28, "Do not use any type assertions.", "1081347704"]
],
"public/app/plugins/datasource/cloudwatch/components/LogsQueryField.test.tsx:1501504663": [
[18, 70, 34, "Do not use any type assertions.", "1535357135"],
[30, 15, 9, "Do not use any type assertions.", "3692209159"],
[51, 10, 726, "Do not use any type assertions.", "1108484993"],
[76, 15, 9, "Do not use any type assertions.", "3692209159"],
[88, 11, 46, "Do not use any type assertions.", "4131145701"],
[185, 10, 806, "Do not use any type assertions.", "1219250420"],
[209, 15, 9, "Do not use any type assertions.", "3692209159"],
[224, 31, 69, "Do not use any type assertions.", "323157160"],
[232, 27, 69, "Do not use any type assertions.", "323157160"],
[252, 15, 9, "Do not use any type assertions.", "3692209159"]
"public/app/plugins/datasource/cloudwatch/components/LogsQueryField.test.tsx:4231019726": [
[12, 70, 34, "Do not use any type assertions.", "1535357135"],
[24, 15, 9, "Do not use any type assertions.", "3692209159"]
],
"public/app/plugins/datasource/cloudwatch/components/LogsQueryField.tsx:1141751632": [
[70, 7, 39, "Do not use any type assertions.", "1215991248"],
[92, 14, 29, "Do not use any type assertions.", "115491437"],
[207, 10, 28, "Do not use any type assertions.", "1081347704"],
[250, 39, 57, "Do not use any type assertions.", "1866145996"],
[343, 22, 28, "Do not use any type assertions.", "1081347704"]
"public/app/plugins/datasource/cloudwatch/components/LogsQueryField.tsx:719311531": [
[62, 14, 29, "Do not use any type assertions.", "115491437"],
[97, 39, 57, "Do not use any type assertions.", "1866145996"]
],
"public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/MetricsQueryEditor.test.tsx:2002367401": [
[23, 27, 101, "Do not use any type assertions.", "1879489671"],
@ -3919,9 +3905,9 @@ exports[`no type assertions`] = {
[80, 20, 443, "Do not use any type assertions.", "2067842726"],
[108, 20, 472, "Do not use any type assertions.", "4092163612"]
],
"public/app/plugins/datasource/cloudwatch/components/QueryHeader.tsx:869258856": [
[41, 15, 78, "Do not use any type assertions.", "2265747900"],
[52, 13, 63, "Do not use any type assertions.", "1673299780"]
"public/app/plugins/datasource/cloudwatch/components/QueryHeader.tsx:4263462895": [
[34, 15, 78, "Do not use any type assertions.", "2265747900"],
[42, 13, 63, "Do not use any type assertions.", "1673299780"]
],
"public/app/plugins/datasource/cloudwatch/datasource.test.ts:3643150425": [
[30, 42, 53, "Do not use any type assertions.", "1293523870"],
@ -10693,20 +10679,13 @@ exports[`no explicit any`] = {
"public/app/plugins/datasource/cloudwatch/__mocks__/monarch/Monaco.ts:1596319131": [
[54, 19, 3, "Unexpected any. Specify a different type.", "193409811"]
],
"public/app/plugins/datasource/cloudwatch/components/LogsQueryField.test.tsx:1501504663": [
[18, 39, 3, "Unexpected any. Specify a different type.", "193409811"],
[18, 47, 3, "Unexpected any. Specify a different type.", "193409811"],
[30, 21, 3, "Unexpected any. Specify a different type.", "193409811"],
[66, 38, 3, "Unexpected any. Specify a different type.", "193409811"],
[74, 15, 3, "Unexpected any. Specify a different type.", "193409811"],
[76, 21, 3, "Unexpected any. Specify a different type.", "193409811"],
[207, 15, 3, "Unexpected any. Specify a different type.", "193409811"],
[209, 21, 3, "Unexpected any. Specify a different type.", "193409811"],
[252, 21, 3, "Unexpected any. Specify a different type.", "193409811"]
"public/app/plugins/datasource/cloudwatch/components/LogsQueryField.test.tsx:4231019726": [
[12, 39, 3, "Unexpected any. Specify a different type.", "193409811"],
[12, 47, 3, "Unexpected any. Specify a different type.", "193409811"],
[24, 21, 3, "Unexpected any. Specify a different type.", "193409811"]
],
"public/app/plugins/datasource/cloudwatch/components/LogsQueryField.tsx:1141751632": [
[82, 75, 3, "Unexpected any. Specify a different type.", "193409811"],
[265, 67, 3, "Unexpected any. Specify a different type.", "193409811"]
"public/app/plugins/datasource/cloudwatch/components/LogsQueryField.tsx:719311531": [
[52, 75, 3, "Unexpected any. Specify a different type.", "193409811"]
],
"public/app/plugins/datasource/cloudwatch/components/MetricsQueryEditor/Alias.tsx:4073366699": [
[6, 20, 3, "Unexpected any. Specify a different type.", "193409811"],

View File

@ -0,0 +1,147 @@
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import lodash from 'lodash'; // eslint-disable-line lodash/import-scope
import React from 'react';
import { openMenu, select } from 'react-select-event';
import { setupMockedDataSource } from '../__mocks__/CloudWatchDataSource';
import { DescribeLogGroupsRequest } from '../types';
import { LogGroupSelector, LogGroupSelectorProps } from './LogGroupSelector';
const ds = setupMockedDataSource();
describe('LogGroupSelector', () => {
const onChange = jest.fn();
const defaultProps: LogGroupSelectorProps = {
region: 'region1',
datasource: ds.datasource,
selectedLogGroups: [],
onChange,
};
beforeEach(() => {
jest.resetAllMocks();
});
it('updates upstream query log groups on region change', async () => {
ds.datasource.describeLogGroups = jest.fn().mockImplementation(async (params: DescribeLogGroupsRequest) => {
if (params.region === 'region1') {
return Promise.resolve(['log_group_1']);
} else {
return Promise.resolve(['log_group_2']);
}
});
const props = {
...defaultProps,
selectedLogGroups: ['log_group_1'],
};
const { rerender } = render(<LogGroupSelector {...props} />);
await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1));
expect(onChange).toHaveBeenLastCalledWith(['log_group_1']);
expect(await screen.findByText('log_group_1')).toBeInTheDocument();
act(() => rerender(<LogGroupSelector {...props} region="region2" />));
await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1));
expect(onChange).toHaveBeenLastCalledWith([]);
});
it('does not update upstream query log groups if saved is false', async () => {
ds.datasource.describeLogGroups = jest.fn().mockImplementation(async (params: DescribeLogGroupsRequest) => {
if (params.region === 'region1') {
return Promise.resolve(['log_group_1']);
} else {
return Promise.resolve(['log_group_2']);
}
});
const props = {
...defaultProps,
selectedLogGroups: ['log_group_1'],
};
const { rerender } = render(<LogGroupSelector {...props} />);
await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1));
expect(onChange).toHaveBeenLastCalledWith(['log_group_1']);
expect(await screen.findByText('log_group_1')).toBeInTheDocument();
act(() => rerender(<LogGroupSelector {...props} region="region2" saved={false} />));
await waitFor(() => expect(onChange).toHaveBeenCalledTimes(1));
expect(onChange).toHaveBeenLastCalledWith(['log_group_1']);
});
it('should merge results of remote log groups search with existing results', async () => {
lodash.debounce = jest.fn().mockImplementation((fn) => fn);
const allLogGroups = [
'AmazingGroup',
'AmazingGroup2',
'AmazingGroup3',
'BeautifulGroup',
'BeautifulGroup2',
'BeautifulGroup3',
'CrazyGroup',
'CrazyGroup2',
'CrazyGroup3',
'DeliciousGroup',
'DeliciousGroup2',
'DeliciousGroup3',
'VelvetGroup',
'VelvetGroup2',
'VelvetGroup3',
'WaterGroup',
'WaterGroup2',
'WaterGroup3',
];
const testLimit = 10;
ds.datasource.describeLogGroups = jest.fn().mockImplementation(async (params: DescribeLogGroupsRequest) => {
const theLogGroups = allLogGroups
.filter((logGroupName) => logGroupName.startsWith(params.logGroupNamePrefix ?? ''))
.slice(0, Math.max(params.limit ?? testLimit, testLimit));
return Promise.resolve(theLogGroups);
});
const props = {
...defaultProps,
};
render(<LogGroupSelector {...props} />);
const multiselect = await screen.findByLabelText('Log Groups');
// Adds the 3 Water groups to the 10 loaded in initially
await userEvent.type(multiselect, 'Water');
// The 3 Water groups + the create option
expect(screen.getAllByLabelText('Select option').length).toBe(4);
await userEvent.clear(multiselect);
expect(screen.getAllByLabelText('Select option').length).toBe(testLimit + 3);
// Adds the three Velvet groups to the previous 13
await userEvent.type(multiselect, 'Velv');
// The 3 Velvet groups + the create option
expect(screen.getAllByLabelText('Select option').length).toBe(4);
await userEvent.clear(multiselect);
expect(screen.getAllByLabelText('Select option').length).toBe(testLimit + 6);
});
it('should render template variables a selectable option', async () => {
lodash.debounce = jest.fn().mockImplementation((fn) => fn);
ds.datasource.describeLogGroups = jest.fn().mockResolvedValue([]);
const onChange = jest.fn();
const props = {
...defaultProps,
onChange,
};
render(<LogGroupSelector {...props} />);
const logGroupSelector = await screen.findByLabelText('Log Groups');
expect(logGroupSelector).toBeInTheDocument();
await openMenu(logGroupSelector);
const templateVariableSelector = await screen.findByText('Template Variables');
expect(templateVariableSelector).toBeInTheDocument();
userEvent.click(templateVariableSelector);
await select(await screen.findByLabelText('Select option'), 'test');
expect(onChange).toBeCalledWith(['test']);
});
});

View File

@ -0,0 +1,151 @@
import { debounce, intersection, unionBy } from 'lodash';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { SelectableValue, toOption } from '@grafana/data';
import { MultiSelect } from '@grafana/ui';
import { InputActionMeta } from '@grafana/ui/src/components/Select/types';
import { notifyApp } from 'app/core/actions';
import { createErrorNotification } from 'app/core/copy/appNotification';
import { dispatch } from 'app/store/store';
import { CloudWatchDatasource } from '../datasource';
import { appendTemplateVariables } from '../utils/utils';
const MAX_LOG_GROUPS = 20;
const MAX_VISIBLE_LOG_GROUPS = 4;
const DEBOUNCE_TIMER = 300;
export interface LogGroupSelectorProps {
region: string;
selectedLogGroups: string[];
onChange: (logGroups: string[]) => void;
datasource?: CloudWatchDatasource;
onRunQuery?: () => void;
onOpenMenu?: () => Promise<void>;
refId?: string;
width?: number | 'auto';
saved?: boolean;
}
export const LogGroupSelector: React.FC<LogGroupSelectorProps> = ({
region,
selectedLogGroups,
onChange,
datasource,
onRunQuery,
onOpenMenu,
refId,
width,
saved = true,
}) => {
const [loadingLogGroups, setLoadingLogGroups] = useState(false);
const [availableLogGroups, setAvailableLogGroups] = useState<Array<SelectableValue<string>>>([]);
const logGroupOptions = useMemo(
() => unionBy(availableLogGroups, selectedLogGroups?.map(toOption), 'value'),
[availableLogGroups, selectedLogGroups]
);
const fetchLogGroupOptions = useCallback(
async (region: string, logGroupNamePrefix?: string) => {
if (!datasource) {
return [];
}
try {
const logGroups: string[] = await datasource.describeLogGroups({
refId,
region,
logGroupNamePrefix,
});
return logGroups.map(toOption);
} catch (err) {
dispatch(notifyApp(createErrorNotification(typeof err === 'string' ? err : JSON.stringify(err))));
return [];
}
},
[datasource, refId]
);
const onLogGroupSearch = async (searchTerm: string, region: string, actionMeta: InputActionMeta) => {
if (actionMeta.action !== 'input-change' || !datasource) {
return;
}
// No need to fetch matching log groups if the search term isn't valid
// This is also useful for preventing searches when a user is typing out a log group with template vars
// See https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_LogGroup.html for the source of the pattern below
const logGroupNamePattern = /^[\.\-_/#A-Za-z0-9]+$/;
if (!logGroupNamePattern.test(searchTerm)) {
if (searchTerm !== '') {
dispatch(notifyApp(createErrorNotification('Invalid Log Group name: ' + searchTerm)));
}
return;
}
setLoadingLogGroups(true);
const matchingLogGroups = await fetchLogGroupOptions(region, searchTerm);
setAvailableLogGroups(unionBy(availableLogGroups, matchingLogGroups, 'value'));
setLoadingLogGroups(false);
};
// Reset the log group options if the datasource or region change and are saved
useEffect(() => {
async function resetLogGroups() {
// Don't call describeLogGroups if datasource or region is undefined
if (!datasource || !datasource.getActualRegion(region)) {
setAvailableLogGroups([]);
return;
}
setLoadingLogGroups(true);
return fetchLogGroupOptions(datasource.getActualRegion(region))
.then((logGroups) => {
const newSelectedLogGroups = intersection(
selectedLogGroups,
logGroups.map((l) => l.value || '')
);
onChange(newSelectedLogGroups);
setAvailableLogGroups(logGroups);
})
.finally(() => {
setLoadingLogGroups(false);
});
}
// Only reset if the current datasource is saved
saved && resetLogGroups();
// this hook shouldn't get called every time selectedLogGroups or onChange updates
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [datasource, region, saved]);
const onOpenLogGroupMenu = async () => {
if (onOpenMenu) {
await onOpenMenu();
}
};
const onLogGroupSearchDebounced = debounce(onLogGroupSearch, DEBOUNCE_TIMER);
return (
<MultiSelect
inputId="default-log-groups"
aria-label="Log Groups"
allowCustomValue
options={datasource ? appendTemplateVariables(datasource, logGroupOptions) : logGroupOptions}
value={selectedLogGroups}
onChange={(v) => onChange(v.filter(({ value }) => value).map(({ value }) => value))}
onBlur={onRunQuery}
closeMenuOnSelect={false}
isClearable
isOptionDisabled={() => selectedLogGroups.length >= MAX_LOG_GROUPS}
placeholder="Choose Log Groups"
maxVisibleValues={MAX_VISIBLE_LOG_GROUPS}
noOptionsMessage="No log groups available"
isLoading={loadingLogGroups}
onOpenMenu={onOpenLogGroupMenu}
onInputChange={(value, actionMeta) => {
onLogGroupSearchDebounced(value, region, actionMeta);
}}
width={width}
/>
);
};

View File

@ -13,7 +13,7 @@ import CloudWatchLink from './CloudWatchLink';
import { CloudWatchLogsQueryField } from './LogsQueryField';
type Props = QueryEditorProps<CloudWatchDatasource, CloudWatchQuery, CloudWatchJsonData> & {
allowCustomValue?: boolean;
query: CloudWatchLogsQuery;
};
const labelClass = css`
@ -22,7 +22,7 @@ const labelClass = css`
`;
export const CloudWatchLogsQueryEditor = memo(function CloudWatchLogsQueryEditor(props: Props) {
const { query, data, datasource, onRunQuery, onChange, exploreId, allowCustomValue = false } = props;
const { query, data, datasource, onRunQuery, onChange, exploreId } = props;
let absolute: AbsoluteTimeRange;
if (data?.request?.range?.from) {
@ -48,7 +48,6 @@ export const CloudWatchLogsQueryEditor = memo(function CloudWatchLogsQueryEditor
history={[]}
data={data}
absoluteRange={absolute}
allowCustomValue={allowCustomValue}
ExtraFieldElement={
<InlineFormLabel className={`gf-form-label--btn ${labelClass}`} width="auto" tooltip="Link to Graph in AWS">
<CloudWatchLink query={query as CloudWatchLogsQuery} panelData={data} datasource={datasource} />

View File

@ -1,16 +1,10 @@
import { render, screen, fireEvent } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { shallow } from 'enzyme';
import _, { DebouncedFunc } from 'lodash'; // eslint-disable-line lodash/import-scope
import React from 'react';
import { act } from 'react-dom/test-utils';
import { openMenu, select } from 'react-select-event';
import { SelectableValue } from '@grafana/data';
import { ExploreId } from '../../../../types';
import { setupMockedDataSource } from '../__mocks__/CloudWatchDataSource';
import { DescribeLogGroupsRequest } from '../types';
import { CloudWatchLogsQueryField } from './LogsQueryField';
@ -40,232 +34,4 @@ describe('CloudWatchLogsQueryField', () => {
});
expect(onRunQuery).toHaveBeenCalled();
});
it('updates upstream query log groups on region change', async () => {
const onChange = jest.fn();
const wrapper = shallow(
<CloudWatchLogsQueryField
history={[]}
absoluteRange={{ from: 1, to: 10 }}
exploreId={ExploreId.left}
datasource={
{
getRegions() {
return Promise.resolve([
{
label: 'region1',
value: 'region1',
text: 'region1',
},
{
label: 'region2',
value: 'region2',
text: 'region2',
},
]);
},
describeLogGroups(params: any) {
if (params.region === 'region1') {
return Promise.resolve(['log_group_1']);
} else {
return Promise.resolve(['log_group_2']);
}
},
getVariables: jest.fn().mockReturnValue([]),
} as any
}
query={{} as any}
onRunQuery={() => {}}
onChange={onChange}
/>
);
const getLogGroupSelect = () => wrapper.find({ label: 'Log Groups' }).props().inputEl;
getLogGroupSelect().props.onChange([{ value: 'log_group_1' }]);
expect(getLogGroupSelect().props.value.length).toBe(1);
expect(getLogGroupSelect().props.value[0].value).toBe('log_group_1');
// We select new region where the selected log group does not exist
await (wrapper.instance() as CloudWatchLogsQueryField).onRegionChange('region2');
// We clear the select
expect(getLogGroupSelect().props.value.length).toBe(0);
// Make sure we correctly updated the upstream state
expect(onChange).toHaveBeenLastCalledWith({ logGroupNames: [] });
});
it('should merge results of remote log groups search with existing results', async () => {
const allLogGroups = [
'AmazingGroup',
'AmazingGroup2',
'AmazingGroup3',
'BeautifulGroup',
'BeautifulGroup2',
'BeautifulGroup3',
'CrazyGroup',
'CrazyGroup2',
'CrazyGroup3',
'DeliciousGroup',
'DeliciousGroup2',
'DeliciousGroup3',
'EnjoyableGroup',
'EnjoyableGroup2',
'EnjoyableGroup3',
'FavouriteGroup',
'FavouriteGroup2',
'FavouriteGroup3',
'GorgeousGroup',
'GorgeousGroup2',
'GorgeousGroup3',
'HappyGroup',
'HappyGroup2',
'HappyGroup3',
'IncredibleGroup',
'IncredibleGroup2',
'IncredibleGroup3',
'JollyGroup',
'JollyGroup2',
'JollyGroup3',
'KoolGroup',
'KoolGroup2',
'KoolGroup3',
'LovelyGroup',
'LovelyGroup2',
'LovelyGroup3',
'MagnificentGroup',
'MagnificentGroup2',
'MagnificentGroup3',
'NiceGroup',
'NiceGroup2',
'NiceGroup3',
'OddGroup',
'OddGroup2',
'OddGroup3',
'PerfectGroup',
'PerfectGroup2',
'PerfectGroup3',
'QuietGroup',
'QuietGroup2',
'QuietGroup3',
'RestlessGroup',
'RestlessGroup2',
'RestlessGroup3',
'SurpriseGroup',
'SurpriseGroup2',
'SurpriseGroup3',
'TestingGroup',
'TestingGroup2',
'TestingGroup3',
'UmbrellaGroup',
'UmbrellaGroup2',
'UmbrellaGroup3',
'VelvetGroup',
'VelvetGroup2',
'VelvetGroup3',
'WaterGroup',
'WaterGroup2',
'WaterGroup3',
'XylophoneGroup',
'XylophoneGroup2',
'XylophoneGroup3',
'YellowGroup',
'YellowGroup2',
'YellowGroup3',
'ZebraGroup',
'ZebraGroup2',
'ZebraGroup3',
];
const onChange = jest.fn();
const wrapper = shallow<CloudWatchLogsQueryField>(
<CloudWatchLogsQueryField
history={[]}
absoluteRange={{ from: 1, to: 10 }}
exploreId={ExploreId.left}
datasource={
{
getRegions() {
return Promise.resolve([
{
label: 'region1',
value: 'region1',
text: 'region1',
},
{
label: 'region2',
value: 'region2',
text: 'region2',
},
]);
},
describeLogGroups(params: DescribeLogGroupsRequest) {
const theLogGroups = allLogGroups
.filter((logGroupName) => logGroupName.startsWith(params.logGroupNamePrefix ?? ''))
.slice(0, Math.max(params.limit ?? 50, 50));
return Promise.resolve(theLogGroups);
},
getVariables: jest.fn().mockReturnValue([]),
} as any
}
query={{} as any}
onRunQuery={() => {}}
onChange={onChange}
/>
);
const initialAvailableGroups = allLogGroups
.slice(0, 50)
.map((logGroupName) => ({ value: logGroupName, label: logGroupName }));
wrapper.setState({
availableLogGroups: initialAvailableGroups,
});
await wrapper.instance().onLogGroupSearch('Water', 'default', { action: 'input-change' });
let nextAvailableGroups = (wrapper.state('availableLogGroups') as Array<SelectableValue<string>>).map(
(logGroup) => logGroup.value
);
expect(nextAvailableGroups).toEqual(
initialAvailableGroups.map((logGroup) => logGroup.value).concat(['WaterGroup', 'WaterGroup2', 'WaterGroup3'])
);
await wrapper.instance().onLogGroupSearch('Velv', 'default', { action: 'input-change' });
nextAvailableGroups = (wrapper.state('availableLogGroups') as Array<SelectableValue<string>>).map(
(logGroup) => logGroup.value
);
expect(nextAvailableGroups).toEqual(
initialAvailableGroups
.map((logGroup) => logGroup.value)
.concat(['WaterGroup', 'WaterGroup2', 'WaterGroup3', 'VelvetGroup', 'VelvetGroup2', 'VelvetGroup3'])
);
});
it('should render template variables a selectable option', async () => {
const { datasource } = setupMockedDataSource();
const onChange = jest.fn();
render(
<CloudWatchLogsQueryField
history={[]}
absoluteRange={{ from: 1, to: 10 }}
exploreId={ExploreId.left}
datasource={datasource}
query={{} as any}
onRunQuery={() => {}}
onChange={onChange}
/>
);
const logGroupSelector = await screen.findByLabelText('Log Groups');
expect(logGroupSelector).toBeInTheDocument();
await openMenu(logGroupSelector);
const templateVariableSelector = await screen.findByText('Template Variables');
expect(templateVariableSelector).toBeInTheDocument();
userEvent.click(templateVariableSelector);
await select(await screen.findByLabelText('Select option'), 'test');
expect(await screen.findByText('test')).toBeInTheDocument();
});
});

View File

@ -1,23 +1,10 @@
import { css } from '@emotion/css';
import { debounce, intersectionBy, unionBy } from 'lodash';
import { LanguageMap, languages as prismLanguages } from 'prismjs';
import React, { ReactNode } from 'react';
import { Editor, Node, Plugin } from 'slate';
import { Node, Plugin } from 'slate';
import { AbsoluteTimeRange, QueryEditorProps, SelectableValue } from '@grafana/data';
import {
BracesPlugin,
LegacyForms,
MultiSelect,
QueryField,
SlatePrism,
TypeaheadInput,
TypeaheadOutput,
} from '@grafana/ui';
import { InputActionMeta } from '@grafana/ui/src/components/Select/types';
import { notifyApp } from 'app/core/actions';
import { createErrorNotification } from 'app/core/copy/appNotification';
import { dispatch } from 'app/store/store';
import { AbsoluteTimeRange, QueryEditorProps } from '@grafana/data';
import { BracesPlugin, LegacyForms, QueryField, SlatePrism, TypeaheadInput, TypeaheadOutput } from '@grafana/ui';
import { ExploreId } from 'app/types';
// Utils & Services
// dom also includes Element polyfills
@ -27,8 +14,8 @@ import { CloudWatchLanguageProvider } from '../language_provider';
import syntax from '../syntax';
import { CloudWatchJsonData, CloudWatchLogsQuery, CloudWatchQuery } from '../types';
import { getStatsGroups } from '../utils/query/getStatsGroups';
import { appendTemplateVariables } from '../utils/utils';
import { LogGroupSelector } from './LogGroupSelector';
import QueryHeader from './QueryHeader';
export interface CloudWatchLogsQueryFieldProps
@ -37,23 +24,14 @@ export interface CloudWatchLogsQueryFieldProps
onLabelsRefresh?: () => void;
ExtraFieldElement?: ReactNode;
exploreId: ExploreId;
allowCustomValue?: boolean;
query: CloudWatchLogsQuery;
}
const containerClass = css`
flex-grow: 1;
min-height: 35px;
`;
const rowGap = css`
gap: 3px;
`;
interface State {
selectedLogGroups: Array<SelectableValue<string>>;
availableLogGroups: Array<SelectableValue<string>>;
loadingLogGroups: boolean;
invalidLogGroups: boolean;
hint:
| {
message: string;
@ -67,14 +45,6 @@ interface State {
export class CloudWatchLogsQueryField extends React.PureComponent<CloudWatchLogsQueryFieldProps, State> {
state: State = {
selectedLogGroups:
(this.props.query as CloudWatchLogsQuery).logGroupNames?.map((logGroup) => ({
value: logGroup,
label: logGroup,
})) ?? [],
availableLogGroups: [],
invalidLogGroups: false,
loadingLogGroups: false,
hint: undefined,
};
@ -95,154 +65,31 @@ export class CloudWatchLogsQueryField extends React.PureComponent<CloudWatchLogs
];
}
fetchLogGroupOptions = async (region: string, logGroupNamePrefix?: string) => {
try {
const logGroups: string[] = await this.props.datasource.describeLogGroups({
refId: this.props.query.refId,
region,
logGroupNamePrefix,
});
return logGroups.map((logGroup) => ({
value: logGroup,
label: logGroup,
}));
} catch (err) {
let errMessage = 'unknown error';
if (typeof err !== 'string') {
try {
errMessage = JSON.stringify(err);
} catch (e) {}
} else {
errMessage = err;
}
dispatch(notifyApp(createErrorNotification(errMessage)));
return [];
}
};
onLogGroupSearch = (searchTerm: string, region: string, actionMeta: InputActionMeta) => {
if (actionMeta.action !== 'input-change') {
return Promise.resolve();
}
// No need to fetch matching log groups if the search term isn't valid
// This is also useful for preventing searches when a user is typing out a log group with template vars
// See https://docs.aws.amazon.com/AmazonCloudWatchLogs/latest/APIReference/API_LogGroup.html for the source of the pattern below
const logGroupNamePattern = /^[\.\-_/#A-Za-z0-9]+$/;
if (!logGroupNamePattern.test(searchTerm)) {
return Promise.resolve();
}
this.setState({
loadingLogGroups: true,
});
return this.fetchLogGroupOptions(region, searchTerm)
.then((matchingLogGroups) => {
this.setState((state) => ({
availableLogGroups: unionBy(state.availableLogGroups, matchingLogGroups, 'value'),
}));
})
.finally(() => {
this.setState({
loadingLogGroups: false,
});
});
};
onLogGroupSearchDebounced = debounce(this.onLogGroupSearch, 300);
componentDidMount = () => {
const { query, onChange } = this.props;
this.setState({
loadingLogGroups: true,
});
query.region &&
this.fetchLogGroupOptions(query.region).then((logGroups) => {
this.setState((state) => {
const selectedLogGroups = state.selectedLogGroups;
if (onChange) {
const nextQuery = {
...query,
logGroupNames: selectedLogGroups.map((group) => group.value!),
};
onChange(nextQuery);
}
return {
loadingLogGroups: false,
availableLogGroups: logGroups,
selectedLogGroups,
};
});
});
if (onChange) {
onChange({ ...query, logGroupNames: query.logGroupNames ?? [] });
}
};
onChangeQuery = (value: string) => {
// Send text change to parent
const { query, onChange } = this.props;
const { selectedLogGroups } = this.state;
if (onChange) {
const nextQuery = {
...query,
expression: value,
logGroupNames: selectedLogGroups?.map((logGroupName) => logGroupName.value!) ?? [],
statsGroups: getStatsGroups(value),
};
onChange(nextQuery);
}
};
setSelectedLogGroups = (selectedLogGroups: Array<SelectableValue<string>>) => {
this.setState({
selectedLogGroups,
});
const { onChange, query } = this.props;
onChange?.({
...(query as CloudWatchLogsQuery),
logGroupNames: selectedLogGroups.map((logGroupName) => logGroupName.value!) ?? [],
});
};
setCustomLogGroups = (v: string) => {
const customLogGroup: SelectableValue<string> = { value: v, label: v };
const selectedLogGroups = [...this.state.selectedLogGroups, customLogGroup];
this.setSelectedLogGroups(selectedLogGroups);
};
onRegionChange = async (v: string) => {
this.setState({
loadingLogGroups: true,
});
const logGroups = await this.fetchLogGroupOptions(v);
this.setState((state) => {
const selectedLogGroups = intersectionBy(state.selectedLogGroups, logGroups, 'value');
const { onChange, query } = this.props;
if (onChange) {
const nextQuery = {
...query,
logGroupNames: selectedLogGroups.map((group) => group.value!),
};
onChange(nextQuery);
}
return {
availableLogGroups: logGroups,
selectedLogGroups: selectedLogGroups,
loadingLogGroups: false,
};
});
};
onTypeahead = async (typeahead: TypeaheadInput): Promise<TypeaheadOutput> => {
const { datasource, query } = this.props;
const { selectedLogGroups } = this.state;
const { logGroupNames } = query;
if (!datasource.languageProvider) {
return { suggestions: [] };
@ -257,41 +104,20 @@ export class CloudWatchLogsQueryField extends React.PureComponent<CloudWatchLogs
{
history,
absoluteRange,
logGroupNames: selectedLogGroups.map((logGroup) => logGroup.value!),
logGroupNames,
region: query.region,
}
);
};
onQueryFieldClick = (_event: Event, _editor: Editor, next: () => any) => {
const { selectedLogGroups, loadingLogGroups } = this.state;
const queryFieldDisabled = loadingLogGroups || selectedLogGroups.length === 0;
if (queryFieldDisabled) {
this.setState({
invalidLogGroups: true,
});
}
next();
};
onOpenLogGroupMenu = () => {
this.setState({
invalidLogGroups: false,
});
};
render() {
const { onRunQuery, onChange, ExtraFieldElement, data, query, datasource, allowCustomValue } = this.props;
const { selectedLogGroups, availableLogGroups, loadingLogGroups, hint, invalidLogGroups } = this.state;
const { onRunQuery, onChange, ExtraFieldElement, data, query, datasource } = this.props;
const { region, refId, expression, logGroupNames } = query;
const { hint } = this.state;
const showError = data && data.error && data.error.refId === query.refId;
const cleanText = datasource.languageProvider ? datasource.languageProvider.cleanText : undefined;
const MAX_LOG_GROUPS = 20;
return (
<>
<QueryHeader
@ -300,7 +126,6 @@ export class CloudWatchLogsQueryField extends React.PureComponent<CloudWatchLogs
datasource={datasource}
onChange={onChange}
sqlCodeEditorIsDirty={false}
onRegionChange={this.onRegionChange}
/>
<div className={`gf-form gf-form--grow flex-grow-1 ${rowGap}`}>
<LegacyForms.FormField
@ -308,31 +133,15 @@ export class CloudWatchLogsQueryField extends React.PureComponent<CloudWatchLogs
labelWidth={6}
className="flex-grow-1"
inputEl={
<MultiSelect
aria-label="Log Groups"
allowCustomValue={allowCustomValue}
options={appendTemplateVariables(datasource, unionBy(availableLogGroups, selectedLogGroups, 'value'))}
value={selectedLogGroups}
onChange={(v) => {
this.setSelectedLogGroups(v);
}}
onCreateOption={(v) => {
this.setCustomLogGroups(v);
}}
onBlur={this.props.onRunQuery}
className={containerClass}
closeMenuOnSelect={false}
isClearable={true}
invalid={invalidLogGroups}
isOptionDisabled={() => selectedLogGroups.length >= MAX_LOG_GROUPS}
placeholder="Choose Log Groups"
maxVisibleValues={4}
noOptionsMessage="No log groups available"
isLoading={loadingLogGroups}
onOpenMenu={this.onOpenLogGroupMenu}
onInputChange={(value, actionMeta) => {
this.onLogGroupSearchDebounced(value, query.region, actionMeta);
<LogGroupSelector
region={region}
selectedLogGroups={logGroupNames ?? []}
datasource={datasource}
onChange={function (logGroups: string[]): void {
onChange({ ...query, logGroupNames: logGroups });
}}
onRunQuery={onRunQuery}
refId={refId}
/>
}
/>
@ -341,15 +150,14 @@ export class CloudWatchLogsQueryField extends React.PureComponent<CloudWatchLogs
<div className="gf-form gf-form--grow flex-shrink-1">
<QueryField
additionalPlugins={this.plugins}
query={(query as CloudWatchLogsQuery).expression ?? ''}
query={expression ?? ''}
onChange={this.onChangeQuery}
onClick={this.onQueryFieldClick}
onRunQuery={this.props.onRunQuery}
onTypeahead={this.onTypeahead}
cleanText={cleanText}
placeholder="Enter a CloudWatch Logs Insights query (run with Shift+Enter)"
portalOrigin="cloudwatch"
disabled={loadingLogGroups || selectedLogGroups.length === 0}
disabled={!logGroupNames || logGroupNames.length === 0}
/>
</div>
{ExtraFieldElement}

View File

@ -3,7 +3,7 @@ import React, { PureComponent } from 'react';
import { QueryEditorProps } from '@grafana/data';
import { CloudWatchDatasource } from '../datasource';
import { isCloudWatchMetricsQuery } from '../guards';
import { isCloudWatchLogsQuery, isCloudWatchMetricsQuery } from '../guards';
import { CloudWatchJsonData, CloudWatchQuery } from '../types';
import { MetricsQueryEditor } from '././MetricsQueryEditor/MetricsQueryEditor';
@ -17,11 +17,8 @@ export class PanelQueryEditor extends PureComponent<Props> {
return (
<>
{isCloudWatchMetricsQuery(query) ? (
<MetricsQueryEditor {...this.props} query={query} />
) : (
<LogsQueryEditor {...this.props} allowCustomValue />
)}
{isCloudWatchMetricsQuery(query) && <MetricsQueryEditor {...this.props} query={query} />}
{isCloudWatchLogsQuery(query) && <LogsQueryEditor {...this.props} query={query} />}
</>
);
}

View File

@ -24,14 +24,7 @@ const apiModes: Array<SelectableValue<CloudWatchQueryMode>> = [
{ label: 'CloudWatch Logs', value: 'Logs' },
];
const QueryHeader: React.FC<QueryHeaderProps> = ({
query,
sqlCodeEditorIsDirty,
datasource,
onChange,
onRunQuery,
onRegionChange,
}) => {
const QueryHeader: React.FC<QueryHeaderProps> = ({ query, sqlCodeEditorIsDirty, datasource, onChange, onRunQuery }) => {
const { queryMode, region } = query;
const [regions, regionIsLoading] = useRegions(datasource);
@ -47,9 +40,6 @@ const QueryHeader: React.FC<QueryHeaderProps> = ({
};
const onRegion = async ({ value }: SelectableValue<string>) => {
if (onRegionChange) {
await onRegionChange(value ?? 'default');
}
onChange({
...query,
region: value,