From 6093afddd4991a390030000f8c43eaf222849cf4 Mon Sep 17 00:00:00 2001 From: Tom Ratcliffe Date: Wed, 29 Oct 2025 11:21:58 +0000 Subject: [PATCH] Folders: Migrate FolderFilter component to ComboBox (#113047) --- .github/CODEOWNERS | 2 +- .../src/handlers/api/search/handlers.ts | 7 +- .../FolderFilter/FolderFilter.test.tsx | 46 +++++++ .../components/FolderFilter/FolderFilter.tsx | 44 ++---- .../LibraryPanelsSearch.test.tsx | 129 +++++++----------- .../LibraryPanelsSearch.tsx | 3 +- public/locales/en-US/grafana.json | 1 - 7 files changed, 122 insertions(+), 110 deletions(-) create mode 100644 public/app/core/components/FolderFilter/FolderFilter.test.tsx diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 853f27774d4..ea0e83e3baa 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -798,7 +798,7 @@ playwright.storybook.config.ts @grafana/grafana-frontend-platform /public/app/core/components/ColorScale/ @grafana/dataviz-squad /public/app/core/components/DynamicImports/ @grafana/grafana-search-navigate-organise /public/app/core/components/EmptyListCTA/ @grafana/grafana-frontend-platform -/public/app/core/components/FolderFilter/ @grafana/sharing-squad +/public/app/core/components/FolderFilter/ @grafana/grafana-search-navigate-organise /public/app/core/components/Footer/ @grafana/grafana-search-navigate-organise /public/app/core/components/ForgottenPassword/ @grafana/grafana-search-navigate-organise /public/app/core/components/Form/ @grafana/grafana-frontend-platform diff --git a/packages/grafana-test-utils/src/handlers/api/search/handlers.ts b/packages/grafana-test-utils/src/handlers/api/search/handlers.ts index 2aebd03b174..d9fa090fbf1 100644 --- a/packages/grafana-test-utils/src/handlers/api/search/handlers.ts +++ b/packages/grafana-test-utils/src/handlers/api/search/handlers.ts @@ -17,12 +17,17 @@ const slugify = (str: string) => { .replace(/ +/g, '-'); }; +const typeFilterMap: Record = { + 'dash-db': 'dashboard', + 'dash-folder': 'folder', +}; + const getLegacySearchHandler = () => http.get('/api/search', ({ request }) => { const folderFilter = new URL(request.url).searchParams.get('folderUIDs') || null; const typeFilter = new URL(request.url).searchParams.get('type') || null; // Workaround for the fixture kind being 'dashboard' instead of 'dash-db' - const mappedTypeFilter = typeFilter === 'dash-db' ? 'dashboard' : typeFilter; + const mappedTypeFilter = typeFilter ? typeFilterMap[typeFilter] || typeFilter : null; const starredFilter = new URL(request.url).searchParams.get('starred') || null; const tagFilter = new URL(request.url).searchParams.getAll('tag') || null; diff --git a/public/app/core/components/FolderFilter/FolderFilter.test.tsx b/public/app/core/components/FolderFilter/FolderFilter.test.tsx new file mode 100644 index 00000000000..06f0cf62a2a --- /dev/null +++ b/public/app/core/components/FolderFilter/FolderFilter.test.tsx @@ -0,0 +1,46 @@ +import { comboboxTestSetup } from 'test/helpers/comboboxTestSetup'; +import { render, screen, testWithFeatureToggles } from 'test/test-utils'; + +import { setBackendSrv } from '@grafana/runtime'; +import { setupMockServer } from '@grafana/test-utils/server'; +import { getFolderFixtures } from '@grafana/test-utils/unstable'; +import { backendSrv } from 'app/core/services/backend_srv'; +import { resetGrafanaSearcher } from 'app/features/search/service/searcher'; + +import { FolderFilter } from './FolderFilter'; +const [_, { folderA, folderB }] = getFolderFixtures(); + +setBackendSrv(backendSrv); +setupMockServer(); +comboboxTestSetup(); + +const fixtures: Array< + [ + // Test title + string, + // Feature toggle setup + Parameters[0], + ] +> = [ + ['app platform APIs enabled', { enable: ['unifiedStorageSearchUI'] }], + ['app platform APIs disabled', {}], +]; + +describe.each(fixtures)('FolderFilter - %s', (_title, featureToggleSetup) => { + beforeEach(() => { + resetGrafanaSearcher(); + }); + + testWithFeatureToggles(featureToggleSetup); + + it('allows selecting folders', async () => { + const onChange = jest.fn(); + const { user } = render(); + + await user.click(screen.getByPlaceholderText('Filter by folder')); + + await user.click(await screen.findByText(folderA.item.title)); + await user.click(await screen.findByText(folderB.item.title)); + expect(onChange).toHaveBeenCalledWith([folderA.item.uid, folderB.item.uid]); + }); +}); diff --git a/public/app/core/components/FolderFilter/FolderFilter.tsx b/public/app/core/components/FolderFilter/FolderFilter.tsx index c98490ea0c7..1f8f3e5557d 100644 --- a/public/app/core/components/FolderFilter/FolderFilter.tsx +++ b/public/app/core/components/FolderFilter/FolderFilter.tsx @@ -1,25 +1,17 @@ -import debounce from 'debounce-promise'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useState } from 'react'; -import { SelectableValue } from '@grafana/data'; import { t } from '@grafana/i18n'; -import { AsyncMultiSelect, Icon } from '@grafana/ui'; +import { ComboboxOption, MultiCombobox } from '@grafana/ui'; import { getGrafanaSearcher } from 'app/features/search/service/searcher'; -import { FolderInfo } from 'app/types/folders'; export interface FolderFilterProps { - onChange: (folder: FolderInfo[]) => void; - maxMenuHeight?: number; + onChange: (folder: string[]) => void; } -export function FolderFilter({ onChange, maxMenuHeight }: FolderFilterProps): JSX.Element { - const [loading, setLoading] = useState(false); - const getOptions = useCallback((searchString: string) => getFoldersAsOptions(searchString, setLoading), []); - const debouncedLoadOptions = useMemo(() => debounce(getOptions, 300), [getOptions]); - - const [value, setValue] = useState>>([]); +export function FolderFilter({ onChange }: FolderFilterProps): JSX.Element { + const [value, setValue] = useState([]); const onSelectOptionChange = useCallback( - (folders: Array>) => { + (folders: ComboboxOption[]) => { const changedFolderIds = folders.filter((f) => Boolean(f.value)).map((f) => f.value!); onChange(changedFolderIds); setValue(folders); @@ -28,26 +20,21 @@ export function FolderFilter({ onChange, maxMenuHeight }: FolderFilterProps): JS ); return ( - } aria-label={t('folder-filter.select-aria-label', 'Folder filter')} - defaultOptions /> ); } -async function getFoldersAsOptions( - searchString: string, - setLoading: (loading: boolean) => void -): Promise>> { - setLoading(true); +async function getFoldersAsOptions(searchString: string) { // Use searcher as it will handle the logic for using the appropriate API const searcher = getGrafanaSearcher(); const queryResponse = await searcher.search({ @@ -59,13 +46,12 @@ async function getFoldersAsOptions( const options = queryResponse.view.map((item) => ({ label: item.name, - value: { uid: item.uid, title: item.name }, + value: item.uid, })); if (!searchString || 'dashboards'.includes(searchString.toLowerCase())) { - options.unshift({ label: 'Dashboards', value: { uid: 'general', title: 'Dashboards' } }); + options.unshift({ label: 'Dashboards', value: 'general' }); } - setLoading(false); return options; } diff --git a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx index bb938595b72..7cb1a99a864 100644 --- a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx +++ b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.test.tsx @@ -1,10 +1,9 @@ -import { render, screen, waitFor, within } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { render, screen, waitFor, within } from 'test/test-utils'; import { PanelPluginMeta, PluginMetaInfo, PluginType } from '@grafana/data'; -import { config } from '@grafana/runtime'; +import { setBackendSrv } from '@grafana/runtime'; import { Panel } from '@grafana/schema'; -import { getGrafanaSearcher } from 'app/features/search/service/searcher'; +import { setupMockServer } from '@grafana/test-utils/server'; import { backendSrv } from '../../../../core/services/backend_srv'; import * as panelUtils from '../../../panel/state/util'; @@ -13,6 +12,29 @@ import { LibraryElementsSearchResult } from '../../types'; import { LibraryPanelsSearch, LibraryPanelsSearchProps } from './LibraryPanelsSearch'; +setBackendSrv(backendSrv); +setupMockServer(); + +const pluginInfo = { logos: { small: '', large: '' } } as PluginMetaInfo; +const graph: PanelPluginMeta = { + name: 'Graph', + id: 'graph', + info: pluginInfo, + baseUrl: '', + type: PluginType.panel, + module: '', + sort: 0, +}; +const timeseries: PanelPluginMeta = { + name: 'Time Series', + id: 'timeseries', + info: pluginInfo, + baseUrl: '', + type: PluginType.panel, + module: '', + sort: 1, +}; + jest.mock('@grafana/runtime', () => ({ ...jest.requireActual('@grafana/runtime'), config: { @@ -26,79 +48,35 @@ jest.mock('@grafana/runtime', () => ({ }, })); -jest.mock('debounce-promise', () => { - const debounce = () => { - const debounced = () => - Promise.resolve([ - { label: 'Dashboards', value: { uid: '', title: 'Dashboards' } }, - { label: 'Folder1', value: { id: 'xMsQdBfWz', title: 'Folder1' } }, - { label: 'Folder2', value: { id: 'wfTJJL5Wz', title: 'Folder2' } }, - ]); - return debounced; - }; - - return debounce; -}); - +const getLibraryPanelsSpy = jest.spyOn(api, 'getLibraryPanels'); jest.spyOn(api, 'getConnectedDashboards').mockResolvedValue([]); jest.spyOn(api, 'deleteLibraryPanel').mockResolvedValue({ message: 'success' }); +jest.spyOn(panelUtils, 'getAllPanelPluginMeta').mockReturnValue([graph, timeseries]); + async function getTestContext( propOverrides: Partial = {}, searchResult: LibraryElementsSearchResult = { elements: [], perPage: 40, page: 1, totalCount: 0 } ) { - jest.clearAllMocks(); - const pluginInfo = { logos: { small: '', large: '' } } as PluginMetaInfo; - const graph: PanelPluginMeta = { - name: 'Graph', - id: 'graph', - info: pluginInfo, - baseUrl: '', - type: PluginType.panel, - module: '', - sort: 0, - }; - const timeseries: PanelPluginMeta = { - name: 'Time Series', - id: 'timeseries', - info: pluginInfo, - baseUrl: '', - type: PluginType.panel, - module: '', - sort: 1, - }; - - config.featureToggles = { panelTitleSearch: false }; - const getSpy = jest.spyOn(backendSrv, 'get'); - - jest.spyOn(getGrafanaSearcher(), 'getSortOptions').mockResolvedValue([ - { - label: 'Alphabetically (A–Z)', - value: 'alpha-asc', - }, - { - label: 'Alphabetically (Z–A)', - value: 'alpha-desc', - }, - ]); - - const getLibraryPanelsSpy = jest.spyOn(api, 'getLibraryPanels').mockResolvedValue(searchResult); - const getAllPanelPluginMetaSpy = jest.spyOn(panelUtils, 'getAllPanelPluginMeta').mockReturnValue([graph, timeseries]); + getLibraryPanelsSpy.mockResolvedValue(searchResult); const props: LibraryPanelsSearchProps = { onClick: jest.fn(), }; Object.assign(props, propOverrides); - const { rerender } = render(); + const view = render(); await waitFor(() => expect(getLibraryPanelsSpy).toHaveBeenCalled()); expect(getLibraryPanelsSpy).toHaveBeenCalledTimes(1); - jest.clearAllMocks(); - return { rerender, getLibraryPanelsSpy, getSpy, getAllPanelPluginMetaSpy }; + return view; } describe('LibraryPanelsSearch', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + describe('when mounted with default options', () => { it('should show input filter and library panels view', async () => { await getTestContext(); @@ -109,9 +87,9 @@ describe('LibraryPanelsSearch', () => { describe('and user searches for library panel by name or description', () => { it('should call api with correct params', async () => { - const { getLibraryPanelsSpy } = await getTestContext(); + const { user } = await getTestContext(); - await userEvent.type(screen.getByPlaceholderText(/search by name/i), 'a'); + await user.type(screen.getByPlaceholderText(/search by name/i), 'a'); await waitFor(() => expect(getLibraryPanelsSpy).toHaveBeenCalled()); await waitFor(() => expect(getLibraryPanelsSpy).toHaveBeenCalledWith({ @@ -138,9 +116,9 @@ describe('LibraryPanelsSearch', () => { describe('and user changes sorting', () => { it('should call api with correct params', async () => { - const { getLibraryPanelsSpy } = await getTestContext({ showSort: true }); + const { user } = await getTestContext({ showSort: true }); - await userEvent.type(screen.getByText(/sort \(default a–z\)/i), 'Desc{enter}'); + await user.type(screen.getByText(/sort \(default a–z\)/i), 'Desc{enter}'); await waitFor(() => expect(getLibraryPanelsSpy).toHaveBeenCalledWith({ searchString: '', @@ -167,10 +145,10 @@ describe('LibraryPanelsSearch', () => { describe('and user changes panel filter', () => { it('should call api with correct params', async () => { - const { getLibraryPanelsSpy } = await getTestContext({ showPanelFilter: true }); + const { user } = await getTestContext({ showPanelFilter: true }); - await userEvent.type(screen.getByRole('combobox', { name: /panel type filter/i }), 'Graph{enter}'); - await userEvent.type(screen.getByRole('combobox', { name: /panel type filter/i }), 'Time Series{enter}'); + await user.type(screen.getByRole('combobox', { name: /panel type filter/i }), 'Graph{enter}'); + await user.type(screen.getByRole('combobox', { name: /panel type filter/i }), 'Time Series{enter}'); await waitFor(() => expect(getLibraryPanelsSpy).toHaveBeenCalledWith({ searchString: '', @@ -191,12 +169,12 @@ describe('LibraryPanelsSearch', () => { expect(screen.getByPlaceholderText(/search by name/i)).toBeInTheDocument(); expect(screen.getByText(/you haven\'t created any library panels yet/i)).toBeInTheDocument(); - expect(screen.getByRole('combobox', { name: /folder filter/i })).toBeInTheDocument(); + expect(screen.getByPlaceholderText('Filter by folder')).toBeInTheDocument(); }); describe('and user changes folder filter', () => { it('should call api with correct params', async () => { - const { getLibraryPanelsSpy } = await getTestContext( + const { user } = await getTestContext( { showFolderFilter: true, currentFolderUID: 'wXyZ1234' }, { elements: [ @@ -225,8 +203,7 @@ describe('LibraryPanelsSearch', () => { } ); - await userEvent.click(screen.getByRole('combobox', { name: /folder filter/i })); - await userEvent.type(screen.getByRole('combobox', { name: /folder filter/i }), 'library', { + await user.type(screen.getByPlaceholderText('Filter by folder'), 'library', { skipClick: true, }); @@ -329,7 +306,7 @@ describe('LibraryPanelsSearch', () => { describe('when mounted with showSecondaryActions and a specific folder', () => { describe('and user deletes a panel', () => { it('should call api with correct params', async () => { - const { getLibraryPanelsSpy } = await getTestContext( + const { user } = await getTestContext( { showSecondaryActions: true, currentFolderUID: 'wfTJJL5Wz' }, { elements: [ @@ -358,11 +335,11 @@ describe('LibraryPanelsSearch', () => { } ); - await userEvent.click(screen.getByLabelText('Delete')); - await waitFor(() => expect(screen.getByText('Do you want to delete this panel?')).toBeInTheDocument()); - await userEvent.click(screen.getAllByRole('button', { name: 'Delete' })[1]); + await user.click(screen.getByLabelText('Delete')); + await screen.findByText('Do you want to delete this panel?'); + await user.click(screen.getAllByRole('button', { name: 'Delete' })[1]); - await waitFor(() => { + await waitFor(() => expect(getLibraryPanelsSpy).toHaveBeenCalledWith({ searchString: '', folderFilterUIDs: ['wfTJJL5Wz'], @@ -370,8 +347,8 @@ describe('LibraryPanelsSearch', () => { typeFilter: [], sortDirection: undefined, perPage: 40, - }); - }); + }) + ); }); }); }); diff --git a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.tsx b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.tsx index 151752b2ede..27a6635fd3f 100644 --- a/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.tsx +++ b/public/app/features/library-panels/components/LibraryPanelsSearch/LibraryPanelsSearch.tsx @@ -5,7 +5,6 @@ import { useDebounce } from 'react-use'; import { GrafanaTheme2, PanelPluginMeta, SelectableValue } from '@grafana/data'; import { t } from '@grafana/i18n'; import { useStyles2, Stack, FilterInput } from '@grafana/ui'; -import { FolderInfo } from 'app/types/folders'; import { FolderFilter } from '../../../../core/components/FolderFilter/FolderFilter'; import { PanelTypeFilter } from '../../../../core/components/PanelTypeFilter/PanelTypeFilter'; @@ -162,7 +161,7 @@ const SearchControls = memo( [onPanelFilterChange] ); const folderFilterChanged = useCallback( - (folders: FolderInfo[]) => onFolderFilterChange(folders.map((f) => f.uid ?? '')), + (folders: string[]) => onFolderFilterChange(folders), [onFolderFilterChange] ); diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index c458e08fe59..e8c29e9ffe7 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -7658,7 +7658,6 @@ } }, "folder-filter": { - "noOptionsMessage-no-folders-found": "No folders found", "select-aria-label": "Folder filter", "select-placeholder": "Filter by folder" },