NestedFolders: Add empty states for Browse and Search (#67423)

* NestedFolders: Add empty states for Browse and Search

* empty states

* fix types

* tests
This commit is contained in:
Josh Hunt 2023-05-02 11:33:59 +00:00 committed by GitHub
parent 32d3e895b3
commit fd2c7594cf
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 134 additions and 8 deletions

View File

@ -1,5 +1,7 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { Spinner } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { DashboardViewItem } from 'app/features/search/types'; import { DashboardViewItem } from 'app/features/search/types';
import { useDispatch } from 'app/types'; import { useDispatch } from 'app/types';
@ -11,6 +13,7 @@ import {
setItemSelectionState, setItemSelectionState,
useChildrenByParentUIDState, useChildrenByParentUIDState,
setAllSelection, setAllSelection,
useBrowseLoadingStatus,
} from '../state'; } from '../state';
import { DashboardTreeSelection, SelectionState } from '../types'; import { DashboardTreeSelection, SelectionState } from '../types';
@ -24,6 +27,7 @@ interface BrowseViewProps {
} }
export function BrowseView({ folderUID, width, height, canSelect }: BrowseViewProps) { export function BrowseView({ folderUID, width, height, canSelect }: BrowseViewProps) {
const status = useBrowseLoadingStatus(folderUID);
const dispatch = useDispatch(); const dispatch = useDispatch();
const flatTree = useFlatTreeState(folderUID); const flatTree = useFlatTreeState(folderUID);
const selectedItems = useCheckboxSelectionState(); const selectedItems = useCheckboxSelectionState();
@ -95,6 +99,27 @@ export function BrowseView({ folderUID, width, height, canSelect }: BrowseViewPr
[selectedItems, childrenByParentUID] [selectedItems, childrenByParentUID]
); );
if (status === 'pending') {
return <Spinner />;
}
if (status === 'fulfilled' && flatTree.length === 0) {
return (
<div style={{ width }}>
<EmptyListCTA
title={folderUID ? "This folder doesn't have any dashboards yet" : 'No dashboards yet. Create your first!'}
buttonIcon="plus"
buttonTitle="Create Dashboard"
buttonLink={folderUID ? `dashboard/new?folderUid=${folderUID}` : 'dashboard/new'}
proTip={folderUID && 'Add/move dashboards to your folder at ->'}
proTipLink={folderUID && 'dashboards'}
proTipLinkTitle={folderUID && 'Browse dashboards'}
proTipTarget=""
/>
</div>
);
}
return ( return (
<DashboardsTree <DashboardsTree
canSelect={canSelect} canSelect={canSelect}

View File

@ -1,6 +1,6 @@
import React, { useCallback } from 'react'; import React, { useCallback } from 'react';
import { Spinner } from '@grafana/ui'; import { Button, Card, Spinner } from '@grafana/ui';
import { useKeyNavigationListener } from 'app/features/search/hooks/useSearchKeyboardSelection'; import { useKeyNavigationListener } from 'app/features/search/hooks/useSearchKeyboardSelection';
import { SearchResultsProps, SearchResultsTable } from 'app/features/search/page/components/SearchResultsTable'; import { SearchResultsProps, SearchResultsTable } from 'app/features/search/page/components/SearchResultsTable';
import { useSearchStateManager } from 'app/features/search/state/SearchStateManager'; import { useSearchStateManager } from 'app/features/search/state/SearchStateManager';
@ -69,7 +69,18 @@ export function SearchView({ width, height, canSelect }: SearchViewProps) {
} }
if (value.totalRows === 0) { if (value.totalRows === 0) {
return <div style={{ width }}>No search results</div>; return (
<div style={{ width }}>
<Card>
<Card.Heading>No results found for your query.</Card.Heading>
<Card.Actions>
<Button variant="secondary" onClick={stateManager.onClearSearchAndFilters}>
Clear search and filters
</Button>
</Card.Actions>
</Card>
</div>
);
} }
const props: SearchResultsProps = { const props: SearchResultsProps = {

View File

@ -0,0 +1,79 @@
import { configureStore } from 'app/store/configureStore';
import { useSelector } from 'app/types';
import { BrowseDashboardsState } from '../types';
import { useBrowseLoadingStatus } from './hooks';
jest.mock('app/types', () => {
const original = jest.requireActual('app/types');
return {
...original,
useSelector: jest.fn(),
};
});
function createInitialState(partial: Partial<BrowseDashboardsState>): BrowseDashboardsState {
return {
rootItems: undefined,
childrenByParentUID: {},
openFolders: {},
selectedItems: {
$all: false,
dashboard: {},
folder: {},
panel: {},
},
...partial,
};
}
describe('browse-dashboards state hooks', () => {
const folderUID = 'abc-123';
function mockState(browseState: BrowseDashboardsState) {
const wholeState = configureStore().getState();
wholeState.browseDashboards = browseState;
jest.mocked(useSelector).mockImplementationOnce((callback) => {
return callback(wholeState);
});
}
describe('useBrowseLoadingStatus', () => {
it('returns loading when root view is loading', () => {
mockState(createInitialState({ rootItems: undefined }));
const status = useBrowseLoadingStatus(undefined);
expect(status).toEqual('pending');
});
it('returns loading when folder view is loading', () => {
mockState(createInitialState({ childrenByParentUID: {} }));
const status = useBrowseLoadingStatus(folderUID);
expect(status).toEqual('pending');
});
it('returns fulfilled when root view is finished loading', () => {
mockState(createInitialState({ rootItems: [] }));
const status = useBrowseLoadingStatus(undefined);
expect(status).toEqual('fulfilled');
});
it('returns fulfilled when folder view is finished loading', () => {
mockState(
createInitialState({
childrenByParentUID: {
[folderUID]: [],
},
})
);
const status = useBrowseLoadingStatus(folderUID);
expect(status).toEqual('fulfilled');
});
});
});

View File

@ -11,7 +11,7 @@ const flatTreeSelector = createSelector(
(wholeState: StoreState) => wholeState.browseDashboards.openFolders, (wholeState: StoreState) => wholeState.browseDashboards.openFolders,
(wholeState: StoreState, rootFolderUID: string | undefined) => rootFolderUID, (wholeState: StoreState, rootFolderUID: string | undefined) => rootFolderUID,
(rootItems, childrenByParentUID, openFolders, folderUID) => { (rootItems, childrenByParentUID, openFolders, folderUID) => {
return createFlatTree(folderUID, rootItems, childrenByParentUID, openFolders); return createFlatTree(folderUID, rootItems ?? [], childrenByParentUID, openFolders);
} }
); );
@ -32,6 +32,16 @@ const selectedItemsForActionsSelector = createSelector(
} }
); );
export function useBrowseLoadingStatus(folderUID: string | undefined): 'pending' | 'fulfilled' {
return useSelector((wholeState) => {
const children = folderUID
? wholeState.browseDashboards.childrenByParentUID[folderUID]
: wholeState.browseDashboards.rootItems;
return children ? 'fulfilled' : 'pending';
});
}
export function useFlatTreeState(folderUID: string | undefined) { export function useFlatTreeState(folderUID: string | undefined) {
return useSelector((state) => flatTreeSelector(state, folderUID)); return useSelector((state) => flatTreeSelector(state, folderUID));
} }

