mirror of
https://github.com/grafana/grafana.git
synced 2026-09-05 04:40:13 -05:00
[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:
co-authored by
Oscar Kilhed
parent
ee247e33b4
commit
c7af53b79f
@@ -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;
|
||||
`,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
`,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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 DataSourceLogoProps {
|
||||
dataSource: DataSourceInstanceSettings<DataSourceJsonData> | string | DataSourceRef | null | undefined;
|
||||
}
|
||||
|
||||
export function DataSourceLogo(props: DataSourceLogoProps) {
|
||||
const { dataSource } = props;
|
||||
const styles = useStyles2(getStyles);
|
||||
|
||||
if (!dataSource) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof dataSource === 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ('name' in dataSource) {
|
||||
return (
|
||||
<img
|
||||
className={styles.pickerDSLogo}
|
||||
alt={`${dataSource.meta.name} logo`}
|
||||
src={dataSource.meta.info.logos.small}
|
||||
></img>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function DataSourceLogoPlaceHolder() {
|
||||
const styles = useStyles2(getStyles);
|
||||
return <div className={styles.pickerDSLogo}></div>;
|
||||
}
|
||||
|
||||
function getStyles(theme: GrafanaTheme2) {
|
||||
return {
|
||||
pickerDSLogo: css`
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
`,
|
||||
};
|
||||
}
|
||||
@@ -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)};
|
||||
`,
|
||||
};
|
||||
}
|
||||
@@ -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} />
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
// Components
|
||||
|
||||
import { DataSourceInstanceSettings, DataSourceRef, getDataSourceUID } from '@grafana/data';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { DataSourceJsonData } from '@grafana/schema';
|
||||
|
||||
import { DataSourceDropdown } from './DataSourceDropdown';
|
||||
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: DataSourceInstanceSettings<DataSourceJsonData>) => {
|
||||
this.props.onChange(ds);
|
||||
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, onClickAddCSV } = this.props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DataSourceDropdown
|
||||
datasources={this.getDatasources()}
|
||||
onChange={this.onChange}
|
||||
recentlyUsed={recentlyUsed}
|
||||
current={this.getCurrentDs()}
|
||||
fileUploadOptions={fileUploadOptions}
|
||||
enableFileUpload={enableFileUpload}
|
||||
onClickAddCSV={onClickAddCSV}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import React from 'react';
|
||||
|
||||
import { dateTime } from '@grafana/data';
|
||||
import { LocalStorageValueProvider } from 'app/core/components/LocalStorageValueProvider';
|
||||
|
||||
import { DataSourcePicker } from './DataSourcePickerNG';
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import React from 'react';
|
||||
import { DropzoneOptions } from 'react-dropzone';
|
||||
|
||||
import { DataSourceInstanceSettings } from '@grafana/data';
|
||||
import { DataSourceJsonData, DataSourceRef } from '@grafana/schema';
|
||||
|
||||
export interface DataSourceDrawerProps {
|
||||
datasources: Array<DataSourceInstanceSettings<DataSourceJsonData>>;
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
onClickAddCSV?: () => void;
|
||||
}
|
||||
|
||||
export interface DataSourcePickerWithHistoryProps extends Omit<DataSourcePickerProps, 'recentlyUsed'> {
|
||||
localStorageKey?: string;
|
||||
}
|
||||
|
||||
export interface DataSourcePickerHistoryItem {
|
||||
lastUse: string;
|
||||
uid: string;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
}
|
||||
Reference in New Issue
Block a user