DataSourcePicker: Add new style of data source picker. (#63736)

* MVP of a new datasource picker

* Add datasource select history, naming DatasourceSelect -> DataSourceDrawer

* refactor cards

* Cleanup and fixing sort order for recents

* add feature flag

* fix feature flag name and use it

* Highlight selected

* Move new ds picker to core

* Restore original datasource picker

* Remove unused property

* update yarn.lock

* Rename folder, update codeowners

* add test for util functions

* Remove es-lint exception

* Change feature toggle description

* remove unnecessary if

Co-authored-by: Ivan Ortega Alba <ivanortegaalba@gmail.com>

* Make test a bit more clear

Co-authored-by: Ivan Ortega Alba <ivanortegaalba@gmail.com>

* Clean up api, filter once and before maps, minor code cleanup

* Fix prettier issue

---------

Co-authored-by: Ivan Ortega Alba <ivanortegaalba@gmail.com>
This commit is contained in:
Oscar Kilhed 2023-03-01 11:26:19 +01:00 committed by GitHub
parent 27635e6f7b
commit dc1600ff14
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 587 additions and 9 deletions

1
.github/CODEOWNERS vendored
View File

@ -356,6 +356,7 @@ lerna.json @grafana/frontend-ops
/public/app/features/datasources/ @grafana/user-essentials
/public/app/features/dimensions/ @grafana/dataviz-squad
/public/app/features/dataframe-import/ @grafana/grafana-bi-squad
/public/app/features/datasource-drawer/ @grafana/grafana-bi-squad
/public/app/features/explore/ @grafana/explore-squad
/public/app/features/expressions/ @grafana/observability-metrics
/public/app/features/folders/ @grafana/user-essentials

View File

@ -92,6 +92,7 @@ Alpha features might be changed or removed without prior notice.
| `logsContextDatasourceUi` | Allow datasource to provide custom UI for context view |
| `lokiQuerySplitting` | Split large interval queries into subqueries with smaller time intervals |
| `individualCookiePreferences` | Support overriding cookie preferences per user |
| `drawerDataSourcePicker` | Changes the user experience for data source selection to a drawer. |
## Development feature toggles

View File

@ -82,4 +82,5 @@ export interface FeatureToggles {
logsContextDatasourceUi?: boolean;
lokiQuerySplitting?: boolean;
individualCookiePreferences?: boolean;
drawerDataSourcePicker?: boolean;
}

View File

@ -372,5 +372,11 @@ var (
Description: "Support overriding cookie preferences per user",
State: FeatureStateAlpha,
},
{
Name: "drawerDataSourcePicker",
Description: "Changes the user experience for data source selection to a drawer.",
State: FeatureStateAlpha,
FrontendOnly: true,
},
}
)

View File

@ -270,4 +270,8 @@ const (
// FlagIndividualCookiePreferences
// Support overriding cookie preferences per user
FlagIndividualCookiePreferences = "individualCookiePreferences"
// FlagDrawerDataSourcePicker
// Changes the user experience for data source selection to a drawer.
FlagDrawerDataSourcePicker = "drawerDataSourcePicker"
)

View File

@ -0,0 +1,37 @@
import { DataSourceInstanceSettings } from '@grafana/data';
import { DataSourceJsonData, DataSourceRef } from '@grafana/schema';
import { isDataSourceMatch } from './DataSourceDrawer';
describe('DataSourceDrawer', () => {
describe('isDataSourceMatch', () => {
const dataSourceInstanceSettings = { uid: 'a' } as DataSourceInstanceSettings<DataSourceJsonData>;
it('matches a string with the uid', () => {
expect(isDataSourceMatch(dataSourceInstanceSettings, 'a')).toBeTruthy();
});
it('matches a datasource with a datasource by the uid', () => {
expect(
isDataSourceMatch(dataSourceInstanceSettings, { uid: 'a' } as DataSourceInstanceSettings<DataSourceJsonData>)
).toBeTruthy();
});
it('matches a datasource ref with a datasource by the uid', () => {
expect(isDataSourceMatch(dataSourceInstanceSettings, { uid: 'a' } as DataSourceRef)).toBeTruthy();
});
it('doesnt match with null', () => {
expect(isDataSourceMatch(dataSourceInstanceSettings, null)).toBeFalsy();
});
it('doesnt match a datasource to a non matching string', () => {
expect(isDataSourceMatch(dataSourceInstanceSettings, 'b')).toBeFalsy();
});
it('doesnt match a datasource with a different datasource uid', () => {
expect(
isDataSourceMatch(dataSourceInstanceSettings, { uid: 'b' } as DataSourceInstanceSettings<DataSourceJsonData>)
).toBeFalsy();
});
it('doesnt match a datasource with a datasource ref with a different uid', () => {
expect(isDataSourceMatch(dataSourceInstanceSettings, { uid: 'b' } as DataSourceRef)).toBeFalsy();
});
});
});

