diff --git a/apps/web/test/lib/getTimezone.test.ts b/apps/web/test/lib/getTimezone.test.ts index 10c025aedd..a9b5c86c67 100644 --- a/apps/web/test/lib/getTimezone.test.ts +++ b/apps/web/test/lib/getTimezone.test.ts @@ -1,88 +1,28 @@ import { expect, beforeEach, afterEach, it, vi, describe } from "vitest"; -import { filterByCities, addCitiesToDropdown, handleOptionLabel } from "@calcom/lib/timezone"; +import { addTimezonesToDropdown, handleOptionLabel, filterBySearchText } from "@calcom/lib/timezone"; -const cityData = [ - { - city: "San Francisco", - timezone: "America/Argentina/Cordoba", - }, - { - city: "Sao Francisco do Sul", - timezone: "America/Sao_Paulo", - }, - { - city: "San Francisco de Macoris", - timezone: "America/Santo_Domingo", - }, - { - city: "San Francisco Gotera", - timezone: "America/El_Salvador", - }, - { - city: "San Francisco", - timezone: "America/Los_Angeles", - }, - { - city: "Eastern Time - US & Canada", - timezone: "America/New_York", - }, - { - city: "Pacific Time - US & Canada", - timezone: "America/Los_Angeles", - }, - { - city: "Central Time - US & Canada", - timezone: "America/Chicago", - }, - { - city: "Mountain Time - US & Canada", - timezone: "America/Denver", - }, - { - city: "Atlantic Time - Canada", - timezone: "America/Halifax", - }, - { - city: "Eastern European Time", - timezone: "Europe/Bucharest", - }, - { - city: "Central European Time", - timezone: "Europe/Berlin", - }, - { - city: "Western European Time", - timezone: "Europe/London", - }, - { - city: "Australian Eastern Time", - timezone: "Australia/Sydney", - }, - { - city: "Japan Standard Time", - timezone: "Asia/Tokyo", - }, - { - city: "India Standard Time", - timezone: "Asia/Kolkata", - }, - { - city: "Gulf Standard Time", - timezone: "Asia/Dubai", - }, - { - city: "South Africa Standard Time", - timezone: "Africa/Johannesburg", - }, - { - city: "Brazil Time", - timezone: "America/Sao_Paulo", - }, - { - city: "Hawaii-Aleutian Standard Time", - timezone: "Pacific/Honolulu", - }, +const timezonesFixture = [ + { label: "San Francisco", timezone: "America/Argentina/Cordoba" }, + { label: "San Francisco", timezone: "America/Los_Angeles" }, + { label: "Sao Francisco do Sul", timezone: "America/Sao_Paulo" }, + { label: "San Francisco de Macoris", timezone: "America/Santo_Domingo" }, + { label: "San Francisco Gotera", timezone: "America/El_Salvador" }, + { label: "Eastern Time - US & Canada", timezone: "America/New_York" }, + { label: "Pacific Time - US & Canada", timezone: "America/Los_Angeles" }, + { label: "Central Time - US & Canada", timezone: "America/Chicago" }, + { label: "Mountain Time - US & Canada", timezone: "America/Denver" }, + { label: "Atlantic Time - Canada", timezone: "America/Halifax" }, + { label: "Eastern European Time", timezone: "Europe/Bucharest" }, + { label: "Central European Time", timezone: "Europe/Berlin" }, + { label: "Western European Time", timezone: "Europe/London" }, + { label: "Australian Eastern Time", timezone: "Australia/Sydney" }, + { label: "Japan Standard Time", timezone: "Asia/Tokyo" }, + { label: "India Standard Time", timezone: "Asia/Kolkata" }, + { label: "Gulf Standard Time", timezone: "Asia/Dubai" }, + { label: "South Africa Standard Time", timezone: "Africa/Johannesburg" }, + { label: "Brazil Time", timezone: "America/Sao_Paulo" }, + { label: "Hawaii-Aleutian Standard Time", timezone: "Pacific/Honolulu" }, ]; const option = { @@ -104,34 +44,34 @@ describe("getTimezone", () => { }); it("should return empty array for an empty string", () => { - expect(filterByCities("", cityData)).toMatchInlineSnapshot(`[]`); + expect(filterBySearchText("", timezonesFixture)).toMatchInlineSnapshot(`[]`); }); it("should filter cities for a valid city name", () => { - expect(filterByCities("San Francisco", cityData)).toMatchInlineSnapshot(` + expect(filterBySearchText("San Francisco", timezonesFixture)).toMatchInlineSnapshot(` [ { - "city": "San Francisco", + "label": "San Francisco", "timezone": "America/Argentina/Cordoba", }, { - "city": "San Francisco de Macoris", + "label": "San Francisco", + "timezone": "America/Los_Angeles", + }, + { + "label": "San Francisco de Macoris", "timezone": "America/Santo_Domingo", }, { - "city": "San Francisco Gotera", + "label": "San Francisco Gotera", "timezone": "America/El_Salvador", }, - { - "city": "San Francisco", - "timezone": "America/Los_Angeles", - }, ] `); }); it("should return appropriate timezone(s) for a given city name array", () => { - expect(addCitiesToDropdown(cityData)).toMatchInlineSnapshot(` + expect(addTimezonesToDropdown(timezonesFixture)).toMatchInlineSnapshot(` { "Africa/Johannesburg": "South Africa Standard Time", "America/Argentina/Cordoba": "San Francisco", @@ -156,9 +96,9 @@ describe("getTimezone", () => { }); it("should render city name as option label if cityData is not empty", () => { - expect(handleOptionLabel(option, cityData)).toMatchInlineSnapshot(`"San Francisco GMT -8:00"`); + expect(handleOptionLabel(option, timezonesFixture)).toMatchInlineSnapshot(`"San Francisco GMT -8:00"`); vi.setSystemTime(new Date("2020-06-01")); - expect(handleOptionLabel(option, cityData)).toMatchInlineSnapshot(`"San Francisco GMT -7:00"`); + expect(handleOptionLabel(option, timezonesFixture)).toMatchInlineSnapshot(`"San Francisco GMT -7:00"`); }); it("should return timezone as option label if cityData is empty", () => { diff --git a/packages/features/additionalTimezones/additionalTimezones.json b/packages/features/additionalTimezones/additionalTimezones.json deleted file mode 100644 index 5549cfb9dd..0000000000 --- a/packages/features/additionalTimezones/additionalTimezones.json +++ /dev/null @@ -1,64 +0,0 @@ -{ - "timezones": [ - { - "name": "Eastern Time - US & Canada", - "identifier": "America/New_York" - }, - { - "name": "Pacific Time - US & Canada", - "identifier": "America/Los_Angeles" - }, - { - "name": "Central Time - US & Canada", - "identifier": "America/Chicago" - }, - { - "name": "Mountain Time - US & Canada", - "identifier": "America/Denver" - }, - { - "name": "Atlantic Time - Canada", - "identifier": "America/Halifax" - }, - { - "name": "Eastern European Time", - "identifier": "Europe/Bucharest" - }, - { - "name": "Central European Time", - "identifier": "Europe/Berlin" - }, - { - "name": "Western European Time", - "identifier": "Europe/London" - }, - { - "name": "Australian Eastern Time", - "identifier": "Australia/Sydney" - }, - { - "name": "Japan Standard Time", - "identifier": "Asia/Tokyo" - }, - { - "name": "India Standard Time", - "identifier": "Asia/Kolkata" - }, - { - "name": "Gulf Standard Time", - "identifier": "Asia/Dubai" - }, - { - "name": "South Africa Standard Time", - "identifier": "Africa/Johannesburg" - }, - { - "name": "Brazil Time", - "identifier": "America/Sao_Paulo" - }, - { - "name": "Hawaii-Aleutian Standard Time", - "identifier": "Pacific/Honolulu" - } - ] -} diff --git a/packages/features/additionalTimezones/additionalTimezones.ts b/packages/features/additionalTimezones/additionalTimezones.ts deleted file mode 100644 index 84f15c9d84..0000000000 --- a/packages/features/additionalTimezones/additionalTimezones.ts +++ /dev/null @@ -1,10 +0,0 @@ -import * as additionalTimezones from "./additionalTimezones.json"; - -export interface Timezone { - name: string; - identifier: string; -} - -export const timezones: Timezone[] = additionalTimezones.timezones; - -export default additionalTimezones; diff --git a/packages/lib/cityTimezonesHandler.ts b/packages/features/cityTimezones/cityTimezonesHandler.ts similarity index 100% rename from packages/lib/cityTimezonesHandler.ts rename to packages/features/cityTimezones/cityTimezonesHandler.ts diff --git a/packages/lib/timezone.ts b/packages/lib/timezone.ts index 92ac08f175..3c74df8273 100644 --- a/packages/lib/timezone.ts +++ b/packages/lib/timezone.ts @@ -1,53 +1,38 @@ import type { ITimezoneOption } from "react-timezone-select"; import dayjs from "@calcom/dayjs"; -import type { ICity } from "@calcom/ui/components/form/timezone-select"; -import { timezones as additionalTimezones } from "../features/additionalTimezones/additionalTimezones"; import isProblematicTimezone from "./isProblematicTimezone"; -function findPartialMatch(itemsToSearch: string, searchString: string) { - const searchItems = searchString.split(" "); - return searchItems.every((i) => itemsToSearch.toLowerCase().indexOf(i.toLowerCase()) >= 0); -} +export type Timezones = { label: string; timezone: string }[]; -function findFromCity(searchString: string, data: ICity[]): ICity[] { - if (searchString) { - const cityLookup = data.filter((o) => findPartialMatch(o.city, searchString)); - return cityLookup?.length ? cityLookup : []; - } - return []; -} - -export const filterByCities = (tz: string, data: ICity[]): ICity[] => { - const cityLookup = findFromCity(tz, data); - return cityLookup.map(({ city, timezone }) => ({ city, timezone })); +const searchTextFilter = (tzOption: Timezones[number], searchText: string) => { + return searchText && tzOption.label.toLowerCase().includes(searchText.toLowerCase()); }; -export const addCitiesToDropdown = (cities: ICity[]) => { - const cityTimezones = cities?.reduce((acc: { [key: string]: string }, city: ICity) => { - if (city.timezone !== null && !isProblematicTimezone(city.timezone)) { - acc[city.timezone] = city.city; - } - return acc; - }, {}); +export const filterBySearchText = (searchText: string, timezones: Timezones) => { + return timezones.filter((tzOption) => searchTextFilter(tzOption, searchText)); +}; - additionalTimezones.forEach((tz) => { - cityTimezones[tz.identifier] = tz.name; - }); - - return cityTimezones || {}; +export const addTimezonesToDropdown = (timezones: Timezones) => { + return Object.fromEntries( + timezones + .filter(({ timezone }) => { + return timezone !== null && !isProblematicTimezone(timezone); + }) + .map(({ label, timezone }) => [timezone, label]) + ); }; const formatOffset = (offset: string) => offset.replace(/^([-+])(0)(\d):00$/, (_, sign, _zero, hour) => `${sign}${hour}:00`); -export const handleOptionLabel = (option: ITimezoneOption, cities: ICity[]) => { +export const handleOptionLabel = (option: ITimezoneOption, timezones: Timezones) => { const offsetUnit = option.label.split("-")[0].substring(1); const cityName = option.label.split(") ")[1]; const timezoneValue = ` ${offsetUnit} ${formatOffset(dayjs.tz(undefined, option.value).format("Z"))}`; - return cities.length > 0 + return timezones.length > 0 ? `${cityName}${timezoneValue}` : `${option.value.replace(/_/g, " ")}${timezoneValue}`; }; diff --git a/packages/platform/libraries/index.ts b/packages/platform/libraries/index.ts index 4fc56f2660..e35d2c2bf9 100644 --- a/packages/platform/libraries/index.ts +++ b/packages/platform/libraries/index.ts @@ -88,8 +88,8 @@ export type { AppsStatus } from "@calcom/types/Calendar"; export { MINUTES_TO_BOOK } from "@calcom/lib/constants"; -export { cityTimezonesHandler } from "@calcom/lib/cityTimezonesHandler"; -export type { CityTimezones } from "@calcom/lib/cityTimezonesHandler"; +export { cityTimezonesHandler } from "@calcom/features/cityTimezones/cityTimezonesHandler"; +export type { CityTimezones } from "@calcom/features/cityTimezones/cityTimezonesHandler"; export { TRPCError } from "@trpc/server"; export type { TUpdateInputSchema } from "@calcom/trpc/server/routers/viewer/availability/schedule/update.schema"; diff --git a/packages/trpc/server/routers/publicViewer/timezones/_router.tsx b/packages/trpc/server/routers/publicViewer/timezones/_router.tsx index 39ec7ff5a0..16183da9af 100644 --- a/packages/trpc/server/routers/publicViewer/timezones/_router.tsx +++ b/packages/trpc/server/routers/publicViewer/timezones/_router.tsx @@ -11,7 +11,7 @@ export const timezonesRouter = router({ cityTimezones: publicProcedure.input(cityTimezonesSchema).query(async () => { const handler = await importHandler( namespaced("cityTimezones"), - () => import("@calcom/lib/cityTimezonesHandler") + () => import("@calcom/features/cityTimezones/cityTimezonesHandler") ); return handler(); }), diff --git a/packages/ui/components/form/timezone-select/TimezoneSelect.tsx b/packages/ui/components/form/timezone-select/TimezoneSelect.tsx index dbb4976699..a912473d2a 100644 --- a/packages/ui/components/form/timezone-select/TimezoneSelect.tsx +++ b/packages/ui/components/form/timezone-select/TimezoneSelect.tsx @@ -6,22 +6,40 @@ import BaseSelect from "react-timezone-select"; import { classNames } from "@calcom/lib"; import { CALCOM_VERSION } from "@calcom/lib/constants"; -import { filterByCities, addCitiesToDropdown, handleOptionLabel } from "@calcom/lib/timezone"; +import { filterBySearchText, addTimezonesToDropdown, handleOptionLabel } from "@calcom/lib/timezone"; +import type { Timezones } from "@calcom/lib/timezone"; import { trpc } from "@calcom/trpc/react"; import { getReactSelectProps } from "../select"; -export interface ICity { - city: string; - timezone: string; -} +const SELECT_SEARCH_DATA: Timezones = [ + { label: "San Francisco", timezone: "America/Los_Angeles" }, + { label: "Sao Francisco do Sul", timezone: "America/Sao_Paulo" }, + { label: "San Francisco de Macoris", timezone: "America/Santo_Domingo" }, + { label: "San Francisco Gotera", timezone: "America/El_Salvador" }, + { label: "Eastern Time - US & Canada", timezone: "America/New_York" }, + { label: "Pacific Time - US & Canada", timezone: "America/Los_Angeles" }, + { label: "Central Time - US & Canada", timezone: "America/Chicago" }, + { label: "Mountain Time - US & Canada", timezone: "America/Denver" }, + { label: "Atlantic Time - Canada", timezone: "America/Halifax" }, + { label: "Eastern European Time", timezone: "Europe/Bucharest" }, + { label: "Central European Time", timezone: "Europe/Berlin" }, + { label: "Western European Time", timezone: "Europe/London" }, + { label: "Australian Eastern Time", timezone: "Australia/Sydney" }, + { label: "Japan Standard Time", timezone: "Asia/Tokyo" }, + { label: "India Standard Time", timezone: "Asia/Kolkata" }, + { label: "Gulf Standard Time", timezone: "Asia/Dubai" }, + { label: "South Africa Standard Time", timezone: "Africa/Johannesburg" }, + { label: "Brazil Time", timezone: "America/Sao_Paulo" }, + { label: "Hawaii-Aleutian Standard Time", timezone: "Pacific/Honolulu" }, +]; export type TimezoneSelectProps = SelectProps & { variant?: "default" | "minimal"; timezoneSelectCustomClassname?: string; }; export function TimezoneSelect(props: TimezoneSelectProps) { - const { data, isPending } = trpc.viewer.timezones.cityTimezones.useQuery( + const { data = [], isPending } = trpc.viewer.timezones.cityTimezones.useQuery( { CalComVersion: CALCOM_VERSION, }, @@ -29,14 +47,19 @@ export function TimezoneSelect(props: TimezoneSelectProps) { trpc: { context: { skipBatch: true } }, } ); - - return ; + return ( + ({ label: city, timezone }))} + isPending={isPending} + {...props} + /> + ); } export type TimezoneSelectComponentProps = SelectProps & { variant?: "default" | "minimal"; isPending: boolean; - data: ICity[] | undefined; + data?: Timezones; timezoneSelectCustomClassname?: string; }; export function TimezoneSelectComponent({ @@ -45,14 +68,21 @@ export function TimezoneSelectComponent({ timezoneSelectCustomClassname, components, variant = "default", - data, isPending, value, ...props }: TimezoneSelectComponentProps) { - const [cities, setCities] = useState([]); - const handleInputChange = (tz: string) => { - if (data) setCities(filterByCities(tz, data)); + const data = [...(props.data || []), ...SELECT_SEARCH_DATA]; + /* + * we support multiple timezones for the different labels + * e.g. 'Sao Paulo' and 'Brazil Time' both being 'America/Sao_Paulo' + * but react-timezone-select does not. + * + * We make sure to be able to search through both options, and flip the key/value on final display. + */ + const [additionalTimezones, setAdditionalTimezones] = useState([]); + const handleInputChange = (searchText: string) => { + if (data) setAdditionalTimezones(filterBySearchText(searchText, data)); }; const reactSelectProps = useMemo(() => { @@ -70,15 +100,15 @@ export function TimezoneSelectComponent({ isDisabled={isPending} {...reactSelectProps} timezones={{ - ...(data ? addCitiesToDropdown(data) : {}), - ...addCitiesToDropdown(cities), + ...(props.data ? addTimezonesToDropdown(data) : {}), + ...addTimezonesToDropdown(additionalTimezones), }} onInputChange={handleInputChange} {...props} formatOptionLabel={(option) => (

{(option as ITimezoneOption).value.replace(/_/g, " ")}

)} - getOptionLabel={(option) => handleOptionLabel(option as ITimezoneOption, cities)} + getOptionLabel={(option) => handleOptionLabel(option as ITimezoneOption, additionalTimezones)} classNames={{ ...timezoneClassNames, input: (state) => diff --git a/packages/ui/components/form/timezone-select/index.ts b/packages/ui/components/form/timezone-select/index.ts index 88f5477516..f1a69283d0 100644 --- a/packages/ui/components/form/timezone-select/index.ts +++ b/packages/ui/components/form/timezone-select/index.ts @@ -2,7 +2,6 @@ export { TimezoneSelect, TimezoneSelectComponent } from "./TimezoneSelect"; export type { ITimezone, ITimezoneOption, - ICity, TimezoneSelectProps, TimezoneSelectComponentProps, } from "./TimezoneSelect"; diff --git a/packages/ui/package.json b/packages/ui/package.json index de3c65587e..71a0250515 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -6,6 +6,11 @@ "main": "./index.tsx", "exports": { ".": "./index.tsx", + "./button": "./components/button/index.ts", + "./form/timezone-select": "./components/form/timezone-select/index.ts", + "./toast": "./components/toast/index.tsx", + "./icon": "./components/icon/index.ts", + "./unpublished-entity/UnpublishedEntity": "./components/unpublished-entity/index.ts", "./components/toast": "./components/toast/index.tsx", "./components/icon": "./components/icon/index.ts", "./components/icon/SatSymbol": "./components/icon/SatSymbol.tsx",