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',
|
||||
itemDelete: 'Delete playlist item',
|
||||
},
|
||||
BrowseDashbards: {
|
||||
table: {
|
||||
row: (uid: string) => `data-testid ${uid} row`,
|
||||
checkbox: (uid: string) => `data-testid ${uid} checkbox`,
|
||||
},
|
||||
},
|
||||
Search: {
|
||||
url: '/?search=openn',
|
||||
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 { 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';
|
||||
|
||||
@ -16,19 +17,45 @@ interface BrowseViewProps {
|
||||
export function BrowseView({ folderUID, width, height }: BrowseViewProps) {
|
||||
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
|
||||
// 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>>({});
|
||||
|
||||
async function loadChildrenForUID(uid: string | undefined) {
|
||||
const folderKey = uid ?? '$$root';
|
||||
const loadChildrenForUID = useCallback(
|
||||
async (uid: string | undefined) => {
|
||||
const folderKey = uid ?? '$$root';
|
||||
|
||||
const childItems = await getFolderChildren(uid, undefined, true);
|
||||
setChildrenByUID((v) => ({ ...v, [folderKey]: childItems }));
|
||||
}
|
||||
const childItems = await getFolderChildren(uid, undefined, true);
|
||||
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(() => {
|
||||
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]);
|
||||
|
||||
const flatTree = useMemo(
|
||||
@ -36,15 +63,66 @@ export function BrowseView({ folderUID, width, height }: BrowseViewProps) {
|
||||
[folderUID, childrenByUID, openFolders]
|
||||
);
|
||||
|
||||
const handleFolderClick = useCallback((uid: string, folderIsOpen: boolean) => {
|
||||
if (folderIsOpen) {
|
||||
loadChildrenForUID(uid);
|
||||
}
|
||||
const handleFolderClick = useCallback(
|
||||
(uid: string, newState: boolean) => {
|
||||
if (newState) {
|
||||
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
|
||||
@ -54,17 +132,22 @@ function createFlatTree(
|
||||
openFolders: Record<string, boolean>,
|
||||
level = 0
|
||||
): 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 isOpen = Boolean(openFolders[item.uid]);
|
||||
const emptyFolder = childrenByUID[item.uid]?.length === 0;
|
||||
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 = {
|
||||
item,
|
||||
parentUID,
|
||||
level,
|
||||
isOpen,
|
||||
};
|
||||
@ -76,5 +159,25 @@ function createFlatTree(
|
||||
const isOpen = Boolean(openFolders[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 HEIGHT = 600;
|
||||
|
||||
const folder = wellFormedFolder();
|
||||
const folder = wellFormedFolder(1);
|
||||
const emptyFolderIndicator = wellFormedEmptyFolder();
|
||||
const dashboard = wellFormedDashboard();
|
||||
const dashboard = wellFormedDashboard(2);
|
||||
const noop = () => {};
|
||||
const selectedItems = {
|
||||
folder: {},
|
||||
dashboard: {},
|
||||
panel: {},
|
||||
};
|
||||
|
||||
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')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
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')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls onFolderClick when a folder button is clicked', async () => {
|
||||
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');
|
||||
await userEvent.click(folderButton);
|
||||
|
||||
@ -45,7 +78,16 @@ describe('browse-dashboards DashboardsTree', () => {
|
||||
});
|
||||
|
||||
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(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 { GrafanaTheme2 } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
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 { TypeCell } from './TypeCell';
|
||||
@ -15,28 +17,56 @@ interface DashboardsTreeProps {
|
||||
items: DashboardsTreeItem[];
|
||||
width: number;
|
||||
height: number;
|
||||
selectedItems: DashboardTreeSelection;
|
||||
onFolderClick: (uid: string, newOpenState: boolean) => void;
|
||||
onItemSelectionChange: (item: DashboardViewItem, newState: boolean) => void;
|
||||
}
|
||||
|
||||
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 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 tableColumns = useMemo(() => {
|
||||
const checkboxColumn: DashboardsTreeColumn = {
|
||||
id: 'checkbox',
|
||||
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 = {
|
||||
id: 'name',
|
||||
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 = {
|
||||
@ -46,11 +76,18 @@ export function DashboardsTree({ items, width, height, onFolderClick }: Dashboar
|
||||
};
|
||||
|
||||
return [checkboxColumn, nameColumn, typeColumn];
|
||||
}, [onFolderClick]);
|
||||
}, [onItemSelectionChange, onFolderClick]);
|
||||
|
||||
const table = useTable({ columns: tableColumns, data: items });
|
||||
const { getTableProps, getTableBodyProps, headerGroups } = table;
|
||||
|
||||
const virtualData = useMemo(() => {
|
||||
return {
|
||||
table,
|
||||
selectedItems,
|
||||
};
|
||||
}, [table, selectedItems]);
|
||||
|
||||
return (
|
||||
<div {...getTableProps()} className={styles.tableRoot} role="table">
|
||||
{headerGroups.map((headerGroup) => {
|
||||
@ -75,10 +112,11 @@ export function DashboardsTree({ items, width, height, onFolderClick }: Dashboar
|
||||
|
||||
<div {...getTableBodyProps()}>
|
||||
<List
|
||||
className="virtual list"
|
||||
height={height - HEADER_HEIGHT}
|
||||
width={width}
|
||||
itemCount={items.length}
|
||||
itemData={table}
|
||||
itemData={virtualData}
|
||||
itemSize={ROW_HEIGHT}
|
||||
>
|
||||
{VirtualListRow}
|
||||
@ -91,24 +129,32 @@ export function DashboardsTree({ items, width, height, onFolderClick }: Dashboar
|
||||
interface VirtualListRowProps {
|
||||
index: number;
|
||||
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 { table, selectedItems } = data;
|
||||
const { rows, prepareRow } = table;
|
||||
|
||||
const row = rows[index];
|
||||
prepareRow(row);
|
||||
|
||||
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) => {
|
||||
const { key, ...cellProps } = cell.getCellProps();
|
||||
|
||||
return (
|
||||
<div key={key} {...cellProps} className={styles.cell}>
|
||||
{cell.render('Cell')}
|
||||
{cell.render('Cell', { selectedItems })}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@ -121,8 +167,7 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
|
||||
return {
|
||||
tableRoot: css({
|
||||
// The Indented component uses this css variable to indent items to their position
|
||||
// in the tree
|
||||
// Responsively
|
||||
[INDENT_AMOUNT_CSS_VAR]: theme.spacing(1),
|
||||
|
||||
[theme.breakpoints.up('md')]: {
|
||||
|
@ -2,39 +2,117 @@ import { Chance } from 'chance';
|
||||
|
||||
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 {
|
||||
item: {
|
||||
kind: 'ui-empty-folder',
|
||||
uid: random.guid(),
|
||||
},
|
||||
level: 0,
|
||||
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 {
|
||||
item: {
|
||||
kind: 'dashboard',
|
||||
title: random.sentence({ words: 3 }),
|
||||
uid: random.guid(),
|
||||
tags: [random.word()],
|
||||
...itemPartial,
|
||||
},
|
||||
level: 0,
|
||||
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 {
|
||||
item: {
|
||||
kind: 'folder',
|
||||
title: random.sentence({ words: 3 }),
|
||||
uid: random.guid(),
|
||||
...itemPartial,
|
||||
},
|
||||
level: 0,
|
||||
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';
|
||||
uid: string;
|
||||
}
|
||||
|
||||
type DashboardViewItem = OrigDashboardViewItem | UIDashboardViewItem;
|
||||
@ -12,4 +13,6 @@ export interface DashboardsTreeItem<T extends DashboardViewItem = DashboardViewI
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export type DashboardTreeSelection = Record<DashboardViewItemKind, Record<string, boolean | undefined>>;
|
||||
|
||||
export const INDENT_AMOUNT_CSS_VAR = '--dashboards-tree-indentation';
|
||||
|
@ -51,6 +51,7 @@ async function getChildFolders(parentUid?: string, parentTitle?: string): Promis
|
||||
uid: item.uid,
|
||||
title: item.title,
|
||||
parentTitle,
|
||||
parentUID: parentUid,
|
||||
url: `/dashboards/f/${item.uid}/`,
|
||||
}));
|
||||
}
|
||||
|
@ -81,6 +81,7 @@ export function queryResultToViewItem(
|
||||
if (parentInfo) {
|
||||
viewItem.parentTitle = parentInfo.name;
|
||||
viewItem.parentKind = parentInfo.kind;
|
||||
viewItem.parentUID = parentUid;
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -50,11 +50,13 @@ export interface DashboardSearchItem {
|
||||
folderUrl?: string;
|
||||
}
|
||||
|
||||
export type DashboardViewItemKind = 'folder' | 'dashboard' | 'panel';
|
||||
|
||||
/**
|
||||
* Type used in the folder view components
|
||||
*/
|
||||
export interface DashboardViewItem {
|
||||
kind: 'folder' | 'dashboard' | 'panel';
|
||||
kind: DashboardViewItemKind;
|
||||
uid: string;
|
||||
title: string;
|
||||
url?: string;
|
||||
@ -62,7 +64,7 @@ export interface DashboardViewItem {
|
||||
|
||||
icon?: string;
|
||||
|
||||
// Most commonly parent folder title, but can be dashboard if panelTitleSearch is enabled
|
||||
parentUID?: string;
|
||||
parentTitle?: string;
|
||||
parentKind?: string;
|
||||
|
||||
|
Loading…
Reference in New Issue
Block a user