mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
NestedFolders: Basic item selection (#66843)
* NestedFolders: Item selection state * Select children in state * Unselect parents when children are unselected * remove unneeded comment * tests * tidy test a little bit * tidy test a little bit * tidy test a little bit * tidy test a little bit * tidy test a little bit
This commit is contained in:
parent
3634079b8f
commit
e0c5b4f0e0
@ -269,6 +269,12 @@ export const Pages = {
|
|||||||
interval: 'Playlist interval',
|
interval: 'Playlist interval',
|
||||||
itemDelete: 'Delete playlist item',
|
itemDelete: 'Delete playlist item',
|
||||||
},
|
},
|
||||||
|
BrowseDashbards: {
|
||||||
|
table: {
|
||||||
|
row: (uid: string) => `data-testid ${uid} row`,
|
||||||
|
checkbox: (uid: string) => `data-testid ${uid} checkbox`,
|
||||||
|
},
|
||||||
|
},
|
||||||
Search: {
|
Search: {
|
||||||
url: '/?search=openn',
|
url: '/?search=openn',
|
||||||
FolderView: {
|
FolderView: {
|
||||||
|
@ -0,0 +1,139 @@
|
|||||||
|
import { getByLabelText, render as rtlRender, screen } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import React from 'react';
|
||||||
|
import { Router } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
|
import { locationService } from '@grafana/runtime';
|
||||||
|
|
||||||
|
import { wellFormedTree } from '../fixtures/dashboardsTreeItem.fixture';
|
||||||
|
|
||||||
|
import { BrowseView } from './BrowseView';
|
||||||
|
|
||||||
|
const [mockTree, { folderA, folderA_folderA, folderA_folderB, folderA_folderB_dashbdB, dashbdD }] = wellFormedTree();
|
||||||
|
|
||||||
|
function render(...args: Parameters<typeof rtlRender>) {
|
||||||
|
const [ui, options] = args;
|
||||||
|
|
||||||
|
rtlRender(<Router history={locationService.getHistory()}>{ui}</Router>, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
jest.mock('app/features/search/service/folders', () => {
|
||||||
|
return {
|
||||||
|
getFolderChildren(parentUID?: string) {
|
||||||
|
const childrenForUID = mockTree
|
||||||
|
.filter((v) => v.item.kind !== 'ui-empty-folder' && v.item.parentUID === parentUID)
|
||||||
|
.map((v) => v.item);
|
||||||
|
|
||||||
|
return Promise.resolve(childrenForUID);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('browse-dashboards BrowseView', () => {
|
||||||
|
const WIDTH = 800;
|
||||||
|
const HEIGHT = 600;
|
||||||
|
|
||||||
|
it('expands and collapses a folder', async () => {
|
||||||
|
render(<BrowseView folderUID={undefined} width={WIDTH} height={HEIGHT} />);
|
||||||
|
await screen.findByText(folderA.item.title);
|
||||||
|
|
||||||
|
await expandFolder(folderA.item.uid);
|
||||||
|
expect(screen.queryByText(folderA_folderA.item.title)).toBeInTheDocument();
|
||||||
|
|
||||||
|
await collapseFolder(folderA.item.uid);
|
||||||
|
expect(screen.queryByText(folderA_folderA.item.title)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks items when selected', async () => {
|
||||||
|
render(<BrowseView folderUID={undefined} width={WIDTH} height={HEIGHT} />);
|
||||||
|
|
||||||
|
const checkbox = await screen.findByTestId(selectors.pages.BrowseDashbards.table.checkbox(dashbdD.item.uid));
|
||||||
|
expect(checkbox).not.toBeChecked();
|
||||||
|
|
||||||
|
await userEvent.click(checkbox);
|
||||||
|
expect(checkbox).toBeChecked();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks all descendants when a folder is selected', async () => {
|
||||||
|
render(<BrowseView folderUID={undefined} width={WIDTH} height={HEIGHT} />);
|
||||||
|
await screen.findByText(folderA.item.title);
|
||||||
|
|
||||||
|
// First expand then click folderA
|
||||||
|
await expandFolder(folderA.item.uid);
|
||||||
|
await clickCheckbox(folderA.item.uid);
|
||||||
|
|
||||||
|
// All the visible items in it should be checked now
|
||||||
|
const directChildren = mockTree.filter(
|
||||||
|
(v) => v.item.kind !== 'ui-empty-folder' && v.item.parentUID === folderA.item.uid
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const child of directChildren) {
|
||||||
|
const childCheckbox = screen.queryByTestId(selectors.pages.BrowseDashbards.table.checkbox(child.item.uid));
|
||||||
|
expect(childCheckbox).toBeChecked();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('checks descendants loaded after a folder is selected', async () => {
|
||||||
|
render(<BrowseView folderUID={undefined} width={WIDTH} height={HEIGHT} />);
|
||||||
|
await screen.findByText(folderA.item.title);
|
||||||
|
|
||||||
|
// First expand then click folderA
|
||||||
|
await expandFolder(folderA.item.uid);
|
||||||
|
await clickCheckbox(folderA.item.uid);
|
||||||
|
|
||||||
|
// When additional children are loaded (by expanding a folder), those items
|
||||||
|
// should also be selected
|
||||||
|
await expandFolder(folderA_folderB.item.uid);
|
||||||
|
|
||||||
|
const grandchildren = mockTree.filter(
|
||||||
|
(v) => v.item.kind !== 'ui-empty-folder' && v.item.parentUID === folderA_folderB.item.uid
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const child of grandchildren) {
|
||||||
|
const childCheckbox = screen.queryByTestId(selectors.pages.BrowseDashbards.table.checkbox(child.item.uid));
|
||||||
|
expect(childCheckbox).toBeChecked();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unchecks ancestors when unselecting an item', async () => {
|
||||||
|
render(<BrowseView 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.item.uid);
|
||||||
|
await clickCheckbox(folderA_folderB_dashbdB.item.uid);
|
||||||
|
|
||||||
|
const itemCheckbox = screen.queryByTestId(
|
||||||
|
selectors.pages.BrowseDashbards.table.checkbox(folderA_folderB_dashbdB.item.uid)
|
||||||
|
);
|
||||||
|
expect(itemCheckbox).not.toBeChecked();
|
||||||
|
|
||||||
|
const parentCheckbox = screen.queryByTestId(
|
||||||
|
selectors.pages.BrowseDashbards.table.checkbox(folderA_folderB.item.uid)
|
||||||
|
);
|
||||||
|
expect(parentCheckbox).not.toBeChecked();
|
||||||
|
|
||||||
|
const grandparentCheckbox = screen.queryByTestId(selectors.pages.BrowseDashbards.table.checkbox(folderA.item.uid));
|
||||||
|
expect(grandparentCheckbox).not.toBeChecked();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
async function expandFolder(uid: string) {
|
||||||
|
const row = screen.getByTestId(selectors.pages.BrowseDashbards.table.row(uid));
|
||||||
|
const expandButton = getByLabelText(row, 'Expand folder');
|
||||||
|
await userEvent.click(expandButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collapseFolder(uid: string) {
|
||||||
|
const row = screen.getByTestId(selectors.pages.BrowseDashbards.table.row(uid));
|
||||||
|
const expandButton = getByLabelText(row, 'Collapse folder');
|
||||||
|
await userEvent.click(expandButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickCheckbox(uid: string) {
|
||||||
|
const checkbox = screen.getByTestId(selectors.pages.BrowseDashbards.table.checkbox(uid));
|
||||||
|
await userEvent.click(checkbox);
|
||||||
|
}
|
@ -1,7 +1,8 @@
|
|||||||
|
import produce from 'immer';
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import { getFolderChildren } from 'app/features/search/service/folders';
|
import { getFolderChildren } from 'app/features/search/service/folders';
|
||||||
import { DashboardViewItem } from 'app/features/search/types';
|
import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
|
||||||
|
|
||||||
import { DashboardsTreeItem } from '../types';
|
import { DashboardsTreeItem } from '../types';
|
||||||
|
|
||||||
@ -16,19 +17,45 @@ interface BrowseViewProps {
|
|||||||
export function BrowseView({ folderUID, width, height }: BrowseViewProps) {
|
export function BrowseView({ folderUID, width, height }: BrowseViewProps) {
|
||||||
const [openFolders, setOpenFolders] = useState<Record<string, boolean>>({ [folderUID ?? '$$root']: true });
|
const [openFolders, setOpenFolders] = useState<Record<string, boolean>>({ [folderUID ?? '$$root']: true });
|
||||||
|
|
||||||
|
const [selectedItems, setSelectedItems] = useState<
|
||||||
|
Record<DashboardViewItemKind, Record<string, boolean | undefined>>
|
||||||
|
>({
|
||||||
|
folder: {},
|
||||||
|
dashboard: {},
|
||||||
|
panel: {},
|
||||||
|
});
|
||||||
|
|
||||||
// Rather than storing an actual tree structure (requiring traversing the tree to update children), instead
|
// Rather than storing an actual tree structure (requiring traversing the tree to update children), instead
|
||||||
// we keep track of children for each UID and then later combine them in the format required to display them
|
// we keep track of children for each UID and then later combine them in the format required to display them
|
||||||
const [childrenByUID, setChildrenByUID] = useState<Record<string, DashboardViewItem[] | undefined>>({});
|
const [childrenByUID, setChildrenByUID] = useState<Record<string, DashboardViewItem[] | undefined>>({});
|
||||||
|
|
||||||
async function loadChildrenForUID(uid: string | undefined) {
|
const loadChildrenForUID = useCallback(
|
||||||
|
async (uid: string | undefined) => {
|
||||||
const folderKey = uid ?? '$$root';
|
const folderKey = uid ?? '$$root';
|
||||||
|
|
||||||
const childItems = await getFolderChildren(uid, undefined, true);
|
const childItems = await getFolderChildren(uid, undefined, true);
|
||||||
setChildrenByUID((v) => ({ ...v, [folderKey]: childItems }));
|
setChildrenByUID((v) => ({ ...v, [folderKey]: childItems }));
|
||||||
|
|
||||||
|
// If the parent is already selected, mark these items as selected also
|
||||||
|
const parentIsSelected = selectedItems.folder[folderKey];
|
||||||
|
if (parentIsSelected) {
|
||||||
|
setSelectedItems((currentState) =>
|
||||||
|
produce(currentState, (draft) => {
|
||||||
|
for (const child of childItems) {
|
||||||
|
draft[child.kind][child.uid] = true;
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedItems]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadChildrenForUID(folderUID);
|
loadChildrenForUID(folderUID);
|
||||||
|
// No need to depend on loadChildrenForUID - we only want this to run
|
||||||
|
// when folderUID changes (initial page view)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [folderUID]);
|
}, [folderUID]);
|
||||||
|
|
||||||
const flatTree = useMemo(
|
const flatTree = useMemo(
|
||||||
@ -36,15 +63,66 @@ export function BrowseView({ folderUID, width, height }: BrowseViewProps) {
|
|||||||
[folderUID, childrenByUID, openFolders]
|
[folderUID, childrenByUID, openFolders]
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleFolderClick = useCallback((uid: string, folderIsOpen: boolean) => {
|
const handleFolderClick = useCallback(
|
||||||
if (folderIsOpen) {
|
(uid: string, newState: boolean) => {
|
||||||
|
if (newState) {
|
||||||
loadChildrenForUID(uid);
|
loadChildrenForUID(uid);
|
||||||
}
|
}
|
||||||
|
|
||||||
setOpenFolders((v) => ({ ...v, [uid]: folderIsOpen }));
|
setOpenFolders((old) => ({ ...old, [uid]: newState }));
|
||||||
}, []);
|
},
|
||||||
|
[loadChildrenForUID]
|
||||||
|
);
|
||||||
|
|
||||||
return <DashboardsTree items={flatTree} width={width} height={height} onFolderClick={handleFolderClick} />;
|
const handleItemSelectionChange = useCallback(
|
||||||
|
(item: DashboardViewItem, newState: boolean) => {
|
||||||
|
// Recursively set selection state for this item and all descendants
|
||||||
|
setSelectedItems((old) =>
|
||||||
|
produce(old, (draft) => {
|
||||||
|
function markChildren(kind: DashboardViewItemKind, uid: string) {
|
||||||
|
draft[kind][uid] = newState;
|
||||||
|
if (kind !== 'folder') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let children = childrenByUID[uid] ?? [];
|
||||||
|
for (const child of children) {
|
||||||
|
markChildren(child.kind, child.uid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
markChildren(item.kind, item.uid);
|
||||||
|
|
||||||
|
// If we're unselecting an item, unselect all ancestors also
|
||||||
|
if (!newState) {
|
||||||
|
let nextParentUID = item.parentUID;
|
||||||
|
|
||||||
|
while (nextParentUID) {
|
||||||
|
const parent = findItem(childrenByUID, nextParentUID);
|
||||||
|
if (!parent) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
draft[parent.kind][parent.uid] = false;
|
||||||
|
nextParentUID = parent.parentUID;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[childrenByUID]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DashboardsTree
|
||||||
|
items={flatTree}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
onFolderClick={handleFolderClick}
|
||||||
|
onItemSelectionChange={handleItemSelectionChange}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Creates a flat list of items, with nested children indicated by its increasing level
|
// Creates a flat list of items, with nested children indicated by its increasing level
|
||||||
@ -54,17 +132,22 @@ function createFlatTree(
|
|||||||
openFolders: Record<string, boolean>,
|
openFolders: Record<string, boolean>,
|
||||||
level = 0
|
level = 0
|
||||||
): DashboardsTreeItem[] {
|
): DashboardsTreeItem[] {
|
||||||
function mapItem(item: DashboardViewItem, level: number): DashboardsTreeItem[] {
|
function mapItem(item: DashboardViewItem, parentUID: string | undefined, level: number): DashboardsTreeItem[] {
|
||||||
const mappedChildren = createFlatTree(item.uid, childrenByUID, openFolders, level + 1);
|
const mappedChildren = createFlatTree(item.uid, childrenByUID, openFolders, level + 1);
|
||||||
|
|
||||||
const isOpen = Boolean(openFolders[item.uid]);
|
const isOpen = Boolean(openFolders[item.uid]);
|
||||||
const emptyFolder = childrenByUID[item.uid]?.length === 0;
|
const emptyFolder = childrenByUID[item.uid]?.length === 0;
|
||||||
if (isOpen && emptyFolder) {
|
if (isOpen && emptyFolder) {
|
||||||
mappedChildren.push({ isOpen: false, level: level + 1, item: { kind: 'ui-empty-folder' } });
|
mappedChildren.push({
|
||||||
|
isOpen: false,
|
||||||
|
level: level + 1,
|
||||||
|
item: { kind: 'ui-empty-folder', uid: item.uid + '-empty-folder' },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const thisItem = {
|
const thisItem = {
|
||||||
item,
|
item,
|
||||||
|
parentUID,
|
||||||
level,
|
level,
|
||||||
isOpen,
|
isOpen,
|
||||||
};
|
};
|
||||||
@ -76,5 +159,25 @@ function createFlatTree(
|
|||||||
const isOpen = Boolean(openFolders[folderKey]);
|
const isOpen = Boolean(openFolders[folderKey]);
|
||||||
const items = (isOpen && childrenByUID[folderKey]) || [];
|
const items = (isOpen && childrenByUID[folderKey]) || [];
|
||||||
|
|
||||||
return items.flatMap((item) => mapItem(item, level));
|
return items.flatMap((item) => mapItem(item, rootFolderUID, level));
|
||||||
|
}
|
||||||
|
|
||||||
|
function findItem(
|
||||||
|
childrenByUID: Record<string, DashboardViewItem[] | undefined>,
|
||||||
|
uid: string
|
||||||
|
): DashboardViewItem | undefined {
|
||||||
|
for (const parentUID in childrenByUID) {
|
||||||
|
const children = childrenByUID[parentUID];
|
||||||
|
if (!children) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const child of children) {
|
||||||
|
if (child.uid === uid) {
|
||||||
|
return child;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
@ -19,25 +19,58 @@ describe('browse-dashboards DashboardsTree', () => {
|
|||||||
const WIDTH = 800;
|
const WIDTH = 800;
|
||||||
const HEIGHT = 600;
|
const HEIGHT = 600;
|
||||||
|
|
||||||
const folder = wellFormedFolder();
|
const folder = wellFormedFolder(1);
|
||||||
const emptyFolderIndicator = wellFormedEmptyFolder();
|
const emptyFolderIndicator = wellFormedEmptyFolder();
|
||||||
const dashboard = wellFormedDashboard();
|
const dashboard = wellFormedDashboard(2);
|
||||||
|
const noop = () => {};
|
||||||
|
const selectedItems = {
|
||||||
|
folder: {},
|
||||||
|
dashboard: {},
|
||||||
|
panel: {},
|
||||||
|
};
|
||||||
|
|
||||||
it('renders a dashboard item', () => {
|
it('renders a dashboard item', () => {
|
||||||
render(<DashboardsTree items={[dashboard]} width={WIDTH} height={HEIGHT} onFolderClick={() => {}} />);
|
render(
|
||||||
|
<DashboardsTree
|
||||||
|
items={[dashboard]}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
width={WIDTH}
|
||||||
|
height={HEIGHT}
|
||||||
|
onFolderClick={noop}
|
||||||
|
onItemSelectionChange={noop}
|
||||||
|
/>
|
||||||
|
);
|
||||||
expect(screen.queryByText(dashboard.item.title)).toBeInTheDocument();
|
expect(screen.queryByText(dashboard.item.title)).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Dashboard')).toBeInTheDocument();
|
expect(screen.queryByText('Dashboard')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders a folder item', () => {
|
it('renders a folder item', () => {
|
||||||
render(<DashboardsTree items={[folder]} width={WIDTH} height={HEIGHT} onFolderClick={() => {}} />);
|
render(
|
||||||
|
<DashboardsTree
|
||||||
|
items={[folder]}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
width={WIDTH}
|
||||||
|
height={HEIGHT}
|
||||||
|
onFolderClick={noop}
|
||||||
|
onItemSelectionChange={noop}
|
||||||
|
/>
|
||||||
|
);
|
||||||
expect(screen.queryByText(folder.item.title)).toBeInTheDocument();
|
expect(screen.queryByText(folder.item.title)).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Folder')).toBeInTheDocument();
|
expect(screen.queryByText('Folder')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls onFolderClick when a folder button is clicked', async () => {
|
it('calls onFolderClick when a folder button is clicked', async () => {
|
||||||
const handler = jest.fn();
|
const handler = jest.fn();
|
||||||
render(<DashboardsTree items={[folder]} width={WIDTH} height={HEIGHT} onFolderClick={handler} />);
|
render(
|
||||||
|
<DashboardsTree
|
||||||
|
items={[folder]}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
width={WIDTH}
|
||||||
|
height={HEIGHT}
|
||||||
|
onFolderClick={handler}
|
||||||
|
onItemSelectionChange={noop}
|
||||||
|
/>
|
||||||
|
);
|
||||||
const folderButton = screen.getByLabelText('Collapse folder');
|
const folderButton = screen.getByLabelText('Collapse folder');
|
||||||
await userEvent.click(folderButton);
|
await userEvent.click(folderButton);
|
||||||
|
|
||||||
@ -45,7 +78,16 @@ describe('browse-dashboards DashboardsTree', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('renders empty folder indicators', () => {
|
it('renders empty folder indicators', () => {
|
||||||
render(<DashboardsTree items={[emptyFolderIndicator]} width={WIDTH} height={HEIGHT} onFolderClick={() => {}} />);
|
render(
|
||||||
|
<DashboardsTree
|
||||||
|
items={[emptyFolderIndicator]}
|
||||||
|
selectedItems={selectedItems}
|
||||||
|
width={WIDTH}
|
||||||
|
height={HEIGHT}
|
||||||
|
onFolderClick={noop}
|
||||||
|
onItemSelectionChange={noop}
|
||||||
|
/>
|
||||||
|
);
|
||||||
expect(screen.queryByText('Empty folder')).toBeInTheDocument();
|
expect(screen.queryByText('Empty folder')).toBeInTheDocument();
|
||||||
expect(screen.queryByText(emptyFolderIndicator.item.kind)).not.toBeInTheDocument();
|
expect(screen.queryByText(emptyFolderIndicator.item.kind)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
@ -4,9 +4,11 @@ import { CellProps, Column, TableInstance, useTable } from 'react-table';
|
|||||||
import { FixedSizeList as List } from 'react-window';
|
import { FixedSizeList as List } from 'react-window';
|
||||||
|
|
||||||
import { GrafanaTheme2 } from '@grafana/data';
|
import { GrafanaTheme2 } from '@grafana/data';
|
||||||
|
import { selectors } from '@grafana/e2e-selectors';
|
||||||
import { Checkbox, useStyles2 } from '@grafana/ui';
|
import { Checkbox, useStyles2 } from '@grafana/ui';
|
||||||
|
import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
|
||||||
|
|
||||||
import { DashboardsTreeItem, INDENT_AMOUNT_CSS_VAR } from '../types';
|
import { DashboardsTreeItem, DashboardTreeSelection, INDENT_AMOUNT_CSS_VAR } from '../types';
|
||||||
|
|
||||||
import { NameCell } from './NameCell';
|
import { NameCell } from './NameCell';
|
||||||
import { TypeCell } from './TypeCell';
|
import { TypeCell } from './TypeCell';
|
||||||
@ -15,28 +17,56 @@ interface DashboardsTreeProps {
|
|||||||
items: DashboardsTreeItem[];
|
items: DashboardsTreeItem[];
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
|
selectedItems: DashboardTreeSelection;
|
||||||
onFolderClick: (uid: string, newOpenState: boolean) => void;
|
onFolderClick: (uid: string, newOpenState: boolean) => void;
|
||||||
|
onItemSelectionChange: (item: DashboardViewItem, newState: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DashboardsTreeColumn = Column<DashboardsTreeItem>;
|
type DashboardsTreeColumn = Column<DashboardsTreeItem>;
|
||||||
|
type DashboardsTreeCellProps = CellProps<DashboardsTreeItem, unknown> & {
|
||||||
|
// 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'];
|
||||||
|
};
|
||||||
|
|
||||||
const HEADER_HEIGHT = 35;
|
const HEADER_HEIGHT = 35;
|
||||||
const ROW_HEIGHT = 35;
|
const ROW_HEIGHT = 35;
|
||||||
|
|
||||||
export function DashboardsTree({ items, width, height, onFolderClick }: DashboardsTreeProps) {
|
export function DashboardsTree({
|
||||||
|
items,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
selectedItems,
|
||||||
|
onFolderClick,
|
||||||
|
onItemSelectionChange,
|
||||||
|
}: DashboardsTreeProps) {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const tableColumns = useMemo(() => {
|
const tableColumns = useMemo(() => {
|
||||||
const checkboxColumn: DashboardsTreeColumn = {
|
const checkboxColumn: DashboardsTreeColumn = {
|
||||||
id: 'checkbox',
|
id: 'checkbox',
|
||||||
Header: () => <Checkbox value={false} />,
|
Header: () => <Checkbox value={false} />,
|
||||||
Cell: () => <Checkbox value={false} />,
|
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)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const nameColumn: DashboardsTreeColumn = {
|
const nameColumn: DashboardsTreeColumn = {
|
||||||
id: 'name',
|
id: 'name',
|
||||||
Header: <span style={{ paddingLeft: 20 }}>Name</span>,
|
Header: <span style={{ paddingLeft: 20 }}>Name</span>,
|
||||||
Cell: (props: CellProps<DashboardsTreeItem, unknown>) => <NameCell {...props} onFolderClick={onFolderClick} />,
|
Cell: (props: DashboardsTreeCellProps) => <NameCell {...props} onFolderClick={onFolderClick} />,
|
||||||
};
|
};
|
||||||
|
|
||||||
const typeColumn: DashboardsTreeColumn = {
|
const typeColumn: DashboardsTreeColumn = {
|
||||||
@ -46,11 +76,18 @@ export function DashboardsTree({ items, width, height, onFolderClick }: Dashboar
|
|||||||
};
|
};
|
||||||
|
|
||||||
return [checkboxColumn, nameColumn, typeColumn];
|
return [checkboxColumn, nameColumn, typeColumn];
|
||||||
}, [onFolderClick]);
|
}, [onItemSelectionChange, onFolderClick]);
|
||||||
|
|
||||||
const table = useTable({ columns: tableColumns, data: items });
|
const table = useTable({ columns: tableColumns, data: items });
|
||||||
const { getTableProps, getTableBodyProps, headerGroups } = table;
|
const { getTableProps, getTableBodyProps, headerGroups } = table;
|
||||||
|
|
||||||
|
const virtualData = useMemo(() => {
|
||||||
|
return {
|
||||||
|
table,
|
||||||
|
selectedItems,
|
||||||
|
};
|
||||||
|
}, [table, selectedItems]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...getTableProps()} className={styles.tableRoot} role="table">
|
<div {...getTableProps()} className={styles.tableRoot} role="table">
|
||||||
{headerGroups.map((headerGroup) => {
|
{headerGroups.map((headerGroup) => {
|
||||||
@ -75,10 +112,11 @@ export function DashboardsTree({ items, width, height, onFolderClick }: Dashboar
|
|||||||
|
|
||||||
<div {...getTableBodyProps()}>
|
<div {...getTableBodyProps()}>
|
||||||
<List
|
<List
|
||||||
|
className="virtual list"
|
||||||
height={height - HEADER_HEIGHT}
|
height={height - HEADER_HEIGHT}
|
||||||
width={width}
|
width={width}
|
||||||
itemCount={items.length}
|
itemCount={items.length}
|
||||||
itemData={table}
|
itemData={virtualData}
|
||||||
itemSize={ROW_HEIGHT}
|
itemSize={ROW_HEIGHT}
|
||||||
>
|
>
|
||||||
{VirtualListRow}
|
{VirtualListRow}
|
||||||
@ -91,24 +129,32 @@ export function DashboardsTree({ items, width, height, onFolderClick }: Dashboar
|
|||||||
interface VirtualListRowProps {
|
interface VirtualListRowProps {
|
||||||
index: number;
|
index: number;
|
||||||
style: React.CSSProperties;
|
style: React.CSSProperties;
|
||||||
data: TableInstance<DashboardsTreeItem>;
|
data: {
|
||||||
|
table: TableInstance<DashboardsTreeItem>;
|
||||||
|
selectedItems: Record<DashboardViewItemKind, Record<string, boolean | undefined>>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function VirtualListRow({ index, style, data: table }: VirtualListRowProps) {
|
function VirtualListRow({ index, style, data }: VirtualListRowProps) {
|
||||||
const styles = useStyles2(getStyles);
|
const styles = useStyles2(getStyles);
|
||||||
|
const { table, selectedItems } = data;
|
||||||
const { rows, prepareRow } = table;
|
const { rows, prepareRow } = table;
|
||||||
|
|
||||||
const row = rows[index];
|
const row = rows[index];
|
||||||
prepareRow(row);
|
prepareRow(row);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div {...row.getRowProps({ style })} className={cx(styles.row, styles.bodyRow)}>
|
<div
|
||||||
|
{...row.getRowProps({ style })}
|
||||||
|
className={cx(styles.row, styles.bodyRow)}
|
||||||
|
data-testid={selectors.pages.BrowseDashbards.table.row(row.original.item.uid)}
|
||||||
|
>
|
||||||
{row.cells.map((cell) => {
|
{row.cells.map((cell) => {
|
||||||
const { key, ...cellProps } = cell.getCellProps();
|
const { key, ...cellProps } = cell.getCellProps();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={key} {...cellProps} className={styles.cell}>
|
<div key={key} {...cellProps} className={styles.cell}>
|
||||||
{cell.render('Cell')}
|
{cell.render('Cell', { selectedItems })}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -121,8 +167,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
tableRoot: css({
|
tableRoot: css({
|
||||||
// The Indented component uses this css variable to indent items to their position
|
// Responsively
|
||||||
// in the tree
|
|
||||||
[INDENT_AMOUNT_CSS_VAR]: theme.spacing(1),
|
[INDENT_AMOUNT_CSS_VAR]: theme.spacing(1),
|
||||||
|
|
||||||
[theme.breakpoints.up('md')]: {
|
[theme.breakpoints.up('md')]: {
|
||||||
|
@ -2,39 +2,117 @@ import { Chance } from 'chance';
|
|||||||
|
|
||||||
import { DashboardViewItem } from 'app/features/search/types';
|
import { DashboardViewItem } from 'app/features/search/types';
|
||||||
|
|
||||||
import { DashboardsTreeItem } from '../types';
|
import { DashboardsTreeItem, UIDashboardViewItem } from '../types';
|
||||||
|
|
||||||
|
export function wellFormedEmptyFolder(
|
||||||
|
seed = 1,
|
||||||
|
partial?: Partial<DashboardsTreeItem<UIDashboardViewItem>>
|
||||||
|
): DashboardsTreeItem<UIDashboardViewItem> {
|
||||||
|
const random = Chance(seed);
|
||||||
|
|
||||||
export function wellFormedEmptyFolder(): DashboardsTreeItem {
|
|
||||||
return {
|
return {
|
||||||
item: {
|
item: {
|
||||||
kind: 'ui-empty-folder',
|
kind: 'ui-empty-folder',
|
||||||
|
uid: random.guid(),
|
||||||
},
|
},
|
||||||
level: 0,
|
level: 0,
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
|
...partial,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wellFormedDashboard(random = Chance(1)): DashboardsTreeItem<DashboardViewItem> {
|
export function wellFormedDashboard(
|
||||||
|
seed = 1,
|
||||||
|
partial?: Partial<DashboardsTreeItem<DashboardViewItem>>,
|
||||||
|
itemPartial?: Partial<DashboardViewItem>
|
||||||
|
): DashboardsTreeItem<DashboardViewItem> {
|
||||||
|
const random = Chance(seed);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
item: {
|
item: {
|
||||||
kind: 'dashboard',
|
kind: 'dashboard',
|
||||||
title: random.sentence({ words: 3 }),
|
title: random.sentence({ words: 3 }),
|
||||||
uid: random.guid(),
|
uid: random.guid(),
|
||||||
tags: [random.word()],
|
tags: [random.word()],
|
||||||
|
...itemPartial,
|
||||||
},
|
},
|
||||||
level: 0,
|
level: 0,
|
||||||
isOpen: false,
|
isOpen: false,
|
||||||
|
...partial,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function wellFormedFolder(random = Chance(2)): DashboardsTreeItem<DashboardViewItem> {
|
export function wellFormedFolder(
|
||||||
|
seed = 1,
|
||||||
|
partial?: Partial<DashboardsTreeItem<DashboardViewItem>>,
|
||||||
|
itemPartial?: Partial<DashboardViewItem>
|
||||||
|
): DashboardsTreeItem<DashboardViewItem> {
|
||||||
|
const random = Chance(seed);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
item: {
|
item: {
|
||||||
kind: 'folder',
|
kind: 'folder',
|
||||||
title: random.sentence({ words: 3 }),
|
title: random.sentence({ words: 3 }),
|
||||||
uid: random.guid(),
|
uid: random.guid(),
|
||||||
|
...itemPartial,
|
||||||
},
|
},
|
||||||
level: 0,
|
level: 0,
|
||||||
isOpen: true,
|
isOpen: true,
|
||||||
|
...partial,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function wellFormedTree() {
|
||||||
|
let seed = 1;
|
||||||
|
|
||||||
|
// prettier-ignore so its easier to see the tree structure
|
||||||
|
/* prettier-ignore */ const folderA = wellFormedFolder(seed++);
|
||||||
|
/* prettier-ignore */ const folderA_folderA = wellFormedFolder(seed++, { level: 1}, { parentUID: folderA.item.uid });
|
||||||
|
/* prettier-ignore */ const folderA_folderB = wellFormedFolder(seed++, { level: 1}, { parentUID: folderA.item.uid });
|
||||||
|
/* prettier-ignore */ const folderA_folderB_dashbdA = wellFormedDashboard(seed++, { level: 2}, { parentUID: folderA_folderB.item.uid });
|
||||||
|
/* prettier-ignore */ const folderA_folderB_dashbdB = wellFormedDashboard(seed++, { level: 2}, { parentUID: folderA_folderB.item.uid });
|
||||||
|
/* prettier-ignore */ const folderA_folderC = wellFormedFolder(seed++, { level: 1},{ parentUID: folderA.item.uid });
|
||||||
|
/* prettier-ignore */ const folderA_folderC_dashbdA = wellFormedDashboard(seed++, { level: 2}, { parentUID: folderA_folderC.item.uid });
|
||||||
|
/* prettier-ignore */ const folderA_folderC_dashbdB = wellFormedDashboard(seed++, { level: 2}, { parentUID: folderA_folderC.item.uid });
|
||||||
|
/* prettier-ignore */ const folderA_dashbdD = wellFormedDashboard(seed++, { level: 1}, { parentUID: folderA.item.uid });
|
||||||
|
/* prettier-ignore */ const folderB = wellFormedFolder(seed++);
|
||||||
|
/* prettier-ignore */ const folderB_empty = wellFormedEmptyFolder(seed++);
|
||||||
|
/* prettier-ignore */ const folderC = wellFormedFolder(seed++);
|
||||||
|
/* prettier-ignore */ const dashbdD = wellFormedDashboard(seed++);
|
||||||
|
/* prettier-ignore */ const dashbdE = wellFormedDashboard(seed++);
|
||||||
|
|
||||||
|
return [
|
||||||
|
[
|
||||||
|
folderA,
|
||||||
|
folderA_folderA,
|
||||||
|
folderA_folderB,
|
||||||
|
folderA_folderB_dashbdA,
|
||||||
|
folderA_folderB_dashbdB,
|
||||||
|
folderA_folderC,
|
||||||
|
folderA_folderC_dashbdA,
|
||||||
|
folderA_folderC_dashbdB,
|
||||||
|
folderA_dashbdD,
|
||||||
|
folderB,
|
||||||
|
folderB_empty,
|
||||||
|
folderC,
|
||||||
|
dashbdD,
|
||||||
|
dashbdE,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
folderA,
|
||||||
|
folderA_folderA,
|
||||||
|
folderA_folderB,
|
||||||
|
folderA_folderB_dashbdA,
|
||||||
|
folderA_folderB_dashbdB,
|
||||||
|
folderA_folderC,
|
||||||
|
folderA_folderC_dashbdA,
|
||||||
|
folderA_folderC_dashbdB,
|
||||||
|
folderA_dashbdD,
|
||||||
|
folderB,
|
||||||
|
folderB_empty,
|
||||||
|
folderC,
|
||||||
|
dashbdD,
|
||||||
|
dashbdE,
|
||||||
|
},
|
||||||
|
] as const;
|
||||||
|
}
|
||||||
|
@ -1,7 +1,8 @@
|
|||||||
import { DashboardViewItem as OrigDashboardViewItem } from 'app/features/search/types';
|
import { DashboardViewItem as OrigDashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
|
||||||
|
|
||||||
interface UIDashboardViewItem {
|
export interface UIDashboardViewItem {
|
||||||
kind: 'ui-empty-folder';
|
kind: 'ui-empty-folder';
|
||||||
|
uid: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DashboardViewItem = OrigDashboardViewItem | UIDashboardViewItem;
|
type DashboardViewItem = OrigDashboardViewItem | UIDashboardViewItem;
|
||||||
@ -12,4 +13,6 @@ export interface DashboardsTreeItem<T extends DashboardViewItem = DashboardViewI
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DashboardTreeSelection = Record<DashboardViewItemKind, Record<string, boolean | undefined>>;
|
||||||
|
|
||||||
export const INDENT_AMOUNT_CSS_VAR = '--dashboards-tree-indentation';
|
export const INDENT_AMOUNT_CSS_VAR = '--dashboards-tree-indentation';
|
||||||
|
@ -51,6 +51,7 @@ async function getChildFolders(parentUid?: string, parentTitle?: string): Promis
|
|||||||
uid: item.uid,
|
uid: item.uid,
|
||||||
title: item.title,
|
title: item.title,
|
||||||
parentTitle,
|
parentTitle,
|
||||||
|
parentUID: parentUid,
|
||||||
url: `/dashboards/f/${item.uid}/`,
|
url: `/dashboards/f/${item.uid}/`,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
@ -81,6 +81,7 @@ export function queryResultToViewItem(
|
|||||||
if (parentInfo) {
|
if (parentInfo) {
|
||||||
viewItem.parentTitle = parentInfo.name;
|
viewItem.parentTitle = parentInfo.name;
|
||||||
viewItem.parentKind = parentInfo.kind;
|
viewItem.parentKind = parentInfo.kind;
|
||||||
|
viewItem.parentUID = parentUid;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -50,11 +50,13 @@ export interface DashboardSearchItem {
|
|||||||
folderUrl?: string;
|
folderUrl?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type DashboardViewItemKind = 'folder' | 'dashboard' | 'panel';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Type used in the folder view components
|
* Type used in the folder view components
|
||||||
*/
|
*/
|
||||||
export interface DashboardViewItem {
|
export interface DashboardViewItem {
|
||||||
kind: 'folder' | 'dashboard' | 'panel';
|
kind: DashboardViewItemKind;
|
||||||
uid: string;
|
uid: string;
|
||||||
title: string;
|
title: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
@ -62,7 +64,7 @@ export interface DashboardViewItem {
|
|||||||
|
|
||||||
icon?: string;
|
icon?: string;
|
||||||
|
|
||||||
// Most commonly parent folder title, but can be dashboard if panelTitleSearch is enabled
|
parentUID?: string;
|
||||||
parentTitle?: string;
|
parentTitle?: string;
|
||||||
parentKind?: string;
|
parentKind?: string;
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user