diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/LogsQueryEditor/ResourceField.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/LogsQueryEditor/ResourceField.tsx index a4277af0c54..11cb59d049c 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/LogsQueryEditor/ResourceField.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/LogsQueryEditor/ResourceField.tsx @@ -7,6 +7,7 @@ import Datasource from '../../datasource'; import { AzureQueryEditorFieldProps, AzureResourceSummaryItem } from '../../types'; import { Field } from '../Field'; import ResourcePicker from '../ResourcePicker'; +import { ResourceRowType } from '../ResourcePicker/types'; import { parseResourceURI } from '../ResourcePicker/utils'; import { Space } from '../Space'; import { setResource } from './setQueryValue'; @@ -65,6 +66,12 @@ const ResourceField: React.FC = ({ query, datasource templateVariables={templateVariables} onApply={handleApply} onCancel={closePicker} + selectableEntryTypes={[ + ResourceRowType.Subscription, + ResourceRowType.ResourceGroup, + ResourceRowType.Resource, + ResourceRowType.Variable, + ]} /> diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/EntryIcon.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/EntryIcon.tsx new file mode 100644 index 00000000000..c24861fc235 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/EntryIcon.tsx @@ -0,0 +1,31 @@ +import { Icon } from '@grafana/ui'; +import React from 'react'; + +import { ResourceRow, ResourceRowType } from './types'; + +interface EntryIconProps { + entry: ResourceRow; + isOpen: boolean; +} + +export const EntryIcon: React.FC = ({ isOpen, entry: { type } }) => { + switch (type) { + case ResourceRowType.Subscription: + return ; + + case ResourceRowType.ResourceGroup: + return ; + + case ResourceRowType.Resource: + return ; + + case ResourceRowType.VariableGroup: + return ; + + case ResourceRowType.Variable: + return ; + + default: + return null; + } +}; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/NestedEntry.test.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/NestedEntry.test.tsx new file mode 100644 index 00000000000..c0df55eb04b --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/NestedEntry.test.tsx @@ -0,0 +1,29 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; +import { NestedEntry } from './NestedEntry'; +import { ResourceRowType } from './types'; + +const defaultProps = { + level: 0, + entry: { id: '123', uri: 'someuri', name: '123', type: ResourceRowType.Resource, typeLabel: '' }, + isSelected: false, + isSelectable: false, + isOpen: false, + isDisabled: false, + onToggleCollapse: jest.fn(), + onSelectedChange: jest.fn(), +}; + +describe('NestedEntry', () => { + it('should be selectable', () => { + render(); + const box = screen.getByRole('checkbox'); + expect(box).toBeInTheDocument(); + }); + + it('should not be selectable', () => { + render(); + const box = screen.queryByRole('checkbox'); + expect(box).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/NestedEntry.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/NestedEntry.tsx new file mode 100644 index 00000000000..dbdabcac023 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/NestedEntry.tsx @@ -0,0 +1,104 @@ +import { cx } from '@emotion/css'; +import { Checkbox, IconButton, useStyles2, useTheme2 } from '@grafana/ui'; +import React, { useCallback, useEffect } from 'react'; + +import { Space } from '../Space'; +import { EntryIcon } from './EntryIcon'; +import getStyles from './styles'; +import { ResourceRow } from './types'; + +interface NestedEntryProps { + level: number; + entry: ResourceRow; + isSelected: boolean; + isSelectable: boolean; + isOpen: boolean; + isDisabled: boolean; + onToggleCollapse: (row: ResourceRow) => void; + onSelectedChange: (row: ResourceRow, selected: boolean) => void; +} + +export const NestedEntry: React.FC = ({ + entry, + isSelected, + isDisabled, + isOpen, + isSelectable, + level, + onToggleCollapse, + onSelectedChange, +}) => { + const theme = useTheme2(); + const styles = useStyles2(getStyles); + const hasChildren = !!entry.children; + // Subscriptions, resource groups, resources, and variables are all selectable, so + // the top-level variable group is the only thing that cannot be selected. + // const isSelectable = entry.type !== ResourceRowType.VariableGroup; + // const isSelectable = selectableEntryTypes?.some((e) => e === entry.type); + + const handleToggleCollapse = useCallback(() => { + onToggleCollapse(entry); + }, [onToggleCollapse, entry]); + + const handleSelectedChanged = useCallback( + (ev: React.ChangeEvent) => { + const isSelected = ev.target.checked; + onSelectedChange(entry, isSelected); + }, + [entry, onSelectedChange] + ); + + const checkboxId = `checkbox_${entry.id}`; + + // Scroll to the selected element if it's not in the view + // Only do it once, when the component is mounted + useEffect(() => { + if (isSelected) { + document.getElementById(checkboxId)?.scrollIntoView({ + behavior: 'smooth', + block: 'center', + }); + } + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + return ( +
+ {/* When groups are selectable, I *think* we will want to show a 2-wide space instead + of the collapse button for leaf rows that have no children to get them to align */} + + {hasChildren ? ( + + ) : ( + + )} + + + + {isSelectable && ( + <> + + + + )} + + + + + +
+ ); +}; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/NestedResourceTable.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/NestedResourceTable.tsx index 30f654c6361..267cfaebc7a 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/NestedResourceTable.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/NestedResourceTable.tsx @@ -5,7 +5,7 @@ import { useStyles2 } from '@grafana/ui'; import NestedRows from './NestedRows'; import getStyles from './styles'; -import { ResourceRow, ResourceRowGroup } from './types'; +import { ResourceRow, ResourceRowGroup, ResourceRowType } from './types'; interface NestedResourceTableProps { rows: ResourceRowGroup; @@ -13,6 +13,7 @@ interface NestedResourceTableProps { noHeader?: boolean; requestNestedRows: (row: ResourceRow) => Promise; onRowSelectedChange: (row: ResourceRow, selected: boolean) => void; + selectableEntryTypes: ResourceRowType[]; } const NestedResourceTable: React.FC = ({ @@ -21,6 +22,7 @@ const NestedResourceTable: React.FC = ({ noHeader, requestNestedRows, onRowSelectedChange, + selectableEntryTypes, }) => { const styles = useStyles2(getStyles); @@ -47,6 +49,7 @@ const NestedResourceTable: React.FC = ({ level={0} requestNestedRows={requestNestedRows} onRowSelectedChange={onRowSelectedChange} + selectableEntryTypes={selectableEntryTypes} /> diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/NestedRow.test.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/NestedRow.test.tsx new file mode 100644 index 00000000000..ca0cc1ca3af --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/NestedRow.test.tsx @@ -0,0 +1,58 @@ +import { render, screen } from '@testing-library/react'; +import React from 'react'; + +import NestedRow from './NestedRow'; +import { ResourceRowType } from './types'; + +const defaultProps = { + row: { + id: '1', + uri: 'some-uri', + name: '1', + type: ResourceRowType.Resource, + typeLabel: '1', + }, + level: 0, + selectedRows: [], + requestNestedRows: jest.fn(), + onRowSelectedChange: jest.fn(), + selectableEntryTypes: [], +}; + +describe('NestedRow', () => { + it('should not display a checkbox when the type of row is empty', () => { + render( + + + + +
+ ); + const box = screen.queryByRole('checkbox'); + expect(box).not.toBeInTheDocument(); + }); + + it('should display a checkbox when the type of row is in selectableEntryTypes', () => { + render( + + + + +
+ ); + const box = screen.queryByRole('checkbox'); + expect(box).toBeInTheDocument(); + }); + + it('should not display a checkbox when the type of row is not in selectableEntryTypes', () => { + render( + + + + +
+ ); + const box = screen.queryByRole('checkbox'); + expect(box).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/NestedRow.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/NestedRow.tsx new file mode 100644 index 00000000000..be4955a0604 --- /dev/null +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/NestedRow.tsx @@ -0,0 +1,101 @@ +import { cx } from '@emotion/css'; +import { FadeTransition, LoadingPlaceholder, useStyles2 } from '@grafana/ui'; +import React, { useEffect, useState } from 'react'; + +import { NestedEntry } from './NestedEntry'; +import NestedRows from './NestedRows'; +import getStyles from './styles'; +import { ResourceRow, ResourceRowGroup, ResourceRowType } from './types'; +import { findRow } from './utils'; + +interface NestedRowProps { + row: ResourceRow; + level: number; + selectedRows: ResourceRowGroup; + requestNestedRows: (row: ResourceRow) => Promise; + onRowSelectedChange: (row: ResourceRow, selected: boolean) => void; + selectableEntryTypes: ResourceRowType[]; +} + +const NestedRow: React.FC = ({ + row, + selectedRows, + level, + requestNestedRows, + onRowSelectedChange, + selectableEntryTypes, +}) => { + const styles = useStyles2(getStyles); + const [rowStatus, setRowStatus] = useState<'open' | 'closed' | 'loading'>('closed'); + + const isSelected = !!selectedRows.find((v) => v.id === row.id); + const isDisabled = selectedRows.length > 0 && !isSelected; + const isOpen = rowStatus === 'open'; + + const onRowToggleCollapse = async () => { + if (rowStatus === 'open') { + setRowStatus('closed'); + return; + } + setRowStatus('loading'); + requestNestedRows(row) + .then(() => setRowStatus('open')) + .catch(() => setRowStatus('closed')); + }; + + // opens the resource group on load of component if there was a previously saved selection + useEffect(() => { + // Assuming we don't have multi-select yet + const selectedRow = selectedRows[0]; + + const containsChild = selectedRow && !!findRow(row.children ?? [], selectedRow.id); + + if (containsChild) { + setRowStatus('open'); + } + }, [selectedRows, row]); + + return ( + <> + + + type === row.type)} + /> + + + {row.typeLabel} + + {row.location ?? '-'} + + + {isOpen && row.children && Object.keys(row.children).length > 0 && ( + + )} + + + + + + + + + + ); +}; + +export default NestedRow; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/NestedRows.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/NestedRows.tsx index dd674a8c541..b66d622aa57 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/NestedRows.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/NestedRows.tsx @@ -1,11 +1,7 @@ -import { cx } from '@emotion/css'; -import { Checkbox, FadeTransition, Icon, IconButton, LoadingPlaceholder, useStyles2, useTheme2 } from '@grafana/ui'; -import React, { useCallback, useEffect, useState } from 'react'; +import React from 'react'; +import NestedRow from './NestedRow'; -import { Space } from '../Space'; -import getStyles from './styles'; import { ResourceRow, ResourceRowGroup, ResourceRowType } from './types'; -import { findRow } from './utils'; interface NestedRowsProps { rows: ResourceRowGroup; @@ -13,6 +9,7 @@ interface NestedRowsProps { selectedRows: ResourceRowGroup; requestNestedRows: (row: ResourceRow) => Promise; onRowSelectedChange: (row: ResourceRow, selected: boolean) => void; + selectableEntryTypes: ResourceRowType[]; } const NestedRows: React.FC = ({ @@ -21,6 +18,7 @@ const NestedRows: React.FC = ({ level, requestNestedRows, onRowSelectedChange, + selectableEntryTypes, }) => ( <> {rows.map((row) => ( @@ -31,208 +29,10 @@ const NestedRows: React.FC = ({ level={level} requestNestedRows={requestNestedRows} onRowSelectedChange={onRowSelectedChange} + selectableEntryTypes={selectableEntryTypes} /> ))} ); -interface NestedRowProps { - row: ResourceRow; - level: number; - selectedRows: ResourceRowGroup; - requestNestedRows: (row: ResourceRow) => Promise; - onRowSelectedChange: (row: ResourceRow, selected: boolean) => void; -} - -const NestedRow: React.FC = ({ row, selectedRows, level, requestNestedRows, onRowSelectedChange }) => { - const styles = useStyles2(getStyles); - const [rowStatus, setRowStatus] = useState<'open' | 'closed' | 'loading'>('closed'); - - const isSelected = !!selectedRows.find((v) => v.id === row.id); - const isDisabled = selectedRows.length > 0 && !isSelected; - const isOpen = rowStatus === 'open'; - - const onRowToggleCollapse = async () => { - if (rowStatus === 'open') { - setRowStatus('closed'); - return; - } - setRowStatus('loading'); - requestNestedRows(row) - .then(() => setRowStatus('open')) - .catch(() => setRowStatus('closed')); - }; - - // opens the resource group on load of component if there was a previously saved selection - useEffect(() => { - // Assuming we don't have multi-select yet - const selectedRow = selectedRows[0]; - const containsChild = selectedRow && !!findRow(row.children ?? [], selectedRow.uri); - - if (containsChild) { - setRowStatus('open'); - } - }, [selectedRows, row]); - - return ( - <> - - - - - - {row.typeLabel} - - {row.location ?? '-'} - - - {isOpen && row.children && Object.keys(row.children).length > 0 && ( - - )} - - - - - - - - - - ); -}; - -interface EntryIconProps { - entry: ResourceRow; - isOpen: boolean; -} - -const EntryIcon: React.FC = ({ isOpen, entry: { type } }) => { - switch (type) { - case ResourceRowType.Subscription: - return ; - - case ResourceRowType.ResourceGroup: - return ; - - case ResourceRowType.Resource: - return ; - - case ResourceRowType.VariableGroup: - return ; - - case ResourceRowType.Variable: - return ; - - default: - return null; - } -}; - -interface NestedEntryProps { - level: number; - entry: ResourceRow; - isSelected: boolean; - isOpen: boolean; - isDisabled: boolean; - onToggleCollapse: (row: ResourceRow) => void; - onSelectedChange: (row: ResourceRow, selected: boolean) => void; -} - -const NestedEntry: React.FC = ({ - entry, - isSelected, - isDisabled, - isOpen, - level, - onToggleCollapse, - onSelectedChange, -}) => { - const theme = useTheme2(); - const styles = useStyles2(getStyles); - const hasChildren = !!entry.children; - // Subscriptions, resource groups, resources, and variables are all selectable, so - // the top-level variable group is the only thing that cannot be selected. - const isSelectable = entry.type !== ResourceRowType.VariableGroup; - - const handleToggleCollapse = useCallback(() => { - onToggleCollapse(entry); - }, [onToggleCollapse, entry]); - - const handleSelectedChanged = useCallback( - (ev: React.ChangeEvent) => { - const isSelected = ev.target.checked; - onSelectedChange(entry, isSelected); - }, - [entry, onSelectedChange] - ); - - const checkboxId = `checkbox_${entry.id}`; - - // Scroll to the selected element if it's not in the view - // Only do it once, when the component is mounted - useEffect(() => { - if (isSelected) { - document.getElementById(checkboxId)?.scrollIntoView({ - behavior: 'smooth', - block: 'center', - }); - } - }, []); // eslint-disable-line react-hooks/exhaustive-deps - - return ( -
- {/* When groups are selectable, I *think* we will want to show a 2-wide space instead - of the collapse button for leaf rows that have no children to get them to align */} - - {hasChildren ? ( - - ) : ( - - )} - - - - {isSelectable && ( - <> - - - - )} - - - - - -
- ); -}; - export default NestedRows; diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/ResourcePicker.test.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/ResourcePicker.test.tsx index 2829e4cdf31..e99213a8d30 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/ResourcePicker.test.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/ResourcePicker.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { act, render, screen } from '@testing-library/react'; import React from 'react'; import ResourcePicker from '.'; @@ -8,6 +8,7 @@ import { createMockSubscriptions, mockResourcesByResourceGroup, } from '../../__mocks__/resourcePickerRows'; +import { ResourceRowType } from './types'; const noResourceURI = ''; const singleSubscriptionSelectionURI = '/subscriptions/def-456'; @@ -15,28 +16,31 @@ const singleResourceGroupSelectionURI = '/subscriptions/def-456/resourceGroups/d const singleResourceSelectionURI = '/subscriptions/def-456/resourceGroups/dev-3/providers/Microsoft.Compute/virtualMachines/db-server'; -const createResourcePickerDataMock = () => { - return createMockResourcePickerData({ +const noop: any = () => {}; +const defaultProps = { + templateVariables: [], + resourceURI: noResourceURI, + resourcePickerData: createMockResourcePickerData({ getSubscriptions: jest.fn().mockResolvedValue(createMockSubscriptions()), getResourceGroupsBySubscriptionId: jest.fn().mockResolvedValue(createMockResourceGroupsBySubscription()), getResourcesForResourceGroup: jest.fn().mockResolvedValue(mockResourcesByResourceGroup()), - }); + }), + onCancel: noop, + onApply: noop, + selectableEntryTypes: [ + ResourceRowType.Subscription, + ResourceRowType.ResourceGroup, + ResourceRowType.Resource, + ResourceRowType.Variable, + ], }; + describe('AzureMonitor ResourcePicker', () => { - const noop: any = () => {}; beforeEach(() => { window.HTMLElement.prototype.scrollIntoView = function () {}; }); it('should pre-load subscriptions when there is no existing selection', async () => { - render( - - ); + render(); const subscriptionCheckbox = await screen.findByLabelText('Primary Subscription'); expect(subscriptionCheckbox).toBeInTheDocument(); expect(subscriptionCheckbox).not.toBeChecked(); @@ -45,58 +49,25 @@ describe('AzureMonitor ResourcePicker', () => { }); it('should show a subscription as selected if there is one saved', async () => { - render( - - ); + render(); const subscriptionCheckbox = await screen.findByLabelText('Dev Subscription'); expect(subscriptionCheckbox).toBeChecked(); }); - it('should show a resource group as selected if there is one saved', async () => { - render( - - ); + it('should show a resourceGroup as selected if there is one saved', async () => { + render(); const resourceGroupCheckbox = await screen.findByLabelText('A Great Resource Group'); expect(resourceGroupCheckbox).toBeChecked(); }); it('should show a resource as selected if there is one saved', async () => { - render( - - ); - + render(); const resourceCheckbox = await screen.findByLabelText('db-server'); expect(resourceCheckbox).toBeChecked(); }); it('should be able to expand a subscription when clicked and reveal resource groups', async () => { - render( - - ); + render(); const expandSubscriptionButton = await screen.findByLabelText('Expand Primary Subscription'); expect(expandSubscriptionButton).toBeInTheDocument(); expect(screen.queryByLabelText('A Great Resource Group')).not.toBeInTheDocument(); @@ -106,15 +77,7 @@ describe('AzureMonitor ResourcePicker', () => { it('should call onApply with a new subscription uri when a user selects it', async () => { const onApply = jest.fn(); - render( - - ); + render(); const subscriptionCheckbox = await screen.findByLabelText('Primary Subscription'); expect(subscriptionCheckbox).toBeInTheDocument(); expect(subscriptionCheckbox).not.toBeChecked(); @@ -124,18 +87,9 @@ describe('AzureMonitor ResourcePicker', () => { expect(onApply).toBeCalledTimes(1); expect(onApply).toBeCalledWith('/subscriptions/def-123'); }); - it('should call onApply with a template variable when a user selects it', async () => { const onApply = jest.fn(); - render( - - ); + render(); const expandButton = await screen.findByLabelText('Expand Template variables'); expandButton.click(); @@ -149,4 +103,14 @@ describe('AzureMonitor ResourcePicker', () => { expect(onApply).toBeCalledTimes(1); expect(onApply).toBeCalledWith('$workspace'); }); + + describe('when rendering resource picker without any selectable entry types', () => { + it('renders no checkboxes', async () => { + await act(async () => { + render(); + }); + const checkboxes = screen.queryAllByRole('checkbox'); + expect(checkboxes.length).toBe(0); + }); + }); }); diff --git a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/ResourcePicker.tsx b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/ResourcePicker.tsx index ec5637bbc3e..5cea1a1a71e 100644 --- a/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/ResourcePicker.tsx +++ b/public/app/plugins/datasource/grafana-azure-monitor-datasource/components/ResourcePicker/ResourcePicker.tsx @@ -15,6 +15,7 @@ interface ResourcePickerProps { resourcePickerData: ResourcePickerData; resourceURI: string | undefined; templateVariables: string[]; + selectableEntryTypes: ResourceRowType[]; onApply: (resourceURI: string | undefined) => void; onCancel: () => void; @@ -26,6 +27,7 @@ const ResourcePicker = ({ templateVariables, onApply, onCancel, + selectableEntryTypes, }: ResourcePickerProps) => { const styles = useStyles2(getStyles); @@ -154,6 +156,7 @@ const ResourcePicker = ({ requestNestedRows={requestNestedRows} onRowSelectedChange={handleSelectionChanged} selectedRows={selectedResourceRows} + selectableEntryTypes={selectableEntryTypes} />
@@ -167,6 +170,7 @@ const ResourcePicker = ({ onRowSelectedChange={handleSelectionChanged} selectedRows={selectedResourceRows} noHeader={true} + selectableEntryTypes={selectableEntryTypes} /> )}