diff --git a/superset-frontend/.storybook/main.js b/superset-frontend/.storybook/main.js
index 65a51bee8f56c..7b15d57dae7eb 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 5aa3420cf1ac0..94a58d15a369a 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 83d1646f94b2a..9d0270a39955f 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 d806ec3b37d3c..fb87f83c9f8bd 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/src/components/TimezoneSelector/TimezoneSelector.stories.tsx b/superset-frontend/src/components/TimezoneSelector/TimezoneSelector.stories.tsx
new file mode 100644
index 0000000000000..cf9d1d6e730ed
--- /dev/null
+++ b/superset-frontend/src/components/TimezoneSelector/TimezoneSelector.stories.tsx
@@ -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 (
+
+ );
+};
+
+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 0000000000000..f0b12d4777537
--- /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(
+ ,
+ );
+ expect(onTimezoneChange).toHaveBeenCalledWith('America/Nassau');
+ });
+ it('renders a TimezoneSelector with the closest value if passed in', async () => {
+ render(
+ ,
+ );
+ 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 0000000000000..b63bf41eb537e
--- /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 => (
+
+ {option.label}
+
+));
+
+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 (
+
+ );
+};
+
+export default TimezoneSelector;