Add fiscal years and search to time picker (#39073)

* Add search to time picker

* implement fiscal datemath

Co-authored-by: Dominik Prokop <dominik.prokop@grafana.com>
This commit is contained in:
Oscar Kilhed 2021-09-30 09:40:02 +02:00 committed by GitHub
parent 787e5e78dd
commit 738d5e499e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
30 changed files with 457 additions and 164 deletions

View File

@ -135,4 +135,71 @@ describe('DateMath', () => {
expect(date!.valueOf()).toEqual(dateTime([2014, 1, 3]).valueOf()); expect(date!.valueOf()).toEqual(dateTime([2014, 1, 3]).valueOf());
}); });
}); });
describe('Round to fiscal start/end', () => {
it('Should round to start of fiscal year when datetime is the same year as the start of the fiscal year', () => {
let date = dateMath.roundToFiscal(1, dateTime([2021, 3, 5]), 'y', false);
let expected = dateTime([2021, 1, 1]);
expect(date!.valueOf()).toEqual(expected.valueOf());
});
it('Should round to start of fiscal year when datetime is the next year from the start of the fiscal year', () => {
let date = dateMath.roundToFiscal(1, dateTime([2022, 0, 2]), 'y', false);
let expected = dateTime([2021, 1, 1]);
expect(date!.valueOf()).toEqual(expected.valueOf());
});
it('Should round to start of fiscal year when datetime is on a leap day', () => {
let date = dateMath.roundToFiscal(1, dateTime([2020, 1, 29]), 'y', false);
let expected = dateTime([2020, 1, 1]);
expect(date!.valueOf()).toEqual(expected.valueOf());
});
it('Should round to end of fiscal year when datetime is the same year as the start of the fiscal year', () => {
let date = dateMath.roundToFiscal(1, dateTime([2021, 5, 2]), 'y', true);
let expected = dateTime([2022, 0, 1]).endOf('M');
expect(date!.valueOf()).toEqual(expected.valueOf());
});
it('Should round to end of fiscal year when datetime is the next year from the start of the fiscal year', () => {
let date = dateMath.roundToFiscal(1, dateTime([2022, 0, 1]), 'y', true);
let expected = dateTime([2022, 0, 1]).endOf('M');
expect(date!.valueOf()).toEqual(expected.valueOf());
});
it('Should round to end of fiscal year when datetime is on a leap day', () => {
let date = dateMath.roundToFiscal(1, dateTime([2020, 1, 29]), 'y', true);
let expected = dateTime([2021, 0, 1]).endOf('M');
expect(date!.valueOf()).toEqual(expected.valueOf());
});
//fq1 = 2021-02-01 - 2021-04-30
//fq2 = 2021-05-01 - 2021-07-31
//fq4 = 2021-08-01 - 2021-10-31
//fq5 = 2021-11-01 - 2022-01-31
it('Should round to start of q2 when one month into q2', () => {
let date = dateMath.roundToFiscal(1, dateTime([2021, 6, 1]), 'Q', false);
let expected = dateTime([2021, 4, 1]);
expect(date!.valueOf()).toEqual(expected.valueOf());
});
it('Should round to start of q4 when datetime is in next year from fiscal year start', () => {
let date = dateMath.roundToFiscal(1, dateTime([2022, 0, 1]), 'Q', false);
let expected = dateTime([2021, 10, 1]);
expect(date!.valueOf()).toEqual(expected.valueOf());
});
it('Should round to end of q2 when one month into q2', () => {
let date = dateMath.roundToFiscal(1, dateTime([2021, 6, 1]), 'Q', true);
let expected = dateTime([2021, 6, 1]).endOf('M');
expect(date!.valueOf()).toEqual(expected.valueOf());
});
it('Should round to end of q4 when datetime is in next year from fiscal year start', () => {
let date = dateMath.roundToFiscal(1, dateTime([2022, 0, 1]), 'Q', true);
let expected = dateTime([2022, 0, 31]).endOf('M');
expect(date!.valueOf()).toEqual(expected.valueOf());
});
});
}); });

View File

