mirror of https://github.com/grafana/grafana
Azure Monitor: Adapt Advanced component to multiple resources (#61981)
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; |
@ -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; |
@ -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; |
Loading…
Reference in new issue