Commit 1abbb477 by Marcus Andersson Committed by GitHub

TimeZone: unify the time zone pickers to one that can rule them all. (#24803)

* draft on a unified TimeZonePicker.

* most of the data structures is in place.

* wip.

* wip.

* wip: timezone selector in progress.2

* fixed so we have proper data on all timezones.

* started to add timezone into time picker.

* addeing time zone footer.

* footer is working.

* fixed so we use the timeZone picker in shared preferences.

* Added so we can change timeZone from picker.

* did some styling changes.

* will update timezone on all places that we need to update it.

* removed console.log

* removed magic string.

* fixed border on calendar.

* ignoring eslint cache.

* cleaned up the code a bit.

* made the default selectable.

* corrected so the behaviour about default works as expected.

* excluded timezone from change tracker.

* revert so default will always be the intial value.

* default will always fallback to the one in the config.

* do the country mapping on startup.

* fixed nit.

* updated snapshots for timepicker.

* fixed build errors.

* updating so snapshot tests is in sync.

* removed Date.now from prop since it will change each run in the snapshot tests.

* fixed so e2e tests works as before.

* moved files into separate folders.
parent 084542a0
......@@ -47,6 +47,7 @@ public/css/*.min.css
.DS_Store
.vscode/
.vs/
.eslintcache
/data/*
/bin/*
......
......@@ -9,7 +9,7 @@ e2e.scenario({
scenario: () => {
e2e.flows.openDashboard('5SdHCasdf');
const fromTimeZone = 'UTC';
const fromTimeZone = 'Coordinated Universal Time';
const toTimeZone = 'America/Chicago';
const offset = -5;
......
......@@ -17,7 +17,7 @@ export interface SelectCommonProps<T> {
components?: any;
defaultValue?: any;
disabled?: boolean;
filterOption?: (option: SelectableValue, searchQuery: string) => void;
filterOption?: (option: SelectableValue, searchQuery: string) => boolean;
/** Function for formatting the text that is displayed when creating a new value*/
formatCreateLabel?: (input: string) => string;
getOptionLabel?: (item: SelectableValue<T>) => string;
......
import { GrafanaTheme } from '@grafana/data';
import { selectThemeVariant } from '../../../themes/selectThemeVariant';
export const getThemeColors = (theme: GrafanaTheme) => {
return {
border: theme.colors.border1,
background: theme.colors.bodyBg,
shadow: theme.colors.dropdownShadow,
formBackground: selectThemeVariant(
{
dark: theme.palette.gray15,
light: theme.palette.gray98,
},
theme.type
),
};
};
......@@ -24,6 +24,7 @@ export const basic = () => {
{(value, updateValue) => {
return (
<TimeRangePicker
onChangeTimeZone={() => {}}
timeZone="browser"
value={value}
onChange={timeRange => {
......
......@@ -17,6 +17,7 @@ describe('TimePicker', () => {
it('renders buttons correctly', () => {
const wrapper = mount(
<UnthemedTimeRangePicker
onChangeTimeZone={() => {}}
onChange={value => {}}
value={value}
onMoveBackward={() => {}}
......
......@@ -5,7 +5,7 @@ import { css, cx } from 'emotion';
// Components
import { Tooltip } from '../Tooltip/Tooltip';
import { Icon } from '../Icon/Icon';
import { TimePickerContent } from './TimePickerContent/TimePickerContent';
import { TimePickerContent } from './TimeRangePicker/TimePickerContent';
import { ClickOutsideWrapper } from '../ClickOutsideWrapper/ClickOutsideWrapper';
// Utils & Services
......@@ -98,6 +98,7 @@ export interface Props extends Themeable {
timeSyncButton?: JSX.Element;
isSynced?: boolean;
onChange: (timeRange: TimeRange) => void;
onChangeTimeZone: (timeZone: TimeZone) => void;
onMoveBackward: () => void;
onMoveForward: () => void;
onZoom: () => void;
......@@ -139,6 +140,7 @@ export class UnthemedTimeRangePicker extends PureComponent<Props, State> {
isSynced,
theme,
history,
onChangeTimeZone,
} = this.props;
const { isOpen } = this.state;
......@@ -168,7 +170,7 @@ export class UnthemedTimeRangePicker extends PureComponent<Props, State> {
</button>
</Tooltip>
{isOpen && (
<ClickOutsideWrapper onClick={this.onClose}>
<ClickOutsideWrapper includeButtonPress={false} onClick={this.onClose}>
<TimePickerContent
timeZone={timeZone}
value={value}
......@@ -176,6 +178,7 @@ export class UnthemedTimeRangePicker extends PureComponent<Props, State> {
otherOptions={otherOptions}
quickOptions={quickOptions}
history={history}
onChangeTimeZone={onChangeTimeZone}
/>
</ClickOutsideWrapper>
)}
......
......@@ -7,31 +7,32 @@ import { TimePickerTitle } from './TimePickerTitle';
import { Button } from '../../Button';
import { Icon } from '../../Icon/Icon';
import { Portal } from '../../Portal/Portal';
import { getThemeColors } from './colors';
import { ClickOutsideWrapper } from '../../ClickOutsideWrapper/ClickOutsideWrapper';
const getStyles = stylesFactory((theme: GrafanaTheme) => {
const colors = getThemeColors(theme);
const containerBorder = theme.isDark ? theme.palette.dark9 : theme.palette.gray5;
return {
container: css`
top: 0;
top: -1px;
position: absolute;
right: 546px;
box-shadow: 0px 0px 20px ${colors.shadow};
background-color: ${colors.background};
right: 544px;
box-shadow: 0px 0px 20px ${theme.colors.dropdownShadow};
background-color: ${theme.colors.bodyBg};
z-index: -1;
border: 1px solid ${containerBorder};
border-radius: 2px 0 0 2px;
&:after {
display: block;
background-color: ${colors.background};
background-color: ${theme.colors.bodyBg};
width: 19px;
height: 381px;
height: 100%;
content: ' ';
position: absolute;
top: 0;
right: -19px;
border-left: 1px solid ${colors.border};
border-left: 1px solid ${theme.colors.border1};
}
`,
modal: css`
......@@ -59,11 +60,9 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
});
const getFooterStyles = stylesFactory((theme: GrafanaTheme) => {
const colors = getThemeColors(theme);
return {
container: css`
background-color: ${colors.background};
background-color: ${theme.colors.bodyBg};
display: flex;
justify-content: center;
padding: 10px;
......@@ -78,12 +77,10 @@ const getFooterStyles = stylesFactory((theme: GrafanaTheme) => {
});
const getBodyStyles = stylesFactory((theme: GrafanaTheme) => {
const colors = getThemeColors(theme);
return {
title: css`
color: ${theme.colors.text};
background-color: ${colors.background};
background-color: ${theme.colors.bodyBg};
font-size: ${theme.typography.size.md};
border: 1px solid transparent;
......@@ -93,7 +90,7 @@ const getBodyStyles = stylesFactory((theme: GrafanaTheme) => {
`,
body: css`
z-index: ${theme.zIndex.modal};
background-color: ${colors.background};
background-color: ${theme.colors.bodyBg};
width: 268px;
.react-calendar__navigation__label,
......@@ -177,11 +174,9 @@ const getBodyStyles = stylesFactory((theme: GrafanaTheme) => {
});
const getHeaderStyles = stylesFactory((theme: GrafanaTheme) => {
const colors = getThemeColors(theme);
return {
container: css`
background-color: ${colors.background};
background-color: ${theme.colors.bodyBg};
display: flex;
justify-content: space-between;
padding: 7px;
......
......@@ -7,7 +7,13 @@ describe('TimePickerContent', () => {
it('renders correctly in full screen', () => {
const value = createTimeRange('2019-12-17T07:48:27.433Z', '2019-12-18T07:48:27.433Z');
const wrapper = shallow(
<TimePickerContentWithScreenSize onChange={value => {}} timeZone="utc" value={value} isFullscreen={true} />
<TimePickerContentWithScreenSize
onChangeTimeZone={() => {}}
onChange={value => {}}
timeZone="utc"
value={value}
isFullscreen={true}
/>
);
expect(wrapper).toMatchSnapshot();
});
......@@ -15,7 +21,13 @@ describe('TimePickerContent', () => {
it('renders correctly in narrow screen', () => {
const value = createTimeRange('2019-12-17T07:48:27.433Z', '2019-12-18T07:48:27.433Z');
const wrapper = shallow(
<TimePickerContentWithScreenSize onChange={value => {}} timeZone="utc" value={value} isFullscreen={false} />
<TimePickerContentWithScreenSize
onChangeTimeZone={() => {}}
onChange={value => {}}
timeZone="utc"
value={value}
isFullscreen={false}
/>
);
expect(wrapper).toMatchSnapshot();
});
......@@ -29,6 +41,7 @@ describe('TimePickerContent', () => {
const wrapper = shallow(
<TimePickerContentWithScreenSize
onChangeTimeZone={() => {}}
onChange={value => {}}
timeZone="utc"
value={value}
......
......@@ -5,26 +5,26 @@ import { useMedia } from 'react-use';
import { stylesFactory, useTheme } from '../../../themes';
import { CustomScrollbar } from '../../CustomScrollbar/CustomScrollbar';
import { Icon } from '../../Icon/Icon';
import { getThemeColors } from './colors';
import { mapRangeToTimeOption } from './mapper';
import { TimePickerTitle } from './TimePickerTitle';
import { TimeRangeForm } from './TimeRangeForm';
import { TimeRangeList } from './TimeRangeList';
import { TimePickerFooter } from './TimePickerFooter';
const getStyles = stylesFactory((theme: GrafanaTheme) => {
const colors = getThemeColors(theme);
const containerBorder = theme.isDark ? theme.palette.dark9 : theme.palette.gray5;
return {
container: css`
display: flex;
background: ${colors.background};
box-shadow: 0px 0px 20px ${colors.shadow};
background: ${theme.colors.bodyBg};
box-shadow: 0px 0px 20px ${theme.colors.dropdownShadow};
position: absolute;
z-index: ${theme.zIndex.modal};
width: 546px;
height: 381px;
top: 116%;
margin-left: -322px;
border-radius: 2px;
border: 1px solid ${containerBorder};
@media only screen and (max-width: ${theme.breakpoints.lg}) {
width: 218px;
......@@ -36,10 +36,14 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
margin-left: -100px;
}
`,
body: css`
display: flex;
height: 381px;
`,
leftSide: css`
display: flex;
flex-direction: column;
border-right: 1px solid ${colors.border};
border-right: 1px solid ${theme.colors.border1};
width: 60%;
overflow: hidden;
......@@ -61,7 +65,7 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
});
const getNarrowScreenStyles = stylesFactory((theme: GrafanaTheme) => {
const colors = getThemeColors(theme);
const formBackground = theme.isDark ? theme.palette.gray15 : theme.palette.gray98;
return {
header: css`
......@@ -69,13 +73,13 @@ const getNarrowScreenStyles = stylesFactory((theme: GrafanaTheme) => {
flex-direction: row;
justify-content: space-between;
align-items: center;
border-bottom: 1px solid ${colors.border};
border-bottom: 1px solid ${theme.colors.border1};
padding: 7px 9px 7px 9px;
`,
body: css`
border-bottom: 1px solid ${colors.border};
background: ${colors.formBackground};
box-shadow: inset 0px 2px 2px ${colors.shadow};
border-bottom: 1px solid ${theme.colors.border1};
background: ${formBackground};
box-shadow: inset 0px 2px 2px ${theme.colors.dropdownShadow};
`,
form: css`
padding: 7px 9px 7px 9px;
......@@ -103,11 +107,11 @@ const getFullScreenStyles = stylesFactory((theme: GrafanaTheme) => {
});
const getEmptyListStyles = stylesFactory((theme: GrafanaTheme) => {
const colors = getThemeColors(theme);
const formBackground = theme.isDark ? theme.palette.gray15 : theme.palette.gray98;
return {
container: css`
background-color: ${colors.formBackground};
background-color: ${formBackground};
padding: 12px;
margin: 12px;
......@@ -125,6 +129,7 @@ const getEmptyListStyles = stylesFactory((theme: GrafanaTheme) => {
interface Props {
value: TimeRange;
onChange: (timeRange: TimeRange) => void;
onChangeTimeZone: (timeZone: TimeZone) => void;
timeZone?: TimeZone;
quickOptions?: TimeOption[];
otherOptions?: TimeOption[];
......@@ -148,27 +153,30 @@ export const TimePickerContentWithScreenSize: React.FC<PropsWithScreenSize> = pr
return (
<div className={styles.container}>
<div className={styles.leftSide}>
<FullScreenForm {...props} visible={isFullscreen} historyOptions={historyOptions} />
<div className={styles.body}>
<div className={styles.leftSide}>
<FullScreenForm {...props} visible={isFullscreen} historyOptions={historyOptions} />
</div>
<CustomScrollbar className={styles.rightSide}>
<NarrowScreenForm {...props} visible={!isFullscreen} historyOptions={historyOptions} />
<TimeRangeList
title="Relative time ranges"
options={quickOptions}
onSelect={props.onChange}
value={props.value}
timeZone={props.timeZone}
/>
<div className={styles.spacing} />
<TimeRangeList
title="Other quick ranges"
options={otherOptions}
onSelect={props.onChange}
value={props.value}
timeZone={props.timeZone}
/>
</CustomScrollbar>
</div>
<CustomScrollbar className={styles.rightSide}>
<NarrowScreenForm {...props} visible={!isFullscreen} historyOptions={historyOptions} />
<TimeRangeList
title="Relative time ranges"
options={quickOptions}
onSelect={props.onChange}
value={props.value}
timeZone={props.timeZone}
/>
<div className={styles.spacing} />
<TimeRangeList
title="Other quick ranges"
options={otherOptions}
onSelect={props.onChange}
value={props.value}
timeZone={props.timeZone}
/>
</CustomScrollbar>
{isFullscreen && <TimePickerFooter timeZone={props.timeZone} onChangeTimeZone={props.onChangeTimeZone} />}
</div>
);
};
......
import React, { FC, useState, useCallback } from 'react';
import { css, cx } from 'emotion';
import { TimeZone, GrafanaTheme, getTimeZoneInfo } from '@grafana/data';
import { stylesFactory, useTheme } from '../../../themes';
import { TimeZoneTitle } from '../TimeZonePicker/TimeZoneTitle';
import { TimeZoneDescription } from '../TimeZonePicker/TimeZoneDescription';
import { TimeZoneOffset } from '../TimeZonePicker/TimeZoneOffset';
import { Button } from '../../Button';
import { TimeZonePicker } from '../TimeZonePicker';
import isString from 'lodash/isString';
interface Props {
timeZone?: TimeZone;
timestamp?: number;
onChangeTimeZone: (timeZone: TimeZone) => void;
}
export const TimePickerFooter: FC<Props> = props => {
const { timeZone, timestamp = Date.now(), onChangeTimeZone } = props;
const [isEditing, setEditing] = useState(false);
const onToggleChangeTz = useCallback(
(event?: React.MouseEvent) => {
if (event) {
event.stopPropagation();
}
setEditing(!isEditing);
},
[isEditing, setEditing]
);
const theme = useTheme();
const style = getStyle(theme);
if (!isString(timeZone)) {
return null;
}
const info = getTimeZoneInfo(timeZone, timestamp);
if (!info) {
return null;
}
if (isEditing) {
return (
<div className={cx(style.container, style.editContainer)}>
<div className={style.timeZoneContainer}>
<TimeZonePicker
onChange={timeZone => {
onToggleChangeTz();
if (isString(timeZone)) {
onChangeTimeZone(timeZone);
}
}}
autoFocus={true}
onBlur={onToggleChangeTz}
/>
</div>
</div>
);
}
return (
<div className={style.container}>
<div className={style.timeZoneContainer}>
<div className={style.timeZone}>
<TimeZoneTitle title={info.name} />
<div className={style.spacer} />
<TimeZoneDescription info={info} />
</div>
<TimeZoneOffset timeZone={timeZone} timestamp={timestamp} />
</div>
<div className={style.spacer} />
<Button variant="secondary" onClick={onToggleChangeTz} size="sm">
Change time zone
</Button>
</div>
);
};
const getStyle = stylesFactory((theme: GrafanaTheme) => {
return {
container: css`
border-top: 1px solid ${theme.colors.border1};
padding: 11px;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
`,
editContainer: css`
padding: 7px;
`,
spacer: css`
margin-left: 7px;
`,
timeZoneContainer: css`
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
flex-grow: 1;
`,
timeZone: css`
display: flex;
flex-direction: row;
align-items: baseline;
flex-grow: 1;
`,
};
});
......@@ -23,6 +23,9 @@ export const basic = () => {
<TimeZonePicker
value={value.value}
onChange={newValue => {
if (!newValue) {
return;
}
action('on selected')(newValue);
updateValue({ value: newValue });
}}
......
import React, { FC } from 'react';
import { getTimeZoneGroups } from '@grafana/data';
import { Cascader } from '../index';
import React, { useMemo, useCallback } from 'react';
import { toLower, isEmpty, isString } from 'lodash';
import {
SelectableValue,
getTimeZoneInfo,
TimeZoneInfo,
getTimeZoneGroups,
GroupedTimeZones,
TimeZone,
InternalTimeZones,
} from '@grafana/data';
import { Select } from '../Select/Select';
import { CompactTimeZoneOption, WideTimeZoneOption, SelectableZone } from './TimeZonePicker/TimeZoneOption';
import { TimeZoneGroup } from './TimeZonePicker/TimeZoneGroup';
import { formatUtcOffset } from './TimeZonePicker/TimeZoneOffset';
export interface Props {
value: string;
value?: TimeZone;
width?: number;
onChange: (newValue: string) => void;
autoFocus?: boolean;
onChange: (timeZone: TimeZone | undefined) => void;
onBlur?: () => void;
}
export const TimeZonePicker: FC<Props> = ({ onChange, value, width }) => {
const timeZoneGroups = getTimeZoneGroups();
const groupOptions = timeZoneGroups.map(group => {
const options = group.options.map(timeZone => {
return {
label: timeZone,
value: timeZone,
};
});
export const TimeZonePicker: React.FC<Props> = props => {
const { onChange, width, autoFocus = false, onBlur, value } = props;
const groupedTimeZones = useTimeZones();
const selected = useSelectedTimeZone(groupedTimeZones, value);
const filterBySearchIndex = useFilterBySearchIndex();
const TimeZoneOption = width && width <= 45 ? CompactTimeZoneOption : WideTimeZoneOption;
return {
label: group.label,
value: group.label,
items: options,
};
});
const selectedValue = groupOptions.reduce(
(acc, group) => {
const found = group.items.find(option => option.value === value);
return found || acc;
const onChangeTz = useCallback(
(selectable: SelectableValue<string>) => {
if (!selectable || !isString(selectable.value)) {
return onChange(value);
}
onChange(selectable.value);
},
{ value: '' }
[onChange, value]
);
return (
<Cascader
options={groupOptions}
initialValue={selectedValue?.value}
onSelect={(newValue: string) => onChange(newValue)}
<Select
value={selected}
placeholder="Type to search (country, city, abbreviation)"
autoFocus={autoFocus}
openMenuOnFocus={true}
width={width}
placeholder="Select timezone"
filterOption={filterBySearchIndex}
options={groupedTimeZones}
onChange={onChangeTz}
onBlur={onBlur}
components={{ Option: TimeZoneOption, Group: TimeZoneGroup }}
/>
);
};
interface SelectableZoneGroup extends SelectableValue<string> {
options: SelectableZone[];
}
const useTimeZones = (): SelectableZoneGroup[] => {
const now = Date.now();
return getTimeZoneGroups(true).map((group: GroupedTimeZones) => {
const options = group.zones.reduce((options: SelectableZone[], zone) => {
const info = getTimeZoneInfo(zone, now);
if (!info) {
return options;
}
options.push({
label: info.name,
value: info.zone,
searchIndex: useSearchIndex(info, now),
});
return options;
}, []);
return {
label: group.name,
options,
};
});
};
const useSelectedTimeZone = (
groups: SelectableZoneGroup[],
timeZone: TimeZone | undefined
): SelectableZone | undefined => {
return useMemo(() => {
if (timeZone === undefined) {
return undefined;
}
const group = groups.find(group => {
if (!group.label) {
return isInternal(timeZone);
}
return timeZone.startsWith(group.label);
});
return group?.options.find(option => {
if (isEmpty(timeZone)) {
return option.value === InternalTimeZones.default;
}
return toLower(option.value) === timeZone;
});
}, [groups, timeZone]);
};
const isInternal = (timeZone: TimeZone): boolean => {
switch (timeZone) {
case InternalTimeZones.default:
case InternalTimeZones.localBrowserTime:
case InternalTimeZones.utc:
return true;
default:
return false;
}
};
const useFilterBySearchIndex = () => {
return useCallback((option: SelectableValue, searchQuery: string) => {
if (!searchQuery || !option.data || !option.data.searchIndex) {
return true;
}
return option.data.searchIndex.indexOf(toLower(searchQuery)) > -1;
}, []);
};
const useSearchIndex = (info: TimeZoneInfo, timestamp: number): string => {
return useMemo(() => {
const parts: string[] = [
toLower(info.name),
toLower(info.abbreviation),
toLower(formatUtcOffset(timestamp, info.zone)),
];
for (const country of info.countries) {
parts.push(toLower(country.name));
parts.push(toLower(country.code));
}
return parts.join('|');
}, [info.zone, info.abbreviation, info.offsetInMins]);
};
import React, { PropsWithChildren, useMemo } from 'react';
import { css } from 'emotion';
import { GrafanaTheme, TimeZoneInfo } from '@grafana/data';
import { useTheme, stylesFactory } from '../../../themes';
interface Props {
info?: TimeZoneInfo;
}
export const TimeZoneDescription: React.FC<PropsWithChildren<Props>> = ({ info }) => {
if (!info) {
return null;
}
const theme = useTheme();
const styles = getStyles(theme);
const description = useDescription(info);
return <div className={styles.description}>{description}</div>;
};
const useDescription = (info: TimeZoneInfo): string => {
return useMemo(() => {
const parts: string[] = [];
if (info.countries.length > 0) {
const country = info.countries[0];
parts.push(country.name);
}
if (info.abbreviation) {
parts.push(info.abbreviation);
}
return parts.join(', ');
}, [info.zone]);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
description: css`
font-weight: normal;
font-size: ${theme.typography.size.sm};
color: ${theme.colors.textWeak};
white-space: normal;
text-overflow: ellipsis;
`,
};
});
import React, { PropsWithChildren } from 'react';
import { css } from 'emotion';
import { GrafanaTheme } from '@grafana/data';
import { useTheme, stylesFactory } from '../../../themes';
interface Props {
label: string | undefined;
}
const stopPropagation = (event: React.MouseEvent) => event.stopPropagation();
export const TimeZoneGroup: React.FC<PropsWithChildren<Props>> = props => {
const theme = useTheme();
const { children, label } = props;
const styles = getStyles(theme);
if (!label) {
return <div onClick={stopPropagation}>{children}</div>;
}
return (
<div onClick={stopPropagation}>
<div className={styles.header}>
<span className={styles.label}>{label}</span>
</div>
{children}
</div>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
header: css`
padding: 7px 10px;
width: 100%;
border-top: 1px solid ${theme.colors.border1};
text-transform: capitalize;
`,
label: css`
font-size: ${theme.typography.size.sm};
color: ${theme.colors.textWeak};
font-weight: ${theme.typography.weight.semibold};
`,
};
});
import React, { PropsWithChildren } from 'react';
import { css, cx } from 'emotion';
import { GrafanaTheme, TimeZone, dateTimeFormat } from '@grafana/data';
import { useTheme, stylesFactory } from '../../../themes';
import isString from 'lodash/isString';
interface Props {
timestamp: number;
timeZone: TimeZone | undefined;
className?: string;
}
export const TimeZoneOffset: React.FC<PropsWithChildren<Props>> = props => {
const theme = useTheme();
const { timestamp, timeZone, className } = props;
const styles = getStyles(theme);
if (!isString(timeZone)) {
return null;
}
return (
<>
<span className={styles.localTime}>{formatLocalTime(timestamp, timeZone)}</span>
<span className={cx(styles.offset, className)}>{formatUtcOffset(timestamp, timeZone)}</span>
</>
);
};
export const formatUtcOffset = (timestamp: number, timeZone: TimeZone): string => {
const offset = dateTimeFormat(timestamp, {
timeZone,
format: 'Z',
});
if (offset === '+00:00') {
return 'UTC';
}
return `UTC${offset}`;
};
const formatLocalTime = (timestamp: number, timeZone: TimeZone): string => {
return dateTimeFormat(timestamp, {
timeZone,
format: 'HH:mm',
});
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
const textBase = css`
font-weight: normal;
font-size: ${theme.typography.size.sm};
color: ${theme.colors.textWeak};
white-space: normal;
`;
return {
localTime: css`
display: none;
${textBase};
color: ${theme.colors.text};
`,
offset: css`
${textBase};
color: ${theme.colors.text};
background: ${theme.colors.bg2};
padding: 2px 5px;
border-radius: 2px;
margin-left: 4px;
`,
};
});
import React, { PropsWithChildren } from 'react';
import { css, cx } from 'emotion';
import { GrafanaTheme, SelectableValue, getTimeZoneInfo } from '@grafana/data';
import { useTheme } from '../../../themes/ThemeContext';
import { stylesFactory } from '../../../themes/stylesFactory';
import { Icon } from '../../Icon/Icon';
import { TimeZoneOffset } from './TimeZoneOffset';
import { TimeZoneDescription } from './TimeZoneDescription';
import { TimeZoneTitle } from './TimeZoneTitle';
import isString from 'lodash/isString';
interface Props {
isFocused: boolean;
isSelected: boolean;
innerProps: any;
data: SelectableZone;
}
const offsetClassName = 'tz-utc-offset';
export interface SelectableZone extends SelectableValue<string> {
searchIndex: string;
}
export const WideTimeZoneOption: React.FC<PropsWithChildren<Props>> = (props, ref) => {
const { children, innerProps, data, isSelected, isFocused } = props;
const theme = useTheme();
const styles = getStyles(theme);
const timestamp = Date.now();
const containerStyles = cx(styles.container, isFocused && styles.containerFocused);
if (!isString(data.value)) {
return null;
}
return (
<div className={containerStyles} {...innerProps} aria-label="Select option">
<div className={cx(styles.leftColumn, styles.row)}>
<div className={cx(styles.leftColumn, styles.wideRow)}>
<TimeZoneTitle title={children} />
<div className={styles.spacer} />
<TimeZoneDescription info={getTimeZoneInfo(data.value, timestamp)} />
</div>
<div className={styles.rightColumn}>
<TimeZoneOffset timeZone={data.value} timestamp={timestamp} className={offsetClassName} />
{isSelected && (
<span>
<Icon name="check" />
</span>
)}
</div>
</div>
</div>
);
};
export const CompactTimeZoneOption: React.FC<PropsWithChildren<Props>> = (props, ref) => {
const { children, innerProps, data, isSelected, isFocused } = props;
const theme = useTheme();
const styles = getStyles(theme);
const timestamp = Date.now();
const containerStyles = cx(styles.container, isFocused && styles.containerFocused);
if (!isString(data.value)) {
return null;
}
return (
<div className={containerStyles} {...innerProps} aria-label="Select option">
<div className={styles.body}>
<div className={styles.row}>
<div className={styles.leftColumn}>
<TimeZoneTitle title={children} />
</div>
<div className={styles.rightColumn}>
{isSelected && (
<span>
<Icon name="check" />
</span>
)}
</div>
</div>
<div className={styles.row}>
<div className={styles.leftColumn}>
<TimeZoneDescription info={getTimeZoneInfo(data.value, timestamp)} />
</div>
<div className={styles.rightColumn}>
<TimeZoneOffset timestamp={timestamp} timeZone={data.value} className={offsetClassName} />
</div>
</div>
</div>
</div>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
const offsetHoverBg = theme.isDark ? theme.palette.gray05 : theme.palette.white;
return {
container: css`
display: flex;
align-items: center;
flex-direction: row;
flex-shrink: 0;
white-space: nowrap;
cursor: pointer;
border-left: 2px solid transparent;
padding: 6px 8px 4px;
&:hover {
background: ${theme.colors.dropdownOptionHoverBg};
span.${offsetClassName} {
background: ${offsetHoverBg};
}
}
`,
containerFocused: css`
background: ${theme.colors.dropdownOptionHoverBg};
border-image: linear-gradient(#f05a28 30%, #fbca0a 99%);
border-image-slice: 1;
border-style: solid;
border-top: 0;
border-right: 0;
border-bottom: 0;
border-left-width: 2px;
span.${offsetClassName} {
background: ${offsetHoverBg};
}
`,
body: css`
display: flex;
font-weight: ${theme.typography.weight.semibold};
flex-direction: column;
flex-grow: 1;
`,
row: css`
display: flex;
flex-direction: row;
`,
leftColumn: css`
flex-grow: 1;
text-overflow: ellipsis;
`,
rightColumn: css`
justify-content: flex-end;
align-items: center;
`,
wideRow: css`
display: flex;
flex-direction: row;
align-items: baseline;
`,
spacer: css`
margin-left: 6px;
`,
};
});
import React, { ReactNode } from 'react';
import { css } from 'emotion';
import { GrafanaTheme } from '@grafana/data';
import { useTheme, stylesFactory } from '../../../themes';
interface Props {
title: string | ReactNode;
}
export const TimeZoneTitle: React.FC<Props> = ({ title }) => {
const theme = useTheme();
const styles = getStyles(theme);
if (!title) {
return null;
}
return <span className={styles.title}>{title}</span>;
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
title: css`
font-weight: ${theme.typography.weight.regular};
text-overflow: ellipsis;
`,
};
});
......@@ -23,8 +23,8 @@ export { UnitPicker } from './UnitPicker/UnitPicker';
export { StatsPicker } from './StatsPicker/StatsPicker';
export { RefreshPicker } from './RefreshPicker/RefreshPicker';
export { TimeRangePicker } from './TimePicker/TimeRangePicker';
export { TimeZonePicker } from './TimePicker/TimeZonePicker';
export { TimeOfDayPicker } from './TimePicker/TimeOfDayPicker';
export { TimeZonePicker } from './TimePicker/TimeZonePicker';
export { List } from './List/List';
export { TagsInput } from './TagsInput/TagsInput';
export { Pagination } from './Pagination/Pagination';
......
......@@ -94,7 +94,7 @@ export class GrafanaApp {
addClassIfNoOverlayScrollbar();
setLocale(config.bootData.user.locale);
setTimeZoneResolver(() => config.bootData.user.timeZone);
setTimeZoneResolver(() => config.bootData.user.timezone);
setMarkdownOptions({ sanitize: !config.disableSanitizeHtml });
......
......@@ -12,8 +12,9 @@ import {
Button,
RadioButtonGroup,
FieldSet,
TimeZonePicker,
} from '@grafana/ui';
import { getTimeZoneGroups, SelectableValue } from '@grafana/data';
import { SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { DashboardSearchHit, DashboardSearchItemType } from 'app/features/search/types';
......@@ -36,18 +37,6 @@ const themes: SelectableValue[] = [
{ value: 'light', label: 'Light' },
];
const grafanaTimeZones = [
{ value: '', label: 'Default' },
{ value: 'browser', label: 'Local browser time' },
{ value: 'utc', label: 'UTC' },
];
const timeZones = getTimeZoneGroups().reduce((tzs, group) => {
const options = group.options.map(tz => ({ value: tz, label: tz }));
tzs.push.apply(tzs, options);
return tzs;
}, grafanaTimeZones);
export class SharedPreferences extends PureComponent<Props, State> {
backendSrv = backendSrv;
......@@ -112,11 +101,11 @@ export class SharedPreferences extends PureComponent<Props, State> {
this.setState({ theme: value });
};
onTimeZoneChanged = (timezone: SelectableValue<string>) => {
if (!timezone || typeof timezone.value !== 'string') {
onTimeZoneChanged = (timezone: string) => {
if (!timezone) {
return;
}
this.setState({ timezone: timezone.value });
this.setState({ timezone: timezone });
};
onHomeDashboardChanged = (dashboardId: number) => {
......@@ -168,12 +157,7 @@ export class SharedPreferences extends PureComponent<Props, State> {
</Field>
<Field label="Timezone" aria-label={selectors.components.TimeZonePicker.container}>
<Select
isSearchable={true}
value={timeZones.find(item => item.value === timezone)}
onChange={this.onTimeZoneChanged}
options={timeZones}
/>
<TimeZonePicker value={timezone} onChange={this.onTimeZoneChanged} />
</Field>
<div className="gf-form-button-row">
<Button variant="primary">Save</Button>
......
// Libaries
import React, { PureComponent, FC, ReactNode } from 'react';
import { connect } from 'react-redux';
import { connect, MapDispatchToProps } from 'react-redux';
import { css } from 'emotion';
// Utils & Services
import { appEvents } from 'app/core/app_events';
......@@ -13,6 +13,7 @@ import { textUtil } from '@grafana/data';
import { BackButton } from 'app/core/components/BackButton/BackButton';
// State
import { updateLocation } from 'app/core/actions';
import { updateTimeZoneForSession } from 'app/features/profile/state/reducers';
// Types
import { DashboardModel } from '../../state';
import { CoreEvents, StoreState } from 'app/types';
......@@ -23,10 +24,14 @@ export interface OwnProps {
dashboard: DashboardModel;
isFullscreen: boolean;
$injector: any;
updateLocation: typeof updateLocation;
onAddPanel: () => void;
}
interface DispatchProps {
updateTimeZoneForSession: typeof updateTimeZoneForSession;
updateLocation: typeof updateLocation;
}
interface DashNavButtonModel {
show: (props: Props) => boolean;
component: FC<Partial<Props>>;
......@@ -48,7 +53,7 @@ export interface StateProps {
location: any;
}
type Props = StateProps & OwnProps;
type Props = StateProps & OwnProps & DispatchProps;
class DashNav extends PureComponent<Props> {
playlistSrv: PlaylistSrv;
......@@ -277,7 +282,7 @@ class DashNav extends PureComponent<Props> {
}
render() {
const { dashboard, location, isFullscreen } = this.props;
const { dashboard, location, isFullscreen, updateTimeZoneForSession } = this.props;
return (
<div className="navbar">
......@@ -315,7 +320,11 @@ class DashNav extends PureComponent<Props> {
{!dashboard.timepicker.hidden && (
<div className="navbar-buttons">
<DashNavTimeControls dashboard={dashboard} location={location} updateLocation={updateLocation} />
<DashNavTimeControls
dashboard={dashboard}
location={location}
onChangeTimeZone={updateTimeZoneForSession}
/>
</div>
)}
</div>
......@@ -327,8 +336,9 @@ const mapStateToProps = (state: StoreState) => ({
location: state.location,
});
const mapDispatchToProps = {
const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = {
updateLocation,
updateTimeZoneForSession,
};
export default connect(mapStateToProps, mapDispatchToProps)(DashNav);
// Libaries
import React, { Component } from 'react';
import { dateMath, GrafanaTheme } from '@grafana/data';
import { dateMath, GrafanaTheme, TimeZone } from '@grafana/data';
import { css } from 'emotion';
// Types
......@@ -9,7 +9,7 @@ import { LocationState, CoreEvents } from 'app/types';
import { TimeRange } from '@grafana/data';
// State
import { updateLocation } from 'app/core/actions';
import { updateTimeZoneForSession } from 'app/features/profile/state/reducers';
// Components
import { RefreshPicker, withTheme, stylesFactory, Themeable } from '@grafana/ui';
......@@ -31,8 +31,8 @@ const getStyles = stylesFactory((theme: GrafanaTheme) => {
export interface Props extends Themeable {
dashboard: DashboardModel;
updateLocation: typeof updateLocation;
location: LocationState;
onChangeTimeZone: typeof updateTimeZoneForSession;
}
class UnthemedDashNavTimeControls extends Component<Props> {
componentDidMount() {
......@@ -87,6 +87,12 @@ class UnthemedDashNavTimeControls extends Component<Props> {
getTimeSrv().setTime(nextRange);
};
onChangeTimeZone = (timeZone: TimeZone) => {
this.props.dashboard.timezone = timeZone;
this.props.onChangeTimeZone(timeZone);
this.onRefresh();
};
onZoom = () => {
appEvents.emit(CoreEvents.zoomOut, 2);
};
......@@ -109,6 +115,7 @@ class UnthemedDashNavTimeControls extends Component<Props> {
onMoveBackward={this.onMoveBack}
onMoveForward={this.onMoveForward}
onZoom={this.onZoom}
onChangeTimeZone={this.onChangeTimeZone}
/>
<RefreshPicker
onIntervalChanged={this.onChangeRefreshInterval}
......
import React, { PureComponent } from 'react';
import { Select, Input, Tooltip, LegacyForms } from '@grafana/ui';
import { TimeZonePicker, Input, Tooltip, LegacyForms } from '@grafana/ui';
import { DashboardModel } from '../../state/DashboardModel';
import { getTimeZoneGroups, TimeZone, rangeUtil, SelectableValue } from '@grafana/data';
import { TimeZone, rangeUtil } from '@grafana/data';
import { config } from '@grafana/runtime';
import kbn from 'app/core/utils/kbn';
import isEmpty from 'lodash/isEmpty';
import { selectors } from '@grafana/e2e-selectors';
const grafanaTimeZones = [
{ value: '', label: 'Default' },
{ value: 'browser', label: 'Local browser time' },
{ value: 'utc', label: 'UTC' },
];
const timeZones = getTimeZoneGroups().reduce((tzs, group) => {
const options = group.options.map(tz => ({ value: tz, label: tz }));
tzs.push.apply(tzs, options);
return tzs;
}, grafanaTimeZones);
interface Props {
getDashboard: () => DashboardModel;
onTimeZoneChange: (timeZone: TimeZone) => void;
......@@ -97,17 +85,16 @@ export class TimePickerSettings extends PureComponent<Props, State> {
this.forceUpdate();
};
onTimeZoneChange = (timeZone: SelectableValue<string>) => {
if (!timeZone || typeof timeZone.value !== 'string') {
onTimeZoneChange = (timeZone: string) => {
if (typeof timeZone !== 'string') {
return;
}
this.props.onTimeZoneChange(timeZone.value);
this.props.onTimeZoneChange(timeZone);
this.forceUpdate();
};
render() {
const dashboard = this.props.getDashboard();
const value = timeZones.find(item => item.value === dashboard.timezone);
return (
<div className="editor-row">
......@@ -115,7 +102,7 @@ export class TimePickerSettings extends PureComponent<Props, State> {
<div className="gf-form-group">
<div className="gf-form" aria-label={selectors.components.TimeZonePicker.container}>
<label className="gf-form-label width-7">Timezone</label>
<Select isSearchable={true} value={value} onChange={this.onTimeZoneChange} options={timeZones} width={40} />
<TimeZonePicker value={dashboard.timezone} onChange={this.onTimeZoneChange} width={40} />
</div>
<div className="gf-form">
......
......@@ -24,6 +24,7 @@ import { PanelEditorUIState, setDiscardChanges } from './state/reducers';
import { getPanelEditorTabs } from './state/selectors';
import { getPanelStateById } from '../../state/selectors';
import { OptionsPaneContent } from './OptionsPaneContent';
import { updateTimeZoneForSession } from 'app/features/profile/state/reducers';
import { DashNavButton } from 'app/features/dashboard/components/DashNav/DashNavButton';
import { VariableModel } from 'app/features/variables/types';
import { getVariables } from 'app/features/variables/state/selectors';
......@@ -54,6 +55,7 @@ interface DispatchProps {
panelEditorCleanUp: typeof panelEditorCleanUp;
setDiscardChanges: typeof setDiscardChanges;
updatePanelEditorUIState: typeof updatePanelEditorUIState;
updateTimeZoneForSession: typeof updateTimeZoneForSession;
}
type Props = OwnProps & ConnectedProps & DispatchProps;
......@@ -220,7 +222,7 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
}
renderPanelToolbar(styles: EditorStyles) {
const { dashboard, location, uiState, variables } = this.props;
const { dashboard, location, uiState, variables, updateTimeZoneForSession } = this.props;
return (
<div className={styles.panelToolbar}>
<HorizontalGroup justify={variables.length > 0 ? 'space-between' : 'flex-end'} align="flex-start">
......@@ -228,7 +230,11 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
<HorizontalGroup>
<RadioButtonGroup value={uiState.mode} options={displayModes} onChange={this.onDiplayModeChange} />
<DashNavTimeControls dashboard={dashboard} location={location} updateLocation={updateLocation} />
<DashNavTimeControls
dashboard={dashboard}
location={location}
onChangeTimeZone={updateTimeZoneForSession}
/>
{!uiState.isPanelOptionsVisible && (
<DashNavButton
onClick={this.onTogglePanelOptions}
......@@ -362,6 +368,7 @@ const mapDispatchToProps: MapDispatchToProps<DispatchProps, OwnProps> = {
panelEditorCleanUp,
setDiscardChanges,
updatePanelEditorUIState,
updateTimeZoneForSession,
};
export const PanelEditor = connect(mapStateToProps, mapDispatchToProps)(PanelEditorUnconnected);
......
......@@ -112,6 +112,7 @@ export class ChangeTracker {
dash.time = 0;
dash.refresh = 0;
dash.schemaVersion = 0;
dash.timezone = 0;
// ignore iteration property
delete dash.iteration;
......
......@@ -23,6 +23,7 @@ export interface Props {
syncedTimes: boolean;
onChangeTimeSync: () => void;
onChangeTime: (range: RawTimeRange) => void;
onChangeTimeZone: (timeZone: TimeZone) => void;
}
export class ExploreTimeControls extends Component<Props> {
......@@ -56,7 +57,7 @@ export class ExploreTimeControls extends Component<Props> {
};
render() {
const { range, timeZone, splitted, syncedTimes, onChangeTimeSync, hideText } = this.props;
const { range, timeZone, splitted, syncedTimes, onChangeTimeSync, hideText, onChangeTimeZone } = this.props;
const timeSyncButton = splitted ? <TimeSyncButton onClick={onChangeTimeSync} isSynced={syncedTimes} /> : undefined;
const timePickerCommonProps = {
value: range,
......@@ -73,6 +74,7 @@ export class ExploreTimeControls extends Component<Props> {
timeSyncButton={timeSyncButton}
isSynced={syncedTimes}
onChange={this.onChangeTimePicker}
onChangeTimeZone={onChangeTimeZone}
/>
);
}
......
......@@ -63,6 +63,7 @@ function createToolbar(supportedModes: ExploreMode[]) {
setDashboardQueriesToUpdateOnLoad={(() => {}) as any}
exploreId={ExploreId.left}
onChangeTime={(() => {}) as any}
onChangeTimeZone={(() => {}) as any}
/>
);
}
......@@ -23,6 +23,7 @@ import {
} from './state/actions';
import { updateLocation } from 'app/core/actions';
import { getTimeZone } from '../profile/state/selectors';
import { updateTimeZoneForSession } from '../profile/state/reducers';
import { getDashboardSrv } from '../dashboard/services/DashboardSrv';
import kbn from '../../core/utils/kbn';
import { ExploreTimeControls } from './ExploreTimeControls';
......@@ -83,6 +84,7 @@ interface DispatchProps {
changeMode: typeof changeMode;
updateLocation: typeof updateLocation;
setDashboardQueriesToUpdateOnLoad: typeof setDashboardQueriesToUpdateOnLoad;
onChangeTimeZone: typeof updateTimeZoneForSession;
}
type Props = StateProps & DispatchProps & OwnProps;
......@@ -180,6 +182,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props> {
originPanelId,
datasourceLoading,
containerWidth,
onChangeTimeZone,
} = this.props;
const styles = getStyles();
......@@ -303,6 +306,7 @@ export class UnConnectedExploreToolbar extends PureComponent<Props> {
syncedTimes={syncedTimes}
onChangeTimeSync={this.onChangeTimeSync}
hideText={showSmallTimePicker}
onChangeTimeZone={onChangeTimeZone}
/>
</div>
)}
......@@ -410,6 +414,7 @@ const mapDispatchToProps: DispatchProps = {
syncTimes,
changeMode: changeMode,
setDashboardQueriesToUpdateOnLoad,
onChangeTimeZone: updateTimeZoneForSession,
};
export const ExploreToolbar = hot(module)(connect(mapStateToProps, mapDispatchToProps)(UnConnectedExploreToolbar));
import { UserState } from 'app/types';
import _ from 'lodash';
import { PayloadAction, createSlice } from '@reduxjs/toolkit';
import { UserState, ThunkResult } from 'app/types';
import config from 'app/core/config';
import { TimeZone } from '@grafana/data';
import { contextSrv } from 'app/core/core';
export const initialState: UserState = {
orgId: config.bootData.user.orgId,
timeZone: config.bootData.user.timezone,
};
export const userReducer = (state = initialState, action: any): UserState => {
return state;
};
export const slice = createSlice({
name: 'user/profile',
initialState,
reducers: {
updateTimeZone: (state, action: PayloadAction<TimeZone>): UserState => {
return {
...state,
timeZone: action.payload,
};
},
},
});
export const updateTimeZoneForSession = (timeZone: TimeZone): ThunkResult<void> => {
return async (dispatch, getState) => {
const { updateTimeZone } = slice.actions;
export default {
user: userReducer,
if (!_.isString(timeZone) || _.isEmpty(timeZone)) {
timeZone = config?.bootData?.user?.timezone;
}
_.set(contextSrv, 'user.timezone', timeZone);
dispatch(updateTimeZone(timeZone));
};
};
export const userReducer = slice.reducer;
export default { user: slice.reducer };
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment