From 3640bf77ba79025d52f1e3490969b37f5f8323c9 Mon Sep 17 00:00:00 2001 From: Josh Hunt Date: Tue, 4 Jul 2023 11:20:02 +0100 Subject: [PATCH] NestedFolders: Add search to NestedFolderPicker (#70848) * NestedFolders: Add search to NestedFolderPicker * fix pagination placeholders * don't allow folders to be expanded in search --- .../NestedFolderPicker/NestedFolderList.tsx | 66 +++++-- .../NestedFolderPicker/NestedFolderPicker.tsx | 169 +++++++++++++----- public/app/features/search/service/utils.ts | 15 +- 3 files changed, 190 insertions(+), 60 deletions(-) diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderList.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderList.tsx index 0280f82f90c..567d44961c5 100644 --- a/public/app/core/components/NestedFolderPicker/NestedFolderList.tsx +++ b/public/app/core/components/NestedFolderPicker/NestedFolderList.tsx @@ -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, 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 ( - <> -

Name

+
+
Name
{Row} - +
); } @@ -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' ? Non-folder item : null; + return process.env.NODE_ENV !== 'production' ? ( + + Non-folder item {item.uid} + + ) : null; } return ( @@ -89,14 +103,22 @@ function Row({ index, style: virtualStyles, data }: RowProps) {
- + {foldersAreOpenable ? ( + + ) : ( + + )}
@@ -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 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', diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx index 30a25df97d5..dcc8c217626 100644 --- a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx +++ b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx @@ -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>({}); const [childrenForUID, setChildrenForUID] = useState>({}); - 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 = {}; - 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, 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 (
- {/* setSearch(val)} /> */} - - {state.loading && } - {state.error &&

{state.error.message}

} - {state.value && ( - + setSearch(val)} /> - )} + + {error && ( + + {error.message || error.toString?.() || 'Unknown error'} + + )} + +
+ {isLoading && ( +
+ +
+ )} + + +
+
); } + +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 + }), + }; +}; diff --git a/public/app/features/search/service/utils.ts b/public/app/features/search/service/utils.ts index f1360d9b8fc..b4cb61756b8 100644 --- a/public/app/features/search/service/utils.ts +++ b/public/app/features/search/service/utils.ts @@ -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 @@ -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,