mirror of
https://github.com/grafana/grafana.git
synced 2025-08-02 03:12:13 +08:00
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:
@ -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',
|
||||
|
@ -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
|
||||
}),
|
||||
};
|
||||
};
|
||||
|
@ -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,
|
||||
|
Reference in New Issue
Block a user