Folders: Migrate FolderFilter component to ComboBox (#113047)
This commit is contained in:
+1
-1
@@ -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
|
||||
|
||||
@@ -17,12 +17,17 @@ const slugify = (str: string) => {
|
||||
.replace(/ +/g, '-');
|
||||
};
|
||||
|
||||
const typeFilterMap: Record<string, string> = {
|
||||
'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;
|
||||
|
||||
|
||||
@@ -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<typeof testWithFeatureToggles>[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(<FolderFilter onChange={onChange} />);
|
||||
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -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<Array<SelectableValue<FolderInfo>>>([]);
|
||||
export function FolderFilter({ onChange }: FolderFilterProps): JSX.Element {
|
||||
const [value, setValue] = useState<ComboboxOption[]>([]);
|
||||
const onSelectOptionChange = useCallback(
|
||||
(folders: Array<SelectableValue<FolderInfo>>) => {
|
||||
(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 (
|
||||
<AsyncMultiSelect
|
||||
<MultiCombobox
|
||||
prefixIcon="filter"
|
||||
minWidth={40}
|
||||
width="auto"
|
||||
options={getFoldersAsOptions}
|
||||
value={value}
|
||||
onChange={onSelectOptionChange}
|
||||
isLoading={loading}
|
||||
loadOptions={debouncedLoadOptions}
|
||||
maxMenuHeight={maxMenuHeight}
|
||||
isClearable
|
||||
placeholder={t('folder-filter.select-placeholder', 'Filter by folder')}
|
||||
noOptionsMessage={t('folder-filter.noOptionsMessage-no-folders-found', 'No folders found')}
|
||||
prefix={<Icon name="filter" />}
|
||||
aria-label={t('folder-filter.select-aria-label', 'Folder filter')}
|
||||
defaultOptions
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
async function getFoldersAsOptions(
|
||||
searchString: string,
|
||||
setLoading: (loading: boolean) => void
|
||||
): Promise<Array<SelectableValue<FolderInfo>>> {
|
||||
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;
|
||||
}
|
||||
|
||||
+53
-76
@@ -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<LibraryPanelsSearchProps> = {},
|
||||
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(<LibraryPanelsSearch {...props} />);
|
||||
const view = render(<LibraryPanelsSearch {...props} />);
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+1
-2
@@ -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]
|
||||
);
|
||||
|
||||
|
||||
@@ -7658,7 +7658,6 @@
|
||||
}
|
||||
},
|
||||
"folder-filter": {
|
||||
"noOptionsMessage-no-folders-found": "No folders found",
|
||||
"select-aria-label": "Folder filter",
|
||||
"select-placeholder": "Filter by folder"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user