mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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:
parent
b65ce6738f
commit
394ff9fcde
@ -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);
|
||||
|
56
public/app/features/browse-dashboards/api/services.ts
Normal file
56
public/app/features/browse-dashboards/api/services.ts
Normal 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;
|
||||
});
|
||||
}
|
@ -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);
|
||||
|
@ -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));
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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();
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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');
|
||||
};
|
||||
|
@ -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>
|
||||
</>
|
||||
);
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -12,7 +12,8 @@ export function wellFormedEmptyFolder(
|
||||
|
||||
return {
|
||||
item: {
|
||||
kind: 'ui-empty-folder',
|
||||
kind: 'ui',
|
||||
uiKind: 'empty-folder',
|
||||
uid: random.guid(),
|
||||
},
|
||||
level: 0,
|
||||
|
@ -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,
|
||||
};
|
||||
}
|
@ -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,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
|
@ -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([]),
|
||||
},
|
||||
})
|
||||
);
|
||||
|
@ -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}`,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
@ -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;
|
||||
|
@ -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 {
|
||||
|
@ -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);
|
||||
},
|
||||
});
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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;
|
||||
}
|
||||
|
||||
|
@ -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}/`,
|
||||
}));
|
||||
}
|
||||
|
@ -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
|
||||
);
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user