diff --git a/public/app/features/datasources/components/picker/DataSourceDropdown.test.tsx b/public/app/features/datasources/components/picker/DataSourceDropdown.test.tsx index 33cb862eb13..4ba217a3e2f 100644 --- a/public/app/features/datasources/components/picker/DataSourceDropdown.test.tsx +++ b/public/app/features/datasources/components/picker/DataSourceDropdown.test.tsx @@ -8,7 +8,7 @@ import { ModalRoot, ModalsProvider } from '@grafana/ui'; import config from 'app/core/config'; import { defaultFileUploadQuery } from 'app/plugins/datasource/grafana/types'; -import { DataSourceDropdown } from './DataSourceDropdown'; +import { DataSourceDropdown, DataSourceDropdownProps } from './DataSourceDropdown'; import * as utils from './utils'; const pluginMetaInfo: PluginMetaInfo = { @@ -44,14 +44,8 @@ const MockDSBuiltIn = createDS('mock.datasource.builtin', 3, true); const mockDSList = [mockDS1, mockDS2, MockDSBuiltIn]; -const setup = (onChange = () => {}, current = mockDS1.name) => { - const props = { onChange, current }; - window.HTMLElement.prototype.scrollIntoView = jest.fn(); - return render(); -}; - -async function setupOpenDropdown(user: UserEvent, onChange?: () => void, current?: string) { - const dropdown = setup(onChange, current); +async function setupOpenDropdown(user: UserEvent, props: DataSourceDropdownProps) { + const dropdown = render(); const searchBox = dropdown.container.querySelector('input'); expect(searchBox).toBeInTheDocument(); await user.click(searchBox!); @@ -87,6 +81,10 @@ jest.mock('../../hooks', () => { }; }); +beforeAll(() => { + window.HTMLElement.prototype.scrollIntoView = jest.fn(); +}); + const getListMock = jest.fn(); const getInstanceSettingsMock = jest.fn(); beforeEach(() => { @@ -96,7 +94,7 @@ beforeEach(() => { describe('DataSourceDropdown', () => { it('should render', () => { - expect(() => setup()).not.toThrow(); + expect(() => render()).not.toThrow(); }); describe('configuration', () => { @@ -121,7 +119,6 @@ describe('DataSourceDropdown', () => { current: mockDS1.name, ...filters, }; - window.HTMLElement.prototype.scrollIntoView = jest.fn(); const dropdown = render(); const searchBox = dropdown.container.querySelector('input'); @@ -130,40 +127,74 @@ describe('DataSourceDropdown', () => { expect(getListMock.mock.lastCall[0]).toEqual(filters); }); + it('should dispaly the current selected DS in the selector', async () => { + getInstanceSettingsMock.mockReturnValue(mockDS2); + render(); + expect(screen.getByTestId('Select a data source')).toHaveAttribute('placeholder', mockDS2.name); + expect(screen.getByAltText(`${mockDS2.meta.name} logo`)).toBeVisible(); + }); + it('should display the current ds on top', async () => { //Mock ds is set as current, it appears on top getInstanceSettingsMock.mockReturnValue(mockDS1); - await setupOpenDropdown(user, jest.fn(), mockDS1.name); + await setupOpenDropdown(user, { onChange: jest.fn(), current: mockDS1.name }); let cards = await screen.findAllByTestId('data-source-card'); expect(await findByText(cards[0], mockDS1.name, { selector: 'span' })).toBeInTheDocument(); //xMock ds is set as current, it appears on top getInstanceSettingsMock.mockReturnValue(mockDS2); - await setupOpenDropdown(user, jest.fn(), mockDS2.name); + await setupOpenDropdown(user, { onChange: jest.fn(), current: mockDS2.name }); cards = await screen.findAllByTestId('data-source-card'); expect(await findByText(cards[0], mockDS2.name, { selector: 'span' })).toBeInTheDocument(); }); + it('should dispaly the default DS as selected when `current` is not set', async () => { + getInstanceSettingsMock.mockReturnValue(mockDS2); + render(); + expect(screen.getByTestId('Select a data source')).toHaveAttribute('placeholder', mockDS2.name); + expect(screen.getByAltText(`${mockDS2.meta.name} logo`)).toBeVisible(); + }); + it('should get the sorting function using the correct parameters', async () => { //The actual sorting is tested in utils.test but let's make sure we're calling getDataSourceCompareFn with the correct parameters const spy = jest.spyOn(utils, 'getDataSourceCompareFn'); - await setupOpenDropdown(user); + await setupOpenDropdown(user, { onChange: jest.fn(), current: mockDS1 }); expect(spy.mock.lastCall).toEqual([mockDS1, [mockDS2.name], ['${foo}']]); }); + + it('should disable the dropdown when `disabled` is true', () => { + render(); + expect(screen.getByTestId('Select a data source')).toBeDisabled(); + }); + + it('should assign the correct `id` to the input element to pair it with a label', () => { + render(); + expect(screen.getByTestId('Select a data source')).toHaveAttribute('id', 'custom.input.id'); + }); + + it('should not set the default DS when setting `noDefault` to true and `current` is not provided', () => { + render(); + getListMock.mockClear(); + getInstanceSettingsMock.mockClear(); + // Doesn't try to get the default DS + expect(getListMock).not.toBeCalled(); + expect(getInstanceSettingsMock).not.toBeCalled(); + expect(screen.getByTestId('Select a data source')).toHaveAttribute('placeholder', 'Select a data source'); + }); }); describe('interactions', () => { const user = userEvent.setup(); it('should open when clicked', async () => { - await setupOpenDropdown(user); + await setupOpenDropdown(user, { onChange: jest.fn() }); expect(await screen.findByText(mockDS1.name, { selector: 'span' })).toBeInTheDocument(); }); it('should call onChange when a data source is clicked', async () => { const onChange = jest.fn(); - await setupOpenDropdown(user, onChange); + await setupOpenDropdown(user, { onChange }); await user.click(await screen.findByText(mockDS2.name, { selector: 'span' })); expect(onChange.mock.lastCall[0]['name']).toEqual(mockDS2.name); @@ -172,7 +203,7 @@ describe('DataSourceDropdown', () => { it('should push recently used datasources when a data source is clicked', async () => { const onChange = jest.fn(); - await setupOpenDropdown(user, onChange); + await setupOpenDropdown(user, { onChange }); await user.click(await screen.findByText(mockDS2.name, { selector: 'span' })); expect(pushRecentlyUsedDataSourceMock.mock.lastCall[0]).toEqual(mockDS2); @@ -180,7 +211,7 @@ describe('DataSourceDropdown', () => { it('should be navigatable by keyboard', async () => { const onChange = jest.fn(); - await setupOpenDropdown(user, onChange); + await setupOpenDropdown(user, { onChange }); await user.keyboard('[ArrowDown]'); //Arrow down, second item is selected @@ -202,7 +233,7 @@ describe('DataSourceDropdown', () => { }); it('should be searchable', async () => { - await setupOpenDropdown(user); + await setupOpenDropdown(user, { onChange: jest.fn() }); await user.keyboard(mockDS2.name); //Search for xMockDS @@ -220,7 +251,7 @@ describe('DataSourceDropdown', () => { it('should call onChange with the default query when add csv is clicked', async () => { config.featureToggles.editPanelCSVDragAndDrop = true; const onChange = jest.fn(); - await setupOpenDropdown(user, onChange); + await setupOpenDropdown(user, { onChange }); await user.click(await screen.findByText('Add csv or spreadsheet')); @@ -231,7 +262,6 @@ describe('DataSourceDropdown', () => { it('should open the modal when open advanced is clicked', async () => { const props = { onChange: jest.fn(), current: mockDS1.name }; - window.HTMLElement.prototype.scrollIntoView = jest.fn(); render( diff --git a/public/app/features/datasources/components/picker/DataSourceDropdown.tsx b/public/app/features/datasources/components/picker/DataSourceDropdown.tsx index 1faef407014..95dec73300c 100644 --- a/public/app/features/datasources/components/picker/DataSourceDropdown.tsx +++ b/public/app/features/datasources/components/picker/DataSourceDropdown.tsx @@ -3,11 +3,12 @@ import { useDialog } from '@react-aria/dialog'; import { useOverlay } from '@react-aria/overlays'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { usePopper } from 'react-popper'; +import { Observable } from 'rxjs'; import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { reportInteraction } from '@grafana/runtime'; -import { DataQuery, DataSourceJsonData } from '@grafana/schema'; +import { DataQuery, DataSourceJsonData, DataSourceRef } from '@grafana/schema'; import { Button, CustomScrollbar, Icon, Input, ModalsController, Portal, useStyles2 } from '@grafana/ui'; import config from 'app/core/config'; import { useKeyNavigationListener } from 'app/features/search/hooks/useSearchKeyboardSelection'; @@ -19,7 +20,6 @@ import { DataSourceList } from './DataSourceList'; import { DataSourceLogo, DataSourceLogoPlaceHolder } from './DataSourceLogo'; import { DataSourceModal } from './DataSourceModal'; import { applyMaxSize, maxSize } from './popperModifiers'; -import { PickerContentProps, DataSourceDropdownProps } from './types'; import { dataSourceLabel, matchDataSourceWithSearch } from './utils'; const INTERACTION_EVENT_NAME = 'dashboards_dspicker_clicked'; @@ -31,8 +31,40 @@ const INTERACTION_ITEM = { CONFIG_NEW_DS_EMPTY_STATE: 'config_new_ds_empty_state', }; +export interface DataSourceDropdownProps { + onChange: (ds: DataSourceInstanceSettings, defaultQueries?: DataQuery[] | GrafanaQuery[]) => void; + current?: DataSourceInstanceSettings | string | DataSourceRef | null | undefined; + recentlyUsed?: string[]; + hideTextValue?: boolean; + width?: number; + inputId?: string; + noDefault?: boolean; + disabled?: boolean; + + // DS filters + tracing?: boolean; + mixed?: boolean; + dashboard?: boolean; + metrics?: boolean; + type?: string | string[]; + annotations?: boolean; + variables?: boolean; + alerting?: boolean; + pluginId?: string; + logs?: boolean; +} + export function DataSourceDropdown(props: DataSourceDropdownProps) { - const { current, onChange, hideTextValue, width, ...restProps } = props; + const { + current, + onChange, + hideTextValue = false, + width, + inputId, + noDefault = false, + disabled = false, + ...restProps + } = props; const [isOpen, setOpen] = useState(false); const [inputHasFocus, setInputHasFocus] = useState(false); @@ -44,6 +76,10 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) { setOpen(true); markerElement?.focus(); }; + const currentDataSourceInstanceSettings = useDatasource(current); + const currentValue = Boolean(!current && noDefault) ? undefined : currentDataSourceInstanceSettings; + const prefixIcon = + filterTerm && isOpen ? : ; const { onKeyDown, keyboardEvents } = useKeyNavigationListener(); @@ -79,8 +115,6 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) { onChange(grafanaDS, [defaultFileUploadQuery]); }; - const currentDataSourceInstanceSettings = useDatasource(current); - const popper = usePopper(markerElement, selectorElement, { placement: 'bottom-start', modifiers: [ @@ -114,25 +148,20 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) { ); const { dialogProps } = useDialog({}, ref); - const styles = useStyles2(getStylesDropdown); + const styles = useStyles2((theme: GrafanaTheme2) => getStylesDropdown(theme, props)); return ( -
+
{/* This clickable div is just extending the clickable area on the input element to include the prefix and suffix. */} {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
- ) : ( - - ) - } + prefix={currentValue ? prefixIcon : undefined} suffix={} - placeholder={hideTextValue ? '' : dataSourceLabel(currentDataSourceInstanceSettings)} + placeholder={hideTextValue ? '' : dataSourceLabel(currentValue)} onClick={openDropdown} onFocus={() => { setInputHasFocus(true); @@ -148,6 +177,7 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) { setFilterTerm(e.currentTarget.value); }} ref={setMarkerElement} + disabled={disabled} >
{isOpen ? ( @@ -172,7 +202,7 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) { onChange(ds, defaultQueries); }} onClose={onClose} - current={currentDataSourceInstanceSettings} + current={currentValue} style={popper.styles.popper} ref={setSelectorElement} onClickAddCSV={onClickAddCSV} @@ -187,25 +217,34 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) { ); } -function getStylesDropdown(theme: GrafanaTheme2) { +function getStylesDropdown(theme: GrafanaTheme2, props: DataSourceDropdownProps) { return { container: css` position: relative; + cursor: ${props.disabled ? 'not-allowed' : 'pointer'}; + width: ${theme.spacing(props.width || 'auto')}; `, trigger: css` cursor: pointer; + ${props.disabled && `pointer-events: none;`} `, input: css` - input { - cursor: pointer; - } input::placeholder { - color: ${theme.colors.text.primary}; + color: ${props.disabled ? theme.colors.action.disabledText : theme.colors.text.primary}; } `, }; } +export interface PickerContentProps extends DataSourceDropdownProps { + onClickAddCSV?: () => void; + keyboardEvents: Observable; + style: React.CSSProperties; + filterTerm?: string; + onClose: () => void; + onDismiss: () => void; +} + const PickerContent = React.forwardRef((props, ref) => { const { filterTerm, onChange, onClose, onClickAddCSV, current } = props; const changeCallback = useCallback( diff --git a/public/app/features/datasources/components/picker/DataSourceModal.tsx b/public/app/features/datasources/components/picker/DataSourceModal.tsx index 3ec15b0b072..013d3b45a47 100644 --- a/public/app/features/datasources/components/picker/DataSourceModal.tsx +++ b/public/app/features/datasources/components/picker/DataSourceModal.tsx @@ -40,9 +40,19 @@ interface DataSourceModalProps { current: DataSourceRef | string | null | undefined; onDismiss: () => void; recentlyUsed?: string[]; - dashboard?: boolean; - mixed?: boolean; reportedInteractionFrom?: string; + + // DS filters + tracing?: boolean; + mixed?: boolean; + dashboard?: boolean; + metrics?: boolean; + type?: string | string[]; + annotations?: boolean; + variables?: boolean; + alerting?: boolean; + pluginId?: string; + logs?: boolean; } export function DataSourceModal({ diff --git a/public/app/features/datasources/components/picker/DataSourcePicker.tsx b/public/app/features/datasources/components/picker/DataSourcePicker.tsx index b67a348a9a9..4c75756d497 100644 --- a/public/app/features/datasources/components/picker/DataSourcePicker.tsx +++ b/public/app/features/datasources/components/picker/DataSourcePicker.tsx @@ -6,8 +6,7 @@ import { } from '@grafana/runtime'; import { config } from 'app/core/config'; -import { DataSourceDropdown } from './DataSourceDropdown'; -import { DataSourceDropdownProps } from './types'; +import { DataSourceDropdown, DataSourceDropdownProps } from './DataSourceDropdown'; type DataSourcePickerProps = DeprecatedDataSourcePickerProps | DataSourceDropdownProps; diff --git a/public/app/features/datasources/components/picker/popperModifiers.ts b/public/app/features/datasources/components/picker/popperModifiers.ts index a4eeb2069c3..276df3227dc 100644 --- a/public/app/features/datasources/components/picker/popperModifiers.ts +++ b/public/app/features/datasources/components/picker/popperModifiers.ts @@ -31,8 +31,16 @@ export const applyMaxSize: Modifier<'applyMaxSize', {}> = { requires: ['maxSize'], fn({ state }: ModifierArguments<{}>) { const { height, width } = state.modifiersData.maxSize; - state.styles.popper.maxHeight = `${height - MODAL_MARGIN}px`; - state.styles.popper.minHeight = `${FLIP_THRESHOLD}px`; - state.styles.popper.maxWidth = width; + + if (!state.styles.popper.maxHeight) { + state.styles.popper.maxHeight = `${height - MODAL_MARGIN}px`; + } + if (!state.styles.popper.minHeight) { + state.styles.popper.minHeight = `${FLIP_THRESHOLD}px`; + } + + if (!state.styles.popper.maxWidth) { + state.styles.popper.maxWidth = width; + } }, }; diff --git a/public/app/features/datasources/components/picker/types.ts b/public/app/features/datasources/components/picker/types.ts deleted file mode 100644 index f83d6328c88..00000000000 --- a/public/app/features/datasources/components/picker/types.ts +++ /dev/null @@ -1,35 +0,0 @@ -import React from 'react'; -import { Observable } from 'rxjs'; - -import { DataSourceInstanceSettings } from '@grafana/data'; -import { DataQuery, DataSourceJsonData, DataSourceRef } from '@grafana/schema'; -import { GrafanaQuery } from 'app/plugins/datasource/grafana/types'; - -export interface DataSourceDropdownProps { - onChange: (ds: DataSourceInstanceSettings, defaultQueries?: DataQuery[] | GrafanaQuery[]) => void; - current: DataSourceInstanceSettings | string | DataSourceRef | null | undefined; - tracing?: boolean; - mixed?: boolean; - dashboard?: boolean; - metrics?: boolean; - type?: string | string[]; - annotations?: boolean; - variables?: boolean; - alerting?: boolean; - pluginId?: string; - logs?: boolean; - recentlyUsed?: string[]; - hideTextValue?: boolean; - width?: number; -} - -export interface PickerContentProps extends DataSourceDropdownProps { - onClickAddCSV?: () => void; - keyboardEvents: Observable; - style: React.CSSProperties; - filterTerm?: string; - dashboard?: boolean; - mixed?: boolean; - onClose: () => void; - onDismiss: () => void; -} diff --git a/public/app/features/datasources/components/picker/utils.ts b/public/app/features/datasources/components/picker/utils.ts index 16caea27c53..104c60b9a6d 100644 --- a/public/app/features/datasources/components/picker/utils.ts +++ b/public/app/features/datasources/components/picker/utils.ts @@ -20,7 +20,7 @@ export function dataSourceLabel( dataSource: DataSourceInstanceSettings | string | DataSourceRef | null | undefined ) { if (!dataSource) { - return 'Unknown'; + return 'Select a data source'; } if (typeof dataSource === 'string') { @@ -35,7 +35,7 @@ export function dataSourceLabel( return `${dataSource.uid} - not found`; } - return 'Unknown'; + return 'Select a data source'; } export function getDataSourceCompareFn( diff --git a/public/app/features/datasources/hooks.ts b/public/app/features/datasources/hooks.ts index 0960b8bcc06..54b6eeceb23 100644 --- a/public/app/features/datasources/hooks.ts +++ b/public/app/features/datasources/hooks.ts @@ -46,10 +46,6 @@ export function useDatasources(filters: GetDataSourceListFilters) { export function useDatasource(dataSource: string | DataSourceRef | DataSourceInstanceSettings | null | undefined) { const dataSourceSrv = getDataSourceSrv(); - if (!dataSource) { - return undefined; - } - if (typeof dataSource === 'string') { return dataSourceSrv.getInstanceSettings(dataSource); }