Dashboards: Remove `advancedDataSourcePicker` feature toggle (#81790)

* remove advancedDataSourcePicker feature toggle from DataSourcePickerWithPrompt

* remove advancedDataSourcePicker toggle from DataSourcePicker and adjust tests that relied on old picker

* adjust failing tests in QueryVariableEditorForm

* move DataSourceDropdown to DataSourcePicker file

* covert style declaration syntax to object style in DataSourcePicker

* remove advancedDataSourcePicker  feature flag from registry

* remove .only from test

* adjust QueryVariableEditor test to avoid console.error
pull/81939/head
Sergej-Vlasov 1 year ago committed by GitHub
parent 96301ce533
commit 3605d85c4c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 18
      .betterer.results
  2. 1
      docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
  3. 1
      packages/grafana-data/src/types/featureToggles.gen.ts
  4. 5
      packages/grafana-e2e-selectors/src/selectors/components.ts
  5. 11
      pkg/services/featuremgmt/registry.go
  6. 1
      pkg/services/featuremgmt/toggles_gen.csv
  7. 4
      pkg/services/featuremgmt/toggles_gen.go
  8. 5
      public/app/features/alerting/unified/RuleEditorCloudOnlyAllowed.test.tsx
  9. 5
      public/app/features/alerting/unified/RuleEditorCloudRules.test.tsx
  10. 7
      public/app/features/alerting/unified/RuleEditorRecordingRule.test.tsx
  11. 1
      public/app/features/alerting/unified/components/rule-editor/query-and-alert-condition/CloudDataSourceSelector.tsx
  12. 5
      public/app/features/alerting/unified/mocks.ts
  13. 15
      public/app/features/correlations/CorrelationsPage.test.tsx
  14. 13
      public/app/features/dashboard-scene/settings/variables/components/QueryVariableForm.test.tsx
  15. 16
      public/app/features/dashboard-scene/settings/variables/editors/QueryVariableEditor.test.tsx
  16. 4
      public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.test.tsx
  17. 3
      public/app/features/datasources/components/picker/BuiltInDataSourceList.tsx
  18. 437
      public/app/features/datasources/components/picker/DataSourceDropdown.tsx
  19. 7
      public/app/features/datasources/components/picker/DataSourceList.tsx
  20. 24
      public/app/features/datasources/components/picker/DataSourcePicker.test.tsx
  21. 447
      public/app/features/datasources/components/picker/DataSourcePicker.tsx
  22. 8
      public/app/features/explore/spec/helper/interactions.ts
  23. 3
      public/app/features/query/components/QueryEditorRowHeader.test.tsx
  24. 2
      public/app/features/query/components/QueryGroup.tsx
  25. 5
      public/app/features/variables/adhoc/AdHocVariableEditor.test.tsx
  26. 2
      public/test/helpers/alertingRuleEditor.tsx

@ -2423,11 +2423,9 @@ exports[`better eslint`] = {
[0, 0, 0, "Do not use any type assertions.", "3"],
[0, 0, 0, "Unexpected any. Specify a different type.", "4"],
[0, 0, 0, "Unexpected any. Specify a different type.", "5"],
[0, 0, 0, "Do not use any type assertions.", "6"],
[0, 0, 0, "Do not use any type assertions.", "7"],
[0, 0, 0, "Unexpected any. Specify a different type.", "8"],
[0, 0, 0, "Unexpected any. Specify a different type.", "9"],
[0, 0, 0, "Do not use any type assertions.", "10"]
[0, 0, 0, "Unexpected any. Specify a different type.", "6"],
[0, 0, 0, "Unexpected any. Specify a different type.", "7"],
[0, 0, 0, "Do not use any type assertions.", "8"]
],
"public/app/features/alerting/unified/state/actions.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
@ -3146,16 +3144,6 @@ exports[`better eslint`] = {
[0, 0, 0, "Styles should be written using objects.", "7"],
[0, 0, 0, "Styles should be written using objects.", "8"]
],
"public/app/features/datasources/components/picker/DataSourceDropdown.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"],
[0, 0, 0, "Styles should be written using objects.", "6"],
[0, 0, 0, "Styles should be written using objects.", "7"]
],
"public/app/features/datasources/components/picker/DataSourceList.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],

@ -40,7 +40,6 @@ Some features are enabled by default. You can disable these feature by setting t
| `lokiMetricDataplane` | Changes metric responses from Loki to be compliant with the dataplane specification. | Yes |
| `dataplaneFrontendFallback` | Support dataplane contract field name change for transformations and field name matchers where the name is different | Yes |
| `enableElasticsearchBackendQuerying` | Enable the processing of queries and responses in the Elasticsearch data source through backend | Yes |
| `advancedDataSourcePicker` | Enable a new data source picker with contextual information, recently used order and advanced mode | Yes |
| `cloudWatchLogsMonacoEditor` | Enables the Monaco editor for CloudWatch Logs queries | Yes |
| `recordedQueriesMulti` | Enables writing multiple items from a single query within Recorded Queries | Yes |
| `transformationsRedesign` | Enables the transformations redesign | Yes |

