DS Picker: Filter available DS based on component props (#70613)

* Apply filters consistently to every list in the picker

* Display all built-in DS when editing a panel

* Add `uploadFile` prop to toggle the CSV file DS
This commit is contained in:
Ivan Ortega Alba 2023-07-06 11:50:55 +02:00 committed by GitHub
parent d87c2c4049
commit 6ad9e386ad
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 240 additions and 79 deletions

View File

@ -2,8 +2,8 @@
// however there are many cases where your component may not need an aria-label
// (a <button> with clear text, for example, does not need an aria-label as it's already labeled)
// but you still might need to select it for testing,
// in that case please add the attribute data-test-id={selector} in the component and
// prefix your selector string with 'data-test-id' so that when create the selectors we know to search for it on the right attribute
// in that case please add the attribute data-testid={selector} in the component and
// prefix your selector string with 'data-testid' so that when create the selectors we know to search for it on the right attribute
/**
* Selectors grouped/defined in Components
*
@ -78,7 +78,7 @@ export const Components = {
headerCornerInfo: (mode: string) => `Panel header ${mode}`,
loadingBar: () => `Panel loading bar`,
HoverWidget: {
container: 'data-test-id hover-header-container',
container: 'data-testid hover-header-container',
dragIcon: 'data-testid drag-icon',
},
},

View File

@ -259,7 +259,7 @@ export function FileDropzoneDefaultChildren({ primaryText = 'Drop file here or c
const styles = getStyles(theme);
return (
<div className={cx(styles.defaultDropZone)}>
<div className={cx(styles.defaultDropZone)} data-testid="file-drop-zone-default-children">
<Icon className={cx(styles.icon)} name="upload" size="xl" />
<h6 className={cx(styles.primaryText)}>{primaryText}</h6>
<small className={styles.small}>{secondaryText}</small>

View File

@ -20,9 +20,9 @@ describe('TopSearchBarSection', () => {
matches: true,
}));
const { container } = renderComponent();
const component = renderComponent();
expect(container.querySelector('[data-test-id="wrapper"]')).toBeInTheDocument();
expect(component.queryByTestId('wrapper')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /test item/i })).toBeInTheDocument();
});
@ -33,9 +33,9 @@ describe('TopSearchBarSection', () => {
matches: false,
}));
const { container } = renderComponent();
const component = renderComponent();
expect(container.querySelector('[data-test-id="wrapper"]')).not.toBeInTheDocument();
expect(component.queryByTestId('wrapper')).not.toBeInTheDocument();
expect(screen.getByRole('button', { name: /test item/i })).toBeInTheDocument();
});
});

View File

@ -29,7 +29,7 @@ export function TopSearchBarSection({ children, align = 'left' }: TopSearchBarSe
}
return (
<div data-test-id="wrapper" className={cx(styles.wrapper, { [styles[align]]: align === 'right' })}>
<div data-testid="wrapper" className={cx(styles.wrapper, { [styles[align]]: align === 'right' })}>
{children}
</div>
);

View File

@ -18,16 +18,55 @@ interface BuiltInDataSourceListProps {
className?: string;
current: DataSourceRef | string | null | undefined;
onChange: (ds: DataSourceInstanceSettings) => void;
dashboard?: boolean;
// DS filters
filter?: (ds: DataSourceInstanceSettings) => boolean;
tracing?: boolean;
mixed?: boolean;
dashboard?: boolean;
metrics?: boolean;
type?: string | string[];
annotations?: boolean;
variables?: boolean;
alerting?: boolean;
pluginId?: string;
logs?: boolean;
}
export function BuiltInDataSourceList({ className, current, onChange, dashboard, mixed }: BuiltInDataSourceListProps) {
const grafanaDataSources = useDatasources({ mixed, dashboard, filter: (ds) => !!ds.meta.builtIn });
export function BuiltInDataSourceList({
className,
current,
onChange,
tracing,
dashboard,
mixed,
metrics,
type,
annotations,
variables,
alerting,
pluginId,
logs,
filter,
}: BuiltInDataSourceListProps) {
const grafanaDataSources = useDatasources({
tracing,
dashboard,
mixed,
metrics,
type,
annotations,
variables,
alerting,
pluginId,
logs,
});
const filteredResults = grafanaDataSources.filter((ds) => (filter ? filter?.(ds) : true) && !!ds.meta.builtIn);
return (
<div className={className} data-testid="built-in-data-sources-list">
{grafanaDataSources.map((ds) => {
{filteredResults.map((ds) => {
return (
<DataSourceCard
key={ds.uid}

View File

@ -100,7 +100,7 @@ describe('DataSourceDropdown', () => {
describe('configuration', () => {
const user = userEvent.setup();
it('should call the dataSourceSrv.getDatasourceList with the correct filters', async () => {
it('should fetch the DS applying the correct filters consistently across lists', async () => {
const filters = {
mixed: true,
tracing: true,
@ -119,12 +119,27 @@ describe('DataSourceDropdown', () => {
current: mockDS1.name,
...filters,
};
const dropdown = render(<DataSourceDropdown {...props}></DataSourceDropdown>);
const searchBox = dropdown.container.querySelector('input');
render(
<ModalsProvider>
<DataSourceDropdown {...props}></DataSourceDropdown>
<ModalRoot />
</ModalsProvider>
);
const searchBox = await screen.findByRole('textbox');
expect(searchBox).toBeInTheDocument();
getListMock.mockClear();
await user.click(searchBox!);
expect(getListMock.mock.lastCall[0]).toEqual(filters);
await user.click(await screen.findByText('Open advanced data source picker'));
expect(await screen.findByText('Select data source')); //Data source modal is open
// Every call to the service must contain same filters
getListMock.mock.calls.forEach((call) =>
expect(call[0]).toMatchObject({
...filters,
})
);
});
it('should dispaly the current selected DS in the selector', async () => {
@ -180,7 +195,7 @@ describe('DataSourceDropdown', () => {
// 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');
expect(screen.getByTestId('Select a data source')).toHaveAttribute('placeholder', 'Select data source');
});
});
@ -251,7 +266,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, uploadFile: true });
await user.click(await screen.findByText('Add csv or spreadsheet'));

View File

@ -8,7 +8,7 @@ import { Observable } from 'rxjs';
import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { reportInteraction } from '@grafana/runtime';
import { DataQuery, DataSourceJsonData, DataSourceRef } from '@grafana/schema';
import { DataQuery, 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';
@ -32,14 +32,15 @@ const INTERACTION_ITEM = {
};
export interface DataSourceDropdownProps {
onChange: (ds: DataSourceInstanceSettings<DataSourceJsonData>, defaultQueries?: DataQuery[] | GrafanaQuery[]) => void;
current?: DataSourceInstanceSettings<DataSourceJsonData> | string | DataSourceRef | null | undefined;
onChange: (ds: DataSourceInstanceSettings, defaultQueries?: DataQuery[] | GrafanaQuery[]) => void;
current?: DataSourceInstanceSettings | string | DataSourceRef | null;
recentlyUsed?: string[];
hideTextValue?: boolean;
width?: number;
inputId?: string;
noDefault?: boolean;
disabled?: boolean;
placeholder?: string;
// DS filters
tracing?: boolean;
@ -52,6 +53,8 @@ export interface DataSourceDropdownProps {
alerting?: boolean;
pluginId?: string;
logs?: boolean;
uploadFile?: boolean;
filter?: (ds: DataSourceInstanceSettings) => boolean;
}
export function DataSourceDropdown(props: DataSourceDropdownProps) {
@ -63,6 +66,7 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) {
inputId,
noDefault = false,
disabled = false,
placeholder = 'Select data source',
...restProps
} = props;
@ -161,7 +165,7 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) {
data-testid={selectors.components.DataSourcePicker.inputV2}
prefix={currentValue ? prefixIcon : undefined}
suffix={<Icon name={isOpen ? 'search' : 'angle-down'} />}
placeholder={hideTextValue ? '' : dataSourceLabel(currentValue)}
placeholder={hideTextValue ? '' : dataSourceLabel(currentValue) || placeholder}
onClick={openDropdown}
onFocus={() => {
setInputHasFocus(true);
@ -196,10 +200,7 @@ export function DataSourceDropdown(props: DataSourceDropdownProps) {
<PickerContent
keyboardEvents={keyboardEvents}
filterTerm={filterTerm}
onChange={(
ds: DataSourceInstanceSettings<DataSourceJsonData>,
defaultQueries?: DataQuery[] | GrafanaQuery[]
) => {
onChange={(ds: DataSourceInstanceSettings, defaultQueries?: DataQuery[] | GrafanaQuery[]) => {
onClose();
onChange(ds, defaultQueries);
}}
@ -248,9 +249,9 @@ export interface PickerContentProps extends DataSourceDropdownProps {
}
const PickerContent = React.forwardRef<HTMLDivElement, PickerContentProps>((props, ref) => {
const { filterTerm, onChange, onClose, onClickAddCSV, current } = props;
const { filterTerm, onChange, onClose, onClickAddCSV, current, filter, uploadFile } = props;
const changeCallback = useCallback(
(ds: DataSourceInstanceSettings<DataSourceJsonData>) => {
(ds: DataSourceInstanceSettings) => {
onChange(ds);
reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.SELECT_DS, ds_type: ds.type });
},
@ -274,7 +275,7 @@ const PickerContent = React.forwardRef<HTMLDivElement, PickerContentProps>((prop
className={styles.dataSourceList}
current={current}
onChange={changeCallback}
filter={(ds) => matchDataSourceWithSearch(ds, filterTerm)}
filter={(ds) => (filter ? filter?.(ds) : true) && matchDataSourceWithSearch(ds, filterTerm)}
onClickEmptyStateCTA={() =>
reportInteraction(INTERACTION_EVENT_NAME, {
item: INTERACTION_ITEM.CONFIG_NEW_DS_EMPTY_STATE,
@ -293,9 +294,19 @@ const PickerContent = React.forwardRef<HTMLDivElement, PickerContentProps>((prop
onClose();
showModal(DataSourceModal, {
reportedInteractionFrom: 'ds_picker',
tracing: props.tracing,
dashboard: props.dashboard,
mixed: props.mixed,
current,
metrics: props.metrics,
type: props.type,
annotations: props.annotations,
variables: props.variables,
alerting: props.alerting,
pluginId: props.pluginId,
logs: props.logs,
filter: props.filter,
uploadFile: props.uploadFile,
current: props.current,
onDismiss: hideModal,
onChange: (ds, defaultQueries) => {
onChange(ds, defaultQueries);
@ -310,7 +321,7 @@ const PickerContent = React.forwardRef<HTMLDivElement, PickerContentProps>((prop
</Button>
)}
</ModalsController>
{onClickAddCSV && config.featureToggles.editPanelCSVDragAndDrop && (
{uploadFile && config.featureToggles.editPanelCSVDragAndDrop && (
<Button variant="secondary" size="sm" onClick={clickAddCSVCallback}>
Add csv or spreadsheet
</Button>

View File

@ -1,11 +1,11 @@
import { findByText, queryByText, render, screen } from '@testing-library/react';
import { queryByTestId, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { DataSourceInstanceSettings, DataSourcePluginMeta, PluginMetaInfo, PluginType } from '@grafana/data';
import { config, GetDataSourceListFilters } from '@grafana/runtime';
import { config } from '@grafana/runtime';
import { DataSourceModal } from './DataSourceModal';
import { DataSourceModal, DataSourceModalProps } from './DataSourceModal';
const pluginMetaInfo: PluginMetaInfo = {
author: { name: '' },
@ -40,10 +40,17 @@ const mockDSBuiltIn = createDS('mock.datasource.builtin', 3, true);
const mockDSList = [mockDS1, mockDS2, mockDSBuiltIn];
const setup = (onChange = () => {}, onDismiss = () => {}) => {
const props = { onChange, onDismiss, current: mockDS1.name };
const setup = (partialProps: Partial<DataSourceModalProps> = {}) => {
window.HTMLElement.prototype.scrollIntoView = function () {};
return render(<DataSourceModal {...props}></DataSourceModal>);
const props: DataSourceModalProps = {
...partialProps,
onChange: partialProps.onChange || jest.fn(),
onDismiss: partialProps.onDismiss || jest.fn(),
current: partialProps.current || mockDS1,
};
return render(<DataSourceModal {...props} />);
};
jest.mock('@grafana/runtime', () => {
@ -61,17 +68,19 @@ jest.mock('@grafana/runtime', () => {
jest.mock('@grafana/runtime/src/services/dataSourceSrv', () => {
return {
getDataSourceSrv: () => ({
getList: (filters: GetDataSourceListFilters) => {
if (filters.filter) {
return mockDSList.filter(filters.filter);
}
return mockDSList;
},
getInstanceSettings: () => mockDS1,
getList: getListMock,
getInstanceSettings: getInstanceSettingsMock,
}),
};
});
const getListMock = jest.fn();
const getInstanceSettingsMock = jest.fn();
beforeEach(() => {
getListMock.mockReturnValue(mockDSList);
getInstanceSettingsMock.mockReturnValue(mockDS1);
});
describe('DataSourceDropdown', () => {
it('should render', () => {
expect(() => setup()).not.toThrow();
@ -90,24 +99,74 @@ describe('DataSourceDropdown', () => {
});
it('only displays the file drop area when the the ff is enabled', async () => {
const defaultValue = config.featureToggles.editPanelCSVDragAndDrop;
config.featureToggles.editPanelCSVDragAndDrop = true;
setup();
expect(await screen.findByText('Drop file here or click to upload')).toBeInTheDocument();
config.featureToggles.editPanelCSVDragAndDrop = false;
setup({ uploadFile: true });
expect(await screen.queryByTestId('file-drop-zone-default-children')).toBeInTheDocument();
config.featureToggles.editPanelCSVDragAndDrop = defaultValue;
});
it('does not show the file drop area when the ff is disabled', async () => {
setup();
expect(screen.queryByText('Drop file here or click to upload')).toBeNull();
const defaultValue = config.featureToggles.editPanelCSVDragAndDrop;
config.featureToggles.editPanelCSVDragAndDrop = false;
setup({ uploadFile: true });
expect(await screen.queryByTestId('file-drop-zone-default-children')).toBeNull();
config.featureToggles.editPanelCSVDragAndDrop = defaultValue;
});
it('should only display built in datasources in the right column', async () => {
setup();
const dsList = await screen.findByTestId('data-sources-list');
const builtInDSList = (await screen.findAllByTestId('built-in-data-sources-list'))[1]; //The second element needs to be selected as the first element is the one on the left, under the regular data sources.
it('should not display the drop zone by default', async () => {
const defaultValue = config.featureToggles.editPanelCSVDragAndDrop;
config.featureToggles.editPanelCSVDragAndDrop = true;
expect(queryByText(dsList, mockDSBuiltIn.name)).toBeNull();
expect(await findByText(builtInDSList, mockDSBuiltIn.name, { selector: 'span' })).toBeInTheDocument();
const component = setup();
expect(queryByTestId(component.container, 'file-drop-zone-default-children')).toBeNull();
config.featureToggles.editPanelCSVDragAndDrop = defaultValue;
});
it('should display the drop zone when uploadFile is enabled', async () => {
const defaultValue = config.featureToggles.editPanelCSVDragAndDrop;
config.featureToggles.editPanelCSVDragAndDrop = true;
setup({ uploadFile: true });
expect(await screen.queryByTestId('file-drop-zone-default-children')).toBeInTheDocument();
config.featureToggles.editPanelCSVDragAndDrop = defaultValue;
});
it('should fetch the DS applying the correct filters consistently across lists', async () => {
const filters = {
mixed: true,
tracing: true,
dashboard: true,
metrics: true,
type: 'foo',
annotations: true,
variables: true,
alerting: true,
pluginId: 'pluginid',
logs: true,
};
const props = {
onChange: () => {},
onDismiss: () => {},
current: mockDS1.name,
...filters,
};
getListMock.mockClear();
render(<DataSourceModal {...props}></DataSourceModal>);
// Every call to the service must contain same filters
expect(getListMock).toHaveBeenCalled();
getListMock.mock.calls.forEach((call) =>
expect(call[0]).toMatchObject({
...filters,
})
);
});
});
@ -134,9 +193,10 @@ describe('DataSourceDropdown', () => {
const user = userEvent.setup();
config.featureToggles.editPanelCSVDragAndDrop = true;
const onChange = jest.fn();
setup(onChange);
setup({ onChange, uploadFile: true });
const fileInput = (
await screen.findByText('Drop file here or click to upload')
await screen.queryByTestId('file-drop-zone-default-children')!
).parentElement!.parentElement!.querySelector('input');
const file = new File([''], 'test.csv', { type: 'text/plain' });
expect(fileInput).toBeInTheDocument();
@ -154,7 +214,7 @@ describe('DataSourceDropdown', () => {
it('should call the onChange handler with the correct datasource', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
setup(onChange);
setup({ onChange });
await user.click(await screen.findByText(mockDS2.name, { selector: 'span' }));
expect(onChange.mock.lastCall[0].name).toEqual(mockDS2.name);
});

View File

@ -35,7 +35,7 @@ const INTERACTION_ITEM = {
DISMISS: 'dismiss',
};
interface DataSourceModalProps {
export interface DataSourceModalProps {
onChange: (ds: DataSourceInstanceSettings, defaultQueries?: DataQuery[] | GrafanaQuery[]) => void;
current: DataSourceRef | string | null | undefined;
onDismiss: () => void;
@ -43,6 +43,7 @@ interface DataSourceModalProps {
reportedInteractionFrom?: string;
// DS filters
filter?: (ds: DataSourceInstanceSettings) => boolean;
tracing?: boolean;
mixed?: boolean;
dashboard?: boolean;
@ -53,11 +54,22 @@ interface DataSourceModalProps {
alerting?: boolean;
pluginId?: string;
logs?: boolean;
uploadFile?: boolean;
}
export function DataSourceModal({
tracing,
dashboard,
mixed,
metrics,
type,
annotations,
variables,
alerting,
pluginId,
logs,
uploadFile,
filter,
onChange,
current,
onDismiss,
@ -106,6 +118,29 @@ export function DataSourceModal({
}
});
// Built-in data sources used twice because of mobile layout adjustments
// In movile the list is appended to the bottom of the DS list
const BuiltInList = ({ className }: { className?: string }) => {
return (
<BuiltInDataSourceList
className={className}
onChange={onChangeDataSource}
current={current}
filter={filter}
variables={variables}
tracing={tracing}
metrics={metrics}
type={type}
annotations={annotations}
alerting={alerting}
pluginId={pluginId}
logs={logs}
dashboard={dashboard}
mixed={mixed}
/>
);
};
return (
<Modal
title="Select data source"
@ -132,10 +167,6 @@ export function DataSourceModal({
/>
<CustomScrollbar>
<DataSourceList
dashboard={false}
mixed={false}
variables
filter={(ds) => matchDataSourceWithSearch(ds, search) && !ds.meta.builtIn}
onChange={onChangeDataSource}
current={current}
onClickEmptyStateCTA={() =>
@ -144,27 +175,27 @@ export function DataSourceModal({
src: analyticsInteractionSrc,
})
}
/>
<BuiltInDataSourceList
filter={(ds) => (filter ? filter?.(ds) : true) && matchDataSourceWithSearch(ds, search) && !ds.meta.builtIn}
variables={variables}
tracing={tracing}
metrics={metrics}
type={type}
annotations={annotations}
alerting={alerting}
pluginId={pluginId}
logs={logs}
dashboard={dashboard}
mixed={mixed}
className={styles.appendBuiltInDataSourcesList}
onChange={onChangeDataSource}
current={current}
/>
<BuiltInList className={styles.appendBuiltInDataSourcesList} />
</CustomScrollbar>
</div>
<div className={styles.rightColumn}>
<div className={styles.builtInDataSources}>
<CustomScrollbar className={styles.builtInDataSourcesList}>
<BuiltInDataSourceList
onChange={onChangeDataSource}
current={current}
dashboard={dashboard}
mixed={mixed}
/>
<BuiltInList />
</CustomScrollbar>
{config.featureToggles.editPanelCSVDragAndDrop && (
{uploadFile && config.featureToggles.editPanelCSVDragAndDrop && (
<FileDropzone
readAs="readAsArrayBuffer"
fileListRenderer={() => undefined}

View File

@ -20,7 +20,7 @@ export function dataSourceLabel(
dataSource: DataSourceInstanceSettings<DataSourceJsonData> | string | DataSourceRef | null | undefined
) {
if (!dataSource) {
return 'Select a data source';
return undefined;
}
if (typeof dataSource === 'string') {
@ -35,7 +35,7 @@ export function dataSourceLabel(
return `${dataSource.uid} - not found`;
}
return 'Select a data source';
return undefined;
}
export function getDataSourceCompareFn(

View File

@ -272,7 +272,12 @@ export class QueryGroup extends PureComponent<Props, State> {
const { isDataSourceModalOpen } = this.state;
const commonProps = {
metrics: true,
mixed: true,
dashboard: true,
variables: true,
current: this.props.options.dataSource,
uploadFile: true,
onChange: async (ds: DataSourceInstanceSettings, defaultQueries?: DataQuery[] | GrafanaQuery[]) => {
await this.onChangeDataSource(ds, defaultQueries);
this.onCloseDataSourceModal();
@ -284,7 +289,7 @@ export class QueryGroup extends PureComponent<Props, State> {
{isDataSourceModalOpen && config.featureToggles.advancedDataSourcePicker && (
<DataSourceModal {...commonProps} onDismiss={this.onCloseDataSourceModal}></DataSourceModal>
)}
<DataSourcePicker {...commonProps} metrics={true} mixed={true} dashboard={true} variables={true} />
<DataSourcePicker {...commonProps} />
</>
);
};