DS Picker: Add missing props and improve autoheight logic (#70393)

* DS Picker: Add `inputId` and `noDefault` options

* DS Picker: Add `disabled` state

* Add tests for `disabled`

* Select default DS if `current` is not provided

* Remove `width` from style

* Move types next to components

* Only calculate height when opening
This commit is contained in:
Ivan Ortega Alba 2023-06-23 10:26:18 +02:00 committed by GitHub
parent 9cf685cfda
commit 3bf8dc1397
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 137 additions and 90 deletions

View File

@ -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(<DataSourceDropdown {...props}></DataSourceDropdown>);
};
async function setupOpenDropdown(user: UserEvent, onChange?: () => void, current?: string) {
const dropdown = setup(onChange, current);
async function setupOpenDropdown(user: UserEvent, props: DataSourceDropdownProps) {
const dropdown = render(<DataSourceDropdown {...props}></DataSourceDropdown>);
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(<DataSourceDropdown onChange={jest.fn()}></DataSourceDropdown>)).not.toThrow();
});
describe('configuration', () => {
@ -121,7 +119,6 @@ describe('DataSourceDropdown', () => {
current: mockDS1.name,
...filters,
};
window.HTMLElement.prototype.scrollIntoView = jest.fn();
const dropdown = render(<DataSourceDropdown {...props}></DataSourceDropdown>);
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(<DataSourceDropdown onChange={jest.fn()} current={mockDS2}></DataSourceDropdown>);
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(<DataSourceDropdown onChange={jest.fn()} current={undefined}></DataSourceDropdown>);
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(<DataSourceDropdown onChange={jest.fn()} disabled></DataSourceDropdown>);
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(<DataSourceDropdown onChange={jest.fn()} inputId={'custom.input.id'}></DataSourceDropdown>);
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(<DataSourceDropdown onChange={jest.fn()} current={null} noDefault></DataSourceDropdown>);
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(
<ModalsProvider>
<DataSourceDropdown {...props}></DataSourceDropdown>

View File

@ -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<DataSourceJsonData>, defaultQueries?: DataQuery[] | GrafanaQuery[]) => void;
current?: DataSourceInstanceSettings<DataSourceJsonData> | 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 ? <DataSourceLogoPlaceHolder /> : <DataSourceLogo dataSource={currentValue} />;
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 (
<div className={styles.container} data-testid={selectors.components.DataSourcePicker.container} style={{ width }}>
<div className={styles.container} data-testid={selectors.components.DataSourcePicker.container}>
{/* 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 */}
<div className={styles.trigger} onClick={openDropdown}>
<Input
id={inputId || 'data-source-picker'}
className={inputHasFocus ? undefined : styles.input}
data-testid={selectors.components.DataSourcePicker.inputV2}
prefix={
filterTerm && isOpen ? (
<DataSourceLogoPlaceHolder />
) : (
<DataSourceLogo dataSource={currentDataSourceInstanceSettings} />
)
}
prefix={currentValue ? prefixIcon : undefined}
suffix={<Icon name={isOpen ? 'search' : 'angle-down'} />}
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}
></Input>
</div>
{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<React.KeyboardEvent>;
style: React.CSSProperties;
filterTerm?: string;
onClose: () => void;
onDismiss: () => void;
}
const PickerContent = React.forwardRef<HTMLDivElement, PickerContentProps>((props, ref) => {
const { filterTerm, onChange, onClose, onClickAddCSV, current } = props;
const changeCallback = useCallback(

View File

@ -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({

View File

@ -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;

View File

@ -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;
}
},
};

View File

@ -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<DataSourceJsonData>, defaultQueries?: DataQuery[] | GrafanaQuery[]) => void;
current: DataSourceInstanceSettings<DataSourceJsonData> | 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<React.KeyboardEvent>;
style: React.CSSProperties;
filterTerm?: string;
dashboard?: boolean;
mixed?: boolean;
onClose: () => void;
onDismiss: () => void;
}

View File

@ -20,7 +20,7 @@ export function dataSourceLabel(
dataSource: DataSourceInstanceSettings<DataSourceJsonData> | 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(

View File

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