diff --git a/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx b/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx index f8eb338be18..4e93e386e83 100644 --- a/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx +++ b/public/app/features/browse-dashboards/BrowseDashboardsPage.test.tsx @@ -73,11 +73,22 @@ function render(...[ui, options]: Parameters) { }; } -jest.mock('app/features/search/service/folders', () => { +jest.mock('app/features/browse-dashboards/api/services', () => { + const orig = jest.requireActual('app/features/browse-dashboards/api/services'); + return { - getFolderChildren(parentUID?: string) { + ...orig, + listFolders(parentUID?: string) { const childrenForUID = mockTree - .filter((v) => v.item.kind !== 'ui-empty-folder' && v.item.parentUID === parentUID) + .filter((v) => v.item.kind === 'folder' && v.item.parentUID === parentUID) + .map((v) => v.item); + + return Promise.resolve(childrenForUID); + }, + + listDashboards(parentUID?: string) { + const childrenForUID = mockTree + .filter((v) => v.item.kind === 'dashboard' && v.item.parentUID === parentUID) .map((v) => v.item); return Promise.resolve(childrenForUID); diff --git a/public/app/features/browse-dashboards/api/services.ts b/public/app/features/browse-dashboards/api/services.ts new file mode 100644 index 00000000000..3f3c8ef5754 --- /dev/null +++ b/public/app/features/browse-dashboards/api/services.ts @@ -0,0 +1,56 @@ +import { getBackendSrv } from '@grafana/runtime'; +import { GENERAL_FOLDER_UID } from 'app/features/search/constants'; +import { getGrafanaSearcher, NestedFolderDTO } from 'app/features/search/service'; +import { queryResultToViewItem } from 'app/features/search/service/utils'; +import { DashboardViewItem } from 'app/features/search/types'; + +export const ROOT_PAGE_SIZE = 50; +export const PAGE_SIZE = 999; + +export async function listFolders( + parentUID?: string, + parentTitle?: string, // TODO: remove this when old UI is gone + page = 1, + pageSize = PAGE_SIZE +): Promise { + const backendSrv = getBackendSrv(); + + const folders = await backendSrv.get('/api/folders', { + parentUid: parentUID, + page, + limit: pageSize, + }); + + return folders.map((item) => ({ + kind: 'folder', + uid: item.uid, + title: item.title, + parentTitle, + parentUID, + url: `/dashboards/f/${item.uid}/`, + })); +} + +export async function listDashboards(parentUID?: string, page = 1, pageSize = PAGE_SIZE): Promise { + const searcher = getGrafanaSearcher(); + + const dashboardsResults = await searcher.search({ + kind: ['dashboard'], + query: '*', + location: parentUID || 'general', + from: page * pageSize, + limit: pageSize, + }); + + return dashboardsResults.view.map((item) => { + const viewItem = queryResultToViewItem(item, dashboardsResults.view); + + // TODO: Once we remove nestedFolders feature flag, undo this and prevent the 'general' + // parentUID from being set in searcher + if (viewItem.parentUID === GENERAL_FOLDER_UID) { + viewItem.parentUID = undefined; + } + + return viewItem; + }); +} diff --git a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx index 72e69e98e32..2d6c6c396fe 100644 --- a/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx +++ b/public/app/features/browse-dashboards/components/BrowseActions/BrowseActions.tsx @@ -9,12 +9,13 @@ import { useDispatch, useSelector } from 'app/types'; import { ShowModalReactEvent } from 'app/types/events'; import { useMoveFolderMutation } from '../../api/browseDashboardsAPI'; +import { PAGE_SIZE, ROOT_PAGE_SIZE } from '../../api/services'; import { childrenByParentUIDSelector, deleteDashboard, deleteFolder, - fetchChildren, moveDashboard, + refetchChildren, rootItemsSelector, setAllSelection, useActionSelectionState, @@ -39,18 +40,15 @@ export function BrowseActions() { const isSearching = stateManager.hasSearchFilters(); const onActionComplete = (parentsToRefresh: Set) => { - dispatch( - setAllSelection({ - isSelected: false, - }) - ); + dispatch(setAllSelection({ isSelected: false })); + if (isSearching) { // Redo search query stateManager.doSearchWithDebounce(); } else { // Refetch parents for (const parentUID of parentsToRefresh) { - dispatch(fetchChildren(parentUID)); + dispatch(refetchChildren({ parentUID, pageSize: parentUID ? PAGE_SIZE : ROOT_PAGE_SIZE })); } } }; @@ -63,7 +61,7 @@ export function BrowseActions() { for (const folderUID of selectedFolders) { await dispatch(deleteFolder(folderUID)); // find the parent folder uid and add it to parentsToRefresh - const folder = findItem(rootItems ?? [], childrenByParentUID, folderUID); + const folder = findItem(rootItems?.items ?? [], childrenByParentUID, folderUID); parentsToRefresh.add(folder?.parentUID); } @@ -72,7 +70,7 @@ export function BrowseActions() { for (const dashboardUID of selectedDashboards) { await dispatch(deleteDashboard(dashboardUID)); // find the parent folder uid and add it to parentsToRefresh - const dashboard = findItem(rootItems ?? [], childrenByParentUID, dashboardUID); + const dashboard = findItem(rootItems?.items ?? [], childrenByParentUID, dashboardUID); parentsToRefresh.add(dashboard?.parentUID); } onActionComplete(parentsToRefresh); @@ -87,7 +85,7 @@ export function BrowseActions() { for (const folderUID of selectedFolders) { await moveFolder({ folderUID, destinationUID }); // find the parent folder uid and add it to parentsToRefresh - const folder = findItem(rootItems ?? [], childrenByParentUID, folderUID); + const folder = findItem(rootItems?.items ?? [], childrenByParentUID, folderUID); parentsToRefresh.add(folder?.parentUID); } @@ -96,7 +94,7 @@ export function BrowseActions() { for (const dashboardUID of selectedDashboards) { await dispatch(moveDashboard({ dashboardUID, destinationUID })); // find the parent folder uid and add it to parentsToRefresh - const dashboard = findItem(rootItems ?? [], childrenByParentUID, dashboardUID); + const dashboard = findItem(rootItems?.items ?? [], childrenByParentUID, dashboardUID); parentsToRefresh.add(dashboard?.parentUID); } onActionComplete(parentsToRefresh); diff --git a/public/app/features/browse-dashboards/components/BrowseView.test.tsx b/public/app/features/browse-dashboards/components/BrowseView.test.tsx index 3b176523902..608bdd6490c 100644 --- a/public/app/features/browse-dashboards/components/BrowseView.test.tsx +++ b/public/app/features/browse-dashboards/components/BrowseView.test.tsx @@ -15,11 +15,22 @@ function render(...[ui, options]: Parameters) { rtlRender({ui}, options); } -jest.mock('app/features/search/service/folders', () => { +jest.mock('app/features/browse-dashboards/api/services', () => { + const orig = jest.requireActual('app/features/browse-dashboards/api/services'); + return { - getFolderChildren(parentUID?: string) { + ...orig, + listFolders(parentUID?: string) { const childrenForUID = mockTree - .filter((v) => v.item.kind !== 'ui-empty-folder' && v.item.parentUID === parentUID) + .filter((v) => v.item.kind === 'folder' && v.item.parentUID === parentUID) + .map((v) => v.item); + + return Promise.resolve(childrenForUID); + }, + + listDashboards(parentUID?: string) { + const childrenForUID = mockTree + .filter((v) => v.item.kind === 'dashboard' && v.item.parentUID === parentUID) .map((v) => v.item); return Promise.resolve(childrenForUID); @@ -61,9 +72,7 @@ describe('browse-dashboards BrowseView', () => { await clickCheckbox(folderA.item.uid); // All the visible items in it should be checked now - const directChildren = mockTree.filter( - (v) => v.item.kind !== 'ui-empty-folder' && v.item.parentUID === folderA.item.uid - ); + const directChildren = mockTree.filter((v) => v.item.kind !== 'ui' && v.item.parentUID === folderA.item.uid); for (const child of directChildren) { const childCheckbox = screen.queryByTestId(selectors.pages.BrowseDashbards.table.checkbox(child.item.uid)); @@ -83,9 +92,7 @@ describe('browse-dashboards BrowseView', () => { // should also be selected await expandFolder(folderA_folderB.item.uid); - const grandchildren = mockTree.filter( - (v) => v.item.kind !== 'ui-empty-folder' && v.item.parentUID === folderA_folderB.item.uid - ); + const grandchildren = mockTree.filter((v) => v.item.kind !== 'ui' && v.item.parentUID === folderA_folderB.item.uid); for (const child of grandchildren) { const childCheckbox = screen.queryByTestId(selectors.pages.BrowseDashbards.table.checkbox(child.item.uid)); diff --git a/public/app/features/browse-dashboards/components/BrowseView.tsx b/public/app/features/browse-dashboards/components/BrowseView.tsx index 8afd4cb9d51..56f6acad0c2 100644 --- a/public/app/features/browse-dashboards/components/BrowseView.tsx +++ b/public/app/features/browse-dashboards/components/BrowseView.tsx @@ -1,21 +1,22 @@ -import React, { useCallback, useEffect } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; import { Spinner } from '@grafana/ui'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; import { DashboardViewItem } from 'app/features/search/types'; import { useDispatch } from 'app/types'; +import { PAGE_SIZE, ROOT_PAGE_SIZE } from '../api/services'; import { useFlatTreeState, useCheckboxSelectionState, - fetchChildren, + fetchNextChildrenPage, setFolderOpenState, setItemSelectionState, useChildrenByParentUIDState, setAllSelection, useBrowseLoadingStatus, } from '../state'; -import { DashboardTreeSelection, SelectionState } from '../types'; +import { BrowseDashboardsState, DashboardTreeSelection, SelectionState } from '../types'; import { DashboardsTree } from './DashboardsTree'; @@ -38,14 +39,14 @@ export function BrowseView({ folderUID, width, height, canSelect }: BrowseViewPr dispatch(setFolderOpenState({ folderUID: clickedFolderUID, isOpen })); if (isOpen) { - dispatch(fetchChildren(clickedFolderUID)); + dispatch(fetchNextChildrenPage({ parentUID: clickedFolderUID, pageSize: PAGE_SIZE })); } }, [dispatch] ); useEffect(() => { - dispatch(fetchChildren(folderUID)); + dispatch(fetchNextChildrenPage({ parentUID: folderUID, pageSize: ROOT_PAGE_SIZE })); }, [handleFolderClick, dispatch, folderUID]); const handleItemSelectionChange = useCallback( @@ -99,6 +100,22 @@ export function BrowseView({ folderUID, width, height, canSelect }: BrowseViewPr [selectedItems, childrenByParentUID] ); + 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 handleLoadMore = useLoadNextChildrenPage(folderUID); + if (status === 'pending') { return ; } @@ -130,21 +147,23 @@ export function BrowseView({ folderUID, width, height, canSelect }: BrowseViewPr onFolderClick={handleFolderClick} onAllSelectionChange={(newState) => dispatch(setAllSelection({ isSelected: newState }))} onItemSelectionChange={handleItemSelectionChange} + isItemLoaded={isItemLoaded} + requestLoadMore={handleLoadMore} /> ); } function hasSelectedDescendants( item: DashboardViewItem, - childrenByParentUID: Record, + childrenByParentUID: BrowseDashboardsState['childrenByParentUID'], selectedItems: DashboardTreeSelection ): boolean { - const children = childrenByParentUID[item.uid]; - if (!children) { + const collection = childrenByParentUID[item.uid]; + if (!collection) { return false; } - return children.some((v) => { + return collection.items.some((v) => { const thisIsSelected = selectedItems[v.kind][v.uid]; if (thisIsSelected) { return thisIsSelected; @@ -153,3 +172,23 @@ function hasSelectedDescendants( return hasSelectedDescendants(v, childrenByParentUID, selectedItems); }); } + +function useLoadNextChildrenPage(folderUID: string | undefined) { + const dispatch = useDispatch(); + const requestInFlightRef = useRef(false); + + const handleLoadMore = useCallback(() => { + if (requestInFlightRef.current) { + return Promise.resolve(); + } + + requestInFlightRef.current = true; + + const promise = dispatch(fetchNextChildrenPage({ parentUID: folderUID, pageSize: ROOT_PAGE_SIZE })); + promise.finally(() => (requestInFlightRef.current = false)); + + return promise; + }, [dispatch, folderUID]); + + return handleLoadMore; +} diff --git a/public/app/features/browse-dashboards/components/CheckboxCell.tsx b/public/app/features/browse-dashboards/components/CheckboxCell.tsx index 0ab480efb58..d2de4f46cdc 100644 --- a/public/app/features/browse-dashboards/components/CheckboxCell.tsx +++ b/public/app/features/browse-dashboards/components/CheckboxCell.tsx @@ -12,7 +12,7 @@ export default function CheckboxCell({ }: DashboardsTreeCellProps) { const item = row.item; - if (item.kind === 'ui-empty-folder' || !isSelected) { + if (item.kind === 'ui' || !isSelected) { return null; } diff --git a/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx b/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx index b1ab12c8a70..e732344a9c2 100644 --- a/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx +++ b/public/app/features/browse-dashboards/components/DashboardsTree.test.tsx @@ -24,6 +24,8 @@ describe('browse-dashboards DashboardsTree', () => { const dashboard = wellFormedDashboard(2); const noop = () => {}; const isSelected = () => SelectionState.Unselected; + const allItemsAreLoaded = () => true; + const requestLoadMore = () => Promise.resolve(); it('renders a dashboard item', () => { render( @@ -36,6 +38,8 @@ describe('browse-dashboards DashboardsTree', () => { onFolderClick={noop} onItemSelectionChange={noop} onAllSelectionChange={noop} + isItemLoaded={allItemsAreLoaded} + requestLoadMore={requestLoadMore} /> ); expect(screen.queryByText(dashboard.item.title)).toBeInTheDocument(); @@ -55,6 +59,8 @@ describe('browse-dashboards DashboardsTree', () => { onFolderClick={noop} onItemSelectionChange={noop} onAllSelectionChange={noop} + isItemLoaded={allItemsAreLoaded} + requestLoadMore={requestLoadMore} /> ); expect( @@ -73,6 +79,8 @@ describe('browse-dashboards DashboardsTree', () => { onFolderClick={noop} onItemSelectionChange={noop} onAllSelectionChange={noop} + isItemLoaded={allItemsAreLoaded} + requestLoadMore={requestLoadMore} /> ); expect(screen.queryByText(folder.item.title)).toBeInTheDocument(); @@ -91,6 +99,8 @@ describe('browse-dashboards DashboardsTree', () => { onFolderClick={handler} onItemSelectionChange={noop} onAllSelectionChange={noop} + isItemLoaded={allItemsAreLoaded} + requestLoadMore={requestLoadMore} /> ); const folderButton = screen.getByLabelText('Collapse folder'); @@ -110,6 +120,8 @@ describe('browse-dashboards DashboardsTree', () => { onFolderClick={noop} onItemSelectionChange={noop} onAllSelectionChange={noop} + isItemLoaded={allItemsAreLoaded} + requestLoadMore={requestLoadMore} /> ); expect(screen.queryByText('No items')).toBeInTheDocument(); diff --git a/public/app/features/browse-dashboards/components/DashboardsTree.tsx b/public/app/features/browse-dashboards/components/DashboardsTree.tsx index 72def5b0889..4d8e6a29f61 100644 --- a/public/app/features/browse-dashboards/components/DashboardsTree.tsx +++ b/public/app/features/browse-dashboards/components/DashboardsTree.tsx @@ -1,7 +1,8 @@ import { css, cx } from '@emotion/css'; -import React, { useMemo } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { TableInstance, useTable } from 'react-table'; import { FixedSizeList as List } from 'react-window'; +import InfiniteLoader from 'react-window-infinite-loader'; import { GrafanaTheme2, isTruthy } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; @@ -27,11 +28,14 @@ interface DashboardsTreeProps { items: DashboardsTreeItem[]; width: number; height: number; + canSelect: boolean; isSelected: (kind: DashboardViewItem | '$all') => SelectionState; onFolderClick: (uid: string, newOpenState: boolean) => void; onAllSelectionChange: (newState: boolean) => void; onItemSelectionChange: (item: DashboardViewItem, newState: boolean) => void; - canSelect: boolean; + + isItemLoaded: (itemIndex: number) => boolean; + requestLoadMore: (startIndex: number, endIndex: number) => void; } const HEADER_HEIGHT = 35; @@ -45,10 +49,22 @@ export function DashboardsTree({ onFolderClick, onAllSelectionChange, onItemSelectionChange, + isItemLoaded, + requestLoadMore, canSelect = false, }: DashboardsTreeProps) { + const infiniteLoaderRef = useRef(null); const styles = useStyles2(getStyles); + useEffect(() => { + // If the tree changed identity, then some indexes that were previously loaded may now be unloaded, + // especially after a refetch after a move/delete. + // Clear that cache, and check if we need to trigger another load + if (infiniteLoaderRef.current) { + infiniteLoaderRef.current.resetloadMoreItemsCache(true); + } + }, [items]); + const tableColumns = useMemo(() => { const checkboxColumn: DashboardsTreeColumn = { id: 'checkbox', @@ -97,6 +113,20 @@ export function DashboardsTree({ [table, isSelected, onAllSelectionChange, onItemSelectionChange, items] ); + const handleIsItemLoaded = useCallback( + (itemIndex: number) => { + return isItemLoaded(itemIndex); + }, + [isItemLoaded] + ); + + const handleLoadMore = useCallback( + (startIndex: number, endIndex: number) => { + requestLoadMore(startIndex, endIndex); + }, + [requestLoadMore] + ); + return (
{headerGroups.map((headerGroup) => { @@ -120,15 +150,26 @@ export function DashboardsTree({ })}
- - {VirtualListRow} - + {({ onItemsRendered, ref }) => ( + + {VirtualListRow} + + )} +
); diff --git a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx index ecbedfd7401..52c3c0b8ddf 100644 --- a/public/app/features/browse-dashboards/components/FolderActionsButton.tsx +++ b/public/app/features/browse-dashboards/components/FolderActionsButton.tsx @@ -8,7 +8,8 @@ import { AccessControlAction, FolderDTO, useDispatch } from 'app/types'; import { ShowModalReactEvent } from 'app/types/events'; import { useMoveFolderMutation } from '../api/browseDashboardsAPI'; -import { deleteFolder, fetchChildren } from '../state'; +import { PAGE_SIZE, ROOT_PAGE_SIZE } from '../api/services'; +import { deleteFolder, refetchChildren } from '../state'; import { DeleteModal } from './BrowseActions/DeleteModal'; import { MoveModal } from './BrowseActions/MoveModal'; @@ -29,16 +30,21 @@ export function FolderActionsButton({ folder }: Props) { const onMove = async (destinationUID: string) => { await moveFolder({ folderUID: folder.uid, destinationUID }); - dispatch(fetchChildren(destinationUID)); + dispatch(refetchChildren({ parentUID: destinationUID, pageSize: destinationUID ? PAGE_SIZE : ROOT_PAGE_SIZE })); + if (folder.parentUid) { - dispatch(fetchChildren(folder.parentUid)); + dispatch( + refetchChildren({ parentUID: folder.parentUid, pageSize: folder.parentUid ? PAGE_SIZE : ROOT_PAGE_SIZE }) + ); } }; const onDelete = async () => { await dispatch(deleteFolder(folder.uid)); if (folder.parentUid) { - dispatch(fetchChildren(folder.parentUid)); + dispatch( + refetchChildren({ parentUID: folder.parentUid, pageSize: folder.parentUid ? PAGE_SIZE : ROOT_PAGE_SIZE }) + ); } locationService.push('/dashboards'); }; diff --git a/public/app/features/browse-dashboards/components/NameCell.tsx b/public/app/features/browse-dashboards/components/NameCell.tsx index c386bcf12eb..2238d8d430a 100644 --- a/public/app/features/browse-dashboards/components/NameCell.tsx +++ b/public/app/features/browse-dashboards/components/NameCell.tsx @@ -19,13 +19,13 @@ export function NameCell({ row: { original: data }, onFolderClick }: NameCellPro const styles = useStyles2(getStyles); const { item, level, isOpen } = data; - if (item.kind === 'ui-empty-folder') { + if (item.kind === 'ui') { return ( <> - No items + {item.uiKind === 'empty-folder' ? 'No items' : 'Loading...'} ); diff --git a/public/app/features/browse-dashboards/components/TagsCell.tsx b/public/app/features/browse-dashboards/components/TagsCell.tsx index cb82a101949..82cfc659f53 100644 --- a/public/app/features/browse-dashboards/components/TagsCell.tsx +++ b/public/app/features/browse-dashboards/components/TagsCell.tsx @@ -10,7 +10,7 @@ import { DashboardsTreeItem } from '../types'; export function TagsCell({ row: { original: data } }: CellProps) { const styles = useStyles2(getStyles); const item = data.item; - if (item.kind === 'ui-empty-folder' || !item.tags) { + if (item.kind === 'ui' || !item.tags) { return null; } diff --git a/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts b/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts index 18fcd6139d4..8f04fc73c3c 100644 --- a/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts +++ b/public/app/features/browse-dashboards/fixtures/dashboardsTreeItem.fixture.ts @@ -12,7 +12,8 @@ export function wellFormedEmptyFolder( return { item: { - kind: 'ui-empty-folder', + kind: 'ui', + uiKind: 'empty-folder', uid: random.guid(), }, level: 0, diff --git a/public/app/features/browse-dashboards/fixtures/state.fixtures.ts b/public/app/features/browse-dashboards/fixtures/state.fixtures.ts new file mode 100644 index 00000000000..667d69416c4 --- /dev/null +++ b/public/app/features/browse-dashboards/fixtures/state.fixtures.ts @@ -0,0 +1,18 @@ +import { DashboardViewItem } from 'app/features/search/types'; + +import { DashboardViewItemCollection } from '../types'; + +export function fullyLoadedViewItemCollection(items: DashboardViewItem[]): DashboardViewItemCollection { + const lastKind = items.at(-1)?.kind ?? 'folder'; + if (!lastKind || lastKind === 'panel') { + throw new Error('invalid items'); + } + + return { + items, + lastFetchedKind: lastKind, + lastFetchedPage: 1, + lastKindHasMoreItems: false, + isFullyLoaded: true, + }; +} diff --git a/public/app/features/browse-dashboards/state/actions.ts b/public/app/features/browse-dashboards/state/actions.ts index 3f91914bd56..5db7c2d0614 100644 --- a/public/app/features/browse-dashboards/state/actions.ts +++ b/public/app/features/browse-dashboards/state/actions.ts @@ -1,15 +1,134 @@ import { getBackendSrv } from '@grafana/runtime'; import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; import { GENERAL_FOLDER_UID } from 'app/features/search/constants'; -import { getFolderChildren } from 'app/features/search/service/folders'; +import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types'; import { createAsyncThunk, DashboardDTO } from 'app/types'; -export const fetchChildren = createAsyncThunk( - 'browseDashboards/fetchChildren', - async (parentUID: string | undefined) => { - // Need to handle the case where the parentUID is the root +import { listDashboards, listFolders } from '../api/services'; + +interface FetchNextChildrenPageArgs { + parentUID: string | undefined; + pageSize: number; +} + +interface FetchNextChildrenPageResult { + children: DashboardViewItem[]; + kind: 'folder' | 'dashboard'; + page: number; + lastPageOfKind: boolean; +} + +interface RefetchChildrenArgs { + parentUID: string | undefined; + pageSize: number; +} + +interface RefetchChildrenResult { + children: DashboardViewItem[]; + kind: 'folder' | 'dashboard'; + page: number; + lastPageOfKind: boolean; +} + +export const refetchChildren = createAsyncThunk( + 'browseDashboards/refetchChildren', + async ({ parentUID, pageSize }: RefetchChildrenArgs): Promise => { const uid = parentUID === GENERAL_FOLDER_UID ? undefined : parentUID; - return await getFolderChildren(uid, undefined, true); + + // At the moment this will just clear out all loaded children and refetch the first page. + // If user has scrolled beyond the first page, then InfiniteLoader will probably trigger + // an additional page load (via fetchNextChildrenPage) + + let page = 1; + let fetchKind: DashboardViewItemKind | undefined = 'folder'; + + let children = await listFolders(uid, undefined, page, pageSize); + let lastPageOfKind = children.length < pageSize; + + // 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) { + fetchKind = 'dashboard'; + page = 1; + + const childDashboards = await listDashboards(uid, page, pageSize); + lastPageOfKind = childDashboards.length < pageSize; + children = children.concat(childDashboards); + } + + return { + children, + lastPageOfKind: lastPageOfKind, + page, + kind: fetchKind, + }; + } +); + +export const fetchNextChildrenPage = createAsyncThunk( + 'browseDashboards/fetchNextChildrenPage', + async ( + { parentUID, pageSize }: FetchNextChildrenPageArgs, + thunkAPI + ): Promise => { + const uid = parentUID === GENERAL_FOLDER_UID ? undefined : parentUID; + + const state = thunkAPI.getState().browseDashboards; + const collection = uid ? state.childrenByParentUID[uid] : state.rootItems; + + let page = 1; + let fetchKind: DashboardViewItemKind | undefined = undefined; + + // Folder children do not come from a single API, so we need to do a bunch of logic to determine + // which page of which kind to load + + if (!collection) { + // No previous data in store, fetching first page of folders + page = 1; + 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`); + // return; + } else if (collection.lastFetchedKind === 'folder' && collection.lastKindHasMoreItems) { + // Load additional pages of folders + page = collection.lastFetchedPage + 1; + fetchKind = 'folder'; + } else { + // 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; + fetchKind = 'dashboard'; + } + + if (!fetchKind) { + return; + } + + let children = + fetchKind === 'folder' + ? await listFolders(uid, undefined, page, pageSize) + : await listDashboards(uid, page, pageSize); + + let lastPageOfKind = children.length < pageSize; + + // 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) { + fetchKind = 'dashboard'; + page = 1; + + const childDashboards = await listDashboards(uid, page, pageSize); + lastPageOfKind = childDashboards.length < pageSize; + children = children.concat(childDashboards); + } + + return { + children, + lastPageOfKind: lastPageOfKind, + page, + kind: fetchKind, + }; } ); diff --git a/public/app/features/browse-dashboards/state/hooks.test.ts b/public/app/features/browse-dashboards/state/hooks.test.ts index 87d302558d9..373a6c0a0f7 100644 --- a/public/app/features/browse-dashboards/state/hooks.test.ts +++ b/public/app/features/browse-dashboards/state/hooks.test.ts @@ -1,6 +1,7 @@ import { configureStore } from 'app/store/configureStore'; import { useSelector } from 'app/types'; +import { fullyLoadedViewItemCollection } from '../fixtures/state.fixtures'; import { BrowseDashboardsState } from '../types'; import { useBrowseLoadingStatus } from './hooks'; @@ -57,7 +58,7 @@ describe('browse-dashboards state hooks', () => { }); it('returns fulfilled when root view is finished loading', () => { - mockState(createInitialState({ rootItems: [] })); + mockState(createInitialState({ rootItems: fullyLoadedViewItemCollection([]) })); const status = useBrowseLoadingStatus(undefined); expect(status).toEqual('fulfilled'); @@ -67,7 +68,7 @@ describe('browse-dashboards state hooks', () => { mockState( createInitialState({ childrenByParentUID: { - [folderUID]: [], + [folderUID]: fullyLoadedViewItemCollection([]), }, }) ); diff --git a/public/app/features/browse-dashboards/state/hooks.ts b/public/app/features/browse-dashboards/state/hooks.ts index 66e74ccdaab..36011a80689 100644 --- a/public/app/features/browse-dashboards/state/hooks.ts +++ b/public/app/features/browse-dashboards/state/hooks.ts @@ -3,7 +3,8 @@ import { createSelector } from 'reselect'; import { DashboardViewItem } from 'app/features/search/types'; import { useSelector, StoreState } from 'app/types'; -import { DashboardsTreeItem, DashboardTreeSelection } from '../types'; +import { ROOT_PAGE_SIZE } from '../api/services'; +import { BrowseDashboardsState, DashboardsTreeItem, DashboardTreeSelection } from '../types'; export const rootItemsSelector = (wholeState: StoreState) => wholeState.browseDashboards.rootItems; export const childrenByParentUIDSelector = (wholeState: StoreState) => wholeState.browseDashboards.childrenByParentUID; @@ -16,7 +17,7 @@ const flatTreeSelector = createSelector( openFoldersSelector, (wholeState: StoreState, rootFolderUID: string | undefined) => rootFolderUID, (rootItems, childrenByParentUID, openFolders, folderUID) => { - return createFlatTree(folderUID, rootItems ?? [], childrenByParentUID, openFolders); + return createFlatTree(folderUID, rootItems, childrenByParentUID, openFolders); } ); @@ -45,9 +46,9 @@ const selectedItemsForActionsSelector = createSelector( const isSelected = selectedItems.folder[folderUID]; if (isSelected) { // Unselect any children in the output - const children = childrenByParentUID[folderUID]; - if (children) { - for (const child of children) { + const collection = childrenByParentUID[folderUID]; + if (collection) { + for (const child of collection.items) { if (child.kind === 'dashboard') { result.dashboard[child.uid] = false; } @@ -104,21 +105,21 @@ export function useActionSelectionState() { */ function createFlatTree( folderUID: string | undefined, - rootItems: DashboardViewItem[], - childrenByUID: Record, + rootCollection: BrowseDashboardsState['rootItems'], + childrenByUID: BrowseDashboardsState['childrenByParentUID'], openFolders: Record, level = 0 ): DashboardsTreeItem[] { function mapItem(item: DashboardViewItem, parentUID: string | undefined, level: number): DashboardsTreeItem[] { - const mappedChildren = createFlatTree(item.uid, rootItems, childrenByUID, openFolders, level + 1); + const mappedChildren = createFlatTree(item.uid, rootCollection, childrenByUID, openFolders, level + 1); const isOpen = Boolean(openFolders[item.uid]); - const emptyFolder = childrenByUID[item.uid]?.length === 0; + const emptyFolder = childrenByUID[item.uid]?.items.length === 0; if (isOpen && emptyFolder) { mappedChildren.push({ isOpen: false, level: level + 1, - item: { kind: 'ui-empty-folder', uid: item.uid + '-empty-folder' }, + item: { kind: 'ui', uiKind: 'empty-folder', uid: item.uid + 'empty-folder' }, }); } @@ -134,9 +135,32 @@ function createFlatTree( const isOpen = (folderUID && openFolders[folderUID]) || level === 0; - const items = folderUID - ? (isOpen && childrenByUID[folderUID]) || [] // keep seperate lines - : rootItems; + const collection = folderUID ? childrenByUID[folderUID] : rootCollection; - return items.flatMap((item) => mapItem(item, folderUID, level)); + const items = folderUID + ? isOpen && collection?.items // keep seperate lines + : collection?.items; + + let children = (items || []).flatMap((item) => mapItem(item, folderUID, level)); + + if (level === 0 && collection && !collection.isFullyLoaded) { + children = children.concat(getPaginationPlaceholders(ROOT_PAGE_SIZE, folderUID, level)); + } + + 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.test.ts b/public/app/features/browse-dashboards/state/reducers.test.ts index 6d3334347a1..060a8843d6d 100644 --- a/public/app/features/browse-dashboards/state/reducers.test.ts +++ b/public/app/features/browse-dashboards/state/reducers.test.ts @@ -1,16 +1,12 @@ import { wellFormedDashboard, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture'; +import { fullyLoadedViewItemCollection } from '../fixtures/state.fixtures'; import { BrowseDashboardsState } from '../types'; -import { - extraReducerFetchChildrenFulfilled, - setAllSelection, - setFolderOpenState, - setItemSelectionState, -} from './reducers'; +import { fetchNextChildrenPageFulfilled, setAllSelection, setFolderOpenState, setItemSelectionState } from './reducers'; function createInitialState(): BrowseDashboardsState { return { - rootItems: [], + rootItems: undefined, childrenByParentUID: {}, openFolders: {}, selectedItems: { @@ -23,29 +19,81 @@ function createInitialState(): BrowseDashboardsState { } describe('browse-dashboards reducers', () => { - describe('extraReducerFetchChildrenFulfilled', () => { - it('updates state correctly for root items', () => { + describe('fetchNextChildrenPageFulfilled', () => { + it('loads first page of root items', () => { + const pageSize = 50; const state = createInitialState(); - const children = [ - wellFormedFolder(1).item, - wellFormedFolder(2).item, - wellFormedFolder(3).item, - wellFormedDashboard(4).item, - ]; + const children = new Array(pageSize).fill(0).map((_, index) => wellFormedFolder(index + 1).item); const action = { - payload: children, + payload: { + children, + kind: 'folder' as const, + page: 1, + lastPageOfKind: false, + }, type: 'action-type', meta: { - arg: undefined, + arg: { + parentUID: undefined, + pageSize: pageSize, + }, requestId: 'abc-123', requestStatus: 'fulfilled' as const, }, }; - extraReducerFetchChildrenFulfilled(state, action); + fetchNextChildrenPageFulfilled(state, action); - expect(state.rootItems).toEqual(children); + expect(state.rootItems).toEqual({ + items: children, + lastFetchedKind: 'folder', + lastFetchedPage: 1, + lastKindHasMoreItems: true, + isFullyLoaded: false, + }); + }); + + it('loads last page of root items', () => { + const pageSize = 50; + const state = createInitialState(); + const firstPageChildren = new Array(20).fill(0).map((_, index) => wellFormedFolder(index + 1).item); + state.rootItems = { + items: firstPageChildren, + lastFetchedKind: 'folder', + lastFetchedPage: 1, + lastKindHasMoreItems: false, + isFullyLoaded: false, + }; + + const lastPageChildren = new Array(20).fill(0).map((_, index) => wellFormedDashboard(index + 51).item); + const action = { + payload: { + children: lastPageChildren, + kind: 'dashboard' as const, + page: 1, + lastPageOfKind: true, + }, + type: 'action-type', + meta: { + arg: { + parentUID: undefined, + pageSize: pageSize, + }, + requestId: 'abc-123', + requestStatus: 'fulfilled' as const, + }, + }; + + fetchNextChildrenPageFulfilled(state, action); + + expect(state.rootItems).toEqual({ + items: [...firstPageChildren, ...lastPageChildren], + lastFetchedKind: 'dashboard', + lastFetchedPage: 1, + lastKindHasMoreItems: false, + isFullyLoaded: true, + }); }); it('updates state correctly for items in folders', () => { @@ -54,18 +102,34 @@ describe('browse-dashboards reducers', () => { const children = [wellFormedFolder(2).item, wellFormedDashboard(3).item]; const action = { - payload: children, + payload: { + children, + kind: 'dashboard' as const, + page: 1, + lastPageOfKind: true, + }, type: 'action-type', meta: { - arg: parentFolder.uid, + arg: { + parentUID: parentFolder.uid, + pageSize: 999, + }, requestId: 'abc-123', requestStatus: 'fulfilled' as const, }, }; - extraReducerFetchChildrenFulfilled(state, action); + fetchNextChildrenPageFulfilled(state, action); - expect(state.childrenByParentUID).toEqual({ [parentFolder.uid]: children }); + expect(state.childrenByParentUID).toEqual({ + [parentFolder.uid]: { + items: children, + lastFetchedKind: 'dashboard', + lastFetchedPage: 1, + lastKindHasMoreItems: false, + isFullyLoaded: true, + }, + }); }); it('marks children as selected if the parent is selected', () => { @@ -78,16 +142,24 @@ describe('browse-dashboards reducers', () => { const childDashboard = wellFormedDashboard(3).item; const action = { - payload: [childFolder, childDashboard], + payload: { + children: [childFolder, childDashboard], + kind: 'dashboard' as const, + page: 1, + lastPageOfKind: true, + }, type: 'action-type', meta: { - arg: parentFolder.uid, + arg: { + parentUID: parentFolder.uid, + pageSize: 999, + }, requestId: 'abc-123', requestStatus: 'fulfilled' as const, }, }; - extraReducerFetchChildrenFulfilled(state, action); + fetchNextChildrenPageFulfilled(state, action); expect(state.selectedItems).toEqual({ $all: false, @@ -118,7 +190,7 @@ describe('browse-dashboards reducers', () => { const folder = wellFormedFolder(1).item; const dashboard = wellFormedDashboard(2).item; const state = createInitialState(); - state.rootItems = [folder, dashboard]; + state.rootItems = fullyLoadedViewItemCollection([folder, dashboard]); setItemSelectionState(state, { type: 'setItemSelectionState', payload: { item: dashboard, isSelected: true } }); @@ -141,9 +213,9 @@ describe('browse-dashboards reducers', () => { const childFolder = wellFormedFolder(4, {}, { parentUID: parentFolder.uid }).item; const grandchildDashboard = wellFormedDashboard(5, {}, { parentUID: childFolder.uid }).item; - state.rootItems = [parentFolder, rootDashboard]; - state.childrenByParentUID[parentFolder.uid] = [childDashboard, childFolder]; - state.childrenByParentUID[childFolder.uid] = [grandchildDashboard]; + state.rootItems = fullyLoadedViewItemCollection([parentFolder, rootDashboard]); + state.childrenByParentUID[parentFolder.uid] = fullyLoadedViewItemCollection([childDashboard, childFolder]); + state.childrenByParentUID[childFolder.uid] = fullyLoadedViewItemCollection([grandchildDashboard]); setItemSelectionState(state, { type: 'setItemSelectionState', @@ -172,9 +244,9 @@ describe('browse-dashboards reducers', () => { const childFolder = wellFormedFolder(3, {}, { parentUID: parentFolder.uid }).item; const grandchildDashboard = wellFormedDashboard(4, {}, { parentUID: childFolder.uid }).item; - state.rootItems = [parentFolder]; - state.childrenByParentUID[parentFolder.uid] = [childDashboard, childFolder]; - state.childrenByParentUID[childFolder.uid] = [grandchildDashboard]; + state.rootItems = fullyLoadedViewItemCollection([parentFolder]); + state.childrenByParentUID[parentFolder.uid] = fullyLoadedViewItemCollection([childDashboard, childFolder]); + state.childrenByParentUID[childFolder.uid] = fullyLoadedViewItemCollection([grandchildDashboard]); state.selectedItems.dashboard[childDashboard.uid] = true; state.selectedItems.dashboard[grandchildDashboard.uid] = true; @@ -209,8 +281,8 @@ describe('browse-dashboards reducers', () => { const childDashboardA = wellFormedDashboard(3, {}, { parentUID: rootFolder.uid }).item; const childDashboardB = wellFormedDashboard(4, {}, { parentUID: rootFolder.uid }).item; - state.rootItems = [rootFolder, rootDashboard]; - state.childrenByParentUID[rootFolder.uid] = [childDashboardA, childDashboardB]; + state.rootItems = fullyLoadedViewItemCollection([rootFolder, rootDashboard]); + state.childrenByParentUID[rootFolder.uid] = fullyLoadedViewItemCollection([childDashboardA, childDashboardB]); state.selectedItems.dashboard = { [rootDashboard.uid]: true, [childDashboardA.uid]: true }; @@ -231,8 +303,8 @@ describe('browse-dashboards reducers', () => { const childDashboardA = wellFormedDashboard(3, {}, { parentUID: rootFolder.uid }).item; const childDashboardB = wellFormedDashboard(4, {}, { parentUID: rootFolder.uid }).item; - state.rootItems = [rootFolder, rootDashboard]; - state.childrenByParentUID[rootFolder.uid] = [childDashboardA, childDashboardB]; + state.rootItems = fullyLoadedViewItemCollection([rootFolder, rootDashboard]); + state.childrenByParentUID[rootFolder.uid] = fullyLoadedViewItemCollection([childDashboardA, childDashboardB]); state.selectedItems.dashboard = { [rootDashboard.uid]: true, @@ -262,9 +334,9 @@ describe('browse-dashboards reducers', () => { const childFolder = wellFormedFolder(seed++, {}, { parentUID: topLevelFolder.uid }).item; const grandchildDashboard = wellFormedDashboard(seed++, {}, { parentUID: childFolder.uid }).item; - state.rootItems = [topLevelFolder, topLevelDashboard]; - state.childrenByParentUID[topLevelFolder.uid] = [childDashboard, childFolder]; - state.childrenByParentUID[childFolder.uid] = [grandchildDashboard]; + state.rootItems = fullyLoadedViewItemCollection([topLevelFolder, topLevelDashboard]); + state.childrenByParentUID[topLevelFolder.uid] = fullyLoadedViewItemCollection([childDashboard, childFolder]); + state.childrenByParentUID[childFolder.uid] = fullyLoadedViewItemCollection([grandchildDashboard]); state.selectedItems.folder[childFolder.uid] = false; state.selectedItems.dashboard[grandchildDashboard.uid] = true; @@ -296,9 +368,9 @@ describe('browse-dashboards reducers', () => { const childFolder = wellFormedFolder(seed++, {}, { parentUID: topLevelFolder.uid }).item; const grandchildDashboard = wellFormedDashboard(seed++, {}, { parentUID: childFolder.uid }).item; - state.rootItems = [topLevelFolder, topLevelDashboard]; - state.childrenByParentUID[topLevelFolder.uid] = [childDashboard, childFolder]; - state.childrenByParentUID[childFolder.uid] = [grandchildDashboard]; + state.rootItems = fullyLoadedViewItemCollection([topLevelFolder, topLevelDashboard]); + state.childrenByParentUID[topLevelFolder.uid] = fullyLoadedViewItemCollection([childDashboard, childFolder]); + state.childrenByParentUID[childFolder.uid] = fullyLoadedViewItemCollection([grandchildDashboard]); state.selectedItems.folder[childFolder.uid] = false; state.selectedItems.dashboard[grandchildDashboard.uid] = true; diff --git a/public/app/features/browse-dashboards/state/reducers.ts b/public/app/features/browse-dashboards/state/reducers.ts index f9dde18434e..ef9fa2d0be1 100644 --- a/public/app/features/browse-dashboards/state/reducers.ts +++ b/public/app/features/browse-dashboards/state/reducers.ts @@ -1,25 +1,64 @@ import { PayloadAction } from '@reduxjs/toolkit'; -import { GENERAL_FOLDER_UID } from 'app/features/search/constants'; import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types'; import { BrowseDashboardsState } from '../types'; -import { fetchChildren } from './actions'; +import { fetchNextChildrenPage, refetchChildren } from './actions'; import { findItem } from './utils'; -type FetchChildrenAction = ReturnType; +type FetchNextChildrenPageFulfilledAction = ReturnType; +type RefetchChildrenFulfilledAction = ReturnType; -export function extraReducerFetchChildrenFulfilled(state: BrowseDashboardsState, action: FetchChildrenAction) { - const parentUID = action.meta.arg; - const children = action.payload; +export function refetchChildrenFulfilled(state: BrowseDashboardsState, action: RefetchChildrenFulfilledAction) { + const { children, page, kind, lastPageOfKind } = action.payload; + const { parentUID } = action.meta.arg; - if (!parentUID || parentUID === GENERAL_FOLDER_UID) { - state.rootItems = children; + const newCollection = { + items: children, + lastFetchedKind: kind, + lastFetchedPage: page, + lastKindHasMoreItems: !lastPageOfKind, + isFullyLoaded: kind === 'dashboard' && lastPageOfKind, + }; + + if (parentUID) { + state.childrenByParentUID[parentUID] = newCollection; + } else { + state.rootItems = newCollection; + } +} + +export function fetchNextChildrenPageFulfilled( + state: BrowseDashboardsState, + action: FetchNextChildrenPageFulfilledAction +) { + const payload = action.payload; + if (!payload) { + // If not additional pages to load, the action returns undefined return; } - state.childrenByParentUID[parentUID] = children; + const { children, page, kind, lastPageOfKind } = payload; + const { parentUID } = action.meta.arg; + + const collection = parentUID ? state.childrenByParentUID[parentUID] : state.rootItems; + const prevItems = collection?.items ?? []; + + const newCollection = { + items: prevItems.concat(children), + lastFetchedKind: kind, + lastFetchedPage: page, + lastKindHasMoreItems: !lastPageOfKind, + isFullyLoaded: kind === 'dashboard' && lastPageOfKind, + }; + + if (!parentUID) { + state.rootItems = newCollection; + return; + } + + state.childrenByParentUID[parentUID] = newCollection; // If the parent of the items we've loaded are selected, we must select all these items also const parentIsSelected = state.selectedItems.folder[parentUID]; @@ -56,8 +95,8 @@ export function setItemSelectionState( return; } - let children = state.childrenByParentUID[uid] ?? []; - for (const child of children) { + let collection = state.childrenByParentUID[uid]; + for (const child of collection?.items ?? []) { markChildren(child.kind, child.uid); } } @@ -70,7 +109,7 @@ export function setItemSelectionState( let nextParentUID = item.parentUID; while (nextParentUID) { - const parent = findItem(state.rootItems ?? [], state.childrenByParentUID, nextParentUID); + const parent = findItem(state.rootItems?.items ?? [], state.childrenByParentUID, nextParentUID); // This case should not happen, but a find can theortically return undefined, and it // helps limit infinite loops @@ -87,7 +126,7 @@ export function setItemSelectionState( } // Check to see if we should mark the header checkbox selected if all root items are selected - state.selectedItems.$all = state.rootItems?.every((v) => state.selectedItems[v.kind][v.uid]) ?? false; + state.selectedItems.$all = state.rootItems?.items?.every((v) => state.selectedItems[v.kind][v.uid]) ?? false; } export function setAllSelection(state: BrowseDashboardsState, action: PayloadAction<{ isSelected: boolean }>) { @@ -103,14 +142,14 @@ export function setAllSelection(state: BrowseDashboardsState, action: PayloadAct if (isSelected) { for (const folderUID in state.childrenByParentUID) { - const children = state.childrenByParentUID[folderUID] ?? []; + const collection = state.childrenByParentUID[folderUID]; - for (const child of children) { + for (const child of collection?.items ?? []) { state.selectedItems[child.kind][child.uid] = isSelected; } } - for (const child of state.rootItems ?? []) { + for (const child of state.rootItems?.items ?? []) { state.selectedItems[child.kind][child.uid] = isSelected; } } else { diff --git a/public/app/features/browse-dashboards/state/slice.ts b/public/app/features/browse-dashboards/state/slice.ts index 3f8f0db9862..55254eb78d1 100644 --- a/public/app/features/browse-dashboards/state/slice.ts +++ b/public/app/features/browse-dashboards/state/slice.ts @@ -2,10 +2,10 @@ import { createSlice } from '@reduxjs/toolkit'; import { BrowseDashboardsState } from '../types'; -import { fetchChildren } from './actions'; +import { fetchNextChildrenPage, refetchChildren } from './actions'; import * as allReducers from './reducers'; -const { extraReducerFetchChildrenFulfilled, ...baseReducers } = allReducers; +const { fetchNextChildrenPageFulfilled, refetchChildrenFulfilled, ...baseReducers } = allReducers; const initialState: BrowseDashboardsState = { rootItems: undefined, @@ -25,7 +25,8 @@ const browseDashboardsSlice = createSlice({ reducers: baseReducers, extraReducers: (builder) => { - builder.addCase(fetchChildren.fulfilled, extraReducerFetchChildrenFulfilled); + builder.addCase(fetchNextChildrenPage.fulfilled, fetchNextChildrenPageFulfilled); + builder.addCase(refetchChildren.fulfilled, refetchChildrenFulfilled); }, }); diff --git a/public/app/features/browse-dashboards/state/utils.ts b/public/app/features/browse-dashboards/state/utils.ts index 04b2bfb5cd4..8d074366f66 100644 --- a/public/app/features/browse-dashboards/state/utils.ts +++ b/public/app/features/browse-dashboards/state/utils.ts @@ -1,8 +1,10 @@ import { DashboardViewItem } from 'app/features/search/types'; +import { BrowseDashboardsState } from '../types'; + export function findItem( rootItems: DashboardViewItem[], - childrenByUID: Record, + childrenByUID: BrowseDashboardsState['childrenByParentUID'], uid: string ): DashboardViewItem | undefined { for (const item of rootItems) { @@ -17,7 +19,7 @@ export function findItem( continue; } - for (const child of children) { + for (const child of children.items) { if (child.uid === uid) { return child; } diff --git a/public/app/features/browse-dashboards/types.ts b/public/app/features/browse-dashboards/types.ts index f9681e83872..1b43f1bf07d 100644 --- a/public/app/features/browse-dashboards/types.ts +++ b/public/app/features/browse-dashboards/types.ts @@ -1,14 +1,26 @@ import { CellProps, Column, HeaderProps } from 'react-table'; -import { DashboardViewItem as DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types'; +import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types'; export type DashboardTreeSelection = Record> & { $all: boolean; }; +/** + * Stores children at a particular location in the tree, and information + * required for pagination. + */ +export type DashboardViewItemCollection = { + items: DashboardViewItem[]; + lastFetchedKind: 'folder' | 'dashboard'; + lastFetchedPage: number; + lastKindHasMoreItems: boolean; + isFullyLoaded: boolean; +}; + export interface BrowseDashboardsState { - rootItems: DashboardViewItem[] | undefined; - childrenByParentUID: Record; + rootItems: DashboardViewItemCollection | undefined; + childrenByParentUID: Record; selectedItems: DashboardTreeSelection; // Only folders can ever be open or closed, so no need to seperate this by kind @@ -16,7 +28,8 @@ export interface BrowseDashboardsState { } export interface UIDashboardViewItem { - kind: 'ui-empty-folder'; + kind: 'ui'; + uiKind: 'empty-folder' | 'pagination-placeholder'; uid: string; } diff --git a/public/app/features/search/service/folders.ts b/public/app/features/search/service/folders.ts index 7a096bce78a..90ea27fb616 100644 --- a/public/app/features/search/service/folders.ts +++ b/public/app/features/search/service/folders.ts @@ -1,10 +1,9 @@ -import { getBackendSrv } from '@grafana/runtime'; import config from 'app/core/config'; +import { listFolders } from 'app/features/browse-dashboards/api/services'; import { DashboardViewItem } from '../types'; import { getGrafanaSearcher } from './searcher'; -import { NestedFolderDTO } from './types'; import { queryResultToViewItem } from './utils'; export async function getFolderChildren( @@ -20,7 +19,7 @@ export async function getFolderChildren( if (!dashboardsAtRoot && !parentUid) { // We don't show dashboards at root in folder view yet - they're shown under a dummy 'general' // folder that FolderView adds in - const folders = await getChildFolders(); + const folders = await listFolders(); return folders; } @@ -36,22 +35,7 @@ export async function getFolderChildren( return queryResultToViewItem(item, dashboardsResults.view); }); - const folders = await getChildFolders(parentUid, parentTitle); + const folders = await listFolders(parentUid, parentTitle); return [...folders, ...dashboardItems]; } - -async function getChildFolders(parentUid?: string, parentTitle?: string): Promise { - const backendSrv = getBackendSrv(); - - const folders = await backendSrv.get('/api/folders', { parentUid }); - - return folders.map((item) => ({ - kind: 'folder', - uid: item.uid, - title: item.title, - parentTitle, - parentUID: parentUid, - url: `/dashboards/f/${item.uid}/`, - })); -} diff --git a/public/app/features/search/service/sql.ts b/public/app/features/search/service/sql.ts index e7c8b1b530c..636e2882b34 100644 --- a/public/app/features/search/service/sql.ts +++ b/public/app/features/search/service/sql.ts @@ -70,11 +70,25 @@ export class SQLSearcher implements GrafanaSearcher { throw new Error('facets not supported!'); } + if (query.from !== undefined) { + if (!query.limit) { + throw new Error('Must specify non-zero limit parameter when using from'); + } + + if ((query.from / query.limit) % 1 !== 0) { + throw new Error('From parameter must be a multiple of limit'); + } + } + + const limit = query.limit ?? (query.from !== undefined ? 1 : DEFAULT_MAX_VALUES); + const page = query.from !== undefined ? query.from / limit : undefined; + const q = await this.composeQuery( { - limit: query.limit ?? DEFAULT_MAX_VALUES, // default 1k max values + limit: limit, tag: query.tags, sort: query.sort, + page, }, query ); diff --git a/public/app/features/search/types.ts b/public/app/features/search/types.ts index f6851f00df8..21cf30fd456 100644 --- a/public/app/features/search/types.ts +++ b/public/app/features/search/types.ts @@ -65,10 +65,13 @@ export interface DashboardViewItem { icon?: string; parentUID?: string; + /** @deprecated Not used in new Browse UI */ parentTitle?: string; + /** @deprecated Not used in new Browse UI */ parentKind?: string; // Used only for psuedo-folders, such as Starred or Recent + /** @deprecated Not used in new Browse UI */ itemsUIDs?: string[]; // For enterprise sort options