diff --git a/packages/grafana-ui/.storybook/config.ts b/packages/grafana-ui/.storybook/config.ts
index 98a4ee8bd9e..9c32f3c7e34 100644
--- a/packages/grafana-ui/.storybook/config.ts
+++ b/packages/grafana-ui/.storybook/config.ts
@@ -28,7 +28,7 @@ const handleThemeChange = (theme: string) => {
}
};
// automatically import all files ending in *.stories.tsx
-const req = require.context('../src/components', true, /.story.tsx$/);
+const req = require.context('../src', true, /.story.tsx$/);
addDecorator(withKnobs);
addDecorator(withPaddedStory);
diff --git a/packages/grafana-ui/src/components/Select/Select.tsx b/packages/grafana-ui/src/components/Select/Select.tsx
index 929d369c951..3721ae4ef37 100644
--- a/packages/grafana-ui/src/components/Select/Select.tsx
+++ b/packages/grafana-ui/src/components/Select/Select.tsx
@@ -11,7 +11,8 @@ import { default as ReactAsyncSelect } from '@torkelo/react-select/lib/Async';
import { components } from '@torkelo/react-select';
// Components
-import { SelectOption, SingleValue } from './SelectOption';
+import { SelectOption } from './SelectOption';
+import { SingleValue } from './SingleValue';
import SelectOptionGroup from './SelectOptionGroup';
import IndicatorsContainer from './IndicatorsContainer';
import NoOptionsMessage from './NoOptionsMessage';
diff --git a/packages/grafana-ui/src/components/Select/SelectOption.tsx b/packages/grafana-ui/src/components/Select/SelectOption.tsx
index 624487940c1..249183eba0c 100644
--- a/packages/grafana-ui/src/components/Select/SelectOption.tsx
+++ b/packages/grafana-ui/src/components/Select/SelectOption.tsx
@@ -30,18 +30,4 @@ export const SelectOption = (props: ExtendedOptionProps) => {
);
};
-// was not able to type this without typescript error
-export const SingleValue = (props: any) => {
- const { children, data } = props;
-
- return (
-
-
- {data.imgUrl &&
![]({data.imgUrl})
}
- {children}
-
-
- );
-};
-
export default SelectOption;
diff --git a/packages/grafana-ui/src/components/Select/SingleValue.tsx b/packages/grafana-ui/src/components/Select/SingleValue.tsx
new file mode 100644
index 00000000000..9346537cbd6
--- /dev/null
+++ b/packages/grafana-ui/src/components/Select/SingleValue.tsx
@@ -0,0 +1,61 @@
+import React from 'react';
+import { css, cx } from 'emotion';
+
+// Ignoring because I couldn't get @types/react-select work wih Torkel's fork
+// @ts-ignore
+import { components } from '@torkelo/react-select';
+import { FadeTransition, Spinner } from '..';
+import { useDelayedSwitch } from '../../utils/useDelayedSwitch';
+import { stylesFactory } from '../../themes';
+
+const getStyles = stylesFactory(() => {
+ const container = css`
+ width: 16px;
+ height: 16px;
+ display: inline-block;
+ margin-right: 10px;
+ position: relative;
+ vertical-align: middle;
+ `;
+
+ const item = css`
+ width: 100%;
+ height: 100%;
+ position: absolute;
+ `;
+
+ return { container, item };
+});
+
+type Props = {
+ children: React.ReactNode;
+ data: {
+ imgUrl?: string;
+ loading?: boolean;
+ };
+};
+
+export const SingleValue = (props: Props) => {
+ const { children, data } = props;
+ const styles = getStyles();
+
+ const loading = useDelayedSwitch(data.loading || false, { delay: 250, duration: 750 });
+
+ return (
+
+
+
+
+
+
+ {data.imgUrl && (
+
+
+
+ )}
+
+ {children}
+
+
+ );
+};
diff --git a/packages/grafana-ui/src/components/Spinner/Spinner.story.tsx b/packages/grafana-ui/src/components/Spinner/Spinner.story.tsx
new file mode 100644
index 00000000000..90ecc20e748
--- /dev/null
+++ b/packages/grafana-ui/src/components/Spinner/Spinner.story.tsx
@@ -0,0 +1,15 @@
+import React from 'react';
+
+import { storiesOf } from '@storybook/react';
+import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
+import { Spinner } from './Spinner';
+
+const story = storiesOf('UI/Spinner', module);
+story.addDecorator(withCenteredStory);
+story.add('spinner', () => {
+ return (
+
+
+
+ );
+});
diff --git a/packages/grafana-ui/src/components/Spinner/Spinner.tsx b/packages/grafana-ui/src/components/Spinner/Spinner.tsx
new file mode 100644
index 00000000000..2642c019f9b
--- /dev/null
+++ b/packages/grafana-ui/src/components/Spinner/Spinner.tsx
@@ -0,0 +1,33 @@
+import React, { FC } from 'react';
+import { cx, css } from 'emotion';
+import { stylesFactory } from '../../themes';
+
+const getStyles = stylesFactory((size: number, inline: boolean) => {
+ return {
+ wrapper: css`
+ font-size: ${size}px;
+ ${inline
+ ? css`
+ display: inline-block;
+ `
+ : ''}
+ `,
+ };
+});
+
+type Props = {
+ className?: string;
+ style?: React.CSSProperties;
+ iconClassName?: string;
+ inline?: boolean;
+ size?: number;
+};
+export const Spinner: FC = (props: Props) => {
+ const { className, inline = false, iconClassName, style, size = 16 } = props;
+ const styles = getStyles(size, inline);
+ return (
+
+
+
+ );
+};
diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts
index a55f3f91d81..4e6505f1b8e 100644
--- a/packages/grafana-ui/src/components/index.ts
+++ b/packages/grafana-ui/src/components/index.ts
@@ -86,3 +86,5 @@ export { JSONFormatter } from './JSONFormatter/JSONFormatter';
export { JsonExplorer } from './JSONFormatter/json_explorer/json_explorer';
export { ErrorBoundary, ErrorBoundaryAlert } from './ErrorBoundary/ErrorBoundary';
export { AlphaNotice } from './AlphaNotice/AlphaNotice';
+export { Spinner } from './Spinner/Spinner';
+export { FadeTransition } from './transitions/FadeTransition';
diff --git a/packages/grafana-ui/src/components/transitions/FadeTransition.tsx b/packages/grafana-ui/src/components/transitions/FadeTransition.tsx
new file mode 100644
index 00000000000..9b3159a7781
--- /dev/null
+++ b/packages/grafana-ui/src/components/transitions/FadeTransition.tsx
@@ -0,0 +1,43 @@
+import React from 'react';
+import { css } from 'emotion';
+import { CSSTransition } from 'react-transition-group';
+import { stylesFactory } from '../../themes';
+
+const getStyles = stylesFactory((duration: number) => {
+ return {
+ enter: css`
+ label: enter;
+ opacity: 0;
+ `,
+ enterActive: css`
+ label: enterActive;
+ opacity: 1;
+ transition: opacity ${duration}ms ease-out;
+ `,
+ exit: css`
+ label: exit;
+ opacity: 1;
+ `,
+ exitActive: css`
+ label: exitActive;
+ opacity: 0;
+ transition: opacity ${duration}ms ease-out;
+ `,
+ };
+});
+
+type Props = {
+ children: React.ReactNode;
+ visible: boolean;
+ duration?: number;
+};
+
+export function FadeTransition(props: Props) {
+ const { visible, children, duration = 250 } = props;
+ const styles = getStyles(duration);
+ return (
+
+ {children}
+
+ );
+}
diff --git a/packages/grafana-ui/src/utils/useDelayedSwitch.story.tsx b/packages/grafana-ui/src/utils/useDelayedSwitch.story.tsx
new file mode 100644
index 00000000000..d62a9132968
--- /dev/null
+++ b/packages/grafana-ui/src/utils/useDelayedSwitch.story.tsx
@@ -0,0 +1,26 @@
+import React from 'react';
+
+import { storiesOf } from '@storybook/react';
+import { withCenteredStory } from './storybook/withCenteredStory';
+import { useDelayedSwitch } from './useDelayedSwitch';
+import { boolean, number } from '@storybook/addon-knobs';
+
+const getKnobs = () => {
+ return {
+ value: boolean('Value', false),
+ duration: number('Duration to stay on', 2000),
+ delay: number('Delay before switching on', 2000),
+ };
+};
+
+function StoryWrapper() {
+ const { value, delay = 0, duration = 0 } = getKnobs();
+ const valueDelayed = useDelayedSwitch(value, { delay, duration });
+ return {valueDelayed ? 'ON' : 'OFF'}
;
+}
+
+const story = storiesOf('Utils/useDelayedSwitch', module);
+story.addDecorator(withCenteredStory);
+story.add('useDelayedSwitch', () => {
+ return ;
+});
diff --git a/packages/grafana-ui/src/utils/useDelayedSwitch.ts b/packages/grafana-ui/src/utils/useDelayedSwitch.ts
new file mode 100644
index 00000000000..99f0bdb9a6b
--- /dev/null
+++ b/packages/grafana-ui/src/utils/useDelayedSwitch.ts
@@ -0,0 +1,55 @@
+import { useEffect, useRef, useState } from 'react';
+
+type DelayOptions = {
+ // Minimal amount of time the switch will be on.
+ duration?: number;
+ // Delay after which switch will turn on.
+ delay?: number;
+};
+
+/**
+ * Hook that delays changing of boolean switch to prevent too much time spent in "on" state. It is kind of a throttle
+ * but you can specify different time for on and off throttling so this only allows a boolean values and also prefers
+ * to stay "off" so turning "on" is always delayed while turning "off" is throttled.
+ *
+ * This is useful for showing loading elements to prevent it flashing too much in case of quick loading time or
+ * prevent it flash if loaded state comes right after switch to loading.
+ */
+export function useDelayedSwitch(value: boolean, options: DelayOptions = {}): boolean {
+ const { duration = 250, delay = 250 } = options;
+
+ const [delayedValue, setDelayedValue] = useState(value);
+ const onStartTime = useRef();
+
+ useEffect(() => {
+ let timeout: number | undefined;
+ if (value) {
+ // If toggling to "on" state we always setTimout no matter how long we have been "off".
+ timeout = setTimeout(() => {
+ onStartTime.current = new Date();
+ setDelayedValue(value);
+ }, delay) as any;
+ } else {
+ // If toggling to "off" state we check how much time we were already "on".
+ const timeSpent = onStartTime.current ? Date.now() - onStartTime.current.valueOf() : 0;
+ const turnOff = () => {
+ onStartTime.current = undefined;
+ setDelayedValue(value);
+ };
+ if (timeSpent >= duration) {
+ // We already spent enough time "on" so change right away.
+ turnOff();
+ } else {
+ timeout = setTimeout(turnOff, duration - timeSpent) as any;
+ }
+ }
+ return () => {
+ if (timeout) {
+ clearTimeout(timeout);
+ timeout = undefined;
+ }
+ };
+ }, [value, duration, delay]);
+
+ return delayedValue;
+}
diff --git a/public/app/core/components/Select/DataSourcePicker.tsx b/public/app/core/components/Select/DataSourcePicker.tsx
index 6edd54d0b44..984c7d062e5 100644
--- a/public/app/core/components/Select/DataSourcePicker.tsx
+++ b/public/app/core/components/Select/DataSourcePicker.tsx
@@ -15,6 +15,7 @@ export interface Props {
onBlur?: () => void;
autoFocus?: boolean;
openMenuOnFocus?: boolean;
+ showLoading?: boolean;
}
export class DataSourcePicker extends PureComponent {
@@ -35,7 +36,7 @@ export class DataSourcePicker extends PureComponent {
};
render() {
- const { datasources, current, autoFocus, onBlur, openMenuOnFocus } = this.props;
+ const { datasources, current, autoFocus, onBlur, openMenuOnFocus, showLoading } = this.props;
const options = datasources.map(ds => ({
value: ds.name,
@@ -47,6 +48,7 @@ export class DataSourcePicker extends PureComponent {
label: current.name,
value: current.name,
imgUrl: current.meta.info.logos.small,
+ loading: showLoading,
};
return (
diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx
index 45503d50e7b..16028807666 100644
--- a/public/app/features/explore/Explore.tsx
+++ b/public/app/features/explore/Explore.tsx
@@ -67,7 +67,6 @@ interface ExploreProps {
changeSize: typeof changeSize;
datasourceError: string;
datasourceInstance: DataSourceApi;
- datasourceLoading: boolean | null;
datasourceMissing: boolean;
exploreId: ExploreId;
initializeExplore: typeof initializeExplore;
@@ -251,7 +250,6 @@ export class Explore extends React.PureComponent {
StartPage,
datasourceInstance,
datasourceError,
- datasourceLoading,
datasourceMissing,
exploreId,
showingStartPage,
@@ -272,7 +270,6 @@ export class Explore extends React.PureComponent {
return (
- {datasourceLoading ?
Loading datasource...
: null}
{datasourceMissing ? this.renderEmptyState() : null}
@@ -360,7 +357,6 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps): Partia
StartPage,
datasourceError,
datasourceInstance,
- datasourceLoading,
datasourceMissing,
initialized,
showingStartPage,
@@ -406,7 +402,6 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps): Partia
StartPage,
datasourceError,
datasourceInstance,
- datasourceLoading,
datasourceMissing,
initialized,
showingStartPage,
diff --git a/public/app/features/explore/ExploreToolbar.tsx b/public/app/features/explore/ExploreToolbar.tsx
index 26469eac606..0d98dc8a0a6 100644
--- a/public/app/features/explore/ExploreToolbar.tsx
+++ b/public/app/features/explore/ExploreToolbar.tsx
@@ -68,6 +68,7 @@ interface StateProps {
isPaused: boolean;
originPanelId: number;
queries: DataQuery[];
+ datasourceLoading: boolean | null;
}
interface DispatchProps {
@@ -156,6 +157,7 @@ export class UnConnectedExploreToolbar extends PureComponent {
isLive,
isPaused,
originPanelId,
+ datasourceLoading,
} = this.props;
const styles = getStyles();
@@ -193,6 +195,7 @@ export class UnConnectedExploreToolbar extends PureComponent {
onChange={this.onChangeDatasource}
datasources={exploreDatasources}
current={selectedDatasource}
+ showLoading={datasourceLoading}
/>
{supportedModes.length > 1 ? (
@@ -316,6 +319,7 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps
isPaused,
originPanelId,
queries,
+ datasourceLoading,
} = exploreItem;
const selectedDatasource = datasourceInstance
? exploreDatasources.find(datasource => datasource.name === datasourceInstance.name)
@@ -339,6 +343,7 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps
isPaused,
originPanelId,
queries,
+ datasourceLoading,
};
};
diff --git a/public/app/plugins/datasource/stackdriver/components/__snapshots__/QueryEditor.test.tsx.snap b/public/app/plugins/datasource/stackdriver/components/__snapshots__/QueryEditor.test.tsx.snap
index 1b2b4147e44..8b53b129511 100644
--- a/public/app/plugins/datasource/stackdriver/components/__snapshots__/QueryEditor.test.tsx.snap
+++ b/public/app/plugins/datasource/stackdriver/components/__snapshots__/QueryEditor.test.tsx.snap
@@ -318,6 +318,9 @@ Array [