Filter Timezones by cities (#7118)

* Filter Timezones by cities

* Update yarn.lock

* Removes large endpoint from batching

* Adds caching for large response

* Updates test snapshots

---------

Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: zomars <zomars@me.com>
pull/7425/head
GitStart-Cal.com 2023-03-02 20:42:41 +00:00 committed by GitHub
parent 5fe0ca7913
commit 4d8198d113
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 151 additions and 8 deletions

View File

@ -17,7 +17,7 @@ export function TimezoneDropdown({
return (
<>
<div className="dark:focus-within:bg-darkgray-200 dark:bg-darkgray-100 dark:hover:bg-darkgray-200 -mx-[2px] !mt-3 flex w-fit items-center rounded-[4px] px-1 py-[2px] text-sm font-medium focus-within:bg-gray-200 hover:bg-gray-100 [&_svg]:focus-within:text-gray-900 dark:[&_svg]:focus-within:text-white [&_p]:focus-within:text-gray-900 dark:[&_p]:focus-within:text-white">
<div className="dark:focus-within:bg-darkgray-200 dark:bg-darkgray-100 dark:hover:bg-darkgray-200 -mx-[2px] !mt-3 flex w-fit max-w-[20rem] items-center rounded-[4px] px-1 py-[2px] text-sm font-medium focus-within:bg-gray-200 hover:bg-gray-100 lg:max-w-[12rem] [&_svg]:focus-within:text-gray-900 dark:[&_svg]:focus-within:text-white [&_p]:focus-within:text-gray-900 dark:[&_p]:focus-within:text-white">
<FiGlobe className="dark:text-darkgray-600 flex h-4 w-4 text-gray-600 ltr:mr-[2px] rtl:ml-[2px]" />
<TimeOptions onSelectTimeZone={handleSelectTimeZone} />
</div>

View File

@ -65,6 +65,7 @@ export default trpcNext.createNextApiHandler({
"viewer.public.i18n": `no-cache`,
// Revalidation time here should be 1 second, per https://github.com/calcom/cal.com/pull/6823#issuecomment-1423215321
"viewer.public.slots.getSchedule": `no-cache`, // FIXME
"viewer.public.cityTimezones": `max-age=${ONE_DAY_IN_SECONDS}, stale-while-revalidate`,
} as const;
// Find which element above is an exact match for this group of paths

View File

@ -0,0 +1,78 @@
import { expect, it } from "@jest/globals";
import { filterByCities, addCitiesToDropdown, handleOptionLabel } 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",
},
];
const option = {
value: "America/Los_Angeles",
label: "(GMT-8:00) San Francisco",
offset: -8,
abbrev: "PST",
altName: "Pacific Standard Time",
};
it("should return empty array for an empty string", () => {
expect(filterByCities("", cityData)).toMatchInlineSnapshot(`Array []`);
});
it("should filter cities for a valid city name", () => {
expect(filterByCities("San Francisco", cityData)).toMatchInlineSnapshot(`
Array [
Object {
"city": "San Francisco",
"timezone": "America/Argentina/Cordoba",
},
Object {
"city": "San Francisco de Macoris",
"timezone": "America/Santo_Domingo",
},
Object {
"city": "San Francisco Gotera",
"timezone": "America/El_Salvador",
},
Object {
"city": "San Francisco",
"timezone": "America/Los_Angeles",
},
]
`);
});
it("should return appropriate timezone(s) for a given city name array", () => {
expect(addCitiesToDropdown(cityData)).toMatchInlineSnapshot(`
Object {
"America/Los_Angeles": "San Francisco",
"America/Sao_Paulo": "Sao Francisco do Sul",
}
`);
});
it("should render city name as option label if cityData is not empty", () => {
expect(handleOptionLabel(option, cityData)).toMatchInlineSnapshot(`"San Francisco GMT -8:00"`);
});
it("should return timezone as option label if cityData is empty", () => {
expect(handleOptionLabel(option, [])).toMatchInlineSnapshot(`"America/Los_Angeles GMT -8:00"`);
});

View File

@ -81,6 +81,7 @@
"ts-jest": "^28.0.8"
},
"dependencies": {
"city-timezones": "^1.2.1",
"turbo": "^1.4.3"
},
"resolutions": {

38
packages/lib/timezone.ts Normal file
View File

@ -0,0 +1,38 @@
import type { ITimezoneOption } from "react-timezone-select";
import { allTimezones } from "react-timezone-select";
import type { ICity } from "@calcom/ui/components/form/timezone-select";
function findPartialMatch(itemsToSearch: string, searchString: string) {
const searchItems = searchString.split(" ");
return searchItems.every((i) => itemsToSearch.toLowerCase().indexOf(i.toLowerCase()) >= 0);
}
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 }));
};
export const addCitiesToDropdown = (cities: ICity[]) => {
const cityTimezones = cities?.reduce((acc: { [key: string]: string }, city: ICity) => {
if (Object.keys(allTimezones).includes(city.timezone)) {
acc[city.timezone] = city.city;
}
return acc;
}, {});
return cityTimezones || {};
};
export const handleOptionLabel = (option: ITimezoneOption, cities: ICity[]) => {
const timezoneValue = option.label.split(")")[0].replace("(", " ").replace("T", "T ");
const cityName = option.label.split(") ")[1];
return cities.length > 0 ? `${cityName}${timezoneValue}` : `${option.value}${timezoneValue}`;
};

