Explore: Improve handling time range keyboard shortcuts inside Explore (#73600)

* Handle time keyboard shortcuts inside Explore

* Remove unused handler for make absolute

* Remove unused code

* Unify handling keyboard shorcuts logic
This commit is contained in:
Piotr Jamróz 2023-08-23 16:02:49 +02:00 committed by GitHub
parent 8b7566c299
commit f11cc0e60e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 243 additions and 83 deletions

View File

@ -3,7 +3,7 @@ import React from 'react';
import { AutoSizerProps } from 'react-virtualized-auto-sizer';
import { TestProvider } from 'test/helpers/TestProvider';
import { DataSourceApi, LoadingState, CoreApp, createTheme, EventBusSrv, PluginExtensionTypes } from '@grafana/data';
import { CoreApp, createTheme, DataSourceApi, EventBusSrv, LoadingState, PluginExtensionTypes } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { getPluginLinkExtensions } from '@grafana/runtime';
import { configureStore } from 'app/store/configureStore';
@ -72,7 +72,6 @@ const dummyProps: Props = {
isLive: false,
syncedTimes: false,
updateTimeRange: jest.fn(),
makeAbsoluteTime: jest.fn(),
graphResult: [],
absoluteRange: {
from: 0,

View File

@ -4,37 +4,34 @@ import memoizeOne from 'memoize-one';
import React, { createRef } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import AutoSizer from 'react-virtualized-auto-sizer';
import { Unsubscribable } from 'rxjs';
import {
AbsoluteTimeRange,
EventBus,
GrafanaTheme2,
hasToggleableQueryFiltersSupport,
LoadingState,
QueryFixAction,
RawTimeRange,
EventBus,
SplitOpenOptions,
SupplementaryQueryType,
hasToggleableQueryFiltersSupport,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { config, getDataSourceSrv, reportInteraction } from '@grafana/runtime';
import { DataQuery } from '@grafana/schema';
import {
AdHocFilterItem,
CustomScrollbar,
ErrorBoundaryAlert,
PanelContainer,
Themeable2,
withTheme2,
PanelContainer,
AdHocFilterItem,
} from '@grafana/ui';
import { FILTER_FOR_OPERATOR, FILTER_OUT_OPERATOR } from '@grafana/ui/src/components/Table/types';
import appEvents from 'app/core/app_events';
import { supportedFeatures } from 'app/core/history/richHistoryStorageProvider';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { getNodeGraphDataFrames } from 'app/plugins/panel/nodeGraph/utils';
import { StoreState } from 'app/types';
import { AbsoluteTimeEvent } from 'app/types/events';
import { getTimeZone } from '../profile/state/selectors';
@ -67,7 +64,7 @@ import {
setSupplementaryQueryEnabled,
} from './state/query';
import { isSplit } from './state/selectors';
import { makeAbsoluteTime, updateTimeRange } from './state/time';
import { updateTimeRange } from './state/time';
const getStyles = (theme: GrafanaTheme2) => {
return {
@ -141,10 +138,10 @@ export type Props = ExploreProps & ConnectedProps<typeof connector>;
*/
export class Explore extends React.PureComponent<Props, ExploreState> {
scrollElement: HTMLDivElement | undefined;
absoluteTimeUnsubsciber: Unsubscribable | undefined;
topOfViewRef = createRef<HTMLDivElement>();
graphEventBus: EventBus;
logsEventBus: EventBus;
memoizedGetNodeGraphDataFrames = memoizeOne(getNodeGraphDataFrames);
constructor(props: Props) {
super(props);
@ -155,14 +152,6 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
this.logsEventBus = props.eventBus.newScopedBus('logs', { onlyLocal: false });
}
componentDidMount() {
this.absoluteTimeUnsubsciber = appEvents.subscribe(AbsoluteTimeEvent, this.onMakeAbsoluteTime);
}
componentWillUnmount() {
this.absoluteTimeUnsubsciber?.unsubscribe();
}
onChangeTime = (rawRange: RawTimeRange) => {
const { updateTimeRange, exploreId } = this.props;
updateTimeRange({ exploreId, rawRange });
@ -228,11 +217,6 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
this.props.addQueryRow(exploreId, queryKeys.length);
};
onMakeAbsoluteTime = () => {
const { makeAbsoluteTime } = this.props;
makeAbsoluteTime();
};
/**
* Used by Logs details.
*/
@ -450,8 +434,6 @@ export class Explore extends React.PureComponent<Props, ExploreState> {
);
}
memoizedGetNodeGraphDataFrames = memoizeOne(getNodeGraphDataFrames);
renderFlameGraphPanel() {
const { queryResponse } = this.props;
return <FlameGraphExploreContainer dataFrames={queryResponse.flameGraphFrames} />;
@ -660,7 +642,6 @@ const mapDispatchToProps = {
scanStopAction,
setQueries,
updateTimeRange,
makeAbsoluteTime,
addQueryRow,
splitOpen,
setSupplementaryQueryEnabled,

View File

@ -12,6 +12,7 @@ import { ExploreQueryParams } from 'app/types/explore';
import { ExploreActions } from './ExploreActions';
import { ExplorePaneContainer } from './ExplorePaneContainer';
import { useExplorePageTitle } from './hooks/useExplorePageTitle';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import { useSplitSizeUpdater } from './hooks/useSplitSizeUpdater';
import { useStateSync } from './hooks/useStateSync';
import { useTimeSrvFix } from './hooks/useTimeSrvFix';
@ -38,7 +39,7 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor
// if we were to update the URL on state change, the title would not match the URL.
// Ultimately the URL is the single source of truth from which state is derived, the page title is not different
useExplorePageTitle(props.queryParams);
const { keybindings, chrome } = useGrafana();
const { chrome } = useGrafana();
const navModel = useNavModel('explore');
const { updateSplitSize, widthCalc } = useSplitSizeUpdater(MIN_PANE_WIDTH);
@ -51,9 +52,7 @@ export default function ExplorePage(props: GrafanaRouteComponentProps<{}, Explor
chrome.update({ sectionNav: navModel });
}, [chrome, navModel]);
useEffect(() => {
keybindings.setupTimeRangeBindings(false);
}, [keybindings]);
useKeyboardShortcuts();
return (
<div className={styles.pageScrollbarWrapper}>

View File

@ -0,0 +1,114 @@
import { render } from '@testing-library/react';
import React from 'react';
import { dateTime, EventBusSrv } from '@grafana/data';
import { getAppEvents } from '@grafana/runtime';
import { AbsoluteTimeEvent, ShiftTimeEvent, ShiftTimeEventDirection, ZoomOutEvent } from 'app/types/events';
import { TestProvider } from '../../../../test/helpers/TestProvider';
import { configureStore } from '../../../store/configureStore';
import { initialExploreState } from '../state/main';
import { makeExplorePaneState } from '../state/utils';
import { useKeyboardShortcuts } from './useKeyboardShortcuts';
const testEventBus = new EventBusSrv();
jest.mock('@grafana/runtime', () => {
return {
...jest.requireActual('@grafana/runtime'),
reportInteraction: jest.fn(),
getAppEvents: () => testEventBus,
};
});
const NOW = new Date('2020-10-10T00:00:00.000Z');
function daysFromNow(daysDiff: number) {
return new Date(NOW.getTime() + daysDiff * 86400000);
}
function setup() {
const store = configureStore({
explore: {
...initialExploreState,
panes: {
left: makeExplorePaneState({
range: {
from: dateTime(),
to: dateTime(),
raw: { from: 'now-1d', to: 'now' },
},
}),
right: makeExplorePaneState({
range: {
from: dateTime(),
to: dateTime(),
raw: { from: 'now-2d', to: 'now' },
},
}),
},
},
});
const Wrapper = () => {
useKeyboardShortcuts();
return <div></div>;
};
render(
<TestProvider store={store}>
<Wrapper />
</TestProvider>
);
return store;
}
describe('useKeyboardShortcuts', () => {
beforeEach(() => {
jest.useFakeTimers().setSystemTime(NOW);
});
afterEach(() => {
jest.useRealTimers();
});
it('changes both panes to absolute time range', () => {
const store = setup();
getAppEvents().publish(new AbsoluteTimeEvent({ updateUrl: false }));
const exploreState = store.getState().explore;
const panes = Object.values(exploreState.panes);
expect(panes[0]!.absoluteRange.from).toBe(daysFromNow(-1).getTime());
expect(panes[0]!.absoluteRange.to).toBe(daysFromNow(0).getTime());
expect(panes[1]!.absoluteRange.from).toBe(daysFromNow(-2).getTime());
expect(panes[1]!.absoluteRange.to).toBe(daysFromNow(0).getTime());
});
it('shifts time range in both panes', () => {
const store = setup();
getAppEvents().publish(new ShiftTimeEvent({ direction: ShiftTimeEventDirection.Left }));
const exploreState = store.getState().explore;
const panes = Object.values(exploreState.panes);
expect(panes[0]!.absoluteRange.from).toBe(daysFromNow(-1.5).getTime());
expect(panes[0]!.absoluteRange.to).toBe(daysFromNow(-0.5).getTime());
expect(panes[1]!.absoluteRange.from).toBe(daysFromNow(-3).getTime());
expect(panes[1]!.absoluteRange.to).toBe(daysFromNow(-1).getTime());
});
it('zooms out the time range in both panes', () => {
const store = setup();
getAppEvents().publish(new ZoomOutEvent({ scale: 2 }));
const exploreState = store.getState().explore;
const panes = Object.values(exploreState.panes);
expect(panes[0]!.absoluteRange.from).toBe(daysFromNow(-1.5).getTime());
expect(panes[0]!.absoluteRange.to).toBe(daysFromNow(0.5).getTime());
expect(panes[1]!.absoluteRange.from).toBe(daysFromNow(-3).getTime());
expect(panes[1]!.absoluteRange.to).toBe(daysFromNow(1).getTime());
});
});

View File

@ -0,0 +1,42 @@
import { useEffect } from 'react';
import { Unsubscribable } from 'rxjs';
import { getAppEvents } from '@grafana/runtime';
import { useGrafana } from 'app/core/context/GrafanaContext';
import { useDispatch } from 'app/types';
import { AbsoluteTimeEvent, ShiftTimeEvent, ZoomOutEvent } from 'app/types/events';
import { makeAbsoluteTime, shiftTime, zoomOut } from '../state/time';
export function useKeyboardShortcuts() {
const { keybindings } = useGrafana();
const dispatch = useDispatch();
useEffect(() => {
keybindings.setupTimeRangeBindings(false);
const tearDown: Unsubscribable[] = [];
tearDown.push(
getAppEvents().subscribe(AbsoluteTimeEvent, () => {
dispatch(makeAbsoluteTime());
})
);
tearDown.push(
getAppEvents().subscribe(ShiftTimeEvent, (event) => {
dispatch(shiftTime(event.payload.direction));
})
);
tearDown.push(
getAppEvents().subscribe(ZoomOutEvent, (event) => {
dispatch(zoomOut(event.payload.scale));
})
);
return () => {
tearDown.forEach((u) => u.unsubscribe());
};
}, [dispatch, keybindings]);
}

View File

@ -1,5 +1,7 @@
import { screen, waitFor } from '@testing-library/react';
import { EventBusSrv } from '@grafana/data';
import { changeDatasource } from './helper/interactions';
import { makeLogsQueryResponse } from './helper/query';
import { setupExplore, tearDown, waitForExplore } from './helper/setup';
@ -10,6 +12,13 @@ jest.mock('../../correlations/utils', () => {
};
});
const testEventBus = new EventBusSrv();
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getAppEvents: () => testEventBus,
}));
describe('Explore: handle datasource states', () => {
afterEach(() => {
tearDown();

View File

@ -1,6 +1,6 @@
import React from 'react';
import { DataQueryRequest, serializeStateToUrlParam } from '@grafana/data';
import { DataQueryRequest, EventBusSrv, serializeStateToUrlParam } from '@grafana/data';
import { getTemplateSrv } from '@grafana/runtime';
import { LokiQuery } from '../../../plugins/datasource/loki/types';
@ -8,10 +8,13 @@ import { LokiQuery } from '../../../plugins/datasource/loki/types';
import { makeLogsQueryResponse } from './helper/query';
import { setupExplore, waitForExplore } from './helper/setup';
const testEventBus = new EventBusSrv();
const fetch = jest.fn();
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => ({ fetch }),
getAppEvents: () => testEventBus,
}));
jest.mock('app/core/core', () => ({

View File

@ -1,10 +1,17 @@
import { screen } from '@testing-library/react';
import { serializeStateToUrlParam } from '@grafana/data';
import { EventBusSrv, serializeStateToUrlParam } from '@grafana/data';
import { makeLogsQueryResponse } from './helper/query';
import { setupExplore, tearDown, waitForExplore } from './helper/setup';
const testEventBus = new EventBusSrv();
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getAppEvents: () => testEventBus,
}));
jest.mock('../../correlations/utils', () => {
return {
getCorrelationsBySourceUIDs: jest.fn().mockReturnValue({ correlations: [] }),

View File

@ -1,7 +1,7 @@
import React from 'react';
import { of } from 'rxjs';
import { serializeStateToUrlParam } from '@grafana/data';
import { EventBusSrv, serializeStateToUrlParam } from '@grafana/data';
import { config } from '@grafana/runtime';
import { silenceConsoleOutput } from '../../../../test/core/utils/silenceConsoleOutput';
@ -13,13 +13,13 @@ import {
assertQueryHistoryComment,
assertQueryHistoryElementsShown,
assertQueryHistoryExists,
assertQueryHistoryIsEmpty,
assertQueryHistoryIsStarred,
assertQueryHistoryTabIsSelected,
assertQueryHistoryIsEmpty,
} from './helper/assert';
import {
commentQueryHistory,
closeQueryHistory,
commentQueryHistory,
deleteQueryHistory,
inputQuery,
loadMoreQueryHistory,
@ -37,12 +37,15 @@ const fetchMock = jest.fn();
const postMock = jest.fn();
const getMock = jest.fn();
const reportInteractionMock = jest.fn();
const testEventBus = new EventBusSrv();
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => ({ fetch: fetchMock, post: postMock, get: getMock }),
reportInteraction: (...args: object[]) => {
reportInteractionMock(...args);
},
getAppEvents: () => testEventBus,
}));
jest.mock('app/core/core', () => ({

View File

@ -3,23 +3,21 @@ import userEvent from '@testing-library/user-event';
import React, { ComponentProps } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { serializeStateToUrlParam } from '@grafana/data';
import { EventBusSrv, serializeStateToUrlParam } from '@grafana/data';
import * as mainState from '../state/main';
import { makeLogsQueryResponse } from './helper/query';
import { setupExplore, waitForExplore } from './helper/setup';
const testEventBus = new EventBusSrv();
jest.mock('app/core/core', () => {
return {
contextSrv: {
hasPermission: () => true,
hasAccess: () => true,
},
appEvents: {
subscribe: () => {},
publish: () => {},
},
};
});
@ -36,6 +34,7 @@ const fetch = jest.fn().mockResolvedValue({ correlations: [] });
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
getBackendSrv: () => ({ fetch }),
getAppEvents: () => testEventBus,
}));
jest.mock('rxjs', () => ({

View File

@ -7,17 +7,6 @@ import { ExploreItemState } from 'app/types';
import { createDefaultInitialState } from './helpers';
import { changeRangeAction, timeReducer, updateTime } from './time';
const MOCK_TIME_RANGE = {};
const mockTimeSrv = {
init: jest.fn(),
timeRange: jest.fn().mockReturnValue(MOCK_TIME_RANGE),
};
jest.mock('app/features/dashboard/services/TimeSrv', () => ({
...jest.requireActual('app/features/dashboard/services/TimeSrv'),
getTimeSrv: () => mockTimeSrv,
}));
const mockTemplateSrv = {
updateTimeRange: jest.fn(),
};
@ -29,10 +18,10 @@ jest.mock('@grafana/runtime', () => ({
describe('Explore item reducer', () => {
describe('When time is updated', () => {
it('Time service is re-initialized and template service is updated with the new time range', async () => {
const { dispatch } = configureStore(createDefaultInitialState().defaultInitialState as any);
const state = createDefaultInitialState().defaultInitialState as any;
const { dispatch } = configureStore(state);
dispatch(updateTime({ exploreId: 'left' }));
expect(mockTimeSrv.init).toBeCalled();
expect(mockTemplateSrv.updateTimeRange).toBeCalledWith(MOCK_TIME_RANGE);
expect(mockTemplateSrv.updateTimeRange).toBeCalledWith(state.explore.panes.left.range);
});
});

View File

@ -4,12 +4,10 @@ import { AbsoluteTimeRange, dateTimeForTimeZone, LoadingState, RawTimeRange, Tim
import { getTemplateSrv } from '@grafana/runtime';
import { RefreshPicker } from '@grafana/ui';
import { getTimeRange, refreshIntervalToSortOrder, stopQueryState } from 'app/core/utils/explore';
import { getShiftedTimeRange, getZoomedTimeRange } from 'app/core/utils/timePicker';
import { sortLogsResult } from 'app/features/logs/utils';
import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state/selectors';
import { ExploreItemState, ThunkResult } from 'app/types';
import { getTimeSrv } from '../../dashboard/services/TimeSrv';
import { TimeModel } from '../../dashboard/state/TimeModel';
import { ExploreItemState, ThunkDispatch, ThunkResult } from 'app/types';
import { syncTimesAction } from './main';
import { runQueries } from './query';
@ -79,21 +77,10 @@ export const updateTime = (config: {
const range = getTimeRange(timeZone, rawRange, fiscalYearStartMonth);
const absoluteRange: AbsoluteTimeRange = { from: range.from.valueOf(), to: range.to.valueOf() };
const timeModel: TimeModel = {
time: range.raw,
refresh: false,
timepicker: {},
getTimezone: () => timeZone,
timeRangeUpdated: (rawTimeRange: RawTimeRange) => {
dispatch(updateTimeRange({ exploreId: exploreId, rawRange: rawTimeRange }));
},
};
// We need to re-initialize TimeSrv because it might have been triggered by the other Explore pane (when split)
getTimeSrv().init(timeModel);
// After re-initializing TimeSrv we need to update the time range in Template service for interpolation
// of __from and __to variables
getTemplateSrv().updateTimeRange(getTimeSrv().timeRange());
getTemplateSrv().updateTimeRange(range);
dispatch(changeRangeAction({ exploreId, range, absoluteRange }));
};
@ -118,24 +105,51 @@ export function syncTimes(exploreId: string): ThunkResult<void> {
};
}
/**
* Forces the timepicker's time into absolute time.
* The conversion is applied to all Explore panes.
* Useful to produce a bookmarkable URL that points to the same data.
*/
export function makeAbsoluteTime(): ThunkResult<void> {
function modifyExplorePanesTimeRange(
modifier: (
exploreId: string,
exploreItemState: ExploreItemState,
currentTimeRange: TimeRange,
dispatch: ThunkDispatch
) => void
): ThunkResult<void> {
return (dispatch, getState) => {
const timeZone = getTimeZone(getState().user);
const fiscalYearStartMonth = getFiscalYearStartMonth(getState().user);
Object.entries(getState().explore.panes).forEach(([exploreId, exploreItemState]) => {
const range = getTimeRange(timeZone, exploreItemState!.range.raw, fiscalYearStartMonth);
const absoluteRange: AbsoluteTimeRange = { from: range.from.valueOf(), to: range.to.valueOf() };
dispatch(updateTime({ exploreId, absoluteRange }));
modifier(exploreId, exploreItemState!, range, dispatch);
});
};
}
/**
* Forces the timepicker's time into absolute time.
* The conversion is applied to all Explore panes.
* Useful to produce a bookmarkable URL that points to the same data.
*/
export function makeAbsoluteTime(): ThunkResult<void> {
return modifyExplorePanesTimeRange((exploreId, exploreItemState, range, dispatch) => {
const absoluteRange: AbsoluteTimeRange = { from: range.from.valueOf(), to: range.to.valueOf() };
dispatch(updateTimeRange({ exploreId, absoluteRange }));
});
}
export function shiftTime(direction: number): ThunkResult<void> {
return modifyExplorePanesTimeRange((exploreId, exploreItemState, range, dispatch) => {
const shiftedRange = getShiftedTimeRange(direction, range);
dispatch(updateTimeRange({ exploreId, absoluteRange: shiftedRange }));
});
}
export function zoomOut(scale: number): ThunkResult<void> {
return modifyExplorePanesTimeRange((exploreId, exploreItemState, range, dispatch) => {
const zoomedRange = getZoomedTimeRange(range, scale);
dispatch(updateTimeRange({ exploreId, absoluteRange: zoomedRange }));
});
}
/**
* Reducer for an Explore area, to be used by the global Explore reducer.
*/

View File

@ -3,18 +3,18 @@ import { uniq } from 'lodash';
import {
AbsoluteTimeRange,
DataSourceApi,
dateMath,
DateTime,
EventBusExtended,
getDefaultTimeRange,
HistoryItem,
isDateTime,
LoadingState,
LogRowModel,
PanelData,
RawTimeRange,
TimeFragment,
TimeRange,
dateMath,
DateTime,
isDateTime,
toUtc,
URLRange,
URLRangeValue,
@ -42,7 +42,7 @@ export const storeGraphStyle = (graphStyle: string): void => {
/**
* Returns a fresh Explore area state
*/
export const makeExplorePaneState = (): ExploreItemState => ({
export const makeExplorePaneState = (overrides?: Partial<ExploreItemState>): ExploreItemState => ({
containerWidth: 0,
datasourceInstance: null,
history: [],
@ -73,6 +73,7 @@ export const makeExplorePaneState = (): ExploreItemState => ({
supplementaryQueries: loadSupplementaryQueries(),
panelsState: {},
correlations: undefined,
...overrides,
});
export const createEmptyQueryResponse = (): ExplorePanelData => ({