diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 99d3016c585..5a853d26ede 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -200,6 +200,9 @@ export const Components = { FolderPicker: { container: 'Folder picker select container', }, + ReadonlyFolderPicker: { + container: 'data-testid Readonly folder picker select container', + }, DataSourcePicker: { container: 'Data source picker select container', input: () => 'input[id="data-source-picker"]', diff --git a/public/app/core/components/Select/ReadonlyFolderPicker/ReadonlyFolderPicker.test.tsx b/public/app/core/components/Select/ReadonlyFolderPicker/ReadonlyFolderPicker.test.tsx new file mode 100644 index 00000000000..c2d954ad0ec --- /dev/null +++ b/public/app/core/components/Select/ReadonlyFolderPicker/ReadonlyFolderPicker.test.tsx @@ -0,0 +1,137 @@ +import React from 'react'; +import { SelectableValue } from '@grafana/data'; +import { render, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { byTestId } from 'testing-library-selector'; + +import * as api from './api'; +import { FolderInfo, PermissionLevelString } from '../../../../types'; +import { ALL_FOLDER, GENERAL_FOLDER, ReadonlyFolderPicker, ReadonlyFolderPickerProps } from './ReadonlyFolderPicker'; +import { selectors as e2eSelectors } from '@grafana/e2e-selectors'; + +const FOLDERS = [ + { value: GENERAL_FOLDER, label: GENERAL_FOLDER.title }, + { value: { id: 1, title: 'Test' }, label: 'Test' }, +]; + +async function getTestContext( + propOverrides: Partial = {}, + folders: Array> = [], + folder: SelectableValue | undefined = undefined +) { + jest.clearAllMocks(); + const selectors = { + container: byTestId(e2eSelectors.components.ReadonlyFolderPicker.container), + }; + const getFoldersAsOptionsSpy = jest.spyOn(api, 'getFoldersAsOptions').mockResolvedValue(folders); + const getFolderAsOptionSpy = jest.spyOn(api, 'getFolderAsOption').mockResolvedValue(folder); + const props: ReadonlyFolderPickerProps = { + onChange: jest.fn(), + }; + + Object.assign(props, propOverrides); + + render(); + await waitFor(() => expect(getFoldersAsOptionsSpy).toHaveBeenCalledTimes(1)); + + return { getFoldersAsOptionsSpy, getFolderAsOptionSpy, selectors }; +} + +describe('ReadonlyFolderPicker', () => { + describe('when there are no folders', () => { + it('then the no folder should be selected and Choose should appear', async () => { + const { selectors } = await getTestContext(); + + expect(within(selectors.container.get()).getByText('Choose')).toBeInTheDocument(); + }); + }); + + describe('when permissionLevel is set', () => { + it('then permissionLevel is passed correctly to getFoldersAsOptions', async () => { + const { getFoldersAsOptionsSpy } = await getTestContext({ permissionLevel: PermissionLevelString.Edit }); + + expect(getFoldersAsOptionsSpy).toHaveBeenCalledWith({ + query: '', + permissionLevel: 'Edit', + extraFolders: [], + }); + }); + }); + + describe('when extraFolders is set', () => { + it('then extraFolders is passed correctly to getFoldersAsOptions', async () => { + const { getFoldersAsOptionsSpy } = await getTestContext({ extraFolders: [ALL_FOLDER] }); + + expect(getFoldersAsOptionsSpy).toHaveBeenCalledWith({ + query: '', + permissionLevel: 'View', + extraFolders: [{ id: undefined, title: 'All' }], + }); + }); + }); + + describe('when entering a query in the input', () => { + it('then query is passed correctly to getFoldersAsOptions', async () => { + const { getFoldersAsOptionsSpy, selectors } = await getTestContext(); + + expect(within(selectors.container.get()).getByRole('textbox')).toBeInTheDocument(); + getFoldersAsOptionsSpy.mockClear(); + await userEvent.type(within(selectors.container.get()).getByRole('textbox'), 'A'); + await waitFor(() => expect(getFoldersAsOptionsSpy).toHaveBeenCalledTimes(1)); + + expect(getFoldersAsOptionsSpy).toHaveBeenCalledWith({ + query: 'A', + permissionLevel: 'View', + extraFolders: [], + }); + }); + }); + + describe('when there are folders', () => { + it('then the first folder in all folders should be selected', async () => { + const { selectors } = await getTestContext({}, FOLDERS); + + expect(within(selectors.container.get()).getByText('General')).toBeInTheDocument(); + }); + + describe('and initialFolderId is passed in props and it matches an existing folder', () => { + it('then the folder with an id equal to initialFolderId should be selected', async () => { + const { selectors } = await getTestContext({ initialFolderId: 1 }, FOLDERS); + + expect(within(selectors.container.get()).getByText('Test')).toBeInTheDocument(); + }); + }); + + describe('and initialFolderId is passed in props and it does not match an existing folder from search api', () => { + it('then getFolderAsOption should be called and correct folder should be selected', async () => { + const folderById = { + value: { id: 50000, title: 'Outside api search' }, + label: 'Outside api search', + }; + const { selectors, getFolderAsOptionSpy } = await getTestContext( + { initialFolderId: 50000 }, + FOLDERS, + folderById + ); + + expect(within(selectors.container.get()).getByText('Outside api search')).toBeInTheDocument(); + expect(getFolderAsOptionSpy).toHaveBeenCalledTimes(1); + expect(getFolderAsOptionSpy).toHaveBeenCalledWith(50000); + }); + }); + + describe('and initialFolderId is passed in props and folder does not exist', () => { + it('then getFolderAsOption should be called and the first folder should be selected instead', async () => { + const { selectors, getFolderAsOptionSpy } = await getTestContext( + { initialFolderId: 50000 }, + FOLDERS, + undefined + ); + + expect(within(selectors.container.get()).getByText('General')).toBeInTheDocument(); + expect(getFolderAsOptionSpy).toHaveBeenCalledTimes(1); + expect(getFolderAsOptionSpy).toHaveBeenCalledWith(50000); + }); + }); + }); +}); diff --git a/public/app/core/components/Select/ReadonlyFolderPicker/ReadonlyFolderPicker.tsx b/public/app/core/components/Select/ReadonlyFolderPicker/ReadonlyFolderPicker.tsx new file mode 100644 index 00000000000..f3c0265d813 --- /dev/null +++ b/public/app/core/components/Select/ReadonlyFolderPicker/ReadonlyFolderPicker.tsx @@ -0,0 +1,85 @@ +import React, { ReactElement, useCallback, useState } from 'react'; +import debouncePromise from 'debounce-promise'; +import { SelectableValue } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { AsyncSelect } from '@grafana/ui'; + +import { FolderInfo, PermissionLevelString } from '../../../../types'; +import { findOptionWithId, getFolderAsOption, getFoldersAsOptions } from './api'; +import { PermissionLevel } from './types'; +import { GENERAL_FOLDER_ID, GENERAL_FOLDER_TITLE } from '../../../../features/search/constants'; + +export const ALL_FOLDER: FolderInfo = { id: undefined, title: 'All' }; +export const GENERAL_FOLDER: FolderInfo = { id: GENERAL_FOLDER_ID, title: GENERAL_FOLDER_TITLE }; + +export interface ReadonlyFolderPickerProps { + onChange: (folder?: FolderInfo) => void; + initialFolderId?: number; + /** + * By default the folders API doesn't include the General folder because it doesn't exist + * Add any extra folders you need to appear in the folder picker with the extraFolders property + */ + extraFolders?: FolderInfo[]; + permissionLevel?: PermissionLevel; +} + +export function ReadonlyFolderPicker({ + onChange: propsOnChange, + extraFolders = [], + initialFolderId, + permissionLevel = PermissionLevelString.View, +}: ReadonlyFolderPickerProps): ReactElement { + const [initialized, setInitialized] = useState(false); + const [option, setOption] = useState | undefined>(undefined); + const [options, setOptions] = useState> | undefined>(undefined); + const initialize = useCallback( + async (options: Array>) => { + let option = findOptionWithId(options, initialFolderId); + if (!option) { + // we didn't find the option with the initialFolderId + // might be because the folder doesn't exist any longer + // might be because the folder is outside of the search limit of the api + option = (await getFolderAsOption(initialFolderId)) ?? options[0]; // get folder by id or select the first item in the options and call propsOnChange + propsOnChange(option.value); + } + + setInitialized(true); + setOptions(options); + setOption(option); + }, + [initialFolderId, propsOnChange] + ); + const loadOptions = useCallback( + async (query: string) => { + const options = await getFoldersAsOptions({ query, permissionLevel, extraFolders }); + if (!initialized) { + await initialize(options); + } + return options; + }, + [permissionLevel, extraFolders, initialized, initialize] + ); + const debouncedLoadOptions = debouncePromise(loadOptions, 300, { leading: true }); + const onChange = useCallback( + ({ value }: SelectableValue) => { + const option = findOptionWithId(options, value?.id); + setOption(option); + propsOnChange(value); + }, + [options, propsOnChange] + ); + + return ( +
+ +
+ ); +} diff --git a/public/app/core/components/Select/ReadonlyFolderPicker/api.test.ts b/public/app/core/components/Select/ReadonlyFolderPicker/api.test.ts new file mode 100644 index 00000000000..e94176af7f5 --- /dev/null +++ b/public/app/core/components/Select/ReadonlyFolderPicker/api.test.ts @@ -0,0 +1,115 @@ +import * as api from '../../../../features/manage-dashboards/state/actions'; +import { getFolderAsOption, getFoldersAsOptions } from './api'; +import { DashboardSearchHit } from '../../../../features/search/types'; +import { PermissionLevelString } from '../../../../types'; +import { ALL_FOLDER, GENERAL_FOLDER } from './ReadonlyFolderPicker'; +import { silenceConsoleOutput } from '../../../../../test/core/utils/silenceConsoleOutput'; + +function getTestContext( + searchHits: DashboardSearchHit[] = [], + folderById: { id: number; title: string } = { id: 1, title: 'Folder 1' } +) { + jest.clearAllMocks(); + const searchFoldersSpy = jest.spyOn(api, 'searchFolders').mockResolvedValue(searchHits); + const getFolderByIdSpy = jest.spyOn(api, 'getFolderById').mockResolvedValue(folderById); + + return { searchFoldersSpy, getFolderByIdSpy }; +} + +describe('getFoldersAsOptions', () => { + describe('when called without permissionLevel and query', () => { + it('then the correct defaults are passed to the api', async () => { + const { searchFoldersSpy } = getTestContext(); + + await getFoldersAsOptions({ query: '' }); + + expect(searchFoldersSpy).toHaveBeenCalledTimes(1); + expect(searchFoldersSpy).toHaveBeenCalledWith('', 'View'); + }); + + describe('and extra folders are passed', () => { + it('then extra folders should all appear first in the result', async () => { + const args = { query: '', extraFolders: [ALL_FOLDER, GENERAL_FOLDER] }; + const searchHits: any[] = [{ id: 1, title: 'Folder 1' }]; + getTestContext(searchHits); + + const result = await getFoldersAsOptions(args); + expect(result).toEqual([ + { value: { id: undefined, title: 'All' }, label: 'All' }, + { value: { id: 0, title: 'General' }, label: 'General' }, + { value: { id: 1, title: 'Folder 1' }, label: 'Folder 1' }, + ]); + }); + }); + }); + + describe('when called with permissionLevel and query', () => { + it('then the correct values are passed to the api', async () => { + const { searchFoldersSpy } = getTestContext(); + + await getFoldersAsOptions({ query: 'Folder1', permissionLevel: PermissionLevelString.Edit }); + + expect(searchFoldersSpy).toHaveBeenCalledTimes(1); + expect(searchFoldersSpy).toHaveBeenCalledWith('Folder1', 'Edit'); + }); + + describe('and extra folders are passed and extra folders contain query', () => { + it('then correct extra folders should all appear first in the result', async () => { + const args = { query: 'er', extraFolders: [ALL_FOLDER, GENERAL_FOLDER] }; + const searchHits: any[] = [{ id: 1, title: 'Folder 1' }]; + getTestContext(searchHits); + + const result = await getFoldersAsOptions(args); + expect(result).toEqual([ + { value: { id: 0, title: 'General' }, label: 'General' }, + { value: { id: 1, title: 'Folder 1' }, label: 'Folder 1' }, + ]); + }); + }); + + describe('and extra folders are passed and extra folders do not contain query', () => { + it('then no extra folders should appear first in the result', async () => { + const args = { query: '1', extraFolders: [ALL_FOLDER, GENERAL_FOLDER] }; + const searchHits: any[] = [{ id: 1, title: 'Folder 1' }]; + getTestContext(searchHits); + + const result = await getFoldersAsOptions(args); + expect(result).toEqual([{ value: { id: 1, title: 'Folder 1' }, label: 'Folder 1' }]); + }); + }); + }); +}); + +describe('getFolderAsOption', () => { + describe('when called with undefined', () => { + it('then it should return undefined', async () => { + const { getFolderByIdSpy } = getTestContext(); + + const result = await getFolderAsOption(undefined); + expect(result).toBeUndefined(); + expect(getFolderByIdSpy).not.toHaveBeenCalled(); + }); + }); + + describe('when called with a folder id that does not exist', () => { + silenceConsoleOutput(); + it('then it should return undefined', async () => { + const { getFolderByIdSpy } = getTestContext(); + getFolderByIdSpy.mockRejectedValue('Not found'); + + const result = await getFolderAsOption(-1); + expect(result).toBeUndefined(); + expect(getFolderByIdSpy).toHaveBeenCalled(); + }); + }); + + describe('when called with a folder id that exist', () => { + it('then it should return a SelectableValue of FolderInfo', async () => { + const { getFolderByIdSpy } = getTestContext(); + + const result = await getFolderAsOption(1); + expect(result).toEqual({ value: { id: 1, title: 'Folder 1' }, label: 'Folder 1' }); + expect(getFolderByIdSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/public/app/core/components/Select/ReadonlyFolderPicker/api.ts b/public/app/core/components/Select/ReadonlyFolderPicker/api.ts new file mode 100644 index 00000000000..06a84c4bbb4 --- /dev/null +++ b/public/app/core/components/Select/ReadonlyFolderPicker/api.ts @@ -0,0 +1,74 @@ +import { SelectableValue } from '@grafana/data'; + +import { FolderInfo, PermissionLevelString } from '../../../../types'; +import { getFolderById, searchFolders } from '../../../../features/manage-dashboards/state/actions'; +import { PermissionLevel } from './types'; + +interface GetFoldersArgs { + query: string; + permissionLevel?: PermissionLevel; +} + +async function getFolders({ query, permissionLevel }: GetFoldersArgs): Promise { + const searchHits = await searchFolders(query, permissionLevel); + const folders: FolderInfo[] = searchHits.map((searchHit) => ({ + id: searchHit.id, + title: searchHit.title, + url: searchHit.url, + })); + + return folders; +} + +export interface GetFoldersWithEntriesArgs extends GetFoldersArgs { + extraFolders?: FolderInfo[]; +} + +async function getFoldersWithEntries({ + query, + permissionLevel, + extraFolders, +}: GetFoldersWithEntriesArgs): Promise { + const folders = await getFolders({ query, permissionLevel }); + const extra: FolderInfo[] = extraFolders ?? []; + const filteredExtra = query ? extra.filter((f) => f.title?.toLowerCase().includes(query.toLowerCase())) : extra; + if (folders) { + return filteredExtra.concat(folders); + } + + return filteredExtra; +} + +export async function getFoldersAsOptions({ + query, + permissionLevel = PermissionLevelString.View, + extraFolders = [], +}: GetFoldersWithEntriesArgs) { + const folders = await getFoldersWithEntries({ query, permissionLevel, extraFolders }); + return folders.map((value) => { + const option: SelectableValue = { value, label: value.title }; + return option; + }); +} + +export function findOptionWithId( + options?: Array>, + id?: number +): SelectableValue | undefined { + return options?.find((o) => o.value?.id === id); +} + +export async function getFolderAsOption(folderId?: number): Promise | undefined> { + if (folderId === undefined || folderId === null) { + return; + } + + try { + const { id, title } = await getFolderById(folderId); + return { value: { id, title }, label: title }; + } catch (err) { + console.error(`Could not find folder with id:${folderId}`); + } + + return; +} diff --git a/public/app/core/components/Select/ReadonlyFolderPicker/types.ts b/public/app/core/components/Select/ReadonlyFolderPicker/types.ts new file mode 100644 index 00000000000..f01bb8a8a58 --- /dev/null +++ b/public/app/core/components/Select/ReadonlyFolderPicker/types.ts @@ -0,0 +1,3 @@ +import { PermissionLevelString } from '../../../../types'; + +export type PermissionLevel = Exclude; diff --git a/public/app/features/search/constants.ts b/public/app/features/search/constants.ts index 5038d201921..edb07eeb01a 100644 --- a/public/app/features/search/constants.ts +++ b/public/app/features/search/constants.ts @@ -5,3 +5,4 @@ export const SEARCH_ITEM_MARGIN = 8; export const DEFAULT_SORT = { label: 'A\u2013Z', value: 'alpha-asc' }; export const SECTION_STORAGE_KEY = 'search.sections'; export const GENERAL_FOLDER_ID = 0; +export const GENERAL_FOLDER_TITLE = 'General'; diff --git a/public/app/plugins/panel/alertlist/module.tsx b/public/app/plugins/panel/alertlist/module.tsx index ba2f5b7fa4a..abea5ee8835 100644 --- a/public/app/plugins/panel/alertlist/module.tsx +++ b/public/app/plugins/panel/alertlist/module.tsx @@ -3,11 +3,15 @@ import { PanelPlugin } from '@grafana/data'; import { TagsInput } from '@grafana/ui'; import { AlertList } from './AlertList'; import { UnifiedAlertList } from './UnifiedAlertList'; -import { FolderPicker } from 'app/core/components/Select/FolderPicker'; -import { AlertListOptions, UnifiedAlertListOptions, ShowOption, SortOrder } from './types'; +import { AlertListOptions, ShowOption, SortOrder, UnifiedAlertListOptions } from './types'; import { alertListPanelMigrationHandler } from './AlertListMigrationHandler'; import { config } from '@grafana/runtime'; import { RuleFolderPicker } from 'app/features/alerting/unified/components/rule-editor/RuleFolderPicker'; +import { + ALL_FOLDER, + GENERAL_FOLDER, + ReadonlyFolderPicker, +} from '../../../core/components/Select/ReadonlyFolderPicker/ReadonlyFolderPicker'; function showIfCurrentState(options: AlertListOptions) { return options.showOptions === ShowOption.Current; @@ -74,13 +78,12 @@ const alertList = new PanelPlugin(AlertList) name: 'Folder', id: 'folderId', defaultValue: null, - editor: function RenderFolderPicker(props) { + editor: function RenderFolderPicker({ value, onChange }) { return ( - props.onChange(id)} + onChange(folder?.id)} + extraFolders={[ALL_FOLDER, GENERAL_FOLDER]} /> ); }, diff --git a/public/app/plugins/panel/dashlist/module.tsx b/public/app/plugins/panel/dashlist/module.tsx index e4c233c5468..f57f224f948 100644 --- a/public/app/plugins/panel/dashlist/module.tsx +++ b/public/app/plugins/panel/dashlist/module.tsx @@ -1,10 +1,13 @@ import { PanelModel, PanelPlugin } from '@grafana/data'; import { DashList } from './DashList'; import { DashListOptions } from './types'; -import { FolderPicker } from 'app/core/components/Select/FolderPicker'; import React from 'react'; import { TagsInput } from '@grafana/ui'; -import { PermissionLevelString } from '../../../types'; +import { + ALL_FOLDER, + GENERAL_FOLDER, + ReadonlyFolderPicker, +} from '../../../core/components/Select/ReadonlyFolderPicker/ReadonlyFolderPicker'; export const plugin = new PanelPlugin(DashList) .setPanelOptions((builder) => { @@ -43,15 +46,13 @@ export const plugin = new PanelPlugin(DashList) path: 'folderId', name: 'Folder', id: 'folderId', - defaultValue: null, - editor: function RenderFolderPicker(props) { + defaultValue: undefined, + editor: function RenderFolderPicker({ value, onChange }) { return ( - props.onChange(id)} + onChange(folder?.id)} + extraFolders={[ALL_FOLDER, GENERAL_FOLDER]} /> ); },