diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx index a3d3cf83352..eccb6dac905 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.tsx @@ -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 { + const styles = useStyles2(getStyles); const { uid: folderUID } = match.params; const searchState = useMemo(() => { @@ -33,16 +37,37 @@ const BrowseDashboardsPage = memo(({ match, location }: Props) => { return ( - + - {folderDTO &&
{JSON.stringify(folderDTO, null, 2)}
} - - {searchState.query ? : } +
+ + {({ width, height }) => + searchState.query ? ( + + ) : ( + + ) + } + +
); }); +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; diff --git a/public/app/features/browse-dashboards/components/BrowseView.tsx b/public/app/features/browse-dashboards/components/BrowseView.tsx index 9ea9b49b30f..c8b03b53350 100644 --- a/public/app/features/browse-dashboards/components/BrowseView.tsx +++ b/public/app/features/browse-dashboards/components/BrowseView.tsx @@ -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; +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({}); +export function BrowseView({ folderUID, width, height }: BrowseViewProps) { + const [openFolders, setOpenFolders] = useState>({ [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>({}); + + 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 ( -
-

Browse view

+ const handleFolderClick = useCallback((uid: string, folderIsOpen: boolean) => { + if (folderIsOpen) { + loadChildrenForUID(uid); + } -
    - {items.map((item) => { - return ( -
  • - -
  • - ); - })} -
-
- ); + setOpenFolders((v) => ({ ...v, [uid]: folderIsOpen })); + }, []); + + return ; } -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, + openFolders: Record, + level = 0 +): DashboardsTreeItem[] { + function mapItem(item: DashboardViewItem, level: number): DashboardsTreeItem[] { + const mappedChildren = createFlatTree(item.uid, childrenByUID, openFolders, level + 1); - return ( - <> -
- {item.kind === 'folder' ? ( - onFolderClick(item.uid)} name={childItems ? 'angle-down' : 'angle-right'} /> - ) : ( - - )} - {' '} - {item.title} -
+ 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 && ( -
    - {childItems.length === 0 && ( -
  • - Empty folder -
  • - )} - {childItems.map((childItem) => { - return ( -
  • - {' '} -
  • - ); - })} -
- )} - - ); + 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)); } diff --git a/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx b/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx new file mode 100644 index 00000000000..e50d99e06ba --- /dev/null +++ b/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx @@ -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) { + const [ui, options] = args; + + rtlRender({ui}, 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( {}} />); + expect(screen.queryByText(dashboard.item.title)).toBeInTheDocument(); + expect(screen.queryByText('Dashboard')).toBeInTheDocument(); + }); + + it('renders a folder item', () => { + render( {}} />); + 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(); + const folderButton = screen.getByLabelText('Collapse folder'); + await userEvent.click(folderButton); + + expect(handler).toHaveBeenCalledWith(folder.item.uid, false); + }); + + it('renders empty folder indicators', () => { + render( {}} />); + expect(screen.queryByText('Empty folder')).toBeInTheDocument(); + expect(screen.queryByText(emptyFolderIndicator.item.kind)).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/features/browse-dashboards/components/DashboardsTree.tsx b/public/app/features/browse-dashboards/components/DashboardsTree.tsx new file mode 100644 index 00000000000..ed1eb6cbe20 --- /dev/null +++ b/public/app/features/browse-dashboards/components/DashboardsTree.tsx @@ -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; + +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: () => , + Cell: () => , + }; + + const nameColumn: DashboardsTreeColumn = { + id: 'name', + Header: Name, + Cell: (props: CellProps) => , + }; + + 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 ( +
+ {headerGroups.map((headerGroup) => { + const { key, ...headerGroupProps } = headerGroup.getHeaderGroupProps({ + style: { width }, + }); + + return ( +
+ {headerGroup.headers.map((column) => { + const { key, ...headerProps } = column.getHeaderProps(); + + return ( +
+ {column.render('Header')} +
+ ); + })} +
+ ); + })} + +
+ + {VirtualListRow} + +
+
+ ); +} + +interface VirtualListRowProps { + index: number; + style: React.CSSProperties; + data: TableInstance; +} + +function VirtualListRow({ index, style, data: table }: VirtualListRowProps) { + const styles = useStyles2(getStyles); + const { rows, prepareRow } = table; + + const row = rows[index]; + prepareRow(row); + + return ( +
+ {row.cells.map((cell) => { + const { key, ...cellProps } = cell.getCellProps(); + + return ( +
+ {cell.render('Cell')} +
+ ); + })} +
+ ); +} + +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', + }, + }), + }; +}; diff --git a/public/app/features/browse-dashboards/components/Indent.tsx b/public/app/features/browse-dashboards/components/Indent.tsx new file mode 100644 index 00000000000..d8edb362b6f --- /dev/null +++ b/public/app/features/browse-dashboards/components/Indent.tsx @@ -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 {children}; +} diff --git a/public/app/features/browse-dashboards/components/NameCell.tsx b/public/app/features/browse-dashboards/components/NameCell.tsx new file mode 100644 index 00000000000..4765b7042a1 --- /dev/null +++ b/public/app/features/browse-dashboards/components/NameCell.tsx @@ -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 & { + 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 ( + <> + + + Empty folder + + ); + } + + const chevronIcon = isOpen ? 'angle-down' : 'angle-right'; + + return ( + <> + + + {item.kind === 'folder' ? ( + onFolderClick(item.uid, !isOpen)} + name={chevronIcon} + ariaLabel={isOpen ? 'Collapse folder' : 'Expand folder'} + /> + ) : ( + + )} + + + {item.title} + + + ); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + // Should be the same size as the 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', + }, + }), + }; +}; diff --git a/public/app/features/browse-dashboards/components/TypeCell.tsx b/public/app/features/browse-dashboards/components/TypeCell.tsx new file mode 100644 index 00000000000..fa280997f2f --- /dev/null +++ b/public/app/features/browse-dashboards/components/TypeCell.tsx @@ -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) { + const styles = useStyles2(getStyles); + const iconName = getIconForKind(data.item.kind); + + switch (data.item.kind) { + case 'dashboard': + return ( + + Dashboard + + ); + case 'folder': + return ( + + Folder + + ); + case 'panel': + return ( + + Panel + + ); + default: + return null; + } +} + +function getStyles(theme: GrafanaTheme2) { + return { + text: css({ + color: theme.colors.text.secondary, + }), + }; +} diff --git a/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts b/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts new file mode 100644 index 00000000000..6f3c33f5502 --- /dev/null +++ b/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts @@ -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 { + 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 { + return { + item: { + kind: 'folder', + title: random.sentence({ words: 3 }), + uid: random.guid(), + }, + level: 0, + isOpen: true, + }; +} diff --git a/public/app/features/browse-dashboards/types.ts b/public/app/features/browse-dashboards/types.ts new file mode 100644 index 00000000000..7779d4f9c0d --- /dev/null +++ b/public/app/features/browse-dashboards/types.ts @@ -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 { + item: T; + level: number; + isOpen: boolean; +} + +export const INDENT_AMOUNT_CSS_VAR = '--dashboards-tree-indentation'; diff --git a/public/app/features/search/page/components/SearchView.tsx b/public/app/features/search/page/components/SearchView.tsx index b1f65393187..71ea47e1e54 100644 --- a/public/app/features/search/page/components/SearchView.tsx +++ b/public/app/features/search/page/components/SearchView.tsx @@ -115,7 +115,7 @@ export const SearchView = ({ showManage, folderDTO, hidePseudoFolders, keyboardE } return ( -
+
{({ width, height }) => { const props: SearchResultsProps = {