Просмотр исходного кода

Merge branch 'master' into table-reducer

Torkel Ödegaard 6 лет назад
Родитель
Сommit
a6c2a8c3cc
36 измененных файлов с 962 добавлено и 252 удалено
  1. 1 1
      README.md
  2. 2 2
      packages/grafana-ui/src/components/BarGauge/BarGauge.tsx
  3. 2 3
      packages/grafana-ui/src/components/Gauge/Gauge.tsx
  4. 14 0
      packages/grafana-ui/src/types/data.ts
  5. 11 0
      packages/grafana-ui/src/types/displayValue.ts
  6. 1 0
      packages/grafana-ui/src/types/index.ts
  7. 31 2
      packages/grafana-ui/src/utils/displayValue.test.ts
  8. 63 13
      packages/grafana-ui/src/utils/displayValue.ts
  9. 2 15
      public/app/core/actions/location.ts
  10. 9 9
      public/app/core/reducers/location.ts
  11. 4 0
      public/app/core/redux/actionCreatorFactory.ts
  12. 3 1
      public/app/features/alerting/AlertRuleList.test.tsx
  13. 1 3
      public/app/features/annotations/all.ts
  14. 0 11
      public/app/features/annotations/event.ts
  15. 1 1
      public/app/features/annotations/event_editor.ts
  16. 8 8
      public/app/features/annotations/event_manager.ts
  17. 3 2
      public/app/features/dashboard/containers/DashboardPage.test.tsx
  18. 3 4
      public/app/features/datasources/state/actions.ts
  19. 45 17
      public/app/features/explore/Explore.tsx
  20. 5 36
      public/app/features/explore/Wrapper.tsx
  21. 1 23
      public/app/features/explore/state/actionTypes.ts
  22. 147 0
      public/app/features/explore/state/actions.test.ts
  23. 51 3
      public/app/features/explore/state/actions.ts
  24. 294 2
      public/app/features/explore/state/reducers.test.ts
  25. 105 19
      public/app/features/explore/state/reducers.ts
  26. 10 3
      public/app/plugins/datasource/graphite/gfunc.ts
  27. 5 5
      public/app/plugins/datasource/graphite/graphite_query.ts
  28. 36 10
      public/app/plugins/datasource/graphite/specs/gfunc.test.ts
  29. 2 1
      public/app/plugins/datasource/graphite/specs/graphite_query.test.ts
  30. 3 2
      public/app/plugins/datasource/graphite/specs/query_ctrl.test.ts
  31. 10 4
      public/app/plugins/datasource/prometheus/datasource.ts
  32. 12 50
      public/app/plugins/panel/singlestat/module.ts
  33. 11 0
      public/app/types/explore.ts
  34. 64 0
      public/test/core/thunk/thunkTester.ts
  35. 1 1
      public/test/specs/helpers.ts
  36. 1 1
      scripts/grunt/options/compress.js

+ 1 - 1
README.md

@@ -163,5 +163,5 @@ plugin development.
 
 ## License
 
