From e84ee33c8bb27c60c117c344892d730ab4fdf493 Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Wed, 24 Jan 2024 11:41:25 +0000 Subject: [PATCH] NestedFolderPicker: Debounce search correctly (#80956) * debounce nested folder picker search * readd logic when no search string --- .../NestedFolderPicker/NestedFolderPicker.tsx | 59 ++++++++++++------- 1 file changed, 39 insertions(+), 20 deletions(-) diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx index 120c918180b..888ea40f01f 100644 --- a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx +++ b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx @@ -1,7 +1,7 @@ import { css } from '@emotion/css'; import { autoUpdate, flip, useClick, useDismiss, useFloating, useInteractions } from '@floating-ui/react'; -import React, { useCallback, useId, useMemo, useState } from 'react'; -import { useAsync } from 'react-use'; +import debounce from 'debounce-promise'; +import React, { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { config } from '@grafana/runtime'; @@ -19,7 +19,7 @@ import { } from 'app/features/browse-dashboards/state'; import { getPaginationPlaceholders } from 'app/features/browse-dashboards/state/utils'; import { DashboardViewItemCollection } from 'app/features/browse-dashboards/types'; -import { getGrafanaSearcher } from 'app/features/search/service'; +import { QueryResponse, getGrafanaSearcher } from 'app/features/search/service'; import { queryResultToViewItem } from 'app/features/search/service/utils'; import { DashboardViewItem } from 'app/features/search/types'; import { useDispatch, useSelector } from 'app/types/store'; @@ -47,6 +47,19 @@ export interface NestedFolderPickerProps { const EXCLUDED_KINDS = ['empty-folder' as const, 'dashboard' as const]; +const debouncedSearch = debounce(getSearchResults, 300); + +async function getSearchResults(searchQuery: string) { + const queryResponse = await getGrafanaSearcher().search({ + query: searchQuery, + kind: ['folder'], + limit: 100, + }); + + const items = queryResponse.view.map((v) => queryResultToViewItem(v, queryResponse.view)); + return { ...queryResponse, items }; +} + export function NestedFolderPicker({ value, invalid, @@ -62,26 +75,34 @@ export function NestedFolderPicker({ const nestedFoldersEnabled = Boolean(config.featureToggles.nestedFolders); const [search, setSearch] = useState(''); + const [searchResults, setSearchResults] = useState<(QueryResponse & { items: DashboardViewItem[] }) | null>(null); + const [isFetchingSearchResults, setIsFetchingSearchResults] = useState(false); const [autoFocusButton, setAutoFocusButton] = useState(false); const [overlayOpen, setOverlayOpen] = useState(false); const [folderOpenState, setFolderOpenState] = useState>({}); const overlayId = useId(); const [error] = useState(undefined); // TODO: error not populated anymore + const lastSearchTimestamp = useRef(0); - const searchState = useAsync(async () => { + useEffect(() => { if (!search) { - return undefined; + setSearchResults(null); + return; } - const searcher = getGrafanaSearcher(); - const queryResponse = await searcher.search({ - query: search, - kind: ['folder'], - limit: 100, + const timestamp = Date.now(); + setIsFetchingSearchResults(true); + debouncedSearch(search).then((queryResponse) => { + // Only keep the results if it's was issued after the most recently resolved search. + // This prevents results showing out of order if first request is slower than later ones. + // We don't need to worry about clearing the isFetching state either - if there's a later + // request in progress, this will clear it for us + if (timestamp > lastSearchTimestamp.current) { + const items = queryResponse.view.map((v) => queryResultToViewItem(v, queryResponse.view)); + setSearchResults({ ...queryResponse, items }); + setIsFetchingSearchResults(false); + lastSearchTimestamp.current = timestamp; + } }); - - const items = queryResponse.view.map((v) => queryResultToViewItem(v, queryResponse.view)); - - return { ...queryResponse, items }; }, [search]); const rootCollection = useSelector(rootItemsSelector); @@ -152,9 +173,7 @@ export function NestedFolderPicker({ ); const flatTree = useMemo(() => { - const searchResults = search && searchState.value; - - if (searchResults) { + if (search && searchResults) { const searchCollection: DashboardViewItemCollection = { isFullyLoaded: true, //searchResults.items.length === searchResults.totalRows, lastKindHasMoreItems: false, // TODO: paginate search @@ -203,7 +222,7 @@ export function NestedFolderPicker({ } return flatTree; - }, [search, searchState.value, rootCollection, childrenCollections, folderOpenState, excludeUIDs, showRootFolder]); + }, [search, searchResults, rootCollection, childrenCollections, folderOpenState, excludeUIDs, showRootFolder]); const isItemLoaded = useCallback( (itemIndex: number) => { @@ -219,7 +238,7 @@ export function NestedFolderPicker({ [flatTree] ); - const isLoading = rootStatus === 'pending' || searchState.loading; + const isLoading = rootStatus === 'pending' || isFetchingSearchResults; const { focusedItemIndex, handleKeyDown } = useTreeInteractions({ tree: flatTree, @@ -311,7 +330,7 @@ export function NestedFolderPicker({ onFolderExpand={handleFolderExpand} onFolderSelect={handleFolderSelect} idPrefix={overlayId} - foldersAreOpenable={nestedFoldersEnabled && !(search && searchState.value)} + foldersAreOpenable={nestedFoldersEnabled && !(search && searchResults)} isItemLoaded={isItemLoaded} requestLoadMore={handleLoadMore} />