View File

@ -0,0 +1,161 @@
import { css } from '@emotion/css';
import React, { useCallback, useState } from 'react';
import { DataSourceInstanceSettings, DataSourceJsonData, DataSourceRef, GrafanaTheme2 } from '@grafana/data';
import {
Button,
CustomScrollbar,
Drawer,
FileDropzone,
FileDropzoneDefaultChildren,
Input,
ModalsController,
useStyles2,
} from '@grafana/ui';
import { DataSourceCard } from './components/DataSourceCard';
import { DataSourceDisplay } from './components/DataSourceDisplay';
import { PickerContentProps, DataSourceDrawerProps } from './types';
export function DataSourceDrawer(props: DataSourceDrawerProps) {
const { current, onChange } = props;
const styles = useStyles2(getStyles);
return (
<ModalsController>
{({ showModal, hideModal }) => (
<Button
className={styles.picker}
onClick={() => {
showModal(PickerContent, {
...props,
onDismiss: hideModal,
onChange: (ds) => {
onChange(ds);
hideModal();
},
});
}}
>
<DataSourceDisplay dataSource={current}></DataSourceDisplay>
</Button>
)}
</ModalsController>
);
}
function PickerContent(props: PickerContentProps) {
const { datasources, enableFileUpload, recentlyUsed = [], onChange, fileUploadOptions, onDismiss, current } = props;
const changeCallback = useCallback(
(ds: string) => {
onChange(ds);
},
[onChange]
);
const [filterTerm, onFilterChange] = useState<string>('');
const styles = useStyles2(getStyles);
const filteredDataSources = datasources.filter((ds) => {
return ds?.name.toLocaleLowerCase().indexOf(filterTerm.toLocaleLowerCase()) !== -1;
});
return (
<Drawer closeOnMaskClick={true} onClose={onDismiss}>
<div className={styles.drawerContent}>
<div className={styles.filterContainer}>
<Input
onChange={(e) => {
onFilterChange(e.currentTarget.value);
}}
value={filterTerm}
></Input>
</div>
<div className={styles.dataSourceList}>
<CustomScrollbar>
{recentlyUsed
.map((uid) => filteredDataSources.find((ds) => ds.uid === uid))
.map((ds) => {
if (!ds) {
return null;
}
return (
<DataSourceCard
selected={isDataSourceMatch(ds, current)}
key={ds.uid}
ds={ds}
onChange={changeCallback}
/>
);
})}
{recentlyUsed && recentlyUsed.length > 0 && <hr />}
{filteredDataSources.map((ds) => (
<DataSourceCard
selected={isDataSourceMatch(ds, current)}
key={ds.uid}
ds={ds}
onChange={changeCallback}
/>
))}
</CustomScrollbar>
</div>
{enableFileUpload && (
<div className={styles.additionalContent}>
<FileDropzone
readAs="readAsArrayBuffer"
fileListRenderer={() => undefined}
options={{
...fileUploadOptions,
onDrop: (...args) => {
onDismiss();
fileUploadOptions?.onDrop?.(...args);
},
}}
>
<FileDropzoneDefaultChildren primaryText={'Upload file'} />
</FileDropzone>
</div>
)}
</div>
</Drawer>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
drawerContent: css`
display: flex;
flex-direction: column;
height: 100%;
`,
picker: css`
background: ${theme.colors.background.secondary};
`,
filterContainer: css`
padding-bottom: ${theme.spacing(1)};
`,
dataSourceList: css`
height: 50px;
flex-grow: 1;
`,
additionalContent: css`
padding-top: ${theme.spacing(1)};
`,
};
}
export function isDataSourceMatch(
ds: DataSourceInstanceSettings<DataSourceJsonData> | undefined,
current: string | DataSourceInstanceSettings<DataSourceJsonData> | DataSourceRef | null | undefined
): boolean | undefined {
if (!ds) {
return false;
}
if (!current) {
return false;
}
if (typeof current === 'string') {
return ds.uid === current;
}
return ds.uid === current.uid;
}

