DashboardList/AlertList: Fix for missing All folder value (#39772)

* DashboardList/AlertList: Fix for missing All folder value

* Refactor: Fixes case where folder does not exist in results
This commit is contained in:
Hugo Häggmark 2021-10-01 06:20:25 +02:00 committed by GitHub
parent 9de633d3a3
commit 7c7b21b39e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 440 additions and 18 deletions

View File

@ -200,6 +200,9 @@ export const Components = {
FolderPicker: { FolderPicker: {
container: 'Folder picker select container', container: 'Folder picker select container',
}, },
ReadonlyFolderPicker: {
container: 'data-testid Readonly folder picker select container',
},
DataSourcePicker: { DataSourcePicker: {
container: 'Data source picker select container', container: 'Data source picker select container',
input: () => 'input[id="data-source-picker"]', input: () => 'input[id="data-source-picker"]',

View File

@ -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<ReadonlyFolderPickerProps> = {},
folders: Array<SelectableValue<FolderInfo>> = [],
folder: SelectableValue<FolderInfo> | 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(<ReadonlyFolderPicker {...props} />);
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);
});
});
});
});

View File

@ -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<SelectableValue<FolderInfo> | undefined>(undefined);
const [options, setOptions] = useState<Array<SelectableValue<FolderInfo>> | undefined>(undefined);
const initialize = useCallback(
async (options: Array<SelectableValue<FolderInfo>>) => {
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<FolderInfo>) => {
const option = findOptionWithId(options, value?.id);
setOption(option);
propsOnChange(value);
},
[options, propsOnChange]
);
return (
<div data-testid={selectors.components.ReadonlyFolderPicker.container}>
<AsyncSelect
menuShouldPortal
loadingMessage="Loading folders..."
defaultOptions
defaultValue={option}
value={option}
loadOptions={debouncedLoadOptions}
onChange={onChange}
/>
</div>
);
}

View File

@ -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();
});
});
});

View File

@ -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<FolderInfo[]> {
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<FolderInfo[]> {
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<FolderInfo> = { value, label: value.title };
return option;
});
}
export function findOptionWithId(
options?: Array<SelectableValue<FolderInfo>>,
id?: number
): SelectableValue<FolderInfo> | undefined {
return options?.find((o) => o.value?.id === id);
}
export async function getFolderAsOption(folderId?: number): Promise<SelectableValue<FolderInfo> | 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;
}

View File

@ -0,0 +1,3 @@
import { PermissionLevelString } from '../../../../types';
export type PermissionLevel = Exclude<PermissionLevelString, PermissionLevelString.Admin>;

View File

@ -5,3 +5,4 @@ export const SEARCH_ITEM_MARGIN = 8;
export const DEFAULT_SORT = { label: 'A\u2013Z', value: 'alpha-asc' }; export const DEFAULT_SORT = { label: 'A\u2013Z', value: 'alpha-asc' };
export const SECTION_STORAGE_KEY = 'search.sections'; export const SECTION_STORAGE_KEY = 'search.sections';
export const GENERAL_FOLDER_ID = 0; export const GENERAL_FOLDER_ID = 0;
export const GENERAL_FOLDER_TITLE = 'General';

View File

@ -3,11 +3,15 @@ import { PanelPlugin } from '@grafana/data';
import { TagsInput } from '@grafana/ui'; import { TagsInput } from '@grafana/ui';
import { AlertList } from './AlertList'; import { AlertList } from './AlertList';
import { UnifiedAlertList } from './UnifiedAlertList'; import { UnifiedAlertList } from './UnifiedAlertList';
import { FolderPicker } from 'app/core/components/Select/FolderPicker'; import { AlertListOptions, ShowOption, SortOrder, UnifiedAlertListOptions } from './types';
import { AlertListOptions, UnifiedAlertListOptions, ShowOption, SortOrder } from './types';
import { alertListPanelMigrationHandler } from './AlertListMigrationHandler'; import { alertListPanelMigrationHandler } from './AlertListMigrationHandler';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { RuleFolderPicker } from 'app/features/alerting/unified/components/rule-editor/RuleFolderPicker'; 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) { function showIfCurrentState(options: AlertListOptions) {
return options.showOptions === ShowOption.Current; return options.showOptions === ShowOption.Current;
@ -74,13 +78,12 @@ const alertList = new PanelPlugin<AlertListOptions>(AlertList)
name: 'Folder', name: 'Folder',
id: 'folderId', id: 'folderId',
defaultValue: null, defaultValue: null,
editor: function RenderFolderPicker(props) { editor: function RenderFolderPicker({ value, onChange }) {
return ( return (
<FolderPicker <ReadonlyFolderPicker
initialFolderId={props.value} initialFolderId={value}
initialTitle="All" onChange={(folder) => onChange(folder?.id)}
enableReset={true} extraFolders={[ALL_FOLDER, GENERAL_FOLDER]}
onChange={({ id }) => props.onChange(id)}
/> />
); );
}, },

View File

@ -1,10 +1,13 @@
import { PanelModel, PanelPlugin } from '@grafana/data'; import { PanelModel, PanelPlugin } from '@grafana/data';
import { DashList } from './DashList'; import { DashList } from './DashList';
import { DashListOptions } from './types'; import { DashListOptions } from './types';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import React from 'react'; import React from 'react';
import { TagsInput } from '@grafana/ui'; 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<DashListOptions>(DashList) export const plugin = new PanelPlugin<DashListOptions>(DashList)
.setPanelOptions((builder) => { .setPanelOptions((builder) => {
@ -43,15 +46,13 @@ export const plugin = new PanelPlugin<DashListOptions>(DashList)
path: 'folderId', path: 'folderId',
name: 'Folder', name: 'Folder',
id: 'folderId', id: 'folderId',
defaultValue: null, defaultValue: undefined,
editor: function RenderFolderPicker(props) { editor: function RenderFolderPicker({ value, onChange }) {
return ( return (
<FolderPicker <ReadonlyFolderPicker
initialFolderId={props.value} initialFolderId={value}
initialTitle="All" onChange={(folder) => onChange(folder?.id)}
enableReset={true} extraFolders={[ALL_FOLDER, GENERAL_FOLDER]}
permissionLevel={PermissionLevelString.View}
onChange={({ id }) => props.onChange(id)}
/> />
); );
}, },