Skip to content

Commit 32d3e89

Browse files
authored
NestedFolders: Indicate when folders have mixed-selection children (grafana#67337)
* Show indeterminate checkbox for folders with partially selected children * When selecting an item, check ancestors to see if all their children are now selected * reword comment * fix test * fix lint * Check all descendants for mixed state * Use indeterminate checkbox * fix test description * make header checkbox select/unselect automatically * mixed header checkbox: * fix tests * add tests
1 parent 15d4169 commit 32d3e89

File tree

11 files changed

+344
-91
lines changed

11 files changed

+344
-91
lines changed

public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,10 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
7575

7676
it('displays the filters and hides the actions initially', async () => {
7777
render(<BrowseDashboardsPage {...props} />);
78+
await screen.findByPlaceholderText('Search for dashboards and folders');
7879

79-
expect(await screen.findByText('Sort')).toBeInTheDocument();
80-
expect(await screen.findByText('Filter by tag')).toBeInTheDocument();
80+
expect(screen.queryByText('Sort')).toBeInTheDocument();
81+
expect(screen.queryByText('Filter by tag')).toBeInTheDocument();
8182

8283
expect(screen.queryByRole('button', { name: 'Move' })).not.toBeInTheDocument();
8384
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();

public/app/features/browse-dashboards/components/BrowseView.test.tsx

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,26 @@ describe('browse-dashboards BrowseView', () => {
116116
const grandparentCheckbox = screen.queryByTestId(selectors.pages.BrowseDashbards.table.checkbox(folderA.item.uid));
117117
expect(grandparentCheckbox).not.toBeChecked();
118118
});
119+
120+
it('shows indeterminate checkboxes when a descendant is selected', async () => {
121+
render(<BrowseView canSelect={true} folderUID={undefined} width={WIDTH} height={HEIGHT} />);
122+
await screen.findByText(folderA.item.title);
123+
124+
await expandFolder(folderA.item.uid);
125+
await expandFolder(folderA_folderB.item.uid);
126+
127+
await clickCheckbox(folderA_folderB_dashbdB.item.uid);
128+
129+
const parentCheckbox = screen.queryByTestId(
130+
selectors.pages.BrowseDashbards.table.checkbox(folderA_folderB.item.uid)
131+
);
132+
expect(parentCheckbox).not.toBeChecked();
133+
expect(parentCheckbox).toBePartiallyChecked();
134+
135+
const grandparentCheckbox = screen.queryByTestId(selectors.pages.BrowseDashbards.table.checkbox(folderA.item.uid));
136+
expect(grandparentCheckbox).not.toBeChecked();
137+
expect(grandparentCheckbox).toBePartiallyChecked();
138+
});
119139
});
120140

121141
async function expandFolder(uid: string) {

public/app/features/browse-dashboards/components/BrowseView.tsx

Lines changed: 72 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ import {
99
fetchChildren,
1010
setFolderOpenState,
1111
setItemSelectionState,
12+
useChildrenByParentUIDState,
1213
setAllSelection,
1314
} from '../state';
15+
import { DashboardTreeSelection, SelectionState } from '../types';
1416

1517
import { DashboardsTree } from './DashboardsTree';
1618

@@ -25,10 +27,7 @@ export function BrowseView({ folderUID, width, height, canSelect }: BrowseViewPr
2527
const dispatch = useDispatch();
2628
const flatTree = useFlatTreeState(folderUID);
2729
const selectedItems = useCheckboxSelectionState();
28-
29-
useEffect(() => {
30-
dispatch(fetchChildren(folderUID));
31-
}, [dispatch, folderUID]);
30+
const childrenByParentUID = useChildrenByParentUIDState();
3231

3332
const handleFolderClick = useCallback(
3433
(clickedFolderUID: string, isOpen: boolean) => {
@@ -41,23 +40,91 @@ export function BrowseView({ folderUID, width, height, canSelect }: BrowseViewPr
4140
[dispatch]
4241
);
4342

43+
useEffect(() => {
44+
dispatch(fetchChildren(folderUID));
45+
}, [handleFolderClick, dispatch, folderUID]);
46+
4447
const handleItemSelectionChange = useCallback(
4548
(item: DashboardViewItem, isSelected: boolean) => {
4649
dispatch(setItemSelectionState({ item, isSelected }));
4750
},
4851
[dispatch]
4952
);
5053

54+
const isSelected = useCallback(
55+
(item: DashboardViewItem | '$all'): SelectionState => {
56+
if (item === '$all') {
57+
// We keep the boolean $all state up to date in redux, so we can short-circut
58+
// the logic if we know this has been selected
59+
if (selectedItems.$all) {
60+
return SelectionState.Selected;
61+
}
62+
63+
// Otherwise, if we have any selected items, then it should be in 'mixed' state
64+
for (const selection of Object.values(selectedItems)) {
65+
if (typeof selection === 'boolean') {
66+
continue;
67+
}
68+
69+
for (const uid in selection) {
70+
const isSelected = selection[uid];
71+
if (isSelected) {
72+
return SelectionState.Mixed;
73+
}
74+
}
75+
}
76+
77+
// Otherwise otherwise, nothing is selected and header should be unselected
78+
return SelectionState.Unselected;
79+
}
80+
81+
const isSelected = selectedItems[item.kind][item.uid];
82+
if (isSelected) {
83+
return SelectionState.Selected;
84+
}
85+
86+
// Because if _all_ children, then the parent is selected (and bailed in the previous check),
87+
// this .some check will only return true if the children are partially selected
88+
const isMixed = hasSelectedDescendants(item, childrenByParentUID, selectedItems);
89+
if (isMixed) {
90+
return SelectionState.Mixed;
91+
}
92+
93+
return SelectionState.Unselected;
94+
},
95+
[selectedItems, childrenByParentUID]
96+
);
97+
5198
return (
5299
<DashboardsTree
53100
canSelect={canSelect}
54101
items={flatTree}
55102
width={width}
56103
height={height}
57-
selectedItems={selectedItems}
104+
isSelected={isSelected}
58105
onFolderClick={handleFolderClick}
59106
onAllSelectionChange={(newState) => dispatch(setAllSelection({ isSelected: newState }))}
60107
onItemSelectionChange={handleItemSelectionChange}
61108
/>
62109
);
63110
}
111+
112+
function hasSelectedDescendants(
113+
item: DashboardViewItem,
114+
childrenByParentUID: Record<string, DashboardViewItem[] | undefined>,
115+
selectedItems: DashboardTreeSelection
116+
): boolean {
117+
const children = childrenByParentUID[item.uid];
118+
if (!children) {
119+
return false;
120+
}
121+
122+
return children.some((v) => {
123+
const thisIsSelected = selectedItems[v.kind][v.uid];
124+
if (thisIsSelected) {
125+
return thisIsSelected;
126+
}
127+
128+
return hasSelectedDescendants(v, childrenByParentUID, selectedItems);
129+
});
130+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import React from 'react';
2+
3+
import { selectors } from '@grafana/e2e-selectors';
4+
import { Checkbox } from '@grafana/ui';
5+
6+
import { DashboardsTreeCellProps, SelectionState } from '../types';
7+
8+
export default function CheckboxCell({
9+
row: { original: row },
10+
isSelected,
11+
onItemSelectionChange,
12+
}: DashboardsTreeCellProps) {
13+
const item = row.item;
14+
15+
if (item.kind === 'ui-empty-folder' || !isSelected) {
16+
return null;
17+
}
18+
19+
const state = isSelected(item);
20+
21+
return (
22+
<Checkbox
23+
data-testid={selectors.pages.BrowseDashbards.table.checkbox(item.uid)}
24+
value={state === SelectionState.Selected}
25+
indeterminate={state === SelectionState.Mixed}
26+
onChange={(ev) => onItemSelectionChange?.(item, ev.currentTarget.checked)}
27+
/>
28+
);
29+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import React from 'react';
2+
3+
import { Checkbox } from '@grafana/ui';
4+
5+
import { DashboardTreeHeaderProps, SelectionState } from '../types';
6+
7+
export default function CheckboxHeaderCell({ isSelected, onAllSelectionChange }: DashboardTreeHeaderProps) {
8+
const state = isSelected?.('$all') ?? SelectionState.Unselected;
9+
10+
return (
11+
<Checkbox
12+
value={state === SelectionState.Selected}
13+
indeterminate={state === SelectionState.Mixed}
14+
onChange={(ev) => onAllSelectionChange?.(ev.currentTarget.checked)}
15+
/>
16+
);
17+
}

public/app/features/browse-dashboards/components/DashboardsTree.test.tsx

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { assertIsDefined } from 'test/helpers/asserts';
77
import { selectors } from '@grafana/e2e-selectors';
88

99
import { wellFormedDashboard, wellFormedEmptyFolder, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture';
10+
import { SelectionState } from '../types';
1011

1112
import { DashboardsTree } from './DashboardsTree';
1213

@@ -22,19 +23,14 @@ describe('browse-dashboards DashboardsTree', () => {
2223
const emptyFolderIndicator = wellFormedEmptyFolder();
2324
const dashboard = wellFormedDashboard(2);
2425
const noop = () => {};
25-
const selectedItems = {
26-
$all: false,
27-
folder: {},
28-
dashboard: {},
29-
panel: {},
30-
};
26+
const isSelected = () => SelectionState.Unselected;
3127

3228
it('renders a dashboard item', () => {
3329
render(
3430
<DashboardsTree
3531
canSelect
3632
items={[dashboard]}
37-
selectedItems={selectedItems}
33+
isSelected={isSelected}
3834
width={WIDTH}
3935
height={HEIGHT}
4036
onFolderClick={noop}
@@ -53,7 +49,7 @@ describe('browse-dashboards DashboardsTree', () => {
5349
<DashboardsTree
5450
canSelect={false}
5551
items={[dashboard]}
56-
selectedItems={selectedItems}
52+
isSelected={isSelected}
5753
width={WIDTH}
5854
height={HEIGHT}
5955
onFolderClick={noop}
@@ -71,7 +67,7 @@ describe('browse-dashboards DashboardsTree', () => {
7167
<DashboardsTree
7268
canSelect
7369
items={[folder]}
74-
selectedItems={selectedItems}
70+
isSelected={isSelected}
7571
width={WIDTH}
7672
height={HEIGHT}
7773
onFolderClick={noop}
@@ -89,7 +85,7 @@ describe('browse-dashboards DashboardsTree', () => {
8985
<DashboardsTree
9086
canSelect
9187
items={[folder]}
92-
selectedItems={selectedItems}
88+
isSelected={isSelected}
9389
width={WIDTH}
9490
height={HEIGHT}
9591
onFolderClick={handler}
@@ -108,7 +104,7 @@ describe('browse-dashboards DashboardsTree', () => {
108104
<DashboardsTree
109105
canSelect
110106
items={[emptyFolderIndicator]}
111-
selectedItems={selectedItems}
107+
isSelected={isSelected}
112108
width={WIDTH}
113109
height={HEIGHT}
114110
onFolderClick={noop}

0 commit comments

Comments
 (0)