View File

@ -0,0 +1,98 @@
import React, { PureComponent } from 'react';
// Components
import { DataSourceInstanceSettings, DataSourceRef, getDataSourceUID } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import { DataSourceJsonData } from '@grafana/schema';
import { DataSourceDrawer } from './DataSourceDrawer';
import { DataSourcePickerProps } from './types';
/**
* Component state description for the {@link DataSourcePicker}
*
* @internal
*/
export interface DataSourcePickerState {
error?: string;
}
/**
* Component to be able to select a datasource from the list of installed and enabled
* datasources in the current Grafana instance.
*
* @internal
*/
export class DataSourcePicker extends PureComponent<DataSourcePickerProps, DataSourcePickerState> {
dataSourceSrv = getDataSourceSrv();
state: DataSourcePickerState = {};
componentDidMount() {
const { current } = this.props;
const dsSettings = this.dataSourceSrv.getInstanceSettings(current);
if (!dsSettings) {
this.setState({ error: 'Could not find data source ' + current });
}
}
onChange = (ds?: string) => {
const dsSettings = this.dataSourceSrv.getInstanceSettings(ds);
if (dsSettings) {
this.props.onChange(dsSettings);
this.setState({ error: undefined });
}
};
private getCurrentDs(): DataSourceInstanceSettings<DataSourceJsonData> | string | DataSourceRef | null | undefined {
const { current, noDefault } = this.props;
if (!current && noDefault) {
return;
}
const ds = this.dataSourceSrv.getInstanceSettings(current);
if (ds) {
return ds;
}
return getDataSourceUID(current);
}
getDatasources() {
const { alerting, tracing, metrics, mixed, dashboard, variables, annotations, pluginId, type, filter, logs } =
this.props;
return this.dataSourceSrv.getList({
alerting,
tracing,
metrics,
logs,
dashboard,
mixed,
variables,
annotations,
pluginId,
filter,
type,
});
}
render() {
const { recentlyUsed, fileUploadOptions, enableFileUpload } = this.props;
return (
<div>
<DataSourceDrawer
datasources={this.getDatasources()}
onChange={this.onChange}
recentlyUsed={recentlyUsed}
current={this.getCurrentDs()}
fileUploadOptions={fileUploadOptions}
enableFileUpload={enableFileUpload}
/>
</div>
);
}
}

View File

@ -0,0 +1,27 @@
import { updateHistory } from './DataSourcePickerWithHistory';
describe('DataSourcePickerWithHistory', () => {
describe('updateHistory', () => {
const early = { uid: 'b', lastUse: '2023-02-27T13:39:08.318Z' };
const later = { uid: 'a', lastUse: '2023-02-28T13:39:08.318Z' };
it('should add an item to the history', () => {
expect(updateHistory([], early)).toEqual([early]);
});
it('should sort later entries first', () => {
expect(updateHistory([early], later)).toEqual([later, early]);
});
it('should update an already existing history item with the new lastUsed date', () => {
const laterB = { uid: early.uid, lastUse: later.lastUse };
expect(updateHistory([early], laterB)).toEqual([laterB]);
});
it('should keep the three latest items in history', () => {
const evenLater = { uid: 'c', lastUse: '2023-03-01T13:39:08.318Z' };
const latest = { uid: 'd', lastUse: '2023-03-02T13:39:08.318Z' };
expect(updateHistory([early, later, evenLater], latest)).toEqual([latest, evenLater, later]);
});
});
});

View File

@ -0,0 +1,55 @@
import React from 'react';
import { dateTime } from '@grafana/data';
import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider';
import { DataSourcePicker } from './DataSourcePicker';
import { DataSourcePickerHistoryItem, DataSourcePickerWithHistoryProps } from './types';
const DS_PICKER_STORAGE_KEY = 'DATASOURCE_PICKER';
export const DataSourcePickerWithHistory = (props: DataSourcePickerWithHistoryProps) => {
return (
<LocalStorageValueProvider<DataSourcePickerHistoryItem[]>
defaultValue={[]}
storageKey={props.localStorageKey ?? DS_PICKER_STORAGE_KEY}
>
{(rawValues, onSaveToStore) => {
return (
<DataSourcePicker
{...props}
recentlyUsed={rawValues.map((dsi) => dsi.uid)} //Filter recently to have a time cutoff
onChange={(ds) => {
onSaveToStore(updateHistory(rawValues, { uid: ds.uid, lastUse: dateTime(new Date()).toISOString() }));
props.onChange(ds);
}}
></DataSourcePicker>
);
}}
</LocalStorageValueProvider>
);
};
export function updateHistory(values: DataSourcePickerHistoryItem[], newValue: DataSourcePickerHistoryItem) {
const newHistory = values;
const existingIndex = newHistory.findIndex((dpi) => dpi.uid === newValue.uid);
if (existingIndex !== -1) {
newHistory[existingIndex] = newValue;
} else {
newHistory.push(newValue);
}
newHistory.sort((a, b) => {
const al = dateTime(a.lastUse);
const bl = dateTime(b.lastUse);
if (al.isBefore(bl)) {
return 1;
} else if (bl.isBefore(al)) {
return -1;
} else {
return 0;
}
});
return newHistory.slice(0, 3);
}

