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;

Reply via email to