diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx
index 131ac5db6a7b..ba5968020a01 100644
--- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx
+++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker.tsx
@@ -122,8 +122,8 @@ export function TimeRangePicker(props: TimeRangePickerProps) {
{isOpen && (
-
-
+
+
)}
{timeSyncButton}
diff --git a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx
index 519af17d1d95..5de2391d1821 100644
--- a/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx
+++ b/packages/grafana-ui/src/components/DateTimePickers/TimeRangePicker/TimePickerContent.tsx
@@ -160,7 +160,6 @@ const NarrowScreenForm = (props: FormProps) => {
-
{showHistory && (
mapRangeToTimeOption(range, timeZone));
+
+ return ranges.map((range) => mapRangeToTimeOption(range, timeZone));
}
EmptyRecentList.displayName = 'EmptyRecentList';
diff --git a/packages/grafana-ui/src/components/Dropdown/ButtonSelect.test.tsx b/packages/grafana-ui/src/components/Dropdown/ButtonSelect.test.tsx
new file mode 100644
index 000000000000..f1c5c63a0558
--- /dev/null
+++ b/packages/grafana-ui/src/components/Dropdown/ButtonSelect.test.tsx
@@ -0,0 +1,56 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+
+import { SelectableValue } from '@grafana/data';
+
+import { ButtonSelect } from './ButtonSelect';
+
+const OPTIONS: SelectableValue[] = [
+ {
+ label: 'Hello',
+ value: 'a',
+ },
+ {
+ label: 'World',
+ value: 'b',
+ },
+];
+
+describe('ButtonSelect', () => {
+ it('initially renders the selected value with the menu closed', () => {
+ const selected = OPTIONS[0];
+ render( {}} />);
+
+ expect(screen.getByText('Hello')).toBeInTheDocument();
+ expect(screen.queryAllByRole('menuitemradio')).toHaveLength(0);
+ });
+
+ it('opens the menu when clicking the button', async () => {
+ const selected = OPTIONS[0];
+ render( {}} />);
+
+ const button = screen.getByText('Hello');
+ await userEvent.click(button);
+
+ expect(screen.queryAllByRole('menuitemradio')).toHaveLength(2);
+ });
+
+ it('closes the menu when clicking an option', async () => {
+ const selected = OPTIONS[0];
+ const onChange = jest.fn();
+ render();
+
+ const button = screen.getByText('Hello');
+ await userEvent.click(button);
+
+ const option = screen.getByText('World');
+ await userEvent.click(option);
+
+ expect(screen.queryAllByRole('menuitemradio')).toHaveLength(0);
+ expect(onChange).toHaveBeenCalledWith({
+ label: 'World',
+ value: 'b',
+ });
+ });
+});
diff --git a/public/app/core/components/TimePicker/TimePickerWithHistory.test.tsx b/public/app/core/components/TimePicker/TimePickerWithHistory.test.tsx
new file mode 100644
index 000000000000..c0f294619d5b
--- /dev/null
+++ b/public/app/core/components/TimePicker/TimePickerWithHistory.test.tsx
@@ -0,0 +1,132 @@
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import React from 'react';
+
+import { getDefaultTimeRange } from '@grafana/data';
+
+import { TimePickerWithHistory } from './TimePickerWithHistory';
+
+describe('TimePickerWithHistory', () => {
+ // In some of the tests we close and re-open the picker. When we do that we must re-find these inputs
+ // as new elements will have been mounted
+ const getFromField = () => screen.getByLabelText('Time Range from field');
+ const getToField = () => screen.getByLabelText('Time Range to field');
+ const getApplyButton = () => screen.getByRole('button', { name: 'Apply time range' });
+
+ const LOCAL_STORAGE_KEY = 'grafana.dashboard.timepicker.history';
+ const OLD_LOCAL_STORAGE = [
+ {
+ from: '2022-12-03T00:00:00.000Z',
+ to: '2022-12-03T23:59:59.000Z',
+ raw: { from: '2022-12-03T00:00:00.000Z', to: '2022-12-03T23:59:59.000Z' },
+ },
+ {
+ from: '2022-12-02T00:00:00.000Z',
+ to: '2022-12-02T23:59:59.000Z',
+ raw: { from: '2022-12-02T00:00:00.000Z', to: '2022-12-02T23:59:59.000Z' },
+ },
+ ];
+
+ const NEW_LOCAL_STORAGE = [
+ { from: '2022-12-03T00:00:00.000Z', to: '2022-12-03T23:59:59.000Z' },
+ { from: '2022-12-02T00:00:00.000Z', to: '2022-12-02T23:59:59.000Z' },
+ ];
+
+ const props = {
+ timeZone: 'utc',
+ onChange: () => {},
+ onChangeTimeZone: () => {},
+ onMoveBackward: () => {},
+ onMoveForward: () => {},
+ onZoom: () => {},
+ };
+
+ afterEach(() => window.localStorage.clear());
+
+ it('Should load with no history', async () => {
+ const timeRange = getDefaultTimeRange();
+ render();
+ await userEvent.click(screen.getByLabelText(/Time range selected/));
+
+ expect(screen.getByText(/It looks like you haven't used this time picker before/i)).toBeInTheDocument();
+ });
+
+ it('Should load with old TimeRange history', async () => {
+ window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(OLD_LOCAL_STORAGE));
+
+ const timeRange = getDefaultTimeRange();
+ render();
+ await userEvent.click(screen.getByLabelText(/Time range selected/));
+
+ expect(screen.getByText(/2022-12-03 00:00:00 to 2022-12-03 23:59:59/i)).toBeInTheDocument();
+ expect(screen.queryByText(/2022-12-02 00:00:00 to 2022-12-02 23:59:59/i)).toBeInTheDocument();
+ });
+
+ it('Should load with new TimePickerHistoryItem history', async () => {
+ window.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(NEW_LOCAL_STORAGE));
+
+ const timeRange = getDefaultTimeRange();
+ render();
+ await userEvent.click(screen.getByLabelText(/Time range selected/));
+
+ expect(screen.queryByText(/2022-12-03 00:00:00 to 2022-12-03 23:59:59/i)).toBeInTheDocument();
+ expect(screen.queryByText(/2022-12-02 00:00:00 to 2022-12-02 23:59:59/i)).toBeInTheDocument();
+ });
+
+ it('Saves changes into local storage without duplicates', async () => {
+ const timeRange = getDefaultTimeRange();
+ render();
+ await userEvent.click(screen.getByLabelText(/Time range selected/));
+
+ await clearAndType(getFromField(), '2022-12-03 00:00:00');
+ await clearAndType(getToField(), '2022-12-03 23:59:59');
+ await userEvent.click(getApplyButton());
+
+ await userEvent.click(screen.getByLabelText(/Time range selected/));
+
+ // Same range again!
+ await clearAndType(getFromField(), '2022-12-03 00:00:00');
+ await clearAndType(getToField(), '2022-12-03 23:59:59');
+ await userEvent.click(getApplyButton());
+
+ const newLsValue = JSON.parse(window.localStorage.getItem(LOCAL_STORAGE_KEY) ?? '[]');
+ expect(newLsValue).toEqual([{ from: '2022-12-03T00:00:00.000Z', to: '2022-12-03T23:59:59.000Z' }]);
+ });
+
+ it('Should show 4 most recently used time ranges', async () => {
+ const inputRanges: Array<[string, string]> = [
+ ['2022-12-10 00:00:00', '2022-12-10 23:59:59'],
+ ['2022-12-11 00:00:00', '2022-12-11 23:59:59'],
+ ['2022-12-12 00:00:00', '2022-12-12 23:59:59'],
+ ['2022-12-13 00:00:00', '2022-12-13 23:59:59'],
+ ['2022-12-14 00:00:00', '2022-12-14 23:59:59'],
+ ];
+
+ const expectedLocalStorage = [
+ { from: '2022-12-14T00:00:00.000Z', to: '2022-12-14T23:59:59.000Z' },
+ { from: '2022-12-13T00:00:00.000Z', to: '2022-12-13T23:59:59.000Z' },
+ { from: '2022-12-12T00:00:00.000Z', to: '2022-12-12T23:59:59.000Z' },
+ { from: '2022-12-11T00:00:00.000Z', to: '2022-12-11T23:59:59.000Z' },
+ ];
+
+ const timeRange = getDefaultTimeRange();
+ render();
+ await userEvent.click(screen.getByLabelText(/Time range selected/));
+
+ for (const [inputFrom, inputTo] of inputRanges) {
+ await userEvent.click(screen.getByLabelText(/Time range selected/));
+ await clearAndType(getFromField(), inputFrom);
+ await clearAndType(getToField(), inputTo);
+
+ await userEvent.click(getApplyButton());
+ }
+
+ const newLsValue = JSON.parse(window.localStorage.getItem(LOCAL_STORAGE_KEY) ?? '[]');
+ expect(newLsValue).toEqual(expectedLocalStorage);
+ });
+});
+
+async function clearAndType(field: HTMLElement, text: string) {
+ await userEvent.clear(field);
+ return await userEvent.type(field, text);
+}
diff --git a/public/app/core/components/TimePicker/TimePickerWithHistory.tsx b/public/app/core/components/TimePicker/TimePickerWithHistory.tsx
index 4cacb3dda587..4a18ada78752 100644
--- a/public/app/core/components/TimePicker/TimePickerWithHistory.tsx
+++ b/public/app/core/components/TimePicker/TimePickerWithHistory.tsx
@@ -1,6 +1,7 @@
+import { uniqBy } from 'lodash';
import React from 'react';
-import { TimeRange, isDateTime, toUtc } from '@grafana/data';
+import { TimeRange, isDateTime, rangeUtil, TimeZone } from '@grafana/data';
import { TimeRangePickerProps, TimeRangePicker } from '@grafana/ui';
import { LocalStorageValueProvider } from '../LocalStorageValueProvider';
@@ -9,14 +10,26 @@ const LOCAL_STORAGE_KEY = 'grafana.dashboard.timepicker.history';
interface Props extends Omit {}
+// Simplified object to store in local storage
+interface TimePickerHistoryItem {
+ from: string;
+ to: string;
+}
+
+// We should only be storing TimePickerHistoryItem, but in the past we also stored TimeRange
+type LSTimePickerHistoryItem = TimePickerHistoryItem | TimeRange;
+
export const TimePickerWithHistory = (props: Props) => {
return (
- storageKey={LOCAL_STORAGE_KEY} defaultValue={[]}>
- {(values, onSaveToStore) => {
+ storageKey={LOCAL_STORAGE_KEY} defaultValue={[]}>
+ {(rawValues, onSaveToStore) => {
+ const values = migrateHistory(rawValues);
+ const history = deserializeHistory(values, props.timeZone);
+
return (
{
onAppendToHistory(value, values, onSaveToStore);
props.onChange(value);
@@ -28,24 +41,37 @@ export const TimePickerWithHistory = (props: Props) => {
);
};
-function convertIfJson(history: TimeRange[]): TimeRange[] {
- return history.map((time) => {
- if (isDateTime(time.from)) {
- return time;
- }
+function deserializeHistory(values: TimePickerHistoryItem[], timeZone: TimeZone | undefined): TimeRange[] {
+ return values.map((item) => rangeUtil.convertRawToRange(item, timeZone));
+}
+
+function migrateHistory(values: LSTimePickerHistoryItem[]): TimePickerHistoryItem[] {
+ return values.map((item) => {
+ const fromValue = typeof item.from === 'string' ? item.from : item.from.toISOString();
+ const toValue = typeof item.to === 'string' ? item.to : item.to.toISOString();
return {
- from: toUtc(time.from),
- to: toUtc(time.to),
- raw: time.raw,
+ from: fromValue,
+ to: toValue,
};
});
}
-function onAppendToHistory(toAppend: TimeRange, values: TimeRange[], onSaveToStore: (values: TimeRange[]) => void) {
- if (!isAbsolute(toAppend)) {
+function onAppendToHistory(
+ newTimeRange: TimeRange,
+ values: TimePickerHistoryItem[],
+ onSaveToStore: (values: TimePickerHistoryItem[]) => void
+) {
+ if (!isAbsolute(newTimeRange)) {
return;
}
+
+ // Convert DateTime objects to strings
+ const toAppend = {
+ from: typeof newTimeRange.raw.from === 'string' ? newTimeRange.raw.from : newTimeRange.raw.from.toISOString(),
+ to: typeof newTimeRange.raw.to === 'string' ? newTimeRange.raw.to : newTimeRange.raw.to.toISOString(),
+ };
+
const toStore = limit([toAppend, ...values]);
onSaveToStore(toStore);
}
@@ -54,6 +80,6 @@ function isAbsolute(value: TimeRange): boolean {
return isDateTime(value.raw.from) || isDateTime(value.raw.to);
}
-function limit(value: TimeRange[]): TimeRange[] {
- return value.slice(0, 4);
+function limit(value: TimePickerHistoryItem[]): TimePickerHistoryItem[] {
+ return uniqBy(value, (v) => v.from + v.to).slice(0, 4);
}