[Under FF] New DS Picker with advance mode (#66566)

* Add a wrapper to switch between the previous and new DS picker depending on the feature toggle advancedDataSourcePicker.

* Add a new component to represent the modal DS picker, which we will refer as advanced DS picker
Integrate this into the Edit panel, for now, until we're ready to replace everywhere the grafana-runtime DS picker by the wrapper.

* Replace Drawer component with the dropdown

* Adjust the first version of the styles to fit into this Figma design

* Adjust the design of the FileDropzoneDefaultChildren to match with the new DS modal but everywhere else is used nowadays.

---------

Co-authored-by: Oscar Kilhed <oscar.kilhed@grafana.com>
This commit is contained in:
Ivan Ortega Alba 2023-04-19 15:08:09 +02:00 committed by GitHub
parent ee247e33b4
commit c7af53b79f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
22 changed files with 762 additions and 331 deletions

1
.github/CODEOWNERS vendored
View File

@ -353,7 +353,6 @@ lerna.json @grafana/frontend-ops
/public/app/features/datasources/ @grafana/plugins-platform-frontend
/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/grafana-frontend-platform

View File

@ -216,18 +216,11 @@ export function FileDropzone({ options, children, readAs, onLoad, fileListRender
{children ?? <FileDropzoneDefaultChildren primaryText={getPrimaryText(files, options)} />}
</div>
{fileErrors.length > 0 && renderErrorMessages(fileErrors)}
<div className={styles.acceptContainer}>
{options?.accept && (
<small className={cx(styles.small, styles.acceptMargin, styles.acceptedFiles)}>
{getAcceptedFileTypeText(options.accept)}
</small>
)}
{options?.maxSize && (
<small className={cx(styles.small, styles.acceptMargin)}>{`Max file size: ${formattedValueToString(
formattedSize
)}`}</small>
)}
</div>
<small className={cx(styles.small, styles.acceptContainer)}>
{options?.maxSize && `Max file size: ${formattedValueToString(formattedSize)}`}
{options?.maxSize && options?.accept && <span className={styles.acceptSeparator}>|</span>}
{options?.accept && getAcceptedFileTypeText(options.accept)}
</small>
{fileList}
</div>
);
@ -261,17 +254,14 @@ export function transformAcceptToNewFormat(accept?: string | string[] | Accept):
return accept;
}
export function FileDropzoneDefaultChildren({
primaryText = 'Upload file',
secondaryText = 'Drag and drop here or browse',
}) {
export function FileDropzoneDefaultChildren({ primaryText = 'Drop file here or click to upload', secondaryText = '' }) {
const theme = useTheme2();
const styles = getStyles(theme);
return (
<div className={styles.iconWrapper}>
<Icon name="upload" size="xxl" />
<h3>{primaryText}</h3>
<div className={cx(styles.defaultDropZone)}>
<Icon className={cx(styles.icon)} name="upload" size="xl" />
<h6 className={cx(styles.primaryText)}>{primaryText}</h6>
<small className={styles.small}>{secondaryText}</small>
</div>
);
@ -312,31 +302,33 @@ function getStyles(theme: GrafanaTheme2, isDragActive?: boolean) {
display: flex;
flex-direction: column;
width: 100%;
padding: ${theme.spacing(2)};
border-radius: 2px;
border: 1px dashed ${theme.colors.border.strong};
background-color: ${isDragActive ? theme.colors.background.secondary : theme.colors.background.primary};
cursor: pointer;
align-items: center;
justify-content: center;
`,
dropzone: css`
display: flex;
flex: 1;
flex-direction: column;
align-items: center;
padding: ${theme.spacing(6)};
border-radius: 2px;
border: 2px dashed ${theme.colors.border.medium};
background-color: ${isDragActive ? theme.colors.background.secondary : theme.colors.background.primary};
cursor: pointer;
`,
iconWrapper: css`
display: flex;
flex-direction: column;
align-items: center;
defaultDropZone: css`
text-align: center;
`,
icon: css`
margin-bottom: ${theme.spacing(1)};
`,
primaryText: css`
margin-bottom: ${theme.spacing(1)};
`,
acceptContainer: css`
display: flex;
text-align: center;
margin: 0;
`,
acceptedFiles: css`
flex-grow: 1;
`,
acceptMargin: css`
margin: ${theme.spacing(2, 0, 1)};
acceptSeparator: css`
margin: 0 ${theme.spacing(1)};
`,
small: css`
color: ${theme.colors.text.secondary};

View File

@ -1,37 +0,0 @@
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

@ -1,161 +0,0 @@
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

@ -1,38 +0,0 @@
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,54 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data';
import { Card, TagList, useStyles2 } from '@grafana/ui';
interface DataSourceCardProps {
ds: DataSourceInstanceSettings;
onClick: () => void;
selected: boolean;
}
export function DataSourceCard({ ds, onClick, selected }: DataSourceCardProps) {
const styles = useStyles2(getStyles);
return (
<Card key={ds.uid} onClick={onClick} className={cx(styles.card, selected ? styles.selected : undefined)}>
<Card.Heading>{ds.name}</Card.Heading>
<Card.Meta className={styles.meta}>
{ds.meta.name}
{ds.meta.info.description}
</Card.Meta>
<Card.Figure>
<img src={ds.meta.info.logos.small} alt={`${ds.meta.name} Logo`} height="40" width="40" />
</Card.Figure>
<Card.Tags>{ds.isDefault ? <TagList tags={['default']} /> : null}</Card.Tags>
</Card>
);
}
// Get styles for the component
function getStyles(theme: GrafanaTheme2) {
return {
card: css`
cursor: pointer;
background-color: ${theme.colors.background.primary};
border-bottom: 1px solid ${theme.colors.border.weak};
// Move to list component
margin-bottom: 0;
border-radius: 0;
`,
selected: css`
background-color: ${theme.colors.background.secondary};
`,
meta: css`
display: block;
overflow-wrap: unset;
white-space: nowrap;
width: 100%;
overflow: hidden;
text-overflow: ellipsis;
`,
};
}

View File

@ -0,0 +1,223 @@
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, useRef, useState } from 'react';
import { usePopper } from 'react-popper';
import { DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data';
import { DataSourceJsonData } from '@grafana/schema';
import { Button, CustomScrollbar, Icon, Input, ModalsController, Portal, useStyles2 } from '@grafana/ui';
import { DataSourceList } from './DataSourceList';
import { DataSourceLogo, DataSourceLogoPlaceHolder } from './DataSourceLogo';
import { DataSourceModal } from './DataSourceModal';
import { PickerContentProps, DataSourceDrawerProps } from './types';
import { dataSourceName as dataSourceLabel } from './utils';
export function DataSourceDropdown(props: DataSourceDrawerProps) {
const { current, onChange, ...restProps } = props;
const [isOpen, setOpen] = useState(false);
const [markerElement, setMarkerElement] = useState<HTMLInputElement | null>();
const [selectorElement, setSelectorElement] = useState<HTMLDivElement | null>();
const [filterTerm, setFilterTerm] = useState<string>();
const popper = usePopper(markerElement, selectorElement, {
placement: 'bottom-start',
});
const ref = useRef<HTMLDivElement>(null);
const { overlayProps, underlayProps } = useOverlay(
{
onClose: () => {
setFilterTerm(undefined);
setOpen(false);
},
isDismissable: true,
isOpen,
shouldCloseOnInteractOutside: (element) => {
return markerElement ? !markerElement.isSameNode(element) : false;
},
},
ref
);
const { dialogProps } = useDialog({}, ref);
const styles = useStyles2(getStylesDropdown);
return (
<div className={styles.container}>
{isOpen ? (
<FocusScope contain autoFocus restoreFocus>
<Input
prefix={filterTerm ? <DataSourceLogoPlaceHolder /> : <DataSourceLogo dataSource={current} />}
suffix={<Icon name={filterTerm ? 'search' : 'angle-down'} />}
placeholder={dataSourceLabel(current)}
className={styles.input}
onChange={(e) => {
setFilterTerm(e.currentTarget.value);
}}
ref={setMarkerElement}
></Input>
<Portal>
<div {...underlayProps} />
<div ref={ref} {...overlayProps} {...dialogProps}>
<PickerContent
filterTerm={filterTerm}
onChange={(ds: DataSourceInstanceSettings<DataSourceJsonData>) => {
setFilterTerm(undefined);
setOpen(false);
onChange(ds);
}}
onClose={() => {
setOpen(false);
}}
current={current}
style={popper.styles.popper}
ref={setSelectorElement}
{...restProps}
onDismiss={() => {}}
></PickerContent>
</div>
</Portal>
</FocusScope>
) : (
<div
className={styles.trigger}
onClick={() => {
setOpen(true);
}}
>
<Input
className={styles.markerInput}
prefix={<DataSourceLogo dataSource={current} />}
suffix={<Icon name="angle-down" />}
value={dataSourceLabel(current)}
onFocus={() => {
setOpen(true);
}}
/>
</div>
)}
</div>
);
}
function getStylesDropdown(theme: GrafanaTheme2) {
return {
container: css`
position: relative;
`,
trigger: css`
cursor: pointer;
`,
input: css`
input:focus {
box-shadow: none;
}
`,
markerInput: css`
input {
cursor: pointer;
}
`,
};
}
const PickerContent = React.forwardRef<HTMLDivElement, PickerContentProps>((props, ref) => {
const { filterTerm, onChange, onClose, onClickAddCSV, current } = props;
const changeCallback = useCallback(
(ds: DataSourceInstanceSettings<DataSourceJsonData>) => {
onChange(ds);
},
[onChange]
);
const clickAddCSVCallback = useCallback(() => {
onClickAddCSV?.();
onClose();
}, [onClickAddCSV, onClose]);
const styles = useStyles2(getStylesPickerContent);
return (
<div style={props.style} ref={ref} className={styles.container}>
<div className={styles.dataSourceList}>
<CustomScrollbar>
<DataSourceList
mixed
dashboard
current={current}
onChange={changeCallback}
filter={(ds) => !ds.meta.builtIn && ds.name.includes(filterTerm ?? '')}
></DataSourceList>
</CustomScrollbar>
</div>
<div className={styles.footer}>
{onClickAddCSV && (
<Button variant="secondary" size="sm" onClick={clickAddCSVCallback}>
Add csv or spreadsheet
</Button>
)}
<ModalsController>
{({ showModal, hideModal }) => (
<Button
size="sm"
variant="secondary"
fill="text"
onClick={() => {
onClose();
showModal(DataSourceModal, {
datasources: props.datasources,
recentlyUsed: props.recentlyUsed,
enableFileUpload: props.enableFileUpload,
fileUploadOptions: props.fileUploadOptions,
current,
onDismiss: hideModal,
onChange: (ds) => {
onChange(ds);
hideModal();
},
});
}}
>
Open advanced data source picker
<Icon name="arrow-right" />
</Button>
)}
</ModalsController>
</div>
</div>
);
});
PickerContent.displayName = 'PickerContent';
function getStylesPickerContent(theme: GrafanaTheme2) {
return {
container: css`
display: flex;
flex-direction: column;
height: 480px;
box-shadow: ${theme.shadows.z3};
width: 480px;
background: ${theme.colors.background.primary};
box-shadow: ${theme.shadows.z3};
`,
picker: css`
background: ${theme.colors.background.secondary};
`,
dataSourceList: css`
height: 423px;
padding: 0 ${theme.spacing(2)};
`,
footer: css`
display: flex;
justify-content: space-between;
padding: ${theme.spacing(2)};
border-top: 1px solid ${theme.colors.border.weak};
height: 57px;
`,
};
}

View File

@ -0,0 +1,119 @@
import React, { PureComponent } from 'react';
import { DataSourceInstanceSettings, DataSourceRef } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime';
import { DataSourceCard } from './DataSourceCard';
import { isDataSourceMatch } from './utils';
/**
* Component props description for the {@link DataSourceList}
*
* @internal
*/
export interface DataSourceListProps {
className?: string;
onChange: (ds: DataSourceInstanceSettings) => void;
current: DataSourceRef | string | null; // uid
tracing?: boolean;
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;
width?: number;
inputId?: string;
filter?: (dataSource: DataSourceInstanceSettings) => boolean;
onClear?: () => void;
}
/**
* Component state description for the {@link DataSourceList}
*
* @internal
*/
export interface DataSourceListState {
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 DataSourceList extends PureComponent<DataSourceListProps, DataSourceListState> {
dataSourceSrv = getDataSourceSrv();
static defaultProps: Partial<DataSourceListProps> = {
filter: () => true,
};
state: DataSourceListState = {};
constructor(props: DataSourceListProps) {
super(props);
}
componentDidMount() {
const { current } = this.props;
const dsSettings = this.dataSourceSrv.getInstanceSettings(current);
if (!dsSettings) {
this.setState({ error: 'Could not find data source ' + current });
}
}
onChange = (item: DataSourceInstanceSettings) => {
const dsSettings = this.dataSourceSrv.getInstanceSettings(item);
if (dsSettings) {
this.props.onChange(dsSettings);
this.setState({ error: undefined });
}
};
getDataSourceOptions() {
const { alerting, tracing, metrics, mixed, dashboard, variables, annotations, pluginId, type, filter, logs } =
this.props;
const options = this.dataSourceSrv.getList({
alerting,
tracing,
metrics,
logs,
dashboard,
mixed,
variables,
annotations,
pluginId,
filter,
type,
});
return options;
}
render() {
const { className, current } = this.props;
// QUESTION: Should we use data from the Redux store as admin DS view does?
const options = this.getDataSourceOptions();
return (
<div className={className}>
{options.map((ds) => (
<DataSourceCard
key={ds.uid}
ds={ds}
onClick={this.onChange.bind(this, ds)}
selected={!!isDataSourceMatch(ds, current)}
/>
))}
</div>
);
}
}

View File

@ -5,36 +5,38 @@ import { DataSourceInstanceSettings, DataSourceJsonData, GrafanaTheme2 } from '@
import { DataSourceRef } from '@grafana/schema';
import { useStyles2 } from '@grafana/ui';
export interface DataSourceDisplayProps {
export interface DataSourceLogoProps {
dataSource: DataSourceInstanceSettings<DataSourceJsonData> | string | DataSourceRef | null | undefined;
}
export function DataSourceDisplay(props: DataSourceDisplayProps) {
export function DataSourceLogo(props: DataSourceLogoProps) {
const { dataSource } = props;
const styles = useStyles2(getStyles);
if (!dataSource) {
return <span>Unknown</span>;
return null;
}
if (typeof dataSource === 'string') {
return <span>${dataSource} - not found</span>;
return null;
}
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>
</>
<img
className={styles.pickerDSLogo}
alt={`${dataSource.meta.name} logo`}
src={dataSource.meta.info.logos.small}
></img>
);
}
return <span>{dataSource.uid} - not found</span>;
return null;
}
export function DataSourceLogoPlaceHolder() {
const styles = useStyles2(getStyles);
return <div className={styles.pickerDSLogo}></div>;
}
function getStyles(theme: GrafanaTheme2) {
@ -42,7 +44,6 @@ function getStyles(theme: GrafanaTheme2) {
pickerDSLogo: css`
height: 20px;
width: 20px;
margin-right: ${theme.spacing(1)};
`,
};
}

View File

@ -0,0 +1,160 @@
import { css } from '@emotion/css';
import React, { useState } from 'react';
import { DropzoneOptions } from 'react-dropzone';
import { DataSourceInstanceSettings, DataSourceRef, GrafanaTheme2 } from '@grafana/data';
import {
Modal,
FileDropzone,
FileDropzoneDefaultChildren,
CustomScrollbar,
LinkButton,
useStyles2,
Input,
Icon,
} from '@grafana/ui';
import * as DFImport from 'app/features/dataframe-import';
import { DataSourceList } from './DataSourceList';
interface DataSourceModalProps {
onChange: (ds: DataSourceInstanceSettings) => void;
current: DataSourceRef | string | null | undefined;
onDismiss: () => void;
datasources: DataSourceInstanceSettings[];
recentlyUsed?: string[];
enableFileUpload?: boolean;
fileUploadOptions?: DropzoneOptions;
}
export function DataSourceModal({
enableFileUpload,
fileUploadOptions,
onChange,
current,
onDismiss,
}: DataSourceModalProps) {
const styles = useStyles2(getDataSourceModalStyles);
const [search, setSearch] = useState('');
return (
<Modal
title="Select data source"
closeOnEscape={true}
closeOnBackdropClick={true}
isOpen={true}
className={styles.modal}
onClickBackdrop={onDismiss}
onDismiss={onDismiss}
>
<div className={styles.modalContent}>
<div className={styles.leftColumn}>
<Input
className={styles.searchInput}
value={search}
prefix={<Icon name="search" />}
placeholder="Search data source"
onChange={(e) => setSearch(e.currentTarget.value)}
/>
<CustomScrollbar>
<DataSourceList
dashboard={false}
mixed={false}
// FIXME: Filter out the grafana data source in a hacky way
filter={(ds) => ds.name.includes(search) && ds.name !== '-- Grafana --'}
onChange={onChange}
current={current}
/>
</CustomScrollbar>
</div>
<div className={styles.rightColumn}>
<div className={styles.builtInDataSources}>
<DataSourceList
className={styles.builtInDataSourceList}
filter={(ds) => !!ds.meta.builtIn}
dashboard
mixed
onChange={onChange}
current={current}
/>
{enableFileUpload && (
<FileDropzone
readAs="readAsArrayBuffer"
fileListRenderer={() => undefined}
options={{
maxSize: DFImport.maxFileSize,
multiple: false,
accept: DFImport.acceptedFiles,
...fileUploadOptions,
onDrop: (...args) => {
fileUploadOptions?.onDrop?.(...args);
onDismiss();
},
}}
>
<FileDropzoneDefaultChildren />
</FileDropzone>
)}
</div>
<div className={styles.dsCTAs}>
<LinkButton variant="secondary" href={`datasources/new`}>
Configure a new data source
</LinkButton>
</div>
</div>
</div>
</Modal>
);
}
function getDataSourceModalStyles(theme: GrafanaTheme2) {
return {
modal: css`
width: 80%;
height: 80%;
`,
modalContent: css`
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: stretch;
height: 100%;
`,
leftColumn: css`
display: flex;
flex-direction: column;
width: 50%;
height: 100%;
padding-right: ${theme.spacing(1)};
border-right: 1px solid ${theme.colors.border.weak};
`,
rightColumn: css`
display: flex;
flex-direction: column;
width: 50%;
height: 100%;
padding: ${theme.spacing(1)};
justify-items: space-evenly;
align-items: stretch;
padding-left: ${theme.spacing(1)};
`,
builtInDataSources: css`
flex: 1;
margin-bottom: ${theme.spacing(4)};
`,
builtInDataSourceList: css`
margin-bottom: ${theme.spacing(4)};
`,
dsCTAs: css`
display: flex;
flex-direction: row;
width: 100%;
justify-content: flex-end;
`,
searchInput: css`
width: 100%;
min-height: 32px;
margin-bottom: ${theme.spacing(1)};
`,
};
}

View File

@ -0,0 +1,25 @@
import React from 'react';
import {
DataSourcePicker as DeprecatedDataSourcePicker,
DataSourcePickerProps as DeprecatedDataSourcePickerProps,
} from '@grafana/runtime';
import { config } from 'app/core/config';
import { DataSourcePickerWithHistory } from './DataSourcePickerWithHistory';
import { DataSourcePickerWithHistoryProps } from './types';
type DataSourcePickerProps = DeprecatedDataSourcePickerProps | DataSourcePickerWithHistoryProps;
/**
* 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} />
) : (
<DataSourcePickerWithHistory {...props} />
);
}

View File

@ -6,7 +6,7 @@ import { DataSourceInstanceSettings, DataSourceRef, getDataSourceUID } from '@gr
import { getDataSourceSrv } from '@grafana/runtime';
import { DataSourceJsonData } from '@grafana/schema';
import { DataSourceDrawer } from './DataSourceDrawer';
import { DataSourceDropdown } from './DataSourceDropdown';
import { DataSourcePickerProps } from './types';
/**
@ -37,13 +37,9 @@ export class DataSourcePicker extends PureComponent<DataSourcePickerProps, DataS
}
}
onChange = (ds?: string) => {
const dsSettings = this.dataSourceSrv.getInstanceSettings(ds);
if (dsSettings) {
this.props.onChange(dsSettings);
this.setState({ error: undefined });
}
onChange = (ds: DataSourceInstanceSettings<DataSourceJsonData>) => {
this.props.onChange(ds);
this.setState({ error: undefined });
};
private getCurrentDs(): DataSourceInstanceSettings<DataSourceJsonData> | string | DataSourceRef | null | undefined {
@ -80,17 +76,18 @@ export class DataSourcePicker extends PureComponent<DataSourcePickerProps, DataS
}
render() {
const { recentlyUsed, fileUploadOptions, enableFileUpload } = this.props;
const { recentlyUsed, fileUploadOptions, enableFileUpload, onClickAddCSV } = this.props;
return (
<div>
<DataSourceDrawer
<DataSourceDropdown
datasources={this.getDatasources()}
onChange={this.onChange}
recentlyUsed={recentlyUsed}
current={this.getCurrentDs()}
fileUploadOptions={fileUploadOptions}
enableFileUpload={enableFileUpload}
onClickAddCSV={onClickAddCSV}
/>
</div>
);

View File

@ -3,7 +3,7 @@ import React from 'react';
import { dateTime } from '@grafana/data';
import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider';
import { DataSourcePicker } from './DataSourcePicker';
import { DataSourcePicker } from './DataSourcePickerNG';
import { DataSourcePickerHistoryItem, DataSourcePickerWithHistoryProps } from './types';
const DS_PICKER_STORAGE_KEY = 'DATASOURCE_PICKER';

View File

@ -1,3 +1,4 @@
import React from 'react';
import { DropzoneOptions } from 'react-dropzone';
import { DataSourceInstanceSettings } from '@grafana/data';
@ -5,15 +6,18 @@ import { DataSourceJsonData, DataSourceRef } from '@grafana/schema';
export interface DataSourceDrawerProps {
datasources: Array<DataSourceInstanceSettings<DataSourceJsonData>>;
onFileDrop?: () => void;
onChange: (ds: string) => void;
onChange: (ds: DataSourceInstanceSettings<DataSourceJsonData>) => void;
current: DataSourceInstanceSettings<DataSourceJsonData> | string | DataSourceRef | null | undefined;
enableFileUpload?: boolean;
fileUploadOptions?: DropzoneOptions;
onClickAddCSV?: () => void;
recentlyUsed?: string[];
}
export interface PickerContentProps extends DataSourceDrawerProps {
style: React.CSSProperties;
filterTerm?: string;
onClose: () => void;
onDismiss: () => void;
}
@ -40,6 +44,7 @@ export interface DataSourcePickerProps {
disabled?: boolean;
enableFileUpload?: boolean;
fileUploadOptions?: DropzoneOptions;
onClickAddCSV?: () => void;
}
export interface DataSourcePickerWithHistoryProps extends Omit<DataSourcePickerProps, 'recentlyUsed'> {

View File

@ -0,0 +1,30 @@
import { DataSourceInstanceSettings, DataSourceRef } from '@grafana/data';
import { isDataSourceMatch } from './utils';
describe('isDataSourceMatch', () => {
const dataSourceInstanceSettings = { uid: 'a' } as DataSourceInstanceSettings;
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)).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)).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,39 @@
import { DataSourceInstanceSettings, DataSourceJsonData, DataSourceRef } from '@grafana/data';
export function isDataSourceMatch(
ds: DataSourceInstanceSettings | undefined,
current: string | DataSourceInstanceSettings | 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;
}
export function dataSourceName(
dataSource: DataSourceInstanceSettings<DataSourceJsonData> | string | DataSourceRef | null | undefined
) {
if (!dataSource) {
return 'Unknown';
}
if (typeof dataSource === 'string') {
return `${dataSource} - not found`;
}
if ('name' in dataSource) {
return dataSource.name;
}
if (dataSource.uid) {
return `${dataSource.uid} - not found`;
}
return 'Unknown';
}

View File

@ -15,14 +15,14 @@ import {
PanelData,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { DataSourcePicker, getDataSourceSrv } from '@grafana/runtime';
import { getDataSourceSrv } from '@grafana/runtime';
import { Button, CustomScrollbar, HorizontalGroup, InlineFormLabel, Modal, stylesFactory } from '@grafana/ui';
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 { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
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';
@ -214,32 +214,22 @@ export class QueryGroup extends PureComponent<Props, State> {
Data source
</InlineFormLabel>
<div className={styles.dataSourceRowItem}>
{config.featureToggles.advancedDataSourcePicker ? (
<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>
)}
<DataSourcePicker
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,
}}
onClickAddCSV={this.onClickAddCSV}
/>
</div>
{dataSource && (
<>
@ -315,6 +305,24 @@ export class QueryGroup extends PureComponent<Props, State> {
this.onScrollBottom();
};
onClickAddCSV = async () => {
const ds = getDataSourceSrv().getInstanceSettings('-- Grafana --');
await this.onChangeDataSource(ds!);
this.onQueriesChange([
{
refId: 'A',
datasource: {
type: 'grafana',
uid: 'grafana',
},
queryType: GrafanaQueryType.Snapshot,
snapshot: [],
},
]);
this.props.onRunQueries();
};
onFileDrop = (acceptedFiles: File[], fileRejections: FileRejection[], event: DropEvent) => {
DFImport.filesToDataframes(acceptedFiles).subscribe(async (next) => {
const snapshot: DataFrameJSON[] = [];

View File

@ -5,7 +5,7 @@
"builtIn": true,
"info": {
"description": "TODO",
"description": "Uses the result set from another panel in the same dashboard",
"author": {
"name": "Grafana Labs",
"url": "https://grafana.com"

View File

@ -419,7 +419,9 @@ export class UnthemedQueryEditor extends PureComponent<Props, State> {
accept: DFImport.acceptedFiles,
}}
>
<FileDropzoneDefaultChildren primaryText={this.props?.query?.file ? 'Replace file' : 'Upload file'} />
<FileDropzoneDefaultChildren
primaryText={this.props?.query?.file ? 'Replace file' : 'Drop file here or click to upload'}
/>
</FileDropzone>
{file && (
<div className={styles.file}>

View File

@ -5,7 +5,7 @@
"builtIn": true,
"info": {
"description": "TODO",
"description": "A built-in data source that generates random walk data and can poll the Testdata data source. This helps you test visualizations and run experiments.",
"author": {
"name": "Grafana Labs",
"url": "https://grafana.com"

View File

@ -4,6 +4,19 @@
"id": "mixed",
"builtIn": true,
"info": {
"description": "Lets you query multiple data sources in the same panel.",
"author": {
"name": "Grafana Labs",
"url": "https://grafana.com"
},
"logos": {
"small": "public/img/icn-datasource.svg",
"large": "public/img/icn-datasource.svg"
}
},
"mixed": true,
"metrics": true,