NestedFolders: Indicate when folders have mixed-selection children (#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
This commit is contained in:
@@ -75,9 +75,10 @@ describe('browse-dashboards BrowseDashboardsPage', () => {
|
||||
|
||||
it('displays the filters and hides the actions initially', async () => {
|
||||
render(<BrowseDashboardsPage {...props} />);
|
||||
await screen.findByPlaceholderText('Search for dashboards and folders');
|
||||
|
||||
expect(await screen.findByText('Sort')).toBeInTheDocument();
|
||||
expect(await screen.findByText('Filter by tag')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Sort')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Filter by tag')).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Move' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Delete' })).not.toBeInTheDocument();
|
||||
|
||||
@@ -116,6 +116,26 @@ describe('browse-dashboards BrowseView', () => {
|
||||
const grandparentCheckbox = screen.queryByTestId(selectors.pages.BrowseDashbards.table.checkbox(folderA.item.uid));
|
||||
expect(grandparentCheckbox).not.toBeChecked();
|
||||
});
|
||||
|
||||
it('shows indeterminate checkboxes when a descendant is selected', async () => {
|
||||
render(<BrowseView canSelect={true} folderUID={undefined} width={WIDTH} height={HEIGHT} />);
|
||||
await screen.findByText(folderA.item.title);
|
||||
|
||||
await expandFolder(folderA.item.uid);
|
||||
await expandFolder(folderA_folderB.item.uid);
|
||||
|
||||
await clickCheckbox(folderA_folderB_dashbdB.item.uid);
|
||||
|
||||
const parentCheckbox = screen.queryByTestId(
|
||||
selectors.pages.BrowseDashbards.table.checkbox(folderA_folderB.item.uid)
|
||||
);
|
||||
expect(parentCheckbox).not.toBeChecked();
|
||||
expect(parentCheckbox).toBePartiallyChecked();
|
||||
|
||||
const grandparentCheckbox = screen.queryByTestId(selectors.pages.BrowseDashbards.table.checkbox(folderA.item.uid));
|
||||
expect(grandparentCheckbox).not.toBeChecked();
|
||||
expect(grandparentCheckbox).toBePartiallyChecked();
|
||||
});
|
||||
});
|
||||
|
||||
async function expandFolder(uid: string) {
|
||||
|
||||
@@ -9,8 +9,10 @@ import {
|
||||
fetchChildren,
|
||||
setFolderOpenState,
|
||||
setItemSelectionState,
|
||||
useChildrenByParentUIDState,
|
||||
setAllSelection,
|
||||
} from '../state';
|
||||
import { DashboardTreeSelection, SelectionState } from '../types';
|
||||
|
||||
import { DashboardsTree } from './DashboardsTree';
|
||||
|
||||
@@ -25,10 +27,7 @@ export function BrowseView({ folderUID, width, height, canSelect }: BrowseViewPr
|
||||
const dispatch = useDispatch();
|
||||
const flatTree = useFlatTreeState(folderUID);
|
||||
const selectedItems = useCheckboxSelectionState();
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchChildren(folderUID));
|
||||
}, [dispatch, folderUID]);
|
||||
const childrenByParentUID = useChildrenByParentUIDState();
|
||||
|
||||
const handleFolderClick = useCallback(
|
||||
(clickedFolderUID: string, isOpen: boolean) => {
|
||||
@@ -41,6 +40,10 @@ export function BrowseView({ folderUID, width, height, canSelect }: BrowseViewPr
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchChildren(folderUID));
|
||||
}, [handleFolderClick, dispatch, folderUID]);
|
||||
|
||||
const handleItemSelectionChange = useCallback(
|
||||
(item: DashboardViewItem, isSelected: boolean) => {
|
||||
dispatch(setItemSelectionState({ item, isSelected }));
|
||||
@@ -48,16 +51,80 @@ export function BrowseView({ folderUID, width, height, canSelect }: BrowseViewPr
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const isSelected = useCallback(
|
||||
(item: DashboardViewItem | '$all'): SelectionState => {
|
||||
if (item === '$all') {
|
||||
// We keep the boolean $all state up to date in redux, so we can short-circut
|
||||
// the logic if we know this has been selected
|
||||
if (selectedItems.$all) {
|
||||
return SelectionState.Selected;
|
||||
}
|
||||
|
||||
// Otherwise, if we have any selected items, then it should be in 'mixed' state
|
||||
for (const selection of Object.values(selectedItems)) {
|
||||
if (typeof selection === 'boolean') {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const uid in selection) {
|
||||
const isSelected = selection[uid];
|
||||
if (isSelected) {
|
||||
return SelectionState.Mixed;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise otherwise, nothing is selected and header should be unselected
|
||||
return SelectionState.Unselected;
|
||||
}
|
||||
|
||||
const isSelected = selectedItems[item.kind][item.uid];
|
||||
if (isSelected) {
|
||||
return SelectionState.Selected;
|
||||
}
|
||||
|
||||
// Because if _all_ children, then the parent is selected (and bailed in the previous check),
|
||||
// this .some check will only return true if the children are partially selected
|
||||
const isMixed = hasSelectedDescendants(item, childrenByParentUID, selectedItems);
|
||||
if (isMixed) {
|
||||
return SelectionState.Mixed;
|
||||
}
|
||||
|
||||
return SelectionState.Unselected;
|
||||
},
|
||||
[selectedItems, childrenByParentUID]
|
||||
);
|
||||
|
||||
return (
|
||||
<DashboardsTree
|
||||
canSelect={canSelect}
|
||||
items={flatTree}
|
||||
width={width}
|
||||
height={height}
|
||||
selectedItems={selectedItems}
|
||||
isSelected={isSelected}
|
||||
onFolderClick={handleFolderClick}
|
||||
onAllSelectionChange={(newState) => dispatch(setAllSelection({ isSelected: newState }))}
|
||||
onItemSelectionChange={handleItemSelectionChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function hasSelectedDescendants(
|
||||
item: DashboardViewItem,
|
||||
childrenByParentUID: Record<string, DashboardViewItem[] | undefined>,
|
||||
selectedItems: DashboardTreeSelection
|
||||
): boolean {
|
||||
const children = childrenByParentUID[item.uid];
|
||||
if (!children) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return children.some((v) => {
|
||||
const thisIsSelected = selectedItems[v.kind][v.uid];
|
||||
if (thisIsSelected) {
|
||||
return thisIsSelected;
|
||||
}
|
||||
|
||||
return hasSelectedDescendants(v, childrenByParentUID, selectedItems);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import React from 'react';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Checkbox } from '@grafana/ui';
|
||||
|
||||
import { DashboardsTreeCellProps, SelectionState } from '../types';
|
||||
|
||||
export default function CheckboxCell({
|
||||
row: { original: row },
|
||||
isSelected,
|
||||
onItemSelectionChange,
|
||||
}: DashboardsTreeCellProps) {
|
||||
const item = row.item;
|
||||
|
||||
if (item.kind === 'ui-empty-folder' || !isSelected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const state = isSelected(item);
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
data-testid={selectors.pages.BrowseDashbards.table.checkbox(item.uid)}
|
||||
value={state === SelectionState.Selected}
|
||||
indeterminate={state === SelectionState.Mixed}
|
||||
onChange={(ev) => onItemSelectionChange?.(item, ev.currentTarget.checked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Checkbox } from '@grafana/ui';
|
||||
|
||||
import { DashboardTreeHeaderProps, SelectionState } from '../types';
|
||||
|
||||
export default function CheckboxHeaderCell({ isSelected, onAllSelectionChange }: DashboardTreeHeaderProps) {
|
||||
const state = isSelected?.('$all') ?? SelectionState.Unselected;
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
value={state === SelectionState.Selected}
|
||||
indeterminate={state === SelectionState.Mixed}
|
||||
onChange={(ev) => onAllSelectionChange?.(ev.currentTarget.checked)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { assertIsDefined } from 'test/helpers/asserts';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
|
||||
import { wellFormedDashboard, wellFormedEmptyFolder, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture';
|
||||
import { SelectionState } from '../types';
|
||||
|
||||
import { DashboardsTree } from './DashboardsTree';
|
||||
|
||||
@@ -22,19 +23,14 @@ describe('browse-dashboards DashboardsTree', () => {
|
||||
const emptyFolderIndicator = wellFormedEmptyFolder();
|
||||
const dashboard = wellFormedDashboard(2);
|
||||
const noop = () => {};
|
||||
const selectedItems = {
|
||||
$all: false,
|
||||
folder: {},
|
||||
dashboard: {},
|
||||
panel: {},
|
||||
};
|
||||
const isSelected = () => SelectionState.Unselected;
|
||||
|
||||
it('renders a dashboard item', () => {
|
||||
render(
|
||||
<DashboardsTree
|
||||
canSelect
|
||||
items={[dashboard]}
|
||||
selectedItems={selectedItems}
|
||||
isSelected={isSelected}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
onFolderClick={noop}
|
||||
@@ -53,7 +49,7 @@ describe('browse-dashboards DashboardsTree', () => {
|
||||
<DashboardsTree
|
||||
canSelect={false}
|
||||
items={[dashboard]}
|
||||
selectedItems={selectedItems}
|
||||
isSelected={isSelected}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
onFolderClick={noop}
|
||||
@@ -71,7 +67,7 @@ describe('browse-dashboards DashboardsTree', () => {
|
||||
<DashboardsTree
|
||||
canSelect
|
||||
items={[folder]}
|
||||
selectedItems={selectedItems}
|
||||
isSelected={isSelected}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
onFolderClick={noop}
|
||||
@@ -89,7 +85,7 @@ describe('browse-dashboards DashboardsTree', () => {
|
||||
<DashboardsTree
|
||||
canSelect
|
||||
items={[folder]}
|
||||
selectedItems={selectedItems}
|
||||
isSelected={isSelected}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
onFolderClick={handler}
|
||||
@@ -108,7 +104,7 @@ describe('browse-dashboards DashboardsTree', () => {
|
||||
<DashboardsTree
|
||||
canSelect
|
||||
items={[emptyFolderIndicator]}
|
||||
selectedItems={selectedItems}
|
||||
isSelected={isSelected}
|
||||
width={WIDTH}
|
||||
height={HEIGHT}
|
||||
onFolderClick={noop}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React, { useMemo } from 'react';
|
||||
import { CellProps, Column, HeaderProps, TableInstance, useTable } from 'react-table';
|
||||
import { TableInstance, useTable } from 'react-table';
|
||||
import { FixedSizeList as List } from 'react-window';
|
||||
|
||||
import { GrafanaTheme2, isTruthy } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { Checkbox, useStyles2 } from '@grafana/ui';
|
||||
import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
import { DashboardViewItem } from 'app/features/search/types';
|
||||
|
||||
import { DashboardsTreeItem, DashboardTreeSelection, INDENT_AMOUNT_CSS_VAR } from '../types';
|
||||
import {
|
||||
DashboardsTreeCellProps,
|
||||
DashboardsTreeColumn,
|
||||
DashboardsTreeItem,
|
||||
INDENT_AMOUNT_CSS_VAR,
|
||||
SelectionState,
|
||||
} from '../types';
|
||||
|
||||
import CheckboxCell from './CheckboxCell';
|
||||
import CheckboxHeaderCell from './CheckboxHeaderCell';
|
||||
import { NameCell } from './NameCell';
|
||||
import { TagsCell } from './TagsCell';
|
||||
import { TypeCell } from './TypeCell';
|
||||
@@ -19,23 +27,13 @@ interface DashboardsTreeProps {
|
||||
items: DashboardsTreeItem[];
|
||||
width: number;
|
||||
height: number;
|
||||
selectedItems: DashboardTreeSelection;
|
||||
isSelected: (kind: DashboardViewItem | '$all') => SelectionState;
|
||||
onFolderClick: (uid: string, newOpenState: boolean) => void;
|
||||
onAllSelectionChange: (newState: boolean) => void;
|
||||
onItemSelectionChange: (item: DashboardViewItem, newState: boolean) => void;
|
||||
canSelect: boolean;
|
||||
}
|
||||
|
||||
type DashboardsTreeColumn = Column<DashboardsTreeItem>;
|
||||
type DashboardTreeHeaderProps = HeaderProps<DashboardsTreeItem> & {
|
||||
// Note: userProps for cell renderers (e.g. second argument in `cell.render('Cell', foo)` )
|
||||
// aren't typed, so we must be careful when accessing this
|
||||
selectedItems?: DashboardsTreeProps['selectedItems'];
|
||||
};
|
||||
type DashboardsTreeCellProps = CellProps<DashboardsTreeItem, unknown> & {
|
||||
selectedItems?: DashboardsTreeProps['selectedItems'];
|
||||
};
|
||||
|
||||
const HEADER_HEIGHT = 35;
|
||||
const ROW_HEIGHT = 35;
|
||||
|
||||
@@ -43,7 +41,7 @@ export function DashboardsTree({
|
||||
items,
|
||||
width,
|
||||
height,
|
||||
selectedItems,
|
||||
isSelected,
|
||||
onFolderClick,
|
||||
onAllSelectionChange,
|
||||
onItemSelectionChange,
|
||||
@@ -52,32 +50,12 @@ export function DashboardsTree({
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
const tableColumns = useMemo(() => {
|
||||
const checkboxColumn: DashboardsTreeColumn | null = canSelect
|
||||
? {
|
||||
id: 'checkbox',
|
||||
width: 0,
|
||||
Header: ({ selectedItems }: DashboardTreeHeaderProps) => {
|
||||
const isAllSelected = selectedItems?.$all ?? false;
|
||||
return <Checkbox value={isAllSelected} onChange={(ev) => onAllSelectionChange(ev.currentTarget.checked)} />;
|
||||
},
|
||||
Cell: ({ row: { original: row }, selectedItems }: DashboardsTreeCellProps) => {
|
||||
const item = row.item;
|
||||
if (item.kind === 'ui-empty-folder' || !selectedItems) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
const isSelected = selectedItems?.[item.kind][item.uid] ?? false;
|
||||
|
||||
return (
|
||||
<Checkbox
|
||||
data-testid={selectors.pages.BrowseDashbards.table.checkbox(item.uid)}
|
||||
value={isSelected}
|
||||
onChange={(ev) => onItemSelectionChange(item, ev.currentTarget.checked)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}
|
||||
: null;
|
||||
const checkboxColumn: DashboardsTreeColumn = {
|
||||
id: 'checkbox',
|
||||
width: 0,
|
||||
Header: CheckboxHeaderCell,
|
||||
Cell: CheckboxCell,
|
||||
};
|
||||
|
||||
const nameColumn: DashboardsTreeColumn = {
|
||||
id: 'name',
|
||||
@@ -102,17 +80,20 @@ export function DashboardsTree({
|
||||
const columns = [canSelect && checkboxColumn, nameColumn, typeColumn, tagsColumns].filter(isTruthy);
|
||||
|
||||
return columns;
|
||||
}, [onItemSelectionChange, onAllSelectionChange, onFolderClick, canSelect]);
|
||||
}, [onFolderClick, canSelect]);
|
||||
|
||||
const table = useTable({ columns: tableColumns, data: items }, useCustomFlexLayout);
|
||||
const { getTableProps, getTableBodyProps, headerGroups } = table;
|
||||
|
||||
const virtualData = useMemo(() => {
|
||||
return {
|
||||
const virtualData = useMemo(
|
||||
() => ({
|
||||
table,
|
||||
selectedItems,
|
||||
};
|
||||
}, [table, selectedItems]);
|
||||
isSelected,
|
||||
onAllSelectionChange,
|
||||
onItemSelectionChange,
|
||||
}),
|
||||
[table, isSelected, onAllSelectionChange, onItemSelectionChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<div {...getTableProps()} className={styles.tableRoot} role="table">
|
||||
@@ -128,7 +109,7 @@ export function DashboardsTree({
|
||||
|
||||
return (
|
||||
<div key={key} {...headerProps} role="columnheader" className={styles.cell}>
|
||||
{column.render('Header', { selectedItems })}
|
||||
{column.render('Header', { isSelected, onAllSelectionChange })}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -156,13 +137,15 @@ interface VirtualListRowProps {
|
||||
style: React.CSSProperties;
|
||||
data: {
|
||||
table: TableInstance<DashboardsTreeItem>;
|
||||
selectedItems: Record<DashboardViewItemKind, Record<string, boolean | undefined>>;
|
||||
isSelected: DashboardsTreeCellProps['isSelected'];
|
||||
onAllSelectionChange: DashboardsTreeCellProps['onAllSelectionChange'];
|
||||
onItemSelectionChange: DashboardsTreeCellProps['onItemSelectionChange'];
|
||||
};
|
||||
}
|
||||
|
||||
function VirtualListRow({ index, style, data }: VirtualListRowProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { table, selectedItems } = data;
|
||||
const { table, isSelected, onItemSelectionChange } = data;
|
||||
const { rows, prepareRow } = table;
|
||||
|
||||
const row = rows[index];
|
||||
@@ -179,7 +162,7 @@ function VirtualListRow({ index, style, data }: VirtualListRowProps) {
|
||||
|
||||
return (
|
||||
<div key={key} {...cellProps} className={styles.cell}>
|
||||
{cell.render('Cell', { selectedItems })}
|
||||
{cell.render('Cell', { isSelected, onItemSelectionChange })}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -44,6 +44,10 @@ export function useCheckboxSelectionState() {
|
||||
return useSelector((wholeState: StoreState) => wholeState.browseDashboards.selectedItems);
|
||||
}
|
||||
|
||||
export function useChildrenByParentUIDState() {
|
||||
return useSelector((wholeState: StoreState) => wholeState.browseDashboards.childrenByParentUID);
|
||||
}
|
||||
|
||||
export function useActionSelectionState() {
|
||||
return useSelector((state) => selectedItemsForActionsSelector(state));
|
||||
}
|
||||
|
||||
@@ -115,8 +115,10 @@ describe('browse-dashboards reducers', () => {
|
||||
|
||||
describe('setItemSelectionState', () => {
|
||||
it('marks items as selected', () => {
|
||||
const folder = wellFormedFolder(1).item;
|
||||
const dashboard = wellFormedDashboard(2).item;
|
||||
const state = createInitialState();
|
||||
const dashboard = wellFormedDashboard().item;
|
||||
state.rootItems = [folder, dashboard];
|
||||
|
||||
setItemSelectionState(state, { type: 'setItemSelectionState', payload: { item: dashboard, isSelected: true } });
|
||||
|
||||
@@ -133,11 +135,13 @@ describe('browse-dashboards reducers', () => {
|
||||
it('marks descendants as selected when the parent folder is selected', () => {
|
||||
const state = createInitialState();
|
||||
|
||||
const parentFolder = wellFormedFolder(1).item;
|
||||
const childDashboard = wellFormedDashboard(2, {}, { parentUID: parentFolder.uid }).item;
|
||||
const childFolder = wellFormedFolder(3, {}, { parentUID: parentFolder.uid }).item;
|
||||
const grandchildDashboard = wellFormedDashboard(4, {}, { parentUID: childFolder.uid }).item;
|
||||
const rootDashboard = wellFormedDashboard(1).item;
|
||||
const parentFolder = wellFormedFolder(2).item;
|
||||
const childDashboard = wellFormedDashboard(3, {}, { parentUID: parentFolder.uid }).item;
|
||||
const childFolder = wellFormedFolder(4, {}, { parentUID: parentFolder.uid }).item;
|
||||
const grandchildDashboard = wellFormedDashboard(5, {}, { parentUID: childFolder.uid }).item;
|
||||
|
||||
state.rootItems = [parentFolder, rootDashboard];
|
||||
state.childrenByParentUID[parentFolder.uid] = [childDashboard, childFolder];
|
||||
state.childrenByParentUID[childFolder.uid] = [grandchildDashboard];
|
||||
|
||||
@@ -196,6 +200,105 @@ describe('browse-dashboards reducers', () => {
|
||||
panel: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('selects ancestors when all their children are now selected', () => {
|
||||
const state = createInitialState();
|
||||
|
||||
const rootDashboard = wellFormedDashboard(1).item;
|
||||
const parentFolder = wellFormedFolder(2).item;
|
||||
const childDashboard = wellFormedDashboard(3, {}, { parentUID: parentFolder.uid }).item;
|
||||
const childFolder = wellFormedFolder(4, {}, { parentUID: parentFolder.uid }).item;
|
||||
const grandchildDashboard = wellFormedDashboard(5, {}, { parentUID: childFolder.uid }).item;
|
||||
|
||||
state.rootItems = [parentFolder, rootDashboard];
|
||||
state.childrenByParentUID[parentFolder.uid] = [childDashboard, childFolder];
|
||||
state.childrenByParentUID[childFolder.uid] = [grandchildDashboard];
|
||||
|
||||
// Selected the deepest grandchild dashboard
|
||||
setItemSelectionState(state, {
|
||||
type: 'setItemSelectionState',
|
||||
payload: { item: grandchildDashboard, isSelected: true },
|
||||
});
|
||||
|
||||
expect(state.selectedItems).toEqual({
|
||||
$all: false,
|
||||
dashboard: {
|
||||
[grandchildDashboard.uid]: true,
|
||||
},
|
||||
folder: {
|
||||
[parentFolder.uid]: false,
|
||||
[childFolder.uid]: true, // is selected because all it's children (grandchildDashboard) is selected
|
||||
},
|
||||
panel: {},
|
||||
});
|
||||
|
||||
setItemSelectionState(state, {
|
||||
type: 'setItemSelectionState',
|
||||
payload: { item: childDashboard, isSelected: true },
|
||||
});
|
||||
|
||||
expect(state.selectedItems).toEqual({
|
||||
$all: false,
|
||||
dashboard: {
|
||||
[childDashboard.uid]: true,
|
||||
[grandchildDashboard.uid]: true,
|
||||
},
|
||||
folder: {
|
||||
[parentFolder.uid]: true, // is now selected because we also selected its other child
|
||||
[childFolder.uid]: true,
|
||||
},
|
||||
panel: {},
|
||||
});
|
||||
});
|
||||
|
||||
it('selects the $all header checkbox when all descendants are now selected', () => {
|
||||
const state = createInitialState();
|
||||
|
||||
const rootDashboard = wellFormedDashboard(1).item;
|
||||
const rootFolder = wellFormedFolder(2).item;
|
||||
const childDashboardA = wellFormedDashboard(3, {}, { parentUID: rootFolder.uid }).item;
|
||||
const childDashboardB = wellFormedDashboard(4, {}, { parentUID: rootFolder.uid }).item;
|
||||
|
||||
state.rootItems = [rootFolder, rootDashboard];
|
||||
state.childrenByParentUID[rootFolder.uid] = [childDashboardA, childDashboardB];
|
||||
|
||||
state.selectedItems.dashboard = { [rootDashboard.uid]: true, [childDashboardA.uid]: true };
|
||||
|
||||
// Selected the deepest grandchild dashboard
|
||||
setItemSelectionState(state, {
|
||||
type: 'setItemSelectionState',
|
||||
payload: { item: childDashboardB, isSelected: true },
|
||||
});
|
||||
|
||||
expect(state.selectedItems.$all).toBeTruthy();
|
||||
});
|
||||
|
||||
it('unselects the $all header checkbox a descendant is unselected', () => {
|
||||
const state = createInitialState();
|
||||
|
||||
const rootDashboard = wellFormedDashboard(1).item;
|
||||
const rootFolder = wellFormedFolder(2).item;
|
||||
const childDashboardA = wellFormedDashboard(3, {}, { parentUID: rootFolder.uid }).item;
|
||||
const childDashboardB = wellFormedDashboard(4, {}, { parentUID: rootFolder.uid }).item;
|
||||
|
||||
state.rootItems = [rootFolder, rootDashboard];
|
||||
state.childrenByParentUID[rootFolder.uid] = [childDashboardA, childDashboardB];
|
||||
|
||||
state.selectedItems.dashboard = {
|
||||
[rootDashboard.uid]: true,
|
||||
[childDashboardA.uid]: true,
|
||||
[childDashboardB.uid]: true,
|
||||
};
|
||||
state.selectedItems.folder = { [rootFolder.uid]: true };
|
||||
|
||||
// Selected the deepest grandchild dashboard
|
||||
setItemSelectionState(state, {
|
||||
type: 'setItemSelectionState',
|
||||
payload: { item: childDashboardB, isSelected: false },
|
||||
});
|
||||
|
||||
expect(state.selectedItems.$all).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setAllSelection', () => {
|
||||
|
||||
@@ -45,6 +45,8 @@ export function setItemSelectionState(
|
||||
) {
|
||||
const { item, isSelected } = action.payload;
|
||||
|
||||
// Selecting a folder selects all children, and unselecting a folder deselects all children
|
||||
// so propagate the new selection state to all descendants
|
||||
function markChildren(kind: DashboardViewItemKind, uid: string) {
|
||||
state.selectedItems[kind][uid] = isSelected;
|
||||
|
||||
@@ -60,26 +62,37 @@ export function setItemSelectionState(
|
||||
|
||||
markChildren(item.kind, item.uid);
|
||||
|
||||
// If we're unselecting an item, unselect all ancestors (parent, grandparent, etc) also
|
||||
// so we can later show a UI-only 'mixed' checkbox
|
||||
if (!isSelected) {
|
||||
let nextParentUID = item.parentUID;
|
||||
// If all children of a folder are selected, then the folder is also selected.
|
||||
// If *any* child of a folder is unselelected, then the folder is alo unselected.
|
||||
// Reconcile all ancestors to make sure they're in the correct state.
|
||||
let nextParentUID = item.parentUID;
|
||||
|
||||
// this is like a recursive climb up the parents of the tree while we have a
|
||||
// parentUID (we've hit a root dashboard/folder)
|
||||
while (nextParentUID) {
|
||||
const parent = findItem(state.rootItems, state.childrenByParentUID, nextParentUID);
|
||||
while (nextParentUID) {
|
||||
const parent = findItem(state.rootItems, state.childrenByParentUID, nextParentUID);
|
||||
|
||||
// This case should not happen, but a find can theortically return undefined, and it
|
||||
// helps limit infinite loops
|
||||
if (!parent) {
|
||||
break;
|
||||
}
|
||||
|
||||
state.selectedItems[parent.kind][parent.uid] = false;
|
||||
nextParentUID = parent.parentUID;
|
||||
// This case should not happen, but a find can theortically return undefined, and it
|
||||
// helps limit infinite loops
|
||||
if (!parent) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (isSelected) {
|
||||
// If we're selecting an item, check all ancestors and see if all their children are
|
||||
// now selected and update them appropriately
|
||||
const children = state.childrenByParentUID[parent.uid];
|
||||
|
||||
const allChildrenSelected = children?.every((v) => state.selectedItems[v.kind][v.uid]) ?? false;
|
||||
state.selectedItems[parent.kind][parent.uid] = allChildrenSelected;
|
||||
} else {
|
||||
// A folder cannot be selected if any of it's children are unselected
|
||||
state.selectedItems[parent.kind][parent.uid] = false;
|
||||
}
|
||||
|
||||
nextParentUID = parent.parentUID;
|
||||
}
|
||||
|
||||
// Check to see if we should mark the header checkbox selected if all root items are selected
|
||||
state.selectedItems.$all = state.rootItems.every((v) => state.selectedItems[v.kind][v.uid]) ?? false;
|
||||
}
|
||||
|
||||
export function setAllSelection(state: BrowseDashboardsState, action: PayloadAction<{ isSelected: boolean }>) {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CellProps, Column, HeaderProps } from 'react-table';
|
||||
|
||||
import { DashboardViewItem as DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
|
||||
|
||||
export type DashboardTreeSelection = Record<DashboardViewItemKind, Record<string, boolean | undefined>> & {
|
||||
@@ -27,3 +29,21 @@ export interface DashboardsTreeItem<T extends DashboardViewItemWithUIItems = Das
|
||||
}
|
||||
|
||||
export const INDENT_AMOUNT_CSS_VAR = '--dashboards-tree-indentation';
|
||||
|
||||
interface RendererUserProps {
|
||||
// Note: userProps for cell renderers (e.g. second argument in `cell.render('Cell', foo)` )
|
||||
// aren't typed, so we must be careful when accessing this
|
||||
isSelected?: (kind: DashboardViewItem | '$all') => SelectionState;
|
||||
onAllSelectionChange?: (newState: boolean) => void;
|
||||
onItemSelectionChange?: (item: DashboardViewItem, newState: boolean) => void;
|
||||
}
|
||||
|
||||
export type DashboardsTreeColumn = Column<DashboardsTreeItem>;
|
||||
export type DashboardsTreeCellProps = CellProps<DashboardsTreeItem, unknown> & RendererUserProps;
|
||||
export type DashboardTreeHeaderProps = HeaderProps<DashboardsTreeItem> & RendererUserProps;
|
||||
|
||||
export enum SelectionState {
|
||||
Unselected,
|
||||
Selected,
|
||||
Mixed,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user