Scopes: Implement radio buttons for containers with single node selection (#89674)

This commit is contained in:
Bogdan Matei 2024-06-25 17:10:36 +03:00 committed by GitHub
parent 71270f3203
commit e4b9f356bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 54 additions and 24 deletions

View File

@ -5,7 +5,8 @@ import { GrafanaTheme2 } from '@grafana/data';
import { useStyles2 } from '../../../themes'; import { useStyles2 } from '../../../themes';
export interface RadioButtonDotProps<T> { export interface RadioButtonDotProps<T>
extends Omit<React.HTMLProps<HTMLInputElement>, 'label' | 'value' | 'onChange' | 'type'> {
id: string; id: string;
name: string; name: string;
checked?: boolean; checked?: boolean;
@ -25,12 +26,14 @@ export const RadioButtonDot = <T extends string | number | readonly string[]>({
disabled, disabled,
description, description,
onChange, onChange,
...props
}: RadioButtonDotProps<T>) => { }: RadioButtonDotProps<T>) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
return ( return (
<label title={description} className={styles.label}> <label title={description} className={styles.label}>
<input <input
{...props}
id={id} id={id}
name={name} name={name}
type="radio" type="radio"

View File

@ -238,6 +238,7 @@ export * from './Select/types';
export { HorizontalGroup, VerticalGroup, Container } from './Layout/Layout'; export { HorizontalGroup, VerticalGroup, Container } from './Layout/Layout';
export { Badge, type BadgeColor, type BadgeProps } from './Badge/Badge'; export { Badge, type BadgeColor, type BadgeProps } from './Badge/Badge';
export { RadioButtonGroup } from './Forms/RadioButtonGroup/RadioButtonGroup'; export { RadioButtonGroup } from './Forms/RadioButtonGroup/RadioButtonGroup';
export { RadioButtonDot } from './Forms/RadioButtonList/RadioButtonDot';
export { RadioButtonList } from './Forms/RadioButtonList/RadioButtonList'; export { RadioButtonList } from './Forms/RadioButtonList/RadioButtonList';
export { Input, getInputStyles } from './Input/Input'; export { Input, getInputStyles } from './Input/Input';

View File

@ -131,14 +131,14 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
public toggleNodeSelect(path: string[]) { public toggleNodeSelect(path: string[]) {
let treeScopes = [...this.state.treeScopes]; let treeScopes = [...this.state.treeScopes];
let siblings = this.state.nodes; let parentNode = this.state.nodes[''];
for (let idx = 0; idx < path.length - 1; idx++) { for (let idx = 1; idx < path.length - 1; idx++) {
siblings = siblings[path[idx]].nodes; parentNode = parentNode.nodes[path[idx]];
} }
const nodeName = path[path.length - 1]; const nodeName = path[path.length - 1];
const { linkId } = siblings[nodeName]; const { linkId } = parentNode.nodes[nodeName];
const selectedIdx = treeScopes.findIndex(({ scopeName }) => scopeName === linkId); const selectedIdx = treeScopes.findIndex(({ scopeName }) => scopeName === linkId);
@ -146,14 +146,17 @@ export class ScopesFiltersScene extends SceneObjectBase<ScopesFiltersSceneState>
fetchScope(linkId!); fetchScope(linkId!);
const selectedFromSameNode = const selectedFromSameNode =
treeScopes.length === 0 || Object.values(siblings).some(({ linkId }) => linkId === treeScopes[0].scopeName); treeScopes.length === 0 ||
Object.values(parentNode.nodes).some(({ linkId }) => linkId === treeScopes[0].scopeName);
const treeScope = { const treeScope = {
scopeName: linkId!, scopeName: linkId!,
path, path,
}; };
this.setState({ treeScopes: !selectedFromSameNode ? [treeScope] : [...treeScopes, treeScope] }); this.setState({
treeScopes: parentNode?.disableMultiSelect || !selectedFromSameNode ? [treeScope] : [...treeScopes, treeScope],
});
} else { } else {
treeScopes.splice(selectedIdx, 1); treeScopes.splice(selectedIdx, 1);

View File

@ -28,8 +28,8 @@ import {
getFiltersInput, getFiltersInput,
getClustersExpand, getClustersExpand,
getClustersSelect, getClustersSelect,
getClustersSlothClusterNorthSelect, getClustersSlothClusterNorthRadio,
getClustersSlothClusterSouthSelect, getClustersSlothClusterSouthRadio,
getDashboard, getDashboard,
getDashboardsContainer, getDashboardsContainer,
getDashboardsExpand, getDashboardsExpand,
@ -158,8 +158,12 @@ describe('ScopesScene', () => {
it('Respects only one select per container', async () => { it('Respects only one select per container', async () => {
await userEvents.click(getFiltersInput()); await userEvents.click(getFiltersInput());
await userEvents.click(getClustersExpand()); await userEvents.click(getClustersExpand());
await userEvents.click(getClustersSlothClusterNorthSelect()); await userEvents.click(getClustersSlothClusterNorthRadio());
expect(getClustersSlothClusterSouthSelect()).toBeDisabled(); expect(getClustersSlothClusterNorthRadio().checked).toBe(true);
expect(getClustersSlothClusterSouthRadio().checked).toBe(false);
await userEvents.click(getClustersSlothClusterSouthRadio());
expect(getClustersSlothClusterNorthRadio().checked).toBe(false);
expect(getClustersSlothClusterSouthRadio().checked).toBe(true);
}); });
it('Search works', async () => { it('Search works', async () => {

View File

@ -4,7 +4,7 @@ import { useMemo } from 'react';
import Skeleton from 'react-loading-skeleton'; import Skeleton from 'react-loading-skeleton';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { Checkbox, Icon, IconButton, Input, useStyles2 } from '@grafana/ui'; import { Checkbox, Icon, IconButton, Input, RadioButtonDot, useStyles2 } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization'; import { t, Trans } from 'app/core/internationalization';
import { NodesMap, TreeScope } from './types'; import { NodesMap, TreeScope } from './types';
@ -72,18 +72,32 @@ export function ScopesTreeLevel({
const childNodePath = [...nodePath, childNode.name]; const childNodePath = [...nodePath, childNode.name];
const radioName = childNodePath.join('.');
return ( return (
<div key={childNode.name} role="treeitem" aria-selected={childNode.isExpanded}> <div key={childNode.name} role="treeitem" aria-selected={childNode.isExpanded}>
<div className={styles.itemTitle}> <div className={styles.itemTitle}>
{childNode.isSelectable && !childNode.isExpanded ? ( {childNode.isSelectable && !childNode.isExpanded ? (
<Checkbox node.disableMultiSelect ? (
checked={isSelected} <RadioButtonDot
disabled={anyChildSelected && !isSelected && node.disableMultiSelect} id={radioName}
data-testid={`scopes-tree-${childNode.name}-checkbox`} name={radioName}
onChange={() => { checked={isSelected}
onNodeSelectToggle(childNodePath); label=""
}} data-testid={`scopes-tree-${childNode.name}-radio`}
/> onClick={() => {
onNodeSelectToggle(childNodePath);
}}
/>
) : (
<Checkbox
checked={isSelected}
data-testid={`scopes-tree-${childNode.name}-checkbox`}
onChange={() => {
onNodeSelectToggle(childNodePath);
}}
/>
)
) : null} ) : null}
{childNode.isExpandable && ( {childNode.isExpandable && (
@ -142,6 +156,10 @@ const getStyles = (theme: GrafanaTheme2) => {
fontSize: theme.typography.pxToRem(14), fontSize: theme.typography.pxToRem(14),
lineHeight: theme.typography.pxToRem(22), lineHeight: theme.typography.pxToRem(22),
padding: theme.spacing(0.5, 0), padding: theme.spacing(0.5, 0),
'& > label': css({
gap: 0,
}),
}), }),
itemChildren: css({ itemChildren: css({
paddingLeft: theme.spacing(4), paddingLeft: theme.spacing(4),

View File

@ -295,6 +295,7 @@ const selectors = {
tree: { tree: {
search: (nodeId: string) => `scopes-tree-${nodeId}-search`, search: (nodeId: string) => `scopes-tree-${nodeId}-search`,
select: (nodeId: string) => `scopes-tree-${nodeId}-checkbox`, select: (nodeId: string) => `scopes-tree-${nodeId}-checkbox`,
radio: (nodeId: string) => `scopes-tree-${nodeId}-radio`,
expand: (nodeId: string) => `scopes-tree-${nodeId}-expand`, expand: (nodeId: string) => `scopes-tree-${nodeId}-expand`,
title: (nodeId: string) => `scopes-tree-${nodeId}-title`, title: (nodeId: string) => `scopes-tree-${nodeId}-title`,
}, },
@ -354,10 +355,10 @@ export const getApplicationsClustersSlothClusterSouthSelect = () =>
export const getClustersSelect = () => screen.getByTestId(selectors.tree.select('clusters')); export const getClustersSelect = () => screen.getByTestId(selectors.tree.select('clusters'));
export const getClustersExpand = () => screen.getByTestId(selectors.tree.expand('clusters')); export const getClustersExpand = () => screen.getByTestId(selectors.tree.expand('clusters'));
export const getClustersSlothClusterNorthSelect = () => export const getClustersSlothClusterNorthRadio = () =>
screen.getByTestId(selectors.tree.select('clusters-slothClusterNorth')); screen.getByTestId<HTMLInputElement>(selectors.tree.radio('clusters-slothClusterNorth'));
export const getClustersSlothClusterSouthSelect = () => export const getClustersSlothClusterSouthRadio = () =>
screen.getByTestId(selectors.tree.select('clusters-slothClusterSouth')); screen.getByTestId<HTMLInputElement>(selectors.tree.radio('clusters-slothClusterSouth'));
export function buildTestScene(overrides: Partial<DashboardScene> = {}) { export function buildTestScene(overrides: Partial<DashboardScene> = {}) {
return new DashboardScene({ return new DashboardScene({