View File

@ -1,5 +1,6 @@
import type { DestinationCalendar, Prisma } from "@prisma/client";
import { AppCategories, BookingStatus, IdentityProvider } from "@prisma/client";
import { cityMapping } from "city-timezones";
import _ from "lodash";
import { authenticator } from "otplib";
import z from "zod";
@ -143,6 +144,7 @@ const publicViewerRouter = router({
}),
// REVIEW: This router is part of both the public and private viewer router?
slots: slotsRouter,
cityTimezones: publicProcedure.query(() => cityMapping),
});
// routes only available to authenticated users

View File

@ -1,28 +1,44 @@
import { useMemo } from "react";
import { useMemo, useState } from "react";
import type { ITimezoneOption, ITimezone, Props as SelectProps } from "react-timezone-select";
import BaseSelect, { allTimezones } from "react-timezone-select";
import { filterByCities, addCitiesToDropdown, handleOptionLabel } from "@calcom/lib/timezone";
import { trpc } from "@calcom/trpc/react";
import { getReactSelectProps } from "../select";
export interface ICity {
city: string;
timezone: string;
}
export function TimezoneSelect({ className, components, ...props }: SelectProps) {
const [cities, setCities] = useState<ICity[]>([]);
const { data, isLoading } = trpc.viewer.public.cityTimezones.useQuery(undefined, {
trpc: { context: { skipBatch: true } },
});
const handleInputChange = (tz: string) => {
if (data) setCities(filterByCities(tz, data));
};
const reactSelectProps = useMemo(() => {
return getReactSelectProps({ className, components: components || {} });
}, [className, components]);
return (
<BaseSelect
isLoading={isLoading}
isDisabled={isLoading}
{...reactSelectProps}
timezones={{
...allTimezones,
...addCitiesToDropdown(cities),
"America/Asuncion": "Asuncion",
}}
onInputChange={handleInputChange}
{...props}
formatOptionLabel={(option) => <p className="truncate">{(option as ITimezoneOption).value}</p>}
getOptionLabel={(data) => {
const option = data as ITimezoneOption;
const formatedLabel = option.label.split(")")[0].replace("(", " ").replace("T", "T ");
return `${option.value}${formatedLabel}`;
}}
getOptionLabel={(option) => handleOptionLabel(option as ITimezoneOption, cities)}
/>
);
}

View File

@ -1,2 +1,2 @@
export { TimezoneSelect } from "./TimezoneSelect";
export type { ITimezone, ITimezoneOption } from "./TimezoneSelect";
export type { ITimezone, ITimezoneOption, ICity } from "./TimezoneSelect";

View File

@ -11624,6 +11624,13 @@ cipher-base@^1.0.0, cipher-base@^1.0.1, cipher-base@^1.0.3:
inherits "^2.0.1"
safe-buffer "^5.0.1"
city-timezones@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/city-timezones/-/city-timezones-1.2.1.tgz#7087d1719dd599f1e88ebc2f7fb96e091201d318"
integrity sha512-hruuB611QFoUFMsan7xd9B2VPMrA8XC716O/999WW34kmaJUT1hxKF2W8TSXAWkhSqgvbu70DjcDv7/wpM6vow==
dependencies:
lodash "^4.17.21"
cjs-module-lexer@^1.0.0:
version "1.2.2"
resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz#9f84ba3244a512f3a54e5277e8eef4c489864e40"