@ -2,7 +2,7 @@ import { includes, isDate } from 'lodash';
import { DateTime, dateTime, dateTimeForTimeZone, ISO_8601, isDateTime, DurationUnit } from './moment_wrapper'; import { DateTime, dateTime, dateTimeForTimeZone, ISO_8601, isDateTime, DurationUnit } from './moment_wrapper';
import { TimeZone } from '../types/index'; import { TimeZone } from '../types/index';
const units: DurationUnit[] = ['y', 'M', 'w', 'd', 'h', 'm', 's']; const units: DurationUnit[] = ['y', 'M', 'w', 'd', 'h', 'm', 's', 'Q'];
export function isMathString(text: string | DateTime | Date): boolean { export function isMathString(text: string | DateTime | Date): boolean {
if (!text) { if (!text) {
@ -26,7 +26,8 @@ export function isMathString(text: string | DateTime | Date): boolean {
export function parse( export function parse(
text?: string | DateTime | Date | null, text?: string | DateTime | Date | null,
roundUp?: boolean, roundUp?: boolean,
timezone?: TimeZone timezone?: TimeZone,
fiscalYearStartMonth?: number
): DateTime | undefined { ): DateTime | undefined {
if (!text) { if (!text) {
return undefined; return undefined;
@ -67,7 +68,7 @@ export function parse(
return time; return time;
} }
return parseDateMath(mathString, time, roundUp); return parseDateMath(mathString, time, roundUp, fiscalYearStartMonth);
} }
} }
@ -96,7 +97,12 @@ export function isValid(text: string | DateTime): boolean {
* @param roundUp If true it will round the time to endOf time unit, otherwise to startOf time unit. * @param roundUp If true it will round the time to endOf time unit, otherwise to startOf time unit.
*/ */
// TODO: Had to revert Andrejs `time: moment.Moment` to `time: any` // TODO: Had to revert Andrejs `time: moment.Moment` to `time: any`
export function parseDateMath(mathString: string, time: any, roundUp?: boolean): DateTime | undefined { export function parseDateMath(
mathString: string,
time: any,
roundUp?: boolean,
fiscalYearStartMonth = 0
): DateTime | undefined {
const strippedMathString = mathString.replace(/\s/g, ''); const strippedMathString = mathString.replace(/\s/g, '');
const dateTime = time; const dateTime = time;
let i = 0; let i = 0;
@ -107,6 +113,7 @@ export function parseDateMath(mathString: string, time: any, roundUp?: boolean):
let type; let type;
let num; let num;
let unit; let unit;
let isFiscal = false;
if (c === '/') { if (c === '/') {
type = 0; type = 0;
@ -121,7 +128,7 @@ export function parseDateMath(mathString: string, time: any, roundUp?: boolean):
if (isNaN(parseInt(strippedMathString.charAt(i), 10))) { if (isNaN(parseInt(strippedMathString.charAt(i), 10))) {
num = 1; num = 1;
} else if (strippedMathString.length === 2) { } else if (strippedMathString.length === 2) {
num = strippedMathString.charAt(i); num = parseInt(strippedMathString.charAt(i), 10);
} else { } else {
const numFrom = i; const numFrom = i;
while (!isNaN(parseInt(strippedMathString.charAt(i), 10))) { while (!isNaN(parseInt(strippedMathString.charAt(i), 10))) {
@ -141,14 +148,27 @@ export function parseDateMath(mathString: string, time: any, roundUp?: boolean):
} }
unit = strippedMathString.charAt(i++); unit = strippedMathString.charAt(i++);
if (unit === 'f') {
unit = strippedMathString.charAt(i++);
isFiscal = true;
}
if (!includes(units, unit)) { if (!includes(units, unit)) {
return undefined; return undefined;
} else { } else {
if (type === 0) { if (type === 0) {
if (roundUp) { if (roundUp) {
dateTime.endOf(unit); if (isFiscal) {
roundToFiscal(fiscalYearStartMonth, dateTime, unit, roundUp);
} else {
dateTime.endOf(unit);
}
} else { } else {
dateTime.startOf(unit); if (isFiscal) {
roundToFiscal(fiscalYearStartMonth, dateTime, unit, roundUp);
} else {
dateTime.startOf(unit);
}
} }
} else if (type === 1) { } else if (type === 1) {
dateTime.add(num, unit); dateTime.add(num, unit);
@ -159,3 +179,24 @@ export function parseDateMath(mathString: string, time: any, roundUp?: boolean):
} }
return dateTime; return dateTime;
} }
export function roundToFiscal(fyStartMonth: number, dateTime: any, unit: string, roundUp: boolean | undefined) {
switch (unit) {
case 'y':
if (roundUp) {
roundToFiscal(fyStartMonth, dateTime, unit, false).add(11, 'M').endOf('M');
} else {
dateTime.subtract((dateTime.month() - fyStartMonth + 12) % 12, 'M').startOf('M');
}
return dateTime;
case 'Q':
if (roundUp) {
roundToFiscal(fyStartMonth, dateTime, unit, false).add(2, 'M').endOf('M');
} else {
dateTime.subtract((dateTime.month() - fyStartMonth + 3) % 3, 'M').startOf('M');
}
return dateTime;
default:
return undefined;
}
}

View File

@ -19,6 +19,7 @@ export interface DateTimeOptionsWhenParsing extends DateTimeOptions {
* the returned DateTime value will be 06:00:00. * the returned DateTime value will be 06:00:00.
*/ */
roundUp?: boolean; roundUp?: boolean;
fiscalYearStartMonth?: number;
} }
type DateTimeParser<T extends DateTimeOptions = DateTimeOptions> = (value: DateTimeInput, options?: T) => DateTime; type DateTimeParser<T extends DateTimeOptions = DateTimeOptions> = (value: DateTimeInput, options?: T) => DateTime;
@ -56,7 +57,7 @@ const parseString = (value: string, options?: DateTimeOptionsWhenParsing): DateT
return moment() as DateTime; return moment() as DateTime;
} }
const parsed = parse(value, options?.roundUp, options?.timeZone); const parsed = parse(value, options?.roundUp, options?.timeZone, options?.fiscalYearStartMonth);
return parsed || (moment() as DateTime); return parsed || (moment() as DateTime);
} }

View File

@ -40,7 +40,9 @@ const rangeOptions: TimeOption[] = [
}, },
{ from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week' }, { from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week' },
{ from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month' }, { from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month' },
{ from: 'now-1Q/fQ', to: 'now-1Q/fQ', display: 'Previous fiscal quarter' },
{ from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year' }, { from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year' },
{ from: 'now-1y/fy', to: 'now-1y/fy', display: 'Previous fiscal year' },
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes' }, { from: 'now-5m', to: 'now', display: 'Last 5 minutes' },
{ from: 'now-15m', to: 'now', display: 'Last 15 minutes' }, { from: 'now-15m', to: 'now', display: 'Last 15 minutes' },
@ -58,6 +60,10 @@ const rangeOptions: TimeOption[] = [
{ from: 'now-1y', to: 'now', display: 'Last 1 year' }, { from: 'now-1y', to: 'now', display: 'Last 1 year' },
{ from: 'now-2y', to: 'now', display: 'Last 2 years' }, { from: 'now-2y', to: 'now', display: 'Last 2 years' },
{ from: 'now-5y', to: 'now', display: 'Last 5 years' }, { from: 'now-5y', to: 'now', display: 'Last 5 years' },
{ from: 'now/fQ', to: 'now', display: 'This fiscal quarter so far' },
{ from: 'now/fQ', to: 'now/fQ', display: 'This fiscal quarter' },
{ from: 'now/fy', to: 'now', display: 'This fiscal year so far' },
{ from: 'now/fy', to: 'now/fy', display: 'This fiscal year' },
]; ];
const hiddenRangeOptions: TimeOption[] = [ const hiddenRangeOptions: TimeOption[] = [
@ -192,9 +198,9 @@ export const describeTimeRangeAbbreviation = (range: TimeRange, timeZone?: TimeZ
return parsed ? timeZoneAbbrevation(parsed, { timeZone }) : ''; return parsed ? timeZoneAbbrevation(parsed, { timeZone }) : '';
}; };
export const convertRawToRange = (raw: RawTimeRange, timeZone?: TimeZone): TimeRange => { export const convertRawToRange = (raw: RawTimeRange, timeZone?: TimeZone, fiscalYearStartMonth?: number): TimeRange => {
const from = dateTimeParse(raw.from, { roundUp: false, timeZone }); const from = dateTimeParse(raw.from, { roundUp: false, timeZone, fiscalYearStartMonth });
const to = dateTimeParse(raw.to, { roundUp: true, timeZone }); const to = dateTimeParse(raw.to, { roundUp: true, timeZone, fiscalYearStartMonth });
if (dateMath.isMathString(raw.from) || dateMath.isMathString(raw.to)) { if (dateMath.isMathString(raw.from) || dateMath.isMathString(raw.to)) {
return { from, to, raw }; return { from, to, raw };
@ -210,6 +216,15 @@ function isRelativeTime(v: DateTime | string) {
return false; return false;
} }
export function isFiscal(timeRange: TimeRange) {
if (typeof timeRange.raw.from === 'string' && timeRange.raw.from.indexOf('f') > 0) {
return true;
} else if (typeof timeRange.raw.to === 'string' && timeRange.raw.to.indexOf('f') > 0) {
return true;
}
return false;
}
export function isRelativeTimeRange(raw: RawTimeRange): boolean { export function isRelativeTimeRange(raw: RawTimeRange): boolean {
return isRelativeTime(raw.from) || isRelativeTime(raw.to); return isRelativeTime(raw.from) || isRelativeTime(raw.to);
} }

View File

@ -11,10 +11,10 @@ export const setTimeRange = ({ from, to, zone }: TimeRangeConfig) => {
e2e.components.TimePicker.openButton().click(); e2e.components.TimePicker.openButton().click();
if (zone) { if (zone) {
e2e().contains('button', 'Change time zone').click(); e2e().contains('button', 'Change time settings').click();
selectOption({ selectOption({
clickToOpen: false, clickToOpen: true,
container: e2e.components.TimeZonePicker.container(), container: e2e.components.TimeZonePicker.container(),
optionText: zone, optionText: zone,
}); });

View File

@ -5,7 +5,7 @@ import { useStyles2 } from '../../../themes';
import { Button } from '../../Button'; import { Button } from '../../Button';
import { ClickOutsideWrapper } from '../../ClickOutsideWrapper/ClickOutsideWrapper'; import { ClickOutsideWrapper } from '../../ClickOutsideWrapper/ClickOutsideWrapper';
import { TimeRangeList } from '../TimeRangePicker/TimeRangeList'; import { TimeRangeList } from '../TimeRangePicker/TimeRangeList';
import { quickOptions } from '../rangeOptions'; import { quickOptions } from '../options';
import CustomScrollbar from '../../CustomScrollbar/CustomScrollbar'; import CustomScrollbar from '../../CustomScrollbar/CustomScrollbar';
import { TimePickerTitle } from '../TimeRangePicker/TimePickerTitle'; import { TimePickerTitle } from '../TimeRangePicker/TimePickerTitle';
import { import {

View File

@ -7,7 +7,7 @@ import { Icon } from '../Icon/Icon';
import { getInputStyles } from '../Input/Input'; import { getInputStyles } from '../Input/Input';
import { TimePickerButtonLabel } from './TimeRangePicker'; import { TimePickerButtonLabel } from './TimeRangePicker';
import { TimePickerContent } from './TimeRangePicker/TimePickerContent'; import { TimePickerContent } from './TimeRangePicker/TimePickerContent';
import { otherOptions, quickOptions } from './rangeOptions'; import { quickOptions } from './options';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { stylesFactory } from '../../themes'; import { stylesFactory } from '../../themes';
@ -100,7 +100,6 @@ export const TimeRangeInput: FC<TimeRangeInputProps> = ({
timeZone={timeZone} timeZone={timeZone}
value={isValidTimeRange(value) ? (value as TimeRange) : getDefaultTimeRange()} value={isValidTimeRange(value) ? (value as TimeRange) : getDefaultTimeRange()}
onChange={onRangeChange} onChange={onRangeChange}
otherOptions={otherOptions}
quickOptions={quickOptions} quickOptions={quickOptions}
onChangeTimeZone={onChangeTimeZone} onChangeTimeZone={onChangeTimeZone}
className={styles.content} className={styles.content}

View File

@ -23,7 +23,7 @@ import {
dateMath, dateMath,
} from '@grafana/data'; } from '@grafana/data';
import { Themeable } from '../../types'; import { Themeable } from '../../types';
import { otherOptions, quickOptions } from './rangeOptions'; import { quickOptions } from './options';
import { ButtonGroup, ToolbarButton } from '../Button'; import { ButtonGroup, ToolbarButton } from '../Button';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
@ -32,10 +32,12 @@ export interface TimeRangePickerProps extends Themeable {
hideText?: boolean; hideText?: boolean;
value: TimeRange; value: TimeRange;
timeZone?: TimeZone; timeZone?: TimeZone;
fiscalYearStartMonth?: number;
timeSyncButton?: JSX.Element; timeSyncButton?: JSX.Element;
isSynced?: boolean; isSynced?: boolean;
onChange: (timeRange: TimeRange) => void; onChange: (timeRange: TimeRange) => void;
onChangeTimeZone: (timeZone: TimeZone) => void; onChangeTimeZone: (timeZone: TimeZone) => void;
onChangeFiscalYearStartMonth?: (month: number) => void;
onMoveBackward: () => void; onMoveBackward: () => void;
onMoveForward: () => void; onMoveForward: () => void;
onZoom: () => void; onZoom: () => void;
@ -75,11 +77,13 @@ export class UnthemedTimeRangePicker extends PureComponent<TimeRangePickerProps,
onMoveForward, onMoveForward,
onZoom, onZoom,
timeZone, timeZone,
fiscalYearStartMonth,
timeSyncButton, timeSyncButton,
isSynced, isSynced,
theme, theme,
history, history,
onChangeTimeZone, onChangeTimeZone,
onChangeFiscalYearStartMonth,
hideQuickRanges, hideQuickRanges,
} = this.props; } = this.props;
@ -117,13 +121,14 @@ export class UnthemedTimeRangePicker extends PureComponent<TimeRangePickerProps,
<ClickOutsideWrapper includeButtonPress={false} onClick={this.onClose}> <ClickOutsideWrapper includeButtonPress={false} onClick={this.onClose}>
<TimePickerContent <TimePickerContent
timeZone={timeZone} timeZone={timeZone}
fiscalYearStartMonth={fiscalYearStartMonth}
value={value} value={value}
onChange={this.onChange} onChange={this.onChange}
otherOptions={otherOptions}
quickOptions={quickOptions} quickOptions={quickOptions}
history={history} history={history}
showHistory showHistory
onChangeTimeZone={onChangeTimeZone} onChangeTimeZone={onChangeTimeZone}
onChangeFiscalYearStartMonth={onChangeFiscalYearStartMonth}
hideQuickRanges={hideQuickRanges} hideQuickRanges={hideQuickRanges}
/> />
</ClickOutsideWrapper> </ClickOutsideWrapper>

View File

@ -38,14 +38,12 @@ describe('TimePickerContent', () => {
it('renders with relative picker', () => { it('renders with relative picker', () => {
renderComponent({ value: absoluteValue }); renderComponent({ value: absoluteValue });
expect(screen.queryByText(/relative time ranges/i)).toBeInTheDocument(); expect(screen.queryByText(/Last 5 minutes/i)).toBeInTheDocument();
expect(screen.queryByText(/other quick ranges/i)).toBeInTheDocument();
}); });
it('renders without relative picker', () => { it('renders without relative picker', () => {
renderComponent({ value: absoluteValue, hideQuickRanges: true }); renderComponent({ value: absoluteValue, hideQuickRanges: true });
expect(screen.queryByText(/relative time ranges/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Last 5 minutes/i)).not.toBeInTheDocument();
expect(screen.queryByText(/other quick ranges/i)).not.toBeInTheDocument();
}); });
it('renders with timezone picker', () => { it('renders with timezone picker', () => {
@ -86,14 +84,12 @@ describe('TimePickerContent', () => {
it('renders with relative picker', () => { it('renders with relative picker', () => {
renderComponent({ value: absoluteValue, isFullscreen: false }); renderComponent({ value: absoluteValue, isFullscreen: false });
expect(screen.queryByText(/relative time ranges/i)).toBeInTheDocument(); expect(screen.queryByText(/Last 5 minutes/i)).toBeInTheDocument();
expect(screen.queryByText(/other quick ranges/i)).toBeInTheDocument();
}); });
it('renders without relative picker', () => { it('renders without relative picker', () => {
renderComponent({ value: absoluteValue, isFullscreen: false, hideQuickRanges: true }); renderComponent({ value: absoluteValue, isFullscreen: false, hideQuickRanges: true });
expect(screen.queryByText(/relative time ranges/i)).not.toBeInTheDocument(); expect(screen.queryByText(/Last 5 minutes/i)).not.toBeInTheDocument();
expect(screen.queryByText(/other quick ranges/i)).not.toBeInTheDocument();
}); });
it('renders with absolute picker when absolute value and quick ranges are visible', () => { it('renders with absolute picker when absolute value and quick ranges are visible', () => {
@ -139,6 +135,10 @@ function renderComponent({
<TimePickerContentWithScreenSize <TimePickerContentWithScreenSize
onChangeTimeZone={noop} onChangeTimeZone={noop}
onChange={noop} onChange={noop}
quickOptions={[
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes' },
{ from: 'now-15m', to: 'now', display: 'Last 15 minutes' },
]}
timeZone="utc" timeZone="utc"
value={value} value={value}
isFullscreen={isFullscreen} isFullscreen={isFullscreen}

View File

@ -12,6 +12,7 @@ import { TimeRangeList } from './TimeRangeList';
import { TimePickerFooter } from './TimePickerFooter'; import { TimePickerFooter } from './TimePickerFooter';
import { getFocusStyles } from '../../../themes/mixins'; import { getFocusStyles } from '../../../themes/mixins';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { FilterInput } from '../..';
const getStyles = stylesFactory((theme: GrafanaTheme2, isReversed, hideQuickRanges, isContainerTall) => { const getStyles = stylesFactory((theme: GrafanaTheme2, isReversed, hideQuickRanges, isContainerTall) => {
return { return {
@ -46,11 +47,15 @@ const getStyles = stylesFactory((theme: GrafanaTheme2, isReversed, hideQuickRang
rightSide: css` rightSide: css`
width: 40% !important; width: 40% !important;
border-right: ${isReversed ? `1px solid ${theme.colors.border.weak}` : 'none'}; border-right: ${isReversed ? `1px solid ${theme.colors.border.weak}` : 'none'};
display: flex;
flex-direction: column;
@media only screen and (max-width: ${theme.breakpoints.values.lg}px) { @media only screen and (max-width: ${theme.breakpoints.values.lg}px) {
width: 100% !important; width: 100% !important;
} }
`, `,
timeRangeFilter: css`
padding: ${theme.spacing(1)};
`,
spacing: css` spacing: css`
margin-top: 16px; margin-top: 16px;
`, `,
@ -127,9 +132,10 @@ interface Props {
value: TimeRange; value: TimeRange;
onChange: (timeRange: TimeRange) => void; onChange: (timeRange: TimeRange) => void;
onChangeTimeZone: (timeZone: TimeZone) => void; onChangeTimeZone: (timeZone: TimeZone) => void;
onChangeFiscalYearStartMonth?: (month: number) => void;
timeZone?: TimeZone; timeZone?: TimeZone;
fiscalYearStartMonth?: number;
quickOptions?: TimeOption[]; quickOptions?: TimeOption[];
otherOptions?: TimeOption[];
history?: TimeRange[]; history?: TimeRange[];
showHistory?: boolean; showHistory?: boolean;
className?: string; className?: string;
@ -150,11 +156,11 @@ interface FormProps extends Omit<Props, 'history'> {
export const TimePickerContentWithScreenSize: React.FC<PropsWithScreenSize> = (props) => { export const TimePickerContentWithScreenSize: React.FC<PropsWithScreenSize> = (props) => {
const { const {
quickOptions = [], quickOptions = [],
otherOptions = [],
isReversed, isReversed,
isFullscreen, isFullscreen,
hideQuickRanges, hideQuickRanges,
timeZone, timeZone,
fiscalYearStartMonth,
value, value,
onChange, onChange,
history, history,
@ -162,6 +168,7 @@ export const TimePickerContentWithScreenSize: React.FC<PropsWithScreenSize> = (p
className, className,
hideTimeZone, hideTimeZone,
onChangeTimeZone, onChangeTimeZone,
onChangeFiscalYearStartMonth,
} = props; } = props;
const isHistoryEmpty = !history?.length; const isHistoryEmpty = !history?.length;
const isContainerTall = const isContainerTall =
@ -169,7 +176,10 @@ export const TimePickerContentWithScreenSize: React.FC<PropsWithScreenSize> = (p
const theme = useTheme2(); const theme = useTheme2();
const styles = getStyles(theme, isReversed, hideQuickRanges, isContainerTall); const styles = getStyles(theme, isReversed, hideQuickRanges, isContainerTall);
const historyOptions = mapToHistoryOptions(history, timeZone); const historyOptions = mapToHistoryOptions(history, timeZone);
const timeOption = useTimeOption(value.raw, otherOptions, quickOptions); const timeOption = useTimeOption(value.raw, quickOptions);
const [searchTerm, setSearchQuery] = useState('');
const filteredQuickOptions = quickOptions.filter((o) => o.display.toLowerCase().includes(searchTerm.toLowerCase()));
const onChangeTimeOption = (timeOption: TimeOption) => { const onChangeTimeOption = (timeOption: TimeOption) => {
return onChange(mapOptionToTimeRange(timeOption)); return onChange(mapOptionToTimeRange(timeOption));
@ -179,26 +189,23 @@ export const TimePickerContentWithScreenSize: React.FC<PropsWithScreenSize> = (p
<div id="TimePickerContent" className={cx(styles.container, className)}> <div id="TimePickerContent" className={cx(styles.container, className)}>
<div className={styles.body}> <div className={styles.body}>
{(!isFullscreen || !hideQuickRanges) && ( {(!isFullscreen || !hideQuickRanges) && (
<CustomScrollbar className={styles.rightSide}> <div className={styles.rightSide}>
{!isFullscreen && <NarrowScreenForm {...props} historyOptions={historyOptions} />} <div className={styles.timeRangeFilter}>
{!hideQuickRanges && ( <FilterInput
<> width={0}
<TimeRangeList autoFocus={true}
title="Relative time ranges" value={searchTerm}
options={quickOptions} onChange={setSearchQuery}
onChange={onChangeTimeOption} placeholder={'Search quick ranges'}
value={timeOption} />
/> </div>
<div className={styles.spacing} /> <CustomScrollbar>
<TimeRangeList {!isFullscreen && <NarrowScreenForm {...props} historyOptions={historyOptions} />}
title="Other quick ranges" {!hideQuickRanges && (
options={otherOptions} <TimeRangeList options={filteredQuickOptions} onChange={onChangeTimeOption} value={timeOption} />
onChange={onChangeTimeOption} )}
value={timeOption} </CustomScrollbar>
/> </div>
</>
)}
</CustomScrollbar>
)} )}
{isFullscreen && ( {isFullscreen && (
<div className={styles.leftSide}> <div className={styles.leftSide}>
@ -206,7 +213,14 @@ export const TimePickerContentWithScreenSize: React.FC<PropsWithScreenSize> = (p
</div> </div>
)} )}
</div> </div>
{!hideTimeZone && isFullscreen && <TimePickerFooter timeZone={timeZone} onChangeTimeZone={onChangeTimeZone} />} {!hideTimeZone && isFullscreen && (
<TimePickerFooter
timeZone={timeZone}
fiscalYearStartMonth={fiscalYearStartMonth}
onChangeTimeZone={onChangeTimeZone}
onChangeFiscalYearStartMonth={onChangeFiscalYearStartMonth}
/>
)}
</div> </div>
); );
}; };
@ -268,7 +282,7 @@ const NarrowScreenForm: React.FC<FormProps> = (props) => {
}; };
const FullScreenForm: React.FC<FormProps> = (props) => { const FullScreenForm: React.FC<FormProps> = (props) => {
const { onChange } = props; const { onChange, value, timeZone, fiscalYearStartMonth, isReversed, historyOptions } = props;
const theme = useTheme2(); const theme = useTheme2();
const styles = getFullScreenStyles(theme, props.hideQuickRanges); const styles = getFullScreenStyles(theme, props.hideQuickRanges);
const onChangeTimeOption = (timeOption: TimeOption) => { const onChangeTimeOption = (timeOption: TimeOption) => {
@ -282,18 +296,19 @@ const FullScreenForm: React.FC<FormProps> = (props) => {
<TimePickerTitle>Absolute time range</TimePickerTitle> <TimePickerTitle>Absolute time range</TimePickerTitle>
</div> </div>
<TimeRangeForm <TimeRangeForm
value={props.value} value={value}
timeZone={props.timeZone} timeZone={timeZone}
onApply={props.onChange} fiscalYearStartMonth={fiscalYearStartMonth}
onApply={onChange}
isFullscreen={true} isFullscreen={true}
isReversed={props.isReversed} isReversed={isReversed}
/> />
</div> </div>
{props.showHistory && ( {props.showHistory && (
<div className={styles.recent}> <div className={styles.recent}>
<TimeRangeList <TimeRangeList
title="Recently used absolute ranges" title="Recently used absolute ranges"
options={props.historyOptions || []} options={historyOptions || []}
onChange={onChangeTimeOption} onChange={onChangeTimeOption}
placeholderEmpty={<EmptyRecentList />} placeholderEmpty={<EmptyRecentList />}
/> />
@ -338,23 +353,13 @@ function mapToHistoryOptions(ranges?: TimeRange[], timeZone?: TimeZone): TimeOpt
EmptyRecentList.displayName = 'EmptyRecentList'; EmptyRecentList.displayName = 'EmptyRecentList';
const useTimeOption = ( const useTimeOption = (raw: RawTimeRange, quickOptions: TimeOption[]): TimeOption | undefined => {
raw: RawTimeRange,
quickOptions: TimeOption[],
otherOptions: TimeOption[]
): TimeOption | undefined => {
return useMemo(() => { return useMemo(() => {
if (!rangeUtil.isRelativeTimeRange(raw)) { if (!rangeUtil.isRelativeTimeRange(raw)) {
return; return;
} }
const quickOption = quickOptions.find((option) => { return quickOptions.find((option) => {
return option.from === raw.from && option.to === raw.to; return option.from === raw.from && option.to === raw.to;
}); });
if (quickOption) { }, [raw, quickOptions]);
return quickOption;
}
return otherOptions.find((option) => {
return option.from === raw.from && option.to === raw.to;
});
}, [raw, otherOptions, quickOptions]);
}; };

View File

@ -9,18 +9,29 @@ import { Button } from '../../Button';
import { TimeZonePicker } from '../TimeZonePicker'; import { TimeZonePicker } from '../TimeZonePicker';
import { isString } from 'lodash'; import { isString } from 'lodash';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { Field, RadioButtonGroup, Select } from '../..';
import { monthOptions } from '../options';
interface Props { interface Props {
timeZone?: TimeZone; timeZone?: TimeZone;
fiscalYearStartMonth?: number;
timestamp?: number; timestamp?: number;
onChangeTimeZone: (timeZone: TimeZone) => void; onChangeTimeZone: (timeZone: TimeZone) => void;
onChangeFiscalYearStartMonth?: (month: number) => void;
} }
export const TimePickerFooter: FC<Props> = (props) => { export const TimePickerFooter: FC<Props> = (props) => {
const { timeZone, timestamp = Date.now(), onChangeTimeZone } = props; const {
timeZone,
fiscalYearStartMonth,
timestamp = Date.now(),
onChangeTimeZone,
onChangeFiscalYearStartMonth,
} = props;
const [isEditing, setEditing] = useState(false); const [isEditing, setEditing] = useState(false);
const [editMode, setEditMode] = useState('tz');
const onToggleChangeTz = useCallback( const onToggleChangeTimeSettings = useCallback(
(event?: React.MouseEvent) => { (event?: React.MouseEvent) => {
if (event) { if (event) {
event.stopPropagation(); event.stopPropagation();
@ -43,42 +54,72 @@ export const TimePickerFooter: FC<Props> = (props) => {
return null; return null;
} }
if (isEditing) {
return (
<div className={cx(style.container, style.editContainer)}>
<section aria-label={selectors.components.TimeZonePicker.container} className={style.timeZoneContainer}>
<TimeZonePicker
includeInternal={true}
onChange={(timeZone) => {
onToggleChangeTz();
if (isString(timeZone)) {
onChangeTimeZone(timeZone);
}
}}
autoFocus={true}
onBlur={onToggleChangeTz}
/>
</section>
</div>
);
}
return ( return (
<section aria-label="Time zone selection" className={style.container}> <div>
<div className={style.timeZoneContainer}> <section aria-label="Time zone selection" className={style.container}>
<div className={style.timeZone}> <div className={style.timeZoneContainer}>
<TimeZoneTitle title={info.name} /> <div className={style.timeZone}>
<div className={style.spacer} /> <TimeZoneTitle title={info.name} />
<TimeZoneDescription info={info} /> <div className={style.spacer} />
<TimeZoneDescription info={info} />
</div>
<TimeZoneOffset timeZone={timeZone} timestamp={timestamp} />
</div> </div>
<TimeZoneOffset timeZone={timeZone} timestamp={timestamp} /> <div className={style.spacer} />
</div> <Button variant="secondary" onClick={onToggleChangeTimeSettings} size="sm">
<div className={style.spacer} /> Change time settings
<Button variant="secondary" onClick={onToggleChangeTz} size="sm"> </Button>
Change time zone </section>
</Button> {isEditing ? (
</section> <div className={style.editContainer}>
<div>
<RadioButtonGroup
value={editMode}
options={[
{ label: 'Time Zone', value: 'tz' },
{ label: 'Fiscal year', value: 'fy' },
]}
onChange={setEditMode}
></RadioButtonGroup>
</div>
{editMode === 'tz' ? (
<section
aria-label={selectors.components.TimeZonePicker.container}
className={cx(style.timeZoneContainer, style.timeSettingContainer)}
>
<TimeZonePicker
includeInternal={true}
onChange={(timeZone) => {
onToggleChangeTimeSettings();
if (isString(timeZone)) {
onChangeTimeZone(timeZone);
}
}}
onBlur={onToggleChangeTimeSettings}
/>
</section>
) : (
<section
aria-label={selectors.components.TimeZonePicker.container}
className={cx(style.timeZoneContainer, style.timeSettingContainer)}
>
<Field className={style.fiscalYearField} label={'Fiscal year start month'}>
<Select
value={fiscalYearStartMonth}
options={monthOptions}
onChange={(value) => {
if (onChangeFiscalYearStartMonth) {
onChangeFiscalYearStartMonth(value.value ?? 0);
}
}}
/>
</Field>
</section>
)}
</div>
) : null}
</div>
); );
}; };
@ -93,11 +134,21 @@ const getStyle = stylesFactory((theme: GrafanaTheme2) => {
align-items: center; align-items: center;
`, `,
editContainer: css` editContainer: css`
border-top: 1px solid ${theme.colors.border.weak};
padding: 11px;
justify-content: space-between;
align-items: center;
padding: 7px; padding: 7px;
`, `,
spacer: css` spacer: css`
margin-left: 7px; margin-left: 7px;
`, `,
timeSettingContainer: css`
padding-top: ${theme.spacing(1)};
`,
fiscalYearField: css`
margin-bottom: 0px;
`,
timeZoneContainer: css` timeZoneContainer: css`
display: flex; display: flex;
flex-direction: row; flex-direction: row;

View File

@ -1,8 +1,10 @@
import { css } from '@emotion/css';
import { import {
dateMath, dateMath,
DateTime, DateTime,
dateTimeFormat, dateTimeFormat,
dateTimeParse, dateTimeParse,
GrafanaTheme2,
isDateTime, isDateTime,
rangeUtil, rangeUtil,
RawTimeRange, RawTimeRange,
@ -11,6 +13,8 @@ import {
} from '@grafana/data'; } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import React, { FormEvent, useCallback, useEffect, useState } from 'react'; import React, { FormEvent, useCallback, useEffect, useState } from 'react';
import { Icon, Tooltip } from '../..';
import { useStyles2 } from '../../..';
import { Button } from '../../Button'; import { Button } from '../../Button';
import { Field } from '../../Forms/Field'; import { Field } from '../../Forms/Field';
import { Input } from '../../Input/Input'; import { Input } from '../../Input/Input';
@ -21,6 +25,7 @@ interface Props {
value: TimeRange; value: TimeRange;
onApply: (range: TimeRange) => void; onApply: (range: TimeRange) => void;
timeZone?: TimeZone; timeZone?: TimeZone;
fiscalYearStartMonth?: number;
roundup?: boolean; roundup?: boolean;
isReversed?: boolean; isReversed?: boolean;
} }
@ -37,8 +42,9 @@ const ERROR_MESSAGES = {
}; };
export const TimeRangeForm: React.FC<Props> = (props) => { export const TimeRangeForm: React.FC<Props> = (props) => {
const { value, isFullscreen = false, timeZone, onApply: onApplyFromProps, isReversed } = props; const { value, isFullscreen = false, timeZone, onApply: onApplyFromProps, isReversed, fiscalYearStartMonth } = props;
const [fromValue, toValue] = valueToState(value.raw.from, value.raw.to, timeZone); const [fromValue, toValue] = valueToState(value.raw.from, value.raw.to, timeZone);
const style = useStyles2(getStyles);
const [from, setFrom] = useState<InputState>(fromValue); const [from, setFrom] = useState<InputState>(fromValue);
const [to, setTo] = useState<InputState>(toValue); const [to, setTo] = useState<InputState>(toValue);
@ -77,11 +83,11 @@ export const TimeRangeForm: React.FC<Props> = (props) => {
} }
const raw: RawTimeRange = { from: from.value, to: to.value }; const raw: RawTimeRange = { from: from.value, to: to.value };
const timeRange = rangeUtil.convertRawToRange(raw, timeZone); const timeRange = rangeUtil.convertRawToRange(raw, timeZone, fiscalYearStartMonth);
onApplyFromProps(timeRange); onApplyFromProps(timeRange);
}, },
[from.invalid, from.value, onApplyFromProps, timeZone, to.invalid, to.value] [from.invalid, from.value, onApplyFromProps, timeZone, to.invalid, to.value, fiscalYearStartMonth]
); );
const onChange = useCallback( const onChange = useCallback(
@ -93,29 +99,47 @@ export const TimeRangeForm: React.FC<Props> = (props) => {
[timeZone] [timeZone]
); );
const fiscalYear = rangeUtil.convertRawToRange({ from: 'now/fy', to: 'now/fy' }, timeZone, fiscalYearStartMonth);
const fyTooltip = (
<div className={style.tooltip}>
{rangeUtil.isFiscal(value) ? (
<Tooltip content={`Fiscal year: ${fiscalYear.from.format('MMM-DD')} - ${fiscalYear.to.format('MMM-DD')}`}>
<Icon name="info-circle" />
</Tooltip>
) : null}
</div>
);
const icon = isFullscreen ? null : <Button icon="calendar-alt" variant="secondary" onClick={onOpen} />; const icon = isFullscreen ? null : <Button icon="calendar-alt" variant="secondary" onClick={onOpen} />;
return ( return (
<div aria-label="Absolute time ranges"> <div aria-label="Absolute time ranges">
<Field label="From" invalid={from.invalid} error={from.errorMessage}> <Field label="From" invalid={from.invalid} error={from.errorMessage}>
<Input <div className={style.fieldContainer}>
onClick={(event) => event.stopPropagation()} <Input
onFocus={onFocus} onClick={(event) => event.stopPropagation()}
onChange={(event) => onChange(event.currentTarget.value, to.value)} onFocus={onFocus}
addonAfter={icon} onChange={(event) => onChange(event.currentTarget.value, to.value)}
aria-label={selectors.components.TimePicker.fromField} addonAfter={icon}
value={from.value} aria-label={selectors.components.TimePicker.fromField}
/> value={from.value}
/>
{fyTooltip}
</div>
</Field> </Field>
<Field label="To" invalid={to.invalid} error={to.errorMessage}> <Field label="To" invalid={to.invalid} error={to.errorMessage}>
<Input <div className={style.fieldContainer}>
onClick={(event) => event.stopPropagation()} <Input
onFocus={onFocus} onClick={(event) => event.stopPropagation()}
onChange={(event) => onChange(from.value, event.currentTarget.value)} onFocus={onFocus}
addonAfter={icon} onChange={(event) => onChange(from.value, event.currentTarget.value)}
aria-label={selectors.components.TimePicker.toField} addonAfter={icon}
value={to.value} aria-label={selectors.components.TimePicker.toField}
/> value={to.value}
/>
{fyTooltip}
</div>
</Field> </Field>
<Button data-testid={selectors.components.TimePicker.applyTimeRange} onClick={onApply}> <Button data-testid={selectors.components.TimePicker.applyTimeRange} onClick={onApply}>
Apply time range Apply time range
@ -185,3 +209,15 @@ function isValid(value: string, roundUp?: boolean, timeZone?: TimeZone): boolean
const parsed = dateTimeParse(value, { roundUp, timeZone }); const parsed = dateTimeParse(value, { roundUp, timeZone });
return parsed.isValid(); return parsed.isValid();
} }
function getStyles(theme: GrafanaTheme2) {
return {
fieldContainer: css`
display: flex;
`,
tooltip: css`
padding-left: ${theme.spacing(1)};
padding-top: ${theme.spacing(0.5)};
`,
};
}

View File

@ -26,7 +26,7 @@ const getOptionsStyles = stylesFactory(() => {
}); });
interface Props { interface Props {
title: string; title?: string;
options: TimeOption[]; options: TimeOption[];
value?: TimeOption; value?: TimeOption;
onChange: (option: TimeOption) => void; onChange: (option: TimeOption) => void;
@ -69,7 +69,7 @@ const Options: React.FC<Props> = ({ options, value, onChange, title }) => {
value={option} value={option}
selected={isEqual(option, value)} selected={isEqual(option, value)}
onSelect={onChange} onSelect={onChange}
name={title} name={title ?? 'Time ranges'}
/> />
))} ))}
</ul> </ul>

View File

@ -43,7 +43,6 @@ export const TimeZonePicker: React.FC<Props> = (props) => {
return ( return (
<Select <Select
menuShouldPortal
value={selected} value={selected}
placeholder="Type to search (country, city, abbreviation)" placeholder="Type to search (country, city, abbreviation)"
autoFocus={autoFocus} autoFocus={autoFocus}

View File

@ -1,4 +1,4 @@
import { TimeOption } from '@grafana/data'; import { SelectableValue, TimeOption } from '@grafana/data';
export const quickOptions: TimeOption[] = [ export const quickOptions: TimeOption[] = [
{ from: 'now-5m', to: 'now', display: 'Last 5 minutes' }, { from: 'now-5m', to: 'now', display: 'Last 5 minutes' },
@ -17,15 +17,14 @@ export const quickOptions: TimeOption[] = [
{ from: 'now-1y', to: 'now', display: 'Last 1 year' }, { from: 'now-1y', to: 'now', display: 'Last 1 year' },
{ from: 'now-2y', to: 'now', display: 'Last 2 years' }, { from: 'now-2y', to: 'now', display: 'Last 2 years' },
{ from: 'now-5y', to: 'now', display: 'Last 5 years' }, { from: 'now-5y', to: 'now', display: 'Last 5 years' },
];
export const otherOptions: TimeOption[] = [
{ from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday' }, { from: 'now-1d/d', to: 'now-1d/d', display: 'Yesterday' },
{ from: 'now-2d/d', to: 'now-2d/d', display: 'Day before yesterday' }, { from: 'now-2d/d', to: 'now-2d/d', display: 'Day before yesterday' },
{ from: 'now-7d/d', to: 'now-7d/d', display: 'This day last week' }, { from: 'now-7d/d', to: 'now-7d/d', display: 'This day last week' },
{ from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week' }, { from: 'now-1w/w', to: 'now-1w/w', display: 'Previous week' },
{ from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month' }, { from: 'now-1M/M', to: 'now-1M/M', display: 'Previous month' },
{ from: 'now-1Q/fQ', to: 'now-1Q/fQ', display: 'Previous fiscal quarter' },
{ from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year' }, { from: 'now-1y/y', to: 'now-1y/y', display: 'Previous year' },
{ from: 'now-1y/fy', to: 'now-1y/fy', display: 'Previous fiscal year' },
{ from: 'now/d', to: 'now/d', display: 'Today' }, { from: 'now/d', to: 'now/d', display: 'Today' },
{ from: 'now/d', to: 'now', display: 'Today so far' }, { from: 'now/d', to: 'now', display: 'Today so far' },
{ from: 'now/w', to: 'now/w', display: 'This week' }, { from: 'now/w', to: 'now/w', display: 'This week' },
@ -34,4 +33,23 @@ export const otherOptions: TimeOption[] = [
{ from: 'now/M', to: 'now', display: 'This month so far' }, { from: 'now/M', to: 'now', display: 'This month so far' },
{ from: 'now/y', to: 'now/y', display: 'This year' }, { from: 'now/y', to: 'now/y', display: 'This year' },
{ from: 'now/y', to: 'now', display: 'This year so far' }, { from: 'now/y', to: 'now', display: 'This year so far' },
{ from: 'now/fQ', to: 'now', display: 'This fiscal quarter so far' },
{ from: 'now/fQ', to: 'now/fQ', display: 'This fiscal quarter' },
{ from: 'now/fy', to: 'now', display: 'This fiscal year so far' },
{ from: 'now/fy', to: 'now/fy', display: 'This fiscal year' },
];
export const monthOptions: Array<SelectableValue<number>> = [
{ label: 'January', value: 0 },
{ label: 'February', value: 1 },
{ label: 'March', value: 2 },
{ label: 'April', value: 3 },
{ label: 'May', value: 4 },
{ label: 'June', value: 5 },
{ label: 'July', value: 6 },
{ label: 'August', value: 7 },
{ label: 'September', value: 8 },
{ label: 'October', value: 9 },
{ label: 'November', value: 10 },
{ label: 'December', value: 11 },
]; ];

View File

@ -14,7 +14,6 @@ export interface Props {
export const FilterInput: FC<Props> = ({ value, placeholder, width, onChange, onKeyDown, autoFocus }) => { export const FilterInput: FC<Props> = ({ value, placeholder, width, onChange, onKeyDown, autoFocus }) => {
const [inputRef, setInputFocus] = useFocus(); const [inputRef, setInputFocus] = useFocus();
const suffix = const suffix =
value !== '' ? ( value !== '' ? (
<Button <Button

View File

@ -59,7 +59,7 @@ export const Input = React.forwardRef<HTMLInputElement, Props>((props, ref) => {
{...restProps} {...restProps}
style={{ style={{
paddingLeft: prefixRect ? prefixRect.width + 12 : undefined, paddingLeft: prefixRect ? prefixRect.width + 12 : undefined,
paddingRight: suffixRect ? suffixRect.width + 12 : undefined, paddingRight: suffixRect && (suffix || loading) ? suffixRect.width + 12 : undefined,
}} }}
/> />

View File

@ -14,6 +14,7 @@ export class User {
login: string; login: string;
orgCount: number; orgCount: number;
timezone: string; timezone: string;
fiscalYearStartMonth: number;
helpFlags1: number; helpFlags1: number;
lightTheme: boolean; lightTheme: boolean;
hasEditPermissionInFolders: boolean; hasEditPermissionInFolders: boolean;
@ -30,6 +31,7 @@ export class User {
this.login = ''; this.login = '';
this.orgCount = 0; this.orgCount = 0;
this.timezone = ''; this.timezone = '';
this.fiscalYearStartMonth = 0;
this.helpFlags1 = 0; this.helpFlags1 = 0;
this.lightTheme = false; this.lightTheme = false;
this.hasEditPermissionInFolders = false; this.hasEditPermissionInFolders = false;

View File

@ -302,7 +302,7 @@ describe('getTimeRangeFromUrl', () => {
it('should parse moment date', () => { it('should parse moment date', () => {
// convert date strings to moment object // convert date strings to moment object
const range = { from: dateTime('2020-10-22T10:44:33.615Z'), to: dateTime('2020-10-22T10:49:33.615Z') }; const range = { from: dateTime('2020-10-22T10:44:33.615Z'), to: dateTime('2020-10-22T10:49:33.615Z') };
const result = getTimeRangeFromUrl(range, 'browser'); const result = getTimeRangeFromUrl(range, 'browser', 0);
expect(result.raw).toEqual(range); expect(result.raw).toEqual(range);
}); });
@ -311,7 +311,7 @@ describe('getTimeRangeFromUrl', () => {
from: dateTime('2020-10-22T10:00:00Z').valueOf().toString(), from: dateTime('2020-10-22T10:00:00Z').valueOf().toString(),
to: dateTime('2020-10-22T11:00:00Z').valueOf().toString(), to: dateTime('2020-10-22T11:00:00Z').valueOf().toString(),
}; };
const result = getTimeRangeFromUrl(range, 'browser'); const result = getTimeRangeFromUrl(range, 'browser', 0);
expect(result.from.valueOf()).toEqual(dateTime('2020-10-22T10:00:00Z').valueOf()); expect(result.from.valueOf()).toEqual(dateTime('2020-10-22T10:00:00Z').valueOf());
expect(result.to.valueOf()).toEqual(dateTime('2020-10-22T11:00:00Z').valueOf()); expect(result.to.valueOf()).toEqual(dateTime('2020-10-22T11:00:00Z').valueOf());
expect(result.raw.from.valueOf()).toEqual(dateTime('2020-10-22T10:00:00Z').valueOf()); expect(result.raw.from.valueOf()).toEqual(dateTime('2020-10-22T10:00:00Z').valueOf());
@ -323,7 +323,7 @@ describe('getTimeRangeFromUrl', () => {
from: dateTime('2020-10-22T10:00:00Z').toISOString(), from: dateTime('2020-10-22T10:00:00Z').toISOString(),
to: dateTime('2020-10-22T11:00:00Z').toISOString(), to: dateTime('2020-10-22T11:00:00Z').toISOString(),
}; };
const result = getTimeRangeFromUrl(range, 'browser'); const result = getTimeRangeFromUrl(range, 'browser', 0);
expect(result.from.valueOf()).toEqual(dateTime('2020-10-22T10:00:00Z').valueOf()); expect(result.from.valueOf()).toEqual(dateTime('2020-10-22T10:00:00Z').valueOf());
expect(result.to.valueOf()).toEqual(dateTime('2020-10-22T11:00:00Z').valueOf()); expect(result.to.valueOf()).toEqual(dateTime('2020-10-22T11:00:00Z').valueOf());
expect(result.raw.from.valueOf()).toEqual(dateTime('2020-10-22T10:00:00Z').valueOf()); expect(result.raw.from.valueOf()).toEqual(dateTime('2020-10-22T10:00:00Z').valueOf());

View File

@ -341,10 +341,10 @@ export const getQueryKeys = (queries: DataQuery[], datasourceInstance?: DataSour
return queryKeys; return queryKeys;
}; };
export const getTimeRange = (timeZone: TimeZone, rawRange: RawTimeRange): TimeRange => { export const getTimeRange = (timeZone: TimeZone, rawRange: RawTimeRange, fiscalYearStartMonth: number): TimeRange => {
return { return {
from: dateMath.parse(rawRange.from, false, timeZone as any)!, from: dateMath.parse(rawRange.from, false, timeZone as any, fiscalYearStartMonth)!,
to: dateMath.parse(rawRange.to, true, timeZone as any)!, to: dateMath.parse(rawRange.to, true, timeZone as any, fiscalYearStartMonth)!,
raw: rawRange, raw: rawRange,
}; };
}; };
@ -387,7 +387,11 @@ const parseRawTime = (value: string | DateTime): TimeFragment | null => {
return null; return null;
}; };
export const getTimeRangeFromUrl = (range: RawTimeRange, timeZone: TimeZone): TimeRange => { export const getTimeRangeFromUrl = (
range: RawTimeRange,
timeZone: TimeZone,
fiscalYearStartMonth: number
): TimeRange => {
const raw = { const raw = {
from: parseRawTime(range.from)!, from: parseRawTime(range.from)!,
to: parseRawTime(range.to)!, to: parseRawTime(range.to)!,

View File

@ -70,6 +70,11 @@ export class DashNavTimeControls extends Component<Props> {
this.onRefresh(); this.onRefresh();
}; };
onChangeFiscalYearStartMonth = (month: number) => {
this.props.dashboard.fiscalYearStartMonth = month;
this.onRefresh();
};
onZoom = () => { onZoom = () => {
appEvents.publish(new ZoomOutEvent(2)); appEvents.publish(new ZoomOutEvent(2));
}; };
@ -81,6 +86,7 @@ export class DashNavTimeControls extends Component<Props> {
const timePickerValue = getTimeSrv().timeRange(); const timePickerValue = getTimeSrv().timeRange();
const timeZone = dashboard.getTimezone(); const timeZone = dashboard.getTimezone();
const fiscalYearStartMonth = dashboard.fiscalYearStartMonth;
const hideIntervalPicker = dashboard.panelInEdit?.isEditing; const hideIntervalPicker = dashboard.panelInEdit?.isEditing;
return ( return (
@ -89,10 +95,12 @@ export class DashNavTimeControls extends Component<Props> {
value={timePickerValue} value={timePickerValue}
onChange={this.onChangeTimePicker} onChange={this.onChangeTimePicker}
timeZone={timeZone} timeZone={timeZone}
fiscalYearStartMonth={fiscalYearStartMonth}
onMoveBackward={this.onMoveBack} onMoveBackward={this.onMoveBack}
onMoveForward={this.onMoveForward} onMoveForward={this.onMoveForward}
onZoom={this.onZoom} onZoom={this.onZoom}
onChangeTimeZone={this.onChangeTimeZone} onChangeTimeZone={this.onChangeTimeZone}
onChangeFiscalYearStartMonth={this.onChangeFiscalYearStartMonth}
/> />
<RefreshPicker <RefreshPicker
onIntervalChanged={this.onChangeRefreshInterval} onIntervalChanged={this.onChangeRefreshInterval}

View File

@ -60,7 +60,11 @@ export class TimeSrv {
// remember time at load so we can go back to it // remember time at load so we can go back to it
this.timeAtLoad = cloneDeep(this.time); this.timeAtLoad = cloneDeep(this.time);
const range = rangeUtil.convertRawToRange(this.time, this.dashboard?.getTimezone()); const range = rangeUtil.convertRawToRange(
this.time,
this.dashboard?.getTimezone(),
this.dashboard?.fiscalYearStartMonth
);
if (range.to.isBefore(range.from)) { if (range.to.isBefore(range.from)) {
this.setTime( this.setTime(
@ -327,8 +331,8 @@ export class TimeSrv {
const timezone = this.dashboard ? this.dashboard.getTimezone() : undefined; const timezone = this.dashboard ? this.dashboard.getTimezone() : undefined;
return { return {
from: dateMath.parse(raw.from, false, timezone)!, from: dateMath.parse(raw.from, false, timezone, this.dashboard?.fiscalYearStartMonth)!,
to: dateMath.parse(raw.to, true, timezone)!, to: dateMath.parse(raw.to, true, timezone, this.dashboard?.fiscalYearStartMonth)!,
raw: raw, raw: raw,
}; };
} }

View File

@ -96,6 +96,7 @@ export class DashboardModel {
panels: PanelModel[]; panels: PanelModel[];
panelInEdit?: PanelModel; panelInEdit?: PanelModel;
panelInView?: PanelModel; panelInView?: PanelModel;
fiscalYearStartMonth?: number;
private hasChangesThatAffectsAllPanels: boolean; private hasChangesThatAffectsAllPanels: boolean;
// ------------------ // ------------------
@ -131,26 +132,27 @@ export class DashboardModel {
this.id = data.id || null; this.id = data.id || null;
this.uid = data.uid || null; this.uid = data.uid || null;
this.revision = data.revision; this.revision = data.revision;
this.title = data.title || 'No Title'; this.title = data.title ?? 'No Title';
this.autoUpdate = data.autoUpdate; this.autoUpdate = data.autoUpdate;
this.description = data.description; this.description = data.description;
this.tags = data.tags || []; this.tags = data.tags ?? [];
this.style = data.style || 'dark'; this.style = data.style ?? 'dark';
this.timezone = data.timezone || ''; this.timezone = data.timezone ?? '';
this.editable = data.editable !== false; this.editable = data.editable !== false;
this.graphTooltip = data.graphTooltip || 0; this.graphTooltip = data.graphTooltip || 0;
this.time = data.time || { from: 'now-6h', to: 'now' }; this.time = data.time ?? { from: 'now-6h', to: 'now' };
this.timepicker = data.timepicker || {}; this.timepicker = data.timepicker ?? {};
this.liveNow = Boolean(data.liveNow); this.liveNow = Boolean(data.liveNow);
this.templating = this.ensureListExist(data.templating); this.templating = this.ensureListExist(data.templating);
this.annotations = this.ensureListExist(data.annotations); this.annotations = this.ensureListExist(data.annotations);
this.refresh = data.refresh; this.refresh = data.refresh;
this.snapshot = data.snapshot; this.snapshot = data.snapshot;
this.schemaVersion = data.schemaVersion || 0; this.schemaVersion = data.schemaVersion ?? 0;
this.version = data.version || 0; this.fiscalYearStartMonth = data.fiscalYearStartMonth ?? 0;
this.links = data.links || []; this.version = data.version ?? 0;
this.links = data.links ?? [];
this.gnetId = data.gnetId || null; this.gnetId = data.gnetId || null;
this.panels = map(data.panels || [], (panelData: any) => new PanelModel(panelData)); this.panels = map(data.panels ?? [], (panelData: any) => new PanelModel(panelData));
this.formatDate = this.formatDate.bind(this); this.formatDate = this.formatDate.bind(this);
this.resetOriginalVariables(true); this.resetOriginalVariables(true);

View File

@ -16,7 +16,7 @@ import {
lastUsedDatasourceKeyForOrgId, lastUsedDatasourceKeyForOrgId,
parseUrlState, parseUrlState,
} from 'app/core/utils/explore'; } from 'app/core/utils/explore';
import { getTimeZone } from '../profile/state/selectors'; import { getFiscalYearStartMonth, getTimeZone } from '../profile/state/selectors';
import Explore from './Explore'; import Explore from './Explore';
interface OwnProps { interface OwnProps {
@ -99,13 +99,14 @@ const getTimeRangeFromUrlMemoized = memoizeOne(getTimeRangeFromUrl);
function mapStateToProps(state: StoreState, props: OwnProps) { function mapStateToProps(state: StoreState, props: OwnProps) {
const urlState = parseUrlState(props.urlQuery); const urlState = parseUrlState(props.urlQuery);
const timeZone = getTimeZone(state.user); const timeZone = getTimeZone(state.user);
const fiscalYearStartMonth = getFiscalYearStartMonth(state.user);
const { datasource, queries, range: urlRange, originPanelId } = (urlState || {}) as ExploreUrlState; const { datasource, queries, range: urlRange, originPanelId } = (urlState || {}) as ExploreUrlState;
const initialDatasource = datasource || store.get(lastUsedDatasourceKeyForOrgId(state.user.orgId)); const initialDatasource = datasource || store.get(lastUsedDatasourceKeyForOrgId(state.user.orgId));
const initialQueries: DataQuery[] = ensureQueriesMemoized(queries); const initialQueries: DataQuery[] = ensureQueriesMemoized(queries);
const initialRange = urlRange const initialRange = urlRange
? getTimeRangeFromUrlMemoized(urlRange, timeZone) ? getTimeRangeFromUrlMemoized(urlRange, timeZone, fiscalYearStartMonth)
: getTimeRange(timeZone, DEFAULT_RANGE); : getTimeRange(timeZone, DEFAULT_RANGE, fiscalYearStartMonth);
return { return {
initialized: state.explore[props.exploreId]?.initialized, initialized: state.explore[props.exploreId]?.initialized,

View File

@ -19,11 +19,13 @@ export interface Props {
hideText?: boolean; hideText?: boolean;
range: TimeRange; range: TimeRange;
timeZone: TimeZone; timeZone: TimeZone;
fiscalYearStartMonth: number;
splitted: boolean; splitted: boolean;
syncedTimes: boolean; syncedTimes: boolean;
onChangeTimeSync: () => void; onChangeTimeSync: () => void;
onChangeTime: (range: RawTimeRange) => void; onChangeTime: (range: RawTimeRange) => void;
onChangeTimeZone: (timeZone: TimeZone) => void; onChangeTimeZone: (timeZone: TimeZone) => void;
onChangeFiscalYearStartMonth: (fiscalYearStartMonth: number) => void;
} }
export class ExploreTimeControls extends Component<Props> { export class ExploreTimeControls extends Component<Props> {
@ -63,11 +65,22 @@ export class ExploreTimeControls extends Component<Props> {
}; };
render() { render() {
const { range, timeZone, splitted, syncedTimes, onChangeTimeSync, hideText, onChangeTimeZone } = this.props; const {
range,
timeZone,
fiscalYearStartMonth,
splitted,
syncedTimes,
onChangeTimeSync,
hideText,
onChangeTimeZone,
onChangeFiscalYearStartMonth,
} = this.props;
const timeSyncButton = splitted ? <TimeSyncButton onClick={onChangeTimeSync} isSynced={syncedTimes} /> : undefined; const timeSyncButton = splitted ? <TimeSyncButton onClick={onChangeTimeSync} isSynced={syncedTimes} /> : undefined;
const timePickerCommonProps = { const timePickerCommonProps = {
value: range, value: range,
timeZone, timeZone,
fiscalYearStartMonth,
onMoveBackward: this.onMoveBack, onMoveBackward: this.onMoveBack,
onMoveForward: this.onMoveForward, onMoveForward: this.onMoveForward,
onZoom: this.onZoom, onZoom: this.onZoom,
@ -81,6 +94,7 @@ export class ExploreTimeControls extends Component<Props> {
isSynced={syncedTimes} isSynced={syncedTimes}
onChange={this.onChangeTimePicker} onChange={this.onChangeTimePicker}
onChangeTimeZone={onChangeTimeZone} onChangeTimeZone={onChangeTimeZone}
onChangeFiscalYearStartMonth={onChangeFiscalYearStartMonth}
/> />
); );
} }

View File

@ -12,8 +12,8 @@ import { createAndCopyShortLink } from 'app/core/utils/shortLinks';
import { changeDatasource } from './state/datasource'; import { changeDatasource } from './state/datasource';
import { splitClose, splitOpen } from './state/main'; import { splitClose, splitOpen } from './state/main';
import { syncTimes, changeRefreshInterval } from './state/time'; import { syncTimes, changeRefreshInterval } from './state/time';
import { getTimeZone } from '../profile/state/selectors'; import { getFiscalYearStartMonth, getTimeZone } from '../profile/state/selectors';
import { updateTimeZoneForSession } from '../profile/state/reducers'; import { updateFiscalYearStartMonthForSession, updateTimeZoneForSession } from '../profile/state/reducers';
import { ExploreTimeControls } from './ExploreTimeControls'; import { ExploreTimeControls } from './ExploreTimeControls';
import { LiveTailButton } from './LiveTailButton'; import { LiveTailButton } from './LiveTailButton';
import { RunButton } from './RunButton'; import { RunButton } from './RunButton';
@ -65,6 +65,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props> {
loading, loading,
range, range,
timeZone, timeZone,
fiscalYearStartMonth,
splitted, splitted,
syncedTimes, syncedTimes,
refreshInterval, refreshInterval,
@ -75,6 +76,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props> {
isPaused, isPaused,
containerWidth, containerWidth,
onChangeTimeZone, onChangeTimeZone,
onChangeFiscalYearStartMonth,
} = this.props; } = this.props;
const showSmallDataSourcePicker = (splitted ? containerWidth < 700 : containerWidth < 800) || false; const showSmallDataSourcePicker = (splitted ? containerWidth < 700 : containerWidth < 800) || false;
@ -158,12 +160,14 @@ export class UnConnectedExploreToolbar extends PureComponent<Props> {
exploreId={exploreId} exploreId={exploreId}
range={range} range={range}
timeZone={timeZone} timeZone={timeZone}
fiscalYearStartMonth={fiscalYearStartMonth}
onChangeTime={onChangeTime} onChangeTime={onChangeTime}
splitted={splitted} splitted={splitted}
syncedTimes={syncedTimes} syncedTimes={syncedTimes}
onChangeTimeSync={this.onChangeTimeSync} onChangeTimeSync={this.onChangeTimeSync}
hideText={showSmallTimePicker} hideText={showSmallTimePicker}
onChangeTimeZone={onChangeTimeZone} onChangeTimeZone={onChangeTimeZone}
onChangeFiscalYearStartMonth={onChangeFiscalYearStartMonth}
/> />
)} )}
@ -230,6 +234,7 @@ const mapStateToProps = (state: StoreState, { exploreId }: OwnProps) => {
loading, loading,
range, range,
timeZone: getTimeZone(state.user), timeZone: getTimeZone(state.user),
fiscalYearStartMonth: getFiscalYearStartMonth(state.user),
splitted: isSplit(state), splitted: isSplit(state),
refreshInterval, refreshInterval,
hasLiveOption, hasLiveOption,
@ -250,6 +255,7 @@ const mapDispatchToProps = {
split: splitOpen, split: splitOpen,
syncTimes, syncTimes,
onChangeTimeZone: updateTimeZoneForSession, onChangeTimeZone: updateTimeZoneForSession,
onChangeFiscalYearStartMonth: updateFiscalYearStartMonthForSession,
}; };
const connector = connect(mapStateToProps, mapDispatchToProps); const connector = connect(mapStateToProps, mapDispatchToProps);

View File

@ -24,7 +24,7 @@ import { createAction, PayloadAction } from '@reduxjs/toolkit';
import { EventBusExtended, DataQuery, ExploreUrlState, TimeRange, HistoryItem, DataSourceApi } from '@grafana/data'; import { EventBusExtended, DataQuery, ExploreUrlState, TimeRange, HistoryItem, DataSourceApi } from '@grafana/data';
// Types // Types
import { ThunkResult } from 'app/types'; import { ThunkResult } from 'app/types';
import { getTimeZone } from 'app/features/profile/state/selectors'; import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state/selectors';
import { getDataSourceSrv } from '@grafana/runtime'; import { getDataSourceSrv } from '@grafana/runtime';
import { getRichHistory } from '../../../core/utils/richHistory'; import { getRichHistory } from '../../../core/utils/richHistory';
import { richHistoryUpdatedAction } from './main'; import { richHistoryUpdatedAction } from './main';
@ -153,7 +153,8 @@ export function refreshExplore(exploreId: ExploreId, newUrlQuery: string): Thunk
} }
const timeZone = getTimeZone(getState().user); const timeZone = getTimeZone(getState().user);
const range = getTimeRangeFromUrl(urlRange, timeZone); const fiscalYearStartMonth = getFiscalYearStartMonth(getState().user);
const range = getTimeRangeFromUrl(urlRange, timeZone, fiscalYearStartMonth);
// commit changes based on the diff of new url vs old url // commit changes based on the diff of new url vs old url

View File

@ -12,7 +12,7 @@ import { RefreshPicker } from '@grafana/ui';
import { getTimeRange, refreshIntervalToSortOrder, stopQueryState } from 'app/core/utils/explore'; import { getTimeRange, refreshIntervalToSortOrder, stopQueryState } from 'app/core/utils/explore';
import { ExploreItemState, ThunkResult } from 'app/types'; import { ExploreItemState, ThunkResult } from 'app/types';
import { ExploreId } from 'app/types/explore'; import { ExploreId } from 'app/types/explore';
import { getTimeZone } from 'app/features/profile/state/selectors'; import { getFiscalYearStartMonth, getTimeZone } from 'app/features/profile/state/selectors';
import { getTimeSrv } from '../../dashboard/services/TimeSrv'; import { getTimeSrv } from '../../dashboard/services/TimeSrv';
import { DashboardModel } from 'app/features/dashboard/state'; import { DashboardModel } from 'app/features/dashboard/state';
import { runQueries } from './query'; import { runQueries } from './query';
@ -78,6 +78,7 @@ export const updateTime = (config: {
const { exploreId, absoluteRange: absRange, rawRange: actionRange } = config; const { exploreId, absoluteRange: absRange, rawRange: actionRange } = config;
const itemState = getState().explore[exploreId]!; const itemState = getState().explore[exploreId]!;
const timeZone = getTimeZone(getState().user); const timeZone = getTimeZone(getState().user);
const fiscalYearStartMonth = getFiscalYearStartMonth(getState().user);
const { range: rangeInState } = itemState; const { range: rangeInState } = itemState;
let rawRange: RawTimeRange = rangeInState.raw; let rawRange: RawTimeRange = rangeInState.raw;
@ -92,7 +93,7 @@ export const updateTime = (config: {
rawRange = actionRange; rawRange = actionRange;
} }
const range = getTimeRange(timeZone, rawRange); const range = getTimeRange(timeZone, rawRange, fiscalYearStartMonth);
const absoluteRange: AbsoluteTimeRange = { from: range.from.valueOf(), to: range.to.valueOf() }; const absoluteRange: AbsoluteTimeRange = { from: range.from.valueOf(), to: range.to.valueOf() };
getTimeSrv().init( getTimeSrv().init(

View File

@ -9,6 +9,7 @@ import { contextSrv } from 'app/core/core';
export interface UserState { export interface UserState {
orgId: number; orgId: number;
timeZone: TimeZone; timeZone: TimeZone;
fiscalYearStartMonth: number;
user: UserDTO | null; user: UserDTO | null;
teams: Team[]; teams: Team[];
orgs: UserOrg[]; orgs: UserOrg[];
@ -22,6 +23,7 @@ export interface UserState {
export const initialUserState: UserState = { export const initialUserState: UserState = {
orgId: config.bootData.user.orgId, orgId: config.bootData.user.orgId,
timeZone: config.bootData.user.timezone, timeZone: config.bootData.user.timezone,
fiscalYearStartMonth: 0,
orgsAreLoading: false, orgsAreLoading: false,
sessionsAreLoading: false, sessionsAreLoading: false,
teamsAreLoading: false, teamsAreLoading: false,
@ -39,6 +41,9 @@ export const slice = createSlice({
updateTimeZone: (state, action: PayloadAction<{ timeZone: TimeZone }>) => { updateTimeZone: (state, action: PayloadAction<{ timeZone: TimeZone }>) => {
state.timeZone = action.payload.timeZone; state.timeZone = action.payload.timeZone;
}, },
updateFiscalYearStartMonth: (state, action: PayloadAction<{ fiscalYearStartMonth: number }>) => {
state.fiscalYearStartMonth = action.payload.fiscalYearStartMonth;
},
setUpdating: (state, action: PayloadAction<{ updating: boolean }>) => { setUpdating: (state, action: PayloadAction<{ updating: boolean }>) => {
state.isUpdating = action.payload.updating; state.isUpdating = action.payload.updating;
}, },
@ -87,6 +92,13 @@ export const slice = createSlice({
}, },
}); });
export const updateFiscalYearStartMonthForSession = (fiscalYearStartMonth: number): ThunkResult<void> => {
return async (dispatch) => {
set(contextSrv, 'user.fiscalYearStartMonth', fiscalYearStartMonth);
dispatch(updateFiscalYearStartMonth({ fiscalYearStartMonth }));
};
};
export const updateTimeZoneForSession = (timeZone: TimeZone): ThunkResult<void> => { export const updateTimeZoneForSession = (timeZone: TimeZone): ThunkResult<void> => {
return async (dispatch) => { return async (dispatch) => {
if (!isString(timeZone) || isEmpty(timeZone)) { if (!isString(timeZone) || isEmpty(timeZone)) {
@ -109,6 +121,7 @@ export const {
initLoadSessions, initLoadSessions,
sessionsLoaded, sessionsLoaded,
updateTimeZone, updateTimeZone,
updateFiscalYearStartMonth,
} = slice.actions; } = slice.actions;
export const userReducer = slice.reducer; export const userReducer = slice.reducer;

View File

@ -1,3 +1,4 @@
import { UserState } from './reducers'; import { UserState } from './reducers';
export const getTimeZone = (state: UserState) => state.timeZone; export const getTimeZone = (state: UserState) => state.timeZone;
export const getFiscalYearStartMonth = (state: UserState) => state.fiscalYearStartMonth;