import { css } from '@emotion/css'; import { autoUpdate, flip, useClick, useDismiss, useFloating, useInteractions } from '@floating-ui/react'; import { skipToken } from '@reduxjs/toolkit/query'; import debounce from 'debounce-promise'; import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react'; import * as React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import { t } from '@grafana/i18n'; import { config } from '@grafana/runtime'; import { Alert, Icon, Input, LoadingBar, Stack, Text, useStyles2 } from '@grafana/ui'; import { useGetFolderQuery } from 'app/features/browse-dashboards/api/browseDashboardsAPI'; import { DashboardViewItemWithUIItems, DashboardsTreeItem } from 'app/features/browse-dashboards/types'; import { getGrafanaSearcher } from 'app/features/search/service/searcher'; import { QueryResponse } from 'app/features/search/service/types'; import { queryResultToViewItem } from 'app/features/search/service/utils'; import { DashboardViewItem } from 'app/features/search/types'; import { PermissionLevelString } from 'app/types'; import { FolderRepo } from './FolderRepo'; import { getDOMId, NestedFolderList } from './NestedFolderList'; import Trigger from './Trigger'; import { ROOT_FOLDER_ITEM, useFoldersQuery } from './useFoldersQuery'; import { useTreeInteractions } from './useTreeInteractions'; export interface NestedFolderPickerProps { /* Folder UID to show as selected */ value?: string; /** Show an invalid state around the folder picker */ invalid?: boolean; /* Whether to show the root 'Dashboards' (formally General) folder as selectable */ showRootFolder?: boolean; /* Folder UIDs to exclude from the picker, to prevent invalid operations */ excludeUIDs?: string[]; /* Show folders matching this permission, mainly used to also show folders user can view. Defaults to showing only folders user has Edit */ permission?: 'view' | 'edit'; /* Callback for when the user selects a folder */ onChange?: (folderUID: string | undefined, folderName: string | undefined) => void; /* Whether the picker should be clearable */ clearable?: boolean; } const debouncedSearch = debounce(getSearchResults, 300); async function getSearchResults(searchQuery: string, permission?: PermissionLevelString) { const queryResponse = await getGrafanaSearcher().search({ query: searchQuery, kind: ['folder'], limit: 100, permission, }); const items = queryResponse.view.map((v) => queryResultToViewItem(v, queryResponse.view)); return { ...queryResponse, items }; } export function NestedFolderPicker({ value, invalid, showRootFolder = true, clearable = false, excludeUIDs, permission = 'edit', onChange, }: NestedFolderPickerProps) { const styles = useStyles2(getStyles); const selectedFolder = useGetFolderQuery(value || skipToken); const nestedFoldersEnabled = Boolean(config.featureToggles.nestedFolders); const [search, setSearch] = useState(''); const [searchResults, setSearchResults] = useState<(QueryResponse & { items: DashboardViewItem[] }) | null>(null); const [isFetchingSearchResults, setIsFetchingSearchResults] = useState(false); const [autoFocusButton, setAutoFocusButton] = useState(false); const [overlayOpen, setOverlayOpen] = useState(false); const [foldersOpenState, setFoldersOpenState] = useState>({}); const overlayId = useId(); const [error] = useState(undefined); // TODO: error not populated anymore const lastSearchTimestamp = useRef(0); // Map the permission string union to enum value for compatibility const permissionLevel = useMemo(() => { if (permission === 'view') { return PermissionLevelString.View; } else if (permission === 'edit') { return PermissionLevelString.Edit; } throw new Error('Invalid permission'); }, [permission]); const isBrowsing = Boolean(overlayOpen && !(search && searchResults)); const { items: browseFlatTree, isLoading: isBrowseLoading, requestNextPage: fetchFolderPage, } = useFoldersQuery(isBrowsing, foldersOpenState, permissionLevel); useEffect(() => { if (!search) { setSearchResults(null); return; } const timestamp = Date.now(); setIsFetchingSearchResults(true); debouncedSearch(search, permissionLevel).then((queryResponse) => { // Only keep the results if it's was issued after the most recently resolved search. // This prevents results showing out of order if first request is slower than later ones. // We don't need to worry about clearing the isFetching state either - if there's a later // request in progress, this will clear it for us if (timestamp > lastSearchTimestamp.current) { const items = queryResponse.view.map((v) => queryResultToViewItem(v, queryResponse.view)); setSearchResults({ ...queryResponse, items }); setIsFetchingSearchResults(false); lastSearchTimestamp.current = timestamp; } }); }, [search, permissionLevel]); // the order of middleware is important! const middleware = [ flip({ // see https://floating-ui.com/docs/flip#combining-with-shift crossAxis: false, boundary: document.body, }), ]; const { context, refs, floatingStyles, elements } = useFloating({ open: overlayOpen, placement: 'bottom', onOpenChange: (value) => { // ensure state is clean on opening the overlay if (value) { setSearch(''); setAutoFocusButton(true); } setOverlayOpen(value); }, middleware, whileElementsMounted: autoUpdate, }); const click = useClick(context); const dismiss = useDismiss(context); const { getReferenceProps, getFloatingProps } = useInteractions([dismiss, click]); const handleFolderExpand = useCallback( async (uid: string, newOpenState: boolean) => { setFoldersOpenState((old) => ({ ...old, [uid]: newOpenState })); if (newOpenState && !foldersOpenState[uid]) { fetchFolderPage(uid); } }, [fetchFolderPage, foldersOpenState] ); const handleFolderSelect = useCallback( (item: DashboardViewItem) => { if (onChange) { onChange(item.uid, item.title); } setOverlayOpen(false); }, [onChange] ); const handleClearSelection = useCallback( (event: React.MouseEvent | React.KeyboardEvent) => { event.preventDefault(); event.stopPropagation(); if (onChange) { onChange(undefined, undefined); } }, [onChange] ); const handleCloseOverlay = useCallback(() => setOverlayOpen(false), [setOverlayOpen]); const handleLoadMore = useCallback( (folderUID: string | undefined) => { if (search) { return; } fetchFolderPage(folderUID); }, [search, fetchFolderPage] ); const flatTree = useMemo(() => { let flatTree: Array> = []; if (isBrowsing) { flatTree = browseFlatTree; } else { flatTree = searchResults?.items.map((item) => ({ isOpen: false, level: 0, item: { kind: 'folder' as const, title: item.title, uid: item.uid, }, })) ?? []; } // It's not super optimal to filter these in an additional iteration, but // these options are used infrequently that its not a big deal if (!showRootFolder || excludeUIDs?.length) { flatTree = flatTree.filter((item) => { if (!showRootFolder && item === ROOT_FOLDER_ITEM) { return false; } if (excludeUIDs?.includes(item.item.uid)) { return false; } return true; }); } return flatTree; }, [browseFlatTree, excludeUIDs, isBrowsing, searchResults?.items, showRootFolder]); 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 = isBrowseLoading || isFetchingSearchResults; const { focusedItemIndex, handleKeyDown } = useTreeInteractions({ tree: flatTree, handleCloseOverlay, handleFolderSelect, handleFolderExpand, idPrefix: overlayId, search, visible: overlayOpen, }); let label = selectedFolder.data?.title; if (value === '') { label = 'Dashboards'; } // Display the folder name and provisioning status when the picker is closed const labelComponent = label ? ( {label} ) : ( '' ); if (!overlayOpen) { return ( ); } return ( <> : null} placeholder={label ?? t('browse-dashboards.folder-picker.search-placeholder', 'Search folders')} value={search} invalid={invalid} className={styles.search} 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={} {...getReferenceProps()} onKeyDown={handleKeyDown} />
{error ? ( {error.message || error.toString?.() || t('browse-dashboards.folder-picker.unknown-error', 'Unknown error')} ) : (
{isLoading && (
)}
)}
); } 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', }, }), }; };