The open and composable observability and data visualization platform. Visualize metrics, logs, and traces from multiple sources like Prometheus, Loki, Elasticsearch, InfluxDB, Postgres and many more.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 
grafana/public/app/core/components/NestedFolderPicker/NestedFolderPicker.tsx

316 lines
9.3 KiB

import { css } from '@emotion/css';
import React, { useCallback, useId, useMemo, useState } from 'react';
import { usePopperTooltip } from 'react-popper-tooltip';
import { useAsync } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { Alert, Icon, Input, LoadingBar, useStyles2 } from '@grafana/ui';
import { t } from 'app/core/internationalization';
import { skipToken, useGetFolderQuery } from 'app/features/browse-dashboards/api/browseDashboardsAPI';
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 { getDOMId, NestedFolderList } from './NestedFolderList';
import Trigger from './Trigger';
import { useTreeInteractions } from './hooks';
import { FolderChange, FolderUID } from './types';
interface NestedFolderPickerProps {
value?: FolderUID;
// TODO: think properly (and pragmatically) about how to communicate moving to general folder,
// vs removing selection (if possible?)
onChange?: (folder: FolderChange) => void;
excludeUIDs?: string[];
}
const EXCLUDED_KINDS = ['empty-folder' as const, 'dashboard' as const];
export function NestedFolderPicker({ value, onChange, excludeUIDs = [] }: NestedFolderPickerProps) {
const styles = useStyles2(getStyles);
const dispatch = useDispatch();
const selectedFolder = useGetFolderQuery(value || skipToken);
const rootStatus = useBrowseLoadingStatus(undefined);
const [search, setSearch] = useState('');
const [autoFocusButton, setAutoFocusButton] = useState(false);
const [overlayOpen, setOverlayOpen] = useState(false);
const [folderOpenState, setFolderOpenState] = useState<Record<string, boolean>>({});
const overlayId = useId();
const [error] = useState<Error | undefined>(undefined); // TODO: error not populated anymore
const searchState = useAsync(async () => {
if (!search) {
return undefined;
}
const searcher = getGrafanaSearcher();
const queryResponse = await searcher.search({
query: search,
kind: ['folder'],
limit: 100,
});
const items = queryResponse.view.map((v) => queryResultToViewItem(v, queryResponse.view));
return { ...queryResponse, items };
}, [search]);
const rootCollection = useSelector(rootItemsSelector);
const childrenCollections = useSelector(childrenByParentUIDSelector);
const { getTooltipProps, setTooltipRef, setTriggerRef, visible, triggerRef } = usePopperTooltip({
visible: overlayOpen,
placement: 'bottom',
interactive: true,
offset: [0, 0],
trigger: 'click',
onVisibleChange: (value: boolean) => {
// ensure state is clean on opening the overlay
if (value) {
setSearch('');
setAutoFocusButton(true);
}
setOverlayOpen(value);
},
});
const handleFolderExpand = useCallback(
async (uid: string, newOpenState: boolean) => {
setFolderOpenState((old) => ({ ...old, [uid]: newOpenState }));
if (newOpenState && !folderOpenState[uid]) {
dispatch(fetchNextChildrenPage({ parentUID: uid, pageSize: PAGE_SIZE, excludeKinds: EXCLUDED_KINDS }));
}
},
[dispatch, folderOpenState]
);
const handleFolderSelect = useCallback(
(item: DashboardViewItem) => {
if (onChange) {
onChange({
uid: item.uid,
title: item.title,
});
}
setOverlayOpen(false);
},
[onChange]
);
const handleCloseOverlay = useCallback(() => setOverlayOpen(false), [setOverlayOpen]);
const baseHandleLoadMore = useLoadNextChildrenPage(EXCLUDED_KINDS);
const handleLoadMore = useCallback(
(folderUID: string | undefined) => {
if (search) {
return;
}
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, excludeUIDs);
}
let flatTree = createFlatTree(
undefined,
rootCollection,
childrenCollections,
folderOpenState,
0,
EXCLUDED_KINDS,
excludeUIDs
);
// 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, excludeUIDs]);
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;
const { focusedItemIndex, handleKeyDown } = useTreeInteractions({
tree: flatTree,
handleCloseOverlay,
handleFolderSelect,
handleFolderExpand,
idPrefix: overlayId,
search,
visible,
});
let label = selectedFolder.data?.title;
if (value === '') {
label = 'Dashboards';
}
if (!visible) {
return (
<Trigger
label={label}
isLoading={selectedFolder.isLoading}
autoFocus={autoFocusButton}
ref={setTriggerRef}
aria-label={
label
? t('browse-dashboards.folder-picker.accessible-label', 'Select folder: {{ label }} currently selected', {
label,
})
: undefined
}
/>
);
}
return (
<>
<Input
ref={setTriggerRef}
autoFocus
prefix={label ? <Icon name="folder" /> : null}
placeholder={label ?? t('browse-dashboards.folder-picker.search-placeholder', 'Search folders')}
value={search}
className={styles.search}
onKeyDown={handleKeyDown}
onChange={(e) => setSearch(e.currentTarget.value)}
aria-autocomplete="list"
aria-expanded
aria-haspopup
aria-controls={overlayId}
aria-owns={overlayId}
aria-activedescendant={getDOMId(overlayId, flatTree[focusedItemIndex]?.item.uid)}
role="combobox"
suffix={<Icon name="search" />}
/>
<fieldset
ref={setTooltipRef}
id={overlayId}
{...getTooltipProps({
className: styles.tableWrapper,
style: {
width: triggerRef?.clientWidth,
},
})}
>
{error ? (
<Alert
className={styles.error}
severity="warning"
title={t('browse-dashboards.folder-picker.error-title', 'Error loading folders')}
>
{error.message || error.toString?.() || t('browse-dashboards.folder-picker.unknown-error', 'Unknown error')}
</Alert>
) : (
<div>
{isLoading && (
<div className={styles.loader}>
<LoadingBar width={600} />
</div>
)}
<NestedFolderList
items={flatTree}
selectedFolder={value}
focusedItemIndex={focusedItemIndex}
onFolderExpand={handleFolderExpand}
onFolderSelect={handleFolderSelect}
idPrefix={overlayId}
foldersAreOpenable={!(search && searchState.value)}
isItemLoaded={isItemLoaded}
requestLoadMore={handleLoadMore}
/>
</div>
)}
</fieldset>
</>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
button: css({
maxWidth: '100%',
}),
error: css({
marginBottom: 0,
}),
tableWrapper: css({
boxShadow: theme.shadows.z3,
position: 'relative',
zIndex: theme.zIndex.portal,
}),
loader: css({
position: 'absolute',
top: 0,
left: 0,
right: 0,
zIndex: theme.zIndex.portal + 1,
overflow: 'hidden', // loading bar overflows its container, so we need to clip it
}),
search: css({
input: {
cursor: 'default',
},
}),
};
};