This is an automated email from the ASF dual-hosted git repository.
jli 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 1f19ef92cb9 refactor(TimezoneSelector): Enhance timezone selection
logic and improve performance (#36486)
1f19ef92cb9 is described below
commit 1f19ef92cb9f6d9deeaf190c5ece37aa79bf18ad
Author: Luis Sánchez <[email protected]>
AuthorDate: Tue Jan 20 00:46:56 2026 -0300
refactor(TimezoneSelector): Enhance timezone selection logic and improve
performance (#36486)
---
.../TimezoneSelector/TimezoneOptionsCache.test.tsx | 256 +++++++++++++++++++++
.../TimezoneSelector/TimezoneOptionsCache.ts | 159 +++++++++++++
.../TimezoneSelector.DaylightSavingTime.test.tsx | 12 +-
.../TimezoneSelector/TimezoneSelector.test.tsx | 36 ++-
.../src/components/TimezoneSelector/index.tsx | 237 +++++++++----------
.../src/components/TimezoneSelector/types.ts | 29 +++
.../src/features/alerts/AlertReportModal.test.tsx | 3 +-
.../src/features/alerts/AlertReportModal.tsx | 50 +++-
8 files changed, 647 insertions(+), 135 deletions(-)
diff --git
a/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneOptionsCache.test.tsx
b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneOptionsCache.test.tsx
new file mode 100644
index 00000000000..0c86e4f9ea9
--- /dev/null
+++
b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneOptionsCache.test.tsx
@@ -0,0 +1,256 @@
+/**
+ * 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 { TimezoneOptionsCache } from './TimezoneOptionsCache';
+import type { OffsetsToName, GetOffsetKeyFn } from './types';
+
+const mockGetOffsetKey: GetOffsetKeyFn = (timezoneName: string) => {
+ // Simplified mock for testing - returns different values for different zones
+ if (timezoneName.includes('New_York')) return '-300-240';
+ if (timezoneName.includes('Los_Angeles')) return '-480-420';
+ if (timezoneName.includes('Chicago')) return '-360-300';
+ if (timezoneName.includes('London')) return '060';
+ return '00';
+};
+
+const mockOffsetsToName: OffsetsToName = {
+ '-300-240': ['Eastern Standard Time', 'Eastern Daylight Time'],
+ '-480-420': ['Pacific Standard Time', 'Pacific Daylight Time'],
+ '-360-300': ['Central Standard Time', 'Central Daylight Time'],
+ '060': ['GMT Standard Time - London', 'British Summer Time'],
+ '00': ['GMT Standard Time', 'GMT Standard Time'],
+};
+
+// Mock Intl.supportedValuesOf to return a controlled set of timezones
+const mockTimezones = [
+ 'America/New_York',
+ 'America/Los_Angeles',
+ 'America/Chicago',
+ 'Europe/London',
+ 'Africa/Abidjan',
+];
+
+beforeAll(() => {
+ global.Intl.supportedValuesOf = jest.fn(() => mockTimezones);
+});
+
+afterAll(() => {
+ jest.restoreAllMocks();
+});
+
+test('initializes with empty cache', () => {
+ const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+ expect(cache.isCached()).toBe(false);
+ expect(cache.getOptions()).toBeNull();
+});
+
+test('isCached returns true after options are computed', async () => {
+ const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+ expect(cache.isCached()).toBe(false);
+
+ await cache.getOptionsAsync();
+
+ expect(cache.isCached()).toBe(true);
+});
+
+test('getOptions returns null before caching', () => {
+ const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+ expect(cache.getOptions()).toBeNull();
+});
+
+test('getOptions returns cached options after computation', async () => {
+ const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+ await cache.getOptionsAsync();
+ const options = cache.getOptions();
+
+ expect(options).not.toBeNull();
+ expect(Array.isArray(options)).toBe(true);
+ expect(options!.length).toBeGreaterThan(0);
+});
+
+test('getOptionsAsync computes and returns timezone options', async () => {
+ const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+ const options = await cache.getOptionsAsync();
+
+ expect(Array.isArray(options)).toBe(true);
+ expect(options.length).toBeGreaterThan(0);
+
+ // Check that each option has the required structure
+ options.forEach(option => {
+ expect(option).toHaveProperty('label');
+ expect(option).toHaveProperty('value');
+ expect(option).toHaveProperty('offsets');
+ expect(option).toHaveProperty('timezoneName');
+ expect(typeof option.label).toBe('string');
+ expect(typeof option.value).toBe('string');
+ expect(typeof option.offsets).toBe('string');
+ expect(typeof option.timezoneName).toBe('string');
+ });
+});
+
+test('getOptionsAsync returns cached options on subsequent calls', async () =>
{
+ const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+ const firstCall = await cache.getOptionsAsync();
+ const secondCall = await cache.getOptionsAsync();
+
+ // Should return the exact same array reference (cached)
+ expect(secondCall).toBe(firstCall);
+});
+
+test('getOptionsAsync deduplicates concurrent calls', async () => {
+ const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+ // Make multiple concurrent calls
+ const promise1 = cache.getOptionsAsync();
+ const promise2 = cache.getOptionsAsync();
+ const promise3 = cache.getOptionsAsync();
+
+ // All promises should be the same instance
+ expect(promise2).toBe(promise1);
+ expect(promise3).toBe(promise1);
+
+ const [result1, result2, result3] = await Promise.all([
+ promise1,
+ promise2,
+ promise3,
+ ]);
+
+ // All results should be the same
+ expect(result2).toBe(result1);
+ expect(result3).toBe(result1);
+});
+
+test('options have correct label format', async () => {
+ const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+ const options = await cache.getOptionsAsync();
+
+ // Check that labels follow the GMT format
+ options.forEach(option => {
+ expect(option.label).toMatch(/^GMT [+-]\d{2}:\d{2} \(.+\)$/);
+ });
+});
+
+test('options include offset information', async () => {
+ const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+ const options = await cache.getOptionsAsync();
+
+ // Verify that options have offset keys
+ options.forEach(option => {
+ expect(option.offsets).toBeTruthy();
+ expect(option.offsets.length).toBeGreaterThan(0);
+ });
+});
+
+test('options deduplicate by label', async () => {
+ const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+ const options = await cache.getOptionsAsync();
+ const labels = options.map(opt => opt.label);
+ const uniqueLabels = new Set(labels);
+
+ // All labels should be unique
+ expect(labels.length).toBe(uniqueLabels.size);
+});
+
+test('each option has timezone name matching its value', async () => {
+ const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+ const options = await cache.getOptionsAsync();
+
+ options.forEach(option => {
+ expect(option.timezoneName).toBe(option.value);
+ });
+});
+
+test('handles errors during computation', async () => {
+ const errorGetOffsetKey: GetOffsetKeyFn = () => {
+ throw new Error('Test error');
+ };
+
+ const cache = new TimezoneOptionsCache(errorGetOffsetKey, mockOffsetsToName);
+
+ await expect(cache.getOptionsAsync()).rejects.toThrow('Test error');
+
+ // Cache should remain empty after error
+ expect(cache.isCached()).toBe(false);
+ expect(cache.getOptions()).toBeNull();
+});
+
+test('allows retry after failed computation', async () => {
+ let shouldFail = true;
+ const conditionalGetOffsetKey: GetOffsetKeyFn = (timezoneName: string) => {
+ if (shouldFail) {
+ throw new Error('Temporary error');
+ }
+ return mockGetOffsetKey(timezoneName);
+ };
+
+ const cache = new TimezoneOptionsCache(
+ conditionalGetOffsetKey,
+ mockOffsetsToName,
+ );
+
+ // First call should fail
+ await expect(cache.getOptionsAsync()).rejects.toThrow('Temporary error');
+ expect(cache.isCached()).toBe(false);
+
+ // Allow success on retry
+ shouldFail = false;
+
+ // Second call should succeed
+ const options = await cache.getOptionsAsync();
+ expect(Array.isArray(options)).toBe(true);
+ expect(options.length).toBeGreaterThan(0);
+ expect(cache.isCached()).toBe(true);
+});
+
+test('uses queueMicrotask when available', async () => {
+ const queueMicrotaskSpy = jest.spyOn(global, 'queueMicrotask');
+ const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+ await cache.getOptionsAsync();
+
+ expect(queueMicrotaskSpy).toHaveBeenCalled();
+
+ queueMicrotaskSpy.mockRestore();
+});
+
+test('falls back to setTimeout when queueMicrotask is not available', async ()
=> {
+ const originalQueueMicrotask = global.queueMicrotask;
+ // @ts-ignore - temporarily remove queueMicrotask for testing
+ delete global.queueMicrotask;
+
+ const setTimeoutSpy = jest.spyOn(global, 'setTimeout');
+ const cache = new TimezoneOptionsCache(mockGetOffsetKey, mockOffsetsToName);
+
+ await cache.getOptionsAsync();
+
+ expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 0);
+
+ setTimeoutSpy.mockRestore();
+ global.queueMicrotask = originalQueueMicrotask;
+});
diff --git
a/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneOptionsCache.ts
b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneOptionsCache.ts
new file mode 100644
index 00000000000..44e180a14a9
--- /dev/null
+++
b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneOptionsCache.ts
@@ -0,0 +1,159 @@
+/**
+ * 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 { isDST, extendedDayjs } from '../../utils/dates';
+import type { TimezoneOption, OffsetsToName, GetOffsetKeyFn } from './types';
+
+// Import dayjs plugin types for TypeScript support
+import 'dayjs/plugin/timezone';
+
+const DEFAULT_TIMEZONE = {
+ name: 'GMT Standard Time',
+ value: 'Africa/Abidjan', // timezones are deduped by the first alphabetical
value
+};
+
+const JANUARY_REF = extendedDayjs.tz('2021-01-01');
+const JULY_REF = extendedDayjs.tz('2021-07-01');
+
+const offsetsToName: 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.name, DEFAULT_TIMEZONE.name],
+ '060': ['GMT Standard Time - London', 'British Summer Time'],
+};
+
+export function getOffsetKey(timezoneName: string): string {
+ return (
+ JANUARY_REF.tz(timezoneName).utcOffset().toString() +
+ JULY_REF.tz(timezoneName).utcOffset().toString()
+ );
+}
+
+export { DEFAULT_TIMEZONE };
+
+function getTimezoneDisplayName(
+ timezoneName: string,
+ currentDate: ReturnType<typeof extendedDayjs>,
+ getOffsetKey: GetOffsetKeyFn,
+ offsetsToName: OffsetsToName,
+): string {
+ const offsetKey = getOffsetKey(timezoneName);
+ const dateInZone = currentDate.tz(timezoneName);
+ const isDSTActive = isDST(dateInZone, timezoneName);
+ const namePair = offsetsToName[offsetKey];
+ return namePair ? (isDSTActive ? namePair[1] : namePair[0]) : timezoneName;
+}
+
+export class TimezoneOptionsCache {
+ private cachedOptions: TimezoneOption[] | null = null;
+
+ private computePromise: Promise<TimezoneOption[]> | null = null;
+
+ constructor(
+ private getOffsetKey: GetOffsetKeyFn,
+ private offsetsToName: OffsetsToName,
+ ) {}
+
+ public isCached(): boolean {
+ return this.cachedOptions !== null;
+ }
+
+ public getOptions(): TimezoneOption[] | null {
+ return this.cachedOptions;
+ }
+
+ private computeOptions(): TimezoneOption[] {
+ const currentDate = extendedDayjs(new Date());
+ const allZones = Intl.supportedValuesOf('timeZone');
+ const seenLabels = new Set<string>();
+ const options: TimezoneOption[] = [];
+
+ for (const zone of allZones) {
+ const offsetKey = this.getOffsetKey(zone);
+ const displayName = getTimezoneDisplayName(
+ zone,
+ currentDate,
+ this.getOffsetKey,
+ this.offsetsToName,
+ );
+ const offset = currentDate.tz(zone).format('Z');
+ const label = `GMT ${offset} (${displayName})`;
+
+ if (!seenLabels.has(label)) {
+ seenLabels.add(label);
+ options.push({
+ label,
+ value: zone,
+ offsets: offsetKey,
+ timezoneName: zone,
+ });
+ }
+ }
+
+ this.cachedOptions = options;
+ return options;
+ }
+
+ public getOptionsAsync(): Promise<TimezoneOption[]> {
+ if (this.cachedOptions) {
+ return Promise.resolve(this.cachedOptions);
+ }
+
+ if (this.computePromise) {
+ return this.computePromise;
+ }
+
+ // Use queueMicrotask for better performance than setTimeout(0)
+ // Falls back to setTimeout for older browsers
+ this.computePromise = new Promise<TimezoneOption[]>((resolve, reject) => {
+ const run = () => {
+ try {
+ const result = this.computeOptions();
+ resolve(result);
+ } catch (err) {
+ reject(err);
+ } finally {
+ this.computePromise = null;
+ }
+ };
+ if (typeof queueMicrotask === 'function') {
+ queueMicrotask(run);
+ } else {
+ setTimeout(run, 0);
+ }
+ });
+
+ return this.computePromise;
+ }
+}
+
+// Export singleton instance
+export const timezoneOptionsCache = new TimezoneOptionsCache(
+ getOffsetKey,
+ offsetsToName,
+);
diff --git
a/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneSelector.DaylightSavingTime.test.tsx
b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneSelector.DaylightSavingTime.test.tsx
index 21fa1fc9abf..3115ccccdd9 100644
---
a/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneSelector.DaylightSavingTime.test.tsx
+++
b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneSelector.DaylightSavingTime.test.tsx
@@ -18,7 +18,8 @@
*/
import { FC } from 'react';
-import { render, waitFor, screen, userEvent } from '@superset-ui/core/spec';
+import { render, screen, userEvent } from '@superset-ui/core/spec';
+import '@testing-library/jest-dom';
import type { TimezoneSelectorProps } from './index';
const loadComponent = (mockCurrentTime?: string) => {
@@ -46,12 +47,15 @@ test('render timezones in correct order for daylight saving
time', async () => {
/>,
);
+ // Wait for loading to complete by waiting for expected timezone text
+ await screen.findByText('GMT -04:00 (Eastern Daylight Time)');
+
const searchInput = screen.getByRole('combobox');
await userEvent.click(searchInput);
- const options = await waitFor(() =>
- document.querySelectorAll('.ant-select-item-option-content'),
- );
+ // Wait for options to appear by finding one of the expected timezone texts
+ await screen.findByText('GMT -11:00 (Pacific/Midway)');
+ const options = document.querySelectorAll('.ant-select-item-option-content');
// first option is always current timezone
expect(options[0]).toHaveTextContent('GMT -04:00 (Eastern Daylight Time)');
diff --git
a/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneSelector.test.tsx
b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneSelector.test.tsx
index ce31337aa31..1be15b93163 100644
---
a/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneSelector.test.tsx
+++
b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/TimezoneSelector.test.tsx
@@ -17,7 +17,8 @@
* under the License.
*/
import { FC } from 'react';
-import { render, screen, userEvent, waitFor } from '@superset-ui/core/spec';
+import { render, screen, userEvent } from '@superset-ui/core/spec';
+import '@testing-library/jest-dom';
import { extendedDayjs } from '../../utils/dates';
import type { TimezoneSelectorProps } from './index';
@@ -33,7 +34,7 @@ const loadComponent = (mockCurrentTime?: string) => {
};
const getSelectOptions = () =>
- waitFor(() => document.querySelectorAll('.ant-select-item-option-content'));
+ document.querySelectorAll('.ant-select-item-option-content');
const openSelectMenu = () => {
const searchInput = screen.getByRole('combobox');
@@ -50,7 +51,8 @@ test('use the timezone from `dayjs` if no timezone provided',
async () => {
const TimezoneSelector = await loadComponent('2022-01-01');
const onTimezoneChange = jest.fn();
render(<TimezoneSelector onTimezoneChange={onTimezoneChange} />);
- expect(onTimezoneChange).toHaveBeenCalledTimes(1);
+ // Wait for async loading and default timezone to be set
+ await screen.findByText('GMT -05:00 (Eastern Standard Time)');
expect(onTimezoneChange).toHaveBeenCalledWith('America/Detroit');
});
@@ -63,8 +65,14 @@ test('update to closest deduped timezone when timezone is
provided', async () =>
timezone="America/Tijuana"
/>,
);
- expect(onTimezoneChange).toHaveBeenCalledTimes(1);
- expect(onTimezoneChange).toHaveBeenLastCalledWith('America/Los_Angeles');
+ // Wait for async loading to complete by waiting for a timezone option to
appear
+ await screen.findByText('GMT -08:00 (Pacific Standard Time)');
+ // When timezone is provided, onTimezoneChange is not called automatically
+ // The component just displays the matching timezone
+ expect(onTimezoneChange).not.toHaveBeenCalled();
+ // Verify the component displays the deduped timezone by checking the select
value
+ const selectInput = screen.getByRole('combobox');
+ expect(selectInput).toBeInTheDocument();
});
test('use the default timezone when an invalid timezone is provided', async ()
=> {
@@ -73,8 +81,14 @@ test('use the default timezone when an invalid timezone is
provided', async () =
render(
<TimezoneSelector onTimezoneChange={onTimezoneChange} timezone="UTC" />,
);
- expect(onTimezoneChange).toHaveBeenCalledTimes(1);
- expect(onTimezoneChange).toHaveBeenLastCalledWith('Africa/Abidjan');
+ // Wait for async loading to complete by waiting for default timezone text
to appear
+ await screen.findByText('GMT +00:00 (GMT Standard Time)');
+ // When timezone is provided (even if invalid), onTimezoneChange is not
called automatically
+ // The component uses findMatchingTimezone to find the closest match or
default
+ expect(onTimezoneChange).not.toHaveBeenCalled();
+ // Verify the component displays the default timezone by checking the select
value
+ const selectInput = screen.getByRole('combobox');
+ expect(selectInput).toBeInTheDocument();
});
test('render timezones in correct order for standard time', async () => {
@@ -86,6 +100,8 @@ test('render timezones in correct order for standard time',
async () => {
timezone="America/Nassau"
/>,
);
+ // Wait for loading to complete by waiting for expected timezone text
+ await screen.findByText('GMT -05:00 (Eastern Standard Time)');
openSelectMenu();
const options = await getSelectOptions();
expect(options[0]).toHaveTextContent('GMT -05:00 (Eastern Standard Time)');
@@ -102,6 +118,8 @@ test('can select a timezone values and returns canonical
timezone name', async (
timezone="Africa/Abidjan"
/>,
);
+ // Wait for loading to complete by waiting for timezone text to appear
+ await screen.findByText('GMT +00:00 (GMT Standard Time)');
openSelectMenu();
const searchInput = screen.getByRole('combobox');
@@ -123,6 +141,8 @@ test('can update props and rerender with different values',
async () => {
timezone="Asia/Dubai"
/>,
);
+ // Wait for loading to complete and timezone to be displayed
+ await screen.findByTitle('GMT +04:00 (Asia/Dubai)');
expect(screen.getByTitle('GMT +04:00 (Asia/Dubai)')).toBeInTheDocument();
rerender(
<TimezoneSelector
@@ -130,5 +150,7 @@ test('can update props and rerender with different values',
async () => {
timezone="Australia/Perth"
/>,
);
+ // Wait for rerender to complete
+ await screen.findByTitle('GMT +08:00 (Australia/Perth)');
expect(screen.getByTitle('GMT +08:00
(Australia/Perth)')).toBeInTheDocument();
});
diff --git
a/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/index.tsx
b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/index.tsx
index 7eef109a3a2..aedd9f6c1f1 100644
---
a/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/index.tsx
+++
b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/index.tsx
@@ -17,146 +17,149 @@
* under the License.
*/
-import { useEffect, useMemo } from 'react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { t } from '@apache-superset/core';
import { Select } from '@superset-ui/core/components';
-import { isDST, extendedDayjs } from '../../utils/dates';
+import { extendedDayjs } from '../../utils/dates';
+import {
+ timezoneOptionsCache,
+ getOffsetKey,
+ DEFAULT_TIMEZONE,
+} from './TimezoneOptionsCache';
+import type { TimezoneOption } from './types';
-const DEFAULT_TIMEZONE = {
- name: 'GMT Standard Time',
- value: 'Africa/Abidjan', // timezones are deduped by the first alphabetical
value
-};
-
-const MIN_SELECT_WIDTH = '400px';
-
-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.name, DEFAULT_TIMEZONE.name],
- '060': ['GMT Standard Time - London', 'British Summer Time'],
-};
+// Import dayjs plugin types for TypeScript support
+import 'dayjs/plugin/timezone';
export type TimezoneSelectorProps = {
onTimezoneChange: (value: string) => void;
timezone?: string | null;
minWidth?: string;
+ placeholder?: string;
};
+const MIN_SELECT_WIDTH = '400px';
+
+function findMatchingTimezone(
+ timezone: string | null | undefined,
+ options: TimezoneOption[],
+): string {
+ const targetTimezone = timezone || extendedDayjs.tz.guess();
+ const targetOffsetKey = getOffsetKey(targetTimezone);
+ let fallbackValue: string | undefined;
+
+ for (const option of options) {
+ if (
+ option.offsets === targetOffsetKey &&
+ option.timezoneName === targetTimezone
+ ) {
+ return option.value;
+ }
+ if (!fallbackValue && option.offsets === targetOffsetKey) {
+ fallbackValue = option.value;
+ }
+ }
+
+ return fallbackValue || DEFAULT_TIMEZONE.value;
+}
+
export default function TimezoneSelector({
onTimezoneChange,
timezone,
- minWidth = MIN_SELECT_WIDTH, // smallest size for current values
+ minWidth = MIN_SELECT_WIDTH,
+ placeholder,
...rest
}: TimezoneSelectorProps) {
- const { TIMEZONE_OPTIONS, TIMEZONE_OPTIONS_SORT_COMPARATOR, validTimezone } =
- useMemo(() => {
- const currentDate = extendedDayjs();
- const JANUARY = extendedDayjs.tz('2021-01-01');
- const JULY = extendedDayjs.tz('2021-07-01');
-
- const getOffsetKey = (name: string) =>
- JANUARY.tz(name).utcOffset().toString() +
- JULY.tz(name).utcOffset().toString();
-
- const getTimezoneName = (name: string) => {
- const offsets = getOffsetKey(name);
- return (
- (isDST(currentDate.tz(name), name)
- ? offsetsToName[offsets as keyof typeof offsetsToName]?.[1]
- : offsetsToName[offsets as keyof typeof offsetsToName]?.[0]) ||
name
- );
- };
-
- // TODO: remove this ts-ignore when typescript is upgraded to 5.1
- // @ts-ignore
- const ALL_ZONES: string[] = Intl.supportedValuesOf('timeZone');
-
- const labels = new Set<string>();
- const TIMEZONE_OPTIONS = ALL_ZONES.map(zone => {
- const label = `GMT ${extendedDayjs
- .tz(currentDate, zone)
- .format('Z')} (${getTimezoneName(zone)})`;
-
- if (labels.has(label)) {
- return null; // Skip duplicates
- }
- labels.add(label);
- return {
- label,
- value: zone,
- offsets: getOffsetKey(zone),
- timezoneName: zone,
- };
- }).filter(Boolean) as {
- label: string;
- value: string;
- offsets: string;
- timezoneName: string;
- }[];
-
- const TIMEZONE_OPTIONS_SORT_COMPARATOR = (
- a: (typeof TIMEZONE_OPTIONS)[number],
- b: (typeof TIMEZONE_OPTIONS)[number],
- ) =>
- extendedDayjs.tz(currentDate, a.timezoneName).utcOffset() -
- extendedDayjs.tz(currentDate, b.timezoneName).utcOffset();
-
- // pre-sort timezone options by time offset
- TIMEZONE_OPTIONS.sort(TIMEZONE_OPTIONS_SORT_COMPARATOR);
-
- const matchTimezoneToOptions = (timezone: string) => {
- const offsetKey = getOffsetKey(timezone);
- let fallbackValue: string | undefined;
-
- for (const option of TIMEZONE_OPTIONS) {
- if (
- option.offsets === offsetKey &&
- option.timezoneName === timezone
- ) {
- return option.value;
- }
- if (!fallbackValue && option.offsets === offsetKey) {
- fallbackValue = option.value;
- }
- }
- return fallbackValue || DEFAULT_TIMEZONE.value;
- };
+ const [timezoneOptions, setTimezoneOptions] = useState<
+ TimezoneOption[] | null
+ >(timezoneOptionsCache.getOptions());
+ const [isLoadingOptions, setIsLoadingOptions] = useState(false);
+ const hasSetDefaultRef = useRef(false);
+
+ const handleOpenChange = useCallback(
+ (isOpen: boolean) => {
+ if (isOpen && !timezoneOptions && !isLoadingOptions) {
+ setIsLoadingOptions(true);
+ timezoneOptionsCache
+ .getOptionsAsync()
+ .then(options => {
+ setTimezoneOptions(options);
+ })
+ .finally(() => setIsLoadingOptions(false));
+ }
+ },
+ [timezoneOptions, isLoadingOptions],
+ );
+
+ const sortComparator = useMemo(() => {
+ if (!timezoneOptions) return undefined;
+ const currentDate = extendedDayjs();
+ const comparator = (a: TimezoneOption, b: TimezoneOption) =>
+ currentDate.tz(a.timezoneName).utcOffset() -
+ currentDate.tz(b.timezoneName).utcOffset();
+ return comparator;
+ }, [timezoneOptions]);
+
+ const validTimezone = useMemo(() => {
+ let result: string | undefined;
+ if (!timezoneOptions) {
+ // Don't call tz.guess() synchronously to avoid blocking render
+ // Return timezone if provided, otherwise undefined (will be set after
options load)
+ result = timezone || undefined;
+ } else {
+ result = findMatchingTimezone(timezone, timezoneOptions);
+ }
+ return result;
+ }, [timezone, timezoneOptions]);
+
+ // Load timezone options asynchronously when component mounts
+ // Parent component (AlertReportModal) already delays mounting until panel
opens
+ useEffect(() => {
+ if (timezoneOptions || isLoadingOptions) return;
+
+ setIsLoadingOptions(true);
- const validTimezone = matchTimezoneToOptions(
- timezone || extendedDayjs.tz.guess(),
- );
+ timezoneOptionsCache
+ .getOptionsAsync()
+ .then(options => {
+ setTimezoneOptions(options);
- return {
- TIMEZONE_OPTIONS,
- TIMEZONE_OPTIONS_SORT_COMPARATOR,
- validTimezone,
- };
- }, [timezone]);
+ // Set default value if no timezone is provided and we haven't set it
yet
+ if (!timezone && !hasSetDefaultRef.current) {
+ const defaultTz = findMatchingTimezone(null, options);
+ onTimezoneChange(defaultTz);
+ hasSetDefaultRef.current = true;
+ }
+ })
+ .finally(() => {
+ setIsLoadingOptions(false);
+ });
+ }, [timezoneOptions, isLoadingOptions, timezone, onTimezoneChange]);
- // force trigger a timezone update if provided `timezone` is not invalid
+ // Set default value when cached options are available on mount
useEffect(() => {
- if (validTimezone && timezone !== validTimezone) {
- onTimezoneChange(validTimezone);
- }
- }, [validTimezone, onTimezoneChange, timezone]);
+ if (!timezoneOptions || timezone || hasSetDefaultRef.current) return;
+
+ const defaultTz = findMatchingTimezone(null, timezoneOptions);
+ onTimezoneChange(defaultTz);
+ hasSetDefaultRef.current = true;
+ }, [timezoneOptions, timezone, onTimezoneChange]);
+
+ const selectValue = timezoneOptions ? validTimezone : undefined;
return (
<Select
ariaLabel={t('Timezone selector')}
- onChange={tz => onTimezoneChange(tz as string)}
- value={validTimezone}
- options={TIMEZONE_OPTIONS}
- sortComparator={TIMEZONE_OPTIONS_SORT_COMPARATOR}
- {...rest}
+ onChange={tz => {
+ onTimezoneChange(tz as string);
+ }}
+ onOpenChange={handleOpenChange}
+ value={selectValue}
+ options={timezoneOptions || []}
+ sortComparator={sortComparator}
+ loading={isLoadingOptions}
+ placeholder={isLoadingOptions ? t('Loading timezones...') : placeholder}
+ {...{ placement: 'topLeft', ...rest }}
/>
);
}
diff --git
a/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/types.ts
b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/types.ts
new file mode 100644
index 00000000000..b8446499c05
--- /dev/null
+++
b/superset-frontend/packages/superset-ui-core/src/components/TimezoneSelector/types.ts
@@ -0,0 +1,29 @@
+/**
+ * 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.
+ */
+
+export type TimezoneOption = {
+ label: string;
+ value: string;
+ offsets: string;
+ timezoneName: string;
+};
+
+export type OffsetsToName = Record<string, [string, string]>;
+
+export type GetOffsetKeyFn = (timezoneName: string) => string;
diff --git a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx
b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx
index 8b288ad3212..6e78540f6ce 100644
--- a/superset-frontend/src/features/alerts/AlertReportModal.test.tsx
+++ b/superset-frontend/src/features/alerts/AlertReportModal.test.tsx
@@ -543,7 +543,8 @@ test('renders default Schedule fields', async () => {
const scheduleType = screen.getByRole('combobox', {
name: /schedule type/i,
});
- const timezone = screen.getByRole('combobox', {
+ // Wait for timezone selector to render after delay
+ const timezone = await screen.findByRole('combobox', {
name: /timezone selector/i,
});
const logRetention = screen.getByRole('combobox', {
diff --git a/superset-frontend/src/features/alerts/AlertReportModal.tsx
b/superset-frontend/src/features/alerts/AlertReportModal.tsx
index 050e3cfaea4..fddbff73a9d 100644
--- a/superset-frontend/src/features/alerts/AlertReportModal.tsx
+++ b/superset-frontend/src/features/alerts/AlertReportModal.tsx
@@ -50,6 +50,7 @@ import {
InfoTooltip,
Input,
InputNumber,
+ Loading,
Select,
Switch,
TreeSelect,
@@ -57,6 +58,7 @@ import {
} from '@superset-ui/core/components';
import TimezoneSelector from '@superset-ui/core/components/TimezoneSelector';
+import { timezoneOptionsCache } from
'@superset-ui/core/components/TimezoneSelector/TimezoneOptionsCache';
import TextAreaControl from 'src/explore/components/controls/TextAreaControl';
import { useCommonConf } from 'src/features/databases/state';
import {
@@ -92,6 +94,7 @@ import { NotificationMethod } from
'./components/NotificationMethod';
import { buildErrorTooltipMessage } from './buildErrorTooltipMessage';
const TIMEOUT_MIN = 1;
+const COLLAPSE_ANIMATION_DURATION = 220;
const TEXT_BASED_VISUALIZATION_TYPES = [
VizType.PivotTable,
'table',
@@ -494,6 +497,14 @@ const AlertReportModal:
FunctionComponent<AlertReportModalProps> = ({
const [currentAlert, setCurrentAlert] =
useState<Partial<AlertObject> | null>();
const [isHidden, setIsHidden] = useState<boolean>(true);
+
+ const [activeCollapsePanel, setActiveCollapsePanel] = useState<
+ string | string[]
+ >('general');
+ // Only delay TimezoneSelector for new alerts; render immediately for
existing ones
+ const [shouldRenderTimezoneSelector, setShouldRenderTimezoneSelector] =
+ useState<boolean>(false);
+
const [contentType, setContentType] = useState<string>('dashboard');
const [reportFormat, setReportFormat] = useState<string>(
DEFAULT_NOTIFICATION_FORMAT,
@@ -1859,6 +1870,9 @@ const AlertReportModal:
FunctionComponent<AlertReportModalProps> = ({
useEffect(() => {
if (resource) {
+ // Render TimezoneSelector immediately in edit mode (data is already
loaded)
+ setShouldRenderTimezoneSelector(true);
+
// Add native filter settings
if (resource.extra?.dashboard?.nativeFilters) {
const filters = resource.extra.dashboard.nativeFilters;
@@ -2025,7 +2039,27 @@ const AlertReportModal:
FunctionComponent<AlertReportModalProps> = ({
<div css={AdditionalStyles}>
<Collapse
expandIconPosition="end"
- defaultActiveKey="general"
+ activeKey={activeCollapsePanel}
+ onChange={key => {
+ setActiveCollapsePanel(key);
+ // Delay rendering TimezoneSelector until after panel animation
completes
+ // Skip delay if options are already cached (instant render on
subsequent opens)
+ const isSchedulePanel = Array.isArray(key)
+ ? key.includes('schedule')
+ : key === 'schedule';
+ if (isSchedulePanel) {
+ const isCached = timezoneOptionsCache.isCached();
+ if (isCached) {
+ // Options are cached, render immediately
+ setShouldRenderTimezoneSelector(true);
+ } else {
+ // First time, delay to avoid blocking panel animation
+ setTimeout(() => {
+ setShouldRenderTimezoneSelector(true);
+ }, COLLAPSE_ANIMATION_DURATION); // Match Collapse animation
duration
+ }
+ }
+ }}
accordion
modalMode
items={[
@@ -2530,11 +2564,15 @@ const AlertReportModal:
FunctionComponent<AlertReportModalProps> = ({
<div className="control-label">
{t('Timezone')} <span className="required">*</span>
</div>
- <TimezoneSelector
- onTimezoneChange={onTimezoneChange}
- timezone={currentAlert?.timezone}
- minWidth="100%"
- />
+ {shouldRenderTimezoneSelector ? (
+ <TimezoneSelector
+ onTimezoneChange={onTimezoneChange}
+ timezone={currentAlert?.timezone}
+ minWidth="100%"
+ />
+ ) : (
+ <Loading size="s" muted position="normal" />
+ )}
</StyledInputContainer>
<StyledInputContainer>
<div className="control-label">