diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
index b2e28ff7d7a..23aa5bf0655 100644
--- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
+++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md
@@ -210,6 +210,7 @@ Experimental features might be changed or removed without prior notice.
| `rolePickerDrawer` | Enables the new role picker drawer design |
| `pluginsSriChecks` | Enables SRI checks for plugin assets |
| `unifiedStorageBigObjectsSupport` | Enables to save big objects in blob storage |
+| `timeRangeProvider` | Enables time pickers sync |
## Development feature toggles
diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts
index fa3f8ae2268..cb9b60760d8 100644
--- a/packages/grafana-data/src/types/featureToggles.gen.ts
+++ b/packages/grafana-data/src/types/featureToggles.gen.ts
@@ -221,4 +221,5 @@ export interface FeatureToggles {
unifiedStorageSearch?: boolean;
pluginsSriChecks?: boolean;
unifiedStorageBigObjectsSupport?: boolean;
+ timeRangeProvider?: boolean;
}
diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangeContext.test.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangeContext.test.tsx
new file mode 100644
index 00000000000..fcea55c1524
--- /dev/null
+++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangeContext.test.tsx
@@ -0,0 +1,101 @@
+import { act, render } from '@testing-library/react';
+import { useEffect } from 'react';
+
+import { makeTimeRange } from '@grafana/data';
+
+import { TimeRangeContextHookValue, TimeRangeProvider, useTimeRangeContext } from './TimeRangeContext';
+
+// Should be fine to have this globally as single file should not be parallelized
+let context: TimeRangeContextHookValue | undefined = undefined;
+function onContextChange(val?: TimeRangeContextHookValue) {
+ context = val;
+}
+
+describe('TimeRangeProvider', () => {
+ it('provides the context with default values', () => {
+ render(
+
+
+
+ );
+
+ expect(context).toMatchObject({
+ sync: expect.any(Function),
+ unSync: expect.any(Function),
+ syncPossible: false,
+ synced: false,
+ syncedValue: undefined,
+ });
+ });
+
+ it('is possible to sync if 2 instances exist', async () => {
+ render(
+
+
+ {}} />
+
+ );
+
+ expect(context).toMatchObject({
+ sync: expect.any(Function),
+ unSync: expect.any(Function),
+ syncPossible: true,
+ synced: false,
+ syncedValue: undefined,
+ });
+ });
+
+ it('syncs and unsyncs time across instances', async () => {
+ let context2: TimeRangeContextHookValue | undefined = undefined;
+ function onContextChange2(val?: TimeRangeContextHookValue) {
+ context2 = val;
+ }
+
+ render(
+
+
+
+
+ );
+
+ const timeRange = makeTimeRange('2021-01-01', '2021-01-02');
+ act(() => {
+ context?.sync(timeRange);
+ });
+
+ expect(context2).toMatchObject({
+ syncPossible: true,
+ synced: true,
+ syncedValue: timeRange,
+ });
+
+ act(() => {
+ context?.unSync();
+ });
+
+ expect(context2).toMatchObject({
+ syncPossible: true,
+ synced: false,
+ syncedValue: undefined,
+ });
+ });
+});
+
+describe('useTimeRangeContext', () => {
+ it('does not error without provider', () => {
+ render();
+ expect(context).toBeUndefined();
+ });
+});
+
+type TestComponentProps = {
+ onContextChange: (context?: TimeRangeContextHookValue) => void;
+};
+
+function TestComponent(props: TestComponentProps) {
+ const context = useTimeRangeContext();
+ useEffect(() => {
+ props.onContextChange(context);
+ }, [context, props]);
+ return null;
+}
diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangeContext.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangeContext.tsx
new file mode 100644
index 00000000000..37907c2a83c
--- /dev/null
+++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangeContext.tsx
@@ -0,0 +1,95 @@
+import { ReactNode, createContext, useEffect, useMemo, useState, useContext } from 'react';
+
+import { TimeRange } from '@grafana/data';
+
+type TimeRangeContextValue = TimeRangeContextHookValue & {
+ // These are to be used internally and aren't passed to the users of the hook.
+
+ // Called when picker is mounted to update the picker count.
+ addPicker(): void;
+
+ // Called when picker is unmounted to update the picker count.
+ removePicker(): void;
+};
+
+export type TimeRangeContextHookValue = {
+ // If the time range is synced, this is the value that all pickers should show.
+ syncedValue?: TimeRange;
+
+ // If the time range is synced across all visible pickers.
+ synced: boolean;
+
+ // If it is possible to sync the time range. This is based just on the number of pickers that are visible so if
+ // there is only one, the sync button can be hidden.
+ syncPossible: boolean;
+
+ // Action passed to the picker to interact with the sync state.
+ // Sync the time range across all pickers with the provided value. Can be also used just to update a value when
+ // already synced.
+ sync(value: TimeRange): void;
+ unSync(): void;
+};
+
+const TimeRangeContext = createContext(undefined);
+
+export function TimeRangeProvider({ children }: { children: ReactNode }) {
+ // We simply keep the count of the pickers visible by letting them call the addPicker and removePicker functions.
+ const [pickersCount, setPickersCount] = useState(0);
+ const [syncedValue, setSyncedValue] = useState();
+
+ const contextVal = useMemo(() => {
+ return {
+ sync: (value: TimeRange) => setSyncedValue(value),
+ unSync: () => setSyncedValue(undefined),
+ addPicker: () => setPickersCount((val) => val + 1),
+ removePicker: () => setPickersCount((val) => val - 1),
+ syncPossible: pickersCount > 1,
+ synced: Boolean(syncedValue),
+ syncedValue,
+ };
+ }, [pickersCount, syncedValue]);
+
+ return {children};
+}
+
+export function useTimeRangeContext(initialSyncValue?: TimeRange): TimeRangeContextHookValue | undefined {
+ const context = useContext(TimeRangeContext);
+
+ // Automatically add and remove the picker when the component mounts and unmounts or if context changes (but that
+ // should not happen). We ignore the initialSyncValue to make this value really just an initial value and isn't a
+ // prop by which you could control the picker.
+ useEffect(() => {
+ // We want the pickers to still function even if they are not used in a context. Not sure, this will be a common
+ // usecase, but it does not seem like it will cost us anything.
+ if (context) {
+ context.addPicker();
+ if (initialSyncValue) {
+ context.sync(initialSyncValue);
+ }
+ return () => {
+ context.removePicker();
+ };
+ }
+ return () => {};
+ // We want to do this only on mount and unmount.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ return useMemo(() => {
+ // We want the pickers to still function even if they are not used in a context. Not sure, this will be a common
+ // usecase, but it does not seem like it will cost us anything.
+ if (!context) {
+ return context;
+ }
+
+ // We just remove the addPicker/removePicker function as that is done automatically here and picker does not need
+ // them.
+ return {
+ sync: context.sync,
+ unSync: context.unSync,
+ syncPossible: context.syncPossible,
+ synced: context.synced,
+ syncedValue: context.syncedValue,
+ };
+ }, [context]);
+}
diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.test.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.test.tsx
index b0dfaa27ede..40569283fe8 100644
--- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.test.tsx
+++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.test.tsx
@@ -1,9 +1,10 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
-import { dateTime, TimeRange } from '@grafana/data';
+import { dateTime, makeTimeRange, TimeRange } from '@grafana/data';
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
+import { TimeRangeProvider } from './TimeRangeContext';
import { TimeRangePicker } from './TimeRangePicker';
const selectors = e2eSelectors.components.TimePicker;
@@ -32,6 +33,7 @@ describe('TimePicker', () => {
expect(container.queryByLabelText(/Time range selected/i)).toBeInTheDocument();
});
+
it('switches overlay content visibility when toolbar button is clicked twice', async () => {
render(
{
await userEvent.click(openButton);
expect(overlayContent).not.toBeInTheDocument();
});
+
+ it('shows a sync button if two are rendered inside a TimeRangeProvider', async () => {
+ const onChange1 = jest.fn();
+ const onChange2 = jest.fn();
+ const value1 = makeTimeRange('2024-01-01T00:00:00Z', '2024-01-01T01:00:00Z');
+ const value2 = makeTimeRange('2024-01-01T00:00:00Z', '2024-01-01T02:00:00Z');
+
+ render(
+
+ {}}
+ onChange={onChange1}
+ value={value1}
+ onMoveBackward={() => {}}
+ onMoveForward={() => {}}
+ onZoom={() => {}}
+ />
+ {}}
+ onChange={onChange2}
+ value={value2}
+ onMoveBackward={() => {}}
+ onMoveForward={() => {}}
+ onZoom={() => {}}
+ />
+
+ );
+
+ const syncButtons = screen.getAllByLabelText('Sync times');
+ expect(syncButtons.length).toBe(2);
+ await userEvent.click(syncButtons[0]);
+ expect(onChange2).toBeCalledWith(value1);
+ const unsyncButtons = screen.getAllByLabelText('Un sync times');
+ expect(unsyncButtons.length).toBe(2);
+ });
});
diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx
index e83357196f5..6c546d380e2 100644
--- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx
+++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx
@@ -25,6 +25,7 @@ import { Tooltip } from '../Tooltip/Tooltip';
import { TimePickerContent } from './TimeRangePicker/TimePickerContent';
import { WeekStart } from './WeekStartPicker';
import { quickOptions } from './options';
+import { useTimeSync } from './utils/useTimeSync';
/** @public */
export interface TimeRangePickerProps {
@@ -32,8 +33,19 @@ export interface TimeRangePickerProps {
value: TimeRange;
timeZone?: TimeZone;
fiscalYearStartMonth?: number;
+
+ /**
+ * If you handle sync state between pickers yourself use this prop to pass the sync button component.
+ * Otherwise, a default one will show automatically if sync is possible.
+ */
timeSyncButton?: JSX.Element;
+
+ // Use to manually set the synced styles for the time range picker if you need to control the sync state yourself.
isSynced?: boolean;
+
+ // Use to manually set the initial sync state for the time range picker. It will use the current value to sync.
+ initialIsSynced?: boolean;
+
onChange: (timeRange: TimeRange) => void;
onChangeTimeZone: (timeZone: TimeZone) => void;
onChangeFiscalYearStartMonth?: (month: number) => void;
@@ -65,8 +77,6 @@ export function TimeRangePicker(props: TimeRangePickerProps) {
onError,
timeZone,
fiscalYearStartMonth,
- timeSyncButton,
- isSynced,
history,
onChangeTimeZone,
onChangeFiscalYearStartMonth,
@@ -75,10 +85,19 @@ export function TimeRangePicker(props: TimeRangePickerProps) {
isOnCanvas,
onToolbarTimePickerClick,
weekStart,
+ initialIsSynced,
} = props;
+ const { onChangeWithSync, isSynced, timeSyncButton } = useTimeSync({
+ initialIsSynced,
+ value,
+ onChangeProp: props.onChange,
+ isSyncedProp: props.isSynced,
+ timeSyncButtonProp: props.timeSyncButton,
+ });
+
const onChange = (timeRange: TimeRange) => {
- props.onChange(timeRange);
+ onChangeWithSync(timeRange);
setOpen(false);
};
diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeSyncButton.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeSyncButton.tsx
new file mode 100644
index 00000000000..96b56b83899
--- /dev/null
+++ b/packages/grafana-ui/src/components/DateTimePickers/TimeSyncButton.tsx
@@ -0,0 +1,27 @@
+import { ToolbarButton } from '../ToolbarButton';
+import { Tooltip } from '../Tooltip';
+
+interface TimeSyncButtonProps {
+ isSynced: boolean;
+ onClick: () => void;
+}
+
+export function TimeSyncButton(props: TimeSyncButtonProps) {
+ const { onClick, isSynced } = props;
+
+ const syncTimesTooltip = () => {
+ const tooltip = isSynced ? 'Unsync all views' : 'Sync all views to this time range';
+ return <>{tooltip}>;
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/packages/grafana-ui/src/components/DateTimePickers/utils/useTimeSync.tsx b/packages/grafana-ui/src/components/DateTimePickers/utils/useTimeSync.tsx
new file mode 100644
index 00000000000..3aa15a7fb98
--- /dev/null
+++ b/packages/grafana-ui/src/components/DateTimePickers/utils/useTimeSync.tsx
@@ -0,0 +1,95 @@
+import { useCallback, useEffect } from 'react';
+import { usePrevious } from 'react-use';
+
+import { TimeRange } from '@grafana/data';
+
+import { useTimeRangeContext } from '../TimeRangeContext';
+import { TimeSyncButton } from '../TimeSyncButton';
+
+/**
+ * Handle the behaviour of the time sync button and syncing the time range between pickers. It also takes care of
+ * backward compatibility with the manually controlled isSynced prop.
+ * @param options
+ */
+export function useTimeSync(options: {
+ initialIsSynced?: boolean;
+ value: TimeRange;
+ isSyncedProp?: boolean;
+ timeSyncButtonProp?: JSX.Element;
+ onChangeProp: (value: TimeRange) => void;
+}) {
+ const { value, onChangeProp, isSyncedProp, initialIsSynced, timeSyncButtonProp } = options;
+ const timeRangeContext = useTimeRangeContext(initialIsSynced && value ? value : undefined);
+
+ // Destructure to make it easier to use in hook deps.
+ const timeRangeContextSynced = timeRangeContext?.synced;
+ const timeRangeContextSyncedValue = timeRangeContext?.syncedValue;
+ const timeRangeContextSyncFunc = timeRangeContext?.sync;
+
+ // This is to determine if we should use the context to sync or not. This is for backward compatibility so that
+ // Explore with multiple panes still works as it is controlling the sync state itself for now.
+ const usingTimeRangeContext = Boolean(options.isSyncedProp === undefined && timeRangeContext);
+
+ // Create new onChange that handles propagating the value to the context if possible and synced is true.
+ const onChangeWithSync = useCallback(
+ (timeRange: TimeRange) => {
+ onChangeProp(timeRange);
+ if (usingTimeRangeContext && timeRangeContextSynced) {
+ timeRangeContextSyncFunc?.(timeRange);
+ }
+ },
+ [onChangeProp, usingTimeRangeContext, timeRangeContextSyncFunc, timeRangeContextSynced]
+ );
+
+ const prevValue = usePrevious(value);
+ const prevSyncedValue = usePrevious(timeRangeContext?.syncedValue);
+
+ // As timepicker is controlled component we need to sync the global sync value back to the parent with onChange
+ // handler whenever the outside global value changes. We do it here while checking if we are actually supposed
+ // to and making sure we don't go into a loop.
+ useEffect(() => {
+ // only react if we are actually synced
+ if (usingTimeRangeContext && timeRangeContextSynced) {
+ if (value !== prevValue && value !== timeRangeContextSyncedValue) {
+ // The value changed outside the picker. To keep the sync working we need to update the synced value.
+ timeRangeContextSyncFunc?.(value);
+ } else if (
+ timeRangeContextSyncedValue &&
+ timeRangeContextSyncedValue !== prevSyncedValue &&
+ timeRangeContextSyncedValue !== value
+ ) {
+ // The global synced value changed, so we need to update the picker value.
+ onChangeProp(timeRangeContextSyncedValue);
+ }
+ }
+ }, [
+ usingTimeRangeContext,
+ timeRangeContextSynced,
+ timeRangeContextSyncedValue,
+ timeRangeContextSyncFunc,
+ prevSyncedValue,
+ value,
+ prevValue,
+ onChangeProp,
+ ]);
+
+ // Decide if we are in synced state or not. This is complicated by the manual controlled isSynced prop that is used
+ // in Explore for now.
+ const isSynced = usingTimeRangeContext ? timeRangeContext?.synced : isSyncedProp;
+
+ // Again in Explore the sync button is controlled prop so here we also decide what kind of button to use.
+ const button = usingTimeRangeContext
+ ? timeRangeContext?.syncPossible && (
+ (timeRangeContext?.synced ? timeRangeContext.unSync() : timeRangeContext.sync(value))}
+ />
+ )
+ : timeSyncButtonProp;
+
+ return {
+ onChangeWithSync,
+ isSynced,
+ timeSyncButton: button,
+ };
+}
diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts
index e2e21fb8af3..ce90f14a8f7 100644
--- a/packages/grafana-ui/src/components/index.ts
+++ b/packages/grafana-ui/src/components/index.ts
@@ -35,6 +35,7 @@ export { UnitPicker } from './UnitPicker/UnitPicker';
export { StatsPicker } from './StatsPicker/StatsPicker';
export { RefreshPicker, defaultIntervals } from './RefreshPicker/RefreshPicker';
export { TimeRangePicker, type TimeRangePickerProps } from './DateTimePickers/TimeRangePicker';
+export { TimeRangeProvider } from './DateTimePickers/TimeRangeContext';
export { TimePickerTooltip } from './DateTimePickers/TimeRangePicker';
export { TimeRangeLabel } from './DateTimePickers/TimeRangePicker/TimeRangeLabel';
export { TimeOfDayPicker } from './DateTimePickers/TimeOfDayPicker';
diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go
index 6948a3df00d..e988e49f0ff 100644
--- a/pkg/services/featuremgmt/registry.go
+++ b/pkg/services/featuremgmt/registry.go
@@ -1522,6 +1522,12 @@ var (
Stage: FeatureStageExperimental,
Owner: grafanaSearchAndStorageSquad,
},
+ {
+ Name: "timeRangeProvider",
+ Description: "Enables time pickers sync",
+ Stage: FeatureStageExperimental,
+ Owner: grafanaFrontendPlatformSquad,
+ },
}
)
diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv
index 9fddabc0d8d..56a8d7cd894 100644
--- a/pkg/services/featuremgmt/toggles_gen.csv
+++ b/pkg/services/featuremgmt/toggles_gen.csv
@@ -202,3 +202,4 @@ rolePickerDrawer,experimental,@grafana/identity-access-team,false,false,false
unifiedStorageSearch,experimental,@grafana/search-and-storage,false,false,false
pluginsSriChecks,experimental,@grafana/plugins-platform-backend,false,false,false
unifiedStorageBigObjectsSupport,experimental,@grafana/search-and-storage,false,false,false
+timeRangeProvider,experimental,@grafana/grafana-frontend-platform,false,false,false
diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go
index 9c2d6d83de5..bd54e39391a 100644
--- a/pkg/services/featuremgmt/toggles_gen.go
+++ b/pkg/services/featuremgmt/toggles_gen.go
@@ -818,4 +818,8 @@ const (
// FlagUnifiedStorageBigObjectsSupport
// Enables to save big objects in blob storage
FlagUnifiedStorageBigObjectsSupport = "unifiedStorageBigObjectsSupport"
+
+ // FlagTimeRangeProvider
+ // Enables time pickers sync
+ FlagTimeRangeProvider = "timeRangeProvider"
)
diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json
index a4cf94cabdb..855327fb6f3 100644
--- a/pkg/services/featuremgmt/toggles_gen.json
+++ b/pkg/services/featuremgmt/toggles_gen.json
@@ -2966,6 +2966,18 @@
"allowSelfServe": true
}
},
+ {
+ "metadata": {
+ "name": "timeRangeProvider",
+ "resourceVersion": "1728565214224",
+ "creationTimestamp": "2024-10-10T13:00:14Z"
+ },
+ "spec": {
+ "description": "Enables time pickers sync",
+ "stage": "experimental",
+ "codeowner": "@grafana/grafana-frontend-platform"
+ }
+ },
{
"metadata": {
"name": "tlsMemcached",
diff --git a/public/app/AppWrapper.tsx b/public/app/AppWrapper.tsx
index a4df2c1dd76..a9de5a23ea7 100644
--- a/public/app/AppWrapper.tsx
+++ b/public/app/AppWrapper.tsx
@@ -1,5 +1,5 @@
import { Action, KBarProvider } from 'kbar';
-import { Component, ComponentType } from 'react';
+import { Component, ComponentType, Fragment } from 'react';
import { Provider } from 'react-redux';
import { Route, Routes } from 'react-router-dom-v5-compat';
@@ -10,7 +10,7 @@ import {
SidecarContext_EXPERIMENTAL,
sidecarServiceSingleton_EXPERIMENTAL,
} from '@grafana/runtime';
-import { ErrorBoundaryAlert, GlobalStyles, PortalContainer } from '@grafana/ui';
+import { ErrorBoundaryAlert, GlobalStyles, PortalContainer, TimeRangeProvider } from '@grafana/ui';
import { getAppRoutes } from 'app/routes/routes';
import { store } from 'app/store/store';
@@ -90,6 +90,8 @@ export class AppWrapper extends Component {
bodyRenderHooks,
};
+ const MaybeTimeRangeProvider = config.featureToggles.timeRangeProvider ? TimeRangeProvider : Fragment;
+
return (
@@ -100,19 +102,21 @@ export class AppWrapper extends Component {
options={{ enableHistory: true, callbacks: { onSelectAction: commandPaletteActionSelected } }}
>
-
-
-