Cloudwatch: Migrate Config editor and Variable editor to new form stying under feature toggle (#77838)

This commit is contained in:
Ida Štambuk 2023-11-20 14:40:02 +01:00 committed by GitHub
parent b3bf38ad68
commit b56d7131bd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 740 additions and 428 deletions

View File

@ -5413,9 +5413,6 @@ exports[`better eslint`] = {
"public/app/plugins/datasource/cloudwatch/components/shared/LogGroups/LegacyLogGroupNamesSelection.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
"public/app/plugins/datasource/cloudwatch/components/shared/LogGroups/LogGroupsField.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"]
],
"public/app/plugins/datasource/cloudwatch/datasource.ts:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],

View File

@ -242,7 +242,7 @@
"@fingerprintjs/fingerprintjs": "^3.4.2",
"@glideapps/glide-data-grid": "^5.2.1",
"@grafana-plugins/grafana-testdata-datasource": "workspace:*",
"@grafana/aws-sdk": "0.2.0",
"@grafana/aws-sdk": "0.3.1",
"@grafana/data": "workspace:*",
"@grafana/e2e-selectors": "workspace:*",
"@grafana/experimental": "1.7.4",

View File

@ -5,6 +5,7 @@ import { Provider } from 'react-redux';
import { AwsAuthType } from '@grafana/aws-sdk';
import { PluginContextProvider, PluginMeta, PluginMetaInfo, PluginType } from '@grafana/data';
import { config } from '@grafana/runtime';
import { configureStore } from 'app/store/configureStore';
import { CloudWatchSettings, setupMockedDataSource } from '../../__mocks__/CloudWatchDataSource';
@ -128,178 +129,205 @@ describe('Render', () => {
datasource.getVariables = jest.fn().mockReturnValue([]);
});
it('it should disable access key id field when the datasource has been previously configured', async () => {
setup({
secureJsonFields: {
secretKey: true,
},
});
await waitFor(async () => expect(screen.getByPlaceholderText('Configured')).toBeDisabled());
});
const originalFormFeatureToggleValue = config.featureToggles.awsDatasourcesNewFormStyling;
it('should show credentials profile name field', async () => {
setup({
jsonData: {
authType: AwsAuthType.Credentials,
},
});
await waitFor(async () => expect(screen.getByLabelText('Credentials Profile Name')).toBeInTheDocument());
});
const cleanupFeatureToggle = () => {
config.featureToggles.awsDatasourcesNewFormStyling = originalFormFeatureToggleValue;
};
it('should show access key and secret access key fields when the datasource has not been configured before', async () => {
setup({
jsonData: {
authType: AwsAuthType.Keys,
},
});
await waitFor(async () => {
expect(screen.getByLabelText('Access Key ID')).toBeInTheDocument();
expect(screen.getByLabelText('Secret Access Key')).toBeInTheDocument();
});
});
it('should show arn role field', async () => {
setup({
jsonData: {
authType: AwsAuthType.ARN,
},
});
await waitFor(async () => expect(screen.getByLabelText('Assume Role ARN')).toBeInTheDocument());
});
it('should display log group selector field', async () => {
setup();
await waitFor(async () => expect(screen.getByText('Select log groups')).toBeInTheDocument());
});
it('should only display the first two default log groups and show all of them when clicking "Show all" button', async () => {
setup({
version: 2,
jsonData: {
logGroups: [
{ arn: 'arn:aws:logs:us-east-2:123456789012:log-group:logGroup-foo:*', name: 'logGroup-foo' },
{ arn: 'arn:aws:logs:us-east-2:123456789012:log-group:logGroup-bar:*', name: 'logGroup-bar' },
{ arn: 'arn:aws:logs:us-east-2:123456789012:log-group:logGroup-baz:*', name: 'logGroup-baz' },
],
},
});
await waitFor(async () => {
expect(await screen.getByText('logGroup-foo')).toBeInTheDocument();
expect(await screen.getByText('logGroup-bar')).toBeInTheDocument();
expect(await screen.queryByText('logGroup-baz')).not.toBeInTheDocument();
await userEvent.click(screen.getByText('Show all'));
expect(await screen.getByText('logGroup-baz')).toBeInTheDocument();
});
});
it('should load the data source if it was saved before', async () => {
const SAVED_VERSION = 2;
setup({ version: SAVED_VERSION });
await waitFor(async () => expect(loadDataSourceMock).toHaveBeenCalled());
});
it('should not load the data source if it wasnt saved before', async () => {
const SAVED_VERSION = undefined;
setup({ version: SAVED_VERSION });
await waitFor(async () => expect(loadDataSourceMock).not.toHaveBeenCalled());
});
it('should show error message if Select log group button is clicked when data source is never saved', async () => {
setup({ version: 1 });
await waitFor(() => expect(screen.getByText('Select log groups')).toBeInTheDocument());
await userEvent.click(screen.getByText('Select log groups'));
await waitFor(() =>
expect(screen.getByText('You need to save the data source before adding log groups.')).toBeInTheDocument()
);
});
it('should show error message if Select log group button is clicked when data source is saved before but have unsaved changes', async () => {
const SAVED_VERSION = 3;
const newProps = {
...props,
options: {
...props.options,
version: SAVED_VERSION,
},
};
const meta: PluginMeta = {
...newProps.options,
id: 'cloudwatch',
type: PluginType.datasource,
info: {} as PluginMetaInfo,
module: '',
baseUrl: '',
};
const { rerender } = render(
<PluginContextProvider meta={meta}>
<ConfigEditor {...newProps} />
</PluginContextProvider>
);
await waitFor(() => expect(screen.getByText('Select log groups')).toBeInTheDocument());
const rerenderProps = {
...newProps,
options: {
...newProps.options,
jsonData: {
...newProps.options.jsonData,
authType: AwsAuthType.Default,
function run() {
it('it should disable access key id field when the datasource has been previously configured', async () => {
setup({
secureJsonFields: {
secretKey: true,
},
},
};
rerender(
<PluginContextProvider meta={meta}>
<ConfigEditor {...rerenderProps} />
</PluginContextProvider>
);
await waitFor(() => expect(screen.getByText('AWS SDK Default')).toBeInTheDocument());
await userEvent.click(screen.getByText('Select log groups'));
await waitFor(() =>
expect(
screen.getByText(
'You have unsaved connection detail changes. You need to save the data source before adding log groups.'
)
).toBeInTheDocument()
);
});
});
await waitFor(async () => expect(screen.getByPlaceholderText('Configured')).toBeDisabled());
});
it('should open log group selector if Select log group button is clicked when data source has saved changes', async () => {
const newProps = {
...props,
options: {
...props.options,
version: 1,
},
};
const meta: PluginMeta = {
...newProps.options,
id: 'cloudwatch',
type: PluginType.datasource,
info: {} as PluginMetaInfo,
module: '',
baseUrl: '',
};
const { rerender } = render(
<PluginContextProvider meta={meta}>
<ConfigEditor {...newProps} />
</PluginContextProvider>
);
await waitFor(() => expect(screen.getByText('Select log groups')).toBeInTheDocument());
const rerenderProps = {
...newProps,
options: {
...newProps.options,
it('should show credentials profile name field', async () => {
setup({
jsonData: {
authType: AwsAuthType.Credentials,
},
});
await waitFor(async () => expect(screen.getByText('Credentials Profile Name')).toBeInTheDocument());
});
it('should show access key and secret access key fields when the datasource has not been configured before', async () => {
setup({
jsonData: {
authType: AwsAuthType.Keys,
},
});
await waitFor(async () => {
expect(screen.getByLabelText('Access Key ID')).toBeInTheDocument();
expect(screen.getByLabelText('Secret Access Key')).toBeInTheDocument();
});
});
it('should show arn role field', async () => {
setup({
jsonData: {
authType: AwsAuthType.ARN,
},
});
await waitFor(async () => expect(screen.getByText('Assume Role ARN')).toBeInTheDocument());
});
it('should display log group selector field', async () => {
setup();
await waitFor(async () => expect(screen.getByText('Select log groups')).toBeInTheDocument());
});
it('should only display the first two default log groups and show all of them when clicking "Show all" button', async () => {
setup({
version: 2,
},
};
rerender(
<PluginContextProvider meta={meta}>
<ConfigEditor {...rerenderProps} />
</PluginContextProvider>
);
await userEvent.click(screen.getByText('Select log groups'));
await waitFor(() => expect(screen.getByText('Log group name prefix')).toBeInTheDocument());
jsonData: {
logGroups: [
{ arn: 'arn:aws:logs:us-east-2:123456789012:log-group:logGroup-foo:*', name: 'logGroup-foo' },
{ arn: 'arn:aws:logs:us-east-2:123456789012:log-group:logGroup-bar:*', name: 'logGroup-bar' },
{ arn: 'arn:aws:logs:us-east-2:123456789012:log-group:logGroup-baz:*', name: 'logGroup-baz' },
],
},
});
await waitFor(async () => {
expect(await screen.getByText('logGroup-foo')).toBeInTheDocument();
expect(await screen.getByText('logGroup-bar')).toBeInTheDocument();
expect(await screen.queryByText('logGroup-baz')).not.toBeInTheDocument();
await userEvent.click(screen.getByText('Show all'));
expect(await screen.getByText('logGroup-baz')).toBeInTheDocument();
});
});
it('should load the data source if it was saved before', async () => {
const SAVED_VERSION = 2;
setup({ version: SAVED_VERSION });
await waitFor(async () => expect(loadDataSourceMock).toHaveBeenCalled());
});
it('should not load the data source if it wasnt saved before', async () => {
const SAVED_VERSION = undefined;
setup({ version: SAVED_VERSION });
await waitFor(async () => expect(loadDataSourceMock).not.toHaveBeenCalled());
});
it('should show error message if Select log group button is clicked when data source is never saved', async () => {
setup({ version: 1 });
await waitFor(() => expect(screen.getByText('Select log groups')).toBeInTheDocument());
await userEvent.click(screen.getByText('Select log groups'));
await waitFor(() =>
expect(screen.getByText('You need to save the data source before adding log groups.')).toBeInTheDocument()
);
});
it('should show error message if Select log group button is clicked when data source is saved before but have unsaved changes', async () => {
const SAVED_VERSION = 3;
const newProps = {
...props,
options: {
...props.options,
version: SAVED_VERSION,
},
};
const meta: PluginMeta = {
...newProps.options,
id: 'cloudwatch',
type: PluginType.datasource,
info: {} as PluginMetaInfo,
module: '',
baseUrl: '',
};
const { rerender } = render(
<PluginContextProvider meta={meta}>
<ConfigEditor {...newProps} />
</PluginContextProvider>
);
await waitFor(() => expect(screen.getByText('Select log groups')).toBeInTheDocument());
const rerenderProps = {
...newProps,
options: {
...newProps.options,
jsonData: {
...newProps.options.jsonData,
authType: AwsAuthType.Default,
},
},
};
rerender(
<PluginContextProvider meta={meta}>
<ConfigEditor {...rerenderProps} />
</PluginContextProvider>
);
await waitFor(() => expect(screen.getByText('AWS SDK Default')).toBeInTheDocument());
await userEvent.click(screen.getByText('Select log groups'));
await waitFor(() =>
expect(
screen.getByText(
'You have unsaved connection detail changes. You need to save the data source before adding log groups.'
)
).toBeInTheDocument()
);
});
it('should open log group selector if Select log group button is clicked when data source has saved changes', async () => {
const newProps = {
...props,
options: {
...props.options,
version: 1,
},
};
const meta: PluginMeta = {
...newProps.options,
id: 'cloudwatch',
type: PluginType.datasource,
info: {} as PluginMetaInfo,
module: '',
baseUrl: '',
};
const { rerender } = render(
<PluginContextProvider meta={meta}>
<ConfigEditor {...newProps} />
</PluginContextProvider>
);
await waitFor(() => expect(screen.getByText('Select log groups')).toBeInTheDocument());
const rerenderProps = {
...newProps,
options: {
...newProps.options,
version: 2,
},
};
rerender(
<PluginContextProvider meta={meta}>
<ConfigEditor {...rerenderProps} />
</PluginContextProvider>
);
await userEvent.click(screen.getByText('Select log groups'));
await waitFor(() => expect(screen.getByText('Log group name prefix')).toBeInTheDocument());
});
}
describe('QueryEditor with awsDatasourcesNewFormStyling feature toggle enabled', () => {
beforeAll(() => {
config.featureToggles.awsDatasourcesNewFormStyling = false;
});
afterAll(() => {
cleanupFeatureToggle();
});
run();
describe('QueryEditor with awsDatasourcesNewFormStyling feature toggle enabled', () => {
beforeAll(() => {
config.featureToggles.awsDatasourcesNewFormStyling = true;
});
afterAll(() => {
cleanupFeatureToggle();
});
run();
});
});
});

View File

@ -10,8 +10,9 @@ import {
DataSourceTestSucceeded,
DataSourceTestFailed,
} from '@grafana/data';
import { ConfigSection } from '@grafana/experimental';
import { getAppEvents, usePluginInteractionReporter } from '@grafana/runtime';
import { Input, InlineField, FieldProps, SecureSocksProxySettings } from '@grafana/ui';
import { Input, InlineField, FieldProps, SecureSocksProxySettings, Field, Divider } from '@grafana/ui';
import { notifyApp } from 'app/core/actions';
import { config } from 'app/core/config';
import { createWarningNotification } from 'app/core/copy/appNotification';
@ -23,6 +24,7 @@ import { SelectableResourceValue } from '../../resources/types';
import { CloudWatchJsonData, CloudWatchSecureJsonData } from '../../types';
import { LogGroupsFieldWrapper } from '../shared/LogGroups/LogGroupsField';
import { SecureSocksProxySettingsNewStyling } from './SecureSocksProxySettingsNewStyling';
import { XrayLinkConfig } from './XrayLinkConfig';
export type Props = DataSourcePluginOptionsEditorProps<CloudWatchJsonData, CloudWatchSecureJsonData>;
@ -39,6 +41,7 @@ export const ConfigEditor = (props: Props) => {
const [logGroupFieldState, setLogGroupFieldState] = useState<LogGroupFieldState>({
invalid: false,
});
const newFormStylingEnabled = config.featureToggles.awsDatasourcesNewFormStyling;
useEffect(() => setLogGroupFieldState({ invalid: false }), [props.options]);
const report = usePluginInteractionReporter();
useEffect(() => {
@ -67,7 +70,103 @@ export const ConfigEditor = (props: Props) => {
}
}, [datasource, externalId]);
return (
return newFormStylingEnabled ? (
<div className="width-30">
<ConnectionConfig
{...props}
newFormStylingEnabled={true}
loadRegions={
datasource &&
(async () => {
return datasource.resources
.getRegions()
.then((regions) =>
regions.reduce(
(acc: string[], curr: SelectableResourceValue) => (curr.value ? [...acc, curr.value] : acc),
[]
)
);
})
}
externalId={externalId}
/>
{config.secureSocksDSProxyEnabled && (
<SecureSocksProxySettingsNewStyling options={options} onOptionsChange={onOptionsChange} />
)}
<Divider />
<ConfigSection title="Cloudwatch Logs">
<Field
htmlFor="logsTimeout"
label="Query Result Timeout"
description='Grafana will poll for Cloudwatch Logs results every second until Done status is returned from AWS or timeout is exceeded, in which case Grafana will return an error. Note: For Alerting, the timeout from Grafana config file will take precedence. Must be a valid duration string, such as "30m" (default) "30s" "2000ms" etc.'
invalid={Boolean(logsTimeoutError)}
>
<Input
id="logsTimeout"
width={60}
placeholder="30m"
value={options.jsonData.logsTimeout || ''}
onChange={onUpdateDatasourceJsonDataOption(props, 'logsTimeout')}
title={'The timeout must be a valid duration string, such as "15m" "30s" "2000ms" etc.'}
/>
</Field>
<Field
label="Default Log Groups"
description="Optionally, specify default log groups for CloudWatch Logs queries."
{...logGroupFieldState}
>
{datasource ? (
<LogGroupsFieldWrapper
newFormStylingEnabled={true}
region={defaultRegion ?? ''}
datasource={datasource}
onBeforeOpen={() => {
if (saved) {
return;
}
let error = 'You need to save the data source before adding log groups.';
if (props.options.version && props.options.version > 1) {
error =
'You have unsaved connection detail changes. You need to save the data source before adding log groups.';
}
setLogGroupFieldState({
invalid: true,
error,
});
throw new Error(error);
}}
legacyLogGroupNames={defaultLogGroups}
logGroups={logGroups}
onChange={(updatedLogGroups) => {
onOptionsChange({
...props.options,
jsonData: {
...props.options.jsonData,
logGroups: updatedLogGroups,
defaultLogGroups: undefined,
},
});
}}
maxNoOfVisibleLogGroups={2}
//legacy props
legacyOnChange={(logGroups) => {
updateDatasourcePluginJsonDataOption(props, 'defaultLogGroups', logGroups);
}}
/>
) : (
<></>
)}
</Field>
</ConfigSection>
<Divider />
<XrayLinkConfig
newFormStyling={true}
onChange={(uid) => updateDatasourcePluginJsonDataOption(props, 'tracingDatasourceUid', uid)}
datasourceUid={options.jsonData.tracingDatasourceUid}
/>
</div>
) : (
<>
<ConnectionConfig
{...props}

View File

@ -0,0 +1,33 @@
import React from 'react';
import { DataSourceJsonData, DataSourcePluginOptionsEditorProps } from '@grafana/data';
import { ConfigSection } from '@grafana/experimental';
import { Field, Switch } from '@grafana/ui';
export interface Props<T extends DataSourceJsonData>
extends Pick<DataSourcePluginOptionsEditorProps<T>, 'options' | 'onOptionsChange'> {}
export interface SecureSocksProxyConfig extends DataSourceJsonData {
enableSecureSocksProxy?: boolean;
}
export function SecureSocksProxySettingsNewStyling<T extends SecureSocksProxyConfig>({
options,
onOptionsChange,
}: Props<T>): JSX.Element {
return (
<ConfigSection title="Secure Socks Proxy">
<Field label="Enabled" description="Connect to this datasource via the secure socks proxy.">
<Switch
value={options.jsonData.enableSecureSocksProxy ?? false}
onChange={(event) =>
onOptionsChange({
...options,
jsonData: { ...options.jsonData, enableSecureSocksProxy: event.currentTarget.checked },
})
}
/>
</Field>
</ConfigSection>
);
}

View File

@ -2,7 +2,8 @@ import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2, DataSourceInstanceSettings } from '@grafana/data';
import { Alert, InlineField, useStyles2 } from '@grafana/ui';
import { ConfigSection } from '@grafana/experimental';
import { Alert, Field, InlineField, useStyles2 } from '@grafana/ui';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
@ -16,16 +17,39 @@ const getStyles = (theme: GrafanaTheme2) => ({
interface Props {
datasourceUid?: string;
onChange: (uid: string) => void;
newFormStyling?: boolean;
}
const xRayDsId = 'grafana-x-ray-datasource';
export function XrayLinkConfig({ datasourceUid, onChange }: Props) {
export function XrayLinkConfig({ newFormStyling, datasourceUid, onChange }: Props) {
const hasXrayDatasource = Boolean(getDatasourceSrv().getList({ pluginId: xRayDsId }).length);
const styles = useStyles2(getStyles);
return (
return newFormStyling ? (
<ConfigSection
title="X-ray trace link"
description="Grafana will automatically create a link to a trace in X-ray data source if logs contain @xrayTraceId field"
>
{!hasXrayDatasource && (
<Alert
title={
'There is no X-ray datasource to link to. First add an X-ray data source and then link it to Cloud Watch. '
}
severity="info"
/>
)}
<Field htmlFor="data-source-picker" label="Data source" description="X-ray data source containing traces">
<DataSourcePicker
pluginId={xRayDsId}
onChange={(ds: DataSourceInstanceSettings) => onChange(ds.uid)}
current={datasourceUid}
noDefault={true}
/>
</Field>
</ConfigSection>
) : (
<>
<h3 className="page-heading">X-ray trace link</h3>

View File

@ -3,6 +3,8 @@ import userEvent from '@testing-library/user-event';
import React from 'react';
import { select } from 'react-select-event';
import { config } from '@grafana/runtime';
import { setupMockedDataSource } from '../../__mocks__/CloudWatchDataSource';
import { GetDimensionKeysRequest } from '../../resources/types';
import { VariableQueryType } from '../../types';
@ -23,6 +25,12 @@ const defaultQuery = {
const ds = setupMockedDataSource();
const originalFormFeatureToggleValue = config.featureToggles.awsDatasourcesNewFormStyling;
const cleanupFeatureToggle = () => {
config.featureToggles.awsDatasourcesNewFormStyling = originalFormFeatureToggleValue;
};
ds.datasource.resources.getRegions = jest.fn().mockResolvedValue([
{ label: 'a1', value: 'a1' },
{ label: 'b1', value: 'b1' },
@ -75,195 +83,216 @@ 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;
props.query = defaultQuery;
render(<VariableQueryEditor {...props} />);
await waitFor(() => {
const querySelect = screen.queryByRole('combobox', { name: 'Query type' });
expect(querySelect).toBeInTheDocument();
expect(screen.queryByText('Regions')).toBeInTheDocument();
// Should not render any fields besides Query Type
const regionSelect = screen.queryByRole('combobox', { name: 'Region' });
expect(regionSelect).not.toBeInTheDocument();
});
});
});
describe('and an existing variable is edited', () => {
it('should trigger new query using the saved query type', async () => {
const props = defaultProps;
props.query = {
...defaultQuery,
queryType: VariableQueryType.Metrics,
namespace: 'z2',
region: 'a1',
};
render(<VariableQueryEditor {...props} />);
await waitFor(() => {
const querySelect = screen.queryByRole('combobox', { name: 'Query type' });
expect(querySelect).toBeInTheDocument();
expect(screen.queryByText('Metrics')).toBeInTheDocument();
const regionSelect = screen.queryByRole('combobox', { name: 'Region' });
expect(regionSelect).toBeInTheDocument();
expect(screen.queryByText('a1')).toBeInTheDocument();
const namespaceSelect = screen.queryByRole('combobox', { name: 'Namespace' });
expect(namespaceSelect).toBeInTheDocument();
expect(screen.queryByText('z2')).toBeInTheDocument();
// Should only render Query Type, Region, and Namespace selectors
const metricSelect = screen.queryByRole('combobox', { name: 'Metric' });
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' },
};
await act(async () => {
function run() {
describe('and a new variable is created', () => {
it('should trigger a query using the first query type in the array', async () => {
const props = defaultProps;
props.query = defaultQuery;
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.resources.getDimensionKeys).toHaveBeenCalledWith({
namespace: 'z2',
region: 'a1',
metricName: 'i3',
dimensionFilters: undefined,
});
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' },
await waitFor(() => {
const querySelect = screen.queryByRole('combobox', { name: 'Query type' });
expect(querySelect).toBeInTheDocument();
expect(screen.queryByText('Regions')).toBeInTheDocument();
// Should not render any fields besides Query Type
const regionSelect = screen.queryByRole('combobox', { name: 'Region' });
expect(regionSelect).not.toBeInTheDocument();
});
});
});
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();
describe('and an existing variable is edited', () => {
it('should trigger new query using the saved query type', async () => {
const props = defaultProps;
props.query = {
...defaultQuery,
queryType: VariableQueryType.Metrics,
namespace: 'z2',
region: 'a1',
};
render(<VariableQueryEditor {...props} />);
await waitFor(() => {
const querySelect = screen.queryByRole('combobox', { name: 'Query type' });
expect(querySelect).toBeInTheDocument();
expect(screen.queryByText('Metrics')).toBeInTheDocument();
const regionSelect = screen.queryByRole('combobox', { name: 'Region' });
expect(regionSelect).toBeInTheDocument();
expect(screen.queryByText('a1')).toBeInTheDocument();
const namespaceSelect = screen.queryByRole('combobox', { name: 'Namespace' });
expect(namespaceSelect).toBeInTheDocument();
expect(screen.queryByText('z2')).toBeInTheDocument();
// Should only render Query Type, Region, and Namespace selectors
const metricSelect = screen.queryByRole('combobox', { name: 'Metric' });
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' },
};
await act(async () => {
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();
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 () => {
const props = defaultProps;
props.query = {
...defaultQuery,
queryType: VariableQueryType.DimensionValues,
namespace: 'z2',
region: 'a1',
metricName: 'i3',
dimensionKey: 's4',
dimensionFilters: { s4: 'foo' },
};
render(<VariableQueryEditor {...props} />);
const querySelect = screen.queryByLabelText('Query type');
expect(querySelect).toBeInTheDocument();
expect(screen.queryByText('Dimension Values')).toBeInTheDocument();
const regionSelect = screen.getByRole('combobox', { name: 'Region' });
await waitFor(() =>
select(regionSelect, 'b1', {
// 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.resources.getDimensionKeys).toHaveBeenCalledWith({
namespace: 'z2',
region: 'a1',
metricName: 'i3',
dimensionFilters: undefined,
});
expect(onChange).toHaveBeenCalledWith({
...defaultQuery,
queryType: VariableQueryType.DimensionValues,
namespace: 'z2',
region: 'a1',
metricName: 'i3',
dimensionKey: 's4',
dimensionFilters: { v4: undefined },
});
expect(ds.datasource.resources.getMetrics).toHaveBeenCalledWith({ namespace: 'z2', region: 'b1' });
expect(ds.datasource.resources.getDimensionKeys).toHaveBeenCalledWith({ namespace: 'z2', region: 'b1' });
expect(props.onChange).toHaveBeenCalledWith({
...defaultQuery,
refId: 'CloudWatchVariableQueryEditor-VariableQuery',
queryType: VariableQueryType.DimensionValues,
namespace: 'z2',
region: 'b1',
// metricName i3 exists in the new region and should not be removed
metricName: 'i3',
// dimensionKey s4 and valueDimension do not exist in the new region and should be removed
dimensionKey: '',
dimensionFilters: {},
// 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' },
});
});
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('LogGroups queryType is selected', () => {
it('should only render region and prefix', async () => {
const props = defaultProps;
props.query = {
...defaultQuery,
queryType: VariableQueryType.LogGroups,
};
render(<VariableQueryEditor {...props} />);
describe('and a different region is selected', () => {
it('should clear invalid fields', async () => {
const props = defaultProps;
props.query = {
...defaultQuery,
queryType: VariableQueryType.DimensionValues,
namespace: 'z2',
region: 'a1',
metricName: 'i3',
dimensionKey: 's4',
dimensionFilters: { s4: 'foo' },
};
render(<VariableQueryEditor {...props} />);
await waitFor(() => {
screen.getByLabelText('Log group prefix');
screen.getByLabelText('Region');
const querySelect = screen.queryByLabelText('Query type');
expect(querySelect).toBeInTheDocument();
expect(screen.queryByText('Dimension Values')).toBeInTheDocument();
const regionSelect = screen.getByRole('combobox', { name: 'Region' });
await waitFor(() =>
select(regionSelect, 'b1', {
container: document.body,
})
);
expect(ds.datasource.resources.getMetrics).toHaveBeenCalledWith({ namespace: 'z2', region: 'b1' });
expect(ds.datasource.resources.getDimensionKeys).toHaveBeenCalledWith({ namespace: 'z2', region: 'b1' });
expect(props.onChange).toHaveBeenCalledWith({
...defaultQuery,
refId: 'CloudWatchVariableQueryEditor-VariableQuery',
queryType: VariableQueryType.DimensionValues,
namespace: 'z2',
region: 'b1',
// metricName i3 exists in the new region and should not be removed
metricName: 'i3',
// dimensionKey s4 and valueDimension do not exist in the new region and should be removed
dimensionKey: '',
dimensionFilters: {},
});
});
});
describe('LogGroups queryType is selected', () => {
it('should only render region and prefix', async () => {
const props = defaultProps;
props.query = {
...defaultQuery,
queryType: VariableQueryType.LogGroups,
};
render(<VariableQueryEditor {...props} />);
expect(screen.queryByLabelText('Namespace')).not.toBeInTheDocument();
await waitFor(() => {
screen.getByLabelText('Log group prefix');
screen.getByLabelText('Region');
});
expect(screen.queryByLabelText('Namespace')).not.toBeInTheDocument();
});
});
}
describe('variable editor with awsDatasourcesNewFormStyling feature toggle enabled', () => {
beforeAll(() => {
config.featureToggles.awsDatasourcesNewFormStyling = false;
});
afterAll(() => {
cleanupFeatureToggle();
});
run();
describe('variable editor with awsDatasourcesNewFormStyling feature toggle enabled', () => {
beforeAll(() => {
config.featureToggles.awsDatasourcesNewFormStyling = true;
});
afterAll(() => {
cleanupFeatureToggle();
});
run();
});
});
});

View File

@ -1,6 +1,7 @@
import React from 'react';
import { QueryEditorProps, SelectableValue } from '@grafana/data';
import { EditorField } from '@grafana/experimental';
import { config } from '@grafana/runtime';
import { InlineField } from '@grafana/ui';
@ -44,6 +45,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
const keysForDimensionFilter = useDimensionKeys(datasource, { region, namespace, metricName, dimensionFilters });
const accountState = useAccountOptions(datasource.resources, query.region);
const newFormStylingEnabled = config.featureToggles.awsDatasourcesNewFormStyling;
const onRegionChange = async (region: string) => {
const validatedQuery = await sanitizeQuery({
...parsedQuery,
@ -122,6 +124,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
}
label="Query type"
inputId={`variable-query-type-${query.refId}`}
newFormStylingEnabled={newFormStylingEnabled}
/>
{hasRegionField && (
<VariableQueryField
@ -131,6 +134,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
label="Region"
isLoading={regionIsLoading}
inputId={`variable-query-region-${query.refId}`}
newFormStylingEnabled={newFormStylingEnabled}
/>
)}
{hasAccountIDField &&
@ -143,6 +147,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
onChange={(accountId?: string) => onQueryChange({ ...parsedQuery, accountId })}
options={[ALL_ACCOUNTS_OPTION, ...accountState?.value]}
allowCustomValue={false}
newFormStylingEnabled={newFormStylingEnabled}
/>
)}
{hasNamespaceField && (
@ -153,6 +158,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
label="Namespace"
inputId={`variable-query-namespace-${query.refId}`}
allowCustomValue
newFormStylingEnabled={newFormStylingEnabled}
/>
)}
{parsedQuery.queryType === VariableQueryType.DimensionValues && (
@ -164,6 +170,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
label="Metric"
inputId={`variable-query-metric-${query.refId}`}
allowCustomValue
newFormStylingEnabled={newFormStylingEnabled}
/>
<VariableQueryField
value={dimensionKey || null}
@ -172,18 +179,38 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
label="Dimension key"
inputId={`variable-query-dimension-key-${query.refId}`}
allowCustomValue
newFormStylingEnabled={newFormStylingEnabled}
/>
<InlineField label="Dimensions" labelWidth={20} shrink tooltip="Dimensions to filter the returned values on">
<Dimensions
metricStat={{ ...parsedQuery, dimensions: parsedQuery.dimensionFilters }}
onChange={(dimensions) => {
onChange({ ...parsedQuery, dimensionFilters: dimensions });
}}
dimensionKeys={keysForDimensionFilter}
disableExpressions={true}
datasource={datasource}
/>
</InlineField>
{newFormStylingEnabled ? (
<EditorField label="Dimensions" className="width-30" tooltip="Dimensions to filter the returned values on">
<Dimensions
metricStat={{ ...parsedQuery, dimensions: parsedQuery.dimensionFilters }}
onChange={(dimensions) => {
onChange({ ...parsedQuery, dimensionFilters: dimensions });
}}
dimensionKeys={keysForDimensionFilter}
disableExpressions={true}
datasource={datasource}
/>
</EditorField>
) : (
<InlineField
label="Dimensions"
labelWidth={20}
shrink
tooltip="Dimensions to filter the returned values on"
>
<Dimensions
metricStat={{ ...parsedQuery, dimensions: parsedQuery.dimensionFilters }}
onChange={(dimensions) => {
onChange({ ...parsedQuery, dimensionFilters: dimensions });
}}
dimensionKeys={keysForDimensionFilter}
disableExpressions={true}
datasource={datasource}
/>
</InlineField>
)}
</>
)}
{parsedQuery.queryType === VariableQueryType.EBSVolumeIDs && (
@ -192,6 +219,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
placeholder="i-XXXXXXXXXXXXXXXXX"
onBlur={(value: string) => onQueryChange({ ...parsedQuery, instanceID: value })}
label="Instance ID"
newFormStylingEnabled={newFormStylingEnabled}
/>
)}
{parsedQuery.queryType === VariableQueryType.EC2InstanceAttributes && (
@ -201,6 +229,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
onBlur={(value: string) => onQueryChange({ ...parsedQuery, attributeName: value })}
label="Attribute name"
interactive={true}
newFormStylingEnabled={newFormStylingEnabled}
tooltip={
<>
{'Attribute or tag to query on. Tags should be formatted "Tags.<name>". '}
@ -214,31 +243,58 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
</>
}
/>
<InlineField
label="Filters"
labelWidth={20}
shrink
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>
{newFormStylingEnabled ? (
<EditorField
label="Filters"
tooltipInteractive
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"
/>
</EditorField>
) : (
<InlineField
label="Filters"
labelWidth={20}
shrink
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 && (
@ -247,16 +303,29 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
value={parsedQuery.resourceType}
onBlur={(value: string) => onQueryChange({ ...parsedQuery, resourceType: value })}
label="Resource type"
newFormStylingEnabled={newFormStylingEnabled}
/>
<InlineField label="Tags" shrink labelWidth={20} tooltip="Tags to filter the returned values on.">
<MultiFilter
filters={parsedQuery.tags}
onChange={(filters) => {
onChange({ ...parsedQuery, tags: filters });
}}
keyPlaceholder="tag"
/>
</InlineField>
{newFormStylingEnabled ? (
<EditorField label="Tags" tooltip="Tags to filter the returned values on.">
<MultiFilter
filters={parsedQuery.tags}
onChange={(filters) => {
onChange({ ...parsedQuery, tags: filters });
}}
keyPlaceholder="tag"
/>
</EditorField>
) : (
<InlineField label="Tags" shrink labelWidth={20} tooltip="Tags to filter the returned values on.">
<MultiFilter
filters={parsedQuery.tags}
onChange={(filters) => {
onChange({ ...parsedQuery, tags: filters });
}}
keyPlaceholder="tag"
/>
</InlineField>
)}
</>
)}
{parsedQuery.queryType === VariableQueryType.LogGroups && (
@ -264,6 +333,7 @@ export const VariableQueryEditor = ({ query, datasource, onChange }: Props) => {
value={query.logGroupPrefix ?? ''}
onBlur={(value: string) => onQueryChange({ ...parsedQuery, logGroupPrefix: value })}
label="Log group prefix"
newFormStylingEnabled={newFormStylingEnabled}
/>
)}
</>

View File

@ -1,9 +1,11 @@
import React from 'react';
import { SelectableValue } from '@grafana/data';
import { EditorField } from '@grafana/experimental';
import { InlineField, Select } from '@grafana/ui';
import { VariableQueryType } from '../../types';
import { removeMarginBottom } from '../styles';
const LABEL_WIDTH = 20;
@ -15,6 +17,7 @@ interface VariableQueryFieldProps<T> {
inputId?: string;
allowCustomValue?: boolean;
isLoading?: boolean;
newFormStylingEnabled?: boolean;
}
export const VariableQueryField = <T extends string | VariableQueryType>({
@ -25,8 +28,21 @@ export const VariableQueryField = <T extends string | VariableQueryType>({
allowCustomValue = false,
isLoading = false,
inputId = label,
newFormStylingEnabled,
}: VariableQueryFieldProps<T>) => {
return (
return newFormStylingEnabled ? (
<EditorField label={label} htmlFor={inputId} className={removeMarginBottom}>
<Select
aria-label={label}
allowCustomValue={allowCustomValue}
value={value}
onChange={({ value }) => onChange(value!)}
options={options}
isLoading={isLoading}
inputId={inputId}
/>
</EditorField>
) : (
<InlineField label={label} labelWidth={LABEL_WIDTH} htmlFor={inputId}>
<Select
aria-label={label}

View File

@ -1,7 +1,10 @@
import React, { useState } from 'react';
import { EditorField } from '@grafana/experimental';
import { InlineField, Input, PopoverContent } from '@grafana/ui';
import { removeMarginBottom } from '../styles';
const LABEL_WIDTH = 20;
interface Props {
@ -11,11 +14,30 @@ interface Props {
placeholder?: string;
tooltip?: PopoverContent;
interactive?: boolean;
newFormStylingEnabled?: boolean;
}
export const VariableTextField = ({ interactive, label, onBlur, placeholder, value, tooltip }: Props) => {
export const VariableTextField = ({
interactive,
label,
onBlur,
placeholder,
value,
tooltip,
newFormStylingEnabled,
}: Props) => {
const [localValue, setLocalValue] = useState(value);
return (
return newFormStylingEnabled ? (
<EditorField label={label} tooltip={tooltip} tooltipInteractive={interactive} className={removeMarginBottom}>
<Input
aria-label={label}
placeholder={placeholder}
value={localValue}
onChange={(e) => setLocalValue(e.currentTarget.value)}
onBlur={() => onBlur(localValue)}
/>
</EditorField>
) : (
<InlineField interactive={interactive} label={label} labelWidth={LABEL_WIDTH} tooltip={tooltip} grow>
<Input
aria-label={label}

View File

@ -20,13 +20,22 @@ type Props = {
logGroups?: LogGroup[];
region: string;
maxNoOfVisibleLogGroups?: number;
newFormStylingEnabled?: boolean;
onBeforeOpen?: () => void;
};
const rowGap = css`
gap: 3px;
`;
const rowGap = css({
gap: 3,
});
const logGroupNewStyles = css({
display: 'flex',
flexDirection: 'column',
marginTop: 8,
'& div:first-child': {
marginBottom: 8,
},
});
// used in Config Editor and in Log Query Editor
export const LogGroupsField = ({
datasource,
@ -35,6 +44,7 @@ export const LogGroupsField = ({
logGroups,
region,
maxNoOfVisibleLogGroups,
newFormStylingEnabled,
onBeforeOpen,
}: Props) => {
const accountState = useAccountOptions(datasource?.resources, region);
@ -74,7 +84,7 @@ export const LogGroupsField = ({
}, [datasource, legacyLogGroupNames, logGroups, onChange, region, loadingLogGroupsStarted]);
return (
<div className={`gf-form gf-form--grow flex-grow-1 ${rowGap}`}>
<div className={newFormStylingEnabled ? logGroupNewStyles : `gf-form gf-form--grow flex-grow-1 ${rowGap}`}>
<LogGroupsSelector
fetchLogGroups={async (params: Partial<DescribeLogGroupsRequest>) =>
datasource?.resources.getLogGroups({ region: region, ...params }) ?? []
@ -104,6 +114,7 @@ type WrapperProps = {
region: string;
maxNoOfVisibleLogGroups?: number;
onBeforeOpen?: () => void;
newFormStylingEnabled?: boolean;
// Legacy Props, can remove once we remove support for Legacy Log Group Selector
legacyOnChange: (logGroups: string[]) => void;

View File

@ -96,5 +96,6 @@ const getStyles = (theme: GrafanaTheme2) => ({
marginLeft: theme.spacing(0.5),
}),
});
export const removeMarginBottom = css({ marginBottom: 8 });
export default getStyles;

View File

@ -2913,13 +2913,13 @@ __metadata:
languageName: node
linkType: hard
"@grafana/aws-sdk@npm:0.2.0":
version: 0.2.0
resolution: "@grafana/aws-sdk@npm:0.2.0"
"@grafana/aws-sdk@npm:0.3.1":
version: 0.3.1
resolution: "@grafana/aws-sdk@npm:0.3.1"
dependencies:
"@grafana/async-query-data": "npm:0.1.4"
"@grafana/experimental": "npm:1.1.0"
checksum: 5f79f62c37dc5b0841a38c0b9bf3d9d687e8e28c40a583399a47f7b7370dd07b0f5fd4d26ede496a453051ae385ea20e86edfdef4c55c2dceb43ab4400de634b
"@grafana/experimental": "npm:1.7.0"
checksum: 89b42fa6351b78ce9760fb07ebfad37d45aa3327858ad4e88acc1699ccc9d6541ba0232ba8331d6b0a6b61a1d6b134e19ae5e91b2e90353a97958291c15bc9ef
languageName: node
linkType: hard
@ -3096,24 +3096,6 @@ __metadata:
languageName: unknown
linkType: soft
"@grafana/experimental@npm:1.1.0":
version: 1.1.0
resolution: "@grafana/experimental@npm:1.1.0"
dependencies:
"@types/uuid": "npm:^8.3.3"
uuid: "npm:^8.3.2"
peerDependencies:
"@emotion/css": 11.1.3
"@grafana/data": ^9.2.0
"@grafana/runtime": ^9.2.0
"@grafana/ui": ^9.2.0
react: 17.0.2
react-dom: 17.0.2
react-select: ^5.2.1
checksum: 5e6b7ddf1a33d84a8bae11b241aaf6bc3a7c672457a91e92745189a17cb60cc170260e584d281ec3be72cb440766fefdcf85a73889d384b975c061872a8a910a
languageName: node
linkType: hard
"@grafana/experimental@npm:1.7.0":
version: 1.7.0
resolution: "@grafana/experimental@npm:1.7.0"
@ -17311,7 +17293,7 @@ __metadata:
"@fingerprintjs/fingerprintjs": "npm:^3.4.2"
"@glideapps/glide-data-grid": "npm:^5.2.1"
"@grafana-plugins/grafana-testdata-datasource": "workspace:*"
"@grafana/aws-sdk": "npm:0.2.0"
"@grafana/aws-sdk": "npm:0.3.1"
"@grafana/data": "workspace:*"
"@grafana/e2e-selectors": "workspace:*"
"@grafana/eslint-config": "npm:6.0.1"