NestedFolders: Virtual tree view (#66102)

* poc for virtual tree

* remove my silly debug stuff

* wip

* style table

* improve styles

* fix

* start to split tree into seperate component

* cleanup unused function

* split into more components

* more better

* secondary color type column

* simple tests for DashboardsTree

* restore styles from dodgy rebase

* remove my weirdo text component thing
This commit is contained in:
Josh Hunt 2023-04-17 11:08:24 +01:00 committed by GitHub
parent f58a63b2df
commit 9b15c79e19
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 495 additions and 84 deletions

View File

@ -1,6 +1,9 @@
import { css } from '@emotion/css';
import React, { memo, useMemo } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { locationSearchToObject } from '@grafana/runtime';
import { useStyles2 } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
@ -22,6 +25,7 @@ interface Props extends GrafanaRouteComponentProps<BrowseDashboardsPageRoutePara
// New Browse/Manage/Search Dashboards views for nested folders
const BrowseDashboardsPage = memo(({ match, location }: Props) => {
const styles = useStyles2(getStyles);
const { uid: folderUID } = match.params;
const searchState = useMemo(() => {
@ -33,16 +37,37 @@ const BrowseDashboardsPage = memo(({ match, location }: Props) => {
return (
<Page navId="dashboards/browse" pageNav={navModel}>
<Page.Contents>
<Page.Contents className={styles.pageContents}>
<BrowseActions />
{folderDTO && <pre>{JSON.stringify(folderDTO, null, 2)}</pre>}
{searchState.query ? <SearchView searchState={searchState} /> : <BrowseView folderUID={folderUID} />}
<div className={styles.subView}>
<AutoSizer>
{({ width, height }) =>
searchState.query ? (
<SearchView searchState={searchState} />
) : (
<BrowseView width={width} height={height} folderUID={folderUID} />
)
}
</AutoSizer>
</div>
</Page.Contents>
</Page>
);
});
const getStyles = () => ({
pageContents: css({
display: 'grid',
gridTemplateRows: 'auto 1fr',
height: '100%',
}),
// AutoSizer needs an element to measure the full height available
subView: css({
height: '100%',
}),
});
BrowseDashboardsPage.displayName = 'BrowseDashboardsPage';
export default BrowseDashboardsPage;

View File

@ -1,101 +1,80 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Icon, IconButton, Link } from '@grafana/ui';
import { getFolderChildren } from 'app/features/search/service/folders';
import { DashboardViewItem } from 'app/features/search/types';
type NestedData = Record<string, DashboardViewItem[] | undefined>;
import { DashboardsTreeItem } from '../types';
import { DashboardsTree } from './DashboardsTree';
interface BrowseViewProps {
height: number;
width: number;
folderUID: string | undefined;
}
export function BrowseView({ folderUID }: BrowseViewProps) {
const [nestedData, setNestedData] = useState<NestedData>({});
export function BrowseView({ folderUID, width, height }: BrowseViewProps) {
const [openFolders, setOpenFolders] = useState<Record<string, boolean>>({ [folderUID ?? '$$root']: true });
// Note: entire implementation of this component must be replaced.
// This is just to show proof of concept for fetching and showing the data
// 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 childItems = await getFolderChildren(uid, undefined, true);
setChildrenByUID((v) => ({ ...v, [folderKey]: childItems }));
}
useEffect(() => {
const folderKey = folderUID ?? '$$root';
getFolderChildren(folderUID, undefined, true).then((children) => {
setNestedData((v) => ({ ...v, [folderKey]: children }));
});
loadChildrenForUID(folderUID);
}, [folderUID]);
const items = nestedData[folderUID ?? '$$root'] ?? [];
const handleNodeClick = useCallback(
(uid: string) => {
if (nestedData[uid]) {
setNestedData((v) => ({ ...v, [uid]: undefined }));
return;
}
getFolderChildren(uid).then((children) => {
setNestedData((v) => ({ ...v, [uid]: children }));
});
},
[nestedData]
const flatTree = useMemo(
() => createFlatTree(folderUID, childrenByUID, openFolders),
[folderUID, childrenByUID, openFolders]
);
return (
<div>
<p>Browse view</p>
const handleFolderClick = useCallback((uid: string, folderIsOpen: boolean) => {
if (folderIsOpen) {
loadChildrenForUID(uid);
}
<ul style={{ marginLeft: 16 }}>
{items.map((item) => {
return (
<li key={item.uid}>
<BrowseItem item={item} nestedData={nestedData} onFolderClick={handleNodeClick} />
</li>
);
})}
</ul>
</div>
);
setOpenFolders((v) => ({ ...v, [uid]: folderIsOpen }));
}, []);
return <DashboardsTree items={flatTree} width={width} height={height} onFolderClick={handleFolderClick} />;
}
function BrowseItem({
item,
nestedData,
onFolderClick,
}: {
item: DashboardViewItem;
nestedData: NestedData;
onFolderClick: (uid: string) => void;
}) {
const childItems = nestedData[item.uid];
// Creates a flat list of items, with nested children indicated by its increasing level
function createFlatTree(
rootFolderUID: string | undefined,
childrenByUID: Record<string, DashboardViewItem[] | undefined>,
openFolders: Record<string, boolean>,
level = 0
): DashboardsTreeItem[] {
function mapItem(item: DashboardViewItem, level: number): DashboardsTreeItem[] {
const mappedChildren = createFlatTree(item.uid, childrenByUID, openFolders, level + 1);
return (
<>
<div>
{item.kind === 'folder' ? (
<IconButton onClick={() => onFolderClick(item.uid)} name={childItems ? 'angle-down' : 'angle-right'} />
) : (
<span style={{ paddingRight: 20 }} />
)}
<Icon name={item.kind === 'folder' ? (childItems ? 'folder-open' : 'folder') : 'apps'} />{' '}
<Link href={item.kind === 'folder' ? `/nested-dashboards/f/${item.uid}` : `/d/${item.uid}`}>{item.title}</Link>
</div>
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' } });
}
{childItems && (
<ul style={{ marginLeft: 16 }}>
{childItems.length === 0 && (
<li>
<em>Empty folder</em>
</li>
)}
{childItems.map((childItem) => {
return (
<li key={childItem.uid}>
<BrowseItem item={childItem} nestedData={nestedData} onFolderClick={onFolderClick} />{' '}
</li>
);
})}
</ul>
)}
</>
);
const thisItem = {
item,
level,
isOpen,
};
return [thisItem, ...mappedChildren];
}
const folderKey = rootFolderUID ?? '$$root';
const isOpen = Boolean(openFolders[folderKey]);
const items = (isOpen && childrenByUID[folderKey]) || [];
return items.flatMap((item) => mapItem(item, level));
}

View File

@ -0,0 +1,52 @@
import { 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 { locationService } from '@grafana/runtime';
import { wellFormedDashboard, wellFormedEmptyFolder, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture';
import { DashboardsTree } from './DashboardsTree';
function render(...args: Parameters<typeof rtlRender>) {
const [ui, options] = args;
rtlRender(<Router history={locationService.getHistory()}>{ui}</Router>, options);
}
describe('browse-dashboards DashboardsTree', () => {
const WIDTH = 800;
const HEIGHT = 600;
const folder = wellFormedFolder();
const emptyFolderIndicator = wellFormedEmptyFolder();
const dashboard = wellFormedDashboard();
it('renders a dashboard item', () => {
render(<DashboardsTree items={[dashboard]} width={WIDTH} height={HEIGHT} onFolderClick={() => {}} />);
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={() => {}} />);
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} />);
const folderButton = screen.getByLabelText('Collapse folder');
await userEvent.click(folderButton);
expect(handler).toHaveBeenCalledWith(folder.item.uid, false);
});
it('renders empty folder indicators', () => {
render(<DashboardsTree items={[emptyFolderIndicator]} width={WIDTH} height={HEIGHT} onFolderClick={() => {}} />);
expect(screen.queryByText('Empty folder')).toBeInTheDocument();
expect(screen.queryByText(emptyFolderIndicator.item.kind)).not.toBeInTheDocument();
});
});

View File

@ -0,0 +1,165 @@
import { css, cx } from '@emotion/css';
import React, { useMemo } from 'react';
import { CellProps, Column, TableInstance, useTable } from 'react-table';
import { FixedSizeList as List } from 'react-window';
import { GrafanaTheme2 } from '@grafana/data';
import { Checkbox, useStyles2 } from '@grafana/ui';
import { DashboardsTreeItem, INDENT_AMOUNT_CSS_VAR } from '../types';
import { NameCell } from './NameCell';
import { TypeCell } from './TypeCell';
interface DashboardsTreeProps {
items: DashboardsTreeItem[];
width: number;
height: number;
onFolderClick: (uid: string, newOpenState: boolean) => void;
}
type DashboardsTreeColumn = Column<DashboardsTreeItem>;
const HEADER_HEIGHT = 35;
const ROW_HEIGHT = 35;
export function DashboardsTree({ items, width, height, onFolderClick }: DashboardsTreeProps) {
const styles = useStyles2(getStyles);
const tableColumns = useMemo(() => {
const checkboxColumn: DashboardsTreeColumn = {
id: 'checkbox',
Header: () => <Checkbox value={false} />,
Cell: () => <Checkbox value={false} />,
};
const nameColumn: DashboardsTreeColumn = {
id: 'name',
Header: <span style={{ paddingLeft: 20 }}>Name</span>,
Cell: (props: CellProps<DashboardsTreeItem, unknown>) => <NameCell {...props} onFolderClick={onFolderClick} />,
};
const typeColumn: DashboardsTreeColumn = {
id: 'type',
Header: 'Type',
Cell: TypeCell,
};
return [checkboxColumn, nameColumn, typeColumn];
}, [onFolderClick]);
const table = useTable({ columns: tableColumns, data: items });
const { getTableProps, getTableBodyProps, headerGroups } = table;
return (
<div {...getTableProps()} className={styles.tableRoot} role="table">
{headerGroups.map((headerGroup) => {
const { key, ...headerGroupProps } = headerGroup.getHeaderGroupProps({
style: { width },
});
return (
<div key={key} {...headerGroupProps} className={cx(styles.row, styles.headerRow)}>
{headerGroup.headers.map((column) => {
const { key, ...headerProps } = column.getHeaderProps();
return (
<div key={key} {...headerProps} role="columnheader" className={styles.cell}>
{column.render('Header')}
</div>
);
})}
</div>
);
})}
<div {...getTableBodyProps()}>
<List
height={height - HEADER_HEIGHT}
width={width}
itemCount={items.length}
itemData={table}
itemSize={ROW_HEIGHT}
>
{VirtualListRow}
</List>
</div>
</div>
);
}
interface VirtualListRowProps {
index: number;
style: React.CSSProperties;
data: TableInstance<DashboardsTreeItem>;
}
function VirtualListRow({ index, style, data: table }: VirtualListRowProps) {
const styles = useStyles2(getStyles);
const { rows, prepareRow } = table;
const row = rows[index];
prepareRow(row);
return (
<div {...row.getRowProps({ style })} className={cx(styles.row, styles.bodyRow)}>
{row.cells.map((cell) => {
const { key, ...cellProps } = cell.getCellProps();
return (
<div key={key} {...cellProps} className={styles.cell}>
{cell.render('Cell')}
</div>
);
})}
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
const columnSizing = 'auto 2fr 1fr';
return {
tableRoot: css({
// The Indented component uses this css variable to indent items to their position
// in the tree
[INDENT_AMOUNT_CSS_VAR]: theme.spacing(1),
[theme.breakpoints.up('md')]: {
[INDENT_AMOUNT_CSS_VAR]: theme.spacing(3),
},
}),
cell: css({
padding: theme.spacing(1),
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}),
row: css({
display: 'grid',
gridTemplateColumns: columnSizing,
alignItems: 'center',
}),
headerRow: css({
backgroundColor: theme.colors.background.secondary,
height: HEADER_HEIGHT,
}),
bodyRow: css({
height: ROW_HEIGHT,
'&:hover': {
backgroundColor: theme.colors.emphasize(theme.colors.background.primary, 0.03),
},
}),
link: css({
'&:hover': {
textDecoration: 'underline',
},
}),
};
};

View File

@ -0,0 +1,20 @@
import React from 'react';
import { useTheme2 } from '@grafana/ui';
import { INDENT_AMOUNT_CSS_VAR } from '../types';
interface IndentProps {
children?: React.ReactNode;
level: number;
}
export function Indent({ children, level }: IndentProps) {
const theme = useTheme2();
// DashboardsTree responsively sets the value of INDENT_AMOUNT_CSS_VAR
// but we also have a fallback just in case it's not set for some reason...
const space = `var(${INDENT_AMOUNT_CSS_VAR}, ${theme.spacing(2)})`;
return <span style={{ paddingLeft: `calc(${space} * ${level})` }}>{children}</span>;
}

View File

@ -0,0 +1,70 @@
import { css } from '@emotion/css';
import React from 'react';
import { CellProps } from 'react-table';
import { GrafanaTheme2 } from '@grafana/data';
import { IconButton, Link, useStyles2 } from '@grafana/ui';
import { getSvgSize } from '@grafana/ui/src/components/Icon/utils';
import { DashboardsTreeItem } from '../types';
import { Indent } from './Indent';
type NameCellProps = CellProps<DashboardsTreeItem, unknown> & {
onFolderClick: (uid: string, newOpenState: boolean) => void;
};
export function NameCell({ row: { original: data }, onFolderClick }: NameCellProps) {
const styles = useStyles2(getStyles);
const { item, level, isOpen } = data;
if (item.kind === 'ui-empty-folder') {
return (
<>
<Indent level={level} />
<span className={styles.folderButtonSpacer} />
<em>Empty folder</em>
</>
);
}
const chevronIcon = isOpen ? 'angle-down' : 'angle-right';
return (
<>
<Indent level={level} />
{item.kind === 'folder' ? (
<IconButton
size="md"
onClick={() => onFolderClick(item.uid, !isOpen)}
name={chevronIcon}
ariaLabel={isOpen ? 'Collapse folder' : 'Expand folder'}
/>
) : (
<span className={styles.folderButtonSpacer} />
)}
<Link
href={item.kind === 'folder' ? `/nested-dashboards/f/${item.uid}` : `/d/${item.uid}`}
className={styles.link}
>
{item.title}
</Link>
</>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
// Should be the same size as the <IconButton /> so Dashboard name is aligned to Folder name siblings
folderButtonSpacer: css({
paddingLeft: `calc(${getSvgSize('md')}px + ${theme.spacing(0.5)})`,
}),
link: css({
'&:hover': {
textDecoration: 'underline',
},
}),
};
};

View File

@ -0,0 +1,45 @@
import { css } from '@emotion/css';
import React from 'react';
import { CellProps } from 'react-table';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, useStyles2 } from '@grafana/ui';
import { getIconForKind } from 'app/features/search/service/utils';
import { DashboardsTreeItem } from '../types';
export function TypeCell({ row: { original: data } }: CellProps<DashboardsTreeItem, unknown>) {
const styles = useStyles2(getStyles);
const iconName = getIconForKind(data.item.kind);
switch (data.item.kind) {
case 'dashboard':
return (
<span className={styles.text}>
<Icon name={iconName} /> Dashboard
</span>
);
case 'folder':
return (
<span className={styles.text}>
<Icon name={iconName} /> Folder
</span>
);
case 'panel':
return (
<span className={styles.text}>
<Icon name={iconName} /> Panel
</span>
);
default:
return null;
}
}
function getStyles(theme: GrafanaTheme2) {
return {
text: css({
color: theme.colors.text.secondary,
}),
};
}

View File

@ -0,0 +1,40 @@
import { Chance } from 'chance';
import { DashboardViewItem } from 'app/features/search/types';
import { DashboardsTreeItem } from '../types';
export function wellFormedEmptyFolder(): DashboardsTreeItem {
return {
item: {
kind: 'ui-empty-folder',
},
level: 0,
isOpen: false,
};
}
export function wellFormedDashboard(random = Chance(1)): DashboardsTreeItem<DashboardViewItem> {
return {
item: {
kind: 'dashboard',
title: random.sentence({ words: 3 }),
uid: random.guid(),
tags: [random.word()],
},
level: 0,
isOpen: false,
};
}
export function wellFormedFolder(random = Chance(2)): DashboardsTreeItem<DashboardViewItem> {
return {
item: {
kind: 'folder',
title: random.sentence({ words: 3 }),
uid: random.guid(),
},
level: 0,
isOpen: true,
};
}

View File

@ -0,0 +1,15 @@
import { DashboardViewItem as OrigDashboardViewItem } from 'app/features/search/types';
interface UIDashboardViewItem {
kind: 'ui-empty-folder';
}
type DashboardViewItem = OrigDashboardViewItem | UIDashboardViewItem;
export interface DashboardsTreeItem<T extends DashboardViewItem = DashboardViewItem> {
item: T;
level: number;
isOpen: boolean;
}
export const INDENT_AMOUNT_CSS_VAR = '--dashboards-tree-indentation';

View File

@ -115,7 +115,7 @@ export const SearchView = ({ showManage, folderDTO, hidePseudoFolders, keyboardE
}
return (
<div style={{ height: '100%', width: '100%' }}>
<div style={{ content: 'auto-sizer-wrapper', height: '100%', width: '100%' }}>
<AutoSizer>
{({ width, height }) => {
const props: SearchResultsProps = {