View File

@ -0,0 +1,38 @@
import { css } from '@emotion/css';
import React from 'react';
import { DataSourceInstanceSettings, DataSourceJsonData, GrafanaTheme2 } from '@grafana/data';
import { Card, PluginSignatureBadge, Tag, useStyles2 } from '@grafana/ui';
export interface DataSourceCardProps {
onChange: (uid: string) => void;
selected?: boolean;
ds: DataSourceInstanceSettings<DataSourceJsonData>;
}
export function DataSourceCard(props: DataSourceCardProps) {
const { selected, ds, onChange } = props;
const styles = useStyles2(getStyles);
return (
<Card className={selected ? styles.selectedDataSource : undefined} key={ds.uid} onClick={() => onChange(ds.uid)}>
<Card.Figure>
<img alt={`${ds.meta.name} logo`} src={ds.meta.info.logos.large}></img>
</Card.Figure>
<Card.Meta>
{[ds.meta.name, ds.url, ds.isDefault && <Tag key="default-tag" name={'default'} colorIndex={1} />]}
</Card.Meta>
<Card.Tags>
<PluginSignatureBadge status={ds.meta.signature} />
</Card.Tags>
<Card.Heading>{ds.name}</Card.Heading>
</Card>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
selectedDataSource: css`
background-color: ${theme.colors.emphasize(theme.colors.background.secondary, 0.1)};
`,
};
}

View File

@ -0,0 +1,48 @@
import { css } from '@emotion/css';
import React from 'react';
import { DataSourceInstanceSettings, DataSourceJsonData, GrafanaTheme2 } from '@grafana/data';
import { DataSourceRef } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
export interface DataSourceDisplayProps {
dataSource: DataSourceInstanceSettings<DataSourceJsonData> | string | DataSourceRef | null | undefined;
}
export function DataSourceDisplay(props: DataSourceDisplayProps) {
const { dataSource } = props;
const styles = useStyles2(getStyles);
if (!dataSource) {
return <span>Unknown</span>;
}
if (typeof dataSource === 'string') {
return <span>${dataSource} - not found</span>;
}
if ('name' in dataSource) {
return (
<>
<img
className={styles.pickerDSLogo}
alt={`${dataSource.meta.name} logo`}
src={dataSource.meta.info.logos.small}
></img>
<span>{dataSource.name}</span>
</>
);
}
return <span>{dataSource.uid} - not found</span>;
}
function getStyles(theme: GrafanaTheme2) {
return {
pickerDSLogo: css`
height: 20px;
width: 20px;
margin-right: ${theme.spacing(1)};
`,
};
}

View File

@ -0,0 +1,52 @@
import { DropzoneOptions } from 'react-dropzone';
import { DataSourceInstanceSettings } from '@grafana/data';
import { DataSourceJsonData, DataSourceRef } from '@grafana/schema';
export interface DataSourceDrawerProps {
datasources: Array<DataSourceInstanceSettings<DataSourceJsonData>>;
onFileDrop?: () => void;
onChange: (ds: string) => void;
current: DataSourceInstanceSettings<DataSourceJsonData> | string | DataSourceRef | null | undefined;
enableFileUpload?: boolean;
fileUploadOptions?: DropzoneOptions;
recentlyUsed?: string[];
}
export interface PickerContentProps extends DataSourceDrawerProps {
onDismiss: () => void;
}
export interface DataSourcePickerProps {
onChange: (ds: DataSourceInstanceSettings) => void;
current: DataSourceRef | string | null; // uid
tracing?: boolean;
recentlyUsed?: string[];
mixed?: boolean;
dashboard?: boolean;
metrics?: boolean;
type?: string | string[];
annotations?: boolean;
variables?: boolean;
alerting?: boolean;
pluginId?: string;
/** If true,we show only DSs with logs; and if true, pluginId shouldnt be passed in */
logs?: boolean;
// Does not set the default data source if there is no value.
noDefault?: boolean;
inputId?: string;
filter?: (dataSource: DataSourceInstanceSettings) => boolean;
onClear?: () => void;
disabled?: boolean;
enableFileUpload?: boolean;
fileUploadOptions?: DropzoneOptions;
}
export interface DataSourcePickerWithHistoryProps extends Omit<DataSourcePickerProps, 'recentlyUsed'> {
localStorageKey?: string;
}
export interface DataSourcePickerHistoryItem {
lastUse: string;
uid: string;
}

