Azure Monitor: Adapt Advanced component to multiple resources (#61981)

pull/62002/head
Andres Martinez Gotor 3 years ago committed by GitHub
parent 12a4a83c77
commit 3e73ba5460
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 50
      public/app/plugins/datasource/grafana-azure-monitor-datasource/components/LogsQueryEditor/AdvancedResourcePicker.test.tsx
  2. 97
      public/app/plugins/datasource/grafana-azure-monitor-datasource/components/LogsQueryEditor/AdvancedResourcePicker.tsx
  3. 7
      public/app/plugins/datasource/grafana-azure-monitor-datasource/components/LogsQueryEditor/LogsQueryEditor.tsx
  4. 104
      public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/AdvancedResourcePicker.test.tsx
  5. 163
      public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/AdvancedResourcePicker.tsx
  6. 9
      public/app/plugins/datasource/grafana-azure-monitor-datasource/components/MetricsQueryEditor/MetricsQueryEditor.tsx
  7. 3
      public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourceField/ResourceField.tsx
  8. 16
      public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/AdvancedMulti.test.tsx
  9. 33
      public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/AdvancedMulti.tsx
  10. 97
      public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/ResourcePicker.test.tsx
  11. 36
      public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/ResourcePicker.tsx
  12. 29
      public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/utils.test.ts
  13. 10
      public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/utils.ts
  14. 3
      public/app/plugins/datasource/grafana-azure-monitor-datasource/e2e/selectors.ts

@ -0,0 +1,50 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import AdvancedResourcePicker from './AdvancedResourcePicker';
describe('AdvancedResourcePicker', () => {
it('should set a parameter as an object', async () => {
const onChange = jest.fn();
const { rerender } = render(<AdvancedResourcePicker onChange={onChange} resources={['']} />);
const subsInput = await screen.findByTestId('input-advanced-resource-picker-1');
await userEvent.type(subsInput, 'd');
expect(onChange).toHaveBeenCalledWith(['d']);
rerender(<AdvancedResourcePicker onChange={onChange} resources={['/subscriptions/def-123']} />);
expect(screen.getByDisplayValue('/subscriptions/def-123')).toBeInTheDocument();
});
it('should initialize with an empty resource', () => {
const onChange = jest.fn();
render(<AdvancedResourcePicker onChange={onChange} resources={[]} />);
expect(onChange).toHaveBeenCalledWith(['']);
});
it('should add a resource', async () => {
const onChange = jest.fn();
render(<AdvancedResourcePicker onChange={onChange} resources={['/subscriptions/def-123']} />);
const addButton = await screen.findByText('Add resource URI');
addButton.click();
expect(onChange).toHaveBeenCalledWith(['/subscriptions/def-123', '']);
});
it('should remove a resource', async () => {
const onChange = jest.fn();
render(<AdvancedResourcePicker onChange={onChange} resources={['/subscriptions/def-123']} />);
const removeButton = await screen.findByTestId('remove-resource');
removeButton.click();
expect(onChange).toHaveBeenCalledWith([]);
});
it('should render multiple resources', async () => {
render(
<AdvancedResourcePicker onChange={jest.fn()} resources={['/subscriptions/def-123', '/subscriptions/def-456']} />
);
expect(screen.getByDisplayValue('/subscriptions/def-123')).toBeInTheDocument();
expect(screen.getByDisplayValue('/subscriptions/def-456')).toBeInTheDocument();
});
});

@ -0,0 +1,97 @@
import { css } from '@emotion/css';
import React, { useEffect } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { AccessoryButton } from '@grafana/experimental';
import { Icon, Input, Tooltip, Label, Button, useStyles2 } from '@grafana/ui';
export interface ResourcePickerProps<T> {
resources: T[];
onChange: (resources: T[]) => void;
}
const getStyles = (theme: GrafanaTheme2) => ({
resourceList: css({ width: '100%', display: 'flex', marginBlock: theme.spacing(1) }),
});
const AdvancedResourcePicker = ({ resources, onChange }: ResourcePickerProps<string>) => {
const styles = useStyles2(getStyles);
useEffect(() => {
// Ensure there is at least one resource
if (resources.length === 0) {
onChange(['']);
}
}, [resources, onChange]);
const onResourceChange = (index: number, resource: string) => {
const newResources = [...resources];
newResources[index] = resource;
onChange(newResources);
};
const removeResource = (index: number) => {
const newResources = [...resources];
newResources.splice(index, 1);
onChange(newResources);
};
const addResource = () => {
onChange(resources.concat(''));
};
return (
<>
<Label>
<h6>
Resource URI(s){' '}
<Tooltip
content={
<>
Manually edit the{' '}
<a
href="https://docs.microsoft.com/en-us/azure/azure-monitor/logs/log-standard-columns#_resourceid"
rel="noopener noreferrer"
target="_blank"
>
resource uri
</a>
. Supports the use of multiple template variables (ex: /subscriptions/$subId/resourceGroups/$rg)
</>
}
placement="right"
interactive={true}
>
<Icon name="info-circle" />
</Tooltip>
</h6>
</Label>
{resources.map((resource, index) => (
<div key={`resource-${index + 1}`}>
<div className={styles.resourceList}>
<Input
id={`input-advanced-resource-picker-${index + 1}`}
value={resource}
onChange={(event) => onResourceChange(index, event.currentTarget.value)}
placeholder="ex: /subscriptions/$subId"
data-testid={`input-advanced-resource-picker-${index + 1}`}
/>
<AccessoryButton
aria-label="remove"
icon="times"
variant="secondary"
onClick={() => removeResource(index)}
data-testid={`remove-resource`}
hidden={resources.length === 1}
/>
</div>
</div>
))}
<Button aria-label="Add" icon="plus" variant="secondary" onClick={addResource} type="button">
Add resource URI
</Button>
</>
);
};
export default AdvancedResourcePicker;

@ -10,6 +10,7 @@ import ResourceField from '../ResourceField';
import { ResourceRow, ResourceRowGroup, ResourceRowType } from '../ResourcePicker/types';
import { parseResourceDetails } from '../ResourcePicker/utils';
import AdvancedResourcePicker from './AdvancedResourcePicker';
import FormatAsField from './FormatAsField';
import QueryField from './QueryField';
import useMigrations from './useMigrations';
@ -75,6 +76,12 @@ const LogsQueryEditor: React.FC<LogsQueryEditorProps> = ({
resources={query.azureLogAnalytics?.resources ?? []}
queryType="logs"
disableRow={disableRow}
renderAdvanced={(resources, onChange) => (
// It's required to cast resources because the resource picker
// specifies the type to string | AzureMetricResource.
// eslint-disable-next-line
<AdvancedResourcePicker resources={resources as string[]} onChange={onChange} />
)}
/>
</EditorFieldGroup>
</EditorRow>

@ -0,0 +1,104 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import AdvancedResourcePicker from './AdvancedResourcePicker';
describe('AdvancedResourcePicker', () => {
it('should set a parameter as an object', async () => {
const onChange = jest.fn();
const { rerender } = render(<AdvancedResourcePicker onChange={onChange} resources={[{}]} />);
const subsInput = await screen.findByLabelText('Subscription');
await userEvent.type(subsInput, 'd');
expect(onChange).toHaveBeenCalledWith([{ subscription: 'd' }]);
rerender(<AdvancedResourcePicker onChange={onChange} resources={[{ subscription: 'def-123' }]} />);
expect(screen.getByLabelText('Subscription').outerHTML).toMatch('value="def-123"');
});
it('should initialize with an empty resource', () => {
const onChange = jest.fn();
render(<AdvancedResourcePicker onChange={onChange} resources={[]} />);
expect(onChange).toHaveBeenCalledWith([{}]);
});
it('should add a resource', async () => {
const onChange = jest.fn();
render(<AdvancedResourcePicker onChange={onChange} resources={[{ subscription: 'def-123' }]} />);
const addButton = await screen.findByText('Add resource');
addButton.click();
expect(onChange).toHaveBeenCalledWith([
{ subscription: 'def-123' },
{ subscription: 'def-123', resourceGroup: '', resourceName: '' },
]);
});
it('should remove a resource', async () => {
const onChange = jest.fn();
render(<AdvancedResourcePicker onChange={onChange} resources={[{ subscription: 'def-123' }]} />);
const removeButton = await screen.findByTestId('remove-resource');
removeButton.click();
expect(onChange).toHaveBeenCalledWith([]);
});
it('should update all resources when editing the subscription', async () => {
const onChange = jest.fn();
render(
<AdvancedResourcePicker
onChange={onChange}
resources={[{ subscription: 'def-123' }, { subscription: 'def-123' }]}
/>
);
const subsInput = await screen.findByLabelText('Subscription');
await userEvent.type(subsInput, 'd');
expect(onChange).toHaveBeenCalledWith([{ subscription: 'def-123d' }, { subscription: 'def-123d' }]);
});
it('should update all resources when editing the namespace', async () => {
const onChange = jest.fn();
render(
<AdvancedResourcePicker onChange={onChange} resources={[{ metricNamespace: 'aa' }, { metricNamespace: 'aa' }]} />
);
const subsInput = await screen.findByLabelText('Namespace');
await userEvent.type(subsInput, 'b');
expect(onChange).toHaveBeenCalledWith([{ metricNamespace: 'aab' }, { metricNamespace: 'aab' }]);
});
it('should update all resources when editing the region', async () => {
const onChange = jest.fn();
render(<AdvancedResourcePicker onChange={onChange} resources={[{ region: 'aa' }, { region: 'aa' }]} />);
const subsInput = await screen.findByLabelText('Region');
await userEvent.type(subsInput, 'b');
expect(onChange).toHaveBeenCalledWith([{ region: 'aab' }, { region: 'aab' }]);
});
it('should render multiple resources', async () => {
render(
<AdvancedResourcePicker
onChange={jest.fn()}
resources={[
{
subscription: 'sub1',
metricNamespace: 'ns1',
resourceGroup: 'rg1',
resourceName: 'res1',
},
{
subscription: 'sub1',
metricNamespace: 'ns1',
resourceGroup: 'rg2',
resourceName: 'res2',
},
]}
/>
);
expect(screen.getByDisplayValue('sub1')).toBeInTheDocument();
expect(screen.getByDisplayValue('ns1')).toBeInTheDocument();
expect(screen.getByDisplayValue('rg1')).toBeInTheDocument();
expect(screen.getByDisplayValue('res1')).toBeInTheDocument();
expect(screen.getByDisplayValue('rg2')).toBeInTheDocument();
expect(screen.getByDisplayValue('res2')).toBeInTheDocument();
});
});

@ -0,0 +1,163 @@
import { css } from '@emotion/css';
import React, { useEffect } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { AccessoryButton } from '@grafana/experimental';
import { Input, Label, InlineField, Button, useStyles2 } from '@grafana/ui';
import { selectors } from '../../e2e/selectors';
import { AzureMetricResource } from '../../types';
export interface ResourcePickerProps<T> {
resources: T[];
onChange: (resources: T[]) => void;
}
const getStyles = (theme: GrafanaTheme2) => ({
resourceList: css({ display: 'flex', columnGap: theme.spacing(1), flexWrap: 'wrap', marginBottom: theme.spacing(1) }),
resource: css({ flex: '0 0 auto' }),
resourceLabel: css({ padding: theme.spacing(1) }),
resourceGroupAndName: css({ display: 'flex', columnGap: theme.spacing(0.5) }),
});
const AdvancedResourcePicker = ({ resources, onChange }: ResourcePickerProps<AzureMetricResource>) => {
const styles = useStyles2(getStyles);
useEffect(() => {
// Ensure there is at least one resource
if (resources.length === 0) {
onChange([{}]);
}
}, [resources, onChange]);
const onResourceChange = (index: number, resource: AzureMetricResource) => {
const newResources = [...resources];
newResources[index] = resource;
onChange(newResources);
};
const removeResource = (index: number) => {
const newResources = [...resources];
newResources.splice(index, 1);
onChange(newResources);
};
const addResource = () => {
onChange(
resources.concat({
subscription: resources[0]?.subscription,
metricNamespace: resources[0]?.metricNamespace,
resourceGroup: '',
resourceName: '',
})
);
};
const onCommonPropChange = (r: Partial<AzureMetricResource>) => {
onChange(resources.map((resource) => ({ ...resource, ...r })));
};
return (
<>
<InlineField
label="Subscription"
grow
transparent
htmlFor={`input-advanced-resource-picker-subscription`}
labelWidth={15}
data-testid={selectors.components.queryEditor.resourcePicker.advanced.subscription.input}
>
<Input
id={`input-advanced-resource-picker-subscription`}
value={resources[0]?.subscription ?? ''}
onChange={(event) => onCommonPropChange({ subscription: event.currentTarget.value })}
placeholder="aaaaaaaa-bbbb-cccc-dddd-eeeeeeee"
/>
</InlineField>
<InlineField
label="Namespace"
grow
transparent
htmlFor={`input-advanced-resource-picker-metricNamespace`}
labelWidth={15}
data-testid={selectors.components.queryEditor.resourcePicker.advanced.namespace.input}
>
<Input
id={`input-advanced-resource-picker-metricNamespace`}
value={resources[0]?.metricNamespace ?? ''}
onChange={(event) => onCommonPropChange({ metricNamespace: event.currentTarget.value })}
placeholder="Microsoft.Insights/metricNamespaces"
/>
</InlineField>
<InlineField
label="Region"
grow
transparent
htmlFor={`input-advanced-resource-picker-region`}
labelWidth={15}
data-testid={selectors.components.queryEditor.resourcePicker.advanced.region.input}
tooltip="The code region of the resource. Optional for one resource but mandatory when selecting multiple ones."
>
<Input
id={`input-advanced-resource-picker-region`}
value={resources[0]?.region ?? ''}
onChange={(event) => onCommonPropChange({ region: event.currentTarget.value })}
placeholder="northeurope"
/>
</InlineField>
<div className={styles.resourceList}>
{resources.map((resource, index) => (
<div key={`resource-${index + 1}`} className={styles.resource}>
{resources.length !== 1 && <Label className={styles.resourceLabel}>Resource {index + 1}</Label>}
<InlineField
label="Resource Group"
transparent
htmlFor={`input-advanced-resource-picker-resourceGroup-${index + 1}`}
labelWidth={15}
data-testid={selectors.components.queryEditor.resourcePicker.advanced.resourceGroup.input}
>
<div className={styles.resourceGroupAndName}>
<Input
id={`input-advanced-resource-picker-resourceGroup-${index + 1}`}
value={resource?.resourceGroup ?? ''}
onChange={(event) =>
onResourceChange(index, { ...resource, resourceGroup: event.currentTarget.value })
}
placeholder="resource-group"
/>
<AccessoryButton
aria-label="remove"
icon="times"
variant="secondary"
onClick={() => removeResource(index)}
hidden={resources.length === 1}
data-testid={'remove-resource'}
/>
</div>
</InlineField>
<InlineField
label="Resource Name"
transparent
htmlFor={`input-advanced-resource-picker-resourceName-${index + 1}`}
labelWidth={15}
data-testid={selectors.components.queryEditor.resourcePicker.advanced.resource.input}
>
<Input
id={`input-advanced-resource-picker-resourceName-${index + 1}`}
value={resource?.resourceName ?? ''}
onChange={(event) => onResourceChange(index, { ...resource, resourceName: event.currentTarget.value })}
placeholder="name"
/>
</InlineField>
</div>
))}
</div>
<Button aria-label="Add" icon="plus" variant="secondary" onClick={addResource} type="button">
Add resource
</Button>
</>
);
};
export default AdvancedResourcePicker;

@ -6,11 +6,12 @@ import { config } from '@grafana/runtime';
import { multiResourceCompatibleTypes } from '../../azureMetadata';
import type Datasource from '../../datasource';
import type { AzureMonitorQuery, AzureMonitorOption, AzureMonitorErrorish } from '../../types';
import type { AzureMonitorQuery, AzureMonitorOption, AzureMonitorErrorish, AzureMetricResource } from '../../types';
import ResourceField from '../ResourceField';
import { ResourceRow, ResourceRowGroup, ResourceRowType } from '../ResourcePicker/types';
import { parseResourceDetails } from '../ResourcePicker/utils';
import AdvancedResourcePicker from './AdvancedResourcePicker';
import AggregationField from './AggregationField';
import DimensionFields from './DimensionFields';
import LegendFormatField from './LegendFormatField';
@ -88,6 +89,12 @@ const MetricsQueryEditor: React.FC<MetricsQueryEditorProps> = ({
resources={resources ?? []}
queryType={'metrics'}
disableRow={disableRow}
renderAdvanced={(resources, onChange) => (
// It's required to cast resources because the resource picker
// specifies the type to string | AzureMetricResource.
// eslint-disable-next-line
<AdvancedResourcePicker resources={resources as AzureMetricResource[]} onChange={onChange} />
)}
/>
<MetricNamespaceField
metricNamespaces={metricNamespaces}

@ -20,6 +20,7 @@ interface ResourceFieldProps<T> extends AzureQueryEditorFieldProps {
inlineField?: boolean;
labelWidth?: number;
disableRow: (row: ResourceRow, selectedRows: ResourceRowGroup) => boolean;
renderAdvanced: (resources: T[], onChange: (resources: T[]) => void) => React.ReactNode;
}
const ResourceField: React.FC<ResourceFieldProps<string | AzureMetricResource>> = ({
@ -32,6 +33,7 @@ const ResourceField: React.FC<ResourceFieldProps<string | AzureMetricResource>>
inlineField,
labelWidth,
disableRow,
renderAdvanced,
}) => {
const styles = useStyles2(getStyles);
const [pickerIsOpen, setPickerIsOpen] = useState(false);
@ -71,6 +73,7 @@ const ResourceField: React.FC<ResourceFieldProps<string | AzureMetricResource>>
selectableEntryTypes={selectableEntryTypes}
queryType={queryType}
disableRow={disableRow}
renderAdvanced={renderAdvanced}
/>
</Modal>
<Field label="Resource" inlineField={inlineField} labelWidth={labelWidth}>

@ -0,0 +1,16 @@
import { render, screen } from '@testing-library/react';
import React from 'react';
import AdvancedMulti from './AdvancedMulti';
describe('AdvancedMulti', () => {
it('should expand and render a section', async () => {
const onChange = jest.fn();
const renderAdvanced = jest.fn().mockReturnValue(<div>details!</div>);
render(<AdvancedMulti onChange={onChange} resources={[{}]} renderAdvanced={renderAdvanced} />);
const advancedSection = screen.getByText('Advanced');
advancedSection.click();
expect(await screen.findByText('details!')).toBeInTheDocument();
});
});

@ -0,0 +1,33 @@
import React, { useState } from 'react';
import { Collapse } from '@grafana/ui';
import { selectors } from '../../e2e/selectors';
import { AzureMetricResource } from '../../types';
import { Space } from '../Space';
export interface ResourcePickerProps<T> {
resources: T[];
onChange: (resources: T[]) => void;
renderAdvanced: (resources: T[], onChange: (resources: T[]) => void) => React.ReactNode;
}
const AdvancedMulti = ({ resources, onChange, renderAdvanced }: ResourcePickerProps<string | AzureMetricResource>) => {
const [isAdvancedOpen, setIsAdvancedOpen] = useState(!!resources.length && JSON.stringify(resources).includes('$'));
return (
<div data-testid={selectors.components.queryEditor.resourcePicker.advanced.collapse}>
<Collapse
collapsible
label="Advanced"
isOpen={isAdvancedOpen}
onToggle={() => setIsAdvancedOpen(!isAdvancedOpen)}
>
{renderAdvanced(resources, onChange)}
<Space v={2} />
</Collapse>
</div>
);
};
export default AdvancedMulti;

@ -59,7 +59,7 @@ const queryType: ResourcePickerQueryType = 'logs';
const defaultProps = {
templateVariables: [],
resources: [noResourceURI],
resources: [],
resourcePickerData: createMockResourcePickerData(),
onCancel: noop,
onApply: noop,
@ -71,6 +71,7 @@ const defaultProps = {
],
queryType,
disableRow: jest.fn(),
renderAdvanced: jest.fn(),
};
describe('AzureMonitor ResourcePicker', () => {
@ -141,6 +142,7 @@ describe('AzureMonitor ResourcePicker', () => {
expect(subscriptionCheckbox).not.toBeChecked();
subscriptionCheckbox.click();
const applyButton = screen.getByRole('button', { name: 'Apply' });
expect(applyButton).toBeEnabled();
applyButton.click();
expect(onApply).toBeCalledTimes(1);
expect(onApply).toBeCalledWith(['/subscriptions/def-123']);
@ -174,26 +176,56 @@ describe('AzureMonitor ResourcePicker', () => {
expect(onApply).toBeCalledWith([]);
});
it('should call onApply with a new subscription when a user clicks on the checkbox in the row', async () => {
it('should call onApply with a new resource when a user clicks on the checkbox in the row', async () => {
const onApply = jest.fn();
render(<ResourcePicker {...defaultProps} onApply={onApply} resources={[]} />);
const subscriptionCheckbox = await screen.findByLabelText('Primary Subscription');
expect(subscriptionCheckbox).toBeInTheDocument();
expect(subscriptionCheckbox).not.toBeChecked();
subscriptionCheckbox.click();
render(<ResourcePicker {...defaultProps} queryType={'metrics'} onApply={onApply} resources={[]} />);
const subscriptionButton = await screen.findByRole('button', { name: 'Expand Primary Subscription' });
expect(subscriptionButton).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Expand A Great Resource Group' })).not.toBeInTheDocument();
subscriptionButton.click();
const resourceGroupButton = await screen.findByRole('button', { name: 'Expand A Great Resource Group' });
resourceGroupButton.click();
const checkbox = await screen.findByLabelText('web-server');
await userEvent.click(checkbox);
expect(checkbox).toBeChecked();
const applyButton = screen.getByRole('button', { name: 'Apply' });
applyButton.click();
expect(onApply).toBeCalledTimes(1);
expect(onApply).toBeCalledWith([{ subscription: 'def-123' }]);
expect(onApply).toBeCalledWith([
{
metricNamespace: 'Microsoft.Compute/virtualMachines',
region: 'northeurope',
resourceGroup: 'dev-3',
resourceName: 'web-server',
subscription: 'def-456',
},
]);
});
it('should call onApply removing a resource element', async () => {
const onApply = jest.fn();
render(<ResourcePicker {...defaultProps} onApply={onApply} resources={[{ subscription: 'def-123' }]} />);
const subscriptionCheckbox = await screen.findAllByLabelText('Primary Subscription');
expect(subscriptionCheckbox).toHaveLength(2);
expect(subscriptionCheckbox.at(0)).toBeChecked();
subscriptionCheckbox.at(0)?.click();
render(
<ResourcePicker
{...defaultProps}
onApply={onApply}
resources={[
{
metricNamespace: 'Microsoft.Compute/virtualMachines',
region: 'northeurope',
resourceGroup: 'dev-3',
resourceName: 'web-server',
subscription: 'def-456',
},
]}
/>
);
const checkbox = await screen.findAllByLabelText('web-server');
expect(checkbox).toHaveLength(2);
expect(checkbox.at(0)).toBeChecked();
checkbox.at(0)?.click();
const applyButton = screen.getByRole('button', { name: 'Apply' });
applyButton.click();
expect(onApply).toBeCalledTimes(1);
@ -202,7 +234,7 @@ describe('AzureMonitor ResourcePicker', () => {
it('should call onApply with a new subscription uri when a user types it in the selection box', async () => {
const onApply = jest.fn();
render(<ResourcePicker {...defaultProps} onApply={onApply} />);
render(<ResourcePicker {...defaultProps} onApply={onApply} resources={['']} />);
const subscriptionCheckbox = await screen.findByLabelText('Primary Subscription');
expect(subscriptionCheckbox).toBeInTheDocument();
expect(subscriptionCheckbox).not.toBeChecked();
@ -222,7 +254,7 @@ describe('AzureMonitor ResourcePicker', () => {
it('should call onApply with a new subscription when a user types it in the selection box', async () => {
const onApply = jest.fn();
render(<ResourcePicker {...defaultProps} onApply={onApply} resources={[{}]} />);
render(<ResourcePicker {...defaultProps} queryType={'metrics'} onApply={onApply} resources={[{}]} />);
const subscriptionCheckbox = await screen.findByLabelText('Primary Subscription');
expect(subscriptionCheckbox).toBeInTheDocument();
expect(subscriptionCheckbox).not.toBeChecked();
@ -232,20 +264,41 @@ describe('AzureMonitor ResourcePicker', () => {
const advancedInput = await screen.findByLabelText('Subscription');
await userEvent.type(advancedInput, 'def-123');
const nsInput = await screen.findByLabelText('Namespace');
await userEvent.type(nsInput, 'ns');
const rgInput = await screen.findByLabelText('Resource Group');
await userEvent.type(rgInput, 'rg');
const rnInput = await screen.findByLabelText('Resource Name');
await userEvent.type(rnInput, 'rn');
const applyButton = screen.getByRole('button', { name: 'Apply' });
applyButton.click();
expect(onApply).toBeCalledTimes(1);
expect(onApply).toBeCalledWith([{ subscription: 'def-123' }]);
expect(onApply).toBeCalledWith([
{ subscription: 'def-123', metricNamespace: 'ns', resourceGroup: 'rg', resourceName: 'rn' },
]);
});
it('should show unselect a subscription if the value is manually edited', async () => {
render(<ResourcePicker {...defaultProps} resources={[{ subscription: 'def-456' }]} />);
const subscriptionCheckboxes = await screen.findAllByLabelText('Dev Subscription');
expect(subscriptionCheckboxes.length).toBe(2);
expect(subscriptionCheckboxes[0]).toBeChecked();
expect(subscriptionCheckboxes[1]).toBeChecked();
render(
<ResourcePicker
{...defaultProps}
resources={[
{
metricNamespace: 'Microsoft.Compute/virtualMachines',
region: 'northeurope',
resourceGroup: 'dev-3',
resourceName: 'web-server',
subscription: 'def-456',
},
]}
/>
);
const checkboxes = await screen.findAllByLabelText('web-server');
expect(checkboxes.length).toBe(2);
expect(checkboxes[0]).toBeChecked();
expect(checkboxes[1]).toBeChecked();
const advancedSection = screen.getByText('Advanced');
advancedSection.click();
@ -253,7 +306,7 @@ describe('AzureMonitor ResourcePicker', () => {
const advancedInput = await screen.findByLabelText('Subscription');
await userEvent.type(advancedInput, 'def-123');
const updatedCheckboxes = await screen.findAllByLabelText('Dev Subscription');
const updatedCheckboxes = await screen.findAllByLabelText('web-server');
expect(updatedCheckboxes.length).toBe(1);
expect(updatedCheckboxes[0]).not.toBeChecked();
});

@ -2,6 +2,7 @@ import { cx } from '@emotion/css';
import React, { useCallback, useEffect, useState } from 'react';
import { useEffectOnce } from 'react-use';
import { config } from '@grafana/runtime';
import { Alert, Button, LoadingPlaceholder, useStyles2 } from '@grafana/ui';
import { selectors } from '../../e2e/selectors';
@ -11,6 +12,7 @@ import messageFromError from '../../utils/messageFromError';
import { Space } from '../Space';
import Advanced from './Advanced';
import AdvancedMulti from './AdvancedMulti';
import NestedRow from './NestedRow';
import Search from './Search';
import getStyles from './styles';
@ -26,6 +28,7 @@ interface ResourcePickerProps<T> {
onApply: (resources: T[]) => void;
onCancel: () => void;
disableRow: (row: ResourceRow, selectedRows: ResourceRowGroup) => boolean;
renderAdvanced: (resources: T[], onChange: (resources: T[]) => void) => React.ReactNode;
}
const ResourcePicker = ({
@ -36,6 +39,7 @@ const ResourcePicker = ({
selectableEntryTypes,
queryType,
disableRow,
renderAdvanced,
}: ResourcePickerProps<string | AzureMetricResource>) => {
const styles = useStyles2(getStyles);
@ -71,13 +75,18 @@ const ResourcePicker = ({
loadInitialData();
});
// Avoid using empty resources
const isValid = (r: string | AzureMetricResource) =>
typeof r === 'string' ? r !== '' : r.subscription && r.resourceGroup && r.resourceName && r.metricNamespace;
// set selected row data whenever row or selection changes
useEffect(() => {
if (!internalSelected) {
setSelectedRows([]);
}
const found = internalSelected && findRows(rows, resourcesToStrings(internalSelected));
const sanitized = internalSelected.filter((r) => isValid(r));
const found = internalSelected && findRows(rows, resourcesToStrings(sanitized));
if (found && found.length) {
return setSelectedRows(found);
}
@ -106,15 +115,11 @@ const ResourcePicker = ({
[resourcePickerData, rows, queryType]
);
const resourceIsString = resources?.length && typeof resources[0] === 'string';
const handleSelectionChanged = useCallback(
(row: ResourceRow, isSelected: boolean) => {
if (isSelected) {
const newRes = resourceIsString ? row.uri : parseMultipleResourceDetails([row.uri], row.location)[0];
const newSelected = (internalSelected ? internalSelected.concat(newRes) : [newRes]).filter((r) => {
// avoid setting empty resources
return typeof r === 'string' ? r !== '' : r.subscription;
});
const newRes = queryType === 'logs' ? row.uri : parseMultipleResourceDetails([row.uri], row.location)[0];
const newSelected = internalSelected ? internalSelected.concat(newRes) : [newRes];
setInternalSelected(newSelected);
} else {
const newInternalSelected = internalSelected?.filter((r) => {
@ -123,14 +128,14 @@ const ResourcePicker = ({
setInternalSelected(newInternalSelected);
}
},
[resourceIsString, internalSelected, setInternalSelected]
[queryType, internalSelected, setInternalSelected]
);
const handleApply = useCallback(() => {
if (internalSelected) {
onApply(resourceIsString ? internalSelected : parseMultipleResourceDetails(internalSelected));
onApply(queryType === 'logs' ? internalSelected : parseMultipleResourceDetails(internalSelected));
}
}, [resourceIsString, internalSelected, onApply]);
}, [queryType, internalSelected, onApply]);
const handleSearch = useCallback(
async (searchWord: string) => {
@ -239,11 +244,20 @@ const ResourcePicker = ({
</>
)}
{config.featureToggles.azureMultipleResourcePicker ? (
<AdvancedMulti
resources={internalSelected}
onChange={(r) => setInternalSelected(r)}
renderAdvanced={renderAdvanced}
/>
) : (
<Advanced resources={internalSelected} onChange={(r) => setInternalSelected(r)} />
)}
<Space v={2} />
<Button
disabled={!!errorMessage}
disabled={!!errorMessage || !internalSelected.every(isValid)}
onClick={handleApply}
data-testid={selectors.components.queryEditor.resourcePicker.apply.button}
>

@ -198,6 +198,12 @@ describe('AzureMonitor ResourcePicker utils', () => {
});
});
it('ignores an empty resource URI', () => {
expect(setResources(createMockQuery(), 'logs', ['/subscription/sub', ''])).toMatchObject({
azureLogAnalytics: { resources: ['/subscription/sub'] },
});
});
it('updates a resource with a resource parameters for Metrics', () => {
expect(
setResources(createMockQuery(), 'metrics', [
@ -225,6 +231,29 @@ describe('AzureMonitor ResourcePicker utils', () => {
},
});
});
it('ignores a partially empty metrics resource', () => {
expect(
setResources(createMockQuery(), 'metrics', [
{
subscription: 'sub',
resourceGroup: 'rg',
metricNamespace: 'Microsoft.Storage/storageAccounts',
resourceName: '',
region: 'westus',
},
])
).toMatchObject({
subscription: 'sub',
azureMonitor: {
aggregation: undefined,
metricName: undefined,
metricNamespace: 'microsoft.storage/storageaccounts',
region: 'westus',
resources: [],
},
});
});
});
describe('parseResourceDetails', () => {

@ -165,7 +165,7 @@ export function setResources(
...query,
azureLogAnalytics: {
...query.azureLogAnalytics,
resources: resourcesToStrings(resources),
resources: resourcesToStrings(resources).filter((resource) => resource !== ''),
},
};
}
@ -178,7 +178,13 @@ export function setResources(
...query.azureMonitor,
metricNamespace: parsedResource.metricNamespace?.toLocaleLowerCase(),
region: parsedResource.region,
resources: parseMultipleResourceDetails(resources),
resources: parseMultipleResourceDetails(resources).filter(
(resource) =>
resource.resourceName !== '' &&
resource.metricNamespace !== '' &&
resource.subscription !== '' &&
resource.resourceGroup !== ''
),
metricName: undefined,
aggregation: undefined,
timeGrain: '',

@ -52,6 +52,9 @@ export const components = {
namespace: {
input: 'data-testid resource-picker-namespace',
},
region: {
input: 'data-testid resource-picker-region',
},
resource: {
input: 'data-testid resource-picker-resource',
},

Loading…
Cancel
Save