Commit dabc848e by Ivana Huckova Committed by GitHub

Explore: Synchronise time ranges in split mode (#19274)

* Explore: create connected sync button when screen is splitted

* Explore: create attachable button to TimePicker

* WIP/Explore: set up redux boilerplate for synced state

* WIP/Explore: add toggling functionality to sync buttons

* WIP/Explore: Fix styling issue

* First pass solution working

* Explore: Clean up, update comments

* Explore: refactore Timepicker, remove newly introduced class names

* Explore: refactore ExploreTimeControls

* Explore: more semantic variables naming

* Explore: run query on syncable item when synced times activated

* Explore: Add tooltip to sync times button

* Explore: Remove typo

* Explore: check exploreId

* Explore: refactor ExploreTimeControls

* Explore: refactor to include suggested changes

* Explore: Create TimeSyncButton component, update colors

* Explore: Toggle tooltip, use stylesFactory
parent 0f32e15a
// Libraries
import React, { PureComponent, createRef } from 'react';
import { css } from 'emotion';
import memoizeOne from 'memoize-one';
import classNames from 'classnames';
// Components
import { ButtonSelect } from '../Select/ButtonSelect';
......@@ -11,15 +14,41 @@ import { ClickOutsideWrapper } from '../ClickOutsideWrapper/ClickOutsideWrapper'
import { isDateTime, DateTime } from '@grafana/data';
import { rangeUtil } from '@grafana/data';
import { rawToTimeRange } from './time';
import { withTheme } from '../../themes/ThemeContext';
// Types
import { TimeRange, TimeOption, TimeZone, TIME_FORMAT, SelectableValue } from '@grafana/data';
import { dateMath } from '@grafana/data';
import { TimeRange, TimeOption, TimeZone, TIME_FORMAT, SelectableValue, dateMath } from '@grafana/data';
import { GrafanaTheme } from '../../types/theme';
import { Themeable } from '../../types';
export interface Props {
const getStyles = memoizeOne((theme: GrafanaTheme) => {
return {
timePickerSynced: css`
label: timePickerSynced;
border-color: ${theme.colors.orangeDark};
background-image: none;
background-color: transparent;
color: ${theme.colors.orangeDark};
&:focus,
:hover {
color: ${theme.colors.orangeDark};
background-image: none;
background-color: transparent;
}
`,
noRightBorderStyle: css`
label: noRightBorderStyle;
border-right: 0;
`,
};
});
export interface Props extends Themeable {
value: TimeRange;
selectOptions: TimeOption[];
timeZone?: TimeZone;
timeSyncButton?: JSX.Element;
isSynced?: boolean;
onChange: (timeRange: TimeRange) => void;
onMoveBackward: () => void;
onMoveForward: () => void;
......@@ -70,7 +99,7 @@ const defaultZoomOutTooltip = () => {
export interface State {
isCustomOpen: boolean;
}
export class TimePicker extends PureComponent<Props, State> {
class UnThemedTimePicker extends PureComponent<Props, State> {
pickerTriggerRef = createRef<HTMLDivElement>();
state: State = {
......@@ -120,7 +149,19 @@ export class TimePicker extends PureComponent<Props, State> {
};
render() {
const { selectOptions: selectTimeOptions, value, onMoveBackward, onMoveForward, onZoom, timeZone } = this.props;
const {
selectOptions: selectTimeOptions,
value,
onMoveBackward,
onMoveForward,
onZoom,
timeZone,
timeSyncButton,
isSynced,
theme,
} = this.props;
const styles = getStyles(theme);
const { isCustomOpen } = this.state;
const options = this.mapTimeOptionsToSelectableValues(selectTimeOptions);
const currentOption = options.find(item => isTimeOptionEqualToTimeRange(item.value, value));
......@@ -152,7 +193,10 @@ export class TimePicker extends PureComponent<Props, State> {
</button>
)}
<ButtonSelect
className="time-picker-button-select"
className={classNames('time-picker-button-select', {
[`btn--radius-right-0 ${styles.noRightBorderStyle}`]: timeSyncButton,
[styles.timePickerSynced]: timeSyncButton ? isSynced : null,
})}
value={currentOption}
label={label}
options={options}
......@@ -161,6 +205,9 @@ export class TimePicker extends PureComponent<Props, State> {
iconClass={'fa fa-clock-o fa-fw'}
tooltipContent={<TimePickerTooltipContent timeRange={value} />}
/>
{timeSyncButton}
{isAbsolute && (
<button className="btn navbar-button navbar-button--tight" onClick={onMoveForward}>
<i className="fa fa-chevron-right" />
......@@ -195,3 +242,5 @@ const TimePickerTooltipContent = ({ timeRange }: { timeRange: TimeRange }) => (
function isTimeOptionEqualToTimeRange(option: TimeOption, range: TimeRange): boolean {
return range.raw.from === option.from && range.raw.to === option.to;
}
export const TimePicker = withTheme(UnThemedTimePicker);
......@@ -89,6 +89,7 @@ interface ExploreProps {
mode: ExploreMode;
initialUI: ExploreUIState;
isLive: boolean;
syncedTimes: boolean;
updateTimeRange: typeof updateTimeRange;
graphResult?: GraphSeriesXY[];
loading?: boolean;
......@@ -178,7 +179,6 @@ export class Explore extends React.PureComponent<ExploreProps> {
onChangeTime = (rawRange: RawTimeRange) => {
const { updateTimeRange, exploreId } = this.props;
updateTimeRange({ exploreId, rawRange });
};
......@@ -218,7 +218,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
};
onUpdateTimeRange = (absoluteRange: AbsoluteTimeRange) => {
const { updateTimeRange, exploreId } = this.props;
const { exploreId, updateTimeRange } = this.props;
updateTimeRange({ exploreId, absoluteRange });
};
......@@ -263,6 +263,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
showingTable,
timeZone,
queryResponse,
syncedTimes,
} = this.props;
const exploreClass = split ? 'explore explore-split' : 'explore';
const styles = getStyles();
......@@ -326,6 +327,7 @@ export class Explore extends React.PureComponent<ExploreProps> {
<LogsContainer
width={width}
exploreId={exploreId}
syncedTimes={syncedTimes}
onClickLabel={this.onClickLabel}
onStartScanning={this.onStartScanning}
onStopScanning={this.onStopScanning}
......@@ -350,7 +352,7 @@ const getTimeRangeFromUrlMemoized = memoizeOne(getTimeRangeFromUrl);
function mapStateToProps(state: StoreState, { exploreId }: ExploreProps): Partial<ExploreProps> {
const explore = state.explore;
const { split } = explore;
const { split, syncedTimes } = explore;
const item: ExploreItemState = explore[exploreId];
const timeZone = getTimeZone(state.user);
const {
......@@ -421,6 +423,7 @@ function mapStateToProps(state: StoreState, { exploreId }: ExploreProps): Partia
absoluteRange,
queryResponse,
originPanelId,
syncedTimes,
};
}
......
......@@ -9,6 +9,7 @@ import { TimeRange, TimeOption, TimeZone, RawTimeRange, dateTimeForTimeZone } fr
// Components
import { TimePicker } from '@grafana/ui';
import { TimeSyncButton } from './TimeSyncButton';
// Utils & Services
import { defaultSelectOptions } from '@grafana/ui/src/components/TimePicker/TimePicker';
......@@ -18,6 +19,9 @@ export interface Props {
exploreId: ExploreId;
range: TimeRange;
timeZone: TimeZone;
splitted: boolean;
syncedTimes: boolean;
onChangeTimeSync: () => void;
onChangeTime: (range: RawTimeRange) => void;
}
......@@ -67,18 +71,18 @@ export class ExploreTimeControls extends Component<Props> {
};
render() {
const { range, timeZone } = this.props;
const { range, timeZone, splitted, syncedTimes, onChangeTimeSync } = this.props;
const timeSyncButton = splitted ? <TimeSyncButton onClick={onChangeTimeSync} isSynced={syncedTimes} /> : null;
const timePickerCommonProps = {
value: range,
onChange: this.onChangeTimePicker,
timeZone,
onMoveBackward: this.onMoveBack,
onMoveForward: this.onMoveForward,
onZoom: this.onZoom,
selectOptions: this.setActiveTimeOption(defaultSelectOptions, range.raw),
};
return (
<TimePicker
value={range}
onChange={this.onChangeTimePicker}
timeZone={timeZone}
onMoveBackward={this.onMoveBack}
onMoveForward={this.onMoveForward}
onZoom={this.onZoom}
selectOptions={this.setActiveTimeOption(defaultSelectOptions, range.raw)}
/>
);
return <TimePicker {...timePickerCommonProps} timeSyncButton={timeSyncButton} isSynced={syncedTimes} />;
}
}
......@@ -25,6 +25,7 @@ import {
splitClose,
runQueries,
splitOpen,
syncTimes,
changeRefreshInterval,
changeMode,
clearOrigin,
......@@ -60,6 +61,7 @@ interface StateProps {
timeZone: TimeZone;
selectedDatasource: DataSourceSelectItem;
splitted: boolean;
syncedTimes: boolean;
refreshInterval: string;
supportedModes: ExploreMode[];
selectedMode: ExploreMode;
......@@ -77,6 +79,7 @@ interface DispatchProps {
runQueries: typeof runQueries;
closeSplit: typeof splitClose;
split: typeof splitOpen;
syncTimes: typeof syncTimes;
changeRefreshInterval: typeof changeRefreshInterval;
changeMode: typeof changeMode;
clearOrigin: typeof clearOrigin;
......@@ -112,6 +115,11 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
changeMode(exploreId, mode);
};
onChangeTimeSync = () => {
const { syncTimes, exploreId } = this.props;
syncTimes(exploreId);
};
returnToPanel = async ({ withChanges = false } = {}) => {
const { originPanelId } = this.props;
......@@ -148,6 +156,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
timeZone,
selectedDatasource,
splitted,
syncedTimes,
refreshInterval,
onChangeTime,
split,
......@@ -259,6 +268,9 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
range={range}
timeZone={timeZone}
onChangeTime={onChangeTime}
splitted={splitted}
syncedTimes={syncedTimes}
onChangeTimeSync={this.onChangeTimeSync}
/>
</div>
)}
......@@ -305,6 +317,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props, {}> {
const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps => {
const splitted = state.explore.split;
const syncedTimes = state.explore.syncedTimes;
const exploreItem: ExploreItemState = state.explore[exploreId];
const {
datasourceInstance,
......@@ -343,6 +356,7 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps): StateProps
isPaused,
originPanelId,
queries,
syncedTimes,
datasourceLoading,
};
};
......@@ -355,6 +369,7 @@ const mapDispatchToProps: DispatchProps = {
runQueries,
closeSplit: splitClose,
split: splitOpen,
syncTimes,
changeMode: changeMode,
clearOrigin,
};
......
......@@ -47,6 +47,7 @@ interface LogsContainerProps {
isLive: boolean;
updateTimeRange: typeof updateTimeRange;
range: TimeRange;
syncedTimes: boolean;
absoluteRange: AbsoluteTimeRange;
isPaused: boolean;
}
......@@ -54,7 +55,6 @@ interface LogsContainerProps {
export class LogsContainer extends PureComponent<LogsContainerProps> {
onChangeTime = (absoluteRange: AbsoluteTimeRange) => {
const { exploreId, updateTimeRange } = this.props;
updateTimeRange({ exploreId, absoluteRange });
};
......
import React from 'react';
import classNames from 'classnames';
import { css } from 'emotion';
import { GrafanaTheme, useTheme, stylesFactory } from '@grafana/ui';
//Components
import { Tooltip } from '@grafana/ui';
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
timePickerSynced: css`
label: timePickerSynced;
border-color: ${theme.colors.orangeDark};
background-image: none;
background-color: transparent;
color: ${theme.colors.orangeDark};
&:focus,
:hover {
color: ${theme.colors.orangeDark};
background-image: none;
background-color: transparent;
}
`,
noRightBorderStyle: css`
label: noRightBorderStyle;
border-right: 0;
`,
};
});
interface TimeSyncButtonProps {
isSynced: boolean;
onClick: () => void;
}
export function TimeSyncButton(props: TimeSyncButtonProps) {
const { onClick, isSynced } = props;
const theme = useTheme();
const styles = getStyles(theme);
const syncTimesTooltip = () => {
const { isSynced } = props;
const tooltip = isSynced ? 'Unsync all views' : 'Sync all views to this time range';
return <>{tooltip}</>;
};
return (
<Tooltip content={syncTimesTooltip} placement="bottom">
<button
className={classNames('btn navbar-button navbar-button--attached', {
[styles.timePickerSynced]: isSynced,
})}
onClick={() => onClick()}
>
<i className="fa fa-link" />
</button>
</Tooltip>
);
}
......@@ -13,6 +13,7 @@ import { actionCreatorFactory, ActionOf } from 'app/core/redux/actionCreatorFact
export enum ActionTypes {
SplitOpen = 'explore/SPLIT_OPEN',
ResetExplore = 'explore/RESET_EXPLORE',
SyncTimes = 'explore/SYNC_TIMES',
}
export interface SplitOpenAction {
type: ActionTypes.SplitOpen;
......@@ -26,6 +27,10 @@ export interface ResetExploreAction {
payload: {};
}
export interface SyncTimesAction {
type: ActionTypes.SyncTimes;
payload: { syncedTimes: boolean };
}
/** Lower order actions
*
*/
......@@ -165,6 +170,10 @@ export interface SplitOpenPayload {
itemState: ExploreItemState;
}
export interface SyncTimesPayload {
syncedTimes: boolean;
}
export interface ToggleTablePayload {
exploreId: ExploreId;
}
......@@ -352,6 +361,7 @@ export const splitCloseAction = actionCreatorFactory<SplitCloseActionPayload>('e
*/
export const splitOpenAction = actionCreatorFactory<SplitOpenPayload>('explore/SPLIT_OPEN').create();
export const syncTimesAction = actionCreatorFactory<SyncTimesPayload>('explore/SYNC_TIMES').create();
/**
* Update state of Explores UI elements (panels visiblity and deduplication strategy)
*/
......@@ -410,4 +420,5 @@ export type HigherOrderAction =
| ActionOf<SplitCloseActionPayload>
| SplitOpenAction
| ResetExploreAction
| SyncTimesAction
| ActionOf<any>;
......@@ -71,6 +71,7 @@ import {
queryStreamUpdatedAction,
queryStoreSubscriptionAction,
clearOriginAction,
syncTimesAction,
} from './actionTypes';
import { ActionOf, ActionCreator } from 'app/core/redux/actionCreatorFactory';
import { getTimeZone } from 'app/features/profile/state/selectors';
......@@ -182,12 +183,19 @@ export const updateTimeRange = (options: {
rawRange?: RawTimeRange;
absoluteRange?: AbsoluteTimeRange;
}): ThunkResult<void> => {
return dispatch => {
return (dispatch, getState) => {
const { syncedTimes } = getState().explore;
if (syncedTimes) {
dispatch(updateTime({ ...options, exploreId: ExploreId.left }));
dispatch(runQueries(ExploreId.left));
dispatch(updateTime({ ...options, exploreId: ExploreId.right }));
dispatch(runQueries(ExploreId.right));
} else {
dispatch(updateTime({ ...options }));
dispatch(runQueries(options.exploreId));
}
};
};
/**
* Change the refresh interval of Explore. Called from the Refresh picker.
*/
......@@ -675,6 +683,25 @@ export function splitOpen(): ThunkResult<void> {
}
/**
* Syncs time interval, if they are not synced on both panels in a split mode.
* Unsyncs time interval, if they are synced on both panels in a split mode.
*/
export function syncTimes(exploreId: ExploreId): ThunkResult<void> {
return (dispatch, getState) => {
if (exploreId === ExploreId.left) {
const leftState = getState().explore.left;
dispatch(updateTimeRange({ exploreId: ExploreId.right, rawRange: leftState.range.raw }));
} else {
const rightState = getState().explore.right;
dispatch(updateTimeRange({ exploreId: ExploreId.left, rawRange: rightState.range.raw }));
}
const isTimeSynced = getState().explore.syncedTimes;
dispatch(syncTimesAction({ syncedTimes: !isTimeSynced }));
dispatch(stateSave());
};
}
/**
* Creates action to collapse graph/logs/table panel. When panel is collapsed,
* queries won't be run
*/
......
......@@ -129,6 +129,7 @@ export const createEmptyQueryResponse = (): PanelData => ({
export const initialExploreItemState = makeExploreItemState();
export const initialExploreState: ExploreState = {
split: null,
syncedTimes: false,
left: initialExploreItemState,
right: initialExploreItemState,
};
......@@ -727,6 +728,9 @@ export const exploreReducer = (state = initialExploreState, action: HigherOrderA
case ActionTypes.SplitOpen: {
return { ...state, split: true, right: { ...action.payload.itemState } };
}
case ActionTypes.SyncTimes: {
return { ...state, syncedTimes: action.payload.syncedTimes };
}
case ActionTypes.ResetExplore: {
if (action.payload.force || !Number.isInteger(state.left.originPanelId)) {
......
......@@ -131,6 +131,10 @@ export interface ExploreState {
*/
split: boolean;
/**
* True if time interval for panels are synced. Only possible with split mode.
*/
syncedTimes: boolean;
/**
* Explore state of the left split (left is default in non-split view).
*/
left: ExploreItemState;
......
......@@ -72,9 +72,11 @@ export const mockExploreState = (options: any = {}) => {
range,
};
const split: boolean = options.split || false;
const syncedTimes: boolean = options.syncedTimes || false;
const explore: ExploreState = {
left,
right,
syncedTimes,
split,
};
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment