FolderPicker: Make lazy in prep for exposing publicly (#100118)

* Make lazy NestedFolderPicker

* Change permission prop to use string union instead of enum

* reword comment
This commit is contained in:
Josh Hunt 2025-02-05 15:21:32 +00:00 committed by GitHub
parent 39d94eabcd
commit 0ca1febb77
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 83 additions and 21 deletions

View File

@ -0,0 +1,16 @@
import { Suspense, lazy } from 'react';
import { FolderPickerSkeleton } from './Skeleton';
const SuspendingNestedFolderPicker = lazy(() =>
import('./NestedFolderPicker').then((module) => ({ default: module.NestedFolderPicker }))
);
// Lazily load folder picker, is what is exposed to plugins through @grafana/runtime
export const LazyFolderPicker = (props: Parameters<typeof SuspendingNestedFolderPicker>[0]) => {
return (
<Suspense fallback={<FolderPickerSkeleton />}>
<SuspendingNestedFolderPicker {...props} />
</Suspense>
);
};

View File

@ -6,7 +6,6 @@ import { TestProvider } from 'test/helpers/TestProvider';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { backendSrv } from 'app/core/services/backend_srv'; import { backendSrv } from 'app/core/services/backend_srv';
import { PermissionLevelString } from 'app/types';
import { import {
treeViewersCanEdit, treeViewersCanEdit,
@ -187,7 +186,7 @@ describe('NestedFolderPicker', () => {
}); });
it('shows items the user can view, with the prop', async () => { it('shows items the user can view, with the prop', async () => {
render(<NestedFolderPicker permission={PermissionLevelString.View} onChange={mockOnChange} />); render(<NestedFolderPicker permission="view" onChange={mockOnChange} />);
const button = await screen.findByRole('button', { name: 'Select folder' }); const button = await screen.findByRole('button', { name: 'Select folder' });
await userEvent.click(button); await userEvent.click(button);
@ -209,7 +208,7 @@ describe('NestedFolderPicker', () => {
}); });
it('can expand and collapse a folder to show its children', async () => { it('can expand and collapse a folder to show its children', async () => {
render(<NestedFolderPicker permission={PermissionLevelString.View} onChange={mockOnChange} />); render(<NestedFolderPicker permission="view" onChange={mockOnChange} />);
// Open the picker and wait for children to load // Open the picker and wait for children to load
const button = await screen.findByRole('button', { name: 'Select folder' }); const button = await screen.findByRole('button', { name: 'Select folder' });
@ -240,7 +239,7 @@ describe('NestedFolderPicker', () => {
}); });
it('can expand and collapse a folder to show its children with the keyboard', async () => { it('can expand and collapse a folder to show its children with the keyboard', async () => {
render(<NestedFolderPicker permission={PermissionLevelString.View} onChange={mockOnChange} />); render(<NestedFolderPicker permission="view" onChange={mockOnChange} />);
const button = await screen.findByRole('button', { name: 'Select folder' }); const button = await screen.findByRole('button', { name: 'Select folder' });
await userEvent.click(button); await userEvent.click(button);

View File

@ -35,7 +35,7 @@ export interface NestedFolderPickerProps {
excludeUIDs?: string[]; excludeUIDs?: string[];
/* Show folders matching this permission, mainly used to also show folders user can view. Defaults to showing only folders user has Edit */ /* Show folders matching this permission, mainly used to also show folders user can view. Defaults to showing only folders user has Edit */
permission?: PermissionLevelString.View | PermissionLevelString.Edit; permission?: 'view' | 'edit';
/* Callback for when the user selects a folder */ /* Callback for when the user selects a folder */
onChange?: (folderUID: string | undefined, folderName: string | undefined) => void; onChange?: (folderUID: string | undefined, folderName: string | undefined) => void;
@ -51,7 +51,7 @@ async function getSearchResults(searchQuery: string, permission?: PermissionLeve
query: searchQuery, query: searchQuery,
kind: ['folder'], kind: ['folder'],
limit: 100, limit: 100,
permission: permission, permission,
}); });
const items = queryResponse.view.map((v) => queryResultToViewItem(v, queryResponse.view)); const items = queryResponse.view.map((v) => queryResultToViewItem(v, queryResponse.view));
@ -64,7 +64,7 @@ export function NestedFolderPicker({
showRootFolder = true, showRootFolder = true,
clearable = false, clearable = false,
excludeUIDs, excludeUIDs,
permission = PermissionLevelString.Edit, permission = 'edit',
onChange, onChange,
}: NestedFolderPickerProps) { }: NestedFolderPickerProps) {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
@ -82,12 +82,23 @@ export function NestedFolderPicker({
const [error] = useState<Error | undefined>(undefined); // TODO: error not populated anymore const [error] = useState<Error | undefined>(undefined); // TODO: error not populated anymore
const lastSearchTimestamp = useRef<number>(0); const lastSearchTimestamp = useRef<number>(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 isBrowsing = Boolean(overlayOpen && !(search && searchResults));
const { const {
items: browseFlatTree, items: browseFlatTree,
isLoading: isBrowseLoading, isLoading: isBrowseLoading,
requestNextPage: fetchFolderPage, requestNextPage: fetchFolderPage,
} = useFoldersQuery(isBrowsing, foldersOpenState, permission); } = useFoldersQuery(isBrowsing, foldersOpenState, permissionLevel);
useEffect(() => { useEffect(() => {
if (!search) { if (!search) {
@ -98,7 +109,7 @@ export function NestedFolderPicker({
const timestamp = Date.now(); const timestamp = Date.now();
setIsFetchingSearchResults(true); setIsFetchingSearchResults(true);
debouncedSearch(search, permission).then((queryResponse) => { debouncedSearch(search, permissionLevel).then((queryResponse) => {
// Only keep the results if it's was issued after the most recently resolved search. // 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. // 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 // We don't need to worry about clearing the isFetching state either - if there's a later
@ -110,7 +121,7 @@ export function NestedFolderPicker({
lastSearchTimestamp.current = timestamp; lastSearchTimestamp.current = timestamp;
} }
}); });
}, [search, permission]); }, [search, permissionLevel]);
// the order of middleware is important! // the order of middleware is important!
const middleware = [ const middleware = [

View File

@ -0,0 +1,36 @@
import { css } from '@emotion/css';
import Skeleton from 'react-loading-skeleton';
import type { GrafanaTheme2 } from '@grafana/data';
import { getInputStyles, useStyles2 } from '@grafana/ui';
// This component is used as a fallback for codesplitting, so aim to keep
// the bundle size of it as small as possible :)
export function FolderPickerSkeleton() {
const styles = useStyles2(getStyles);
return (
<div className={styles.wrapper}>
<div className={styles.inputWrapper}>
<button type="button" className={styles.fakeInput} aria-disabled>
<Skeleton width={100} />
</button>
</div>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
const baseStyles = getInputStyles({ theme });
return {
wrapper: baseStyles.wrapper,
inputWrapper: baseStyles.inputWrapper,
fakeInput: css([
baseStyles.input,
{
textAlign: 'left',
},
]),
};
};

View File

@ -1,13 +1,14 @@
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import { forwardRef, ReactNode, ButtonHTMLAttributes } from 'react'; import { forwardRef, ReactNode, ButtonHTMLAttributes } from 'react';
import * as React from 'react'; import * as React from 'react';
import Skeleton from 'react-loading-skeleton';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { Icon, getInputStyles, useTheme2, Text } from '@grafana/ui'; import { Icon, getInputStyles, useTheme2, Text } from '@grafana/ui';
import { getFocusStyles, getMouseFocusStyles } from '@grafana/ui/src/themes/mixins'; import { getFocusStyles, getMouseFocusStyles } from '@grafana/ui/src/themes/mixins';
import { Trans, t } from 'app/core/internationalization'; import { Trans, t } from 'app/core/internationalization';
import { FolderPickerSkeleton } from './Skeleton';
interface TriggerProps extends ButtonHTMLAttributes<HTMLButtonElement> { interface TriggerProps extends ButtonHTMLAttributes<HTMLButtonElement> {
isLoading: boolean; isLoading: boolean;
handleClearSelection?: (event: React.MouseEvent<SVGElement> | React.KeyboardEvent<SVGElement>) => void; handleClearSelection?: (event: React.MouseEvent<SVGElement> | React.KeyboardEvent<SVGElement>) => void;
@ -28,6 +29,10 @@ function Trigger(
} }
}; };
if (isLoading) {
return <FolderPickerSkeleton />;
}
return ( return (
<div className={styles.wrapper}> <div className={styles.wrapper}>
<div className={styles.inputWrapper}> <div className={styles.inputWrapper}>
@ -43,9 +48,7 @@ function Trigger(
{...rest} {...rest}
ref={ref} ref={ref}
> >
{isLoading ? ( {label ? (
<Skeleton width={100} />
) : label ? (
<Text truncate>{label}</Text> <Text truncate>{label}</Text>
) : ( ) : (
<Text truncate color="secondary"> <Text truncate color="secondary">

View File

@ -1,7 +1,6 @@
import { PanelPlugin } from '@grafana/data'; import { PanelPlugin } from '@grafana/data';
import { TagsInput } from '@grafana/ui'; import { TagsInput } from '@grafana/ui';
import { FolderPicker } from 'app/core/components/Select/FolderPicker'; import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { PermissionLevelString } from 'app/types';
import { DashList } from './DashList'; import { DashList } from './DashList';
import { dashlistMigrationHandler } from './migrations'; import { dashlistMigrationHandler } from './migrations';
@ -62,12 +61,7 @@ export const plugin = new PanelPlugin<Options>(DashList)
defaultValue: undefined, defaultValue: undefined,
editor: function RenderFolderPicker({ value, onChange }) { editor: function RenderFolderPicker({ value, onChange }) {
return ( return (
<FolderPicker <FolderPicker clearable permission="view" value={value} onChange={(folderUID) => onChange(folderUID)} />
clearable
permission={PermissionLevelString.View}
value={value}
onChange={(folderUID) => onChange(folderUID)}
/>
); );
}, },
}) })

View File

@ -7,6 +7,9 @@ export enum TeamPermissionLevel {
export { OrgRole as OrgRole }; export { OrgRole as OrgRole };
export type PermissionLevel = 'view' | 'edit' | 'admin';
/** @deprecated Use PermissionLevel instead */
export enum PermissionLevelString { export enum PermissionLevelString {
View = 'View', View = 'View',
Edit = 'Edit', Edit = 'Edit',