View File

@ -68,7 +68,7 @@ export function setItemSelectionState(
let nextParentUID = item.parentUID; let nextParentUID = item.parentUID;
while (nextParentUID) { while (nextParentUID) {
const parent = findItem(state.rootItems, state.childrenByParentUID, nextParentUID); const parent = findItem(state.rootItems ?? [], state.childrenByParentUID, nextParentUID);
// This case should not happen, but a find can theortically return undefined, and it // This case should not happen, but a find can theortically return undefined, and it
// helps limit infinite loops // helps limit infinite loops
@ -92,7 +92,7 @@ export function setItemSelectionState(
} }
// Check to see if we should mark the header checkbox selected if all root items are selected // Check to see if we should mark the header checkbox selected if all root items are selected
state.selectedItems.$all = state.rootItems.every((v) => state.selectedItems[v.kind][v.uid]) ?? false; state.selectedItems.$all = state.rootItems?.every((v) => state.selectedItems[v.kind][v.uid]) ?? false;
} }
export function setAllSelection(state: BrowseDashboardsState, action: PayloadAction<{ isSelected: boolean }>) { export function setAllSelection(state: BrowseDashboardsState, action: PayloadAction<{ isSelected: boolean }>) {
@ -115,7 +115,7 @@ export function setAllSelection(state: BrowseDashboardsState, action: PayloadAct
} }
} }
for (const child of state.rootItems) { for (const child of state.rootItems ?? []) {
state.selectedItems[child.kind][child.uid] = isSelected; state.selectedItems[child.kind][child.uid] = isSelected;
} }
} else { } else {

View File

@ -8,7 +8,7 @@ import * as allReducers from './reducers';
const { extraReducerFetchChildrenFulfilled, ...baseReducers } = allReducers; const { extraReducerFetchChildrenFulfilled, ...baseReducers } = allReducers;
const initialState: BrowseDashboardsState = { const initialState: BrowseDashboardsState = {
rootItems: [], rootItems: undefined,
childrenByParentUID: {}, childrenByParentUID: {},
openFolders: {}, openFolders: {},
selectedItems: { selectedItems: {

View File

@ -7,7 +7,7 @@ export type DashboardTreeSelection = Record<DashboardViewItemKind, Record<string
}; };
export interface BrowseDashboardsState { export interface BrowseDashboardsState {
rootItems: DashboardViewItem[]; rootItems: DashboardViewItem[] | undefined;
childrenByParentUID: Record<string, DashboardViewItem[] | undefined>; childrenByParentUID: Record<string, DashboardViewItem[] | undefined>;
selectedItems: DashboardTreeSelection; selectedItems: DashboardTreeSelection;

View File

@ -95,6 +95,7 @@ export class SearchStateManager extends StateManagerBase<SearchState> {
tag: [], tag: [],
panel_type: undefined, panel_type: undefined,
starred: undefined, starred: undefined,
sort: undefined,
}); });
}; };