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 } }} > - - -
- {config.featureToggles.appSidecar ? ( - - ) : ( - - )} - - -
-
-
+ + + +
+ {config.featureToggles.appSidecar ? ( + + ) : ( + + )} + + +
+
+
+