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
parent
5fe0ca7913
commit
4d8198d113
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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"`);
|
||||
});
|
|
@ -81,6 +81,7 @@
|
|||
"ts-jest": "^28.0.8"
|
||||
},
|
||||
"dependencies": {
|
||||
"city-timezones": "^1.2.1",
|
||||
"turbo": "^1.4.3"
|
||||
},
|
||||
"resolutions": {
|
||||
|
|
|
@ -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}`;
|
||||
};
|
|
@ -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
|
||||
|
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
export { TimezoneSelect } from "./TimezoneSelect";
|
||||
export type { ITimezone, ITimezoneOption } from "./TimezoneSelect";
|
||||
export type { ITimezone, ITimezoneOption, ICity } from "./TimezoneSelect";
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue