This is an automated email from the ASF dual-hosted git repository.
elizabeth pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new b81f120 add timezone selector component (#15880)
b81f120 is described below
commit b81f120916eedc2169bed62ce1843dd7f3c52b9e
Author: Elizabeth Thompson <[email protected]>
AuthorDate: Mon Jul 26 09:30:35 2021 -0700
add timezone selector component (#15880)
---
superset-frontend/.storybook/main.js | 2 +-
superset-frontend/babel.config.js | 1 +
superset-frontend/package-lock.json | 8 ++
superset-frontend/package.json | 1 +
.../TimezoneSelector/TimezoneSelector.stories.tsx} | 48 ++++----
.../TimezoneSelector/TimezoneSelector.test.tsx | 48 ++++++++
.../src/components/TimezoneSelector/index.tsx | 132 +++++++++++++++++++++
7 files changed, 211 insertions(+), 29 deletions(-)
diff --git a/superset-frontend/.storybook/main.js
b/superset-frontend/.storybook/main.js
index 65a51be..7b15d57 100644
--- a/superset-frontend/.storybook/main.js
+++ b/superset-frontend/.storybook/main.js
@@ -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 = {
diff --git a/superset-frontend/babel.config.js
b/superset-frontend/babel.config.js
index 5aa3420..94a58d1 100644
--- a/superset-frontend/babel.config.js
+++ b/superset-frontend/babel.config.js
@@ -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',
],
diff --git a/superset-frontend/package-lock.json
b/superset-frontend/package-lock.json
index 83d1646f..9d0270a 100644
--- a/superset-frontend/package-lock.json
+++ b/superset-frontend/package-lock.json
@@ -30044,6 +30044,14 @@
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.1.tgz",
"integrity":
"sha512-kHmoybcPV8Sqy59DwNDY3Jefr64lK/by/da0ViFcuA4DH0vQg5Q6Ze5VimxkfQNSC+Mls/Kx53s7TjP1RhFEDQ=="
},
+ "moment-timezone": {
+ "version": "0.5.33",
+ "resolved":
"https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.33.tgz",
+ "integrity":
"sha512-PTc2vcT8K9J5/9rDEPe5czSIKgLoGsH8UNpA4qZTVw0Vd/Uz19geE9abbIOQKaAQFcnQ3v5YEXrbSc5BpshH+w==",
+ "requires": {
+ "moment": ">= 2.9.0"
+ }
+ },
"moo": {
"version": "0.4.3",
"resolved": "https://registry.npmjs.org/moo/-/moo-0.4.3.tgz",
diff --git a/superset-frontend/package.json b/superset-frontend/package.json
index d806ec3..fb87f83 100644
--- a/superset-frontend/package.json
+++ b/superset-frontend/package.json
@@ -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",
diff --git a/superset-frontend/.storybook/main.js
b/superset-frontend/src/components/TimezoneSelector/TimezoneSelector.stories.tsx
similarity index 52%
copy from superset-frontend/.storybook/main.js
copy to
superset-frontend/src/components/TimezoneSelector/TimezoneSelector.stories.tsx
index 65a51be..cf9d1d6 100644
--- a/superset-frontend/.storybook/main.js
+++
b/superset-frontend/src/components/TimezoneSelector/TimezoneSelector.stories.tsx
@@ -16,34 +16,26 @@
* specific language governing permissions and limitations
* under the License.
*/
-const path = require('path');
+import React from 'react';
+import { useArgs } from '@storybook/client-api';
+import TimezoneSelector, { TimezoneProps } from './index';
-// Suerset's webpack.config.js
-const customConfig = require('../webpack.config.js');
+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}
/>
+ );
+};
-module.exports = {
- stories: ['../src/@(components|common|filters)/**/*.stories.@(t|j)sx'],
- addons: [
- '@storybook/addon-essentials',
- '@storybook/addon-links',
- '@storybook/preset-typescript',
- 'storybook-addon-jsx',
- '@storybook/addon-knobs/register',
- 'storybook-addon-paddings',
- ],
- webpackFinal: config => ({
- ...config,
- module: {
- ...config.module,
- rules: customConfig.module.rules,
- },
- resolve: {
- ...config.resolve,
- ...customConfig.resolve,
- },
- plugins: [...config.plugins, ...customConfig.plugins],
- }),
- typescript: {
- reactDocgen: 'none',
- },
+InteractiveTimezoneSelector.args = {
+ timezone: 'America/Los_Angeles',
};
diff --git
a/superset-frontend/src/components/TimezoneSelector/TimezoneSelector.test.tsx
b/superset-frontend/src/components/TimezoneSelector/TimezoneSelector.test.tsx
new file mode 100644
index 0000000..f0b12d4
--- /dev/null
+++
b/superset-frontend/src/components/TimezoneSelector/TimezoneSelector.test.tsx
@@ -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');
+ });
+});
diff --git a/superset-frontend/src/components/TimezoneSelector/index.tsx
b/superset-frontend/src/components/TimezoneSelector/index.tsx
new file mode 100644
index 0000000..b63bf41
--- /dev/null
+++ b/superset-frontend/src/components/TimezoneSelector/index.tsx
@@ -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;