@ -78,7 +78,6 @@ export interface FeatureToggles {
externalServiceAuth?: boolean;
refactorVariablesTimeRange?: boolean;
enableElasticsearchBackendQuerying?: boolean;
advancedDataSourcePicker?: boolean;
faroDatasourceSelector?: boolean;
enableDatagridEditing?: boolean;
extraThemes?: boolean;

@ -404,6 +404,11 @@ export const Components = {
*/
input: () => 'input[id="data-source-picker"]',
inputV2: 'data-testid Select a data source',
dataSourceList: 'data-testid Data source list dropdown',
advancedModal: {
dataSourceList: 'data-testid Data source list',
builtInDataSourceList: 'data-testid Built in data source list',
},
},
TimeZonePicker: {
/**

@ -509,17 +509,6 @@ var (
AllowSelfServe: true,
Created: time.Date(2023, time.April, 14, 12, 0, 0, 0, time.UTC),
},
{
Name: "advancedDataSourcePicker",
Description: "Enable a new data source picker with contextual information, recently used order and advanced mode",
Stage: FeatureStageGeneralAvailability,
FrontendOnly: true,
Expression: "true", // enabled by default
Owner: grafanaDashboardsSquad,
AllowSelfServe: false,
HideFromAdminPage: true,
Created: time.Date(2023, time.April, 14, 12, 0, 0, 0, time.UTC),
},
{
Name: "faroDatasourceSelector",
Description: "Enable the data source selector within the Frontend Apps section of the Frontend Observability",

@ -59,7 +59,6 @@ renderAuthJWT,preview,@grafana/grafana-as-code,2023-04-03,false,false,false
externalServiceAuth,experimental,@grafana/identity-access-team,2023-04-11,true,false,false
refactorVariablesTimeRange,preview,@grafana/dashboards-squad,2023-06-06,false,false,false
enableElasticsearchBackendQuerying,GA,@grafana/observability-logs,2023-04-14,false,false,false
advancedDataSourcePicker,GA,@grafana/dashboards-squad,2023-04-14,false,false,true
faroDatasourceSelector,preview,@grafana/app-o11y,2023-05-04,false,false,true
enableDatagridEditing,preview,@grafana/grafana-bi-squad,2023-04-24,false,false,true
extraThemes,experimental,@grafana/grafana-frontend-platform,2023-05-10,false,false,true

1 Name Stage Owner Created requiresDevMode RequiresRestart FrontendOnly
59 externalServiceAuth experimental @grafana/identity-access-team 2023-04-11 true false false
60 refactorVariablesTimeRange preview @grafana/dashboards-squad 2023-06-06 false false false
61 enableElasticsearchBackendQuerying GA @grafana/observability-logs 2023-04-14 false false false
advancedDataSourcePicker GA @grafana/dashboards-squad 2023-04-14 false false true
62 faroDatasourceSelector preview @grafana/app-o11y 2023-05-04 false false true
63 enableDatagridEditing preview @grafana/grafana-bi-squad 2023-04-24 false false true
64 extraThemes experimental @grafana/grafana-frontend-platform 2023-05-10 false false true

@ -247,10 +247,6 @@ const (
// Enable the processing of queries and responses in the Elasticsearch data source through backend
FlagEnableElasticsearchBackendQuerying = "enableElasticsearchBackendQuerying"
// FlagAdvancedDataSourcePicker
// Enable a new data source picker with contextual information, recently used order and advanced mode
FlagAdvancedDataSourcePicker = "advancedDataSourcePicker"
// FlagFaroDatasourceSelector
// Enable the data source selector within the Frontend Apps section of the Frontend Observability
FlagFaroDatasourceSelector = "faroDatasourceSelector"

@ -2,7 +2,7 @@ import { screen, waitForElementToBeRemoved } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
import { byRole, byText } from 'testing-library-selector';
import { byText } from 'testing-library-selector';
import { setDataSourceSrv } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
@ -104,6 +104,7 @@ jest.mock('@grafana/runtime', () => ({
getDataSourceSrv: jest.fn(() => ({
getInstanceSettings: () => dataSources.prom,
get: () => dataSources.prom,
getList: () => Object.values(dataSources),
})),
}));
@ -197,7 +198,7 @@ describe('RuleEditor cloud: checking editable data sources', () => {
// check that only rules sources that have ruler available are there
const dataSourceSelect = ui.inputs.dataSource.get();
await userEvent.click(byRole('combobox').get(dataSourceSelect));
await userEvent.click(dataSourceSelect);
expect(byText('cortex with ruler').query()).toBeInTheDocument();
expect(byText('loki with ruler').query()).toBeInTheDocument();

@ -4,6 +4,7 @@ import React from 'react';
import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
import { selectors } from '@grafana/e2e-selectors';
import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction } from 'app/types';
@ -138,11 +139,11 @@ describe('RuleEditor cloud', () => {
//expressions are removed after switching to data-source managed
expect(screen.queryAllByLabelText('Remove expression')).toHaveLength(0);
expect(screen.getByTestId('datasource-picker')).toBeInTheDocument();
expect(screen.getByTestId(selectors.components.DataSourcePicker.inputV2)).toBeInTheDocument();
const dataSourceSelect = await ui.inputs.dataSource.find();
await user.click(dataSourceSelect);
await clickSelectOption(dataSourceSelect, 'Prom (default)');
await user.click(screen.getByText('Prom'));
await waitFor(() => expect(mocks.api.fetchRulerRules).toHaveBeenCalled());
await user.type(await ui.inputs.expr.find(), 'up == 1');

@ -3,7 +3,7 @@ import userEvent, { PointerEventsCheckLevel } from '@testing-library/user-event'
import React from 'react';
import { renderRuleEditor, ui } from 'test/helpers/alertingRuleEditor';
import { clickSelectOption } from 'test/helpers/selectOptionInTest';
import { byRole, byText } from 'testing-library-selector';
import { byText } from 'testing-library-selector';
import { setDataSourceSrv } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
@ -72,6 +72,7 @@ jest.mock('@grafana/runtime', () => ({
getDataSourceSrv: jest.fn(() => ({
getInstanceSettings: () => dataSources.default,
get: () => dataSources.default,
getList: () => Object.values(dataSources),
})),
}));
@ -149,9 +150,9 @@ describe('RuleEditor recording rules', () => {
await userEvent.type(await ui.inputs.name.find(), 'my great new recording rule');
const dataSourceSelect = ui.inputs.dataSource.get();
await userEvent.click(byRole('combobox').get(dataSourceSelect));
await userEvent.click(dataSourceSelect);
await clickSelectOption(dataSourceSelect, 'Prom (default)');
await userEvent.click(screen.getByText('Prom'));
await clickSelectOption(ui.inputs.namespace.get(), 'namespace2');
await clickSelectOption(ui.inputs.group.get(), 'group2');

@ -32,7 +32,6 @@ export const CloudDataSourceSelector = ({ disabled, onChangeCloudDatasource }: C
label={disabled ? 'Data source' : 'Select data source'}
error={errors.dataSourceName?.message}
invalid={!!errors.dataSourceName?.message}
data-testid="datasource-picker"
>
<InputControl
render={({ field: { onChange, ref, ...field } }) => (

@ -395,10 +395,7 @@ export class MockDataSourceSrv implements DataSourceSrv {
* Get settings and plugin metadata by name or uid
*/
getInstanceSettings(nameOrUid: string | null | undefined): DataSourceInstanceSettings | undefined {
return (
DatasourceSrv.prototype.getInstanceSettings.call(this, nameOrUid) ||
({ meta: { info: { logos: {} } } } as unknown as DataSourceInstanceSettings)
);
return DatasourceSrv.prototype.getInstanceSettings.call(this, nameOrUid);
}
async loadDatasource(name: string): Promise<DataSourceApi<any, any>> {

@ -1,4 +1,4 @@
import { render, waitFor, screen, fireEvent, within, Matcher, getByRole } from '@testing-library/react';
import { render, waitFor, screen, within, Matcher, getByRole } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { merge, uniqueId } from 'lodash';
import React from 'react';
@ -9,6 +9,7 @@ import { MockDataSourceApi } from 'test/mocks/datasource_srv';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { DataSourcePluginMeta, SupportedTransformationType } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { BackendSrv, setDataSourceSrv, BackendSrvRequest, reportInteraction } from '@grafana/runtime';
import { contextSrv } from 'app/core/services/context_srv';
import { configureStore } from 'app/store/configureStore';
@ -270,13 +271,13 @@ describe('CorrelationsPage', () => {
// step 2:
// set target datasource picker value
fireEvent.keyDown(screen.getByLabelText(/^target/i), { keyCode: 40 });
await userEvent.click(screen.getByLabelText(/^target/i));
await userEvent.click(screen.getByText('prometheus'));
await userEvent.click(await screen.findByRole('button', { name: /next$/i }));
// step 3:
// set source datasource picker value
fireEvent.keyDown(screen.getByLabelText(/^source/i), { keyCode: 40 });
await userEvent.click(screen.getByLabelText(/^source/i));
await userEvent.click(screen.getByText('loki'));
await userEvent.click(await screen.findByRole('button', { name: /add$/i }));
@ -427,14 +428,16 @@ describe('CorrelationsPage', () => {
// step 2:
// set target datasource picker value
fireEvent.keyDown(screen.getByLabelText(/^target/i), { keyCode: 40 });
await userEvent.click(screen.getByLabelText(/^target/i));
await userEvent.click(screen.getByText('elastic'));
await userEvent.click(await screen.findByRole('button', { name: /next$/i }));
// step 3:
// set source datasource picker value
fireEvent.keyDown(screen.getByLabelText(/^source/i), { keyCode: 40 });
await userEvent.click(within(screen.getByLabelText('Select options menu')).getByText('prometheus'));
await userEvent.click(screen.getByLabelText(/^source/i));
await userEvent.click(
within(screen.getByTestId(selectors.components.DataSourcePicker.dataSourceList)).getByText('prometheus')
);
await userEvent.clear(screen.getByRole('textbox', { name: /results field/i }));
await userEvent.type(screen.getByRole('textbox', { name: /results field/i }), 'Line');

@ -108,7 +108,7 @@ describe('QueryVariableEditorForm', () => {
const {
renderer: { getByTestId, getByRole },
} = setup();
const dataSourcePicker = getByTestId(selectors.components.DataSourcePicker.container);
const dataSourcePicker = getByTestId(selectors.components.DataSourcePicker.inputV2);
//const queryEditor = getByTestId('query-editor');
const regexInput = getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2
@ -131,7 +131,7 @@ describe('QueryVariableEditorForm', () => {
);
expect(dataSourcePicker).toBeInTheDocument();
expect(dataSourcePicker).toHaveTextContent('Default Test Data Source');
expect(dataSourcePicker.getAttribute('placeholder')).toBe('Default Test Data Source');
expect(regexInput).toBeInTheDocument();
expect(regexInput).toHaveValue('.*');
expect(sortSelect).toBeInTheDocument();
@ -151,12 +151,11 @@ describe('QueryVariableEditorForm', () => {
renderer: { getByTestId },
} = setup();
const dataSourcePicker = getByTestId(selectors.components.DataSourcePicker.inputV2);
await waitFor(async () => {
await userEvent.click(dataSourcePicker); // open the select
await userEvent.tab();
});
await userEvent.click(dataSourcePicker);
await userEvent.click(screen.getByText(/prometheus/i));
expect(mockOnDataSourceChange).toHaveBeenCalledTimes(1);
expect(mockOnDataSourceChange).toHaveBeenCalledWith(defaultDatasource);
expect(mockOnDataSourceChange).toHaveBeenCalledWith(promDatasource, undefined);
});
it('should call onQueryChange when changing the query', async () => {

@ -93,7 +93,7 @@ describe('QueryVariableEditor', () => {
it('should render the component with initializing the components correctly', async () => {
const { renderer } = await setup();
const dataSourcePicker = renderer.getByTestId(selectors.components.DataSourcePicker.container);
const dataSourcePicker = renderer.getByTestId(selectors.components.DataSourcePicker.inputV2);
const queryEditor = renderer.getByTestId(
selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsQueryInput
);
@ -118,7 +118,7 @@ describe('QueryVariableEditor', () => {
);
expect(dataSourcePicker).toBeInTheDocument();
expect(dataSourcePicker).toHaveTextContent('Default Test Data Source');
expect(dataSourcePicker.getAttribute('placeholder')).toBe('Default Test Data Source');
expect(queryEditor).toBeInTheDocument();
expect(queryEditor).toHaveValue('my-query');
expect(regexInput).toBeInTheDocument();
@ -138,18 +138,20 @@ describe('QueryVariableEditor', () => {
it('should update variable state when changing the datasource', async () => {
const {
variable,
renderer: { getByTestId },
renderer: { getByTestId, getByText },
user,
} = await setup();
const dataSourcePicker = getByTestId(selectors.components.DataSourcePicker.container).getElementsByTagName('input');
expect(variable.state.datasource).toEqual({ uid: 'mock-ds-2', type: 'test' });
await user.click(getByTestId(selectors.components.DataSourcePicker.inputV2));
await user.click(getByText(/prom/i));
await waitFor(async () => {
await user.type(dataSourcePicker[0], 'm');
await user.tab();
await lastValueFrom(variable.validateAndUpdate());
});
expect(variable.state.datasource).toEqual({ uid: 'mock-ds-2', type: 'test' });
expect(variable.state.datasource).toEqual({ uid: 'mock-ds-3', type: 'prometheus' });
});
it('should update the variable state when changing the query', async () => {

@ -205,10 +205,10 @@ describe('AnnotationsSettings', () => {
await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'My Prometheus Annotation');
await userEvent.click(screen.getByText(/testdata/i));
await userEvent.click(screen.getByPlaceholderText(/testdata/i));
expect(await screen.findByText(/Prometheus/i)).toBeVisible();
expect(screen.queryAllByText(/testdata/i)).toHaveLength(2);
expect(screen.queryAllByText(/testdata/i)).toHaveLength(1);
await userEvent.click(screen.getByText(/prometheus/i));

@ -1,6 +1,7 @@
import React from 'react';
import { DataSourceInstanceSettings } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { DataSourceRef } from '@grafana/schema';
import { t } from 'app/core/internationalization';
@ -69,7 +70,7 @@ export function BuiltInDataSourceList({
const filteredResults = grafanaDataSources.filter((ds) => (filter ? filter?.(ds) : true) && !!ds.meta.builtIn);
return (
<div className={className} data-testid="built-in-data-sources-list">
<div className={className} data-testid={selectors.components.DataSourcePicker.advancedModal.builtInDataSourceList}>
{filteredResults.map((ds) => {
return (
<DataSourceCard

@ -1,437 +0,0 @@
import { css } from '@emotion/css';
import { useDialog } from '@react-aria/dialog';
import { FocusScope } from '@react-aria/focus';
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, DataSourceRef } from '@grafana/schema';
import { Button, CustomScrollbar, Icon, Input, ModalsController, Portal, useStyles2 } from '@grafana/ui';
import config from 'app/core/config';
import { Trans } from 'app/core/internationalization';
import { useKeyNavigationListener } from 'app/features/search/hooks/useSearchKeyboardSelection';
import { defaultFileUploadQuery, GrafanaQuery } from 'app/plugins/datasource/grafana/types';
import { useDatasource } from '../../hooks';
import { DataSourceList } from './DataSourceList';
import { DataSourceLogo, DataSourceLogoPlaceHolder } from './DataSourceLogo';
import { DataSourceModal } from './DataSourceModal';
import { applyMaxSize, maxSize } from './popperModifiers';
import { dataSourceLabel, matchDataSourceWithSearch } from './utils';
const INTERACTION_EVENT_NAME = 'dashboards_dspicker_clicked';
const INTERACTION_ITEM = {
OPEN_DROPDOWN: 'open_dspicker',
SELECT_DS: 'select_ds',
ADD_FILE: 'add_file',
OPEN_ADVANCED_DS_PICKER: 'open_advanced_ds_picker',
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;
recentlyUsed?: string[];
hideTextValue?: boolean;
width?: number;
inputId?: string;
noDefault?: boolean;
disabled?: boolean;
placeholder?: string;
// DS filters
tracing?: boolean;
mixed?: boolean;
dashboard?: boolean;
metrics?: boolean;
type?: string | string[];
annotations?: boolean;
variables?: boolean;
alerting?: boolean;
pluginId?: string;
logs?: boolean;
uploadFile?: boolean;
filter?: (ds: DataSourceInstanceSettings) => boolean;
}
export function DataSourceDropdown(props: DataSourceDropdownProps) {
const {
current,
onChange,
hideTextValue = false,
width,
inputId,
noDefault = false,
disabled = false,
placeholder = 'Select data source',
...restProps
} = props;
const styles = useStyles2(getStylesDropdown, props);
const [isOpen, setOpen] = useState(false);
const [inputHasFocus, setInputHasFocus] = useState(false);
const [filterTerm, setFilterTerm] = useState<string>('');
const { onKeyDown, keyboardEvents } = useKeyNavigationListener();
const ref = useRef<HTMLDivElement>(null);
// Used to position the popper correctly and to bring back the focus when navigating from footer to input
const [markerElement, setMarkerElement] = useState<HTMLInputElement | null>();
// Used to position the popper correctly
const [selectorElement, setSelectorElement] = useState<HTMLDivElement | null>();
// Used to move the focus to the footer when tabbing from the input
const [footerRef, setFooterRef] = useState<HTMLElement | null>();
const currentDataSourceInstanceSettings = useDatasource(current);
const grafanaDS = useDatasource('-- Grafana --');
const currentValue = Boolean(!current && noDefault) ? undefined : currentDataSourceInstanceSettings;
const prefixIcon =
filterTerm && isOpen ? <DataSourceLogoPlaceHolder /> : <DataSourceLogo dataSource={currentValue} />;
const popper = usePopper(markerElement, selectorElement, {
placement: 'bottom-start',
modifiers: [
{
name: 'offset',
options: {
offset: [0, 4],
},
},
maxSize,
applyMaxSize,
],
});
const onClose = useCallback(() => {
setFilterTerm('');
setOpen(false);
markerElement?.focus();
}, [setOpen, markerElement]);
const { overlayProps, underlayProps } = useOverlay(
{
onClose: onClose,
isDismissable: true,
isOpen,
shouldCloseOnInteractOutside: (element) => {
return markerElement ? !markerElement.isSameNode(element) : false;
},
},
ref
);
const { dialogProps } = useDialog(
{
'aria-label': 'Opened data source picker list',
},
ref
);
function openDropdown() {
reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.OPEN_DROPDOWN });
setOpen(true);
markerElement?.focus();
}
function onClickAddCSV() {
if (!grafanaDS) {
return;
}
onChange(grafanaDS, [defaultFileUploadQuery]);
}
function onKeyDownInput(keyEvent: React.KeyboardEvent<HTMLInputElement>) {
// From the input, it navigates to the footer
if (keyEvent.key === 'Tab' && !keyEvent.shiftKey && isOpen) {
keyEvent.preventDefault();
footerRef?.focus();
}
// From the input, if we navigate back, it closes the dropdown
if (keyEvent.key === 'Tab' && keyEvent.shiftKey && isOpen) {
onClose();
}
onKeyDown(keyEvent);
}
function onNavigateOutsiteFooter(e: React.KeyboardEvent<HTMLButtonElement>) {
// When navigating back, the dropdown keeps open and the input element is focused.
if (e.shiftKey) {
e.preventDefault();
markerElement?.focus();
// When navigating forward, the dropdown closes and and the element next to the input element is focused.
} else {
onClose();
}
}
useEffect(() => {
const sub = keyboardEvents.subscribe({
next: (keyEvent) => {
switch (keyEvent?.code) {
case 'ArrowDown':
openDropdown();
keyEvent.preventDefault();
break;
case 'ArrowUp':
openDropdown();
keyEvent.preventDefault();
break;
case 'Escape':
onClose();
keyEvent.preventDefault();
break;
}
},
});
return () => sub.unsubscribe();
});
return (
<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}
aria-label="Select a data source"
autoComplete="off"
prefix={currentValue ? prefixIcon : undefined}
suffix={<Icon name={isOpen ? 'search' : 'angle-down'} />}
placeholder={hideTextValue ? '' : dataSourceLabel(currentValue) || placeholder}
onFocus={() => {
setInputHasFocus(true);
}}
onBlur={() => {
setInputHasFocus(false);
}}
onKeyDown={onKeyDownInput}
value={filterTerm}
onChange={(e) => {
openDropdown();
setFilterTerm(e.currentTarget.value);
}}
ref={setMarkerElement}
disabled={disabled}
></Input>
</div>
{isOpen ? (
<Portal>
<div {...underlayProps} />
<div ref={ref} {...overlayProps} {...dialogProps}>
<PickerContent
{...restProps}
{...popper.attributes.popper}
style={popper.styles.popper}
ref={setSelectorElement}
footerRef={setFooterRef}
current={currentValue}
filterTerm={filterTerm}
keyboardEvents={keyboardEvents}
onChange={(ds: DataSourceInstanceSettings, defaultQueries?: DataQuery[] | GrafanaQuery[]) => {
onClose();
if (ds.uid !== currentValue?.uid) {
onChange(ds, defaultQueries);
reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.SELECT_DS, ds_type: ds.type });
}
}}
onClose={onClose}
onClickAddCSV={onClickAddCSV}
onDismiss={onClose}
onNavigateOutsiteFooter={onNavigateOutsiteFooter}
/>
</div>
</Portal>
) : null}
</div>
);
}
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::placeholder {
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;
footerRef: (element: HTMLElement | null) => void;
onNavigateOutsiteFooter: (e: React.KeyboardEvent<HTMLButtonElement>) => void;
}
const PickerContent = React.forwardRef<HTMLDivElement, PickerContentProps>((props, ref) => {
const { filterTerm, onChange, onClose, onClickAddCSV, current, filter } = props;
const changeCallback = useCallback(
(ds: DataSourceInstanceSettings) => {
onChange(ds);
},
[onChange]
);
const clickAddCSVCallback = useCallback(() => {
onClickAddCSV?.();
onClose();
reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.ADD_FILE });
}, [onClickAddCSV, onClose]);
const styles = useStyles2(getStylesPickerContent);
return (
<div style={props.style} ref={ref} className={styles.container}>
<CustomScrollbar>
<DataSourceList
{...props}
enableKeyboardNavigation
className={styles.dataSourceList}
current={current}
onChange={changeCallback}
filter={(ds) => (filter ? filter?.(ds) : true) && matchDataSourceWithSearch(ds, filterTerm)}
onClickEmptyStateCTA={() =>
reportInteraction(INTERACTION_EVENT_NAME, {
item: INTERACTION_ITEM.CONFIG_NEW_DS_EMPTY_STATE,
})
}
></DataSourceList>
</CustomScrollbar>
<FocusScope>
<Footer
{...props}
onClickAddCSV={clickAddCSVCallback}
onChange={changeCallback}
onNavigateOutsiteFooter={props.onNavigateOutsiteFooter}
/>
</FocusScope>
</div>
);
});
PickerContent.displayName = 'PickerContent';
function getStylesPickerContent(theme: GrafanaTheme2) {
return {
container: css`
display: flex;
flex-direction: column;
background: ${theme.colors.background.primary};
box-shadow: ${theme.shadows.z3};
`,
picker: css`
background: ${theme.colors.background.secondary};
`,
dataSourceList: css`
flex: 1;
`,
footer: css`
flex: 0;
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
padding: ${theme.spacing(1.5)};
border-top: 1px solid ${theme.colors.border.weak};
background-color: ${theme.colors.background.secondary};
`,
};
}
export interface FooterProps extends PickerContentProps {}
function Footer({ onClose, onChange, onClickAddCSV, ...props }: FooterProps) {
const styles = useStyles2(getStylesFooter);
const isUploadFileEnabled = props.uploadFile && config.featureToggles.editPanelCSVDragAndDrop;
const onKeyDownLastButton = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'Tab') {
props.onNavigateOutsiteFooter(e);
}
};
const onKeyDownFirstButton = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'Tab' && e.shiftKey) {
props.onNavigateOutsiteFooter(e);
}
};
return (
<div className={styles.footer}>
<ModalsController>
{({ showModal, hideModal }) => (
<Button
size="sm"
variant="secondary"
fill="text"
onClick={() => {
onClose();
showModal(DataSourceModal, {
reportedInteractionFrom: 'ds_picker',
tracing: props.tracing,
dashboard: props.dashboard,
mixed: props.mixed,
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);
hideModal();
},
});
reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.OPEN_ADVANCED_DS_PICKER });
}}
ref={props.footerRef}
onKeyDown={isUploadFileEnabled ? onKeyDownFirstButton : onKeyDownLastButton}
>
<Trans i18nKey="data-source-picker.open-advanced-button">Open advanced data source picker</Trans>
<Icon name="arrow-right" />
</Button>
)}
</ModalsController>
{isUploadFileEnabled && (
<Button variant="secondary" size="sm" onClick={onClickAddCSV} onKeyDown={onKeyDownLastButton}>
Add csv or spreadsheet
</Button>
)}
</div>
);
}
function getStylesFooter(theme: GrafanaTheme2) {
return {
footer: css`
flex: 0;
display: flex;
flex-direction: row-reverse;
justify-content: space-between;
padding: ${theme.spacing(1.5)};
border-top: 1px solid ${theme.colors.border.weak};
background-color: ${theme.colors.background.secondary};
`,
};
}

@ -3,6 +3,7 @@ import React, { useRef } from 'react';
import { Observable } from 'rxjs';
import { DataSourceInstanceSettings, DataSourceRef, GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { getTemplateSrv } from '@grafana/runtime';
import { useStyles2, useTheme2 } from '@grafana/ui';
import { Trans } from 'app/core/internationalization';
@ -72,7 +73,11 @@ export function DataSourceList(props: DataSourceListProps) {
const filteredDataSources = props.filter ? dataSources.filter(props.filter) : dataSources;
return (
<div ref={containerRef} className={cx(className, styles.container)} data-testid="data-sources-list">
<div
ref={containerRef}
className={cx(className, styles.container)}
data-testid={selectors.components.DataSourcePicker.dataSourceList}
>
{filteredDataSources.length === 0 && (
<EmptyState className={styles.emptyState} onClickCTA={onClickEmptyStateCTA} />
)}

@ -9,7 +9,7 @@ import { ModalRoot, ModalsProvider } from '@grafana/ui';
import config from 'app/core/config';
import { defaultFileUploadQuery } from 'app/plugins/datasource/grafana/types';
import { DataSourceDropdown, DataSourceDropdownProps } from './DataSourceDropdown';
import { DataSourcePicker, DataSourcePickerProps } from './DataSourcePicker';
import * as utils from './utils';
const pluginMetaInfo: PluginMetaInfo = {
@ -45,8 +45,8 @@ const MockDSBuiltIn = createDS('mock.datasource.builtin', 3, true);
const mockDSList = [mockDS1, mockDS2, MockDSBuiltIn];
async function setupOpenDropdown(user: UserEvent, props: DataSourceDropdownProps) {
const dropdown = render(<DataSourceDropdown {...props}></DataSourceDropdown>);
async function setupOpenDropdown(user: UserEvent, props: DataSourcePickerProps) {
const dropdown = render(<DataSourcePicker {...props}></DataSourcePicker>);
const searchBox = dropdown.container.querySelector('input');
expect(searchBox).toBeInTheDocument();
await user.click(searchBox!);
@ -93,9 +93,9 @@ beforeEach(() => {
getInstanceSettingsMock.mockReturnValue(mockDS1);
});
describe('DataSourceDropdown', () => {
describe('DataSourcePicker', () => {
it('should render', () => {
expect(() => render(<DataSourceDropdown onChange={jest.fn()}></DataSourceDropdown>)).not.toThrow();
expect(() => render(<DataSourcePicker onChange={jest.fn()}></DataSourcePicker>)).not.toThrow();
});
describe('configuration', () => {
@ -123,7 +123,7 @@ describe('DataSourceDropdown', () => {
render(
<ModalsProvider>
<DataSourceDropdown {...props}></DataSourceDropdown>
<DataSourcePicker {...props}></DataSourcePicker>
<ModalRoot />
</ModalsProvider>
);
@ -145,7 +145,7 @@ describe('DataSourceDropdown', () => {
it('should display the current selected DS in the selector', async () => {
getInstanceSettingsMock.mockReturnValue(mockDS2);
render(<DataSourceDropdown onChange={jest.fn()} current={mockDS2}></DataSourceDropdown>);
render(<DataSourcePicker onChange={jest.fn()} current={mockDS2}></DataSourcePicker>);
expect(screen.getByTestId(selectors.components.DataSourcePicker.inputV2)).toHaveAttribute(
'placeholder',
mockDS2.name
@ -169,7 +169,7 @@ describe('DataSourceDropdown', () => {
it('should display the default DS as selected when `current` is not set', async () => {
getInstanceSettingsMock.mockReturnValue(mockDS2);
render(<DataSourceDropdown onChange={jest.fn()} current={undefined}></DataSourceDropdown>);
render(<DataSourcePicker onChange={jest.fn()} current={undefined}></DataSourcePicker>);
expect(screen.getByTestId(selectors.components.DataSourcePicker.inputV2)).toHaveAttribute(
'placeholder',
mockDS2.name
@ -186,12 +186,12 @@ describe('DataSourceDropdown', () => {
});
it('should disable the dropdown when `disabled` is true', () => {
render(<DataSourceDropdown onChange={jest.fn()} disabled></DataSourceDropdown>);
render(<DataSourcePicker onChange={jest.fn()} disabled></DataSourcePicker>);
expect(screen.getByTestId(selectors.components.DataSourcePicker.inputV2)).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>);
render(<DataSourcePicker onChange={jest.fn()} inputId={'custom.input.id'}></DataSourcePicker>);
expect(screen.getByTestId(selectors.components.DataSourcePicker.inputV2)).toHaveAttribute(
'id',
'custom.input.id'
@ -199,7 +199,7 @@ describe('DataSourceDropdown', () => {
});
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>);
render(<DataSourcePicker onChange={jest.fn()} current={null} noDefault></DataSourcePicker>);
getListMock.mockClear();
getInstanceSettingsMock.mockClear();
// Doesn't try to get the default DS
@ -300,7 +300,7 @@ describe('DataSourceDropdown', () => {
const props = { onChange: jest.fn(), current: mockDS1.name };
render(
<ModalsProvider>
<DataSourceDropdown {...props}></DataSourceDropdown>
<DataSourcePicker {...props}></DataSourcePicker>
<ModalRoot />
</ModalsProvider>
);

@ -1,24 +1,437 @@
import React from 'react';
import { css } from '@emotion/css';
import { useDialog } from '@react-aria/dialog';
import { FocusScope } from '@react-aria/focus';
import { useOverlay } from '@react-aria/overlays';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { usePopper } from 'react-popper';
import { Observable } from 'rxjs';
import {
DataSourcePicker as DeprecatedDataSourcePicker,
DataSourcePickerProps as DeprecatedDataSourcePickerProps,
} from '@grafana/runtime';
import { config } from 'app/core/config';
import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { reportInteraction } from '@grafana/runtime';
import { DataQuery, DataSourceRef } from '@grafana/schema';
import { Button, CustomScrollbar, Icon, Input, ModalsController, Portal, useStyles2 } from '@grafana/ui';
import config from 'app/core/config';
import { Trans } from 'app/core/internationalization';
import { useKeyNavigationListener } from 'app/features/search/hooks/useSearchKeyboardSelection';
import { defaultFileUploadQuery, GrafanaQuery } from 'app/plugins/datasource/grafana/types';
import { DataSourceDropdown, DataSourceDropdownProps } from './DataSourceDropdown';
import { useDatasource } from '../../hooks';
type DataSourcePickerProps = DeprecatedDataSourcePickerProps | DataSourceDropdownProps;
import { DataSourceList } from './DataSourceList';
import { DataSourceLogo, DataSourceLogoPlaceHolder } from './DataSourceLogo';
import { DataSourceModal } from './DataSourceModal';
import { applyMaxSize, maxSize } from './popperModifiers';
import { dataSourceLabel, matchDataSourceWithSearch } from './utils';
const INTERACTION_EVENT_NAME = 'dashboards_dspicker_clicked';
const INTERACTION_ITEM = {
OPEN_DROPDOWN: 'open_dspicker',
SELECT_DS: 'select_ds',
ADD_FILE: 'add_file',
OPEN_ADVANCED_DS_PICKER: 'open_advanced_ds_picker',
CONFIG_NEW_DS_EMPTY_STATE: 'config_new_ds_empty_state',
};
export interface DataSourcePickerProps {
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;
mixed?: boolean;
dashboard?: boolean;
metrics?: boolean;
type?: string | string[];
annotations?: boolean;
variables?: boolean;
alerting?: boolean;
pluginId?: string;
logs?: boolean;
uploadFile?: boolean;
filter?: (ds: DataSourceInstanceSettings) => boolean;
}
/**
* DataSourcePicker is a wrapper around the old DataSourcePicker and the new one.
* Depending on the feature toggle, it will render the old or the new one.
* Feature toggle: advancedDataSourcePicker
*/
export function DataSourcePicker(props: DataSourcePickerProps) {
return !config.featureToggles.advancedDataSourcePicker ? (
<DeprecatedDataSourcePicker {...props} />
) : (
<DataSourceDropdown {...props} />
const {
current,
onChange,
hideTextValue = false,
width,
inputId,
noDefault = false,
disabled = false,
placeholder = 'Select data source',
...restProps
} = props;
const styles = useStyles2(getStylesDropdown, props);
const [isOpen, setOpen] = useState(false);
const [inputHasFocus, setInputHasFocus] = useState(false);
const [filterTerm, setFilterTerm] = useState<string>('');
const { onKeyDown, keyboardEvents } = useKeyNavigationListener();
const ref = useRef<HTMLDivElement>(null);
// Used to position the popper correctly and to bring back the focus when navigating from footer to input
const [markerElement, setMarkerElement] = useState<HTMLInputElement | null>();
// Used to position the popper correctly
const [selectorElement, setSelectorElement] = useState<HTMLDivElement | null>();
// Used to move the focus to the footer when tabbing from the input
const [footerRef, setFooterRef] = useState<HTMLElement | null>();
const currentDataSourceInstanceSettings = useDatasource(current);
const grafanaDS = useDatasource('-- Grafana --');
const currentValue = Boolean(!current && noDefault) ? undefined : currentDataSourceInstanceSettings;
const prefixIcon =
filterTerm && isOpen ? <DataSourceLogoPlaceHolder /> : <DataSourceLogo dataSource={currentValue} />;
const popper = usePopper(markerElement, selectorElement, {
placement: 'bottom-start',
modifiers: [
{
name: 'offset',
options: {
offset: [0, 4],
},
},
maxSize,
applyMaxSize,
],
});
const onClose = useCallback(() => {
setFilterTerm('');
setOpen(false);
markerElement?.focus();
}, [setOpen, markerElement]);
const { overlayProps, underlayProps } = useOverlay(
{
onClose: onClose,
isDismissable: true,
isOpen,
shouldCloseOnInteractOutside: (element) => {
return markerElement ? !markerElement.isSameNode(element) : false;
},
},
ref
);
const { dialogProps } = useDialog(
{
'aria-label': 'Opened data source picker list',
},
ref
);
function openDropdown() {
reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.OPEN_DROPDOWN });
setOpen(true);
markerElement?.focus();
}
function onClickAddCSV() {
if (!grafanaDS) {
return;
}
onChange(grafanaDS, [defaultFileUploadQuery]);
}
function onKeyDownInput(keyEvent: React.KeyboardEvent<HTMLInputElement>) {
// From the input, it navigates to the footer
if (keyEvent.key === 'Tab' && !keyEvent.shiftKey && isOpen) {
keyEvent.preventDefault();
footerRef?.focus();
}
// From the input, if we navigate back, it closes the dropdown
if (keyEvent.key === 'Tab' && keyEvent.shiftKey && isOpen) {
onClose();
}
onKeyDown(keyEvent);
}
function onNavigateOutsiteFooter(e: React.KeyboardEvent<HTMLButtonElement>) {
// When navigating back, the dropdown keeps open and the input element is focused.
if (e.shiftKey) {
e.preventDefault();
markerElement?.focus();
// When navigating forward, the dropdown closes and and the element next to the input element is focused.
} else {
onClose();
}
}
useEffect(() => {
const sub = keyboardEvents.subscribe({
next: (keyEvent) => {
switch (keyEvent?.code) {
case 'ArrowDown':
openDropdown();
keyEvent.preventDefault();
break;
case 'ArrowUp':
openDropdown();
keyEvent.preventDefault();
break;
case 'Escape':
onClose();
keyEvent.preventDefault();
break;
}
},
});
return () => sub.unsubscribe();
});
return (
<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}
aria-label="Select a data source"
autoComplete="off"
prefix={currentValue ? prefixIcon : undefined}
suffix={<Icon name={isOpen ? 'search' : 'angle-down'} />}
placeholder={hideTextValue ? '' : dataSourceLabel(currentValue) || placeholder}
onFocus={() => {
setInputHasFocus(true);
}}
onBlur={() => {
setInputHasFocus(false);
}}
onKeyDown={onKeyDownInput}
value={filterTerm}
onChange={(e) => {
openDropdown();
setFilterTerm(e.currentTarget.value);
}}
ref={setMarkerElement}
disabled={disabled}
></Input>
</div>
{isOpen ? (
<Portal>
<div {...underlayProps} />
<div ref={ref} {...overlayProps} {...dialogProps}>
<PickerContent
{...restProps}
{...popper.attributes.popper}
style={popper.styles.popper}
ref={setSelectorElement}
footerRef={setFooterRef}
current={currentValue}
filterTerm={filterTerm}
keyboardEvents={keyboardEvents}
onChange={(ds: DataSourceInstanceSettings, defaultQueries?: DataQuery[] | GrafanaQuery[]) => {
onClose();
if (ds.uid !== currentValue?.uid) {
onChange(ds, defaultQueries);
reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.SELECT_DS, ds_type: ds.type });
}
}}
onClose={onClose}
onClickAddCSV={onClickAddCSV}
onDismiss={onClose}
onNavigateOutsiteFooter={onNavigateOutsiteFooter}
/>
</div>
</Portal>
) : null}
</div>
);
}
function getStylesDropdown(theme: GrafanaTheme2, props: DataSourcePickerProps) {
return {
container: css({
position: 'relative',
cursor: props.disabled ? 'not-allowed' : 'pointer',
width: theme.spacing(props.width || 'auto'),
}),
trigger: css({
cursor: 'pointer',
pointerEvents: props.disabled ? 'none' : 'auto',
}),
input: css({
'input::placeholder': {
color: props.disabled ? theme.colors.action.disabledText : theme.colors.text.primary,
},
}),
};
}
export interface PickerContentProps extends DataSourcePickerProps {
onClickAddCSV?: () => void;
keyboardEvents: Observable<React.KeyboardEvent>;
style: React.CSSProperties;
filterTerm?: string;
onClose: () => void;
onDismiss: () => void;
footerRef: (element: HTMLElement | null) => void;
onNavigateOutsiteFooter: (e: React.KeyboardEvent<HTMLButtonElement>) => void;
}
const PickerContent = React.forwardRef<HTMLDivElement, PickerContentProps>((props, ref) => {
const { filterTerm, onChange, onClose, onClickAddCSV, current, filter } = props;
const changeCallback = useCallback(
(ds: DataSourceInstanceSettings) => {
onChange(ds);
},
[onChange]
);
const clickAddCSVCallback = useCallback(() => {
onClickAddCSV?.();
onClose();
reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.ADD_FILE });
}, [onClickAddCSV, onClose]);
const styles = useStyles2(getStylesPickerContent);
return (
<div style={props.style} ref={ref} className={styles.container}>
<CustomScrollbar>
<DataSourceList
{...props}
enableKeyboardNavigation
className={styles.dataSourceList}
current={current}
onChange={changeCallback}
filter={(ds) => (filter ? filter?.(ds) : true) && matchDataSourceWithSearch(ds, filterTerm)}
onClickEmptyStateCTA={() =>
reportInteraction(INTERACTION_EVENT_NAME, {
item: INTERACTION_ITEM.CONFIG_NEW_DS_EMPTY_STATE,
})
}
></DataSourceList>
</CustomScrollbar>
<FocusScope>
<Footer
{...props}
onClickAddCSV={clickAddCSVCallback}
onChange={changeCallback}
onNavigateOutsiteFooter={props.onNavigateOutsiteFooter}
/>
</FocusScope>
</div>
);
});
PickerContent.displayName = 'PickerContent';
function getStylesPickerContent(theme: GrafanaTheme2) {
return {
container: css({
display: 'flex',
flexDirection: 'column',
background: theme.colors.background.primary,
boxShadow: theme.shadows.z3,
}),
picker: css({
background: theme.colors.background.secondary,
}),
dataSourceList: css({
flex: 1,
}),
footer: css({
flex: 0,
display: 'flex',
flexDirection: 'row-reverse',
justifyContent: 'space-between',
padding: theme.spacing(1.5),
borderTop: `1px solid ${theme.colors.border.weak}`,
backgroundColor: theme.colors.background.secondary,
}),
};
}
export interface FooterProps extends PickerContentProps {}
function Footer({ onClose, onChange, onClickAddCSV, ...props }: FooterProps) {
const styles = useStyles2(getStylesFooter);
const isUploadFileEnabled = props.uploadFile && config.featureToggles.editPanelCSVDragAndDrop;
const onKeyDownLastButton = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'Tab') {
props.onNavigateOutsiteFooter(e);
}
};
const onKeyDownFirstButton = (e: React.KeyboardEvent<HTMLButtonElement>) => {
if (e.key === 'Tab' && e.shiftKey) {
props.onNavigateOutsiteFooter(e);
}
};
return (
<div className={styles.footer}>
<ModalsController>
{({ showModal, hideModal }) => (
<Button
size="sm"
variant="secondary"
fill="text"
onClick={() => {
onClose();
showModal(DataSourceModal, {
reportedInteractionFrom: 'ds_picker',
tracing: props.tracing,
dashboard: props.dashboard,
mixed: props.mixed,
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);
hideModal();
},
});
reportInteraction(INTERACTION_EVENT_NAME, { item: INTERACTION_ITEM.OPEN_ADVANCED_DS_PICKER });
}}
ref={props.footerRef}
onKeyDown={isUploadFileEnabled ? onKeyDownFirstButton : onKeyDownLastButton}
>
<Trans i18nKey="data-source-picker.open-advanced-button">Open advanced data source picker</Trans>
<Icon name="arrow-right" />
</Button>
)}
</ModalsController>
{isUploadFileEnabled && (
<Button variant="secondary" size="sm" onClick={onClickAddCSV} onKeyDown={onKeyDownLastButton}>
Add csv or spreadsheet
</Button>
)}
</div>
);
}
function getStylesFooter(theme: GrafanaTheme2) {
return {
footer: css({
flex: 0,
display: 'flex',
flexDirection: 'row-reverse',
justifyContent: 'space-between',
padding: theme.spacing(1.5),
borderTop: `1px solid ${theme.colors.border.weak}`,
backgroundColor: theme.colors.background.secondary,
}),
};
}

@ -1,4 +1,4 @@
import { fireEvent, screen, within } from '@testing-library/react';
import { screen, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { selectors } from '@grafana/e2e-selectors';
@ -7,9 +7,9 @@ import { getAllByRoleInQueryHistoryTab, withinExplore } from './setup';
export const changeDatasource = async (name: string) => {
const datasourcePicker = (await screen.findByTestId(selectors.components.DataSourcePicker.container)).children[0];
fireEvent.keyDown(datasourcePicker, { keyCode: 40 });
const option = screen.getByText(name);
fireEvent.click(option);
await userEvent.click(datasourcePicker);
const option = within(screen.getByTestId(selectors.components.DataSourcePicker.dataSourceList)).getAllByText(name)[0];
await userEvent.click(option);
};
export const inputQuery = async (query: string, exploreId = 'left') => {

@ -1,7 +1,6 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { openMenu } from 'react-select-event';
import { DataSourceInstanceSettings } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
@ -84,7 +83,7 @@ describe('QueryEditorRowHeader', () => {
renderScenario({ onChangeDataSource: () => {} });
const dsSelect = screen.getByTestId(selectors.components.DataSourcePicker.container).querySelector('input')!;
openMenu(dsSelect);
userEvent.click(dsSelect);
expect(await screen.findByText('${dsVariable}')).toBeInTheDocument();
});
});

@ -497,7 +497,7 @@ function DataSourcePickerWithPrompt({ options, onChange, ...otherProps }: DataSo
return (
<>
{isDataSourceModalOpen && config.featureToggles.advancedDataSourcePicker && (
{isDataSourceModalOpen && (
<DataSourceModal {...commonProps} onDismiss={() => setIsDataSourceModalOpen(false)}></DataSourceModal>
)}

@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React, { ComponentProps } from 'react';
import { selectOptionInTest } from 'test/helpers/selectOptionInTest';
import { selectors } from '@grafana/e2e-selectors';
import { mockDataSource } from 'app/features/alerting/unified/mocks';
@ -68,7 +68,8 @@ describe('AdHocVariableEditor', () => {
const selectEl = screen
.getByTestId(selectors.components.DataSourcePicker.container)
.getElementsByTagName('input')[0];
await selectOptionInTest(selectEl, 'Loki');
await userEvent.click(selectEl);
await userEvent.click(screen.getByText('Loki'));
expect(props.changeVariableDatasource).toBeCalledWith(
{ type: 'adhoc', id: 'adhoc', rootStateKey: 'key' },

@ -13,7 +13,7 @@ export const ui = {
inputs: {
name: byRole('textbox', { name: 'name' }),
alertType: byTestId('alert-type-picker'),
dataSource: byTestId('datasource-picker'),
dataSource: byTestId(selectors.components.DataSourcePicker.inputV2),
folder: byTestId('folder-picker'),
folderContainer: byTestId(selectors.components.FolderPicker.containerV2),
namespace: byTestId('namespace-picker'),

Loading…
Cancel
Save