-Grafana is distributed under [Apache 2.0 License](https://github.com/grafana/grafana/blob/master/LICENSE.md).
+Grafana is distributed under [Apache 2.0 License](https://github.com/grafana/grafana/blob/master/LICENSE).
 

+ 2 - 2
packages/grafana-ui/src/components/BarGauge/BarGauge.tsx

@@ -3,10 +3,10 @@ import React, { PureComponent, CSSProperties, ReactNode } from 'react';
 import tinycolor from 'tinycolor2';
 
 // Utils
-import { getColorFromHexRgbOrName, getThresholdForValue, DisplayValue } from '../../utils';
+import { getColorFromHexRgbOrName, getThresholdForValue } from '../../utils';
 
 // Types
-import { Themeable, TimeSeriesValue, Threshold, VizOrientation } from '../../types';
+import { DisplayValue, Themeable, TimeSeriesValue, Threshold, VizOrientation } from '../../types';
 
 const BAR_SIZE_RATIO = 0.8;
 

+ 2 - 3
packages/grafana-ui/src/components/Gauge/Gauge.tsx

@@ -1,10 +1,9 @@
 import React, { PureComponent } from 'react';
 import $ from 'jquery';
 
-import { Threshold, GrafanaThemeType } from '../../types';
 import { getColorFromHexRgbOrName } from '../../utils';
-import { Themeable } from '../../index';
-import { DisplayValue } from '../../utils/displayValue';
+
+import { DisplayValue, Threshold, GrafanaThemeType, Themeable } from '../../types';
 
 export interface Props extends Themeable {
   height: number;

+ 14 - 0
packages/grafana-ui/src/types/data.ts

@@ -76,3 +76,17 @@ export interface TableData {
   rows: any[][];
   tags?: Tags;
 }
+
+export interface AnnotationEvent {
+  annotation?: any;
+  dashboardId?: number;
+  panelId?: number;
+  userId?: number;
+  time?: number;
+  timeEnd?: number;
+  isRegion?: boolean;
+  title?: string;
+  text?: string;
+  type?: string;
+  tags?: string;
+}

+ 11 - 0
packages/grafana-ui/src/types/displayValue.ts

@@ -0,0 +1,11 @@
+export interface DisplayValue {
+  text: string; // Show in the UI
+  numeric: number; // Use isNaN to check if it is a real number
+  color?: string; // color based on configs or Threshold
+  title?: string;
+}
+
+export interface DecimalInfo {
+  decimals: number;
+  scaledDecimals: number;
+}

+ 1 - 0
packages/grafana-ui/src/types/index.ts

@@ -6,3 +6,4 @@ export * from './datasource';
 export * from './theme';
 export * from './threshold';
 export * from './input';
+export * from './displayValue';

+ 31 - 2
packages/grafana-ui/src/utils/displayValue.test.ts

@@ -1,5 +1,5 @@
-import { getDisplayProcessor, getColorFromThreshold, DisplayProcessor, DisplayValue } from './displayValue';
-import { MappingType, ValueMapping } from '../types/panel';
+import { getDisplayProcessor, getColorFromThreshold, DisplayProcessor, getDecimalsForValue } from './displayValue';
+import { DisplayValue, MappingType, ValueMapping } from '../types';
 
 function assertSame(input: any, processors: DisplayProcessor[], match: DisplayValue) {
   processors.forEach(processor => {
@@ -144,6 +144,20 @@ describe('Format value', () => {
     expect(result.text).toEqual('10.0');
   });
 
+  it('should set auto decimals, 1 significant', () => {
+    const value = '1.23';
+    const instance = getDisplayProcessor({ decimals: null });
+
+    expect(instance(value).text).toEqual('1.2');
+  });
+
+  it('should set auto decimals, 2 significant', () => {
+    const value = '0.0245';
+    const instance = getDisplayProcessor({ decimals: null });
+
+    expect(instance(value).text).toEqual('0.02');
+  });
+
   it('should return mapped value if there are matching value mappings', () => {
     const valueMappings: ValueMapping[] = [
       { id: 0, operator: '', text: '1-20', type: MappingType.RangeToText, from: '1', to: '20' },
@@ -155,3 +169,18 @@ describe('Format value', () => {
     expect(instance(value).text).toEqual('1-20');
   });
 });
+
+describe('getDecimalsForValue()', () => {
+  it('should calculate reasonable decimals precision for given value', () => {
+    expect(getDecimalsForValue(1.01)).toEqual({ decimals: 1, scaledDecimals: 4 });
+    expect(getDecimalsForValue(9.01)).toEqual({ decimals: 0, scaledDecimals: 2 });
+    expect(getDecimalsForValue(1.1)).toEqual({ decimals: 1, scaledDecimals: 4 });
+    expect(getDecimalsForValue(2)).toEqual({ decimals: 0, scaledDecimals: 2 });
+    expect(getDecimalsForValue(20)).toEqual({ decimals: 0, scaledDecimals: 1 });
+    expect(getDecimalsForValue(200)).toEqual({ decimals: 0, scaledDecimals: 0 });
+    expect(getDecimalsForValue(2000)).toEqual({ decimals: 0, scaledDecimals: 0 });
+    expect(getDecimalsForValue(20000)).toEqual({ decimals: 0, scaledDecimals: -2 });
+    expect(getDecimalsForValue(200000)).toEqual({ decimals: 0, scaledDecimals: -3 });
+    expect(getDecimalsForValue(200000000)).toEqual({ decimals: 0, scaledDecimals: -6 });
+  });
+});

+ 63 - 13
packages/grafana-ui/src/utils/displayValue.ts

@@ -1,21 +1,21 @@
-import { ValueMapping, Threshold } from '../types';
+// Libraries
 import _ from 'lodash';
-import { getValueFormat, DecimalCount } from './valueFormats/valueFormats';
+import moment from 'moment';
+
+// Utils
+import { getValueFormat } from './valueFormats/valueFormats';
 import { getMappedValue } from './valueMappings';
-import { GrafanaTheme, GrafanaThemeType } from '../types';
 import { getColorFromHexRgbOrName } from './namedColorsPalette';
-import moment from 'moment';
 
-export interface DisplayValue {
-  text: string; // Show in the UI
-  numeric: number; // Use isNaN to check if it is a real number
-  color?: string; // color based on configs or Threshold
-}
+// Types
+import { Threshold, ValueMapping, DecimalInfo, DisplayValue, GrafanaTheme, GrafanaThemeType } from '../types';
+import { DecimalCount } from './valueFormats/valueFormats';
+
+export type DisplayProcessor = (value: any) => DisplayValue;
 
 export interface DisplayValueOptions {
   unit?: string;
   decimals?: DecimalCount;
-  scaledDecimals?: DecimalCount;
   dateFormat?: string; // If set try to convert numbers to date
 
   color?: string;
@@ -32,11 +32,10 @@ export interface DisplayValueOptions {
   theme?: GrafanaTheme; // Will pick 'dark' if not defined
 }
 
-export type DisplayProcessor = (value: any) => DisplayValue;
-
 export function getDisplayProcessor(options?: DisplayValueOptions): DisplayProcessor {
   if (options && !_.isEmpty(options)) {
     const formatFunc = getValueFormat(options.unit || 'none');
+
     return (value: any) => {
       const { prefix, suffix, mappings, thresholds, theme } = options;
       let color = options.color;
@@ -47,12 +46,15 @@ export function getDisplayProcessor(options?: DisplayValueOptions): DisplayProce
       let shouldFormat = true;
       if (mappings && mappings.length > 0) {
         const mappedValue = getMappedValue(mappings, value);
+
         if (mappedValue) {
           text = mappedValue.text;
           const v = toNumber(text);
+
           if (!isNaN(v)) {
             numeric = v;
           }
+
           shouldFormat = false;
         }
       }
@@ -67,7 +69,19 @@ export function getDisplayProcessor(options?: DisplayValueOptions): DisplayProce
 
       if (!isNaN(numeric)) {
         if (shouldFormat && !_.isBoolean(value)) {
-          text = formatFunc(numeric, options.decimals, options.scaledDecimals, options.isUtc);
+          let decimals;
+          let scaledDecimals = 0;
+
+          if (!options.decimals) {
+            const decimalInfo = getDecimalsForValue(value);
+
+            decimals = decimalInfo.decimals;
+            scaledDecimals = decimalInfo.scaledDecimals;
+          } else {
+            decimals = options.decimals;
+          }
+
+          text = formatFunc(numeric, decimals, scaledDecimals, options.isUtc);
         }
         if (thresholds && thresholds.length > 0) {
           color = getColorFromThreshold(numeric, thresholds, theme);
@@ -143,3 +157,39 @@ export function getColorFromThreshold(value: number, thresholds: Threshold[], th
   // Use the first threshold as the default color
   return getColorFromHexRgbOrName(thresholds[0].color, themeType);
 }
+
+export function getDecimalsForValue(value: number): DecimalInfo {
+  const delta = value / 2;
+  let dec = -Math.floor(Math.log(delta) / Math.LN10);
+
+  const magn = Math.pow(10, -dec);
+  const norm = delta / magn; // norm is between 1.0 and 10.0
+  let size;
+
+  if (norm < 1.5) {
+    size = 1;
+  } else if (norm < 3) {
+    size = 2;
+    // special case for 2.5, requires an extra decimal
+    if (norm > 2.25) {
+      size = 2.5;
+      ++dec;
+    }
+  } else if (norm < 7.5) {
+    size = 5;
+  } else {
+    size = 10;
+  }
+
+  size *= magn;
+
+  // reduce starting decimals if not needed
+  if (Math.floor(value) === value) {
+    dec = 0;
+  }
+
+  const decimals = Math.max(0, dec);
+  const scaledDecimals = decimals - Math.floor(Math.log(size) / Math.LN10) + 2;
+
+  return { decimals, scaledDecimals };
+}

+ 2 - 15
public/app/core/actions/location.ts

@@ -1,17 +1,4 @@
 import { LocationUpdate } from 'app/types';
+import { actionCreatorFactory } from 'app/core/redux';
 
-export enum CoreActionTypes {
-  UpdateLocation = 'UPDATE_LOCATION',
-}
-
-export type Action = UpdateLocationAction;
-
-export interface UpdateLocationAction {
-  type: CoreActionTypes.UpdateLocation;
-  payload: LocationUpdate;
-}
-
-export const updateLocation = (location: LocationUpdate): UpdateLocationAction => ({
-  type: CoreActionTypes.UpdateLocation,
-  payload: location,
-});
+export const updateLocation = actionCreatorFactory<LocationUpdate>('UPDATE_LOCATION').create();

+ 9 - 9
public/app/core/reducers/location.ts

@@ -1,7 +1,8 @@
-import { Action, CoreActionTypes } from 'app/core/actions/location';
 import { LocationState } from 'app/types';
 import { renderUrl } from 'app/core/utils/url';
 import _ from 'lodash';
+import { reducerFactory } from 'app/core/redux';
+import { updateLocation } from 'app/core/actions';
 
 export const initialState: LocationState = {
   url: '',
@@ -12,9 +13,10 @@ export const initialState: LocationState = {
   lastUpdated: 0,
 };
 
-export const locationReducer = (state = initialState, action: Action): LocationState => {
-  switch (action.type) {
-    case CoreActionTypes.UpdateLocation: {
+export const locationReducer = reducerFactory<LocationState>(initialState)
+  .addMapper({
+    filter: updateLocation,
+    mapper: (state, action): LocationState => {
       const { path, routeParams, replace } = action.payload;
       let query = action.payload.query || state.query;
 
@@ -31,8 +33,6 @@ export const locationReducer = (state = initialState, action: Action): LocationS
         replace: replace === true,
         lastUpdated: new Date().getTime(),
       };
-    }
-  }
-
-  return state;
-};
+    },
+  })
+  .create();

+ 4 - 0
public/app/core/redux/actionCreatorFactory.ts

@@ -68,5 +68,9 @@ export const getNoPayloadActionCreatorMock = (creator: NoPayloadActionCreator):
   return mock;
 };
 
+export const mockActionCreator = (creator: ActionCreator<any>) => {
+  return Object.assign(jest.fn(), creator);
+};
+
 // Should only be used by tests
 export const resetAllActionCreatorTypes = () => (allActionCreators.length = 0);

+ 3 - 1
public/app/features/alerting/AlertRuleList.test.tsx

@@ -3,6 +3,8 @@ import { shallow } from 'enzyme';
 import { AlertRuleList, Props } from './AlertRuleList';
 import { AlertRule, NavModel } from '../../types';
 import appEvents from '../../core/app_events';
+import { mockActionCreator } from 'app/core/redux';
+import { updateLocation } from 'app/core/actions';
 
 jest.mock('../../core/app_events', () => ({
   emit: jest.fn(),
@@ -12,7 +14,7 @@ const setup = (propOverrides?: object) => {
   const props: Props = {
     navModel: {} as NavModel,
     alertRules: [] as AlertRule[],
-    updateLocation: jest.fn(),
+    updateLocation: mockActionCreator(updateLocation),
     getAlertRulesAsync: jest.fn(),
     setSearchQuery: jest.fn(),
     togglePauseAlertRule: jest.fn(),

+ 1 - 3
public/app/features/annotations/all.ts

@@ -1,7 +1,5 @@
 import { AnnotationsSrv } from './annotations_srv';
 import { eventEditor } from './event_editor';
 import { EventManager } from './event_manager';
-import { AnnotationEvent } from './event';
 import { annotationTooltipDirective } from './annotation_tooltip';
-
-export { AnnotationsSrv, eventEditor, EventManager, AnnotationEvent, annotationTooltipDirective };
+export { AnnotationsSrv, eventEditor, EventManager, annotationTooltipDirective };

+ 0 - 11
public/app/features/annotations/event.ts

@@ -1,11 +0,0 @@
-export class AnnotationEvent {
-  dashboardId: number;
-  panelId: number;
-  userId: number;
-  time: any;
-  timeEnd: any;
-  isRegion: boolean;
-  text: string;
-  type: string;
-  tags: string;
-}

+ 1 - 1
public/app/features/annotations/event_editor.ts

@@ -2,7 +2,7 @@ import _ from 'lodash';
 import moment from 'moment';
 import { coreModule } from 'app/core/core';
 import { MetricsPanelCtrl } from 'app/plugins/sdk';
-import { AnnotationEvent } from './event';
+import { AnnotationEvent } from '@grafana/ui';
 
 export class EventEditorCtrl {
   panelCtrl: MetricsPanelCtrl;

+ 8 - 8
public/app/features/annotations/event_manager.ts

@@ -1,5 +1,4 @@
 import _ from 'lodash';
-import moment from 'moment';
 import tinycolor from 'tinycolor2';
 import {
   OK_COLOR,
@@ -11,7 +10,7 @@ import {
 } from '@grafana/ui';
 
 import { MetricsPanelCtrl } from 'app/plugins/sdk';
-import { AnnotationEvent } from './event';
+import { AnnotationEvent } from '@grafana/ui';
 
 export class EventManager {
   event: AnnotationEvent;
@@ -31,16 +30,17 @@ export class EventManager {
 
   updateTime(range) {
     if (!this.event) {
-      this.event = new AnnotationEvent();
+      this.event = {};
       this.event.dashboardId = this.panelCtrl.dashboard.id;
       this.event.panelId = this.panelCtrl.panel.id;
     }
 
     // update time
-    this.event.time = moment(range.from);
+    this.event.time = range.from;
     this.event.isRegion = false;
+
     if (range.to) {
-      this.event.timeEnd = moment(range.to);
+      this.event.timeEnd = range.to;
       this.event.isRegion = true;
     }
 
@@ -90,8 +90,8 @@ export class EventManager {
         annotations = [
           {
             isRegion: true,
-            min: this.event.time.valueOf(),
-            timeEnd: this.event.timeEnd.valueOf(),
+            min: this.event.time,
+            timeEnd: this.event.timeEnd,
             text: this.event.text,
             eventType: '$__editing',
             editModel: this.event,
@@ -100,7 +100,7 @@ export class EventManager {
       } else {
         annotations = [
           {
-            min: this.event.time.valueOf(),
+            min: this.event.time,
             text: this.event.text,
             editModel: this.event,
             eventType: '$__editing',

+ 3 - 2
public/app/features/dashboard/containers/DashboardPage.test.tsx

@@ -3,8 +3,9 @@ import { shallow, ShallowWrapper } from 'enzyme';
 import { DashboardPage, Props, State, mapStateToProps } from './DashboardPage';
 import { DashboardModel } from '../state';
 import { cleanUpDashboard } from '../state/actions';
-import { getNoPayloadActionCreatorMock, NoPayloadActionCreatorMock } from 'app/core/redux';
+import { getNoPayloadActionCreatorMock, NoPayloadActionCreatorMock, mockActionCreator } from 'app/core/redux';
 import { DashboardRouteInfo, DashboardInitPhase } from 'app/types';
+import { updateLocation } from 'app/core/actions';
 
 jest.mock('app/features/dashboard/components/DashboardSettings/SettingsCtrl', () => ({}));
 
@@ -62,7 +63,7 @@ function dashboardPageScenario(description, scenarioFn: (ctx: ScenarioContext) =
           initPhase: DashboardInitPhase.NotStarted,
           isInitSlow: false,
           initDashboard: jest.fn(),
-          updateLocation: jest.fn(),
+          updateLocation: mockActionCreator(updateLocation),
           notifyApp: jest.fn(),
           cleanUpDashboard: ctx.cleanUpDashboardMock,
           dashboard: null,

+ 3 - 4
public/app/features/datasources/state/actions.ts

@@ -4,10 +4,9 @@ import { getBackendSrv } from 'app/core/services/backend_srv';
 import { getDatasourceSrv } from 'app/features/plugins/datasource_srv';
 import { LayoutMode } from 'app/core/components/LayoutSelector/LayoutSelector';
 import { updateLocation, updateNavIndex, UpdateNavIndexAction } from 'app/core/actions';
-import { UpdateLocationAction } from 'app/core/actions/location';
 import { buildNavModel } from './navModel';
 import { DataSourceSettings } from '@grafana/ui/src/types';
-import { Plugin, StoreState } from 'app/types';
+import { Plugin, StoreState, LocationUpdate } from 'app/types';
 import { actionCreatorFactory } from 'app/core/redux';
 import { ActionOf, noPayloadActionCreatorFactory } from 'app/core/redux/actionCreatorFactory';
 
@@ -32,12 +31,12 @@ export const setDataSourceName = actionCreatorFactory<string>('SET_DATA_SOURCE_N
 export const setIsDefault = actionCreatorFactory<boolean>('SET_IS_DEFAULT').create();
 
 export type Action =
-  | UpdateLocationAction
   | UpdateNavIndexAction
   | ActionOf<DataSourceSettings>
   | ActionOf<DataSourceSettings[]>
   | ActionOf<Plugin>
-  | ActionOf<Plugin[]>;
+  | ActionOf<Plugin[]>
+  | ActionOf<LocationUpdate>;
 
 type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
 

+ 45 - 17
public/app/features/explore/Explore.tsx

@@ -1,7 +1,9 @@
 // Libraries
 import React, { ComponentClass } from 'react';
 import { hot } from 'react-hot-loader';
+// @ts-ignore
 import { connect } from 'react-redux';
+// @ts-ignore
 import _ from 'lodash';
 import { AutoSizer } from 'react-virtualized';
 
@@ -18,11 +20,19 @@ import TableContainer from './TableContainer';
 import TimePicker, { parseTime } from './TimePicker';
 
 // Actions
-import { changeSize, changeTime, initializeExplore, modifyQueries, scanStart, setQueries } from './state/actions';
+import {
+  changeSize,
+  changeTime,
+  initializeExplore,
+  modifyQueries,
+  scanStart,
+  setQueries,
+  refreshExplore,
+} from './state/actions';
 
 // Types
 import { RawTimeRange, TimeRange, DataQuery, ExploreStartPageProps, ExploreDataSourceApi } from '@grafana/ui';
-import { ExploreItemState, ExploreUrlState, RangeScanner, ExploreId } from 'app/types/explore';
+import { ExploreItemState, ExploreUrlState, RangeScanner, ExploreId, ExploreUpdateState } from 'app/types/explore';
 import { StoreState } from 'app/types';
 import { LAST_USED_DATASOURCE_KEY, ensureQueries, DEFAULT_RANGE, DEFAULT_UI_STATE } from 'app/core/utils/explore';
 import { Emitter } from 'app/core/utils/emitter';
@@ -42,6 +52,8 @@ interface ExploreProps {
   initialized: boolean;
   modifyQueries: typeof modifyQueries;
   range: RawTimeRange;
+  update: ExploreUpdateState;
+  refreshExplore: typeof refreshExplore;
   scanner?: RangeScanner;
   scanning?: boolean;
   scanRange?: RawTimeRange;
@@ -53,8 +65,8 @@ interface ExploreProps {
   supportsGraph: boolean | null;
   supportsLogs: boolean | null;
   supportsTable: boolean | null;
-  urlState: ExploreUrlState;
   queryKeys: string[];
+  urlState: ExploreUrlState;
 }
 
 /**
@@ -89,23 +101,22 @@ export class Explore extends React.PureComponent<ExploreProps> {
    */
   timepickerRef: React.RefObject<TimePicker>;
 
-  constructor(props) {
+  constructor(props: ExploreProps) {
     super(props);
     this.exploreEvents = new Emitter();
     this.timepickerRef = React.createRef();
   }
 
-  async componentDidMount() {
-    const { exploreId, initialized, urlState } = this.props;
-    // Don't initialize on split, but need to initialize urlparameters when present
-    if (!initialized) {
-      // Load URL state and parse range
-      const { datasource, queries, range = DEFAULT_RANGE, ui = DEFAULT_UI_STATE } = (urlState || {}) as ExploreUrlState;
-      const initialDatasource = datasource || store.get(LAST_USED_DATASOURCE_KEY);
-      const initialQueries: DataQuery[] = ensureQueries(queries);
-      const initialRange = { from: parseTime(range.from), to: parseTime(range.to) };
-      const width = this.el ? this.el.offsetWidth : 0;
+  componentDidMount() {
+    const { exploreId, urlState, initialized } = this.props;
+    const { datasource, queries, range = DEFAULT_RANGE, ui = DEFAULT_UI_STATE } = (urlState || {}) as ExploreUrlState;
+    const initialDatasource = datasource || store.get(LAST_USED_DATASOURCE_KEY);
+    const initialQueries: DataQuery[] = ensureQueries(queries);
+    const initialRange = { from: parseTime(range.from), to: parseTime(range.to) };
+    const width = this.el ? this.el.offsetWidth : 0;
 
+    // initialize the whole explore first time we mount and if browser history contains a change in datasource
+    if (!initialized) {
       this.props.initializeExplore(
         exploreId,
         initialDatasource,
@@ -122,7 +133,11 @@ export class Explore extends React.PureComponent<ExploreProps> {
     this.exploreEvents.removeAllListeners();
   }
 
-  getRef = el => {
+  componentDidUpdate(prevProps: ExploreProps) {
+    this.refreshExplore();
+  }
+
+  getRef = (el: any) => {
     this.el = el;
   };
 
@@ -142,7 +157,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
     this.onModifyQueries({ type: 'ADD_FILTER', key, value });
   };
 
-  onModifyQueries = (action, index?: number) => {
+  onModifyQueries = (action: any, index?: number) => {
     const { datasourceInstance } = this.props;
     if (datasourceInstance && datasourceInstance.modifyQuery) {
       const modifier = (queries: DataQuery, modification: any) => datasourceInstance.modifyQuery(queries, modification);
@@ -169,6 +184,14 @@ export class Explore extends React.PureComponent<ExploreProps> {
     this.props.scanStopAction({ exploreId: this.props.exploreId });
   };
 
+  refreshExplore = () => {
+    const { exploreId, update } = this.props;
+
+    if (update.queries || update.ui || update.range || update.datasource) {
+      this.props.refreshExplore(exploreId);
+    }
+  };
+
   render() {
     const {
       StartPage,
@@ -241,7 +264,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
   }
 }
 
-function mapStateToProps(state: StoreState, { exploreId }) {
+function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) {
   const explore = state.explore;
   const { split } = explore;
   const item: ExploreItemState = explore[exploreId];
@@ -258,6 +281,8 @@ function mapStateToProps(state: StoreState, { exploreId }) {
     supportsLogs,
     supportsTable,
     queryKeys,
+    urlState,
+    update,
   } = item;
   return {
     StartPage,
@@ -273,6 +298,8 @@ function mapStateToProps(state: StoreState, { exploreId }) {
     supportsLogs,
     supportsTable,
     queryKeys,
+    urlState,
+    update,
   };
 }
 
@@ -281,6 +308,7 @@ const mapDispatchToProps = {
   changeTime,
   initializeExplore,
   modifyQueries,
+  refreshExplore,
   scanStart,
   scanStopAction,
   setQueries,

+ 5 - 36
public/app/features/explore/Wrapper.tsx

@@ -2,65 +2,37 @@ import React, { Component } from 'react';
 import { hot } from 'react-hot-loader';
 import { connect } from 'react-redux';
 
-import { updateLocation } from 'app/core/actions';
 import { StoreState } from 'app/types';
-import { ExploreId, ExploreUrlState } from 'app/types/explore';
-import { parseUrlState } from 'app/core/utils/explore';
+import { ExploreId } from 'app/types/explore';
 
 import ErrorBoundary from './ErrorBoundary';
 import Explore from './Explore';
 import { CustomScrollbar } from '@grafana/ui';
-import { initializeExploreSplitAction, resetExploreAction } from './state/actionTypes';
+import { resetExploreAction } from './state/actionTypes';
 
 interface WrapperProps {
-  initializeExploreSplitAction: typeof initializeExploreSplitAction;
   split: boolean;
-  updateLocation: typeof updateLocation;
   resetExploreAction: typeof resetExploreAction;
-  urlStates: { [key: string]: string };
 }
 
 export class Wrapper extends Component<WrapperProps> {
-  initialSplit: boolean;
-  urlStates: { [key: string]: ExploreUrlState };
-
-  constructor(props: WrapperProps) {
-    super(props);
-    this.urlStates = {};
-    const { left, right } = props.urlStates;
-    if (props.urlStates.left) {
-      this.urlStates.leftState = parseUrlState(left);
-    }
-    if (props.urlStates.right) {
-      this.urlStates.rightState = parseUrlState(right);
-      this.initialSplit = true;
-    }
-  }
-
-  componentDidMount() {
-    if (this.initialSplit) {
-      this.props.initializeExploreSplitAction();
-    }
-  }
-
   componentWillUnmount() {
     this.props.resetExploreAction();
   }
 
   render() {
     const { split } = this.props;
-    const { leftState, rightState } = this.urlStates;
 
     return (
       <div className="page-scrollbar-wrapper">
         <CustomScrollbar autoHeightMin={'100%'} className="custom-scrollbar--page">
           <div className="explore-wrapper">
             <ErrorBoundary>
-              <Explore exploreId={ExploreId.left} urlState={leftState} />
+              <Explore exploreId={ExploreId.left} />
             </ErrorBoundary>
             {split && (
               <ErrorBoundary>
-                <Explore exploreId={ExploreId.right} urlState={rightState} />
+                <Explore exploreId={ExploreId.right} />
               </ErrorBoundary>
             )}
           </div>
@@ -71,14 +43,11 @@ export class Wrapper extends Component<WrapperProps> {
 }
 
 const mapStateToProps = (state: StoreState) => {
-  const urlStates = state.location.query;
   const { split } = state.explore;
-  return { split, urlStates };
+  return { split };
 };
 
 const mapDispatchToProps = {
-  initializeExploreSplitAction,
-  updateLocation,
   resetExploreAction,
 };
 

+ 1 - 23
public/app/features/explore/state/actionTypes.ts

@@ -24,17 +24,11 @@ import { LogLevel } from 'app/core/logs_model';
  *
  */
 export enum ActionTypes {
-  InitializeExploreSplit = 'explore/INITIALIZE_EXPLORE_SPLIT',
   SplitClose = 'explore/SPLIT_CLOSE',
   SplitOpen = 'explore/SPLIT_OPEN',
   ResetExplore = 'explore/RESET_EXPLORE',
 }
 
-export interface InitializeExploreSplitAction {
-  type: ActionTypes.InitializeExploreSplit;
-  payload: {};
-}
-
 export interface SplitCloseAction {
   type: ActionTypes.SplitClose;
   payload: {};
@@ -154,10 +148,6 @@ export interface RemoveQueryRowPayload {
   index: number;
 }
 
-export interface RunQueriesEmptyPayload {
-  exploreId: ExploreId;
-}
-
 export interface ScanStartPayload {
   exploreId: ExploreId;
   scanner: RangeScanner;
@@ -259,11 +249,6 @@ export const initializeExploreAction = actionCreatorFactory<InitializeExplorePay
   'explore/INITIALIZE_EXPLORE'
 ).create();
 
-/**
- * Initialize the wrapper split state
- */
-export const initializeExploreSplitAction = noPayloadActionCreatorFactory('explore/INITIALIZE_EXPLORE_SPLIT').create();
-
 /**
  * Display an error that happened during the selection of a datasource
  */
@@ -342,7 +327,6 @@ export const queryTransactionSuccessAction = actionCreatorFactory<QueryTransacti
  */
 export const removeQueryRowAction = actionCreatorFactory<RemoveQueryRowPayload>('explore/REMOVE_QUERY_ROW').create();
 export const runQueriesAction = noPayloadActionCreatorFactory('explore/RUN_QUERIES').create();
-export const runQueriesEmptyAction = actionCreatorFactory<RunQueriesEmptyPayload>('explore/RUN_QUERIES_EMPTY').create();
 
 /**
  * Start a scan for more results using the given scanner.
@@ -411,12 +395,7 @@ export const toggleLogLevelAction = actionCreatorFactory<ToggleLogLevelPayload>(
 export const resetExploreAction = noPayloadActionCreatorFactory('explore/RESET_EXPLORE').create();
 export const queriesImportedAction = actionCreatorFactory<QueriesImportedPayload>('explore/QueriesImported').create();
 
-export type HigherOrderAction =
-  | InitializeExploreSplitAction
-  | SplitCloseAction
-  | SplitOpenAction
-  | ResetExploreAction
-  | ActionOf<any>;
+export type HigherOrderAction = SplitCloseAction | SplitOpenAction | ResetExploreAction | ActionOf<any>;
 
 export type Action =
   | ActionOf<AddQueryRowPayload>
@@ -435,7 +414,6 @@ export type Action =
   | ActionOf<QueryTransactionStartPayload>
   | ActionOf<QueryTransactionSuccessPayload>
   | ActionOf<RemoveQueryRowPayload>
-  | ActionOf<RunQueriesEmptyPayload>
   | ActionOf<ScanStartPayload>
   | ActionOf<ScanRangePayload>
   | ActionOf<SetQueriesPayload>

+ 147 - 0
public/app/features/explore/state/actions.test.ts

@@ -0,0 +1,147 @@
+import { refreshExplore } from './actions';
+import { ExploreId, ExploreUrlState, ExploreUpdateState } from 'app/types';
+import { thunkTester } from 'test/core/thunk/thunkTester';
+import { LogsDedupStrategy } from 'app/core/logs_model';
+import {
+  initializeExploreAction,
+  InitializeExplorePayload,
+  changeTimeAction,
+  updateUIStateAction,
+  setQueriesAction,
+} from './actionTypes';
+import { Emitter } from 'app/core/core';
+import { ActionOf } from 'app/core/redux/actionCreatorFactory';
+import { makeInitialUpdateState } from './reducers';
+
+jest.mock('app/features/plugins/datasource_srv', () => ({
+  getDatasourceSrv: () => ({
+    getExternal: jest.fn().mockReturnValue([]),
+    get: jest.fn().mockReturnValue({
+      testDatasource: jest.fn(),
+      init: jest.fn(),
+    }),
+  }),
+}));
+
+const setup = (updateOverides?: Partial<ExploreUpdateState>) => {
+  const exploreId = ExploreId.left;
+  const containerWidth = 1920;
+  const eventBridge = {} as Emitter;
+  const ui = { dedupStrategy: LogsDedupStrategy.none, showingGraph: false, showingLogs: false, showingTable: false };
+  const range = { from: 'now', to: 'now' };
+  const urlState: ExploreUrlState = { datasource: 'some-datasource', queries: [], range, ui };
+  const updateDefaults = makeInitialUpdateState();
+  const update = { ...updateDefaults, ...updateOverides };
+  const initialState = {
+    explore: {
+      [exploreId]: {
+        initialized: true,
+        urlState,
+        containerWidth,
+        eventBridge,
+        update,
+        datasourceInstance: { name: 'some-datasource' },
+        queries: [],
+        range,
+        ui,
+      },
+    },
+  };
+
+  return {
+    initialState,
+    exploreId,
+    range,
+    ui,
+    containerWidth,
+    eventBridge,
+  };
+};
+
+describe('refreshExplore', () => {
+  describe('when explore is initialized', () => {
+    describe('and update datasource is set', () => {
+      it('then it should dispatch initializeExplore', () => {
+        const { exploreId, ui, range, initialState, containerWidth, eventBridge } = setup({ datasource: true });
+
+        thunkTester(initialState)
+          .givenThunk(refreshExplore)
+          .whenThunkIsDispatched(exploreId)
+          .thenDispatchedActionsAreEqual(dispatchedActions => {
+            const initializeExplore = dispatchedActions[0] as ActionOf<InitializeExplorePayload>;
+            const { type, payload } = initializeExplore;
+
+            expect(type).toEqual(initializeExploreAction.type);
+            expect(payload.containerWidth).toEqual(containerWidth);
+            expect(payload.eventBridge).toEqual(eventBridge);
+            expect(payload.exploreDatasources).toEqual([]);
+            expect(payload.queries.length).toBe(1); // Queries have generated keys hard to expect on
+            expect(payload.range).toEqual(range);
+            expect(payload.ui).toEqual(ui);
+
+            return true;
+          });
+      });
+    });
+
+    describe('and update range is set', () => {
+      it('then it should dispatch changeTimeAction', () => {
+        const { exploreId, range, initialState } = setup({ range: true });
+
+        thunkTester(initialState)
+          .givenThunk(refreshExplore)
+          .whenThunkIsDispatched(exploreId)
+          .thenDispatchedActionsAreEqual(dispatchedActions => {
+            expect(dispatchedActions[0].type).toEqual(changeTimeAction.type);
+            expect(dispatchedActions[0].payload).toEqual({ exploreId, range });
+
+            return true;
+          });
+      });
+    });
+
+    describe('and update ui is set', () => {
+      it('then it should dispatch updateUIStateAction', () => {
+        const { exploreId, initialState, ui } = setup({ ui: true });
+
+        thunkTester(initialState)
+          .givenThunk(refreshExplore)
+          .whenThunkIsDispatched(exploreId)
+          .thenDispatchedActionsAreEqual(dispatchedActions => {
+            expect(dispatchedActions[0].type).toEqual(updateUIStateAction.type);
+            expect(dispatchedActions[0].payload).toEqual({ ...ui, exploreId });
+
+            return true;
+          });
+      });
+    });
+
+    describe('and update queries is set', () => {
+      it('then it should dispatch setQueriesAction', () => {
+        const { exploreId, initialState } = setup({ queries: true });
+
+        thunkTester(initialState)
+          .givenThunk(refreshExplore)
+          .whenThunkIsDispatched(exploreId)
+          .thenDispatchedActionsAreEqual(dispatchedActions => {
+            expect(dispatchedActions[0].type).toEqual(setQueriesAction.type);
+            expect(dispatchedActions[0].payload).toEqual({ exploreId, queries: [] });
+
+            return true;
+          });
+      });
+    });
+  });
+
+  describe('when update is not initialized', () => {
+    it('then it should not dispatch any actions', () => {
+      const exploreId = ExploreId.left;
+      const initialState = { explore: { [exploreId]: { initialized: false } } };
+
+      thunkTester(initialState)
+        .givenThunk(refreshExplore)
+        .whenThunkIsDispatched(exploreId)
+        .thenThereAreNoDispatchedActions();
+    });
+  });
+});

+ 51 - 3
public/app/features/explore/state/actions.ts

@@ -16,6 +16,7 @@ import {
   updateHistory,
   buildQueryTransaction,
   serializeStateToUrlParam,
+  parseUrlState,
 } from 'app/core/utils/explore';
 
 // Actions
@@ -54,7 +55,6 @@ import {
   queryTransactionStartAction,
   queryTransactionSuccessAction,
   scanRangeAction,
-  runQueriesEmptyAction,
   scanStartAction,
   setQueriesAction,
   splitCloseAction,
@@ -67,9 +67,11 @@ import {
   ToggleLogsPayload,
   ToggleTablePayload,
   updateUIStateAction,
+  runQueriesAction,
 } from './actionTypes';
 import { ActionOf, ActionCreator } from 'app/core/redux/actionCreatorFactory';
 import { LogsDedupStrategy } from 'app/core/logs_model';
+import { parseTime } from '../TimePicker';
 
 type ThunkResult<R> = ThunkAction<R, StoreState, undefined, Action>;
 
@@ -518,7 +520,7 @@ export function runQueries(exploreId: ExploreId, ignoreUIState = false) {
     } = getState().explore[exploreId];
 
     if (!hasNonEmptyQuery(queries)) {
-      dispatch(runQueriesEmptyAction({ exploreId }));
+      dispatch(clearQueriesAction({ exploreId }));
       dispatch(stateSave()); // Remember to saves to state and update location
       return;
     }
@@ -527,6 +529,7 @@ export function runQueries(exploreId: ExploreId, ignoreUIState = false) {
     // but we're using the datasource interval limit for now
     const interval = datasourceInstance.interval;
 
+    dispatch(runQueriesAction());
     // Keep table queries first since they need to return quickly
     if ((ignoreUIState || showingTable) && supportsTable) {
       dispatch(
@@ -657,11 +660,15 @@ export function splitClose(): ThunkResult<void> {
 export function splitOpen(): ThunkResult<void> {
   return (dispatch, getState) => {
     // Clone left state to become the right state
-    const leftState = getState().explore.left;
+    const leftState = getState().explore[ExploreId.left];
+    const queryState = getState().location.query[ExploreId.left] as string;
+    const urlState = parseUrlState(queryState);
     const itemState = {
       ...leftState,
       queryTransactions: [],
       queries: leftState.queries.slice(),
+      exploreId: ExploreId.right,
+      urlState,
     };
     dispatch(splitOpenAction({ itemState }));
     dispatch(stateSave());
@@ -766,3 +773,44 @@ export const changeDedupStrategy = (exploreId, dedupStrategy: LogsDedupStrategy)
     dispatch(updateExploreUIState(exploreId, { dedupStrategy }));
   };
 };
+
+export function refreshExplore(exploreId: ExploreId): ThunkResult<void> {
+  return (dispatch, getState) => {
+    const itemState = getState().explore[exploreId];
+    if (!itemState.initialized) {
+      return;
+    }
+
+    const { urlState, update, containerWidth, eventBridge } = itemState;
+    const { datasource, queries, range, ui } = urlState;
+    const refreshQueries = queries.map(q => ({ ...q, ...generateEmptyQuery(itemState.queries) }));
+    const refreshRange = { from: parseTime(range.from), to: parseTime(range.to) };
+
+    // need to refresh datasource
+    if (update.datasource) {
+      const initialQueries = ensureQueries(queries);
+      const initialRange = { from: parseTime(range.from), to: parseTime(range.to) };
+      dispatch(initializeExplore(exploreId, datasource, initialQueries, initialRange, containerWidth, eventBridge, ui));
+      return;
+    }
+
+    if (update.range) {
+      dispatch(changeTimeAction({ exploreId, range: refreshRange as TimeRange }));
+    }
+
+    // need to refresh ui state
+    if (update.ui) {
+      dispatch(updateUIStateAction({ ...ui, exploreId }));
+    }
+
+    // need to refresh queries
+    if (update.queries) {
+      dispatch(setQueriesAction({ exploreId, queries: refreshQueries }));
+    }
+
+    // always run queries when refresh is needed
+    if (update.queries || update.ui || update.range) {
+      dispatch(runQueries(exploreId));
+    }
+  };
+}

+ 294 - 2
public/app/features/explore/state/reducers.test.ts

@@ -1,9 +1,12 @@
-import { itemReducer, makeExploreItemState } from './reducers';
-import { ExploreId, ExploreItemState } from 'app/types/explore';
+import { itemReducer, makeExploreItemState, exploreReducer, makeInitialUpdateState } from './reducers';
+import { ExploreId, ExploreItemState, ExploreUrlState } from 'app/types/explore';
 import { reducerTester } from 'test/core/redux/reducerTester';
 import { scanStartAction, scanStopAction } from './actionTypes';
 import { Reducer } from 'redux';
 import { ActionOf } from 'app/core/redux/actionCreatorFactory';
+import { updateLocation } from 'app/core/actions/location';
+import { LogsDedupStrategy } from 'app/core/logs_model';
+import { serializeStateToUrlParam } from 'app/core/utils/explore';
 
 describe('Explore item reducer', () => {
   describe('scanning', () => {
@@ -45,3 +48,292 @@ describe('Explore item reducer', () => {
     });
   });
 });
+
+export const setup = (urlStateOverrides?: any) => {
+  const update = makeInitialUpdateState();
+  const urlStateDefaults: ExploreUrlState = {
+    datasource: 'some-datasource',
+    queries: [],
+    range: {
+      from: '',
+      to: '',
+    },
+    ui: {
+      dedupStrategy: LogsDedupStrategy.none,
+      showingGraph: false,
+      showingTable: false,
+      showingLogs: false,
+    },
+  };
+  const urlState: ExploreUrlState = { ...urlStateDefaults, ...urlStateOverrides };
+  const serializedUrlState = serializeStateToUrlParam(urlState);
+  const initalState = { split: false, left: { urlState, update }, right: { urlState, update } };
+
+  return {
+    initalState,
+    serializedUrlState,
+  };
+};
+
+describe('Explore reducer', () => {
+  describe('when updateLocation is dispatched', () => {
+    describe('and payload does not contain a query', () => {
+      it('then it should just return state', () => {
+        reducerTester()
+          .givenReducer(exploreReducer, {})
+          .whenActionIsDispatched(updateLocation({ query: null }))
+          .thenStateShouldEqual({});
+      });
+    });
+
+    describe('and payload contains a query', () => {
+      describe("but does not contain 'left'", () => {
+        it('then it should just return state', () => {
+          reducerTester()
+            .givenReducer(exploreReducer, {})
+            .whenActionIsDispatched(updateLocation({ query: {} }))
+            .thenStateShouldEqual({});
+        });
+      });
+
+      describe("and query contains a 'right'", () => {
+        it('then it should add split in state', () => {
+          const { initalState, serializedUrlState } = setup();
+          const expectedState = { ...initalState, split: true };
+
+          reducerTester()
+            .givenReducer(exploreReducer, initalState)
+            .whenActionIsDispatched(
+              updateLocation({
+                query: {
+                  left: serializedUrlState,
+                  right: serializedUrlState,
+                },
+              })
+            )
+            .thenStateShouldEqual(expectedState);
+        });
+      });
+
+      describe("and query contains a 'left'", () => {
+        describe('but urlState is not set in state', () => {
+          it('then it should just add urlState and update in state', () => {
+            const { initalState, serializedUrlState } = setup();
+            const stateWithoutUrlState = { ...initalState, left: { urlState: null } };
+            const expectedState = { ...initalState };
+
+            reducerTester()
+              .givenReducer(exploreReducer, stateWithoutUrlState)
+              .whenActionIsDispatched(
+                updateLocation({
+                  query: {
+                    left: serializedUrlState,
+                  },
+                  path: '/explore',
+                })
+              )
+              .thenStateShouldEqual(expectedState);
+          });
+        });
+
+        describe("but '/explore' is missing in path", () => {
+          it('then it should just add urlState and update in state', () => {
+            const { initalState, serializedUrlState } = setup();
+            const expectedState = { ...initalState };
+
+            reducerTester()
+              .givenReducer(exploreReducer, initalState)
+              .whenActionIsDispatched(
+                updateLocation({
+                  query: {
+                    left: serializedUrlState,
+                  },
+                  path: '/dashboard',
+                })
+              )
+              .thenStateShouldEqual(expectedState);
+          });
+        });
+
+        describe("and '/explore' is in path", () => {
+          describe('and datasource differs', () => {
+            it('then it should return update datasource', () => {
+              const { initalState, serializedUrlState } = setup();
+              const expectedState = {
+                ...initalState,
+                left: {
+                  ...initalState.left,
+                  update: {
+                    ...initalState.left.update,
+                    datasource: true,
+                  },
+                },
+              };
+              const stateWithDifferentDataSource = {
+                ...initalState,
+                left: {
+                  ...initalState.left,
+                  urlState: {
+                    ...initalState.left.urlState,
+                    datasource: 'different datasource',
+                  },
+                },
+              };
+
+              reducerTester()
+                .givenReducer(exploreReducer, stateWithDifferentDataSource)
+                .whenActionIsDispatched(
+                  updateLocation({
+                    query: {
+                      left: serializedUrlState,
+                    },
+                    path: '/explore',
+                  })
+                )
+                .thenStateShouldEqual(expectedState);
+            });
+          });
+
+          describe('and range differs', () => {
+            it('then it should return update range', () => {
+              const { initalState, serializedUrlState } = setup();
+              const expectedState = {
+                ...initalState,
+                left: {
+                  ...initalState.left,
+                  update: {
+                    ...initalState.left.update,
+                    range: true,
+                  },
+                },
+              };
+              const stateWithDifferentDataSource = {
+                ...initalState,
+                left: {
+                  ...initalState.left,
+                  urlState: {
+                    ...initalState.left.urlState,
+                    range: {
+                      from: 'now',
+                      to: 'now-6h',
+                    },
+                  },
+                },
+              };
+
+              reducerTester()
+                .givenReducer(exploreReducer, stateWithDifferentDataSource)
+                .whenActionIsDispatched(
+                  updateLocation({
+                    query: {
+                      left: serializedUrlState,
+                    },
+                    path: '/explore',
+                  })
+                )
+                .thenStateShouldEqual(expectedState);
+            });
+          });
+
+          describe('and queries differs', () => {
+            it('then it should return update queries', () => {
+              const { initalState, serializedUrlState } = setup();
+              const expectedState = {
+                ...initalState,
+                left: {
+                  ...initalState.left,
+                  update: {
+                    ...initalState.left.update,
+                    queries: true,
+                  },
+                },
+              };
+              const stateWithDifferentDataSource = {
+                ...initalState,
+                left: {
+                  ...initalState.left,
+                  urlState: {
+                    ...initalState.left.urlState,
+                    queries: [{ expr: '{__filename__="some.log"}' }],
+                  },
+                },
+              };
+
+              reducerTester()
+                .givenReducer(exploreReducer, stateWithDifferentDataSource)
+                .whenActionIsDispatched(
+                  updateLocation({
+                    query: {
+                      left: serializedUrlState,
+                    },
+                    path: '/explore',
+                  })
+                )
+                .thenStateShouldEqual(expectedState);
+            });
+          });
+
+          describe('and ui differs', () => {
+            it('then it should return update ui', () => {
+              const { initalState, serializedUrlState } = setup();
+              const expectedState = {
+                ...initalState,
+                left: {
+                  ...initalState.left,
+                  update: {
+                    ...initalState.left.update,
+                    ui: true,
+                  },
+                },
+              };
+              const stateWithDifferentDataSource = {
+                ...initalState,
+                left: {
+                  ...initalState.left,
+                  urlState: {
+                    ...initalState.left.urlState,
+                    ui: {
+                      ...initalState.left.urlState.ui,
+                      showingGraph: true,
+                    },
+                  },
+                },
+              };
+
+              reducerTester()
+                .givenReducer(exploreReducer, stateWithDifferentDataSource)
+                .whenActionIsDispatched(
+                  updateLocation({
+                    query: {
+                      left: serializedUrlState,
+                    },
+                    path: '/explore',
+                  })
+                )
+                .thenStateShouldEqual(expectedState);
+            });
+          });
+
+          describe('and nothing differs', () => {
+            fit('then it should return update ui', () => {
+              const { initalState, serializedUrlState } = setup();
+              const expectedState = { ...initalState };
+
+              reducerTester()
+                .givenReducer(exploreReducer, initalState)
+                .whenActionIsDispatched(
+                  updateLocation({
+                    query: {
+                      left: serializedUrlState,
+                    },
+                    path: '/explore',
+                  })
+                )
+                .thenStateShouldEqual(expectedState);
+            });
+          });
+        });
+      });
+    });
+  });
+});

+ 105 - 19
public/app/features/explore/state/reducers.ts

@@ -1,11 +1,15 @@
+// @ts-ignore
+import _ from 'lodash';
 import {
   calculateResultsFromQueryTransactions,
   generateEmptyQuery,
   getIntervals,
   ensureQueries,
   getQueryKeys,
+  parseUrlState,
+  DEFAULT_UI_STATE,
 } from 'app/core/utils/explore';
-import { ExploreItemState, ExploreState, QueryTransaction } from 'app/types/explore';
+import { ExploreItemState, ExploreState, QueryTransaction, ExploreId, ExploreUpdateState } from 'app/types/explore';
 import { DataQuery } from '@grafana/ui/src/types';
 
 import { HigherOrderAction, ActionTypes } from './actionTypes';
@@ -28,7 +32,6 @@ import {
   queryTransactionStartAction,
   queryTransactionSuccessAction,
   removeQueryRowAction,
-  runQueriesEmptyAction,
   scanRangeAction,
   scanStartAction,
   scanStopAction,
@@ -40,6 +43,8 @@ import {
   updateUIStateAction,
   toggleLogLevelAction,
 } from './actionTypes';
+import { updateLocation } from 'app/core/actions/location';
+import { LocationUpdate } from 'app/types';
 
 export const DEFAULT_RANGE = {
   from: 'now-6h',
@@ -49,6 +54,12 @@ export const DEFAULT_RANGE = {
 // Millies step for helper bar charts
 const DEFAULT_GRAPH_INTERVAL = 15 * 1000;
 
+export const makeInitialUpdateState = (): ExploreUpdateState => ({
+  datasource: false,
+  queries: false,
+  range: false,
+  ui: false,
+});
 /**
  * Returns a fresh Explore area state
  */
@@ -76,6 +87,8 @@ export const makeExploreItemState = (): ExploreItemState => ({
   supportsLogs: null,
   supportsTable: null,
   queryKeys: [],
+  urlState: null,
+  update: makeInitialUpdateState(),
 });
 
 /**
@@ -195,6 +208,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
         initialized: true,
         queryKeys: getQueryKeys(queries, state.datasourceInstance),
         ...ui,
+        update: makeInitialUpdateState(),
       };
     },
   })
@@ -208,13 +222,23 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
   .addMapper({
     filter: loadDatasourceFailureAction,
     mapper: (state, action): ExploreItemState => {
-      return { ...state, datasourceError: action.payload.error, datasourceLoading: false };
+      return {
+        ...state,
+        datasourceError: action.payload.error,
+        datasourceLoading: false,
+        update: makeInitialUpdateState(),
+      };
     },
   })
   .addMapper({
     filter: loadDatasourceMissingAction,
     mapper: (state): ExploreItemState => {
-      return { ...state, datasourceMissing: true, datasourceLoading: false };
+      return {
+        ...state,
+        datasourceMissing: true,
+        datasourceLoading: false,
+        update: makeInitialUpdateState(),
+      };
     },
   })
   .addMapper({
@@ -253,6 +277,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
         datasourceError: null,
         logsHighlighterExpressions: undefined,
         queryTransactions: [],
+        update: makeInitialUpdateState(),
       };
     },
   })
@@ -262,7 +287,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
       const { queries, queryTransactions } = state;
       const { modification, index, modifier } = action.payload;
       let nextQueries: DataQuery[];
-      let nextQueryTransactions;
+      let nextQueryTransactions: QueryTransaction[];
       if (index === undefined) {
         // Modify all queries
         nextQueries = queries.map((query, i) => ({
@@ -303,7 +328,12 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
     filter: queryTransactionFailureAction,
     mapper: (state, action): ExploreItemState => {
       const { queryTransactions } = action.payload;
-      return { ...state, queryTransactions, showingStartPage: false };
+      return {
+        ...state,
+        queryTransactions,
+        showingStartPage: false,
+        update: makeInitialUpdateState(),
+      };
     },
   })
   .addMapper({
@@ -319,7 +349,12 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
       // Append new transaction
       const nextQueryTransactions: QueryTransaction[] = [...remainingTransactions, transaction];
 
-      return { ...state, queryTransactions: nextQueryTransactions, showingStartPage: false };
+      return {
+        ...state,
+        queryTransactions: nextQueryTransactions,
+        showingStartPage: false,
+        update: makeInitialUpdateState(),
+      };
     },
   })
   .addMapper({
@@ -333,7 +368,14 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
         queryIntervals.intervalMs
       );
 
-      return { ...state, ...results, history, queryTransactions, showingStartPage: false };
+      return {
+        ...state,
+        ...results,
+        history,
+        queryTransactions,
+        showingStartPage: false,
+        update: makeInitialUpdateState(),
+      };
     },
   })
   .addMapper({
@@ -367,12 +409,6 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
       };
     },
   })
-  .addMapper({
-    filter: runQueriesEmptyAction,
-    mapper: (state): ExploreItemState => {
-      return { ...state, queryTransactions: [] };
-    },
-  })
   .addMapper({
     filter: scanRangeAction,
     mapper: (state, action): ExploreItemState => {
@@ -396,6 +432,7 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
         scanning: false,
         scanRange: undefined,
         scanner: undefined,
+        update: makeInitialUpdateState(),
       };
     },
   })
@@ -482,6 +519,41 @@ export const itemReducer = reducerFactory<ExploreItemState>({} as ExploreItemSta
   })
   .create();
 
+export const updateChildRefreshState = (
+  state: Readonly<ExploreItemState>,
+  payload: LocationUpdate,
+  exploreId: ExploreId
+): ExploreItemState => {
+  const path = payload.path || '';
+  const queryState = payload.query[exploreId] as string;
+  if (!queryState) {
+    return state;
+  }
+
+  const urlState = parseUrlState(queryState);
+  if (!state.urlState || path !== '/explore') {
+    // we only want to refresh when browser back/forward
+    return { ...state, urlState, update: { datasource: false, queries: false, range: false, ui: false } };
+  }
+
+  const datasource = _.isEqual(urlState ? urlState.datasource : '', state.urlState.datasource) === false;
+  const queries = _.isEqual(urlState ? urlState.queries : [], state.urlState.queries) === false;
+  const range = _.isEqual(urlState ? urlState.range : DEFAULT_RANGE, state.urlState.range) === false;
+  const ui = _.isEqual(urlState ? urlState.ui : DEFAULT_UI_STATE, state.urlState.ui) === false;
+
+  return {
+    ...state,
+    urlState,
+    update: {
+      ...state.update,
+      datasource,
+      queries,
+      range,
+      ui,
+    },
+  };
+};
+
 /**
  * Global Explore reducer that handles multiple Explore areas (left and right).
  * Actions that have an `exploreId` get routed to the ExploreItemReducer.
@@ -493,16 +565,30 @@ export const exploreReducer = (state = initialExploreState, action: HigherOrderA
     }
 
     case ActionTypes.SplitOpen: {
-      return { ...state, split: true, right: action.payload.itemState };
-    }
-
-    case ActionTypes.InitializeExploreSplit: {
-      return { ...state, split: true };
+      return { ...state, split: true, right: { ...action.payload.itemState } };
     }
 
     case ActionTypes.ResetExplore: {
       return initialExploreState;
     }
+
+    case updateLocation.type: {
+      const { query } = action.payload;
+      if (!query || !query[ExploreId.left]) {
+        return state;
+      }
+
+      const split = query[ExploreId.right] ? true : false;
+      const leftState = state[ExploreId.left];
+      const rightState = state[ExploreId.right];
+
+      return {
+        ...state,
+        split,
+        [ExploreId.left]: updateChildRefreshState(leftState, action.payload, ExploreId.left),
+        [ExploreId.right]: updateChildRefreshState(rightState, action.payload, ExploreId.right),
+      };
+    }
   }
 
   if (action.payload) {

+ 10 - 3
public/app/plugins/datasource/graphite/gfunc.ts

@@ -1,5 +1,6 @@
 import _ from 'lodash';
 import { isVersionGtOrEq } from 'app/core/utils/version';
+import { InterpolateFunction } from '@grafana/ui';
 
 const index = {};
 
@@ -961,24 +962,30 @@ export class FuncInstance {
     this.updateText();
   }
 
-  render(metricExp) {
+  render(metricExp: string, replaceVariables: InterpolateFunction): string {
     const str = this.def.name + '(';
 
     const parameters = _.map(this.params, (value, index) => {
+      const valueInterpolated = replaceVariables(value);
       let paramType;
+
       if (index < this.def.params.length) {
         paramType = this.def.params[index].type;
       } else if (_.get(_.last(this.def.params), 'multiple')) {
         paramType = _.get(_.last(this.def.params), 'type');
       }
+
       // param types that should never be quoted
       if (_.includes(['value_or_series', 'boolean', 'int', 'float', 'node'], paramType)) {
         return value;
       }
+
       // param types that might be quoted
-      if (_.includes(['int_or_interval', 'node_or_tag'], paramType) && _.isFinite(+value)) {
-        return _.toString(+value);
+      // To quote variables correctly we need to interpolate it to check if it contains a numeric or string value
+      if (_.includes(['int_or_interval', 'node_or_tag'], paramType) && _.isFinite(+valueInterpolated)) {
+        return _.toString(value);
       }
+
       return "'" + value + "'";
     });
 

+ 5 - 5
public/app/plugins/datasource/graphite/graphite_query.ts

@@ -18,6 +18,7 @@ export default class GraphiteQuery {
   constructor(datasource, target, templateSrv?, scopedVars?) {
     this.datasource = datasource;
     this.target = target;
+    this.templateSrv = templateSrv;
     this.parseTarget();
 
     this.removeTagValue = '-- remove tag --';
@@ -160,7 +161,10 @@ export default class GraphiteQuery {
   }
 
   updateModelTarget(targets) {
-    // render query
+    const wrapFunction = (target: string, func: any) => {
+      return func.render(target, this.templateSrv.replace);
+    };
+
     if (!this.target.textEditor) {
       const metricPath = this.getSegmentPathUpTo(this.segments.length).replace(/\.select metric$/, '');
       this.target.target = _.reduce(this.functions, wrapFunction, metricPath);
@@ -302,10 +306,6 @@ export default class GraphiteQuery {
   }
 }
 
-function wrapFunction(target, func) {
-  return func.render(target);
-}
-
 function renderTagString(tag) {
   return tag.key + tag.operator + tag.value;
 }

+ 36 - 10
public/app/plugins/datasource/graphite/specs/gfunc.test.ts

@@ -30,66 +30,92 @@ describe('when creating func instance from func names', () => {
   });
 });
 
+function replaceVariablesDummy(str: string) {
+  return str;
+}
+
 describe('when rendering func instance', () => {
   it('should handle single metric param', () => {
     const func = gfunc.createFuncInstance('sumSeries');
-    expect(func.render('hello.metric')).toEqual('sumSeries(hello.metric)');
+    expect(func.render('hello.metric', replaceVariablesDummy)).toEqual('sumSeries(hello.metric)');
   });
 
   it('should include default params if options enable it', () => {
     const func = gfunc.createFuncInstance('scaleToSeconds', {
       withDefaultParams: true,
     });
-    expect(func.render('hello')).toEqual('scaleToSeconds(hello, 1)');
+    expect(func.render('hello', replaceVariablesDummy)).toEqual('scaleToSeconds(hello, 1)');
   });
 
   it('should handle int or interval params with number', () => {
     const func = gfunc.createFuncInstance('movingMedian');
     func.params[0] = '5';
-    expect(func.render('hello')).toEqual('movingMedian(hello, 5)');
+    expect(func.render('hello', replaceVariablesDummy)).toEqual('movingMedian(hello, 5)');
   });
 
   it('should handle int or interval params with interval string', () => {
     const func = gfunc.createFuncInstance('movingMedian');
     func.params[0] = '5min';
-    expect(func.render('hello')).toEqual("movingMedian(hello, '5min')");
+    expect(func.render('hello', replaceVariablesDummy)).toEqual("movingMedian(hello, '5min')");
   });
 
   it('should never quote boolean paramater', () => {
     const func = gfunc.createFuncInstance('sortByName');
     func.params[0] = '$natural';
-    expect(func.render('hello')).toEqual('sortByName(hello, $natural)');
+    expect(func.render('hello', replaceVariablesDummy)).toEqual('sortByName(hello, $natural)');
   });
 
   it('should never quote int paramater', () => {
     const func = gfunc.createFuncInstance('maximumAbove');
     func.params[0] = '$value';
-    expect(func.render('hello')).toEqual('maximumAbove(hello, $value)');
+    expect(func.render('hello', replaceVariablesDummy)).toEqual('maximumAbove(hello, $value)');
   });
 
   it('should never quote node paramater', () => {
     const func = gfunc.createFuncInstance('aliasByNode');
     func.params[0] = '$node';
-    expect(func.render('hello')).toEqual('aliasByNode(hello, $node)');
+    expect(func.render('hello', replaceVariablesDummy)).toEqual('aliasByNode(hello, $node)');
   });
 
   it('should handle metric param and int param and string param', () => {
     const func = gfunc.createFuncInstance('groupByNode');
     func.params[0] = 5;
     func.params[1] = 'avg';
-    expect(func.render('hello.metric')).toEqual("groupByNode(hello.metric, 5, 'avg')");
+    expect(func.render('hello.metric', replaceVariablesDummy)).toEqual("groupByNode(hello.metric, 5, 'avg')");
   });
 
   it('should handle function with no metric param', () => {
     const func = gfunc.createFuncInstance('randomWalk');
     func.params[0] = 'test';
-    expect(func.render(undefined)).toEqual("randomWalk('test')");
+    expect(func.render(undefined, replaceVariablesDummy)).toEqual("randomWalk('test')");
   });
 
   it('should handle function multiple series params', () => {
     const func = gfunc.createFuncInstance('asPercent');
     func.params[0] = '#B';
-    expect(func.render('#A')).toEqual('asPercent(#A, #B)');
+    expect(func.render('#A', replaceVariablesDummy)).toEqual('asPercent(#A, #B)');
+  });
+
+  it('should not quote variables that have numeric value', () => {
+    const func = gfunc.createFuncInstance('movingAverage');
+    func.params[0] = '$variable';
+
+    const replaceVariables = (str: string) => {
+      return str.replace('$variable', '60');
+    };
+
+    expect(func.render('metric', replaceVariables)).toBe('movingAverage(metric, $variable)');
+  });
+
+  it('should quote variables that have string value', () => {
+    const func = gfunc.createFuncInstance('movingAverage');
+    func.params[0] = '$variable';
+
+    const replaceVariables = (str: string) => {
+      return str.replace('$variable', '10min');
+    };
+
+    expect(func.render('metric', replaceVariables)).toBe("movingAverage(metric, '$variable')");
   });
 });
 

+ 2 - 1
public/app/plugins/datasource/graphite/specs/graphite_query.test.ts

@@ -1,5 +1,6 @@
 import gfunc from '../gfunc';
 import GraphiteQuery from '../graphite_query';
+import { TemplateSrvStub } from 'test/specs/helpers';
 
 describe('Graphite query model', () => {
   const ctx: any = {
@@ -9,7 +10,7 @@ describe('Graphite query model', () => {
       waitForFuncDefsLoaded: jest.fn().mockReturnValue(Promise.resolve(null)),
       createFuncInstance: gfunc.createFuncInstance,
     },
-    templateSrv: {},
+    templateSrv: new TemplateSrvStub(),
     targets: [],
   };
 

+ 3 - 2
public/app/plugins/datasource/graphite/specs/query_ctrl.test.ts

@@ -1,6 +1,7 @@
 import { uiSegmentSrv } from 'app/core/services/segment_srv';
 import gfunc from '../gfunc';
 import { GraphiteQueryCtrl } from '../query_ctrl';
+import { TemplateSrvStub } from 'test/specs/helpers';
 
 describe('GraphiteQueryCtrl', () => {
   const ctx = {
@@ -30,7 +31,7 @@ describe('GraphiteQueryCtrl', () => {
       {},
       {},
       new uiSegmentSrv({ trustAsHtml: html => html }, { highlightVariablesAsHtml: () => {} }),
-      {},
+      new TemplateSrvStub(),
       {}
     );
   });
@@ -291,7 +292,7 @@ describe('GraphiteQueryCtrl', () => {
       ctx.ctrl.target.target = "seriesByTag('tag1=value1', 'tag2!=~value2')";
       ctx.ctrl.datasource.metricFindQuery = () => Promise.resolve([{ expandable: false }]);
       ctx.ctrl.parseTarget();
-      ctx.ctrl.removeTag(0);
+      ctx.ctrl.tagChanged({ key: ctx.ctrl.removeTagValue });
     });
 
     it('should update tags', () => {

+ 10 - 4
public/app/plugins/datasource/prometheus/datasource.ts

@@ -15,7 +15,7 @@ import { expandRecordingRules } from './language_utils';
 
 // Types
 import { PromQuery } from './types';
-import { DataQueryOptions, DataSourceApi } from '@grafana/ui/src/types';
+import { DataQueryOptions, DataSourceApi, AnnotationEvent } from '@grafana/ui/src/types';
 import { ExploreUrlState } from 'app/types/explore';
 
 export class PrometheusDatasource implements DataSourceApi<PromQuery> {
@@ -355,10 +355,11 @@ export class PrometheusDatasource implements DataSourceApi<PromQuery> {
           })
           .value();
 
+        const dupCheck = {};
         for (const value of series.values) {
           const valueIsTrue = value[1] === '1'; // e.g. ALERTS
           if (valueIsTrue || annotation.useValueForTime) {
-            const event = {
+            const event: AnnotationEvent = {
               annotation: annotation,
               title: self.resultTransformer.renderTemplate(titleFormat, series.metric),
               tags: tags,
@@ -366,9 +367,14 @@ export class PrometheusDatasource implements DataSourceApi<PromQuery> {
             };
 
             if (annotation.useValueForTime) {
-              event['time'] = Math.floor(parseFloat(value[1]));
+              const timestampValue = Math.floor(parseFloat(value[1]));
+              if (dupCheck[timestampValue]) {
+                continue;
+              }
+              dupCheck[timestampValue] = true;
+              event.time = timestampValue;
             } else {
-              event['time'] = Math.floor(parseFloat(value[0])) * 1000;
+              event.time = Math.floor(parseFloat(value[0])) * 1000;
             }
 
             eventList.push(event);

+ 12 - 50
public/app/plugins/panel/singlestat/module.ts

@@ -3,6 +3,7 @@ import $ from 'jquery';
 import 'vendor/flot/jquery.flot';
 import 'vendor/flot/jquery.flot.gauge';
 import 'app/features/panel/panellinks/link_srv';
+import { getDecimalsForValue } from '@grafana/ui';
 
 import kbn from 'app/core/utils/kbn';
 import config from 'app/core/config';
@@ -190,7 +191,7 @@ class SingleStatCtrl extends MetricsPanelCtrl {
       data.value = 0;
       data.valueRounded = 0;
     } else {
-      const decimalInfo = this.getDecimalsForValue(data.value);
+      const decimalInfo = getDecimalsForValue(data.value);
       const formatFunc = getValueFormat(this.panel.format);
 
       data.valueFormatted = formatFunc(
@@ -243,47 +244,6 @@ class SingleStatCtrl extends MetricsPanelCtrl {
     this.render();
   }
 
-  getDecimalsForValue(value) {
-    if (_.isNumber(this.panel.decimals)) {
-      return { decimals: this.panel.decimals, scaledDecimals: null };
-    }
-
-    const delta = value / 2;
-    let dec = -Math.floor(Math.log(delta) / Math.LN10);
-
-    const magn = Math.pow(10, -dec);
-    const norm = delta / magn; // norm is between 1.0 and 10.0
-    let size;
-
-    if (norm < 1.5) {
-      size = 1;
-    } else if (norm < 3) {
-      size = 2;
-      // special case for 2.5, requires an extra decimal
-      if (norm > 2.25) {
-        size = 2.5;
-        ++dec;
-      }
-    } else if (norm < 7.5) {
-      size = 5;
-    } else {
-      size = 10;
-    }
-
-    size *= magn;
-
-    // reduce starting decimals if not needed
-    if (Math.floor(value) === value) {
-      dec = 0;
-    }
-
-    const result: any = {};
-    result.decimals = Math.max(0, dec);
-    result.scaledDecimals = result.decimals - Math.floor(Math.log(size) / Math.LN10) + 2;
-
-    return result;
-  }
-
   setValues(data) {
     data.flotpairs = [];
 
@@ -319,15 +279,17 @@ class SingleStatCtrl extends MetricsPanelCtrl {
         data.value = this.series[0].stats[this.panel.valueName];
         data.flotpairs = this.series[0].flotpairs;
 
-        const decimalInfo = this.getDecimalsForValue(data.value);
+        let decimals = this.panel.decimals;
+        let scaledDecimals = 0;
+
+        if (!this.panel.decimals) {
+          const decimalInfo = getDecimalsForValue(data.value);
+          decimals = decimalInfo.decimals;
+          scaledDecimals = decimalInfo.scaledDecimals;
+        }
 
-        data.valueFormatted = formatFunc(
-          data.value,
-          decimalInfo.decimals,
-          decimalInfo.scaledDecimals,
-          this.dashboard.isTimezoneUtc()
-        );
-        data.valueRounded = kbn.roundValue(data.value, decimalInfo.decimals);
+        data.valueFormatted = formatFunc(data.value, decimals, scaledDecimals, this.dashboard.isTimezoneUtc());
+        data.valueRounded = kbn.roundValue(data.value, decimals);
       }
 
       // Add $__name variable for using in prefix or postfix

+ 11 - 0
public/app/types/explore.ts

@@ -248,6 +248,17 @@ export interface ExploreItemState {
    * Currently hidden log series
    */
   hiddenLogLevels?: LogLevel[];
+
+  urlState: ExploreUrlState;
+
+  update: ExploreUpdateState;
+}
+
+export interface ExploreUpdateState {
+  datasource: boolean;
+  queries: boolean;
+  range: boolean;
+  ui: boolean;
 }
 
 export interface ExploreUIState {

+ 64 - 0
public/test/core/thunk/thunkTester.ts

@@ -0,0 +1,64 @@
+import configureMockStore from 'redux-mock-store';
+import thunk from 'redux-thunk';
+import { ActionOf } from 'app/core/redux/actionCreatorFactory';
+
+const mockStore = configureMockStore([thunk]);
+
+export interface ThunkGiven {
+  givenThunk: (thunkFunction: any) => ThunkWhen;
+}
+
+export interface ThunkWhen {
+  whenThunkIsDispatched: (...args: any) => ThunkThen;
+}
+
+export interface ThunkThen {
+  thenDispatchedActionsEqual: (actions: Array<ActionOf<any>>) => ThunkWhen;
+  thenDispatchedActionsAreEqual: (callback: (actions: Array<ActionOf<any>>) => boolean) => ThunkWhen;
+  thenThereAreNoDispatchedActions: () => ThunkWhen;
+}
+
+export const thunkTester = (initialState: any): ThunkGiven => {
+  const store = mockStore(initialState);
+  let thunkUnderTest = null;
+
+  const givenThunk = (thunkFunction: any): ThunkWhen => {
+    thunkUnderTest = thunkFunction;
+
+    return instance;
+  };
+
+  function whenThunkIsDispatched(...args: any): ThunkThen {
+    store.dispatch(thunkUnderTest(...arguments));
+
+    return instance;
+  }
+
+  const thenDispatchedActionsEqual = (actions: Array<ActionOf<any>>): ThunkWhen => {
+    const resultingActions = store.getActions();
+    expect(resultingActions).toEqual(actions);
+
+    return instance;
+  };
+
+  const thenDispatchedActionsAreEqual = (callback: (dispathedActions: Array<ActionOf<any>>) => boolean): ThunkWhen => {
+    const resultingActions = store.getActions();
+    expect(callback(resultingActions)).toBe(true);
+
+    return instance;
+  };
+
+  const thenThereAreNoDispatchedActions = () => {
+    return thenDispatchedActionsEqual([]);
+  };
+
+  const instance = {
+    givenThunk,
+    whenThunkIsDispatched,
+    thenDispatchedActionsEqual,
+    thenDispatchedActionsAreEqual,
+    thenThereAreNoDispatchedActions,
+  };
+
+  return instance;
+};

+ 1 - 1
public/test/specs/helpers.ts

@@ -172,7 +172,7 @@ export function TemplateSrvStub(this: any) {
   this.variables = [];
   this.templateSettings = { interpolate: /\[\[([\s\S]+?)\]\]/g };
   this.data = {};
-  this.replace = function(text: string) {
+  this.replace = (text: string) => {
     return _.template(text, this.templateSettings)(this.data);
   };
   this.init = () => {};

+ 1 - 1
scripts/grunt/options/compress.js

@@ -15,7 +15,7 @@ module.exports = function(config) {
         },
         {
           expand: true,
-          src: ['LICENSE.md', 'README.md', 'NOTICE.md'],
+          src: ['LICENSE', 'README.md', 'NOTICE.md'],
           dest: '<%= pkg.name %>-<%= pkg.version %>/',
         }
       ]