NestedFolders: Improve performance of Browse Dashboards by loading one page at a time (#68617)

* wip for pagination

* kind of doing pagination, but only for the root folder

* wip

* wip

* refactor paginated fetchChildren

* make sure dashboards are loaded if a folder contains only dashboards

* rename lastKindHasMoreItems

* load additional root pages

* undo accidental commit

* return promise from loadMoreChildren, and prevent loading additional page while a request is already in flight

* rename browseDashboards/fetchChildren action so it's more clear

* starting to revalidate children after an action

* unset general uid

* comment

* clean up

* fix tests omg

* cleanup

* fix items not loading after invalidating loaded cache

* comment

* fix lints
This commit is contained in:
Josh Hunt 2023-06-05 11:21:45 +01:00 committed by GitHub
parent b65ce6738f
commit 394ff9fcde
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 621 additions and 160 deletions

View File

@ -73,11 +73,22 @@ function render(...[ui, options]: Parameters<typeof rtlRender>) {
}; };
} }
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 { return {
getFolderChildren(parentUID?: string) { ...orig,
listFolders(parentUID?: string) {
const childrenForUID = mockTree 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); .map((v) => v.item);
return Promise.resolve(childrenForUID); return Promise.resolve(childrenForUID);

View File

@ -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<DashboardViewItem[]> {
const backendSrv = getBackendSrv();
const folders = await backendSrv.get<NestedFolderDTO[]>('/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<DashboardViewItem[]> {
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;
});
}

View File

@ -9,12 +9,13 @@ import { useDispatch, useSelector } from 'app/types';
import { ShowModalReactEvent } from 'app/types/events'; import { ShowModalReactEvent } from 'app/types/events';
import { useMoveFolderMutation } from '../../api/browseDashboardsAPI'; import { useMoveFolderMutation } from '../../api/browseDashboardsAPI';
import { PAGE_SIZE, ROOT_PAGE_SIZE } from '../../api/services';
import { import {
childrenByParentUIDSelector, childrenByParentUIDSelector,
deleteDashboard, deleteDashboard,
deleteFolder, deleteFolder,
fetchChildren,
moveDashboard, moveDashboard,
refetchChildren,
rootItemsSelector, rootItemsSelector,
setAllSelection, setAllSelection,
useActionSelectionState, useActionSelectionState,
@ -39,18 +40,15 @@ export function BrowseActions() {
const isSearching = stateManager.hasSearchFilters(); const isSearching = stateManager.hasSearchFilters();
const onActionComplete = (parentsToRefresh: Set<string | undefined>) => { const onActionComplete = (parentsToRefresh: Set<string | undefined>) => {
dispatch( dispatch(setAllSelection({ isSelected: false }));
setAllSelection({
isSelected: false,
})
);
if (isSearching) { if (isSearching) {
// Redo search query // Redo search query
stateManager.doSearchWithDebounce(); stateManager.doSearchWithDebounce();
} else { } else {
// Refetch parents // Refetch parents
for (const parentUID of parentsToRefresh) { 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) { for (const folderUID of selectedFolders) {
await dispatch(deleteFolder(folderUID)); await dispatch(deleteFolder(folderUID));
// find the parent folder uid and add it to parentsToRefresh // 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); parentsToRefresh.add(folder?.parentUID);
} }
@ -72,7 +70,7 @@ export function BrowseActions() {
for (const dashboardUID of selectedDashboards) { for (const dashboardUID of selectedDashboards) {
await dispatch(deleteDashboard(dashboardUID)); await dispatch(deleteDashboard(dashboardUID));
// find the parent folder uid and add it to parentsToRefresh // 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); parentsToRefresh.add(dashboard?.parentUID);
} }
onActionComplete(parentsToRefresh); onActionComplete(parentsToRefresh);
@ -87,7 +85,7 @@ export function BrowseActions() {
for (const folderUID of selectedFolders) { for (const folderUID of selectedFolders) {
await moveFolder({ folderUID, destinationUID }); await moveFolder({ folderUID, destinationUID });
// find the parent folder uid and add it to parentsToRefresh // 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); parentsToRefresh.add(folder?.parentUID);
} }
@ -96,7 +94,7 @@ export function BrowseActions() {
for (const dashboardUID of selectedDashboards) { for (const dashboardUID of selectedDashboards) {
await dispatch(moveDashboard({ dashboardUID, destinationUID })); await dispatch(moveDashboard({ dashboardUID, destinationUID }));
// find the parent folder uid and add it to parentsToRefresh // 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); parentsToRefresh.add(dashboard?.parentUID);
} }
onActionComplete(parentsToRefresh); onActionComplete(parentsToRefresh);

View File

@ -15,11 +15,22 @@ function render(...[ui, options]: Parameters<typeof rtlRender>) {
rtlRender(<TestProvider>{ui}</TestProvider>, options); rtlRender(<TestProvider>{ui}</TestProvider>, 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 { return {
getFolderChildren(parentUID?: string) { ...orig,
listFolders(parentUID?: string) {
const childrenForUID = mockTree 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); .map((v) => v.item);
return Promise.resolve(childrenForUID); return Promise.resolve(childrenForUID);
@ -61,9 +72,7 @@ describe('browse-dashboards BrowseView', () => {
await clickCheckbox(folderA.item.uid); await clickCheckbox(folderA.item.uid);
// All the visible items in it should be checked now // All the visible items in it should be checked now
const directChildren = mockTree.filter( const directChildren = mockTree.filter((v) => v.item.kind !== 'ui' && v.item.parentUID === folderA.item.uid);
(v) => v.item.kind !== 'ui-empty-folder' && v.item.parentUID === folderA.item.uid
);
for (const child of directChildren) { for (const child of directChildren) {
const childCheckbox = screen.queryByTestId(selectors.pages.BrowseDashbards.table.checkbox(child.item.uid)); const childCheckbox = screen.queryByTestId(selectors.pages.BrowseDashbards.table.checkbox(child.item.uid));
@ -83,9 +92,7 @@ describe('browse-dashboards BrowseView', () => {
// should also be selected // should also be selected
await expandFolder(folderA_folderB.item.uid); await expandFolder(folderA_folderB.item.uid);
const grandchildren = mockTree.filter( const grandchildren = mockTree.filter((v) => v.item.kind !== 'ui' && v.item.parentUID === folderA_folderB.item.uid);
(v) => v.item.kind !== 'ui-empty-folder' && v.item.parentUID === folderA_folderB.item.uid
);
for (const child of grandchildren) { for (const child of grandchildren) {
const childCheckbox = screen.queryByTestId(selectors.pages.BrowseDashbards.table.checkbox(child.item.uid)); const childCheckbox = screen.queryByTestId(selectors.pages.BrowseDashbards.table.checkbox(child.item.uid));

View File

@ -1,21 +1,22 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect, useRef } from 'react';
import { Spinner } from '@grafana/ui'; import { Spinner } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { DashboardViewItem } from 'app/features/search/types'; import { DashboardViewItem } from 'app/features/search/types';
import { useDispatch } from 'app/types'; import { useDispatch } from 'app/types';
import { PAGE_SIZE, ROOT_PAGE_SIZE } from '../api/services';
import { import {
useFlatTreeState, useFlatTreeState,
useCheckboxSelectionState, useCheckboxSelectionState,
fetchChildren, fetchNextChildrenPage,
setFolderOpenState, setFolderOpenState,
setItemSelectionState, setItemSelectionState,
useChildrenByParentUIDState, useChildrenByParentUIDState,
setAllSelection, setAllSelection,
useBrowseLoadingStatus, useBrowseLoadingStatus,
} from '../state'; } from '../state';
import { DashboardTreeSelection, SelectionState } from '../types'; import { BrowseDashboardsState, DashboardTreeSelection, SelectionState } from '../types';
import { DashboardsTree } from './DashboardsTree'; import { DashboardsTree } from './DashboardsTree';
@ -38,14 +39,14 @@ export function BrowseView({ folderUID, width, height, canSelect }: BrowseViewPr
dispatch(setFolderOpenState({ folderUID: clickedFolderUID, isOpen })); dispatch(setFolderOpenState({ folderUID: clickedFolderUID, isOpen }));
if (isOpen) { if (isOpen) {
dispatch(fetchChildren(clickedFolderUID)); dispatch(fetchNextChildrenPage({ parentUID: clickedFolderUID, pageSize: PAGE_SIZE }));
} }
}, },
[dispatch] [dispatch]
); );
useEffect(() => { useEffect(() => {
dispatch(fetchChildren(folderUID)); dispatch(fetchNextChildrenPage({ parentUID: folderUID, pageSize: ROOT_PAGE_SIZE }));
}, [handleFolderClick, dispatch, folderUID]); }, [handleFolderClick, dispatch, folderUID]);
const handleItemSelectionChange = useCallback( const handleItemSelectionChange = useCallback(
@ -99,6 +100,22 @@ export function BrowseView({ folderUID, width, height, canSelect }: BrowseViewPr
[selectedItems, childrenByParentUID] [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') { if (status === 'pending') {
return <Spinner />; return <Spinner />;
} }
@ -130,21 +147,23 @@ export function BrowseView({ folderUID, width, height, canSelect }: BrowseViewPr
onFolderClick={handleFolderClick} onFolderClick={handleFolderClick}
onAllSelectionChange={(newState) => dispatch(setAllSelection({ isSelected: newState }))} onAllSelectionChange={(newState) => dispatch(setAllSelection({ isSelected: newState }))}
onItemSelectionChange={handleItemSelectionChange} onItemSelectionChange={handleItemSelectionChange}
isItemLoaded={isItemLoaded}
requestLoadMore={handleLoadMore}
/> />
); );
} }
function hasSelectedDescendants( function hasSelectedDescendants(
item: DashboardViewItem, item: DashboardViewItem,
childrenByParentUID: Record<string, DashboardViewItem[] | undefined>, childrenByParentUID: BrowseDashboardsState['childrenByParentUID'],
selectedItems: DashboardTreeSelection selectedItems: DashboardTreeSelection
): boolean { ): boolean {
const children = childrenByParentUID[item.uid]; const collection = childrenByParentUID[item.uid];
if (!children) { if (!collection) {
return false; return false;
} }
return children.some((v) => { return collection.items.some((v) => {
const thisIsSelected = selectedItems[v.kind][v.uid]; const thisIsSelected = selectedItems[v.kind][v.uid];
if (thisIsSelected) { if (thisIsSelected) {
return thisIsSelected; return thisIsSelected;
@ -153,3 +172,23 @@ function hasSelectedDescendants(
return hasSelectedDescendants(v, childrenByParentUID, selectedItems); 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;
}

View File

@ -12,7 +12,7 @@ export default function CheckboxCell({
}: DashboardsTreeCellProps) { }: DashboardsTreeCellProps) {
const item = row.item; const item = row.item;
if (item.kind === 'ui-empty-folder' || !isSelected) { if (item.kind === 'ui' || !isSelected) {
return null; return null;
} }

View File

@ -24,6 +24,8 @@ describe('browse-dashboards DashboardsTree', () => {
const dashboard = wellFormedDashboard(2); const dashboard = wellFormedDashboard(2);
const noop = () => {}; const noop = () => {};
const isSelected = () => SelectionState.Unselected; const isSelected = () => SelectionState.Unselected;
const allItemsAreLoaded = () => true;
const requestLoadMore = () => Promise.resolve();
it('renders a dashboard item', () => { it('renders a dashboard item', () => {
render( render(
@ -36,6 +38,8 @@ describe('browse-dashboards DashboardsTree', () => {
onFolderClick={noop} onFolderClick={noop}
onItemSelectionChange={noop} onItemSelectionChange={noop}
onAllSelectionChange={noop} onAllSelectionChange={noop}
isItemLoaded={allItemsAreLoaded}
requestLoadMore={requestLoadMore}
/> />
); );
expect(screen.queryByText(dashboard.item.title)).toBeInTheDocument(); expect(screen.queryByText(dashboard.item.title)).toBeInTheDocument();
@ -55,6 +59,8 @@ describe('browse-dashboards DashboardsTree', () => {
onFolderClick={noop} onFolderClick={noop}
onItemSelectionChange={noop} onItemSelectionChange={noop}
onAllSelectionChange={noop} onAllSelectionChange={noop}
isItemLoaded={allItemsAreLoaded}
requestLoadMore={requestLoadMore}
/> />
); );
expect( expect(
@ -73,6 +79,8 @@ describe('browse-dashboards DashboardsTree', () => {
onFolderClick={noop} onFolderClick={noop}
onItemSelectionChange={noop} onItemSelectionChange={noop}
onAllSelectionChange={noop} onAllSelectionChange={noop}
isItemLoaded={allItemsAreLoaded}
requestLoadMore={requestLoadMore}
/> />
); );
expect(screen.queryByText(folder.item.title)).toBeInTheDocument(); expect(screen.queryByText(folder.item.title)).toBeInTheDocument();
@ -91,6 +99,8 @@ describe('browse-dashboards DashboardsTree', () => {
onFolderClick={handler} onFolderClick={handler}
onItemSelectionChange={noop} onItemSelectionChange={noop}
onAllSelectionChange={noop} onAllSelectionChange={noop}
isItemLoaded={allItemsAreLoaded}
requestLoadMore={requestLoadMore}
/> />
); );
const folderButton = screen.getByLabelText('Collapse folder'); const folderButton = screen.getByLabelText('Collapse folder');
@ -110,6 +120,8 @@ describe('browse-dashboards DashboardsTree', () => {
onFolderClick={noop} onFolderClick={noop}
onItemSelectionChange={noop} onItemSelectionChange={noop}
onAllSelectionChange={noop} onAllSelectionChange={noop}
isItemLoaded={allItemsAreLoaded}
requestLoadMore={requestLoadMore}
/> />
); );
expect(screen.queryByText('No items')).toBeInTheDocument(); expect(screen.queryByText('No items')).toBeInTheDocument();

View File

@ -1,7 +1,8 @@
import { css, cx } from '@emotion/css'; 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 { TableInstance, useTable } from 'react-table';
import { FixedSizeList as List } from 'react-window'; import { FixedSizeList as List } from 'react-window';
import InfiniteLoader from 'react-window-infinite-loader';
import { GrafanaTheme2, isTruthy } from '@grafana/data'; import { GrafanaTheme2, isTruthy } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
@ -27,11 +28,14 @@ interface DashboardsTreeProps {
items: DashboardsTreeItem[]; items: DashboardsTreeItem[];
width: number; width: number;
height: number; height: number;
canSelect: boolean;
isSelected: (kind: DashboardViewItem | '$all') => SelectionState; isSelected: (kind: DashboardViewItem | '$all') => SelectionState;
onFolderClick: (uid: string, newOpenState: boolean) => void; onFolderClick: (uid: string, newOpenState: boolean) => void;
onAllSelectionChange: (newState: boolean) => void; onAllSelectionChange: (newState: boolean) => void;
onItemSelectionChange: (item: DashboardViewItem, 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; const HEADER_HEIGHT = 35;
@ -45,10 +49,22 @@ export function DashboardsTree({
onFolderClick, onFolderClick,
onAllSelectionChange, onAllSelectionChange,
onItemSelectionChange, onItemSelectionChange,
isItemLoaded,
requestLoadMore,
canSelect = false, canSelect = false,
}: DashboardsTreeProps) { }: DashboardsTreeProps) {
const infiniteLoaderRef = useRef<InfiniteLoader>(null);
const styles = useStyles2(getStyles); 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 tableColumns = useMemo(() => {
const checkboxColumn: DashboardsTreeColumn = { const checkboxColumn: DashboardsTreeColumn = {
id: 'checkbox', id: 'checkbox',
@ -97,6 +113,20 @@ export function DashboardsTree({
[table, isSelected, onAllSelectionChange, onItemSelectionChange, items] [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 ( return (
<div {...getTableProps()} className={styles.tableRoot} role="table"> <div {...getTableProps()} className={styles.tableRoot} role="table">
{headerGroups.map((headerGroup) => { {headerGroups.map((headerGroup) => {
@ -120,15 +150,26 @@ export function DashboardsTree({
})} })}
<div {...getTableBodyProps()}> <div {...getTableBodyProps()}>
<List <InfiniteLoader
height={height - HEADER_HEIGHT} ref={infiniteLoaderRef}
width={width}
itemCount={items.length} itemCount={items.length}
itemData={virtualData} isItemLoaded={handleIsItemLoaded}
itemSize={ROW_HEIGHT} loadMoreItems={handleLoadMore}
> >
{VirtualListRow} {({ onItemsRendered, ref }) => (
</List> <List
ref={ref}
height={height - HEADER_HEIGHT}
width={width}
itemCount={items.length}
itemData={virtualData}
itemSize={ROW_HEIGHT}
onItemsRendered={onItemsRendered}
>
{VirtualListRow}
</List>
)}
</InfiniteLoader>
</div> </div>
</div> </div>
); );

View File

@ -8,7 +8,8 @@ import { AccessControlAction, FolderDTO, useDispatch } from 'app/types';
import { ShowModalReactEvent } from 'app/types/events'; import { ShowModalReactEvent } from 'app/types/events';
import { useMoveFolderMutation } from '../api/browseDashboardsAPI'; 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 { DeleteModal } from './BrowseActions/DeleteModal';
import { MoveModal } from './BrowseActions/MoveModal'; import { MoveModal } from './BrowseActions/MoveModal';
@ -29,16 +30,21 @@ export function FolderActionsButton({ folder }: Props) {
const onMove = async (destinationUID: string) => { const onMove = async (destinationUID: string) => {
await moveFolder({ folderUID: folder.uid, destinationUID }); await moveFolder({ folderUID: folder.uid, destinationUID });
dispatch(fetchChildren(destinationUID)); dispatch(refetchChildren({ parentUID: destinationUID, pageSize: destinationUID ? PAGE_SIZE : ROOT_PAGE_SIZE }));
if (folder.parentUid) { if (folder.parentUid) {
dispatch(fetchChildren(folder.parentUid)); dispatch(
refetchChildren({ parentUID: folder.parentUid, pageSize: folder.parentUid ? PAGE_SIZE : ROOT_PAGE_SIZE })
);
} }
}; };
const onDelete = async () => { const onDelete = async () => {
await dispatch(deleteFolder(folder.uid)); await dispatch(deleteFolder(folder.uid));
if (folder.parentUid) { if (folder.parentUid) {
dispatch(fetchChildren(folder.parentUid)); dispatch(
refetchChildren({ parentUID: folder.parentUid, pageSize: folder.parentUid ? PAGE_SIZE : ROOT_PAGE_SIZE })
);
} }
locationService.push('/dashboards'); locationService.push('/dashboards');
}; };

View File

@ -19,13 +19,13 @@ export function NameCell({ row: { original: data }, onFolderClick }: NameCellPro
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const { item, level, isOpen } = data; const { item, level, isOpen } = data;
if (item.kind === 'ui-empty-folder') { if (item.kind === 'ui') {
return ( return (
<> <>
<Indent level={level} /> <Indent level={level} />
<span className={styles.folderButtonSpacer} /> <span className={styles.folderButtonSpacer} />
<em> <em>
<TextModifier color="secondary">No items</TextModifier> <TextModifier color="secondary">{item.uiKind === 'empty-folder' ? 'No items' : 'Loading...'}</TextModifier>
</em> </em>
</> </>
); );

View File

@ -10,7 +10,7 @@ import { DashboardsTreeItem } from '../types';
export function TagsCell({ row: { original: data } }: CellProps<DashboardsTreeItem, unknown>) { export function TagsCell({ row: { original: data } }: CellProps<DashboardsTreeItem, unknown>) {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
const item = data.item; const item = data.item;
if (item.kind === 'ui-empty-folder' || !item.tags) { if (item.kind === 'ui' || !item.tags) {
return null; return null;
} }

View File

@ -12,7 +12,8 @@ export function wellFormedEmptyFolder(
return { return {
item: { item: {
kind: 'ui-empty-folder', kind: 'ui',
uiKind: 'empty-folder',
uid: random.guid(), uid: random.guid(),
}, },
level: 0, level: 0,

View File

@ -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,
};
}

View File

@ -1,15 +1,134 @@
import { getBackendSrv } from '@grafana/runtime'; import { getBackendSrv } from '@grafana/runtime';
import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types'; import { DeleteDashboardResponse } from 'app/features/manage-dashboards/types';
import { GENERAL_FOLDER_UID } from 'app/features/search/constants'; 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'; import { createAsyncThunk, DashboardDTO } from 'app/types';
export const fetchChildren = createAsyncThunk( import { listDashboards, listFolders } from '../api/services';
'browseDashboards/fetchChildren',
async (parentUID: string | undefined) => { interface FetchNextChildrenPageArgs {
// Need to handle the case where the parentUID is the root 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<RefetchChildrenResult> => {
const uid = parentUID === GENERAL_FOLDER_UID ? undefined : parentUID; 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<undefined | FetchNextChildrenPageResult> => {
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,
};
} }
); );

View File

@ -1,6 +1,7 @@
import { configureStore } from 'app/store/configureStore'; import { configureStore } from 'app/store/configureStore';
import { useSelector } from 'app/types'; import { useSelector } from 'app/types';
import { fullyLoadedViewItemCollection } from '../fixtures/state.fixtures';
import { BrowseDashboardsState } from '../types'; import { BrowseDashboardsState } from '../types';
import { useBrowseLoadingStatus } from './hooks'; import { useBrowseLoadingStatus } from './hooks';
@ -57,7 +58,7 @@ describe('browse-dashboards state hooks', () => {
}); });
it('returns fulfilled when root view is finished loading', () => { it('returns fulfilled when root view is finished loading', () => {
mockState(createInitialState({ rootItems: [] })); mockState(createInitialState({ rootItems: fullyLoadedViewItemCollection([]) }));
const status = useBrowseLoadingStatus(undefined); const status = useBrowseLoadingStatus(undefined);
expect(status).toEqual('fulfilled'); expect(status).toEqual('fulfilled');
@ -67,7 +68,7 @@ describe('browse-dashboards state hooks', () => {
mockState( mockState(
createInitialState({ createInitialState({
childrenByParentUID: { childrenByParentUID: {
[folderUID]: [], [folderUID]: fullyLoadedViewItemCollection([]),
}, },
}) })
); );

View File

@ -3,7 +3,8 @@ import { createSelector } from 'reselect';
import { DashboardViewItem } from 'app/features/search/types'; import { DashboardViewItem } from 'app/features/search/types';
import { useSelector, StoreState } from 'app/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 rootItemsSelector = (wholeState: StoreState) => wholeState.browseDashboards.rootItems;
export const childrenByParentUIDSelector = (wholeState: StoreState) => wholeState.browseDashboards.childrenByParentUID; export const childrenByParentUIDSelector = (wholeState: StoreState) => wholeState.browseDashboards.childrenByParentUID;
@ -16,7 +17,7 @@ const flatTreeSelector = createSelector(
openFoldersSelector, openFoldersSelector,
(wholeState: StoreState, rootFolderUID: string | undefined) => rootFolderUID, (wholeState: StoreState, rootFolderUID: string | undefined) => rootFolderUID,
(rootItems, childrenByParentUID, openFolders, folderUID) => { (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]; const isSelected = selectedItems.folder[folderUID];
if (isSelected) { if (isSelected) {
// Unselect any children in the output // Unselect any children in the output
const children = childrenByParentUID[folderUID]; const collection = childrenByParentUID[folderUID];
if (children) { if (collection) {
for (const child of children) { for (const child of collection.items) {
if (child.kind === 'dashboard') { if (child.kind === 'dashboard') {
result.dashboard[child.uid] = false; result.dashboard[child.uid] = false;
} }
@ -104,21 +105,21 @@ export function useActionSelectionState() {
*/ */
function createFlatTree( function createFlatTree(
folderUID: string | undefined, folderUID: string | undefined,
rootItems: DashboardViewItem[], rootCollection: BrowseDashboardsState['rootItems'],
childrenByUID: Record<string, DashboardViewItem[] | undefined>, childrenByUID: BrowseDashboardsState['childrenByParentUID'],
openFolders: Record<string, boolean>, openFolders: Record<string, boolean>,
level = 0 level = 0
): DashboardsTreeItem[] { ): DashboardsTreeItem[] {
function mapItem(item: DashboardViewItem, parentUID: string | undefined, level: number): 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 isOpen = Boolean(openFolders[item.uid]);
const emptyFolder = childrenByUID[item.uid]?.length === 0; const emptyFolder = childrenByUID[item.uid]?.items.length === 0;
if (isOpen && emptyFolder) { if (isOpen && emptyFolder) {
mappedChildren.push({ mappedChildren.push({
isOpen: false, isOpen: false,
level: level + 1, 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 isOpen = (folderUID && openFolders[folderUID]) || level === 0;
const items = folderUID const collection = folderUID ? childrenByUID[folderUID] : rootCollection;
? (isOpen && childrenByUID[folderUID]) || [] // keep seperate lines
: rootItems;
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}`,
},
};
});
} }

View File

@ -1,16 +1,12 @@
import { wellFormedDashboard, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture'; import { wellFormedDashboard, wellFormedFolder } from '../fixtures/dashboardsTreeItem.fixture';
import { fullyLoadedViewItemCollection } from '../fixtures/state.fixtures';
import { BrowseDashboardsState } from '../types'; import { BrowseDashboardsState } from '../types';
import { import { fetchNextChildrenPageFulfilled, setAllSelection, setFolderOpenState, setItemSelectionState } from './reducers';
extraReducerFetchChildrenFulfilled,
setAllSelection,
setFolderOpenState,
setItemSelectionState,
} from './reducers';
function createInitialState(): BrowseDashboardsState { function createInitialState(): BrowseDashboardsState {
return { return {
rootItems: [], rootItems: undefined,
childrenByParentUID: {}, childrenByParentUID: {},
openFolders: {}, openFolders: {},
selectedItems: { selectedItems: {
@ -23,29 +19,81 @@ function createInitialState(): BrowseDashboardsState {
} }
describe('browse-dashboards reducers', () => { describe('browse-dashboards reducers', () => {
describe('extraReducerFetchChildrenFulfilled', () => { describe('fetchNextChildrenPageFulfilled', () => {
it('updates state correctly for root items', () => { it('loads first page of root items', () => {
const pageSize = 50;
const state = createInitialState(); const state = createInitialState();
const children = [ const children = new Array(pageSize).fill(0).map((_, index) => wellFormedFolder(index + 1).item);
wellFormedFolder(1).item,
wellFormedFolder(2).item,
wellFormedFolder(3).item,
wellFormedDashboard(4).item,
];
const action = { const action = {
payload: children, payload: {
children,
kind: 'folder' as const,
page: 1,
lastPageOfKind: false,
},
type: 'action-type', type: 'action-type',
meta: { meta: {
arg: undefined, arg: {
parentUID: undefined,
pageSize: pageSize,
},
requestId: 'abc-123', requestId: 'abc-123',
requestStatus: 'fulfilled' as const, 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', () => { 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 children = [wellFormedFolder(2).item, wellFormedDashboard(3).item];
const action = { const action = {
payload: children, payload: {
children,
kind: 'dashboard' as const,
page: 1,
lastPageOfKind: true,
},
type: 'action-type', type: 'action-type',
meta: { meta: {
arg: parentFolder.uid, arg: {
parentUID: parentFolder.uid,
pageSize: 999,
},
requestId: 'abc-123', requestId: 'abc-123',
requestStatus: 'fulfilled' as const, 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', () => { it('marks children as selected if the parent is selected', () => {
@ -78,16 +142,24 @@ describe('browse-dashboards reducers', () => {
const childDashboard = wellFormedDashboard(3).item; const childDashboard = wellFormedDashboard(3).item;
const action = { const action = {
payload: [childFolder, childDashboard], payload: {
children: [childFolder, childDashboard],
kind: 'dashboard' as const,
page: 1,
lastPageOfKind: true,
},
type: 'action-type', type: 'action-type',
meta: { meta: {
arg: parentFolder.uid, arg: {
parentUID: parentFolder.uid,
pageSize: 999,
},
requestId: 'abc-123', requestId: 'abc-123',
requestStatus: 'fulfilled' as const, requestStatus: 'fulfilled' as const,
}, },
}; };
extraReducerFetchChildrenFulfilled(state, action); fetchNextChildrenPageFulfilled(state, action);
expect(state.selectedItems).toEqual({ expect(state.selectedItems).toEqual({
$all: false, $all: false,
@ -118,7 +190,7 @@ describe('browse-dashboards reducers', () => {
const folder = wellFormedFolder(1).item; const folder = wellFormedFolder(1).item;
const dashboard = wellFormedDashboard(2).item; const dashboard = wellFormedDashboard(2).item;
const state = createInitialState(); const state = createInitialState();
state.rootItems = [folder, dashboard]; state.rootItems = fullyLoadedViewItemCollection([folder, dashboard]);
setItemSelectionState(state, { type: 'setItemSelectionState', payload: { item: dashboard, isSelected: true } }); 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 childFolder = wellFormedFolder(4, {}, { parentUID: parentFolder.uid }).item;
const grandchildDashboard = wellFormedDashboard(5, {}, { parentUID: childFolder.uid }).item; const grandchildDashboard = wellFormedDashboard(5, {}, { parentUID: childFolder.uid }).item;
state.rootItems = [parentFolder, rootDashboard]; state.rootItems = fullyLoadedViewItemCollection([parentFolder, rootDashboard]);
state.childrenByParentUID[parentFolder.uid] = [childDashboard, childFolder]; state.childrenByParentUID[parentFolder.uid] = fullyLoadedViewItemCollection([childDashboard, childFolder]);
state.childrenByParentUID[childFolder.uid] = [grandchildDashboard]; state.childrenByParentUID[childFolder.uid] = fullyLoadedViewItemCollection([grandchildDashboard]);
setItemSelectionState(state, { setItemSelectionState(state, {
type: 'setItemSelectionState', type: 'setItemSelectionState',
@ -172,9 +244,9 @@ describe('browse-dashboards reducers', () => {
const childFolder = wellFormedFolder(3, {}, { parentUID: parentFolder.uid }).item; const childFolder = wellFormedFolder(3, {}, { parentUID: parentFolder.uid }).item;
const grandchildDashboard = wellFormedDashboard(4, {}, { parentUID: childFolder.uid }).item; const grandchildDashboard = wellFormedDashboard(4, {}, { parentUID: childFolder.uid }).item;
state.rootItems = [parentFolder]; state.rootItems = fullyLoadedViewItemCollection([parentFolder]);
state.childrenByParentUID[parentFolder.uid] = [childDashboard, childFolder]; state.childrenByParentUID[parentFolder.uid] = fullyLoadedViewItemCollection([childDashboard, childFolder]);
state.childrenByParentUID[childFolder.uid] = [grandchildDashboard]; state.childrenByParentUID[childFolder.uid] = fullyLoadedViewItemCollection([grandchildDashboard]);
state.selectedItems.dashboard[childDashboard.uid] = true; state.selectedItems.dashboard[childDashboard.uid] = true;
state.selectedItems.dashboard[grandchildDashboard.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 childDashboardA = wellFormedDashboard(3, {}, { parentUID: rootFolder.uid }).item;
const childDashboardB = wellFormedDashboard(4, {}, { parentUID: rootFolder.uid }).item; const childDashboardB = wellFormedDashboard(4, {}, { parentUID: rootFolder.uid }).item;
state.rootItems = [rootFolder, rootDashboard]; state.rootItems = fullyLoadedViewItemCollection([rootFolder, rootDashboard]);
state.childrenByParentUID[rootFolder.uid] = [childDashboardA, childDashboardB]; state.childrenByParentUID[rootFolder.uid] = fullyLoadedViewItemCollection([childDashboardA, childDashboardB]);
state.selectedItems.dashboard = { [rootDashboard.uid]: true, [childDashboardA.uid]: true }; 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 childDashboardA = wellFormedDashboard(3, {}, { parentUID: rootFolder.uid }).item;
const childDashboardB = wellFormedDashboard(4, {}, { parentUID: rootFolder.uid }).item; const childDashboardB = wellFormedDashboard(4, {}, { parentUID: rootFolder.uid }).item;
state.rootItems = [rootFolder, rootDashboard]; state.rootItems = fullyLoadedViewItemCollection([rootFolder, rootDashboard]);
state.childrenByParentUID[rootFolder.uid] = [childDashboardA, childDashboardB]; state.childrenByParentUID[rootFolder.uid] = fullyLoadedViewItemCollection([childDashboardA, childDashboardB]);
state.selectedItems.dashboard = { state.selectedItems.dashboard = {
[rootDashboard.uid]: true, [rootDashboard.uid]: true,
@ -262,9 +334,9 @@ describe('browse-dashboards reducers', () => {
const childFolder = wellFormedFolder(seed++, {}, { parentUID: topLevelFolder.uid }).item; const childFolder = wellFormedFolder(seed++, {}, { parentUID: topLevelFolder.uid }).item;
const grandchildDashboard = wellFormedDashboard(seed++, {}, { parentUID: childFolder.uid }).item; const grandchildDashboard = wellFormedDashboard(seed++, {}, { parentUID: childFolder.uid }).item;
state.rootItems = [topLevelFolder, topLevelDashboard]; state.rootItems = fullyLoadedViewItemCollection([topLevelFolder, topLevelDashboard]);
state.childrenByParentUID[topLevelFolder.uid] = [childDashboard, childFolder]; state.childrenByParentUID[topLevelFolder.uid] = fullyLoadedViewItemCollection([childDashboard, childFolder]);
state.childrenByParentUID[childFolder.uid] = [grandchildDashboard]; state.childrenByParentUID[childFolder.uid] = fullyLoadedViewItemCollection([grandchildDashboard]);
state.selectedItems.folder[childFolder.uid] = false; state.selectedItems.folder[childFolder.uid] = false;
state.selectedItems.dashboard[grandchildDashboard.uid] = true; state.selectedItems.dashboard[grandchildDashboard.uid] = true;
@ -296,9 +368,9 @@ describe('browse-dashboards reducers', () => {
const childFolder = wellFormedFolder(seed++, {}, { parentUID: topLevelFolder.uid }).item; const childFolder = wellFormedFolder(seed++, {}, { parentUID: topLevelFolder.uid }).item;
const grandchildDashboard = wellFormedDashboard(seed++, {}, { parentUID: childFolder.uid }).item; const grandchildDashboard = wellFormedDashboard(seed++, {}, { parentUID: childFolder.uid }).item;
state.rootItems = [topLevelFolder, topLevelDashboard]; state.rootItems = fullyLoadedViewItemCollection([topLevelFolder, topLevelDashboard]);
state.childrenByParentUID[topLevelFolder.uid] = [childDashboard, childFolder]; state.childrenByParentUID[topLevelFolder.uid] = fullyLoadedViewItemCollection([childDashboard, childFolder]);
state.childrenByParentUID[childFolder.uid] = [grandchildDashboard]; state.childrenByParentUID[childFolder.uid] = fullyLoadedViewItemCollection([grandchildDashboard]);
state.selectedItems.folder[childFolder.uid] = false; state.selectedItems.folder[childFolder.uid] = false;
state.selectedItems.dashboard[grandchildDashboard.uid] = true; state.selectedItems.dashboard[grandchildDashboard.uid] = true;

View File

@ -1,25 +1,64 @@
import { PayloadAction } from '@reduxjs/toolkit'; import { PayloadAction } from '@reduxjs/toolkit';
import { GENERAL_FOLDER_UID } from 'app/features/search/constants';
import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types'; import { DashboardViewItem, DashboardViewItemKind } from 'app/features/search/types';
import { BrowseDashboardsState } from '../types'; import { BrowseDashboardsState } from '../types';
import { fetchChildren } from './actions'; import { fetchNextChildrenPage, refetchChildren } from './actions';
import { findItem } from './utils'; import { findItem } from './utils';
type FetchChildrenAction = ReturnType<typeof fetchChildren.fulfilled>; type FetchNextChildrenPageFulfilledAction = ReturnType<typeof fetchNextChildrenPage.fulfilled>;
type RefetchChildrenFulfilledAction = ReturnType<typeof refetchChildren.fulfilled>;
export function extraReducerFetchChildrenFulfilled(state: BrowseDashboardsState, action: FetchChildrenAction) { export function refetchChildrenFulfilled(state: BrowseDashboardsState, action: RefetchChildrenFulfilledAction) {
const parentUID = action.meta.arg; const { children, page, kind, lastPageOfKind } = action.payload;
const children = action.payload; const { parentUID } = action.meta.arg;
if (!parentUID || parentUID === GENERAL_FOLDER_UID) { const newCollection = {
state.rootItems = children; 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; 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 // If the parent of the items we've loaded are selected, we must select all these items also
const parentIsSelected = state.selectedItems.folder[parentUID]; const parentIsSelected = state.selectedItems.folder[parentUID];
@ -56,8 +95,8 @@ export function setItemSelectionState(
return; return;
} }
let children = state.childrenByParentUID[uid] ?? []; let collection = state.childrenByParentUID[uid];
for (const child of children) { for (const child of collection?.items ?? []) {
markChildren(child.kind, child.uid); markChildren(child.kind, child.uid);
} }
} }
@ -70,7 +109,7 @@ export function setItemSelectionState(
let nextParentUID = item.parentUID; let nextParentUID = item.parentUID;
while (nextParentUID) { 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 // This case should not happen, but a find can theortically return undefined, and it
// helps limit infinite loops // 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 // 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 }>) { export function setAllSelection(state: BrowseDashboardsState, action: PayloadAction<{ isSelected: boolean }>) {
@ -103,14 +142,14 @@ export function setAllSelection(state: BrowseDashboardsState, action: PayloadAct
if (isSelected) { if (isSelected) {
for (const folderUID in state.childrenByParentUID) { 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; 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; state.selectedItems[child.kind][child.uid] = isSelected;
} }
} else { } else {

View File

@ -2,10 +2,10 @@ import { createSlice } from '@reduxjs/toolkit';
import { BrowseDashboardsState } from '../types'; import { BrowseDashboardsState } from '../types';
import { fetchChildren } from './actions'; import { fetchNextChildrenPage, refetchChildren } from './actions';
import * as allReducers from './reducers'; import * as allReducers from './reducers';
const { extraReducerFetchChildrenFulfilled, ...baseReducers } = allReducers; const { fetchNextChildrenPageFulfilled, refetchChildrenFulfilled, ...baseReducers } = allReducers;
const initialState: BrowseDashboardsState = { const initialState: BrowseDashboardsState = {
rootItems: undefined, rootItems: undefined,
@ -25,7 +25,8 @@ const browseDashboardsSlice = createSlice({
reducers: baseReducers, reducers: baseReducers,
extraReducers: (builder) => { extraReducers: (builder) => {
builder.addCase(fetchChildren.fulfilled, extraReducerFetchChildrenFulfilled); builder.addCase(fetchNextChildrenPage.fulfilled, fetchNextChildrenPageFulfilled);
builder.addCase(refetchChildren.fulfilled, refetchChildrenFulfilled);
}, },
}); });

View File

@ -1,8 +1,10 @@
import { DashboardViewItem } from 'app/features/search/types'; import { DashboardViewItem } from 'app/features/search/types';
import { BrowseDashboardsState } from '../types';
export function findItem( export function findItem(
rootItems: DashboardViewItem[], rootItems: DashboardViewItem[],
childrenByUID: Record<string, DashboardViewItem[] | undefined>, childrenByUID: BrowseDashboardsState['childrenByParentUID'],
uid: string uid: string
): DashboardViewItem | undefined { ): DashboardViewItem | undefined {
for (const item of rootItems) { for (const item of rootItems) {
@ -17,7 +19,7 @@ export function findItem(
continue; continue;
} }
for (const child of children) { for (const child of children.items) {
if (child.uid === uid) { if (child.uid === uid) {
return child; return child;
} }

View File

@ -1,14 +1,26 @@
import { CellProps, Column, HeaderProps } from 'react-table'; 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<DashboardViewItemKind, Record<string, boolean | undefined>> & { export type DashboardTreeSelection = Record<DashboardViewItemKind, Record<string, boolean | undefined>> & {
$all: boolean; $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 { export interface BrowseDashboardsState {
rootItems: DashboardViewItem[] | undefined; rootItems: DashboardViewItemCollection | undefined;
childrenByParentUID: Record<string, DashboardViewItem[] | undefined>; childrenByParentUID: Record<string, DashboardViewItemCollection | undefined>;
selectedItems: DashboardTreeSelection; selectedItems: DashboardTreeSelection;
// Only folders can ever be open or closed, so no need to seperate this by kind // 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 { export interface UIDashboardViewItem {
kind: 'ui-empty-folder'; kind: 'ui';
uiKind: 'empty-folder' | 'pagination-placeholder';
uid: string; uid: string;
} }

View File

@ -1,10 +1,9 @@
import { getBackendSrv } from '@grafana/runtime';
import config from 'app/core/config'; import config from 'app/core/config';
import { listFolders } from 'app/features/browse-dashboards/api/services';
import { DashboardViewItem } from '../types'; import { DashboardViewItem } from '../types';
import { getGrafanaSearcher } from './searcher'; import { getGrafanaSearcher } from './searcher';
import { NestedFolderDTO } from './types';
import { queryResultToViewItem } from './utils'; import { queryResultToViewItem } from './utils';
export async function getFolderChildren( export async function getFolderChildren(
@ -20,7 +19,7 @@ export async function getFolderChildren(
if (!dashboardsAtRoot && !parentUid) { if (!dashboardsAtRoot && !parentUid) {
// We don't show dashboards at root in folder view yet - they're shown under a dummy 'general' // We don't show dashboards at root in folder view yet - they're shown under a dummy 'general'
// folder that FolderView adds in // folder that FolderView adds in
const folders = await getChildFolders(); const folders = await listFolders();
return folders; return folders;
} }
@ -36,22 +35,7 @@ export async function getFolderChildren(
return queryResultToViewItem(item, dashboardsResults.view); return queryResultToViewItem(item, dashboardsResults.view);
}); });
const folders = await getChildFolders(parentUid, parentTitle); const folders = await listFolders(parentUid, parentTitle);
return [...folders, ...dashboardItems]; return [...folders, ...dashboardItems];
} }
async function getChildFolders(parentUid?: string, parentTitle?: string): Promise<DashboardViewItem[]> {
const backendSrv = getBackendSrv();
const folders = await backendSrv.get<NestedFolderDTO[]>('/api/folders', { parentUid });
return folders.map((item) => ({
kind: 'folder',
uid: item.uid,
title: item.title,
parentTitle,
parentUID: parentUid,
url: `/dashboards/f/${item.uid}/`,
}));
}

View File

@ -70,11 +70,25 @@ export class SQLSearcher implements GrafanaSearcher {
throw new Error('facets not supported!'); 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( const q = await this.composeQuery(
{ {
limit: query.limit ?? DEFAULT_MAX_VALUES, // default 1k max values limit: limit,
tag: query.tags, tag: query.tags,
sort: query.sort, sort: query.sort,
page,
}, },
query query
); );

View File

@ -65,10 +65,13 @@ export interface DashboardViewItem {
icon?: string; icon?: string;
parentUID?: string; parentUID?: string;
/** @deprecated Not used in new Browse UI */
parentTitle?: string; parentTitle?: string;
/** @deprecated Not used in new Browse UI */
parentKind?: string; parentKind?: string;
// Used only for psuedo-folders, such as Starred or Recent // Used only for psuedo-folders, such as Starred or Recent
/** @deprecated Not used in new Browse UI */
itemsUIDs?: string[]; itemsUIDs?: string[];
// For enterprise sort options // For enterprise sort options