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
This commit is contained in:
+3
-15
@@ -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
|
||||
|
||||
|
@@ -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');
|
||||
|
||||
|
||||
-1
@@ -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');
|
||||
|
||||
+6
-7
@@ -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 () => {
|
||||
|
||||
+9
-7
@@ -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 () => {
|
||||
|
||||
+2
-2
@@ -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} />
|
||||
)}
|
||||
|
||||
+12
-12
@@ -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'),
|
||||
|
||||
Reference in New Issue
Block a user