mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Azure Monitor: Adapt Advanced component to multiple resources (#61981)
This commit is contained in:
parent
12a4a83c77
commit
3e73ba5460
@ -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 = ({
|
||||
</>
|
||||
)}
|
||||
|
||||
<Advanced resources={internalSelected} onChange={(r) => setInternalSelected(r)} />
|
||||
{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…
Reference in New Issue
Block a user