NestedFolders: Add search to NestedFolderPicker (#70848)

* NestedFolders: Add search to NestedFolderPicker

* fix pagination placeholders

* don't allow folders to be expanded in search
This commit is contained in:
Josh Hunt
2023-07-04 11:20:02 +01:00
committed by GitHub
parent 62254cc363
commit 3640bf77ba
3 changed files with 190 additions and 60 deletions

View File

@ -4,6 +4,8 @@ import { FixedSizeList as List } from 'react-window';
import { GrafanaTheme2 } from '@grafana/data';
import { IconButton, useStyles2 } from '@grafana/ui';
import { getSvgSize } from '@grafana/ui/src/components/Icon/utils';
import { Text } from '@grafana/ui/src/components/Text/Text';
import { Indent } from 'app/features/browse-dashboards/components/Indent';
import { DashboardsTreeItem } from 'app/features/browse-dashboards/types';
import { DashboardViewItem } from 'app/features/search/types';
@ -12,29 +14,37 @@ import { FolderUID } from './types';
const ROW_HEIGHT = 40;
const LIST_HEIGHT = ROW_HEIGHT * 6.5; // show 6 and a bit rows
const CHEVRON_SIZE = 'md';
interface NestedFolderListProps {
items: DashboardsTreeItem[];
foldersAreOpenable: boolean;
selectedFolder: FolderUID | undefined;
onFolderClick: (uid: string, newOpenState: boolean) => void;
onSelectionChange: (event: React.FormEvent<HTMLInputElement>, item: DashboardViewItem) => void;
}
export function NestedFolderList({ items, selectedFolder, onFolderClick, onSelectionChange }: NestedFolderListProps) {
export function NestedFolderList({
items,
foldersAreOpenable,
selectedFolder,
onFolderClick,
onSelectionChange,
}: NestedFolderListProps) {
const styles = useStyles2(getStyles);
const virtualData = useMemo(
(): VirtualData => ({ items, selectedFolder, onFolderClick, onSelectionChange }),
[items, selectedFolder, onFolderClick, onSelectionChange]
(): VirtualData => ({ items, foldersAreOpenable, selectedFolder, onFolderClick, onSelectionChange }),
[items, foldersAreOpenable, selectedFolder, onFolderClick, onSelectionChange]
);
return (
<>
<p className={styles.headerRow}>Name</p>
<div className={styles.table}>
<div className={styles.headerRow}>Name</div>
<List height={LIST_HEIGHT} width="100%" itemData={virtualData} itemSize={ROW_HEIGHT} itemCount={items.length}>
{Row}
</List>
</>
</div>
);
}
@ -47,7 +57,7 @@ interface RowProps {
}
function Row({ index, style: virtualStyles, data }: RowProps) {
const { items, selectedFolder, onFolderClick, onSelectionChange } = data;
const { items, foldersAreOpenable, selectedFolder, onFolderClick, onSelectionChange } = data;
const { item, isOpen, level } = items[index];
const id = useId() + `-uid-${item.uid}`;
@ -71,7 +81,11 @@ function Row({ index, style: virtualStyles, data }: RowProps) {
);
if (item.kind !== 'folder') {
return process.env.NODE_ENV !== 'production' ? <span>Non-folder item</span> : null;
return process.env.NODE_ENV !== 'production' ? (
<span style={virtualStyles} className={styles.row}>
Non-folder item {item.uid}
</span>
) : null;
}
return (
@ -89,14 +103,22 @@ function Row({ index, style: virtualStyles, data }: RowProps) {
<div className={styles.rowBody}>
<Indent level={level} />
<IconButton
onClick={handleClick}
aria-label={isOpen ? 'Collapse folder' : 'Expand folder'}
name={isOpen ? 'angle-down' : 'angle-right'}
/>
{foldersAreOpenable ? (
<IconButton
size={CHEVRON_SIZE}
onClick={handleClick}
aria-label={isOpen ? 'Collapse folder' : 'Expand folder'}
name={isOpen ? 'angle-down' : 'angle-right'}
/>
) : (
<span className={styles.folderButtonSpacer} />
)}
<label className={styles.label} htmlFor={id}>
<span>{item.title}</span>
{/* TODO: text is not truncated properly, it still overflows the container */}
<Text as="span" truncate>
{item.title}
</Text>
</label>
</div>
</div>
@ -114,19 +136,29 @@ const getStyles = (theme: GrafanaTheme2) => {
});
return {
table: css({
border: `solid 1px ${theme.components.input.borderColor}`,
background: theme.components.input.background,
}),
// Should be the same size as the <IconButton /> for proper alignment
folderButtonSpacer: css({
paddingLeft: `calc(${getSvgSize(CHEVRON_SIZE)}px + ${theme.spacing(0.5)})`,
}),
headerRow: css({
backgroundColor: theme.colors.background.secondary,
height: ROW_HEIGHT,
lineHeight: ROW_HEIGHT + 'px',
margin: 0,
paddingLeft: theme.spacing(3),
paddingLeft: theme.spacing(3.5),
}),
row: css({
display: 'flex',
position: 'relative',
alignItems: 'center',
borderBottom: `solid 1px ${theme.colors.border.weak}`,
borderTop: `solid 1px ${theme.components.input.borderColor}`,
}),
radio: css({
@ -157,6 +189,8 @@ const getStyles = (theme: GrafanaTheme2) => {
rowBody,
label: css({
lineHeight: ROW_HEIGHT + 'px',
flexGrow: 1,
'&:hover': {
textDecoration: 'underline',
cursor: 'pointer',

View File

@ -1,10 +1,15 @@
import { css } from '@emotion/css';
import React, { useCallback, useMemo, useState } from 'react';
import { useAsync } from 'react-use';
import { LoadingBar } from '@grafana/ui';
import { GrafanaTheme2 } from '@grafana/data';
import { Stack } from '@grafana/experimental';
import { Alert, FilterInput, LoadingBar, useStyles2 } from '@grafana/ui';
import { listFolders, PAGE_SIZE } from 'app/features/browse-dashboards/api/services';
import { createFlatTree } from 'app/features/browse-dashboards/state';
import { DashboardViewItemCollection } from 'app/features/browse-dashboards/types';
import { getGrafanaSearcher } from 'app/features/search/service';
import { queryResultToViewItem } from 'app/features/search/service/utils';
import { DashboardViewItem } from 'app/features/search/types';
import { NestedFolderList } from './NestedFolderList';
@ -22,11 +27,28 @@ interface NestedFolderPickerProps {
}
export function NestedFolderPicker({ value, onChange }: NestedFolderPickerProps) {
// const [search, setSearch] = useState('');
const styles = useStyles2(getStyles);
const [search, setSearch] = useState('');
const [folderOpenState, setFolderOpenState] = useState<Record<string, boolean>>({});
const [childrenForUID, setChildrenForUID] = useState<Record<string, DashboardViewItem[]>>({});
const state = useAsync(fetchRootFolders);
const rootFoldersState = useAsync(fetchRootFolders);
const searchState = useAsync(async () => {
if (!search) {
return undefined;
}
const searcher = getGrafanaSearcher();
const queryResponse = await searcher.search({
query: search,
kind: ['folder'],
limit: 100,
});
const items = queryResponse.view.map((v) => queryResultToViewItem(v, queryResponse.view));
return { ...queryResponse, items };
}, [search]);
const handleFolderClick = useCallback(async (uid: string, newOpenState: boolean) => {
setFolderOpenState((old) => ({ ...old, [uid]: newOpenState }));
@ -38,44 +60,65 @@ export function NestedFolderPicker({ value, onChange }: NestedFolderPickerProps)
}, []);
const flatTree = useMemo(() => {
const rootCollection: DashboardViewItemCollection = {
isFullyLoaded: !state.loading,
lastKindHasMoreItems: false,
lastFetchedKind: 'folder',
lastFetchedPage: 1,
items: state.value ?? [],
};
const searchResults = search && searchState.value;
const rootCollection: DashboardViewItemCollection = searchResults
? {
isFullyLoaded: searchResults.items.length === searchResults.totalRows,
lastKindHasMoreItems: false, // not relevent for search
lastFetchedKind: 'folder', // not relevent for search
lastFetchedPage: 1, // not relevent for search
items: searchResults.items ?? [],
}
: {
isFullyLoaded: !rootFoldersState.loading,
lastKindHasMoreItems: false,
lastFetchedKind: 'folder',
lastFetchedPage: 1,
items: rootFoldersState.value ?? [],
};
const childrenCollections: Record<string, DashboardViewItemCollection | undefined> = {};
for (const parentUID in childrenForUID) {
const children = childrenForUID[parentUID];
childrenCollections[parentUID] = {
isFullyLoaded: !!children,
lastKindHasMoreItems: false,
lastFetchedKind: 'folder',
lastFetchedPage: 1,
items: children,
};
if (!searchResults) {
// We don't expand folders when searching
for (const parentUID in childrenForUID) {
const children = childrenForUID[parentUID];
childrenCollections[parentUID] = {
isFullyLoaded: !!children,
lastKindHasMoreItems: false,
lastFetchedKind: 'folder',
lastFetchedPage: 1,
items: children,
};
}
}
const result = createFlatTree(undefined, rootCollection, childrenCollections, folderOpenState, 0, false);
result.unshift({
isOpen: false,
level: 0,
item: {
kind: 'folder',
title: 'Dashboards',
uid: '',
},
});
const result = createFlatTree(
undefined,
rootCollection,
childrenCollections,
searchResults ? {} : folderOpenState,
searchResults ? 0 : 1,
false
);
if (!searchResults) {
result.unshift({
isOpen: true,
level: 0,
item: {
kind: 'folder',
title: 'Dashboards',
uid: '',
},
});
}
return result;
}, [childrenForUID, folderOpenState, state.loading, state.value]);
}, [search, searchState.value, rootFoldersState.loading, rootFoldersState.value, folderOpenState, childrenForUID]);
const handleSelectionChange = useCallback(
(event: React.FormEvent<HTMLInputElement>, item: DashboardViewItem) => {
console.log('selected', item);
if (onChange) {
onChange({ title: item.title, uid: item.uid });
}
@ -83,20 +126,62 @@ export function NestedFolderPicker({ value, onChange }: NestedFolderPickerProps)
[onChange]
);
const isLoading = rootFoldersState.loading || searchState.loading;
const error = rootFoldersState.error || searchState.error;
const tree = flatTree;
return (
<fieldset>
{/* <FilterInput placeholder="Search folder" value={search} escapeRegex={false} onChange={(val) => setSearch(val)} /> */}
{state.loading && <LoadingBar width={300} />}
{state.error && <p>{state.error.message}</p>}
{state.value && (
<NestedFolderList
items={flatTree}
selectedFolder={value}
onFolderClick={handleFolderClick}
onSelectionChange={handleSelectionChange}
<Stack direction="column" gap={1}>
<FilterInput
placeholder="Search folder"
value={search}
escapeRegex={false}
onChange={(val) => setSearch(val)}
/>
)}
{error && (
<Alert severity="warning" title="Error loading folders">
{error.message || error.toString?.() || 'Unknown error'}
</Alert>
)}
<div className={styles.tableWrapper}>
{isLoading && (
<div className={styles.loader}>
<LoadingBar width={600} />
</div>
)}
<NestedFolderList
items={tree}
selectedFolder={value}
onFolderClick={handleFolderClick}
onSelectionChange={handleSelectionChange}
foldersAreOpenable={!(search && searchState.value)}
/>
</div>
</Stack>
</fieldset>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
tableWrapper: css({
position: 'relative',
zIndex: 1,
background: 'palegoldenrod',
}),
loader: css({
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: 2,
overflow: 'hidden', // loading bar overflows its container, so we need to clip it
}),
};
};

View File

@ -1,7 +1,7 @@
import { DataFrameView, IconName } from '@grafana/data';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { DashboardViewItem } from '../types';
import { DashboardViewItem, DashboardViewItemKind } from '../types';
import { DashboardQueryResult, SearchQuery, SearchResultMeta } from './types';
@ -50,6 +50,17 @@ export function getIconForKind(kind: string, isOpen?: boolean): IconName {
return 'question-circle';
}
function parseKindString(kind: string): DashboardViewItemKind {
switch (kind) {
case 'dashboard':
case 'folder':
case 'panel':
return kind;
default:
return 'dashboard'; // not a great fallback, but it's the previous behaviour
}
}
export function queryResultToViewItem(
item: DashboardQueryResult,
view?: DataFrameView<DashboardQueryResult>
@ -57,7 +68,7 @@ export function queryResultToViewItem(
const meta = view?.dataFrame.meta?.custom as SearchResultMeta | undefined;
const viewItem: DashboardViewItem = {
kind: 'dashboard',
kind: parseKindString(item.kind),
uid: item.uid,
title: item.name,
url: item.url,