View File

@ -1,9 +1,12 @@
import { css } from '@emotion/css';
import React, { PureComponent } from 'react';
import { DropEvent, FileRejection } from 'react-dropzone';
import { Unsubscribable } from 'rxjs';
import {
CoreApp,
DataFrameJSON,
dataFrameToJSON,
DataQuery,
DataSourceApi,
DataSourceInstanceSettings,
@ -18,8 +21,11 @@ import { PluginHelp } from 'app/core/components/PluginHelp/PluginHelp';
import config from 'app/core/config';
import { backendSrv } from 'app/core/services/backend_srv';
import { addQuery, queryIsEmpty } from 'app/core/utils/query';
import * as DFImport from 'app/features/dataframe-import';
import { DataSourcePickerWithHistory } from 'app/features/datasource-drawer/DataSourcePickerWithHistory';
import { dataSource as expressionDatasource } from 'app/features/expressions/ExpressionDatasource';
import { DashboardQueryEditor, isSharedDashboardQuery } from 'app/plugins/datasource/dashboard';
import { GrafanaQuery, GrafanaQueryType } from 'app/plugins/datasource/grafana/types';
import { QueryGroupDataSource, QueryGroupOptions } from 'app/types';
import { isQueryWithMixedDatasource } from '../../query-library/api/SavedQueriesApi';
@ -274,14 +280,32 @@ export class QueryGroup extends PureComponent<Props, State> {
Data source
</InlineFormLabel>
<div className={styles.dataSourceRowItem}>
<DataSourcePicker
onChange={this.onChangeDataSource}
current={options.dataSource}
metrics={true}
mixed={true}
dashboard={true}
variables={true}
/>
{config.featureToggles.drawerDataSourcePicker ? (
<DataSourcePickerWithHistory
onChange={this.onChangeDataSource}
current={options.dataSource}
metrics={true}
mixed={true}
dashboard={true}
variables={true}
enableFileUpload={config.featureToggles.editPanelCSVDragAndDrop}
fileUploadOptions={{
onDrop: this.onFileDrop,
maxSize: DFImport.maxFileSize,
multiple: false,
accept: DFImport.acceptedFiles,
}}
></DataSourcePickerWithHistory>
) : (
<DataSourcePicker
onChange={this.onChangeDataSource}
current={options.dataSource}
metrics={true}
mixed={true}
dashboard={true}
variables={true}
></DataSourcePicker>
)}
</div>
{dataSource && (
<>
@ -369,7 +393,32 @@ export class QueryGroup extends PureComponent<Props, State> {
this.onScrollBottom();
};
onQueriesChange = (queries: DataQuery[]) => {
onFileDrop = (acceptedFiles: File[], fileRejections: FileRejection[], event: DropEvent) => {
DFImport.filesToDataframes(acceptedFiles).subscribe(async (next) => {
const snapshot: DataFrameJSON[] = [];
next.dataFrames.forEach((df) => {
const dataframeJson = dataFrameToJSON(df);
snapshot.push(dataframeJson);
});
const ds = getDataSourceSrv().getInstanceSettings('-- Grafana --');
await this.onChangeDataSource(ds!);
this.onQueriesChange([
{
refId: 'A',
datasource: {
type: 'grafana',
uid: 'grafana',
},
queryType: GrafanaQueryType.Snapshot,
snapshot: snapshot,
file: next.file,
},
]);
this.props.onRunQueries();
});
};
onQueriesChange = (queries: DataQuery[] | GrafanaQuery[]) => {
this.onChange({ queries });
this.setState({ queries });
};