mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06: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 { GrafanaTheme2 } from '@grafana/data';
|
||||||
import { IconButton, useStyles2 } from '@grafana/ui';
|
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 { Indent } from 'app/features/browse-dashboards/components/Indent';
|
||||||
import { DashboardsTreeItem } from 'app/features/browse-dashboards/types';
|
import { DashboardsTreeItem } from 'app/features/browse-dashboards/types';
|
||||||
import { DashboardViewItem } from 'app/features/search/types';
|
import { DashboardViewItem } from 'app/features/search/types';
|
||||||
@@ -12,29 +14,37 @@ import { FolderUID } from './types';
|
|||||||
|
|
||||||
const ROW_HEIGHT = 40;
|
const ROW_HEIGHT = 40;
|
||||||
const LIST_HEIGHT = ROW_HEIGHT * 6.5; // show 6 and a bit rows
|
const LIST_HEIGHT = ROW_HEIGHT * 6.5; // show 6 and a bit rows
|
||||||
|
const CHEVRON_SIZE = 'md';
|
||||||
|
|
||||||
interface NestedFolderListProps {
|
interface NestedFolderListProps {
|
||||||
items: DashboardsTreeItem[];
|
items: DashboardsTreeItem[];
|
||||||
|
foldersAreOpenable: boolean;
|
||||||
selectedFolder: FolderUID | undefined;
|
selectedFolder: FolderUID | undefined;
|
||||||
onFolderClick: (uid: string, newOpenState: boolean) => void;
|
onFolderClick: (uid: string, newOpenState: boolean) => void;
|
||||||
onSelectionChange: (event: React.FormEvent<HTMLInputElement>, item: DashboardViewItem) => 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 styles = useStyles2(getStyles);
|
||||||
|
|
||||||
const virtualData = useMemo(
|
const virtualData = useMemo(
|
||||||
(): VirtualData => ({ items, selectedFolder, onFolderClick, onSelectionChange }),
|
(): VirtualData => ({ items, foldersAreOpenable, selectedFolder, onFolderClick, onSelectionChange }),
|
||||||
[items, selectedFolder, onFolderClick, onSelectionChange]
|
[items, foldersAreOpenable, selectedFolder, onFolderClick, onSelectionChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div className={styles.table}>
|
||||||
<p className={styles.headerRow}>Name</p>
|
<div className={styles.headerRow}>Name</div>
|
||||||
<List height={LIST_HEIGHT} width="100%" itemData={virtualData} itemSize={ROW_HEIGHT} itemCount={items.length}>
|
<List height={LIST_HEIGHT} width="100%" itemData={virtualData} itemSize={ROW_HEIGHT} itemCount={items.length}>
|
||||||
{Row}
|
{Row}
|
||||||
</List>
|
</List>
|
||||||
</>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -47,7 +57,7 @@ interface RowProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Row({ index, style: virtualStyles, data }: 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 { item, isOpen, level } = items[index];
|
||||||
|
|
||||||
const id = useId() + `-uid-${item.uid}`;
|
const id = useId() + `-uid-${item.uid}`;
|
||||||
@@ -71,7 +81,11 @@ function Row({ index, style: virtualStyles, data }: RowProps) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (item.kind !== 'folder') {
|
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 (
|
return (
|
||||||
@@ -89,14 +103,22 @@ function Row({ index, style: virtualStyles, data }: RowProps) {
|
|||||||
<div className={styles.rowBody}>
|
<div className={styles.rowBody}>
|
||||||
<Indent level={level} />
|
<Indent level={level} />
|
||||||
|
|
||||||
<IconButton
|
{foldersAreOpenable ? (
|
||||||
onClick={handleClick}
|
<IconButton
|
||||||
aria-label={isOpen ? 'Collapse folder' : 'Expand folder'}
|
size={CHEVRON_SIZE}
|
||||||
name={isOpen ? 'angle-down' : 'angle-right'}
|
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}>
|
<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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,19 +136,29 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
return {
|
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({
|
headerRow: css({
|
||||||
backgroundColor: theme.colors.background.secondary,
|
backgroundColor: theme.colors.background.secondary,
|
||||||
height: ROW_HEIGHT,
|
height: ROW_HEIGHT,
|
||||||
lineHeight: ROW_HEIGHT + 'px',
|
lineHeight: ROW_HEIGHT + 'px',
|
||||||
margin: 0,
|
margin: 0,
|
||||||
paddingLeft: theme.spacing(3),
|
paddingLeft: theme.spacing(3.5),
|
||||||
}),
|
}),
|
||||||
|
|
||||||
row: css({
|
row: css({
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
position: 'relative',
|
position: 'relative',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
borderBottom: `solid 1px ${theme.colors.border.weak}`,
|
borderTop: `solid 1px ${theme.components.input.borderColor}`,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
radio: css({
|
radio: css({
|
||||||
@@ -157,6 +189,8 @@ const getStyles = (theme: GrafanaTheme2) => {
|
|||||||
rowBody,
|
rowBody,
|
||||||
|
|
||||||
label: css({
|
label: css({
|
||||||
|
lineHeight: ROW_HEIGHT + 'px',
|
||||||
|
flexGrow: 1,
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
textDecoration: 'underline',
|
textDecoration: 'underline',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
|
import { css } from '@emotion/css';
|
||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { useAsync } from 'react-use';
|
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 { listFolders, PAGE_SIZE } from 'app/features/browse-dashboards/api/services';
|
||||||
import { createFlatTree } from 'app/features/browse-dashboards/state';
|
import { createFlatTree } from 'app/features/browse-dashboards/state';
|
||||||
import { DashboardViewItemCollection } from 'app/features/browse-dashboards/types';
|
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 { DashboardViewItem } from 'app/features/search/types';
|
||||||
|
|
||||||
import { NestedFolderList } from './NestedFolderList';
|
import { NestedFolderList } from './NestedFolderList';
|
||||||
@@ -22,11 +27,28 @@ interface NestedFolderPickerProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function NestedFolderPicker({ value, onChange }: 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 [folderOpenState, setFolderOpenState] = useState<Record<string, boolean>>({});
|
||||||
const [childrenForUID, setChildrenForUID] = useState<Record<string, DashboardViewItem[]>>({});
|
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) => {
|
const handleFolderClick = useCallback(async (uid: string, newOpenState: boolean) => {
|
||||||
setFolderOpenState((old) => ({ ...old, [uid]: newOpenState }));
|
setFolderOpenState((old) => ({ ...old, [uid]: newOpenState }));
|
||||||
@@ -38,44 +60,65 @@ export function NestedFolderPicker({ value, onChange }: NestedFolderPickerProps)
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const flatTree = useMemo(() => {
|
const flatTree = useMemo(() => {
|
||||||
const rootCollection: DashboardViewItemCollection = {
|
const searchResults = search && searchState.value;
|
||||||
isFullyLoaded: !state.loading,
|
const rootCollection: DashboardViewItemCollection = searchResults
|
||||||
lastKindHasMoreItems: false,
|
? {
|
||||||
lastFetchedKind: 'folder',
|
isFullyLoaded: searchResults.items.length === searchResults.totalRows,
|
||||||
lastFetchedPage: 1,
|
lastKindHasMoreItems: false, // not relevent for search
|
||||||
items: state.value ?? [],
|
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> = {};
|
const childrenCollections: Record<string, DashboardViewItemCollection | undefined> = {};
|
||||||
|
|
||||||
for (const parentUID in childrenForUID) {
|
if (!searchResults) {
|
||||||
const children = childrenForUID[parentUID];
|
// We don't expand folders when searching
|
||||||
childrenCollections[parentUID] = {
|
for (const parentUID in childrenForUID) {
|
||||||
isFullyLoaded: !!children,
|
const children = childrenForUID[parentUID];
|
||||||
lastKindHasMoreItems: false,
|
childrenCollections[parentUID] = {
|
||||||
lastFetchedKind: 'folder',
|
isFullyLoaded: !!children,
|
||||||
lastFetchedPage: 1,
|
lastKindHasMoreItems: false,
|
||||||
items: children,
|
lastFetchedKind: 'folder',
|
||||||
};
|
lastFetchedPage: 1,
|
||||||
|
items: children,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = createFlatTree(undefined, rootCollection, childrenCollections, folderOpenState, 0, false);
|
const result = createFlatTree(
|
||||||
result.unshift({
|
undefined,
|
||||||
isOpen: false,
|
rootCollection,
|
||||||
level: 0,
|
childrenCollections,
|
||||||
item: {
|
searchResults ? {} : folderOpenState,
|
||||||
kind: 'folder',
|
searchResults ? 0 : 1,
|
||||||
title: 'Dashboards',
|
false
|
||||||
uid: '',
|
);
|
||||||
},
|
|
||||||
});
|
if (!searchResults) {
|
||||||
|
result.unshift({
|
||||||
|
isOpen: true,
|
||||||
|
level: 0,
|
||||||
|
item: {
|
||||||
|
kind: 'folder',
|
||||||
|
title: 'Dashboards',
|
||||||
|
uid: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [childrenForUID, folderOpenState, state.loading, state.value]);
|
}, [search, searchState.value, rootFoldersState.loading, rootFoldersState.value, folderOpenState, childrenForUID]);
|
||||||
|
|
||||||
const handleSelectionChange = useCallback(
|
const handleSelectionChange = useCallback(
|
||||||
(event: React.FormEvent<HTMLInputElement>, item: DashboardViewItem) => {
|
(event: React.FormEvent<HTMLInputElement>, item: DashboardViewItem) => {
|
||||||
console.log('selected', item);
|
|
||||||
if (onChange) {
|
if (onChange) {
|
||||||
onChange({ title: item.title, uid: item.uid });
|
onChange({ title: item.title, uid: item.uid });
|
||||||
}
|
}
|
||||||
@@ -83,20 +126,62 @@ export function NestedFolderPicker({ value, onChange }: NestedFolderPickerProps)
|
|||||||
[onChange]
|
[onChange]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isLoading = rootFoldersState.loading || searchState.loading;
|
||||||
|
const error = rootFoldersState.error || searchState.error;
|
||||||
|
|
||||||
|
const tree = flatTree;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<fieldset>
|
<fieldset>
|
||||||
{/* <FilterInput placeholder="Search folder" value={search} escapeRegex={false} onChange={(val) => setSearch(val)} /> */}
|
<Stack direction="column" gap={1}>
|
||||||
|
<FilterInput
|
||||||
{state.loading && <LoadingBar width={300} />}
|
placeholder="Search folder"
|
||||||
{state.error && <p>{state.error.message}</p>}
|
value={search}
|
||||||
{state.value && (
|
escapeRegex={false}
|
||||||
<NestedFolderList
|
onChange={(val) => setSearch(val)}
|
||||||
items={flatTree}
|
|
||||||
selectedFolder={value}
|
|
||||||
onFolderClick={handleFolderClick}
|
|
||||||
onSelectionChange={handleSelectionChange}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
{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>
|
</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 { DataFrameView, IconName } from '@grafana/data';
|
||||||
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
|
||||||
|
|
||||||
import { DashboardViewItem } from '../types';
|
import { DashboardViewItem, DashboardViewItemKind } from '../types';
|
||||||
|
|
||||||
import { DashboardQueryResult, SearchQuery, SearchResultMeta } from './types';
|
import { DashboardQueryResult, SearchQuery, SearchResultMeta } from './types';
|
||||||
|
|
||||||
@@ -50,6 +50,17 @@ export function getIconForKind(kind: string, isOpen?: boolean): IconName {
|
|||||||
return 'question-circle';
|
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(
|
export function queryResultToViewItem(
|
||||||
item: DashboardQueryResult,
|
item: DashboardQueryResult,
|
||||||
view?: DataFrameView<DashboardQueryResult>
|
view?: DataFrameView<DashboardQueryResult>
|
||||||
@@ -57,7 +68,7 @@ export function queryResultToViewItem(
|
|||||||
const meta = view?.dataFrame.meta?.custom as SearchResultMeta | undefined;
|
const meta = view?.dataFrame.meta?.custom as SearchResultMeta | undefined;
|
||||||
|
|
||||||
const viewItem: DashboardViewItem = {
|
const viewItem: DashboardViewItem = {
|
||||||
kind: 'dashboard',
|
kind: parseKindString(item.kind),
|
||||||
uid: item.uid,
|
uid: item.uid,
|
||||||
title: item.name,
|
title: item.name,
|
||||||
url: item.url,
|
url: item.url,
|
||||||
|
|||||||
Reference in New Issue
Block a user