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 {
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);

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 { 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<string | undefined>) => {
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);

View File

@ -15,11 +15,22 @@ function render(...[ui, options]: Parameters<typeof rtlRender>) {
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 {
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));

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 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 <Spinner />;
}
@ -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<string, DashboardViewItem[] | undefined>,
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;
}

View File

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

View File

@ -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();

View File

@ -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<InfiniteLoader>(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 (
<div {...getTableProps()} className={styles.tableRoot} role="table">
{headerGroups.map((headerGroup) => {
@ -120,15 +150,26 @@ export function DashboardsTree({
})}
<div {...getTableBodyProps()}>
<List
height={height - HEADER_HEIGHT}
width={width}
<InfiniteLoader
ref={infiniteLoaderRef}
itemCount={items.length}
itemData={virtualData}
itemSize={ROW_HEIGHT}
isItemLoaded={handleIsItemLoaded}
loadMoreItems={handleLoadMore}
>
{VirtualListRow}
</List>
{({ onItemsRendered, ref }) => (
<List
ref={ref}
height={height - HEADER_HEIGHT}
width={width}
itemCount={items.length}
itemData={virtualData}
itemSize={ROW_HEIGHT}
onItemsRendered={onItemsRendered}
>
{VirtualListRow}
</List>
)}
</InfiniteLoader>
</div>
</div>
);

View File

@ -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');
};

View File

@ -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 (
<>
<Indent level={level} />
<span className={styles.folderButtonSpacer} />
<em>
<TextModifier color="secondary">No items</TextModifier>
<TextModifier color="secondary">{item.uiKind === 'empty-folder' ? 'No items' : 'Loading...'}</TextModifier>
</em>
</>
);

View File

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

View File

@ -12,7 +12,8 @@ export function wellFormedEmptyFolder(
return {
item: {
kind: 'ui-empty-folder',
kind: 'ui',
uiKind: 'empty-folder',
uid: random.guid(),
},
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 { 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<RefetchChildrenResult> => {
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 { 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([]),
},
})
);

View File

@ -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<string, DashboardViewItem[] | undefined>,
rootCollection: BrowseDashboardsState['rootItems'],
childrenByUID: BrowseDashboardsState['childrenByParentUID'],
openFolders: Record<string, boolean>,
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}`,
},
};
});
}

View File

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

View File

@ -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<typeof fetchChildren.fulfilled>;
type FetchNextChildrenPageFulfilledAction = ReturnType<typeof fetchNextChildrenPage.fulfilled>;
type RefetchChildrenFulfilledAction = ReturnType<typeof refetchChildren.fulfilled>;
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 {

View File

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

View File

@ -1,8 +1,10 @@
import { DashboardViewItem } from 'app/features/search/types';
import { BrowseDashboardsState } from '../types';
export function findItem(
rootItems: DashboardViewItem[],
childrenByUID: Record<string, DashboardViewItem[] | undefined>,
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;
}

View File

@ -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<DashboardViewItemKind, Record<string, boolean | undefined>> & {
$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<string, DashboardViewItem[] | undefined>;
rootItems: DashboardViewItemCollection | undefined;
childrenByParentUID: Record<string, DashboardViewItemCollection | undefined>;
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;
}

View File

@ -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<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!');
}
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
);

View File

@ -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