2
0

first commit

This commit is contained in:
2024-08-09 00:39:27 +02:00
commit 79688abe2e
5698 changed files with 497838 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
import { useMemo, useState } from "react";
import type { ITimezoneOption, ITimezone, Props as SelectProps } from "react-timezone-select";
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 { trpc } from "@calcom/trpc/react";
import { getReactSelectProps } from "../select";
export interface ICity {
city: string;
timezone: string;
}
export type TimezoneSelectProps = SelectProps & {
variant?: "default" | "minimal";
timezoneSelectCustomClassname?: string;
};
export function TimezoneSelect(props: TimezoneSelectProps) {
const { data, isPending } = trpc.viewer.timezones.cityTimezones.useQuery(
{
CalComVersion: CALCOM_VERSION,
},
{
trpc: { context: { skipBatch: true } },
}
);
return <TimezoneSelectComponent data={data} isPending={isPending} {...props} />;
}
export type TimezoneSelectComponentProps = SelectProps & {
variant?: "default" | "minimal";
isPending: boolean;
data: ICity[] | undefined;
timezoneSelectCustomClassname?: string;
};
export function TimezoneSelectComponent({
className,
classNames: timezoneClassNames,
timezoneSelectCustomClassname,
components,
variant = "default",
data,
isPending,
value,
...props
}: TimezoneSelectComponentProps) {
const [cities, setCities] = useState<ICity[]>([]);
const handleInputChange = (tz: string) => {
if (data) setCities(filterByCities(tz, data));
};
const reactSelectProps = useMemo(() => {
return getReactSelectProps({
components: components || {},
});
}, [components]);
return (
<BaseSelect
value={value}
className={`${className} ${timezoneSelectCustomClassname}`}
isLoading={isPending}
isDisabled={isPending}
{...reactSelectProps}
timezones={{
...(data ? addCitiesToDropdown(data) : {}),
...addCitiesToDropdown(cities),
}}
onInputChange={handleInputChange}
{...props}
formatOptionLabel={(option) => (
<p className="truncate">{(option as ITimezoneOption).value.replace(/_/g, " ")}</p>
)}
getOptionLabel={(option) => handleOptionLabel(option as ITimezoneOption, cities)}
classNames={{
...timezoneClassNames,
input: (state) =>
classNames(
"text-emphasis h-6 md:max-w-[145px] max-w-[250px]",
timezoneClassNames?.input && timezoneClassNames.input(state)
),
option: (state) =>
classNames(
"bg-default flex !cursor-pointer justify-between py-2.5 px-3 rounded-none text-default ",
state.isFocused && "bg-subtle",
state.isSelected && "bg-emphasis",
timezoneClassNames?.option && timezoneClassNames.option(state)
),
placeholder: (state) => classNames("text-muted", state.isFocused && "hidden"),
dropdownIndicator: () => "text-default",
control: (state) =>
classNames(
"!cursor-pointer",
variant === "default"
? "px-3 py-2 bg-default border-default !min-h-9 text-sm leading-4 placeholder:text-sm placeholder:font-normal focus-within:ring-2 focus-within:ring-emphasis hover:border-emphasis rounded-md border gap-1"
: "text-sm gap-1",
timezoneClassNames?.control && timezoneClassNames.control(state)
),
singleValue: (state) =>
classNames(
"text-emphasis placeholder:text-muted",
timezoneClassNames?.singleValue && timezoneClassNames.singleValue(state)
),
valueContainer: (state) =>
classNames(
"text-emphasis placeholder:text-muted flex gap-1",
timezoneClassNames?.valueContainer && timezoneClassNames.valueContainer(state)
),
multiValue: (state) =>
classNames(
"bg-subtle text-default rounded-md py-1.5 px-2 flex items-center text-sm leading-none",
timezoneClassNames?.multiValue && timezoneClassNames.multiValue(state)
),
menu: (state) =>
classNames(
"rounded-md bg-default text-sm leading-4 text-default mt-1 border border-subtle",
state.selectProps.menuIsOpen && "shadow-dropdown", // Add box-shadow when menu is open
timezoneClassNames?.menu && timezoneClassNames.menu(state)
),
groupHeading: () => "leading-none text-xs uppercase text-default pl-2.5 pt-4 pb-2",
menuList: (state) =>
classNames(
"scroll-bar scrollbar-track-w-20 rounded-md",
timezoneClassNames?.menuList && timezoneClassNames.menuList(state)
),
indicatorsContainer: (state) =>
classNames(
state.selectProps.menuIsOpen
? state.isMulti
? "[&>*:last-child]:rotate-180 [&>*:last-child]:transition-transform"
: "rotate-180 transition-transform"
: "text-default", // Woo it adds another SVG here on multi for some reason
timezoneClassNames?.indicatorsContainer && timezoneClassNames.indicatorsContainer(state)
),
multiValueRemove: () => "text-default py-auto ml-2",
noOptionsMessage: () => "h-12 py-2 flex items-center justify-center",
}}
/>
);
}
export type { ITimezone, ITimezoneOption };

