From 2c5400c61eeb8f228ec54492a7e5a69b29925e92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hugo=20H=C3=A4ggmark?= Date: Tue, 25 Jun 2019 14:44:19 +0200 Subject: [PATCH] Explore: Parses and updates TimeSrv in one place in Explore (#17677) * Wip: Adds timeEpic * Refactor: Introduces absoluteRange in Explore state * Refactor: Removes changeTime action * Tests: Adds tests for timeEpic * Refactor: Spells AbsoluteRange correctly --- .../features/dashboard/services/TimeSrv.ts | 3 + public/app/features/explore/Explore.tsx | 13 ++- .../app/features/explore/GraphContainer.tsx | 48 +++++--- public/app/features/explore/Logs.tsx | 11 +- public/app/features/explore/LogsContainer.tsx | 29 ++--- .../app/features/explore/state/actionTypes.ts | 21 ++-- .../features/explore/state/actions.test.ts | 8 +- public/app/features/explore/state/actions.ts | 32 +++--- .../state/epics/runQueriesBatchEpic.ts | 20 +++- .../state/epics/runQueriesEpic.test.ts | 8 +- .../explore/state/epics/timeEpic.test.ts | 105 ++++++++++++++++++ .../features/explore/state/epics/timeEpic.ts | 46 ++++++++ .../features/explore/state/reducers.test.ts | 11 +- public/app/features/explore/state/reducers.ts | 19 ++-- public/app/store/configureStore.ts | 26 ++++- public/app/types/explore.ts | 3 + public/test/core/redux/epicTester.ts | 63 ++++++++++- public/test/mocks/mockExploreState.ts | 12 ++ 18 files changed, 366 insertions(+), 112 deletions(-) create mode 100644 public/app/features/explore/state/epics/timeEpic.test.ts create mode 100644 public/app/features/explore/state/epics/timeEpic.ts diff --git a/public/app/features/dashboard/services/TimeSrv.ts b/public/app/features/dashboard/services/TimeSrv.ts index ca9b244b956..1716563874e 100644 --- a/public/app/features/dashboard/services/TimeSrv.ts +++ b/public/app/features/dashboard/services/TimeSrv.ts @@ -112,6 +112,9 @@ export class TimeSrv { private routeUpdated() { const params = this.$location.search(); + if (params.left) { + return; // explore handles this; + } const urlRange = this.timeRangeForUrl(); // check if url has time range if (params.from && params.to) { diff --git a/public/app/features/explore/Explore.tsx b/public/app/features/explore/Explore.tsx index e5dbb508edc..a8a7bd9ab04 100644 --- a/public/app/features/explore/Explore.tsx +++ b/public/app/features/explore/Explore.tsx @@ -21,13 +21,13 @@ import TimePicker from './TimePicker'; // Actions import { changeSize, - changeTime, initializeExplore, modifyQueries, scanStart, setQueries, refreshExplore, reconnectDatasource, + updateTimeRange, } from './state/actions'; // Types @@ -60,7 +60,6 @@ import { scanStopAction } from './state/actionTypes'; interface ExploreProps { StartPage?: ComponentClass; changeSize: typeof changeSize; - changeTime: typeof changeTime; datasourceError: string; datasourceInstance: DataSourceApi; datasourceLoading: boolean | null; @@ -88,6 +87,7 @@ interface ExploreProps { queryErrors: DataQueryError[]; mode: ExploreMode; isLive: boolean; + updateTimeRange: typeof updateTimeRange; } /** @@ -158,11 +158,12 @@ export class Explore extends React.PureComponent { this.el = el; }; - onChangeTime = (range: RawTimeRange, changedByScanner?: boolean) => { - if (this.props.scanning && !changedByScanner) { + onChangeTime = (rawRange: RawTimeRange, changedByScanner?: boolean) => { + const { updateTimeRange, exploreId, scanning } = this.props; + if (scanning && !changedByScanner) { this.onStopScanning(); } - this.props.changeTime(this.props.exploreId, range); + updateTimeRange({ exploreId, rawRange }); }; // Use this in help pages to set page to a single query @@ -348,7 +349,6 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps) { const mapDispatchToProps = { changeSize, - changeTime, initializeExplore, modifyQueries, reconnectDatasource, @@ -356,6 +356,7 @@ const mapDispatchToProps = { scanStart, scanStopAction, setQueries, + updateTimeRange, }; export default hot(module)( diff --git a/public/app/features/explore/GraphContainer.tsx b/public/app/features/explore/GraphContainer.tsx index 632613f60c0..4c17c43b23c 100644 --- a/public/app/features/explore/GraphContainer.tsx +++ b/public/app/features/explore/GraphContainer.tsx @@ -1,28 +1,27 @@ import React, { PureComponent } from 'react'; import { hot } from 'react-hot-loader'; import { connect } from 'react-redux'; -import { TimeRange, TimeZone, AbsoluteTimeRange, LoadingState } from '@grafana/ui'; +import { TimeZone, AbsoluteTimeRange, LoadingState } from '@grafana/ui'; import { ExploreId, ExploreItemState } from 'app/types/explore'; import { StoreState } from 'app/types'; -import { toggleGraph, changeTime } from './state/actions'; +import { toggleGraph, updateTimeRange } from './state/actions'; import Graph from './Graph'; import Panel from './Panel'; import { getTimeZone } from '../profile/state/selectors'; -import { toUtc, dateTime } from '@grafana/ui/src/utils/moment_wrapper'; interface GraphContainerProps { exploreId: ExploreId; graphResult?: any[]; loading: boolean; - range: TimeRange; + absoluteRange: AbsoluteTimeRange; timeZone: TimeZone; showingGraph: boolean; showingTable: boolean; split: boolean; toggleGraph: typeof toggleGraph; - changeTime: typeof changeTime; + updateTimeRange: typeof updateTimeRange; width: number; } @@ -31,20 +30,25 @@ export class GraphContainer extends PureComponent { this.props.toggleGraph(this.props.exploreId, this.props.showingGraph); }; - onChangeTime = (absRange: AbsoluteTimeRange) => { - const { exploreId, timeZone, changeTime } = this.props; - const range = { - from: timeZone === 'utc' ? toUtc(absRange.from) : dateTime(absRange.from), - to: timeZone === 'utc' ? toUtc(absRange.to) : dateTime(absRange.to), - }; + onChangeTime = (absoluteRange: AbsoluteTimeRange) => { + const { exploreId, updateTimeRange } = this.props; - changeTime(exploreId, range); + updateTimeRange({ exploreId, absoluteRange }); }; render() { - const { exploreId, graphResult, loading, showingGraph, showingTable, range, split, width, timeZone } = this.props; + const { + exploreId, + graphResult, + loading, + showingGraph, + showingTable, + absoluteRange, + split, + width, + timeZone, + } = this.props; const graphHeight = showingGraph && showingTable ? 200 : 400; - const timeRange = { from: range.from.valueOf(), to: range.to.valueOf() }; return ( @@ -54,7 +58,7 @@ export class GraphContainer extends PureComponent { height={graphHeight} id={`explore-graph-${exploreId}`} onChangeTime={this.onChangeTime} - range={timeRange} + range={absoluteRange} timeZone={timeZone} split={split} width={width} @@ -69,14 +73,22 @@ function mapStateToProps(state: StoreState, { exploreId }) { const explore = state.explore; const { split } = explore; const item: ExploreItemState = explore[exploreId]; - const { graphResult, loadingState, range, showingGraph, showingTable } = item; + const { graphResult, loadingState, showingGraph, showingTable, absoluteRange } = item; const loading = loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming; - return { graphResult, loading, range, showingGraph, showingTable, split, timeZone: getTimeZone(state.user) }; + return { + graphResult, + loading, + showingGraph, + showingTable, + split, + timeZone: getTimeZone(state.user), + absoluteRange, + }; } const mapDispatchToProps = { toggleGraph, - changeTime, + updateTimeRange, }; export default hot(module)( diff --git a/public/app/features/explore/Logs.tsx b/public/app/features/explore/Logs.tsx index b6ee5a7f731..201eda44ece 100644 --- a/public/app/features/explore/Logs.tsx +++ b/public/app/features/explore/Logs.tsx @@ -7,7 +7,6 @@ import { Switch, LogLevel, TimeZone, - TimeRange, AbsoluteTimeRange, LogsMetaKind, LogsModel, @@ -58,7 +57,7 @@ interface Props { exploreId: string; highlighterExpressions: string[]; loading: boolean; - range: TimeRange; + absoluteRange: AbsoluteTimeRange; timeZone: TimeZone; scanning?: boolean; scanRange?: RawTimeRange; @@ -167,7 +166,7 @@ export default class Logs extends PureComponent { highlighterExpressions, loading = false, onClickLabel, - range, + absoluteRange, timeZone, scanning, scanRange, @@ -206,10 +205,6 @@ export default class Logs extends PureComponent { const timeSeries = data.series ? data.series.map(series => new TimeSeries(series)) : [new TimeSeries({ datapoints: [] })]; - const absRange = { - from: range.from.valueOf(), - to: range.to.valueOf(), - }; return (
@@ -218,7 +213,7 @@ export default class Logs extends PureComponent { data={timeSeries} height={100} width={width} - range={absRange} + range={absoluteRange} timeZone={timeZone} id={`explore-logs-graph-${exploreId}`} onChangeTime={this.props.onChangeTime} diff --git a/public/app/features/explore/LogsContainer.tsx b/public/app/features/explore/LogsContainer.tsx index 80366077a39..88639aa6864 100644 --- a/public/app/features/explore/LogsContainer.tsx +++ b/public/app/features/explore/LogsContainer.tsx @@ -3,12 +3,9 @@ import { hot } from 'react-hot-loader'; import { connect } from 'react-redux'; import { RawTimeRange, - TimeRange, LogLevel, TimeZone, AbsoluteTimeRange, - toUtc, - dateTime, DataSourceApi, LogsModel, LogRowModel, @@ -19,7 +16,7 @@ import { import { ExploreId, ExploreItemState } from 'app/types/explore'; import { StoreState } from 'app/types'; -import { changeDedupStrategy, changeTime } from './state/actions'; +import { changeDedupStrategy, updateTimeRange } from './state/actions'; import Logs from './Logs'; import Panel from './Panel'; import { toggleLogLevelAction, changeRefreshIntervalAction } from 'app/features/explore/state/actionTypes'; @@ -39,7 +36,6 @@ interface LogsContainerProps { onClickLabel: (key: string, value: string) => void; onStartScanning: () => void; onStopScanning: () => void; - range: TimeRange; timeZone: TimeZone; scanning?: boolean; scanRange?: RawTimeRange; @@ -48,20 +44,17 @@ interface LogsContainerProps { dedupStrategy: LogsDedupStrategy; hiddenLogLevels: Set; width: number; - changeTime: typeof changeTime; isLive: boolean; stopLive: typeof changeRefreshIntervalAction; + updateTimeRange: typeof updateTimeRange; + absoluteRange: AbsoluteTimeRange; } export class LogsContainer extends Component { - onChangeTime = (absRange: AbsoluteTimeRange) => { - const { exploreId, timeZone, changeTime } = this.props; - const range = { - from: timeZone === 'utc' ? toUtc(absRange.from) : dateTime(absRange.from), - to: timeZone === 'utc' ? toUtc(absRange.to) : dateTime(absRange.to), - }; + onChangeTime = (absoluteRange: AbsoluteTimeRange) => { + const { exploreId, updateTimeRange } = this.props; - changeTime(exploreId, range); + updateTimeRange({ exploreId, absoluteRange }); }; onStopLive = () => { @@ -111,7 +104,7 @@ export class LogsContainer extends Component { onClickLabel, onStartScanning, onStopScanning, - range, + absoluteRange, timeZone, scanning, scanRange, @@ -143,7 +136,7 @@ export class LogsContainer extends Component { onStopScanning={onStopScanning} onDedupStrategyChange={this.handleDedupStrategyChange} onToggleLogLevel={this.hangleToggleLogLevel} - range={range} + absoluteRange={absoluteRange} timeZone={timeZone} scanning={scanning} scanRange={scanRange} @@ -165,9 +158,9 @@ function mapStateToProps(state: StoreState, { exploreId }) { loadingState, scanning, scanRange, - range, datasourceInstance, isLive, + absoluteRange, } = item; const loading = loadingState === LoadingState.Loading || loadingState === LoadingState.Streaming; const { dedupStrategy } = exploreItemUIStateSelector(item); @@ -181,21 +174,21 @@ function mapStateToProps(state: StoreState, { exploreId }) { logsResult, scanning, scanRange, - range, timeZone, dedupStrategy, hiddenLogLevels, dedupedResult, datasourceInstance, isLive, + absoluteRange, }; } const mapDispatchToProps = { changeDedupStrategy, toggleLogLevelAction, - changeTime, stopLive: changeRefreshIntervalAction, + updateTimeRange, }; export default hot(module)( diff --git a/public/app/features/explore/state/actionTypes.ts b/public/app/features/explore/state/actionTypes.ts index 68b9ac604eb..da7d0d4dcf1 100644 --- a/public/app/features/explore/state/actionTypes.ts +++ b/public/app/features/explore/state/actionTypes.ts @@ -14,6 +14,7 @@ import { TimeSeries, DataQueryResponseData, LoadingState, + AbsoluteTimeRange, } from '@grafana/ui/src/types'; import { ExploreId, @@ -73,11 +74,6 @@ export interface ChangeSizePayload { height: number; } -export interface ChangeTimePayload { - exploreId: ExploreId; - range: TimeRange; -} - export interface ChangeRefreshIntervalPayload { exploreId: ExploreId; refreshInterval: string; @@ -233,7 +229,6 @@ export interface LoadExploreDataSourcesPayload { export interface RunQueriesPayload { exploreId: ExploreId; - range: TimeRange; } export interface ResetQueryErrorPayload { @@ -274,6 +269,13 @@ export interface LimitMessageRatePayload { export interface ChangeRangePayload { exploreId: ExploreId; range: TimeRange; + absoluteRange: AbsoluteTimeRange; +} + +export interface UpdateTimeRangePayload { + exploreId: ExploreId; + rawRange?: RawTimeRange; + absoluteRange?: AbsoluteTimeRange; } /** @@ -303,11 +305,6 @@ export const changeQueryAction = actionCreatorFactory('explo */ export const changeSizeAction = actionCreatorFactory('explore/CHANGE_SIZE').create(); -/** - * Change the time range of Explore. Usually called from the Timepicker or a graph interaction. - */ -export const changeTimeAction = actionCreatorFactory('explore/CHANGE_TIME').create(); - /** * Change the time range of Explore. Usually called from the Timepicker or a graph interaction. */ @@ -490,6 +487,8 @@ export const limitMessageRatePayloadAction = actionCreatorFactory('explore/CHANGE_RANGE').create(); +export const updateTimeRangeAction = actionCreatorFactory('explore/UPDATE_TIMERANGE').create(); + export type HigherOrderAction = | ActionOf | SplitOpenAction diff --git a/public/app/features/explore/state/actions.test.ts b/public/app/features/explore/state/actions.test.ts index ba096602c05..ab5421770fe 100644 --- a/public/app/features/explore/state/actions.test.ts +++ b/public/app/features/explore/state/actions.test.ts @@ -4,7 +4,6 @@ import { thunkTester } from 'test/core/thunk/thunkTester'; import { initializeExploreAction, InitializeExplorePayload, - changeTimeAction, updateUIStateAction, setQueriesAction, testDataSourcePendingAction, @@ -12,6 +11,7 @@ import { testDataSourceFailureAction, loadDatasourcePendingAction, loadDatasourceReadyAction, + updateTimeRangeAction, } from './actionTypes'; import { Emitter } from 'app/core/core'; import { ActionOf } from 'app/core/redux/actionCreatorFactory'; @@ -118,15 +118,15 @@ describe('refreshExplore', () => { }); describe('and update range is set', () => { - it('then it should dispatch changeTimeAction', async () => { + it('then it should dispatch updateTimeRangeAction', async () => { const { exploreId, range, initialState } = setup({ range: true }); const dispatchedActions = await thunkTester(initialState) .givenThunk(refreshExplore) .whenThunkIsDispatched(exploreId); - expect(dispatchedActions[0].type).toEqual(changeTimeAction.type); - expect(dispatchedActions[0].payload).toEqual({ exploreId, range }); + expect(dispatchedActions[0].type).toEqual(updateTimeRangeAction.type); + expect(dispatchedActions[0].payload).toEqual({ exploreId, rawRange: range.raw }); }); }); diff --git a/public/app/features/explore/state/actions.ts b/public/app/features/explore/state/actions.ts index 604101c6632..b33805d119c 100644 --- a/public/app/features/explore/state/actions.ts +++ b/public/app/features/explore/state/actions.ts @@ -24,6 +24,7 @@ import { DataSourceSelectItem, QueryFixAction, LogsDedupStrategy, + AbsoluteTimeRange, } from '@grafana/ui'; import { ExploreId, RangeScanner, ExploreUIState, QueryTransaction, ExploreMode } from 'app/types/explore'; import { @@ -33,7 +34,6 @@ import { ChangeRefreshIntervalPayload, changeSizeAction, ChangeSizePayload, - changeTimeAction, clearQueriesAction, initializeExploreAction, loadDatasourceMissingAction, @@ -61,6 +61,7 @@ import { scanRangeAction, runQueriesAction, stateSaveAction, + updateTimeRangeAction, } from './actionTypes'; import { ActionOf, ActionCreator } from 'app/core/redux/actionCreatorFactory'; import { getTimeZone } from 'app/features/profile/state/selectors'; @@ -164,17 +165,16 @@ export function changeSize( return changeSizeAction({ exploreId, height, width }); } -/** - * Change the time range of Explore. Usually called from the Time picker or a graph interaction. - */ -export function changeTime(exploreId: ExploreId, rawRange: RawTimeRange): ThunkResult { - return (dispatch, getState) => { - const timeZone = getTimeZone(getState().user); - const range = getTimeRange(timeZone, rawRange); - dispatch(changeTimeAction({ exploreId, range })); - dispatch(runQueries(exploreId)); +export const updateTimeRange = (options: { + exploreId: ExploreId; + rawRange?: RawTimeRange; + absoluteRange?: AbsoluteTimeRange; +}): ThunkResult => { + return dispatch => { + dispatch(updateTimeRangeAction({ ...options })); + dispatch(runQueries(options.exploreId)); }; -} +}; /** * Change the refresh interval of Explore. Called from the Refresh picker. @@ -402,12 +402,8 @@ export function modifyQueries( */ export function runQueries(exploreId: ExploreId): ThunkResult { return (dispatch, getState) => { - const { range } = getState().explore[exploreId]; - - const timeZone = getTimeZone(getState().user); - const updatedRange = getTimeRange(timeZone, range.raw); - - dispatch(runQueriesAction({ exploreId, range: updatedRange })); + dispatch(updateTimeRangeAction({ exploreId })); + dispatch(runQueriesAction({ exploreId })); }; } @@ -548,7 +544,7 @@ export function refreshExplore(exploreId: ExploreId): ThunkResult { } if (update.range) { - dispatch(changeTimeAction({ exploreId, range })); + dispatch(updateTimeRangeAction({ exploreId, rawRange: range.raw })); } // need to refresh ui state diff --git a/public/app/features/explore/state/epics/runQueriesBatchEpic.ts b/public/app/features/explore/state/epics/runQueriesBatchEpic.ts index 8e2642f193f..d5187d9438f 100644 --- a/public/app/features/explore/state/epics/runQueriesBatchEpic.ts +++ b/public/app/features/explore/state/epics/runQueriesBatchEpic.ts @@ -3,7 +3,14 @@ import { Observable, Subject } from 'rxjs'; import { mergeMap, catchError, takeUntil, filter } from 'rxjs/operators'; import _, { isString } from 'lodash'; import { isLive } from '@grafana/ui/src/components/RefreshPicker/RefreshPicker'; -import { DataStreamState, LoadingState, DataQueryResponse, SeriesData, DataQueryResponseData } from '@grafana/ui'; +import { + DataStreamState, + LoadingState, + DataQueryResponse, + SeriesData, + DataQueryResponseData, + AbsoluteTimeRange, +} from '@grafana/ui'; import * as dateMath from '@grafana/ui/src/utils/datemath'; import { ActionOf } from 'app/core/redux/actionCreatorFactory'; @@ -115,15 +122,24 @@ export const runQueriesBatchEpic: Epic, ActionOf, StoreState> if (state === LoadingState.Streaming) { if (event.request && event.request.range) { let newRange = event.request.range; + let absoluteRange: AbsoluteTimeRange = { + from: newRange.from.valueOf(), + to: newRange.to.valueOf(), + }; if (isString(newRange.raw.from)) { newRange = { from: dateMath.parse(newRange.raw.from, false), to: dateMath.parse(newRange.raw.to, true), raw: newRange.raw, }; + absoluteRange = { + from: newRange.from.valueOf(), + to: newRange.to.valueOf(), + }; } - outerObservable.next(changeRangeAction({ exploreId, range: newRange })); + outerObservable.next(changeRangeAction({ exploreId, range: newRange, absoluteRange })); } + outerObservable.next( limitMessageRatePayloadAction({ exploreId, diff --git a/public/app/features/explore/state/epics/runQueriesEpic.test.ts b/public/app/features/explore/state/epics/runQueriesEpic.test.ts index 87b1f86513f..2d0d305150c 100644 --- a/public/app/features/explore/state/epics/runQueriesEpic.test.ts +++ b/public/app/features/explore/state/epics/runQueriesEpic.test.ts @@ -13,7 +13,7 @@ describe('runQueriesEpic', () => { const { exploreId, state, datasourceInterval, containerWidth } = mockExploreState({ queries }); epicTester(runQueriesEpic, state) - .whenActionIsDispatched(runQueriesAction({ exploreId, range: null })) + .whenActionIsDispatched(runQueriesAction({ exploreId })) .thenResultingActionsEqual( runQueriesBatchAction({ exploreId, @@ -33,7 +33,7 @@ describe('runQueriesEpic', () => { }); epicTester(runQueriesEpic, state) - .whenActionIsDispatched(runQueriesAction({ exploreId, range: null })) + .whenActionIsDispatched(runQueriesAction({ exploreId })) .thenResultingActionsEqual( runQueriesBatchAction({ exploreId, @@ -50,7 +50,7 @@ describe('runQueriesEpic', () => { const { exploreId, state } = mockExploreState({ queries }); epicTester(runQueriesEpic, state) - .whenActionIsDispatched(runQueriesAction({ exploreId, range: null })) + .whenActionIsDispatched(runQueriesAction({ exploreId })) .thenResultingActionsEqual(clearQueriesAction({ exploreId }), stateSaveAction()); }); }); @@ -63,7 +63,7 @@ describe('runQueriesEpic', () => { }); epicTester(runQueriesEpic, state) - .whenActionIsDispatched(runQueriesAction({ exploreId, range: null })) + .whenActionIsDispatched(runQueriesAction({ exploreId })) .thenNoActionsWhereDispatched(); }); }); diff --git a/public/app/features/explore/state/epics/timeEpic.test.ts b/public/app/features/explore/state/epics/timeEpic.test.ts new file mode 100644 index 00000000000..350f72527bf --- /dev/null +++ b/public/app/features/explore/state/epics/timeEpic.test.ts @@ -0,0 +1,105 @@ +import { dateTime, DefaultTimeZone } from '@grafana/ui'; + +import { epicTester } from 'test/core/redux/epicTester'; +import { mockExploreState } from 'test/mocks/mockExploreState'; +import { timeEpic } from './timeEpic'; +import { updateTimeRangeAction, changeRangeAction } from '../actionTypes'; +import { EpicDependencies } from 'app/store/configureStore'; + +const from = dateTime('2019-01-01 10:00:00.000Z'); +const to = dateTime('2019-01-01 16:00:00.000Z'); +const rawFrom = 'now-6h'; +const rawTo = 'now'; +const rangeMock = { + from, + to, + raw: { + from: rawFrom, + to: rawTo, + }, +}; + +describe('timeEpic', () => { + describe('when updateTimeRangeAction is dispatched', () => { + describe('and no rawRange is supplied', () => { + describe('and no absoluteRange is supplied', () => { + it('then the correct actions are dispatched', () => { + const { exploreId, state, range } = mockExploreState({ range: rangeMock }); + const absoluteRange = { from: range.from.valueOf(), to: range.to.valueOf() }; + const stateToTest = { ...state, user: { timeZone: 'browser', orgId: -1 } }; + const getTimeRange = jest.fn().mockReturnValue(rangeMock); + const dependencies: Partial = { + getTimeRange, + }; + + epicTester(timeEpic, stateToTest, dependencies) + .whenActionIsDispatched(updateTimeRangeAction({ exploreId })) + .thenDependencyWasCalledTimes(1, 'getTimeSrv', 'init') + .thenDependencyWasCalledTimes(1, 'getTimeRange') + .thenDependencyWasCalledWith([DefaultTimeZone, rangeMock.raw], 'getTimeRange') + .thenResultingActionsEqual( + changeRangeAction({ + exploreId, + range, + absoluteRange, + }) + ); + }); + }); + + describe('and absoluteRange is supplied', () => { + it('then the correct actions are dispatched', () => { + const { exploreId, state, range } = mockExploreState({ range: rangeMock }); + const absoluteRange = { from: range.from.valueOf(), to: range.to.valueOf() }; + const stateToTest = { ...state, user: { timeZone: 'browser', orgId: -1 } }; + const getTimeRange = jest.fn().mockReturnValue(rangeMock); + const dependencies: Partial = { + getTimeRange, + }; + + epicTester(timeEpic, stateToTest, dependencies) + .whenActionIsDispatched(updateTimeRangeAction({ exploreId, absoluteRange })) + .thenDependencyWasCalledTimes(1, 'getTimeSrv', 'init') + .thenDependencyWasCalledTimes(1, 'getTimeRange') + .thenDependencyWasCalledWith([DefaultTimeZone, { from: null, to: null }], 'getTimeRange') + .thenDependencyWasCalledTimes(2, 'dateTime') + .thenResultingActionsEqual( + changeRangeAction({ + exploreId, + range, + absoluteRange, + }) + ); + }); + }); + }); + + describe('and rawRange is supplied', () => { + describe('and no absoluteRange is supplied', () => { + it('then the correct actions are dispatched', () => { + const { exploreId, state, range } = mockExploreState({ range: rangeMock }); + const rawRange = { from: 'now-5m', to: 'now' }; + const absoluteRange = { from: range.from.valueOf(), to: range.to.valueOf() }; + const stateToTest = { ...state, user: { timeZone: 'browser', orgId: -1 } }; + const getTimeRange = jest.fn().mockReturnValue(rangeMock); + const dependencies: Partial = { + getTimeRange, + }; + + epicTester(timeEpic, stateToTest, dependencies) + .whenActionIsDispatched(updateTimeRangeAction({ exploreId, rawRange })) + .thenDependencyWasCalledTimes(1, 'getTimeSrv', 'init') + .thenDependencyWasCalledTimes(1, 'getTimeRange') + .thenDependencyWasCalledWith([DefaultTimeZone, rawRange], 'getTimeRange') + .thenResultingActionsEqual( + changeRangeAction({ + exploreId, + range, + absoluteRange, + }) + ); + }); + }); + }); + }); +}); diff --git a/public/app/features/explore/state/epics/timeEpic.ts b/public/app/features/explore/state/epics/timeEpic.ts new file mode 100644 index 00000000000..29fdb60355a --- /dev/null +++ b/public/app/features/explore/state/epics/timeEpic.ts @@ -0,0 +1,46 @@ +import { Epic } from 'redux-observable'; +import { map } from 'rxjs/operators'; +import { AbsoluteTimeRange, RawTimeRange } from '@grafana/ui'; + +import { ActionOf } from 'app/core/redux/actionCreatorFactory'; +import { StoreState } from 'app/types/store'; +import { updateTimeRangeAction, UpdateTimeRangePayload, changeRangeAction } from '../actionTypes'; + +export const timeEpic: Epic, ActionOf, StoreState> = ( + action$, + state$, + { getTimeSrv, getTimeRange, getTimeZone, toUtc, dateTime } +) => { + return action$.ofType(updateTimeRangeAction.type).pipe( + map((action: ActionOf) => { + const { exploreId, absoluteRange: absRange, rawRange: actionRange } = action.payload; + const itemState = state$.value.explore[exploreId]; + const timeZone = getTimeZone(state$.value.user); + const { range: rangeInState } = itemState; + let rawRange: RawTimeRange = rangeInState.raw; + + if (absRange) { + rawRange = { + from: timeZone.isUtc ? toUtc(absRange.from) : dateTime(absRange.from), + to: timeZone.isUtc ? toUtc(absRange.to) : dateTime(absRange.to), + }; + } + + if (actionRange) { + rawRange = actionRange; + } + + const range = getTimeRange(timeZone, rawRange); + const absoluteRange: AbsoluteTimeRange = { from: range.from.valueOf(), to: range.to.valueOf() }; + + getTimeSrv().init({ + time: range.raw, + refresh: false, + getTimezone: () => timeZone.raw, + timeRangeUpdated: () => undefined, + }); + + return changeRangeAction({ exploreId, range, absoluteRange }); + }) + ); +}; diff --git a/public/app/features/explore/state/reducers.test.ts b/public/app/features/explore/state/reducers.test.ts index 1f553313f80..7175089e4d7 100644 --- a/public/app/features/explore/state/reducers.test.ts +++ b/public/app/features/explore/state/reducers.test.ts @@ -4,7 +4,6 @@ import { exploreReducer, makeInitialUpdateState, initialExploreState, - DEFAULT_RANGE, } from './reducers'; import { ExploreId, @@ -32,7 +31,7 @@ import { ActionOf } from 'app/core/redux/actionCreatorFactory'; import { updateLocation } from 'app/core/actions/location'; import { serializeStateToUrlParam } from 'app/core/utils/explore'; import TableModel from 'app/core/table_model'; -import { DataSourceApi, DataQuery, LogsModel, LogsDedupStrategy, LoadingState, dateTime } from '@grafana/ui'; +import { DataSourceApi, DataQuery, LogsModel, LogsDedupStrategy, LoadingState } from '@grafana/ui'; describe('Explore item reducer', () => { describe('scanning', () => { @@ -193,16 +192,12 @@ describe('Explore item reducer', () => { intervalMs: 1000, }, showingStartPage: false, - range: { - from: dateTime(), - to: dateTime(), - raw: DEFAULT_RANGE, - }, + range: null, }; reducerTester() .givenReducer(itemReducer, initalState) - .whenActionIsDispatched(runQueriesAction({ exploreId: ExploreId.left, range: expectedState.range })) + .whenActionIsDispatched(runQueriesAction({ exploreId: ExploreId.left })) .thenStateShouldEqual(expectedState); }); }); diff --git a/public/app/features/explore/state/reducers.ts b/public/app/features/explore/state/reducers.ts index 5c159611cdf..37e9c7950d0 100644 --- a/public/app/features/explore/state/reducers.ts +++ b/public/app/features/explore/state/reducers.ts @@ -36,7 +36,6 @@ import { addQueryRowAction, changeQueryAction, changeSizeAction, - changeTimeAction, changeRefreshIntervalAction, clearQueriesAction, highlightLogsExpressionAction, @@ -95,6 +94,10 @@ export const makeExploreItemState = (): ExploreItemState => ({ to: null, raw: DEFAULT_RANGE, }, + absoluteRange: { + from: null, + to: null, + }, scanning: false, scanRange: null, showingGraph: true, @@ -174,12 +177,6 @@ export const itemReducer = reducerFactory({} as ExploreItemSta return { ...state, mode }; }, }) - .addMapper({ - filter: changeTimeAction, - mapper: (state, action): ExploreItemState => { - return { ...state, range: action.payload.range }; - }, - }) .addMapper({ filter: changeRefreshIntervalAction, mapper: (state, action): ExploreItemState => { @@ -520,8 +517,8 @@ export const itemReducer = reducerFactory({} as ExploreItemSta }) .addMapper({ filter: runQueriesAction, - mapper: (state, action): ExploreItemState => { - const { range } = action.payload; + mapper: (state): ExploreItemState => { + const { range } = state; const { datasourceInstance, containerWidth } = state; let interval = '1s'; if (datasourceInstance && datasourceInstance.interval) { @@ -575,9 +572,11 @@ export const itemReducer = reducerFactory({} as ExploreItemSta .addMapper({ filter: changeRangeAction, mapper: (state, action): ExploreItemState => { + const { range, absoluteRange } = action.payload; return { ...state, - range: action.payload.range, + range, + absoluteRange, }; }, }) diff --git a/public/app/store/configureStore.ts b/public/app/store/configureStore.ts index 1d238cf7dc3..2c4ad840271 100644 --- a/public/app/store/configureStore.ts +++ b/public/app/store/configureStore.ts @@ -28,11 +28,24 @@ import { DataSourceJsonData, DataQueryRequest, DataStreamObserver, + TimeZone, + RawTimeRange, + TimeRange, + DateTimeInput, + FormatInput, + DateTime, + toUtc, + dateTime, } from '@grafana/ui'; import { Observable } from 'rxjs'; import { getQueryResponse } from 'app/core/utils/explore'; import { StoreState } from 'app/types/store'; import { toggleLogActionsMiddleware } from 'app/core/middlewares/application'; +import { timeEpic } from 'app/features/explore/state/epics/timeEpic'; +import { TimeSrv, getTimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { UserState } from 'app/types/user'; +import { getTimeRange } from 'app/core/utils/explore'; +import { getTimeZone } from 'app/features/profile/state/selectors'; const rootReducers = { ...sharedReducers, @@ -59,7 +72,8 @@ export const rootEpic: any = combineEpics( runQueriesEpic, runQueriesBatchEpic, processQueryResultsEpic, - processQueryErrorsEpic + processQueryErrorsEpic, + timeEpic ); export interface EpicDependencies { @@ -68,10 +82,20 @@ export interface EpicDependencies { options: DataQueryRequest, observer?: DataStreamObserver ) => Observable; + getTimeSrv: () => TimeSrv; + getTimeRange: (timeZone: TimeZone, rawRange: RawTimeRange) => TimeRange; + getTimeZone: (state: UserState) => TimeZone; + toUtc: (input?: DateTimeInput, formatInput?: FormatInput) => DateTime; + dateTime: (input?: DateTimeInput, formatInput?: FormatInput) => DateTime; } const dependencies: EpicDependencies = { getQueryResponse, + getTimeSrv, + getTimeRange, + getTimeZone, + toUtc, + dateTime, }; const epicMiddleware = createEpicMiddleware({ dependencies }); diff --git a/public/app/types/explore.ts b/public/app/types/explore.ts index 3774c74916a..e57810b6658 100644 --- a/public/app/types/explore.ts +++ b/public/app/types/explore.ts @@ -12,6 +12,7 @@ import { LogsModel, LogsDedupStrategy, LoadingState, + AbsoluteTimeRange, } from '@grafana/ui'; import { Emitter } from 'app/core/core'; @@ -189,6 +190,8 @@ export interface ExploreItemState { * Time range for this Explore. Managed by the time picker and used by all query runs. */ range: TimeRange; + + absoluteRange: AbsoluteTimeRange; /** * Scanner function that calculates a new range, triggers a query run, and returns the new range. */ diff --git a/public/test/core/redux/epicTester.ts b/public/test/core/redux/epicTester.ts index 88638f556c6..6da44bfe2cf 100644 --- a/public/test/core/redux/epicTester.ts +++ b/public/test/core/redux/epicTester.ts @@ -8,15 +8,19 @@ import { DataStreamObserver, DataQueryResponse, DataStreamState, + DefaultTimeZone, } from '@grafana/ui'; import { ActionOf } from 'app/core/redux/actionCreatorFactory'; import { StoreState } from 'app/types/store'; import { EpicDependencies } from 'app/store/configureStore'; +import { TimeSrv } from 'app/features/dashboard/services/TimeSrv'; +import { DEFAULT_RANGE } from 'app/core/utils/explore'; export const epicTester = ( epic: Epic, ActionOf, StoreState, EpicDependencies>, - state?: Partial + state?: Partial, + dependencies?: Partial ) => { const resultingActions: Array> = []; const action$ = new Subject>(); @@ -35,12 +39,35 @@ export const epicTester = ( } return queryResponse$; }; + const init = jest.fn(); + const getTimeSrv = (): TimeSrv => { + const timeSrvMock: TimeSrv = {} as TimeSrv; - const dependencies: EpicDependencies = { - getQueryResponse, + return Object.assign(timeSrvMock, { init }); }; - epic(actionObservable$, stateObservable$, dependencies).subscribe({ next: action => resultingActions.push(action) }); + const getTimeRange = jest.fn().mockReturnValue(DEFAULT_RANGE); + + const getTimeZone = jest.fn().mockReturnValue(DefaultTimeZone); + + const toUtc = jest.fn().mockReturnValue(null); + + const dateTime = jest.fn().mockReturnValue(null); + + const defaultDependencies: EpicDependencies = { + getQueryResponse, + getTimeSrv, + getTimeRange, + getTimeZone, + toUtc, + dateTime, + }; + + const theDependencies: EpicDependencies = { ...defaultDependencies, ...dependencies }; + + epic(actionObservable$, stateObservable$, theDependencies).subscribe({ + next: action => resultingActions.push(action), + }); const whenActionIsDispatched = (action: ActionOf) => { action$.next(action); @@ -78,6 +105,32 @@ export const epicTester = ( return instance; }; + const getDependencyMock = (dependency: string, method?: string) => { + const dep = theDependencies[dependency]; + let mock = null; + if (dep instanceof Function) { + mock = method ? dep()[method] : dep(); + } else { + mock = method ? dep[method] : dep; + } + + return mock; + }; + + const thenDependencyWasCalledTimes = (times: number, dependency: string, method?: string) => { + const mock = getDependencyMock(dependency, method); + expect(mock).toBeCalledTimes(times); + + return instance; + }; + + const thenDependencyWasCalledWith = (args: any[], dependency: string, method?: string) => { + const mock = getDependencyMock(dependency, method); + expect(mock).toBeCalledWith(...args); + + return instance; + }; + const instance = { whenActionIsDispatched, whenQueryReceivesResponse, @@ -85,6 +138,8 @@ export const epicTester = ( whenQueryObserverReceivesEvent, thenResultingActionsEqual, thenNoActionsWhereDispatched, + thenDependencyWasCalledTimes, + thenDependencyWasCalledWith, }; return instance; diff --git a/public/test/mocks/mockExploreState.ts b/public/test/mocks/mockExploreState.ts index d6d2859f94c..a51d18968f0 100644 --- a/public/test/mocks/mockExploreState.ts +++ b/public/test/mocks/mockExploreState.ts @@ -3,6 +3,7 @@ import { DataSourceApi } from '@grafana/ui/src/types/datasource'; import { ExploreId, ExploreItemState, ExploreState } from 'app/types/explore'; import { makeExploreItemState } from 'app/features/explore/state/reducers'; import { StoreState } from 'app/types'; +import { TimeRange, dateTime } from '@grafana/ui'; export const mockExploreState = (options: any = {}) => { const isLive = options.isLive || false; @@ -31,6 +32,14 @@ export const mockExploreState = (options: any = {}) => { }, interval: datasourceInterval, }; + const range: TimeRange = options.range || { + from: dateTime('2019-01-01 10:00:00.000Z'), + to: dateTime('2019-01-01 16:00:00.000Z'), + raw: { + from: 'now-6h', + to: 'now', + }, + }; const urlReplaced = options.urlReplaced || false; const left: ExploreItemState = options.left || { ...makeExploreItemState(), @@ -45,6 +54,7 @@ export const mockExploreState = (options: any = {}) => { scanner, scanning, urlReplaced, + range, }; const right: ExploreItemState = options.right || { ...makeExploreItemState(), @@ -59,6 +69,7 @@ export const mockExploreState = (options: any = {}) => { scanner, scanning, urlReplaced, + range, }; const split: boolean = options.split || false; const explore: ExploreState = { @@ -82,5 +93,6 @@ export const mockExploreState = (options: any = {}) => { refreshInterval, state, scanner, + range, }; };