Grafana/ui: TimeRangePicker with automatic sync across instances (#94074)

This commit is contained in:
Andrej Ocenas 2024-10-22 12:52:33 +02:00 committed by GitHub
parent 5a4b051a91
commit 3bf3290340
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
14 changed files with 423 additions and 19 deletions

View File

@ -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

View File

@ -221,4 +221,5 @@ export interface FeatureToggles {
unifiedStorageSearch?: boolean;
pluginsSriChecks?: boolean;
unifiedStorageBigObjectsSupport?: boolean;
timeRangeProvider?: boolean;
}

View File

@ -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(
<TimeRangeProvider>
<TestComponent onContextChange={onContextChange} />
</TimeRangeProvider>
);
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(
<TimeRangeProvider>
<TestComponent onContextChange={onContextChange} />
<TestComponent onContextChange={() => {}} />
</TimeRangeProvider>
);
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(
<TimeRangeProvider>
<TestComponent onContextChange={onContextChange} />
<TestComponent onContextChange={onContextChange2} />
</TimeRangeProvider>
);
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(<TestComponent onContextChange={onContextChange} />);
expect(context).toBeUndefined();
});
});
type TestComponentProps = {
onContextChange: (context?: TimeRangeContextHookValue) => void;
};
function TestComponent(props: TestComponentProps) {
const context = useTimeRangeContext();
useEffect(() => {
props.onContextChange(context);
}, [context, props]);
return null;
}

View File

@ -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<TimeRangeContextValue | undefined>(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<TimeRange>();
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 <TimeRangeContext.Provider value={contextVal}>{children}</TimeRangeContext.Provider>;
}
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]);
}

View File

@ -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(
<TimeRangePicker
@ -53,4 +55,39 @@ describe('TimePicker', () => {
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(
<TimeRangeProvider>
<TimeRangePicker
onChangeTimeZone={() => {}}
onChange={onChange1}
value={value1}
onMoveBackward={() => {}}
onMoveForward={() => {}}
onZoom={() => {}}
/>
<TimeRangePicker
onChangeTimeZone={() => {}}
onChange={onChange2}
value={value2}
onMoveBackward={() => {}}
onMoveForward={() => {}}
onZoom={() => {}}
/>
</TimeRangeProvider>
);
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);
});
});

View File

@ -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);
};

View File

@ -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 (
<Tooltip content={syncTimesTooltip} placement="bottom">
<ToolbarButton
icon="link"
variant={isSynced ? 'active' : 'canvas'}
aria-label={isSynced ? 'Un sync times' : 'Sync times'}
onClick={onClick}
/>
</Tooltip>
);
}

View File

@ -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 && (
<TimeSyncButton
isSynced={timeRangeContext?.synced}
onClick={() => (timeRangeContext?.synced ? timeRangeContext.unSync() : timeRangeContext.sync(value))}
/>
)
: timeSyncButtonProp;
return {
onChangeWithSync,
isSynced,
timeSyncButton: button,
};
}

View File

@ -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';

View File

@ -1522,6 +1522,12 @@ var (
Stage: FeatureStageExperimental,
Owner: grafanaSearchAndStorageSquad,
},
{
Name: "timeRangeProvider",
Description: "Enables time pickers sync",
Stage: FeatureStageExperimental,
Owner: grafanaFrontendPlatformSquad,
},
}
)

View File

@ -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

1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
202 unifiedStorageSearch experimental @grafana/search-and-storage false false false
203 pluginsSriChecks experimental @grafana/plugins-platform-backend false false false
204 unifiedStorageBigObjectsSupport experimental @grafana/search-and-storage false false false
205 timeRangeProvider experimental @grafana/grafana-frontend-platform false false false

View File

@ -818,4 +818,8 @@ const (
// FlagUnifiedStorageBigObjectsSupport
// Enables to save big objects in blob storage
FlagUnifiedStorageBigObjectsSupport = "unifiedStorageBigObjectsSupport"
// FlagTimeRangeProvider
// Enables time pickers sync
FlagTimeRangeProvider = "timeRangeProvider"
)

View File

@ -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",

View File

@ -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<AppWrapperProps, AppWrapperState> {
bodyRenderHooks,
};
const MaybeTimeRangeProvider = config.featureToggles.timeRangeProvider ? TimeRangeProvider : Fragment;
return (
<Provider store={store}>
<ErrorBoundaryAlert style="page">
@ -100,19 +102,21 @@ export class AppWrapper extends Component<AppWrapperProps, AppWrapperState> {
options={{ enableHistory: true, callbacks: { onSelectAction: commandPaletteActionSelected } }}
>
<GlobalStyles />
<SidecarContext_EXPERIMENTAL.Provider value={sidecarServiceSingleton_EXPERIMENTAL}>
<ExtensionRegistriesProvider registries={app.pluginExtensionsRegistries}>
<div className="grafana-app">
{config.featureToggles.appSidecar ? (
<ExperimentalSplitPaneRouterWrapper {...routerWrapperProps} />
) : (
<RouterWrapper {...routerWrapperProps} />
)}
<LiveConnectionWarning />
<PortalContainer />
</div>
</ExtensionRegistriesProvider>
</SidecarContext_EXPERIMENTAL.Provider>
<MaybeTimeRangeProvider>
<SidecarContext_EXPERIMENTAL.Provider value={sidecarServiceSingleton_EXPERIMENTAL}>
<ExtensionRegistriesProvider registries={app.pluginExtensionsRegistries}>
<div className="grafana-app">
{config.featureToggles.appSidecar ? (
<ExperimentalSplitPaneRouterWrapper {...routerWrapperProps} />
) : (
<RouterWrapper {...routerWrapperProps} />
)}
<LiveConnectionWarning />
<PortalContainer />
</div>
</ExtensionRegistriesProvider>
</SidecarContext_EXPERIMENTAL.Provider>
</MaybeTimeRangeProvider>
</KBarProvider>
</ThemeProvider>
</GrafanaContext.Provider>