diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json index 1a4a8b71f..dbebf1311 100644 --- a/superset-frontend/package-lock.json +++ b/superset-frontend/package-lock.json @@ -261,6 +261,7 @@ "less-loader": "^10.2.0", "mini-css-extract-plugin": "^2.9.0", "mock-socket": "^9.3.1", + "moment-locales-webpack-plugin": "^1.2.0", "node-fetch": "^2.6.7", "po2json": "^0.4.5", "prettier": "3.1.0", @@ -41429,6 +41430,13 @@ "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", "dev": true }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.escape": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz", @@ -47666,6 +47674,20 @@ "node": "*" } }, + "node_modules/moment-locales-webpack-plugin": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/moment-locales-webpack-plugin/-/moment-locales-webpack-plugin-1.2.0.tgz", + "integrity": "sha512-QAi5v0OlPUP7GXviKMtxnpBAo8WmTHrUNN7iciAhNOEAd9evCOvuN0g1N7ThIg3q11GLCkjY1zQ2saRcf/43nQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash.difference": "^4.5.0" + }, + "peerDependencies": { + "moment": "^2.8.0", + "webpack": "^1 || ^2 || ^3 || ^4 || ^5" + } + }, "node_modules/moment-timezone": { "version": "0.5.44", "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.44.tgz", @@ -99486,6 +99508,12 @@ "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", "dev": true }, + "lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "dev": true + }, "lodash.escape": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-4.0.1.tgz", @@ -103398,6 +103426,15 @@ "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==" }, + "moment-locales-webpack-plugin": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/moment-locales-webpack-plugin/-/moment-locales-webpack-plugin-1.2.0.tgz", + "integrity": "sha512-QAi5v0OlPUP7GXviKMtxnpBAo8WmTHrUNN7iciAhNOEAd9evCOvuN0g1N7ThIg3q11GLCkjY1zQ2saRcf/43nQ==", + "dev": true, + "requires": { + "lodash.difference": "^4.5.0" + } + }, "moment-timezone": { "version": "0.5.44", "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.44.tgz", diff --git a/superset-frontend/package.json b/superset-frontend/package.json index 1283bd6a8..aae88e280 100644 --- a/superset-frontend/package.json +++ b/superset-frontend/package.json @@ -326,6 +326,7 @@ "less-loader": "^10.2.0", "mini-css-extract-plugin": "^2.9.0", "mock-socket": "^9.3.1", + "moment-locales-webpack-plugin": "^1.2.0", "node-fetch": "^2.6.7", "po2json": "^0.4.5", "prettier": "3.1.0", diff --git a/superset-frontend/src/components/TimezoneSelector/TimezoneSelector.DaylightSavingTime.test.tsx b/superset-frontend/src/components/TimezoneSelector/TimezoneSelector.DaylightSavingTime.test.tsx index 81dbc6fdc..f30eeb2d5 100644 --- a/superset-frontend/src/components/TimezoneSelector/TimezoneSelector.DaylightSavingTime.test.tsx +++ b/superset-frontend/src/components/TimezoneSelector/TimezoneSelector.DaylightSavingTime.test.tsx @@ -18,7 +18,12 @@ */ import { FC } from 'react'; -import { render, waitFor, screen } from 'spec/helpers/testing-library'; +import { + render, + waitFor, + screen, + waitForElementToBeRemoved, +} from 'spec/helpers/testing-library'; import userEvent from '@testing-library/user-event'; import type { TimezoneSelectorProps } from './index'; @@ -44,6 +49,7 @@ test('render timezones in correct order for daylight saving time', async () => { />, ); + await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading')); const searchInput = screen.getByRole('combobox'); userEvent.click(searchInput); diff --git a/superset-frontend/src/components/TimezoneSelector/TimezoneSelector.test.tsx b/superset-frontend/src/components/TimezoneSelector/TimezoneSelector.test.tsx index 173118c7e..c10ecf2b0 100644 --- a/superset-frontend/src/components/TimezoneSelector/TimezoneSelector.test.tsx +++ b/superset-frontend/src/components/TimezoneSelector/TimezoneSelector.test.tsx @@ -16,10 +16,15 @@ * specific language governing permissions and limitations * under the License. */ -import moment from 'moment-timezone'; import { FC } from 'react'; -import { render, screen, waitFor } from 'spec/helpers/testing-library'; +import moment from 'moment-timezone'; import userEvent from '@testing-library/user-event'; +import { + render, + screen, + waitFor, + waitForElementToBeRemoved, +} from 'spec/helpers/testing-library'; import type { TimezoneSelectorProps } from './index'; const loadComponent = (mockCurrentTime?: string) => { @@ -48,6 +53,8 @@ test('use the timezone from `moment` if no timezone provided', async () => { const TimezoneSelector = await loadComponent('2022-01-01'); const onTimezoneChange = jest.fn(); render(); + expect(screen.getByLabelText('Loading')).toBeVisible(); + await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading')); expect(onTimezoneChange).toHaveBeenCalledTimes(1); expect(onTimezoneChange).toHaveBeenCalledWith('America/Nassau'); }); @@ -61,6 +68,7 @@ test('update to closest deduped timezone when timezone is provided', async () => timezone="America/Los_Angeles" />, ); + await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading')); expect(onTimezoneChange).toHaveBeenCalledTimes(1); expect(onTimezoneChange).toHaveBeenLastCalledWith('America/Vancouver'); }); @@ -71,6 +79,7 @@ test('use the default timezone when an invalid timezone is provided', async () = render( , ); + await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading')); expect(onTimezoneChange).toHaveBeenCalledTimes(1); expect(onTimezoneChange).toHaveBeenLastCalledWith('Africa/Abidjan'); }); @@ -84,12 +93,13 @@ test('render timezones in correct oder for standard time', async () => { timezone="America/Nassau" />, ); + await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading')); openSelectMenu(); const options = await getSelectOptions(); - expect(options[0]).toHaveTextContent('GMT -05:00 (Eastern Standard Time)'); + expect(options[0]).toHaveTextContent('GMT -04:00 (Eastern Daylight Time)'); expect(options[1]).toHaveTextContent('GMT -11:00 (Pacific/Pago_Pago)'); expect(options[2]).toHaveTextContent('GMT -10:00 (Hawaii Standard Time)'); - expect(options[3]).toHaveTextContent('GMT -10:00 (America/Adak)'); + expect(options[3]).toHaveTextContent('GMT -09:30 (Pacific/Marquesas)'); }); test('can select a timezone values and returns canonical timezone name', async () => { @@ -101,13 +111,13 @@ test('can select a timezone values and returns canonical timezone name', async ( timezone="Africa/Abidjan" />, ); - + await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading')); openSelectMenu(); const searchInput = screen.getByRole('combobox'); // search for mountain time await userEvent.type(searchInput, 'mou', { delay: 10 }); - const findTitle = 'GMT -07:00 (Mountain Standard Time)'; + const findTitle = 'GMT -06:00 (Mountain Daylight Time)'; const selectOption = await screen.findByTitle(findTitle); userEvent.click(selectOption); expect(onTimezoneChange).toHaveBeenCalledTimes(1); @@ -123,6 +133,7 @@ test('can update props and rerender with different values', async () => { timezone="Asia/Dubai" />, ); + await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading')); expect(screen.getByTitle('GMT +04:00 (Asia/Dubai)')).toBeInTheDocument(); rerender( - 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 - ); -}; - -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.map(zone => ({ - label: `GMT ${moment - .tz(currentDate, zone.name) - .format('Z')} (${getTimezoneName(zone.name)})`, - value: zone.name, - offsets: getOffsetKey(zone.name), - timezoneName: zone.name, -})); - -const TIMEZONE_OPTIONS_SORT_COMPARATOR = ( - a: (typeof TIMEZONE_OPTIONS)[number], - b: (typeof TIMEZONE_OPTIONS)[number], -) => - moment.tz(currentDate, a.timezoneName).utcOffset() - - moment.tz(currentDate, b.timezoneName).utcOffset(); - -// pre-sort timezone options by time offset -TIMEZONE_OPTIONS.sort(TIMEZONE_OPTIONS_SORT_COMPARATOR); - -const matchTimezoneToOptions = (timezone: string) => - TIMEZONE_OPTIONS.find(option => option.offsets === getOffsetKey(timezone)) - ?.value || DEFAULT_TIMEZONE.value; - export type TimezoneSelectorProps = { onTimezoneChange: (value: string) => void; timezone?: string | null; @@ -112,18 +56,100 @@ export default function TimezoneSelector({ timezone, minWidth = MIN_SELECT_WIDTH, // smallest size for current values }: TimezoneSelectorProps) { - const validTimezone = useMemo( - () => matchTimezoneToOptions(timezone || moment.tz.guess()), - [timezone], - ); + const [momentLib, setMomentLib] = useState< + typeof import('moment-timezone') | null + >(null); + + useEffect(() => { + import('moment-timezone').then(momentLib => + setMomentLib(() => momentLib.default), + ); + }, []); + + const { TIMEZONE_OPTIONS, TIMEZONE_OPTIONS_SORT_COMPARATOR, validTimezone } = + useMemo(() => { + if (!momentLib) { + return {}; + } + const currentDate = momentLib(); + const JANUARY = momentLib([2021, 1]); + const JULY = momentLib([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 + ); + }; + + const ALL_ZONES = momentLib.tz + .countries() + .map(country => momentLib.tz.zonesForCountry(country, true)) + .flat(); + + const TIMEZONES: import('moment-timezone').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.map(zone => ({ + label: `GMT ${momentLib + .tz(currentDate, zone.name) + .format('Z')} (${getTimezoneName(zone.name)})`, + value: zone.name, + offsets: getOffsetKey(zone.name), + timezoneName: zone.name, + })); + + const TIMEZONE_OPTIONS_SORT_COMPARATOR = ( + a: (typeof TIMEZONE_OPTIONS)[number], + b: (typeof TIMEZONE_OPTIONS)[number], + ) => + momentLib.tz(currentDate, a.timezoneName).utcOffset() - + momentLib.tz(currentDate, b.timezoneName).utcOffset(); + + // pre-sort timezone options by time offset + TIMEZONE_OPTIONS.sort(TIMEZONE_OPTIONS_SORT_COMPARATOR); + + const matchTimezoneToOptions = (timezone: string) => + TIMEZONE_OPTIONS.find( + option => option.offsets === getOffsetKey(timezone), + )?.value || DEFAULT_TIMEZONE.value; + + const validTimezone = matchTimezoneToOptions( + timezone || momentLib.tz.guess(), + ); + + return { + TIMEZONE_OPTIONS, + TIMEZONE_OPTIONS_SORT_COMPARATOR, + validTimezone, + }; + }, [momentLib, timezone]); // force trigger a timezone update if provided `timezone` is not invalid useEffect(() => { - if (timezone !== validTimezone) { + if (validTimezone && timezone !== validTimezone) { onTimezoneChange(validTimezone); } }, [validTimezone, onTimezoneChange, timezone]); + if (!TIMEZONE_OPTIONS || !TIMEZONE_OPTIONS_SORT_COMPARATOR) { + return ; + } + return (