mirror of
https://github.com/grafana/grafana.git
synced 2025-09-23 12:13:18 +08:00
Azure Monitor : Add support for the resource picker to be configurable to only select some entry types (#46735)
Co-authored-by: Kevin Yu <kevinwcyu@users.noreply.github.com> Co-authored-by: Andres Martinez Gotor <andres.mgotor@gmail.com> Co-authored-by: Isabella Siu <isabella.siu@grafana.com> Co-authored-by: Sarah Zinger <sarah.zinger@grafana.com>
This commit is contained in:
@ -7,6 +7,7 @@ import Datasource from '../../datasource';
|
|||||||
import { AzureQueryEditorFieldProps, AzureResourceSummaryItem } from '../../types';
|
import { AzureQueryEditorFieldProps, AzureResourceSummaryItem } from '../../types';
|
||||||
import { Field } from '../Field';
|
import { Field } from '../Field';
|
||||||
import ResourcePicker from '../ResourcePicker';
|
import ResourcePicker from '../ResourcePicker';
|
||||||
|
import { ResourceRowType } from '../ResourcePicker/types';
|
||||||
import { parseResourceURI } from '../ResourcePicker/utils';
|
import { parseResourceURI } from '../ResourcePicker/utils';
|
||||||
import { Space } from '../Space';
|
import { Space } from '../Space';
|
||||||
import { setResource } from './setQueryValue';
|
import { setResource } from './setQueryValue';
|
||||||
@ -65,6 +66,12 @@ const ResourceField: React.FC<AzureQueryEditorFieldProps> = ({ query, datasource
|
|||||||
templateVariables={templateVariables}
|
templateVariables={templateVariables}
|
||||||
onApply={handleApply}
|
onApply={handleApply}
|
||||||
onCancel={closePicker}
|
onCancel={closePicker}
|
||||||
|
selectableEntryTypes={[
|
||||||
|
ResourceRowType.Subscription,
|
||||||
|
ResourceRowType.ResourceGroup,
|
||||||
|
ResourceRowType.Resource,
|
||||||
|
ResourceRowType.Variable,
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
@ -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<EntryIconProps> = ({ isOpen, entry: { type } }) => {
|
||||||
|
switch (type) {
|
||||||
|
case ResourceRowType.Subscription:
|
||||||
|
return <Icon name="layer-group" />;
|
||||||
|
|
||||||
|
case ResourceRowType.ResourceGroup:
|
||||||
|
return <Icon name={isOpen ? 'folder-open' : 'folder'} />;
|
||||||
|
|
||||||
|
case ResourceRowType.Resource:
|
||||||
|
return <Icon name="cube" />;
|
||||||
|
|
||||||
|
case ResourceRowType.VariableGroup:
|
||||||
|
return <Icon name="x" />;
|
||||||
|
|
||||||
|
case ResourceRowType.Variable:
|
||||||
|
return <Icon name="x" />;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
@ -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(<NestedEntry {...defaultProps} isSelectable={true} />);
|
||||||
|
const box = screen.getByRole('checkbox');
|
||||||
|
expect(box).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not be selectable', () => {
|
||||||
|
render(<NestedEntry {...defaultProps} />);
|
||||||
|
const box = screen.queryByRole('checkbox');
|
||||||
|
expect(box).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
@ -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<NestedEntryProps> = ({
|
||||||
|
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<HTMLInputElement>) => {
|
||||||
|
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 (
|
||||||
|
<div className={styles.nestedEntry} style={{ marginLeft: level * (3 * theme.spacing.gridSize) }}>
|
||||||
|
{/* 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 ? (
|
||||||
|
<IconButton
|
||||||
|
className={styles.collapseButton}
|
||||||
|
name={isOpen ? 'angle-down' : 'angle-right'}
|
||||||
|
aria-label={isOpen ? `Collapse ${entry.name}` : `Expand ${entry.name}`}
|
||||||
|
onClick={handleToggleCollapse}
|
||||||
|
id={entry.id}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Space layout="inline" h={2} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Space layout="inline" h={2} />
|
||||||
|
|
||||||
|
{isSelectable && (
|
||||||
|
<>
|
||||||
|
<Checkbox
|
||||||
|
id={checkboxId}
|
||||||
|
onChange={handleSelectedChanged}
|
||||||
|
disabled={isDisabled}
|
||||||
|
value={isSelected}
|
||||||
|
className={styles.nestedRowCheckbox}
|
||||||
|
/>
|
||||||
|
<Space layout="inline" h={2} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EntryIcon entry={entry} isOpen={isOpen} />
|
||||||
|
<Space layout="inline" h={1} />
|
||||||
|
|
||||||
|
<label htmlFor={checkboxId} className={cx(styles.entryContentItem, styles.truncated)}>
|
||||||
|
{entry.name}
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
@ -5,7 +5,7 @@ import { useStyles2 } from '@grafana/ui';
|
|||||||
|
|
||||||
import NestedRows from './NestedRows';
|
import NestedRows from './NestedRows';
|
||||||
import getStyles from './styles';
|
import getStyles from './styles';
|
||||||
import { ResourceRow, ResourceRowGroup } from './types';
|
import { ResourceRow, ResourceRowGroup, ResourceRowType } from './types';
|
||||||
|
|
||||||
interface NestedResourceTableProps {
|
interface NestedResourceTableProps {
|
||||||
rows: ResourceRowGroup;
|
rows: ResourceRowGroup;
|
||||||
@ -13,6 +13,7 @@ interface NestedResourceTableProps {
|
|||||||
noHeader?: boolean;
|
noHeader?: boolean;
|
||||||
requestNestedRows: (row: ResourceRow) => Promise<void>;
|
requestNestedRows: (row: ResourceRow) => Promise<void>;
|
||||||
onRowSelectedChange: (row: ResourceRow, selected: boolean) => void;
|
onRowSelectedChange: (row: ResourceRow, selected: boolean) => void;
|
||||||
|
selectableEntryTypes: ResourceRowType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const NestedResourceTable: React.FC<NestedResourceTableProps> = ({
|
const NestedResourceTable: React.FC<NestedResourceTableProps> = ({
|
||||||
@ -21,6 +22,7 @@ const NestedResourceTable: React.FC<NestedResourceTableProps> = ({
|
|||||||
noHeader,
|
noHeader,
|
||||||
requestNestedRows,
|
requestNestedRows,
|
||||||
onRowSelectedChange,
|
onRowSelectedChange,
|
||||||
|
selectableEntryTypes,
|
||||||
}) => {
|
}) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
@ -47,6 +49,7 @@ const NestedResourceTable: React.FC<NestedResourceTableProps> = ({
|
|||||||
level={0}
|
level={0}
|
||||||
requestNestedRows={requestNestedRows}
|
requestNestedRows={requestNestedRows}
|
||||||
onRowSelectedChange={onRowSelectedChange}
|
onRowSelectedChange={onRowSelectedChange}
|
||||||
|
selectableEntryTypes={selectableEntryTypes}
|
||||||
/>
|
/>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
@ -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(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<NestedRow {...defaultProps} />
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
const box = screen.queryByRole('checkbox');
|
||||||
|
expect(box).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should display a checkbox when the type of row is in selectableEntryTypes', () => {
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<NestedRow {...defaultProps} selectableEntryTypes={[ResourceRowType.Resource]} />
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
const box = screen.queryByRole('checkbox');
|
||||||
|
expect(box).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not display a checkbox when the type of row is not in selectableEntryTypes', () => {
|
||||||
|
render(
|
||||||
|
<table>
|
||||||
|
<tbody>
|
||||||
|
<NestedRow {...defaultProps} selectableEntryTypes={[ResourceRowType.ResourceGroup]} />
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
);
|
||||||
|
const box = screen.queryByRole('checkbox');
|
||||||
|
expect(box).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
@ -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<void>;
|
||||||
|
onRowSelectedChange: (row: ResourceRow, selected: boolean) => void;
|
||||||
|
selectableEntryTypes: ResourceRowType[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const NestedRow: React.FC<NestedRowProps> = ({
|
||||||
|
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 (
|
||||||
|
<>
|
||||||
|
<tr className={cx(styles.row, isDisabled && styles.disabledRow)} key={row.id}>
|
||||||
|
<td className={styles.cell}>
|
||||||
|
<NestedEntry
|
||||||
|
level={level}
|
||||||
|
isSelected={isSelected}
|
||||||
|
isDisabled={isDisabled}
|
||||||
|
isOpen={isOpen}
|
||||||
|
entry={row}
|
||||||
|
onToggleCollapse={onRowToggleCollapse}
|
||||||
|
onSelectedChange={onRowSelectedChange}
|
||||||
|
isSelectable={selectableEntryTypes.some((type) => type === row.type)}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className={styles.cell}>{row.typeLabel}</td>
|
||||||
|
|
||||||
|
<td className={styles.cell}>{row.location ?? '-'}</td>
|
||||||
|
</tr>
|
||||||
|
|
||||||
|
{isOpen && row.children && Object.keys(row.children).length > 0 && (
|
||||||
|
<NestedRows
|
||||||
|
rows={row.children}
|
||||||
|
selectedRows={selectedRows}
|
||||||
|
level={level + 1}
|
||||||
|
requestNestedRows={requestNestedRows}
|
||||||
|
onRowSelectedChange={onRowSelectedChange}
|
||||||
|
selectableEntryTypes={selectableEntryTypes}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<FadeTransition visible={rowStatus === 'loading'}>
|
||||||
|
<tr>
|
||||||
|
<td className={cx(styles.cell, styles.loadingCell)} colSpan={3}>
|
||||||
|
<LoadingPlaceholder text="Loading..." className={styles.spinner} />
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</FadeTransition>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default NestedRow;
|
@ -1,11 +1,7 @@
|
|||||||
import { cx } from '@emotion/css';
|
import React from 'react';
|
||||||
import { Checkbox, FadeTransition, Icon, IconButton, LoadingPlaceholder, useStyles2, useTheme2 } from '@grafana/ui';
|
import NestedRow from './NestedRow';
|
||||||
import React, { useCallback, useEffect, useState } from 'react';
|
|
||||||
|
|
||||||
import { Space } from '../Space';
|
|
||||||
import getStyles from './styles';
|
|
||||||
import { ResourceRow, ResourceRowGroup, ResourceRowType } from './types';
|
import { ResourceRow, ResourceRowGroup, ResourceRowType } from './types';
|
||||||
import { findRow } from './utils';
|
|
||||||
|
|
||||||
interface NestedRowsProps {
|
interface NestedRowsProps {
|
||||||
rows: ResourceRowGroup;
|
rows: ResourceRowGroup;
|
||||||
@ -13,6 +9,7 @@ interface NestedRowsProps {
|
|||||||
selectedRows: ResourceRowGroup;
|
selectedRows: ResourceRowGroup;
|
||||||
requestNestedRows: (row: ResourceRow) => Promise<void>;
|
requestNestedRows: (row: ResourceRow) => Promise<void>;
|
||||||
onRowSelectedChange: (row: ResourceRow, selected: boolean) => void;
|
onRowSelectedChange: (row: ResourceRow, selected: boolean) => void;
|
||||||
|
selectableEntryTypes: ResourceRowType[];
|
||||||
}
|
}
|
||||||
|
|
||||||
const NestedRows: React.FC<NestedRowsProps> = ({
|
const NestedRows: React.FC<NestedRowsProps> = ({
|
||||||
@ -21,6 +18,7 @@ const NestedRows: React.FC<NestedRowsProps> = ({
|
|||||||
level,
|
level,
|
||||||
requestNestedRows,
|
requestNestedRows,
|
||||||
onRowSelectedChange,
|
onRowSelectedChange,
|
||||||
|
selectableEntryTypes,
|
||||||
}) => (
|
}) => (
|
||||||
<>
|
<>
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
@ -31,208 +29,10 @@ const NestedRows: React.FC<NestedRowsProps> = ({
|
|||||||
level={level}
|
level={level}
|
||||||
requestNestedRows={requestNestedRows}
|
requestNestedRows={requestNestedRows}
|
||||||
onRowSelectedChange={onRowSelectedChange}
|
onRowSelectedChange={onRowSelectedChange}
|
||||||
|
selectableEntryTypes={selectableEntryTypes}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
interface NestedRowProps {
|
|
||||||
row: ResourceRow;
|
|
||||||
level: number;
|
|
||||||
selectedRows: ResourceRowGroup;
|
|
||||||
requestNestedRows: (row: ResourceRow) => Promise<void>;
|
|
||||||
onRowSelectedChange: (row: ResourceRow, selected: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const NestedRow: React.FC<NestedRowProps> = ({ 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 (
|
|
||||||
<>
|
|
||||||
<tr className={cx(styles.row, isDisabled && styles.disabledRow)} key={row.id}>
|
|
||||||
<td className={styles.cell}>
|
|
||||||
<NestedEntry
|
|
||||||
level={level}
|
|
||||||
isSelected={isSelected}
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
isOpen={isOpen}
|
|
||||||
entry={row}
|
|
||||||
onToggleCollapse={onRowToggleCollapse}
|
|
||||||
onSelectedChange={onRowSelectedChange}
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className={styles.cell}>{row.typeLabel}</td>
|
|
||||||
|
|
||||||
<td className={styles.cell}>{row.location ?? '-'}</td>
|
|
||||||
</tr>
|
|
||||||
|
|
||||||
{isOpen && row.children && Object.keys(row.children).length > 0 && (
|
|
||||||
<NestedRows
|
|
||||||
rows={row.children}
|
|
||||||
selectedRows={selectedRows}
|
|
||||||
level={level + 1}
|
|
||||||
requestNestedRows={requestNestedRows}
|
|
||||||
onRowSelectedChange={onRowSelectedChange}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FadeTransition visible={rowStatus === 'loading'}>
|
|
||||||
<tr>
|
|
||||||
<td className={cx(styles.cell, styles.loadingCell)} colSpan={3}>
|
|
||||||
<LoadingPlaceholder text="Loading..." className={styles.spinner} />
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
</FadeTransition>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
interface EntryIconProps {
|
|
||||||
entry: ResourceRow;
|
|
||||||
isOpen: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EntryIcon: React.FC<EntryIconProps> = ({ isOpen, entry: { type } }) => {
|
|
||||||
switch (type) {
|
|
||||||
case ResourceRowType.Subscription:
|
|
||||||
return <Icon name="layer-group" />;
|
|
||||||
|
|
||||||
case ResourceRowType.ResourceGroup:
|
|
||||||
return <Icon name={isOpen ? 'folder-open' : 'folder'} />;
|
|
||||||
|
|
||||||
case ResourceRowType.Resource:
|
|
||||||
return <Icon name="cube" />;
|
|
||||||
|
|
||||||
case ResourceRowType.VariableGroup:
|
|
||||||
return <Icon name="x" />;
|
|
||||||
|
|
||||||
case ResourceRowType.Variable:
|
|
||||||
return <Icon name="x" />;
|
|
||||||
|
|
||||||
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<NestedEntryProps> = ({
|
|
||||||
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<HTMLInputElement>) => {
|
|
||||||
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 (
|
|
||||||
<div className={styles.nestedEntry} style={{ marginLeft: level * (3 * theme.spacing.gridSize) }}>
|
|
||||||
{/* 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 ? (
|
|
||||||
<IconButton
|
|
||||||
className={styles.collapseButton}
|
|
||||||
name={isOpen ? 'angle-down' : 'angle-right'}
|
|
||||||
aria-label={isOpen ? `Collapse ${entry.name}` : `Expand ${entry.name}`}
|
|
||||||
onClick={handleToggleCollapse}
|
|
||||||
id={entry.id}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Space layout="inline" h={2} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Space layout="inline" h={2} />
|
|
||||||
|
|
||||||
{isSelectable && (
|
|
||||||
<>
|
|
||||||
<Checkbox
|
|
||||||
id={checkboxId}
|
|
||||||
onChange={handleSelectedChanged}
|
|
||||||
disabled={isDisabled}
|
|
||||||
value={isSelected}
|
|
||||||
className={styles.nestedRowCheckbox}
|
|
||||||
/>
|
|
||||||
<Space layout="inline" h={2} />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<EntryIcon entry={entry} isOpen={isOpen} />
|
|
||||||
<Space layout="inline" h={1} />
|
|
||||||
|
|
||||||
<label htmlFor={checkboxId} className={cx(styles.entryContentItem, styles.truncated)}>
|
|
||||||
{entry.name}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NestedRows;
|
export default NestedRows;
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { render, screen } from '@testing-library/react';
|
import { act, render, screen } from '@testing-library/react';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import ResourcePicker from '.';
|
import ResourcePicker from '.';
|
||||||
@ -8,6 +8,7 @@ import {
|
|||||||
createMockSubscriptions,
|
createMockSubscriptions,
|
||||||
mockResourcesByResourceGroup,
|
mockResourcesByResourceGroup,
|
||||||
} from '../../__mocks__/resourcePickerRows';
|
} from '../../__mocks__/resourcePickerRows';
|
||||||
|
import { ResourceRowType } from './types';
|
||||||
|
|
||||||
const noResourceURI = '';
|
const noResourceURI = '';
|
||||||
const singleSubscriptionSelectionURI = '/subscriptions/def-456';
|
const singleSubscriptionSelectionURI = '/subscriptions/def-456';
|
||||||
@ -15,28 +16,31 @@ const singleResourceGroupSelectionURI = '/subscriptions/def-456/resourceGroups/d
|
|||||||
const singleResourceSelectionURI =
|
const singleResourceSelectionURI =
|
||||||
'/subscriptions/def-456/resourceGroups/dev-3/providers/Microsoft.Compute/virtualMachines/db-server';
|
'/subscriptions/def-456/resourceGroups/dev-3/providers/Microsoft.Compute/virtualMachines/db-server';
|
||||||
|
|
||||||
const createResourcePickerDataMock = () => {
|
const noop: any = () => {};
|
||||||
return createMockResourcePickerData({
|
const defaultProps = {
|
||||||
|
templateVariables: [],
|
||||||
|
resourceURI: noResourceURI,
|
||||||
|
resourcePickerData: createMockResourcePickerData({
|
||||||
getSubscriptions: jest.fn().mockResolvedValue(createMockSubscriptions()),
|
getSubscriptions: jest.fn().mockResolvedValue(createMockSubscriptions()),
|
||||||
getResourceGroupsBySubscriptionId: jest.fn().mockResolvedValue(createMockResourceGroupsBySubscription()),
|
getResourceGroupsBySubscriptionId: jest.fn().mockResolvedValue(createMockResourceGroupsBySubscription()),
|
||||||
getResourcesForResourceGroup: jest.fn().mockResolvedValue(mockResourcesByResourceGroup()),
|
getResourcesForResourceGroup: jest.fn().mockResolvedValue(mockResourcesByResourceGroup()),
|
||||||
});
|
}),
|
||||||
|
onCancel: noop,
|
||||||
|
onApply: noop,
|
||||||
|
selectableEntryTypes: [
|
||||||
|
ResourceRowType.Subscription,
|
||||||
|
ResourceRowType.ResourceGroup,
|
||||||
|
ResourceRowType.Resource,
|
||||||
|
ResourceRowType.Variable,
|
||||||
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('AzureMonitor ResourcePicker', () => {
|
describe('AzureMonitor ResourcePicker', () => {
|
||||||
const noop: any = () => {};
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
window.HTMLElement.prototype.scrollIntoView = function () {};
|
window.HTMLElement.prototype.scrollIntoView = function () {};
|
||||||
});
|
});
|
||||||
it('should pre-load subscriptions when there is no existing selection', async () => {
|
it('should pre-load subscriptions when there is no existing selection', async () => {
|
||||||
render(
|
render(<ResourcePicker {...defaultProps} resourceURI={noResourceURI} />);
|
||||||
<ResourcePicker
|
|
||||||
templateVariables={[]}
|
|
||||||
resourcePickerData={createResourcePickerDataMock()}
|
|
||||||
resourceURI={noResourceURI}
|
|
||||||
onCancel={noop}
|
|
||||||
onApply={noop}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
const subscriptionCheckbox = await screen.findByLabelText('Primary Subscription');
|
const subscriptionCheckbox = await screen.findByLabelText('Primary Subscription');
|
||||||
expect(subscriptionCheckbox).toBeInTheDocument();
|
expect(subscriptionCheckbox).toBeInTheDocument();
|
||||||
expect(subscriptionCheckbox).not.toBeChecked();
|
expect(subscriptionCheckbox).not.toBeChecked();
|
||||||
@ -45,58 +49,25 @@ describe('AzureMonitor ResourcePicker', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should show a subscription as selected if there is one saved', async () => {
|
it('should show a subscription as selected if there is one saved', async () => {
|
||||||
render(
|
render(<ResourcePicker {...defaultProps} resourceURI={singleSubscriptionSelectionURI} />);
|
||||||
<ResourcePicker
|
|
||||||
templateVariables={[]}
|
|
||||||
resourcePickerData={createResourcePickerDataMock()}
|
|
||||||
resourceURI={singleSubscriptionSelectionURI}
|
|
||||||
onCancel={noop}
|
|
||||||
onApply={noop}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
const subscriptionCheckbox = await screen.findByLabelText('Dev Subscription');
|
const subscriptionCheckbox = await screen.findByLabelText('Dev Subscription');
|
||||||
expect(subscriptionCheckbox).toBeChecked();
|
expect(subscriptionCheckbox).toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show a resource group as selected if there is one saved', async () => {
|
it('should show a resourceGroup as selected if there is one saved', async () => {
|
||||||
render(
|
render(<ResourcePicker {...defaultProps} resourceURI={singleResourceGroupSelectionURI} />);
|
||||||
<ResourcePicker
|
|
||||||
templateVariables={[]}
|
|
||||||
resourcePickerData={createResourcePickerDataMock()}
|
|
||||||
resourceURI={singleResourceGroupSelectionURI}
|
|
||||||
onCancel={noop}
|
|
||||||
onApply={noop}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
const resourceGroupCheckbox = await screen.findByLabelText('A Great Resource Group');
|
const resourceGroupCheckbox = await screen.findByLabelText('A Great Resource Group');
|
||||||
expect(resourceGroupCheckbox).toBeChecked();
|
expect(resourceGroupCheckbox).toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should show a resource as selected if there is one saved', async () => {
|
it('should show a resource as selected if there is one saved', async () => {
|
||||||
render(
|
render(<ResourcePicker {...defaultProps} resourceURI={singleResourceSelectionURI} />);
|
||||||
<ResourcePicker
|
|
||||||
templateVariables={[]}
|
|
||||||
resourcePickerData={createResourcePickerDataMock()}
|
|
||||||
resourceURI={singleResourceSelectionURI}
|
|
||||||
onCancel={noop}
|
|
||||||
onApply={noop}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const resourceCheckbox = await screen.findByLabelText('db-server');
|
const resourceCheckbox = await screen.findByLabelText('db-server');
|
||||||
expect(resourceCheckbox).toBeChecked();
|
expect(resourceCheckbox).toBeChecked();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be able to expand a subscription when clicked and reveal resource groups', async () => {
|
it('should be able to expand a subscription when clicked and reveal resource groups', async () => {
|
||||||
render(
|
render(<ResourcePicker {...defaultProps} />);
|
||||||
<ResourcePicker
|
|
||||||
templateVariables={[]}
|
|
||||||
resourcePickerData={createResourcePickerDataMock()}
|
|
||||||
resourceURI={noResourceURI}
|
|
||||||
onCancel={noop}
|
|
||||||
onApply={noop}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
const expandSubscriptionButton = await screen.findByLabelText('Expand Primary Subscription');
|
const expandSubscriptionButton = await screen.findByLabelText('Expand Primary Subscription');
|
||||||
expect(expandSubscriptionButton).toBeInTheDocument();
|
expect(expandSubscriptionButton).toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText('A Great Resource Group')).not.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 () => {
|
it('should call onApply with a new subscription uri when a user selects it', async () => {
|
||||||
const onApply = jest.fn();
|
const onApply = jest.fn();
|
||||||
render(
|
render(<ResourcePicker {...defaultProps} onApply={onApply} />);
|
||||||
<ResourcePicker
|
|
||||||
templateVariables={[]}
|
|
||||||
resourcePickerData={createResourcePickerDataMock()}
|
|
||||||
resourceURI={noResourceURI}
|
|
||||||
onCancel={noop}
|
|
||||||
onApply={onApply}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
const subscriptionCheckbox = await screen.findByLabelText('Primary Subscription');
|
const subscriptionCheckbox = await screen.findByLabelText('Primary Subscription');
|
||||||
expect(subscriptionCheckbox).toBeInTheDocument();
|
expect(subscriptionCheckbox).toBeInTheDocument();
|
||||||
expect(subscriptionCheckbox).not.toBeChecked();
|
expect(subscriptionCheckbox).not.toBeChecked();
|
||||||
@ -124,18 +87,9 @@ describe('AzureMonitor ResourcePicker', () => {
|
|||||||
expect(onApply).toBeCalledTimes(1);
|
expect(onApply).toBeCalledTimes(1);
|
||||||
expect(onApply).toBeCalledWith('/subscriptions/def-123');
|
expect(onApply).toBeCalledWith('/subscriptions/def-123');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should call onApply with a template variable when a user selects it', async () => {
|
it('should call onApply with a template variable when a user selects it', async () => {
|
||||||
const onApply = jest.fn();
|
const onApply = jest.fn();
|
||||||
render(
|
render(<ResourcePicker {...defaultProps} templateVariables={['$workspace']} onApply={onApply} />);
|
||||||
<ResourcePicker
|
|
||||||
templateVariables={['$workspace']}
|
|
||||||
resourcePickerData={createResourcePickerDataMock()}
|
|
||||||
resourceURI={noResourceURI}
|
|
||||||
onCancel={noop}
|
|
||||||
onApply={onApply}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const expandButton = await screen.findByLabelText('Expand Template variables');
|
const expandButton = await screen.findByLabelText('Expand Template variables');
|
||||||
expandButton.click();
|
expandButton.click();
|
||||||
@ -149,4 +103,14 @@ describe('AzureMonitor ResourcePicker', () => {
|
|||||||
expect(onApply).toBeCalledTimes(1);
|
expect(onApply).toBeCalledTimes(1);
|
||||||
expect(onApply).toBeCalledWith('$workspace');
|
expect(onApply).toBeCalledWith('$workspace');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('when rendering resource picker without any selectable entry types', () => {
|
||||||
|
it('renders no checkboxes', async () => {
|
||||||
|
await act(async () => {
|
||||||
|
render(<ResourcePicker {...defaultProps} selectableEntryTypes={[]} />);
|
||||||
|
});
|
||||||
|
const checkboxes = screen.queryAllByRole('checkbox');
|
||||||
|
expect(checkboxes.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -15,6 +15,7 @@ interface ResourcePickerProps {
|
|||||||
resourcePickerData: ResourcePickerData;
|
resourcePickerData: ResourcePickerData;
|
||||||
resourceURI: string | undefined;
|
resourceURI: string | undefined;
|
||||||
templateVariables: string[];
|
templateVariables: string[];
|
||||||
|
selectableEntryTypes: ResourceRowType[];
|
||||||
|
|
||||||
onApply: (resourceURI: string | undefined) => void;
|
onApply: (resourceURI: string | undefined) => void;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
@ -26,6 +27,7 @@ const ResourcePicker = ({
|
|||||||
templateVariables,
|
templateVariables,
|
||||||
onApply,
|
onApply,
|
||||||
onCancel,
|
onCancel,
|
||||||
|
selectableEntryTypes,
|
||||||
}: ResourcePickerProps) => {
|
}: ResourcePickerProps) => {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
@ -154,6 +156,7 @@ const ResourcePicker = ({
|
|||||||
requestNestedRows={requestNestedRows}
|
requestNestedRows={requestNestedRows}
|
||||||
onRowSelectedChange={handleSelectionChanged}
|
onRowSelectedChange={handleSelectionChanged}
|
||||||
selectedRows={selectedResourceRows}
|
selectedRows={selectedResourceRows}
|
||||||
|
selectableEntryTypes={selectableEntryTypes}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className={styles.selectionFooter}>
|
<div className={styles.selectionFooter}>
|
||||||
@ -167,6 +170,7 @@ const ResourcePicker = ({
|
|||||||
onRowSelectedChange={handleSelectionChanged}
|
onRowSelectedChange={handleSelectionChanged}
|
||||||
selectedRows={selectedResourceRows}
|
selectedRows={selectedResourceRows}
|
||||||
noHeader={true}
|
noHeader={true}
|
||||||
|
selectableEntryTypes={selectableEntryTypes}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
Reference in New Issue
Block a user