diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderList.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderList.tsx index 9617668e356..53d0d17599f 100644 --- a/public/app/core/components/NestedFolderPicker/NestedFolderList.tsx +++ b/public/app/core/components/NestedFolderPicker/NestedFolderList.tsx @@ -1,6 +1,8 @@ import { css } from '@emotion/css'; -import React, { useCallback, useId, useMemo } from 'react'; +import React, { useCallback, useId, useMemo, useRef } from 'react'; +import Skeleton from 'react-loading-skeleton'; import { FixedSizeList as List } from 'react-window'; +import InfiniteLoader from 'react-window-infinite-loader'; import { GrafanaTheme2 } from '@grafana/data'; import { IconButton, useStyles2 } from '@grafana/ui'; @@ -22,6 +24,9 @@ interface NestedFolderListProps { selectedFolder: FolderUID | undefined; onFolderClick: (uid: string, newOpenState: boolean) => void; onSelectionChange: (event: React.FormEvent, item: DashboardViewItem) => void; + + isItemLoaded: (itemIndex: number) => boolean; + requestLoadMore: (folderUid: string | undefined) => void; } export function NestedFolderList({ @@ -30,7 +35,10 @@ export function NestedFolderList({ selectedFolder, onFolderClick, onSelectionChange, + isItemLoaded, + requestLoadMore, }: NestedFolderListProps) { + const infiniteLoaderRef = useRef(null); const styles = useStyles2(getStyles); const virtualData = useMemo( @@ -38,18 +46,44 @@ export function NestedFolderList({ [items, foldersAreOpenable, selectedFolder, onFolderClick, onSelectionChange] ); + const handleIsItemLoaded = useCallback( + (itemIndex: number) => { + return isItemLoaded(itemIndex); + }, + [isItemLoaded] + ); + + const handleLoadMore = useCallback( + (startIndex: number, endIndex: number) => { + const { parentUID } = items[startIndex]; + requestLoadMore(parentUID); + }, + [requestLoadMore, items] + ); + return (
- {items.length > 0 ? ( - - {Row} - + {({ onItemsRendered, ref }) => ( + + {Row} + + )} + ) : (
No folders found @@ -59,7 +93,7 @@ export function NestedFolderList({ ); } -interface VirtualData extends NestedFolderListProps {} +interface VirtualData extends Omit {} interface RowProps { index: number; @@ -67,6 +101,8 @@ interface RowProps { data: VirtualData; } +const SKELETON_WIDTHS = [100, 200, 130, 160, 150]; + function Row({ index, style: virtualStyles, data }: RowProps) { const { items, foldersAreOpenable, selectedFolder, onFolderClick, onSelectionChange } = data; const { item, isOpen, level } = items[index]; @@ -102,10 +138,19 @@ function Row({ index, style: virtualStyles, data }: RowProps) { [item.uid, foldersAreOpenable, onFolderClick] ); + if (item.kind === 'ui' && item.uiKind === 'pagination-placeholder') { + return ( + + + + + ); + } + if (item.kind !== 'folder') { return process.env.NODE_ENV !== 'production' ? ( - Non-folder item {item.uid} + Non-folder {item.kind} {item.uid} ) : null; } diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.test.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.test.tsx index 0306c783ce9..09eb51cf956 100644 --- a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.test.tsx +++ b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.test.tsx @@ -78,9 +78,11 @@ describe('NestedFolderPicker', () => { it('clicking the button opens the folder picker', async () => { render(); - const button = await screen.findByRole('button', { name: 'Select folder' }); + // Open the picker and wait for children to load + const button = await screen.findByRole('button', { name: 'Select folder' }); await userEvent.click(button); + await screen.findByLabelText(folderA.item.title); // Select folder button is no longer visible expect(screen.queryByRole('button', { name: 'Select folder' })).not.toBeInTheDocument(); @@ -95,9 +97,11 @@ describe('NestedFolderPicker', () => { it('can select a folder from the picker', async () => { render(); - const button = await screen.findByRole('button', { name: 'Select folder' }); + // Open the picker and wait for children to load + const button = await screen.findByRole('button', { name: 'Select folder' }); await userEvent.click(button); + await screen.findByLabelText(folderA.item.title); await userEvent.click(screen.getByLabelText(folderA.item.title)); expect(mockOnChange).toHaveBeenCalledWith({ @@ -108,9 +112,11 @@ describe('NestedFolderPicker', () => { it('can expand and collapse a folder to show its children', async () => { render(); - const button = await screen.findByRole('button', { name: 'Select folder' }); + // Open the picker and wait for children to load + const button = await screen.findByRole('button', { name: 'Select folder' }); await userEvent.click(button); + await screen.findByLabelText(folderA.item.title); // Expand Folder A await userEvent.click(screen.getByRole('button', { name: `Expand folder ${folderA.item.title}` })); diff --git a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx index e6b26525c6d..3f9c10ddaee 100644 --- a/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx +++ b/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx @@ -9,20 +9,25 @@ import { Alert, Button, Icon, Input, LoadingBar, useStyles2 } from '@grafana/ui' import { Text } from '@grafana/ui/src/components/Text/Text'; import { Trans, t } from 'app/core/internationalization'; import { skipToken, useGetFolderQuery } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; -import { listFolders, PAGE_SIZE } from 'app/features/browse-dashboards/api/services'; -import { createFlatTree } from 'app/features/browse-dashboards/state'; +import { PAGE_SIZE } from 'app/features/browse-dashboards/api/services'; +import { + childrenByParentUIDSelector, + createFlatTree, + fetchNextChildrenPage, + rootItemsSelector, + useBrowseLoadingStatus, + useLoadNextChildrenPage, +} 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 { queryResultToViewItem } from 'app/features/search/service/utils'; import { DashboardViewItem } from 'app/features/search/types'; +import { useDispatch, useSelector } from 'app/types/store'; import { NestedFolderList } from './NestedFolderList'; import { FolderChange, FolderUID } from './types'; -async function fetchRootFolders() { - return await listFolders(undefined, undefined, 1, PAGE_SIZE); -} - interface NestedFolderPickerProps { value?: FolderUID; // TODO: think properly (and pragmatically) about how to communicate moving to general folder, @@ -30,15 +35,19 @@ interface NestedFolderPickerProps { onChange?: (folder: FolderChange) => void; } +const EXCLUDED_KINDS = ['empty-folder' as const, 'dashboard' as const]; + export function NestedFolderPicker({ value, onChange }: NestedFolderPickerProps) { const styles = useStyles2(getStyles); + const dispatch = useDispatch(); + const selectedFolder = useGetFolderQuery(value || skipToken); + + const rootStatus = useBrowseLoadingStatus(undefined); const [search, setSearch] = useState(''); const [overlayOpen, setOverlayOpen] = useState(false); const [folderOpenState, setFolderOpenState] = useState>({}); - const [childrenForUID, setChildrenForUID] = useState>({}); - const rootFoldersState = useAsync(fetchRootFolders); - const selectedFolder = useGetFolderQuery(value || skipToken); + const [error] = useState(undefined); // TODO: error not populated anymore const searchState = useAsync(async () => { if (!search) { @@ -56,72 +65,19 @@ export function NestedFolderPicker({ value, onChange }: NestedFolderPickerProps) return { ...queryResponse, items }; }, [search]); - const handleFolderClick = useCallback(async (uid: string, newOpenState: boolean) => { - setFolderOpenState((old) => ({ ...old, [uid]: newOpenState })); + const handleFolderClick = useCallback( + async (uid: string, newOpenState: boolean) => { + setFolderOpenState((old) => ({ ...old, [uid]: newOpenState })); - if (newOpenState) { - const folders = await listFolders(uid, undefined, 1, PAGE_SIZE); - setChildrenForUID((old) => ({ ...old, [uid]: folders })); - } - }, []); - - const flatTree = useMemo(() => { - 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 = {}; - - 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, - }; + if (newOpenState) { + dispatch(fetchNextChildrenPage({ parentUID: uid, pageSize: PAGE_SIZE, excludeKinds: EXCLUDED_KINDS })); } - } + }, + [dispatch] + ); - 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; - }, [search, searchState.value, rootFoldersState.loading, rootFoldersState.value, folderOpenState, childrenForUID]); + const rootCollection = useSelector(rootItemsSelector); + const childrenCollections = useSelector(childrenByParentUIDSelector); const handleSelectionChange = useCallback( (event: React.FormEvent, item: DashboardViewItem) => { @@ -151,10 +107,73 @@ export function NestedFolderPicker({ value, onChange }: NestedFolderPickerProps) }, }); - const isLoading = rootFoldersState.loading || searchState.loading; - const error = rootFoldersState.error || searchState.error; + const baseHandleLoadMore = useLoadNextChildrenPage(EXCLUDED_KINDS); + const handleLoadMore = useCallback( + (folderUID: string | undefined) => { + if (search) { + return; + } - const tree = flatTree; + baseHandleLoadMore(folderUID); + }, + [search, baseHandleLoadMore] + ); + + const flatTree = useMemo(() => { + const searchResults = search && searchState.value; + + if (searchResults) { + const searchCollection: DashboardViewItemCollection = { + isFullyLoaded: true, //searchResults.items.length === searchResults.totalRows, + lastKindHasMoreItems: false, // TODO: paginate search + lastFetchedKind: 'folder', // TODO: paginate search + lastFetchedPage: 1, // TODO: paginate search + items: searchResults.items ?? [], + }; + + return createFlatTree(undefined, searchCollection, childrenCollections, {}, 0, EXCLUDED_KINDS); + } + + let flatTree = createFlatTree(undefined, rootCollection, childrenCollections, folderOpenState, 0, EXCLUDED_KINDS); + + // Increase the level of each item to 'make way' for the fake root Dashboards item + for (const item of flatTree) { + item.level += 1; + } + + flatTree.unshift({ + isOpen: true, + level: 0, + item: { + kind: 'folder', + title: 'Dashboards', + uid: '', + }, + }); + + // If the root collection hasn't loaded yet, create loading placeholders + if (!rootCollection) { + flatTree = flatTree.concat(getPaginationPlaceholders(PAGE_SIZE, undefined, 0)); + } + + return flatTree; + }, [search, searchState.value, rootCollection, childrenCollections, folderOpenState]); + + const isItemLoaded = useCallback( + (itemIndex: number) => { + const treeItem = flatTree[itemIndex]; + if (!treeItem) { + return false; + } + const item = treeItem.item; + const result = !(item.kind === 'ui' && item.uiKind === 'pagination-placeholder'); + + return result; + }, + [flatTree] + ); + + const isLoading = rootStatus === 'pending' || searchState.loading; let label = selectedFolder.data?.title; if (value === '') { @@ -218,11 +237,13 @@ export function NestedFolderPicker({ value, onChange }: NestedFolderPickerProps) )}
)} diff --git a/public/app/features/browse-dashboards/state/actions.ts b/public/app/features/browse-dashboards/state/actions.ts index d04d401042c..88073d0e518 100644 --- a/public/app/features/browse-dashboards/state/actions.ts +++ b/public/app/features/browse-dashboards/state/actions.ts @@ -3,11 +3,15 @@ import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/ty import { createAsyncThunk } from 'app/types'; import { listDashboards, listFolders, PAGE_SIZE } from '../api/services'; +import { DashboardViewItemWithUIItems, UIDashboardViewItem } from '../types'; import { findItem } from './utils'; interface FetchNextChildrenPageArgs { parentUID: string | undefined; + + // Allow UI items to be excluded (they're always excluded) for convenience for callers + excludeKinds?: Array; pageSize: number; } @@ -87,9 +91,12 @@ export const refetchChildren = createAsyncThunk( export const fetchNextChildrenPage = createAsyncThunk( 'browseDashboards/fetchNextChildrenPage', async ( - { parentUID, pageSize }: FetchNextChildrenPageArgs, + { parentUID, excludeKinds = [], pageSize }: FetchNextChildrenPageArgs, thunkAPI ): Promise => { + // TODO: invert prop to `includeKinds`, but also support not loading folders + const loadDashboards = !excludeKinds.includes('dashboard'); + const uid = parentUID === GENERAL_FOLDER_UID ? undefined : parentUID; const state = thunkAPI.getState().browseDashboards; @@ -107,13 +114,13 @@ export const fetchNextChildrenPage = createAsyncThunk( fetchKind = 'folder'; } else if (collection.lastFetchedKind === 'dashboard' && !collection.lastKindHasMoreItems) { // There's nothing to load at all - console.warn(`FetchedChildren called for ${uid} but that collection is fully loaded`); + console.warn(`fetchNextChildrenPage called for ${uid} but that collection is fully loaded`); // return; } else if (collection.lastFetchedKind === 'folder' && collection.lastKindHasMoreItems) { // Load additional pages of folders page = collection.lastFetchedPage + 1; fetchKind = 'folder'; - } else { + } else if (loadDashboards) { // We've already checked if there's more folders to load, so if the last fetched is folder // then we fetch first page of dashboards page = collection.lastFetchedKind === 'folder' ? 1 : collection.lastFetchedPage + 1; @@ -133,7 +140,7 @@ export const fetchNextChildrenPage = createAsyncThunk( // If we've loaded all folders, load the first page of dashboards. // This ensures dashboards are loaded if a folder contains only dashboards. - if (fetchKind === 'folder' && lastPageOfKind) { + if (fetchKind === 'folder' && lastPageOfKind && loadDashboards) { fetchKind = 'dashboard'; page = 1; diff --git a/public/app/features/browse-dashboards/state/hooks.ts b/public/app/features/browse-dashboards/state/hooks.ts index d591281db5d..b0f37738bb9 100644 --- a/public/app/features/browse-dashboards/state/hooks.ts +++ b/public/app/features/browse-dashboards/state/hooks.ts @@ -5,9 +5,16 @@ import { DashboardViewItem } from 'app/features/search/types'; import { useSelector, StoreState, useDispatch } from 'app/types'; import { PAGE_SIZE } from '../api/services'; -import { BrowseDashboardsState, DashboardsTreeItem, DashboardTreeSelection } from '../types'; +import { + BrowseDashboardsState, + DashboardsTreeItem, + DashboardTreeSelection, + DashboardViewItemWithUIItems, + UIDashboardViewItem, +} from '../types'; import { fetchNextChildrenPage } from './actions'; +import { getPaginationPlaceholders } from './utils'; export const rootItemsSelector = (wholeState: StoreState) => wholeState.browseDashboards.rootItems; export const childrenByParentUIDSelector = (wholeState: StoreState) => wholeState.browseDashboards.childrenByParentUID; @@ -97,7 +104,9 @@ export function useActionSelectionState() { return useSelector((state) => selectedItemsForActionsSelector(state)); } -export function useLoadNextChildrenPage() { +export function useLoadNextChildrenPage( + excludeKinds: Array = [] +) { const dispatch = useDispatch(); const requestInFlightRef = useRef(false); @@ -109,12 +118,12 @@ export function useLoadNextChildrenPage() { requestInFlightRef.current = true; - const promise = dispatch(fetchNextChildrenPage({ parentUID: folderUID, pageSize: PAGE_SIZE })); + const promise = dispatch(fetchNextChildrenPage({ parentUID: folderUID, excludeKinds, pageSize: PAGE_SIZE })); promise.finally(() => (requestInFlightRef.current = false)); return promise; }, - [dispatch] + [dispatch, excludeKinds] ); return handleLoadMore; @@ -135,21 +144,25 @@ export function createFlatTree( childrenByUID: BrowseDashboardsState['childrenByParentUID'], openFolders: Record, level = 0, - insertEmptyFolderIndicator = true + excludeKinds: Array = [] ): DashboardsTreeItem[] { function mapItem(item: DashboardViewItem, parentUID: string | undefined, level: number): DashboardsTreeItem[] { + if (excludeKinds.includes(item.kind)) { + return []; + } + const mappedChildren = createFlatTree( item.uid, rootCollection, childrenByUID, openFolders, level + 1, - insertEmptyFolderIndicator + excludeKinds ); const isOpen = Boolean(openFolders[item.uid]); const emptyFolder = childrenByUID[item.uid]?.items.length === 0; - if (isOpen && emptyFolder && insertEmptyFolderIndicator) { + if (isOpen && emptyFolder && !excludeKinds.includes('empty-folder')) { mappedChildren.push({ isOpen: false, level: level + 1, @@ -186,18 +199,3 @@ export function createFlatTree( return children; } - -function getPaginationPlaceholders(amount: number, parentUID: string | undefined, level: number) { - return new Array(amount).fill(null).map((_, index) => { - return { - parentUID, - level, - isOpen: false, - item: { - kind: 'ui' as const, - uiKind: 'pagination-placeholder' as const, - uid: `${parentUID}-pagination-${index}`, - }, - }; - }); -} diff --git a/public/app/features/browse-dashboards/state/reducers.ts b/public/app/features/browse-dashboards/state/reducers.ts index 35bdca99ce8..0eafcb939be 100644 --- a/public/app/features/browse-dashboards/state/reducers.ts +++ b/public/app/features/browse-dashboards/state/reducers.ts @@ -40,7 +40,7 @@ export function fetchNextChildrenPageFulfilled( } const { children, page, kind, lastPageOfKind } = payload; - const { parentUID } = action.meta.arg; + const { parentUID, excludeKinds = [] } = action.meta.arg; const collection = parentUID ? state.childrenByParentUID[parentUID] : state.rootItems; const prevItems = collection?.items ?? []; @@ -50,7 +50,7 @@ export function fetchNextChildrenPageFulfilled( lastFetchedKind: kind, lastFetchedPage: page, lastKindHasMoreItems: !lastPageOfKind, - isFullyLoaded: kind === 'dashboard' && lastPageOfKind, + isFullyLoaded: !excludeKinds.includes('dashboard') ? kind === 'dashboard' && lastPageOfKind : lastPageOfKind, }; if (!parentUID) { diff --git a/public/app/features/browse-dashboards/state/utils.ts b/public/app/features/browse-dashboards/state/utils.ts index 8d074366f66..f709b72919a 100644 --- a/public/app/features/browse-dashboards/state/utils.ts +++ b/public/app/features/browse-dashboards/state/utils.ts @@ -28,3 +28,18 @@ export function findItem( return undefined; } + +export function getPaginationPlaceholders(amount: number, parentUID: string | undefined, level: number) { + return new Array(amount).fill(null).map((_, index) => { + return { + parentUID, + level, + isOpen: false, + item: { + kind: 'ui' as const, + uiKind: 'pagination-placeholder' as const, + uid: `${parentUID}-pagination-${index}`, + }, + }; + }); +} diff --git a/public/app/features/browse-dashboards/types.ts b/public/app/features/browse-dashboards/types.ts index 98ba83280bd..0333ad4498c 100644 --- a/public/app/features/browse-dashboards/types.ts +++ b/public/app/features/browse-dashboards/types.ts @@ -33,7 +33,7 @@ export interface UIDashboardViewItem { uid: string; } -type DashboardViewItemWithUIItems = DashboardViewItem | UIDashboardViewItem; +export type DashboardViewItemWithUIItems = DashboardViewItem | UIDashboardViewItem; export interface DashboardsTreeItem { item: T;