View File

@@ -0,0 +1,8 @@
export { TimezoneSelect, TimezoneSelectComponent } from "./TimezoneSelect";
export type {
ITimezone,
ITimezoneOption,
ICity,
TimezoneSelectProps,
TimezoneSelectComponentProps,
} from "./TimezoneSelect";

View File

@@ -0,0 +1,174 @@
/* eslint-disable playwright/missing-playwright-await */
import { render, screen, waitFor, fireEvent } from "@testing-library/react";
import type { Props as SelectProps } from "react-timezone-select";
import { vi } from "vitest";
import dayjs from "@calcom/dayjs";
import { TimezoneSelect } from "./TimezoneSelect";
const cityTimezonesMock = [
{ city: "Dawson City", timezone: "America/Dawson" },
{ city: "Honolulu", timezone: "Pacific/Honolulu" },
{ city: "Juneau", timezone: "America/Juneau" },
{ city: "Toronto", timezone: "America/Toronto" },
];
const runtimeMock = async (isPending: boolean) => {
const updatedTrcp = {
viewer: {
timezones: {
cityTimezones: {
useQuery() {
return {
data: cityTimezonesMock,
isPending,
};
},
},
},
},
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const mockedLib = (await import("@calcom/trpc/react")) as any;
mockedLib.trpc = updatedTrcp;
};
const formatOffset = (offset: string) =>
offset.replace(/^([-+])(0)(\d):00$/, (_, sign, _zero, hour) => `${sign}${hour}:00`);
const formatTimeZoneWithOffset = (timeZone: string) =>
`${timeZone} GMT ${formatOffset(dayjs.tz(undefined, timeZone).format("Z"))}`;
const timezoneMockValues = ["America/Dawson", "Pacific/Honolulu", "America/Juneau", "America/Toronto"];
const optionMockValues = timezoneMockValues.map(formatTimeZoneWithOffset);
const classNames = {
singleValue: () => "test1",
valueContainer: () => "test2",
control: () => "test3",
input: () => "test4",
option: () => "test5",
menuList: () => "test6",
menu: () => "test7",
multiValue: () => "test8",
};
const onChangeMock = vi.fn();
const renderSelect = (newProps: SelectProps & { variant?: "default" | "minimal" }) => {
render(
<form aria-label="test-form">
<label htmlFor="test">Test</label>
<TimezoneSelect {...newProps} inputId="test" />
</form>
);
};
const openMenu = async () => {
await waitFor(async () => {
const element = screen.getByLabelText("Test");
element.focus();
fireEvent.keyDown(element, { key: "ArrowDown", code: "ArrowDown" });
screen.getByText(optionMockValues[0]);
});
};
describe("Test TimezoneSelect", () => {
afterEach(() => {
vi.clearAllMocks();
vi.resetAllMocks();
});
describe("Test TimezoneSelect with isPending = false", () => {
beforeAll(async () => {
await runtimeMock(false);
});
test("Should render with the correct CSS when provided with classNames prop", async () => {
renderSelect({ value: timezoneMockValues[0], classNames });
openMenu();
const dawsonEl = screen.getByText(timezoneMockValues[0]);
expect(dawsonEl).toBeInTheDocument();
const singleValueEl = dawsonEl.parentElement;
const valueContainerEl = singleValueEl?.parentElement;
const controlEl = valueContainerEl?.parentElement;
const inputEl = screen.getByRole("combobox", { hidden: true }).parentElement;
const optionEl = screen.getByText(optionMockValues[0]).parentElement?.parentElement;
const menuListEl = optionEl?.parentElement;
const menuEl = menuListEl?.parentElement;
expect(singleValueEl).toHaveClass(classNames.singleValue());
expect(valueContainerEl).toHaveClass(classNames.valueContainer());
expect(controlEl).toHaveClass(classNames.control());
expect(inputEl).toHaveClass(classNames.input());
expect(optionEl).toHaveClass(classNames.option());
expect(menuListEl).toHaveClass(classNames.menuList());
expect(menuEl).toHaveClass(classNames.menu());
for (const mockText of optionMockValues) {
expect(screen.getByText(mockText)).toBeInTheDocument();
}
});
test("Should render with the correct CSS when provided with className prop", async () => {
renderSelect({ value: timezoneMockValues[0], className: "test-css" });
openMenu();
const labelTest = screen.getByText("Test");
const timezoneEl = labelTest.nextSibling;
expect(timezoneEl).toHaveClass("test-css");
});
test("Should render with the correct CSS when isMulti is enabled", async () => {
renderSelect({ value: timezoneMockValues[0], isMulti: true, classNames });
openMenu();
const dawsonEl = screen.getByText(timezoneMockValues[0]);
const multiValueEl = dawsonEl.parentElement?.parentElement;
expect(multiValueEl).toHaveClass(classNames.multiValue());
const inputEl = screen.getByRole("combobox", { hidden: true }).parentElement;
const menuIsOpenEl = inputEl?.parentElement?.nextSibling;
expect(menuIsOpenEl).toHaveClass("[&>*:last-child]:rotate-180 [&>*:last-child]:transition-transform ");
});
test("Should render with the correct CSS when menu is open and onChange is called", async () => {
renderSelect({ value: timezoneMockValues[0], onChange: onChangeMock });
await waitFor(async () => {
const element = screen.getByLabelText("Test");
element.focus();
fireEvent.keyDown(element, { key: "ArrowDown", code: "ArrowDown" });
screen.getByText(optionMockValues[3]);
const inputEl = screen.getByRole("combobox", { hidden: true }).parentElement;
const menuIsOpenEl = inputEl?.parentElement?.nextSibling;
expect(menuIsOpenEl).toHaveClass("rotate-180 transition-transform ");
const opt = screen.getByText(optionMockValues[3]);
fireEvent.click(opt);
fireEvent.keyDown(element, { key: "ArrowDown", code: "ArrowDown" });
});
expect(onChangeMock).toBeCalled();
});
});
describe("Test TimezoneSelect with isPending = true", () => {
beforeAll(async () => {
await runtimeMock(true);
});
test("Should have no options when isPending is true", async () => {
renderSelect({ value: timezoneMockValues[0] });
await waitFor(async () => {
const element = screen.getByLabelText("Test");
element.focus();
fireEvent.keyDown(element, { key: "ArrowDown", code: "ArrowDown" });
});
for (const mockText of optionMockValues) {
const optionEl = screen.queryByText(mockText);
expect(optionEl).toBeNull();
}
});
});
});

View File

@@ -0,0 +1,83 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { Canvas, Meta, Story } from "@storybook/addon-docs";
import {
CustomArgsTable,
Examples,
Example,
Title,
VariantsTable,
VariantRow,
} from "@calcom/storybook/components";
import { StorybookTrpcProvider } from "../../mocks/trpc";
import { TimezoneSelect } from "./TimezoneSelect";
<Meta title="UI/Form/TimezoneSelect" component={TimezoneSelect} />
<Title title="TimezoneSelect" suffix="Brief" subtitle="Version 1.0 — Last Update: 25 Aug 2023" />
## Definition
The `TimezoneSelect` component is used to display timezone options.
## Structure
The `TimezoneSelect` component can be used to display timezone options.
<CustomArgsTable of={TimezoneSelect} />
## Examples
<Examples title="TimezoneSelect">
<Example title="Default">
<StorybookTrpcProvider>
<TimezoneSelect value="Africa/Douala" />
</StorybookTrpcProvider>
</Example>
<Example title="Disabled">
<StorybookTrpcProvider>
<TimezoneSelect value="Africa/Douala" isDisabled />
</StorybookTrpcProvider>
</Example>
</Examples>
## TimezoneSelect Story
<Canvas>
<Story
name="TimezoneSelect"
args={{
className: "mt-24",
value: "Africa/Douala",
variant: "default",
isDisabled: false,
timezones: {
"Timezone 1": "City 1",
"Timezone 2": "City 2",
"Timezone 3": "City 3",
},
isPending: false,
}}
argTypes={{
value: {
control: { disable: true },
},
variant: {
control: {
type: "inline-radio",
options: ["default", "minimal"],
},
},
}}>
{(args) => (
<VariantsTable titles={["Default"]} columnMinWidth={350}>
<VariantRow>
<StorybookTrpcProvider>
<TimezoneSelect {...args} />
</StorybookTrpcProvider>
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>