NestedFolders: Paginate nested folder picker (#71489)

* Paginate nested folder picker items

* Prevent dashboards from appearing in the folder picker

* fix tests

* remove SkeletonGroup

* more excludeKinds

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