From ed04d4782c36b518d4f086c9c2766ab42c5d5e24 Mon Sep 17 00:00:00 2001 From: Elizabeth Thompson Date: Mon, 26 Jul 2021 09:30:35 -0700 Subject: [PATCH] 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.stories.tsx | 41 ++++++ .../TimezoneSelector.test.tsx | 48 +++++++ .../src/components/TimezoneSelector/index.tsx | 132 ++++++++++++++++++ 7 files changed, 232 insertions(+), 1 deletion(-) create mode 100644 superset-frontend/src/components/TimezoneSelector/TimezoneSelector.stories.tsx create mode 100644 superset-frontend/src/components/TimezoneSelector/TimezoneSelector.test.tsx create mode 100644 superset-frontend/src/components/TimezoneSelector/index.tsx 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 33e17a090f994..8611b0509042d 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -84591,6 +84591,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 0ad6547b3ac63..fdd3f21fd314d 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -139,6 +139,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;