diff --git a/.betterer.results b/.betterer.results index b67988c0cca..6e44507467f 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2725,10 +2725,6 @@ exports[`better eslint`] = { "public/app/core/components/RolePicker/RolePickerMenu.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"] ], - "public/app/core/components/Select/FolderPicker.tsx:5381": [ - [0, 0, 0, "Unexpected any. Specify a different type.", "0"], - [0, 0, 0, "Unexpected any. Specify a different type.", "1"] - ], "public/app/core/components/Select/MetricSelect.test.tsx:5381": [ [0, 0, 0, "Unexpected any. Specify a different type.", "0"], [0, 0, 0, "Unexpected any. Specify a different type.", "1"] diff --git a/public/app/core/components/Select/FolderPicker.tsx b/public/app/core/components/Select/FolderPicker.tsx index 20a584061ad..8f50e381094 100644 --- a/public/app/core/components/Select/FolderPicker.tsx +++ b/public/app/core/components/Select/FolderPicker.tsx @@ -1,25 +1,28 @@ +import { css } from '@emotion/css'; import { t } from '@lingui/macro'; import { debounce } from 'lodash'; -import React, { PureComponent } from 'react'; +import React, { useState, useEffect, useMemo, useCallback, FormEvent } from 'react'; +import { useAsync } from 'react-use'; -import { AppEvents, SelectableValue } from '@grafana/data'; +import { AppEvents, SelectableValue, GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { ActionMeta, AsyncSelect, LoadOptionsCallback } from '@grafana/ui'; +import { useStyles2, ActionMeta, AsyncSelect, Input, InputActionMeta } from '@grafana/ui'; +import appEvents from 'app/core/app_events'; import { contextSrv } from 'app/core/services/context_srv'; import { createFolder, getFolderById, searchFolders } from 'app/features/manage-dashboards/state/actions'; import { DashboardSearchHit } from 'app/features/search/types'; - -import { AccessControlAction, PermissionLevelString } from '../../../types'; -import appEvents from '../../app_events'; +import { AccessControlAction, PermissionLevelString } from 'app/types'; export type FolderPickerFilter = (hits: DashboardSearchHit[]) => DashboardSearchHit[]; +export const ADD_NEW_FOLER_OPTION = '+ Add new'; + export interface Props { onChange: ($folder: { title: string; id: number }) => void; enableCreateNew?: boolean; rootName?: string; enableReset?: boolean; - dashboardId?: any; + dashboardId?: number | string; initialTitle?: string; initialFolderId?: number; permissionLevel?: Exclude; @@ -28,6 +31,7 @@ export interface Props { showRoot?: boolean; onClear?: () => void; accessControlMetadata?: boolean; + customAdd?: boolean; /** * Skips loading all folders in order to find the folder matching * the folder where the dashboard is stored. @@ -38,139 +42,84 @@ export interface Props { /** The id of the search input. Use this to set a matching label with htmlFor */ inputId?: string; } +export type SelectedFolder = SelectableValue; +const VALUE_FOR_ADD = -10; -interface State { - folder: SelectableValue | null; -} +export function FolderPicker(props: Props) { + const { + dashboardId, + allowEmpty, + onChange, + filter, + enableCreateNew, + inputId, + onClear, + enableReset, + initialFolderId, + initialTitle, + permissionLevel, + rootName, + showRoot, + skipInitialLoad, + accessControlMetadata, + customAdd, + } = props; + const isClearable = typeof onClear === 'function'; + const [folder, setFolder] = useState(null); + const [isCreatingNew, setIsCreatingNew] = useState(false); + const styles = useStyles2(getStyles); + const [inputValue, setInputValue] = useState(''); -export class FolderPicker extends PureComponent { - debouncedSearch: any; + const getOptions = useCallback( + async (query: string) => { + const searchHits = await searchFolders(query, permissionLevel, accessControlMetadata); + const options: Array> = mapSearchHitsToOptions(searchHits, filter); - constructor(props: Props) { - super(props); + const hasAccess = + contextSrv.hasAccess(AccessControlAction.DashboardsWrite, contextSrv.isEditor) || + contextSrv.hasAccess(AccessControlAction.DashboardsCreate, contextSrv.isEditor); - this.state = { - folder: null, - }; + if (hasAccess && rootName?.toLowerCase().startsWith(query.toLowerCase()) && showRoot) { + options.unshift({ label: rootName, value: 0 }); + } - this.debouncedSearch = debounce(this.loadOptions, 300, { - leading: true, - trailing: true, - }); - } - - static defaultProps: Partial = { - rootName: 'General', - enableReset: false, - initialTitle: '', - enableCreateNew: false, - permissionLevel: PermissionLevelString.Edit, - allowEmpty: false, - showRoot: true, - }; - - componentDidMount = async () => { - if (this.props.skipInitialLoad) { - const folder = await getInitialValues({ - getFolder: getFolderById, - folderId: this.props.initialFolderId, - folderName: this.props.initialTitle, - }); - this.setState({ folder }); - return; - } - - await this.loadInitialValue(); - }; - - // when debouncing, we must use the callback form of react-select's loadOptions so we don't - // drop results for user input. This must not return a promise/use await. - loadOptions = (query: string, callback: LoadOptionsCallback): void => { - this.searchFolders(query).then(callback); - }; - - private searchFolders = async (query: string) => { - const { - rootName, + if ( + enableReset && + query === '' && + initialTitle !== '' && + !options.find((option) => option.label === initialTitle) + ) { + Boolean(initialTitle) && options.unshift({ label: initialTitle, value: initialFolderId }); + } + if (enableCreateNew && customAdd) { + return [...options, { value: VALUE_FOR_ADD, label: ADD_NEW_FOLER_OPTION, title: query }]; + } else { + return options; + } + }, + [ enableReset, + initialFolderId, initialTitle, permissionLevel, - filter, - accessControlMetadata, - initialFolderId, + rootName, showRoot, - } = this.props; + accessControlMetadata, + filter, + enableCreateNew, + customAdd, + ] + ); - const searchHits = await searchFolders(query, permissionLevel, accessControlMetadata); - const options: Array> = mapSearchHitsToOptions(searchHits, filter); + const debouncedSearch = useMemo(() => { + return debounce(getOptions, 300, { leading: true }); + }, [getOptions]); - const hasAccess = - contextSrv.hasAccess(AccessControlAction.DashboardsWrite, contextSrv.isEditor) || - contextSrv.hasAccess(AccessControlAction.DashboardsCreate, contextSrv.isEditor); - - if (hasAccess && rootName?.toLowerCase().startsWith(query.toLowerCase()) && showRoot) { - options.unshift({ label: rootName, value: 0 }); - } - - if ( - enableReset && - query === '' && - initialTitle !== '' && - !options.find((option) => option.label === initialTitle) - ) { - options.unshift({ label: initialTitle, value: initialFolderId }); - } - - return options; - }; - - onFolderChange = (newFolder: SelectableValue, actionMeta: ActionMeta) => { - if (!newFolder) { - newFolder = { value: 0, label: this.props.rootName }; - } - - if (actionMeta.action === 'clear' && this.props.onClear) { - this.props.onClear(); - return; - } - - this.setState( - { - folder: newFolder, - }, - () => this.props.onChange({ id: newFolder.value!, title: newFolder.label! }) - ); - }; - - createNewFolder = async (folderName: string) => { - const newFolder = await createFolder({ title: folderName }); - let folder: SelectableValue = { value: -1, label: 'Not created' }; - - if (newFolder.id > -1) { - appEvents.emit(AppEvents.alertSuccess, ['Folder Created', 'OK']); - folder = { value: newFolder.id, label: newFolder.title }; - - this.setState( - { - folder: newFolder, - }, - () => { - this.onFolderChange(folder, { action: 'create-option', option: folder }); - } - ); - } else { - appEvents.emit(AppEvents.alertError, ['Folder could not be created']); - } - - return folder; - }; - - private loadInitialValue = async () => { - const { initialTitle, rootName, initialFolderId, enableReset, dashboardId } = this.props; + const loadInitialValue = async () => { const resetFolder: SelectableValue = { label: initialTitle, value: undefined }; const rootFolder: SelectableValue = { label: rootName, value: 0 }; - const options = await this.searchFolders(''); + const options = await getOptions(''); let folder: SelectableValue | null = null; @@ -182,7 +131,7 @@ export class FolderPicker extends PureComponent { folder = options.find((option) => option.id === initialFolderId) || null; } - if (!folder && !this.props.allowEmpty) { + if (!folder && !allowEmpty) { if (contextSrv.isEditor) { folder = rootFolder; } else { @@ -195,25 +144,137 @@ export class FolderPicker extends PureComponent { } } } - - this.setState( - { - folder, - }, - () => { - // if this is not the same as our initial value notify parent - if (folder && folder.value !== initialFolderId) { - this.props.onChange({ id: folder.value!, title: folder.label! }); - } - } - ); + !isCreatingNew && setFolder(folder); }; - render() { - const { folder } = this.state; - const { enableCreateNew, inputId, onClear } = this.props; - const isClearable = typeof onClear === 'function'; + useEffect(() => { + // if this is not the same as our initial value notify parent + if (folder && folder.value !== initialFolderId) { + !isCreatingNew && onChange({ id: folder.value!, title: folder.label! }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [folder, initialFolderId]); + // initial values for dropdown + useAsync(async () => { + if (skipInitialLoad) { + const folder = await getInitialValues({ + getFolder: getFolderById, + folderId: initialFolderId, + folderName: initialTitle, + }); + setFolder(folder); + } + + await loadInitialValue(); + }, [skipInitialLoad, initialFolderId, initialTitle]); + + useEffect(() => { + if (folder && folder.id === VALUE_FOR_ADD) { + setIsCreatingNew(true); + } + }, [folder]); + + const onFolderChange = useCallback( + (newFolder: SelectableValue, actionMeta: ActionMeta) => { + const value = newFolder.value; + if (value === VALUE_FOR_ADD) { + setFolder({ + id: VALUE_FOR_ADD, + title: inputValue, + }); + } else { + if (!newFolder) { + newFolder = { value: 0, label: rootName }; + } + + if (actionMeta.action === 'clear' && onClear) { + onClear(); + return; + } + + setFolder(newFolder); + onChange({ id: newFolder.value!, title: newFolder.label! }); + } + }, + [onChange, onClear, rootName, inputValue] + ); + + const createNewFolder = useCallback( + async (folderName: string) => { + const newFolder = await createFolder({ title: folderName }); + let folder: SelectableValue = { value: -1, label: 'Not created' }; + + if (newFolder.id > -1) { + appEvents.emit(AppEvents.alertSuccess, ['Folder Created', 'OK']); + folder = { value: newFolder.id, label: newFolder.title }; + + setFolder(newFolder); + onFolderChange(folder, { action: 'create-option', option: folder }); + } else { + appEvents.emit(AppEvents.alertError, ['Folder could not be created']); + } + + return folder; + }, + [onFolderChange] + ); + + const onKeyDown = useCallback( + (event: React.KeyboardEvent) => { + switch (event.key) { + case 'Enter': { + createNewFolder(folder?.title!); + setIsCreatingNew(false); + break; + } + case 'Escape': { + setFolder({ value: 0, label: rootName }); + setIsCreatingNew(false); + } + } + }, + [createNewFolder, folder, rootName] + ); + + const onNewFolderChange = (e: FormEvent) => { + const value = e.currentTarget.value; + setFolder({ id: -1, title: value }); + }; + + const onBlur = () => { + setFolder({ value: 0, label: rootName }); + setIsCreatingNew(false); + }; + + const onInputChange = (value: string, { action }: InputActionMeta) => { + if (action === 'input-change') { + setInputValue((ant) => value); + } + + if (action === 'menu-close') { + setInputValue((_) => value); + } + return; + }; + + if (isCreatingNew) { + return ( + <> + +
Press enter to create the new folder.
+ + ); + } else { return (
{ loadingMessage={t({ id: 'folder-picker.loading', message: 'Loading folders...' })} defaultOptions defaultValue={folder} + inputValue={inputValue} + onInputChange={onInputChange} value={folder} - allowCustomValue={enableCreateNew} - loadOptions={this.debouncedSearch} - onChange={this.onFolderChange} - onCreateOption={this.createNewFolder} + allowCustomValue={enableCreateNew && !customAdd} + loadOptions={debouncedSearch} + onChange={onFolderChange} + onCreateOption={createNewFolder} isClearable={isClearable} />
@@ -238,7 +301,6 @@ function mapSearchHitsToOptions(hits: DashboardSearchHit[], filter?: FolderPicke const filteredHits = filter ? filter(hits) : hits; return filteredHits.map((hit) => ({ label: hit.title, value: hit.id })); } - interface Args { getFolder: typeof getFolderById; folderId?: number; @@ -257,3 +319,11 @@ export async function getInitialValues({ folderName, folderId, getFolder }: Args const folderDto = await getFolder(folderId); return { label: folderDto.title, value: folderId }; } + +const getStyles = (theme: GrafanaTheme2) => ({ + newFolder: css` + color: ${theme.colors.warning.main}; + font-size: ${theme.typography.bodySmall.fontSize}; + padding-top: ${theme.spacing(1)}; + `, +}); diff --git a/public/app/features/alerting/unified/RuleEditor.test.tsx b/public/app/features/alerting/unified/RuleEditor.test.tsx index c986a3df1cc..140eeff15d8 100644 --- a/public/app/features/alerting/unified/RuleEditor.test.tsx +++ b/public/app/features/alerting/unified/RuleEditor.test.tsx @@ -1,4 +1,4 @@ -import { Matcher, render, waitFor, screen } from '@testing-library/react'; +import { Matcher, render, waitFor, screen, within } from '@testing-library/react'; import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event'; import React from 'react'; import { Provider } from 'react-redux'; @@ -7,7 +7,9 @@ import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; import { byLabelText, byRole, byTestId, byText } from 'testing-library-selector'; import { DataSourceInstanceSettings } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; import { locationService, setDataSourceSrv } from '@grafana/runtime'; +import { ADD_NEW_FOLER_OPTION } from 'app/core/components/Select/FolderPicker'; import { contextSrv } from 'app/core/services/context_srv'; import { DashboardSearchHit } from 'app/features/search/types'; import { configureStore } from 'app/store/configureStore'; @@ -79,6 +81,7 @@ const ui = { dataSource: byTestId('datasource-picker'), folder: byTestId('folder-picker'), namespace: byTestId('namespace-picker'), + folderContainer: byTestId(selectors.components.FolderPicker.containerV2), group: byTestId('group-picker'), annotationKey: (idx: number) => byTestId(`annotation-key-${idx}`), annotationValue: (idx: number) => byTestId(`annotation-value-${idx}`), @@ -480,6 +483,13 @@ describe('RuleEditor', () => { await userEvent.click(ui.buttons.save.get()); await waitFor(() => expect(mocks.api.setRulerRuleGroup).toHaveBeenCalled()); + //check that '+ Add new' option is in folders drop down even if we don't have values + const folderInput = await ui.inputs.folderContainer.find(); + mocks.searchFolders.mockResolvedValue([] as DashboardSearchHit[]); + await renderRuleEditor(uid); + await userEvent.click(within(folderInput).getByRole('combobox')); + expect(screen.getByText(ADD_NEW_FOLER_OPTION)).toBeInTheDocument(); + expect(mocks.api.setRulerRuleGroup).toHaveBeenCalledWith( { dataSourceName: GRAFANA_RULES_SOURCE_NAME, apiVersion: 'legacy' }, 'Folder A', diff --git a/public/app/features/alerting/unified/components/rule-editor/RuleFolderPicker.tsx b/public/app/features/alerting/unified/components/rule-editor/RuleFolderPicker.tsx index 991b0a5fcb0..fee87e60f41 100644 --- a/public/app/features/alerting/unified/components/rule-editor/RuleFolderPicker.tsx +++ b/public/app/features/alerting/unified/components/rule-editor/RuleFolderPicker.tsx @@ -1,4 +1,4 @@ -import React, { FC } from 'react'; +import React from 'react'; import { FolderPicker, Props as FolderPickerProps } from 'app/core/components/Select/FolderPicker'; import { PermissionLevelString } from 'app/types'; @@ -12,7 +12,8 @@ export interface RuleFolderPickerProps extends Omit = ({ value, ...props }) => { +export function RuleFolderPicker(props: RuleFolderPickerProps) { + const { value } = props; return ( = ({ value, ...props }) accessControlMetadata {...props} permissionLevel={PermissionLevelString.View} + customAdd={true} /> ); -}; +}