Skip to content

Commit

Permalink
add timezone selector component (#15880)
Browse files Browse the repository at this point in the history
  • Loading branch information
eschutho authored Jul 26, 2021
1 parent 6d3e19d commit b81f120
Show file tree
Hide file tree
Showing 7 changed files with 232 additions and 1 deletion.
2 changes: 1 addition & 1 deletion superset-frontend/.storybook/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*/
const path = require('path');

// Suerset's webpack.config.js
// Superset's webpack.config.js
const customConfig = require('../webpack.config.js');

module.exports = {
Expand Down
1 change: 1 addition & 0 deletions superset-frontend/babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ module.exports = {
['@babel/plugin-proposal-class-properties', { loose: true }],
['@babel/plugin-proposal-optional-chaining', { loose: true }],
['@babel/plugin-proposal-private-methods', { loose: true }],
['@babel/plugin-proposal-nullish-coalescing-operator', { loose: true }],
['@babel/plugin-transform-runtime', { corejs: 3 }],
'react-hot-loader/babel',
],
Expand Down
8 changes: 8 additions & 0 deletions superset-frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions superset-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@
"mathjs": "^8.0.1",
"memoize-one": "^5.1.1",
"moment": "^2.26.0",
"moment-timezone": "^0.5.33",
"mousetrap": "^1.6.1",
"mustache": "^2.2.1",
"omnibar": "^2.1.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { useArgs } from '@storybook/client-api';
import TimezoneSelector, { TimezoneProps } from './index';

export default {
title: 'TimezoneSelector',
component: TimezoneSelector,
};

// eslint-disable-next-line @typescript-eslint/no-unused-vars
export const InteractiveTimezoneSelector = (args: TimezoneProps) => {
const [{ timezone }, updateArgs] = useArgs();
const onTimezoneChange = (value: string) => {
updateArgs({ timezone: value });
};
return (
<TimezoneSelector timezone={timezone} onTimezoneChange={onTimezoneChange} />
);
};

InteractiveTimezoneSelector.args = {
timezone: 'America/Los_Angeles',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import moment from 'moment-timezone';
import { render } from 'spec/helpers/testing-library';
import TimezoneSelector from './index';

describe('TimezoneSelector', () => {
let timezone: string;
const onTimezoneChange = jest.fn(zone => {
timezone = zone;
});
it('renders a TimezoneSelector with a default if undefined', () => {
jest.spyOn(moment.tz, 'guess').mockReturnValue('America/New_York');
render(
<TimezoneSelector
onTimezoneChange={onTimezoneChange}
timezone={timezone}
/>,
);
expect(onTimezoneChange).toHaveBeenCalledWith('America/Nassau');
});
it('renders a TimezoneSelector with the closest value if passed in', async () => {
render(
<TimezoneSelector
onTimezoneChange={onTimezoneChange}
timezone="America/Los_Angeles"
/>,
);
expect(onTimezoneChange).toHaveBeenLastCalledWith('America/Vancouver');
});
});
132 changes: 132 additions & 0 deletions superset-frontend/src/components/TimezoneSelector/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/

import React, { useEffect, useRef } from 'react';
import moment from 'moment-timezone';

import { NativeGraySelect as Select } from 'src/components/Select';

const DEFAULT_TIMEZONE = 'GMT Standard Time';
const MIN_SELECT_WIDTH = '375px';

const offsetsToName = {
'-300-240': ['Eastern Standard Time', 'Eastern Daylight Time'],
'-360-300': ['Central Standard Time', 'Central Daylight Time'],
'-420-360': ['Mountain Standard Time', 'Mountain Daylight Time'],
'-420-420': [
'Mountain Standard Time - Phoenix',
'Mountain Standard Time - Phoenix',
],
'-480-420': ['Pacific Standard Time', 'Pacific Daylight Time'],
'-540-480': ['Alaska Standard Time', 'Alaska Daylight Time'],
'-600-600': ['Hawaii Standard Time', 'Hawaii Daylight Time'],
'60120': ['Central European Time', 'Central European Daylight Time'],
'00': [DEFAULT_TIMEZONE, DEFAULT_TIMEZONE],
'060': ['GMT Standard Time - London', 'British Summer Time'],
};

const currentDate = moment();
const JANUARY = moment([2021, 1]);
const JULY = moment([2021, 7]);

const getOffsetKey = (name: string) =>
JANUARY.tz(name).utcOffset().toString() +
JULY.tz(name).utcOffset().toString();

const getTimezoneName = (name: string) => {
const offsets = getOffsetKey(name);
return (
(currentDate.tz(name).isDST()
? offsetsToName[offsets]?.[1]
: offsetsToName[offsets]?.[0]) || name
);
};

export interface TimezoneProps {
onTimezoneChange: (value: string) => void;
timezone?: string | null;
}

const ALL_ZONES = moment.tz
.countries()
.map(country => moment.tz.zonesForCountry(country, true))
.flat();

const TIMEZONES: moment.MomentZoneOffset[] = [];
ALL_ZONES.forEach(zone => {
if (
!TIMEZONES.find(
option => getOffsetKey(option.name) === getOffsetKey(zone.name),
)
) {
TIMEZONES.push(zone); // dedupe zones by offsets
}
});

const TIMEZONE_OPTIONS = TIMEZONES.sort(
// sort by offset
(a, b) =>
moment.tz(currentDate, a.name).utcOffset() -
moment.tz(currentDate, b.name).utcOffset(),
).map(zone => ({
label: `GMT ${moment
.tz(currentDate, zone.name)
.format('Z')} (${getTimezoneName(zone.name)})`,
value: zone.name,
offsets: getOffsetKey(zone.name),
}));

const timezoneOptions = TIMEZONE_OPTIONS.map(option => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
));

const TimezoneSelector = ({ onTimezoneChange, timezone }: TimezoneProps) => {
const prevTimezone = useRef(timezone);
const matchTimezoneToOptions = (timezone: string) =>
TIMEZONE_OPTIONS.find(option => option.offsets === getOffsetKey(timezone))
?.value || DEFAULT_TIMEZONE;

const updateTimezone = (tz: string) => {
// update the ref to track changes
prevTimezone.current = tz;
// the parent component contains the state for the value
onTimezoneChange(tz);
};

useEffect(() => {
const updatedTz = matchTimezoneToOptions(timezone || moment.tz.guess());
if (prevTimezone.current !== updatedTz) {
updateTimezone(updatedTz);
}
}, [timezone]);

return (
<Select
css={{ minWidth: MIN_SELECT_WIDTH }} // smallest size for current values
onChange={onTimezoneChange}
value={timezone || DEFAULT_TIMEZONE}
>
{timezoneOptions}
</Select>
);
};

export default TimezoneSelector;

0 comments on commit b81f120

Please sign in to comment.