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>
This commit is contained in:
parent
5fe0ca7913
commit
4d8198d113
|
@ -17,7 +17,7 @@ export function TimezoneDropdown({
|
||||||
|
|
||||||
return (
|
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]" />
|
<FiGlobe className="dark:text-darkgray-600 flex h-4 w-4 text-gray-600 ltr:mr-[2px] rtl:ml-[2px]" />
|
||||||
<TimeOptions onSelectTimeZone={handleSelectTimeZone} />
|
<TimeOptions onSelectTimeZone={handleSelectTimeZone} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -65,6 +65,7 @@ export default trpcNext.createNextApiHandler({
|
||||||
"viewer.public.i18n": `no-cache`,
|
"viewer.public.i18n": `no-cache`,
|
||||||
// Revalidation time here should be 1 second, per https://github.com/calcom/cal.com/pull/6823#issuecomment-1423215321
|
// 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.slots.getSchedule": `no-cache`, // FIXME
|
||||||
|
"viewer.public.cityTimezones": `max-age=${ONE_DAY_IN_SECONDS}, stale-while-revalidate`,
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
// Find which element above is an exact match for this group of paths
|
// 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"
|
"ts-jest": "^28.0.8"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"city-timezones": "^1.2.1",
|
||||||
"turbo": "^1.4.3"
|
"turbo": "^1.4.3"
|
||||||
},
|
},
|
||||||
"resolutions": {
|
"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 type { DestinationCalendar, Prisma } from "@prisma/client";
|
||||||
import { AppCategories, BookingStatus, IdentityProvider } from "@prisma/client";
|
import { AppCategories, BookingStatus, IdentityProvider } from "@prisma/client";
|
||||||
|
import { cityMapping } from "city-timezones";
|
||||||
import _ from "lodash";
|
import _ from "lodash";
|
||||||
import { authenticator } from "otplib";
|
import { authenticator } from "otplib";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
@ -143,6 +144,7 @@ const publicViewerRouter = router({
|
||||||
}),
|
}),
|
||||||
// REVIEW: This router is part of both the public and private viewer router?
|
// REVIEW: This router is part of both the public and private viewer router?
|
||||||
slots: slotsRouter,
|
slots: slotsRouter,
|
||||||
|
cityTimezones: publicProcedure.query(() => cityMapping),
|
||||||
});
|
});
|
||||||
|
|
||||||
// routes only available to authenticated users
|
// 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 type { ITimezoneOption, ITimezone, Props as SelectProps } from "react-timezone-select";
|
||||||
import BaseSelect, { allTimezones } 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";
|
import { getReactSelectProps } from "../select";
|
||||||
|
|
||||||
|
export interface ICity {
|
||||||
|
city: string;
|
||||||
|
timezone: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function TimezoneSelect({ className, components, ...props }: SelectProps) {
|
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(() => {
|
const reactSelectProps = useMemo(() => {
|
||||||
return getReactSelectProps({ className, components: components || {} });
|
return getReactSelectProps({ className, components: components || {} });
|
||||||
}, [className, components]);
|
}, [className, components]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<BaseSelect
|
<BaseSelect
|
||||||
|
isLoading={isLoading}
|
||||||
|
isDisabled={isLoading}
|
||||||
{...reactSelectProps}
|
{...reactSelectProps}
|
||||||
timezones={{
|
timezones={{
|
||||||
...allTimezones,
|
...allTimezones,
|
||||||
|
...addCitiesToDropdown(cities),
|
||||||
"America/Asuncion": "Asuncion",
|
"America/Asuncion": "Asuncion",
|
||||||
}}
|
}}
|
||||||
|
onInputChange={handleInputChange}
|
||||||
{...props}
|
{...props}
|
||||||
formatOptionLabel={(option) => <p className="truncate">{(option as ITimezoneOption).value}</p>}
|
formatOptionLabel={(option) => <p className="truncate">{(option as ITimezoneOption).value}</p>}
|
||||||
getOptionLabel={(data) => {
|
getOptionLabel={(option) => handleOptionLabel(option as ITimezoneOption, cities)}
|
||||||
const option = data as ITimezoneOption;
|
|
||||||
const formatedLabel = option.label.split(")")[0].replace("(", " ").replace("T", "T ");
|
|
||||||
return `${option.value}${formatedLabel}`;
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,2 +1,2 @@
|
||||||
export { TimezoneSelect } from "./TimezoneSelect";
|
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"
|
inherits "^2.0.1"
|
||||||
safe-buffer "^5.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:
|
cjs-module-lexer@^1.0.0:
|
||||||
version "1.2.2"
|
version "1.2.2"
|
||||||
resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz#9f84ba3244a512f3a54e5277e8eef4c489864e40"
|
resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.2.tgz#9f84ba3244a512f3a54e5277e8eef4c489864e40"
|
||||||
|
|
Loading…
Reference in New Issue
Block a user