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 @@
components/icon/dynamicIconImports.tsx

View File

@@ -0,0 +1,36 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Label, Input } from "@calcom/ui";
type Digit = {
value: number;
onChange: () => void;
};
type PropType = {
digits: Digit[];
digitClassName: string;
};
const TokenHandler = ({ digits, digitClassName }: PropType) => {
const { t } = useLocale();
return (
<div>
<Label htmlFor="code">{t("code")}</Label>
<div className="flex flex-row justify-between">
{digits.map((element, index) => (
<Input
key={index}
className={digitClassName}
name={`2fa${index + 1}`}
inputMode="decimal"
{...element}
autoFocus={index === 0}
autoComplete="one-time-code"
/>
))}
</div>
</div>
);
};
export default TokenHandler;

View File

@@ -0,0 +1,30 @@
/* eslint-disable playwright/missing-playwright-await */
import { render, screen, fireEvent } from "@testing-library/react";
import { vi } from "vitest";
import TokenHandler from "./TokenHandler";
describe("Tests for TokenHandler component", () => {
test("Should render the correct number of input elements", () => {
const digits = [
{ value: 1, onChange: vi.fn() },
{ value: 2, onChange: vi.fn() },
{ value: 3, onChange: vi.fn() },
];
render(<TokenHandler digits={digits} digitClassName="digit" />);
expect(screen.getAllByRole("textbox")).toHaveLength(digits.length);
});
test("Should handle digit input correctly", () => {
const onChangeMock = vi.fn();
const digits = [{ value: 1, onChange: onChangeMock }];
render(<TokenHandler digits={digits} digitClassName="digit" />);
const inputElement = screen.getByRole("textbox");
fireEvent.change(inputElement, { target: { value: "5" } });
expect(onChangeMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,43 @@
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import { Examples, Example, Note, Title, CustomArgsTable } from "@calcom/storybook/components";
import TokenHandler from "./TokenHandler";
<Meta title="UI/TokenHandler" component={TokenHandler} />
<Title title="TokenHandler" subtitle="Version 0.1" />
## Structure
<TokenHandler
digits={[
{
value: "1",
onChange: (e) => {},
},
{
value: "2",
onChange: (e) => {},
},
{
value: "3",
onChange: (e) => {},
},
{
value: "4",
onChange: (e) => {},
},
{
value: "5",
onChange: (e) => {},
},
{
value: "6",
onChange: (e) => {},
},
]}
digitClassName="digit-input"
/>
#all the numbers should be visible while the first one is focused

View File

@@ -0,0 +1,103 @@
import classNames from "classnames";
import type { ReactNode } from "react";
import { forwardRef } from "react";
import { Icon, type IconName } from "../..";
export interface AlertProps {
title?: ReactNode;
// @TODO: Message should be children, more flexible?
message?: ReactNode;
// @TODO: Provide action buttons so style is always the same.
actions?: ReactNode;
className?: string;
iconClassName?: string;
// @TODO: Success and info shouldn't exist as per design?
severity: "success" | "warning" | "error" | "info" | "neutral" | "green";
CustomIcon?: IconName;
customIconColor?: string;
}
export const Alert = forwardRef<HTMLDivElement, AlertProps>((props, ref) => {
const { severity, iconClassName, CustomIcon, customIconColor } = props;
return (
<div
data-testid="alert"
ref={ref}
className={classNames(
"rounded-md p-3",
props.className,
severity === "error" && "bg-red-100 text-red-900 dark:bg-red-900 dark:text-red-200",
severity === "warning" && "text-attention bg-attention dark:bg-orange-900 dark:text-orange-200",
severity === "info" && "bg-blue-100 text-blue-900 dark:bg-blue-900 dark:text-blue-200",
severity === "green" && "bg-success text-success",
severity === "success" && "bg-inverted text-inverted",
severity === "neutral" && "bg-subtle text-default"
)}>
<div className="relative flex md:flex-row">
{CustomIcon ? (
<div className="flex-shrink-0">
<Icon
name={CustomIcon}
data-testid="custom-icon"
aria-hidden="true"
className={classNames(`h-5 w-5`, iconClassName, customIconColor ?? "text-default")}
/>
</div>
) : (
<div className="flex-shrink-0">
{severity === "error" && (
<Icon
name="circle-x"
data-testid="circle-x"
className={classNames("h-5 w-5 text-red-900 dark:text-red-200", iconClassName)}
aria-hidden="true"
/>
)}
{severity === "warning" && (
<Icon
name="triangle-alert"
data-testid="alert-triangle"
className={classNames("text-attention h-5 w-5 dark:text-orange-200", iconClassName)}
aria-hidden="true"
/>
)}
{severity === "info" && (
<Icon
name="info"
data-testid="info"
className={classNames("h-5 w-5 text-blue-900 dark:text-blue-200", iconClassName)}
aria-hidden="true"
/>
)}
{severity === "neutral" && (
<Icon
name="info"
data-testid="neutral"
className={classNames("text-default h-5 w-5 fill-transparent", iconClassName)}
aria-hidden="true"
/>
)}
{severity === "success" && (
<Icon
name="circle-check"
data-testid="circle-check"
className={classNames("fill-muted text-default h-5 w-5", iconClassName)}
aria-hidden="true"
/>
)}
</div>
)}
<div className="flex flex-grow flex-col sm:flex-row">
<div className="ltr:ml-3 rtl:mr-3">
<h3 className="text-sm font-medium">{props.title}</h3>
<div className="text-sm">{props.message}</div>
</div>
{props.actions && <div className="ml-auto mt-2 text-sm sm:mt-0 md:relative">{props.actions}</div>}
</div>
</div>
</div>
);
});
Alert.displayName = "Alert";

View File

@@ -0,0 +1,94 @@
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import {
Examples,
Example,
Note,
Title,
CustomArgsTable,
VariantsTable,
VariantRow,
} from "@calcom/storybook/components";
import { Alert } from "./Alert";
<Meta title="UI/Alert" component={Alert} />
<Title title="Alert" suffix="Brief" subtitle="Version 2.0 — Last Update: 04 jan 2023" />
## Definition
Alerts provide time-based feedback to the user after taking an action.
## Structure
Each alert has a state to represent neutral, positive or negative responses.
<CustomArgsTable of={Alert} />
<Examples title="Alert style">
<Example title="Error">
<Alert
severity="error"
title="Summarise what happened"
message="Describe what can be done about it here."
/>
</Example>
<Example title="Warning">
<Alert
severity="warning"
title="Summarise what happened"
message="Describe what can be done about it here."
/>
</Example>
<Example title="With actions">
<Alert
severity="warning"
title="Summarise what happened"
message="Describe what can be done about it here."
actions={
<>
<button>Cancel me</button>
</>
}
/>
</Example>
</Examples>
<Title offset title="Alert" suffix="Variants" />
<Canvas>
<Story
name="Alert"
args={{
severity: "success",
title: "Summarise what happened",
message: "Describe what can be done about it here.",
}}
argTypes={{
severity: {
control: {
type: "inline-radio",
options: ["success", "warning", "error", "neutral", "info"],
},
},
title: {
control: {
type: "text",
},
},
message: {
control: {
type: "text",
},
},
}}>
{({ severity, title, message }) => (
<VariantsTable titles={["Default"]} columnMinWidth={150}>
<VariantRow variant={severity}>
<Alert severity={severity} title={title} message={message} />
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>

View File

@@ -0,0 +1,41 @@
import { render, screen } from "@testing-library/react";
import { Alert } from "./Alert";
describe("Tests for Alert component", () => {
test("Should render text", () => {
render(<Alert severity="info" title="I'm an Alert!" message="Hello World" />);
expect(screen.getByText("I'm an Alert!")).toBeInTheDocument();
expect(screen.getByText("Hello World")).toBeInTheDocument();
});
test("Should render actions", () => {
render(<Alert severity="info" actions={<button>Click Me</button>} />);
expect(screen.getByText("Click Me")).toBeInTheDocument();
});
test("Should render corresponding icon: error", async () => {
render(<Alert severity="error" />);
expect(await screen.findByTestId("circle-x")).toBeInTheDocument();
});
test("Should render corresponding icon: warning", async () => {
render(<Alert severity="warning" />);
expect(await screen.findByTestId("alert-triangle")).toBeInTheDocument();
});
test("Should render corresponding icon: info", async () => {
render(<Alert severity="info" />);
expect(await screen.findByTestId("info")).toBeInTheDocument();
});
test("Should render corresponding icon: neutral", async () => {
render(<Alert severity="neutral" />);
expect(await screen.findByTestId("neutral")).toBeInTheDocument();
});
test("Should render corresponding icon: success", async () => {
render(<Alert severity="success" />);
expect(await screen.findByTestId("circle-check")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,2 @@
export { Alert } from "./Alert";
export type { AlertProps } from "./Alert";

View File

@@ -0,0 +1,76 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { render } from "@testing-library/react";
import type { ReactNode } from "react";
import { FormProvider, useForm } from "react-hook-form";
import { vi } from "vitest";
import { FormBuilder } from "@calcom/features/form-builder/FormBuilder";
import { mockProps, questionUtils, setMockIntersectionObserver, setMockMatchMedia } from "./utils";
vi.mock("@formkit/auto-animate/react", () => ({
useAutoAnimate: () => [null],
}));
const renderComponent = () => {
const Wrapper = ({ children }: { children: ReactNode }) => {
const methods = useForm();
return (
<TooltipProvider>
<FormProvider {...methods}>{children}</FormProvider>
</TooltipProvider>
);
};
return render(<FormBuilder {...mockProps} />, { wrapper: Wrapper });
};
describe("MultiSelect Question", () => {
beforeEach(() => {
renderComponent();
});
beforeAll(() => {
setMockMatchMedia();
setMockIntersectionObserver();
});
const questionTypes = [
{ questionType: "email", label: "Email Field" },
{ questionType: "phone", label: "Phone Field" },
{ questionType: "address", label: "Address Field" },
{ questionType: "text", label: "Short Text Field" },
{ questionType: "number", label: "Number Field" },
{ questionType: "textarea", label: "LongText Field" },
{ questionType: "select", label: "Select Field" },
{ questionType: "multiselect", label: "MultiSelect Field" },
{ questionType: "multiemail", label: "Multiple Emails Field" },
{ questionType: "checkbox", label: "CheckBox Group Field" },
{ questionType: "radio", label: "Radio Group Field" },
{ questionType: "boolean", label: "Checkbox Field" },
];
for (const { questionType, label } of questionTypes) {
it(`Should handle ${label}`, async () => {
const defaultIdentifier = `${questionType}-id`;
const newIdentifier = `${defaultIdentifier}-edited`;
await questionUtils.addQuestion({
questionType,
identifier: defaultIdentifier,
label,
});
await questionUtils.editQuestion({
identifier: newIdentifier,
existingQuestionId: defaultIdentifier,
});
await questionUtils.requiredAndOptionalQuestion();
await questionUtils.hideQuestion();
await questionUtils.deleteQuestion(newIdentifier);
});
}
});

View File

@@ -0,0 +1,106 @@
import { fireEvent, waitFor, screen } from "@testing-library/react";
import { vi } from "vitest";
export interface QuestionProps {
questionType: string;
identifier: string;
label: string;
}
interface FormBuilderProps {
formProp: string;
title: string;
description: string;
addFieldLabel: string;
disabled: boolean;
LockedIcon: false | JSX.Element;
dataStore: {
options: Record<string, { label: string; value: string; inputPlaceholder?: string }[]>;
};
}
export const mockProps: FormBuilderProps = {
formProp: "formProp",
title: "FormBuilder Title",
description: "FormBuilder Description",
addFieldLabel: "Add Field",
disabled: false,
LockedIcon: false,
dataStore: { options: {} },
};
export const setMockMatchMedia = () => {
Object.defineProperty(window, "matchMedia", {
value: vi.fn().mockImplementation((query) => ({
matches: false,
media: query,
onchange: null,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
dispatchEvent: vi.fn(),
})),
});
};
export const setMockIntersectionObserver = () => {
Object.defineProperty(window, "IntersectionObserver", {
value: vi.fn().mockImplementation(() => ({
observe: vi.fn(),
unobserve: vi.fn(),
disconnect: vi.fn(),
takeRecords: vi.fn(() => []),
})),
});
};
export const questionUtils = {
addQuestion: async (props: QuestionProps) => {
fireEvent.click(screen.getByTestId("add-field"));
fireEvent.keyDown(screen.getByTestId("test-field-type"), { key: "ArrowDown", code: "ArrowDown" });
fireEvent.click(screen.getByTestId("select-option-email"));
fireEvent.change(screen.getAllByRole("textbox")[0], { target: { value: props.identifier } });
fireEvent.change(screen.getAllByRole("textbox")[1], { target: { value: props.label } });
fireEvent.click(screen.getByTestId("field-add-save"));
await waitFor(() => {
expect(screen.queryByTestId(`field-${props.identifier}`)).toBeInTheDocument();
});
},
editQuestion: async (props: { identifier: string; existingQuestionId: string }) => {
fireEvent.click(screen.getByTestId("edit-field-action"));
fireEvent.change(screen.getAllByRole("textbox")[0], { target: { value: props.identifier } });
fireEvent.click(screen.getByTestId("field-add-save"));
await waitFor(() => {
expect(screen.queryByTestId(`field-${props.identifier}`)).toBeInTheDocument();
expect(screen.queryByTestId(`field-${props.existingQuestionId}`)).not.toBeInTheDocument();
});
},
deleteQuestion: async (existingQuestionId: string) => {
expect(screen.queryByTestId(`field-${existingQuestionId}`)).toBeInTheDocument();
fireEvent.click(screen.getByTestId("delete-field-action"));
await waitFor(() => {
expect(screen.queryByTestId(`field-${existingQuestionId}`)).not.toBeInTheDocument();
});
},
hideQuestion: async () => {
fireEvent.click(screen.getByTestId("toggle-field"));
await waitFor(() => {
expect(screen.queryByText(/hidden/i)).toBeInTheDocument();
});
},
requiredAndOptionalQuestion: async () => {
expect(screen.queryByTestId("required")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("edit-field-action"));
fireEvent.click(screen.getAllByRole("radio")[1]);
fireEvent.click(screen.getByTestId("field-add-save"));
await waitFor(() => {
expect(screen.getByTestId("optional")).toBeInTheDocument();
});
},
};

View File

@@ -0,0 +1,202 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import type { AppCategories } from "@prisma/client";
import { usePathname, useRouter } from "next/navigation";
import type { UIEvent } from "react";
import { useEffect, useRef, useState } from "react";
import { classNames } from "@calcom/lib";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { UserAdminTeams } from "@calcom/lib/server/repository/user";
import type { AppFrontendPayload as App } from "@calcom/types/App";
import type { CredentialFrontendPayload as Credential } from "@calcom/types/Credential";
import { Icon } from "../..";
import { EmptyScreen } from "../empty-screen";
import { AppCard } from "./AppCard";
export function useShouldShowArrows() {
const ref = useRef<HTMLUListElement>(null);
const [showArrowScroll, setShowArrowScroll] = useState({
left: false,
right: false,
});
useEffect(() => {
const appCategoryList = ref.current;
if (appCategoryList && appCategoryList.scrollWidth > appCategoryList.clientWidth) {
setShowArrowScroll({ left: false, right: true });
}
}, []);
const calculateScroll = (e: UIEvent<HTMLUListElement>) => {
setShowArrowScroll({
left: e.currentTarget.scrollLeft > 0,
right:
Math.floor(e.currentTarget.scrollWidth) - Math.floor(e.currentTarget.offsetWidth) !==
Math.floor(e.currentTarget.scrollLeft),
});
};
return { ref, calculateScroll, leftVisible: showArrowScroll.left, rightVisible: showArrowScroll.right };
}
type AllAppsPropsType = {
apps: (App & { credentials?: Credential[] })[];
searchText?: string;
categories: string[];
userAdminTeams?: UserAdminTeams;
};
interface CategoryTabProps {
selectedCategory: string | null;
categories: string[];
searchText?: string;
}
function CategoryTab({ selectedCategory, categories, searchText }: CategoryTabProps) {
const pathname = usePathname();
const searchParams = useCompatSearchParams();
const { t } = useLocale();
const router = useRouter();
const { ref, calculateScroll, leftVisible, rightVisible } = useShouldShowArrows();
const handleLeft = () => {
if (ref.current) {
ref.current.scrollLeft -= 100;
}
};
const handleRight = () => {
if (ref.current) {
ref.current.scrollLeft += 100;
}
};
return (
<div className="relative mb-4 flex flex-col justify-between lg:flex-row lg:items-center">
<h2 className="text-emphasis hidden text-base font-semibold leading-none sm:block">
{searchText
? t("search")
: t("category_apps", {
category:
(selectedCategory && selectedCategory[0].toUpperCase() + selectedCategory.slice(1)) ||
t("all"),
})}
</h2>
{leftVisible && (
<button onClick={handleLeft} className="absolute bottom-0 flex md:-top-1 md:left-1/2">
<div className="bg-default flex h-12 w-5 items-center justify-end">
<Icon name="chevron-left" className="text-subtle h-4 w-4" />
</div>
<div className="to-default flex h-12 w-5 bg-gradient-to-l from-transparent" />
</button>
)}
<ul
className="no-scrollbar mt-3 flex max-w-full space-x-1 overflow-x-auto lg:mt-0 lg:max-w-[50%]"
onScroll={(e) => calculateScroll(e)}
ref={ref}>
<li
onClick={() => {
if (pathname !== null) {
router.replace(pathname);
}
}}
className={classNames(
selectedCategory === null ? "bg-emphasis text-default" : "bg-muted text-emphasis",
"hover:bg-emphasis min-w-max rounded-md px-4 py-2.5 text-sm font-medium transition hover:cursor-pointer"
)}>
{t("all")}
</li>
{categories.map((cat, pos) => (
<li
key={pos}
onClick={() => {
if (selectedCategory === cat) {
if (pathname !== null) {
router.replace(pathname);
}
} else {
const _searchParams = new URLSearchParams(searchParams ?? undefined);
_searchParams.set("category", cat);
router.replace(`${pathname}?${_searchParams.toString()}`);
}
}}
className={classNames(
selectedCategory === cat ? "bg-emphasis text-default" : "bg-muted text-emphasis",
"hover:bg-emphasis rounded-md px-4 py-2.5 text-sm font-medium transition hover:cursor-pointer"
)}>
{cat[0].toUpperCase() + cat.slice(1)}
</li>
))}
</ul>
{rightVisible && (
<button onClick={handleRight} className="absolute bottom-0 right-0 flex md:-top-1">
<div className="to-default flex h-12 w-5 bg-gradient-to-r from-transparent" />
<div className="bg-default flex h-12 w-5 items-center justify-end">
<Icon name="chevron-right" className="text-subtle h-4 w-4" />
</div>
</button>
)}
</div>
);
}
export function AllApps({ apps, searchText, categories, userAdminTeams }: AllAppsPropsType) {
const searchParams = useCompatSearchParams();
const { t } = useLocale();
const [selectedCategory, setSelectedCategory] = useState<string | null>(null);
const [appsContainerRef, enableAnimation] = useAutoAnimate<HTMLDivElement>();
const categoryQuery = searchParams?.get("category");
if (searchText) {
enableAnimation && enableAnimation(false);
}
useEffect(() => {
const queryCategory =
typeof categoryQuery === "string" && categories.includes(categoryQuery) ? categoryQuery : null;
setSelectedCategory(queryCategory);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [categoryQuery]);
const filteredApps = apps
.filter((app) =>
selectedCategory !== null
? app.categories
? app.categories.includes(selectedCategory as AppCategories)
: app.category === selectedCategory
: true
)
.filter((app) => (searchText ? app.name.toLowerCase().includes(searchText.toLowerCase()) : true))
.sort(function (a, b) {
if (a.name < b.name) return -1;
else if (a.name > b.name) return 1;
return 0;
});
return (
<div>
<CategoryTab selectedCategory={selectedCategory} searchText={searchText} categories={categories} />
{filteredApps.length ? (
<div
className="grid gap-3 lg:grid-cols-4 [@media(max-width:1270px)]:grid-cols-3 [@media(max-width:500px)]:grid-cols-1 [@media(max-width:730px)]:grid-cols-1"
ref={appsContainerRef}>
{filteredApps.map((app) => (
<AppCard
key={app.name}
app={app}
searchText={searchText}
credentials={app.credentials}
userAdminTeams={userAdminTeams}
/>
))}{" "}
</div>
) : (
<EmptyScreen
Icon="search"
headline={t("no_results")}
description={searchText ? searchText?.toString() : ""}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,233 @@
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import useAddAppMutation from "@calcom/app-store/_utils/useAddAppMutation";
import { InstallAppButton } from "@calcom/app-store/components";
import { doesAppSupportTeamInstall, isConferencing } from "@calcom/app-store/utils";
import { AppOnboardingSteps } from "@calcom/lib/apps/appOnboardingSteps";
import { getAppOnboardingUrl } from "@calcom/lib/apps/getAppOnboardingUrl";
import classNames from "@calcom/lib/classNames";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { UserAdminTeams } from "@calcom/lib/server/repository/user";
import type { AppFrontendPayload as App } from "@calcom/types/App";
import type { CredentialFrontendPayload as Credential } from "@calcom/types/Credential";
import type { ButtonProps } from "@calcom/ui";
import { Badge, showToast } from "@calcom/ui";
import { Button } from "../button";
interface AppCardProps {
app: App;
credentials?: Credential[];
searchText?: string;
userAdminTeams?: UserAdminTeams;
}
export function AppCard({ app, credentials, searchText, userAdminTeams }: AppCardProps) {
const { t } = useLocale();
const router = useRouter();
const allowedMultipleInstalls = app.categories && app.categories.indexOf("calendar") > -1;
const appAdded = (credentials && credentials.length) || 0;
const enabledOnTeams = doesAppSupportTeamInstall({
appCategories: app.categories,
concurrentMeetings: app.concurrentMeetings,
isPaid: !!app.paid,
});
const appInstalled = enabledOnTeams && userAdminTeams ? userAdminTeams.length < appAdded : appAdded > 0;
const mutation = useAddAppMutation(null, {
onSuccess: (data) => {
if (data?.setupPending) return;
setIsLoading(false);
showToast(t("app_successfully_installed"), "success");
},
onError: (error) => {
if (error instanceof Error) showToast(error.message || t("app_could_not_be_installed"), "error");
setIsLoading(false);
},
});
const [searchTextIndex, setSearchTextIndex] = useState<number | undefined>(undefined);
/**
* @todo Refactor to eliminate the isLoading state by using mutation.isPending directly.
* Currently, the isLoading state is used to manage the loading indicator due to the delay in loading the next page,
* which is caused by heavy queries in getServersideProps. This causes the loader to turn off before the page changes.
*/
const [isLoading, setIsLoading] = useState<boolean>(mutation.isPending);
useEffect(() => {
setSearchTextIndex(searchText ? app.name.toLowerCase().indexOf(searchText.toLowerCase()) : undefined);
}, [app.name, searchText]);
const handleAppInstall = () => {
setIsLoading(true);
if (isConferencing(app.categories)) {
mutation.mutate({
type: app.type,
variant: app.variant,
slug: app.slug,
returnTo:
WEBAPP_URL +
getAppOnboardingUrl({
slug: app.slug,
step: AppOnboardingSteps.EVENT_TYPES_STEP,
}),
});
} else if (
!doesAppSupportTeamInstall({
appCategories: app.categories,
concurrentMeetings: app.concurrentMeetings,
isPaid: !!app.paid,
})
) {
mutation.mutate({ type: app.type });
} else {
router.push(getAppOnboardingUrl({ slug: app.slug, step: AppOnboardingSteps.ACCOUNTS_STEP }));
}
};
return (
<div className="border-subtle relative flex h-64 flex-col rounded-md border p-5">
<div className="flex">
<img
src={app.logo}
alt={`${app.name} Logo`}
className={classNames(
app.logo.includes("-dark") && "dark:invert",
"mb-4 h-12 w-12 rounded-sm" // TODO: Maybe find a better way to handle this @Hariom?
)}
/>
</div>
<div className="flex items-center">
<h3 className="text-emphasis font-medium">
{searchTextIndex != undefined && searchText ? (
<>
{app.name.substring(0, searchTextIndex)}
<span className="bg-yellow-300" data-testid="highlighted-text">
{app.name.substring(searchTextIndex, searchTextIndex + searchText.length)}
</span>
{app.name.substring(searchTextIndex + searchText.length)}
</>
) : (
app.name
)}
</h3>
</div>
{/* TODO: add reviews <div className="flex text-sm text-default">
<span>{props.rating} stars</span> <Icon name="star" className="ml-1 mt-0.5 h-4 w-4 text-yellow-600" />
<span className="pl-1 text-subtle">{props.reviews} reviews</span>
</div> */}
<p
className="text-default mt-2 flex-grow text-sm"
style={{
overflow: "hidden",
display: "-webkit-box",
WebkitBoxOrient: "vertical",
WebkitLineClamp: "3",
}}>
{app.description}
</p>
<div className="mt-5 flex max-w-full flex-row justify-between gap-2">
<Button
color="secondary"
className="flex w-32 flex-grow justify-center"
href={`/apps/${app.slug}`}
data-testid={`app-store-app-card-${app.slug}`}>
{t("details")}
</Button>
{app.isGlobal || (credentials && credentials.length > 0 && allowedMultipleInstalls)
? !app.isGlobal && (
<InstallAppButton
type={app.type}
teamsPlanRequired={app.teamsPlanRequired}
disableInstall={!!app.dependencies && !app.dependencyData?.some((data) => !data.installed)}
wrapperClassName="[@media(max-width:260px)]:w-full"
render={({ useDefaultComponent, ...props }) => {
if (useDefaultComponent) {
props = {
...props,
onClick: () => {
handleAppInstall();
},
loading: isLoading,
};
}
return <InstallAppButtonChild paid={app.paid} {...props} />;
}}
/>
)
: credentials &&
!appInstalled && (
<InstallAppButton
type={app.type}
wrapperClassName="[@media(max-width:260px)]:w-full"
disableInstall={!!app.dependencies && app.dependencyData?.some((data) => !data.installed)}
teamsPlanRequired={app.teamsPlanRequired}
render={({ useDefaultComponent, ...props }) => {
if (useDefaultComponent) {
props = {
...props,
disabled: !!props.disabled,
onClick: () => {
handleAppInstall();
},
loading: isLoading,
};
}
return <InstallAppButtonChild paid={app.paid} {...props} />;
}}
/>
)}
</div>
<div className="max-w-44 absolute right-0 mr-4 flex flex-wrap justify-end gap-1">
{appAdded > 0 ? <Badge variant="green">{t("installed", { count: appAdded })}</Badge> : null}
{app.isTemplate && (
<span className="bg-error rounded-md px-2 py-1 text-sm font-normal text-red-800">Template</span>
)}
{(app.isDefault || (!app.isDefault && app.isGlobal)) && (
<span className="bg-subtle text-emphasis flex items-center rounded-md px-2 py-1 text-sm font-normal">
{t("default")}
</span>
)}
</div>
</div>
);
}
const InstallAppButtonChild = ({
paid,
...props
}: {
paid: App["paid"];
} & ButtonProps) => {
const { t } = useLocale();
// Paid apps don't support team installs at the moment
// Also, cal.ai(the only paid app at the moment) doesn't support team install either
if (paid) {
return (
<Button
color="secondary"
className="[@media(max-width:260px)]:w-full [@media(max-width:260px)]:justify-center"
StartIcon="plus"
data-testid="install-app-button"
{...props}>
{paid.trial ? t("start_paid_trial") : t("subscribe")}
</Button>
);
}
return (
<Button
color="secondary"
className="[@media(max-width:260px)]:w-full [@media(max-width:260px)]:justify-center"
StartIcon="plus"
data-testid="install-app-button"
{...props}
size="base">
{t("install")}
</Button>
);
};

View File

@@ -0,0 +1,30 @@
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import {
Examples,
Example,
Note,
Title,
CustomArgsTable,
VariantsTable,
VariantRow,
} from "@calcom/storybook/components";
import { AppStoreCategories as Categories } from "./Categories";
import { _SBAppCategoryList } from "./_storybookData";
<Meta title="UI/apps/Categories" component={Categories} />
<Title title="Categories" suffix="Brief" subtitle="Version 2.0 — Last Update: 03 Jan 2023" />
## Definition
Categories that is used in our appstore.
<CustomArgsTable of={Categories} />
## Examples
We don't currently mock translations in storybook so the stories will display placeholder text.
<Categories categories={_SBAppCategoryList} />

View File

@@ -0,0 +1,63 @@
import Image from "next/image";
import Link from "next/link";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon } from "../..";
import { SkeletonText } from "../skeleton";
import { Slider } from "./Slider";
export function AppStoreCategories({
categories,
}: {
categories: {
name: string;
count: number;
}[];
}) {
const { t, isLocaleReady } = useLocale();
return (
<div>
<Slider
title={t("featured_categories")}
items={categories}
itemKey={(category) => category.name}
options={{
perView: 5,
breakpoints: {
768 /* and below */: {
perView: 2,
},
},
}}
renderItem={(category) => (
<Link
key={category.name}
href={`/apps/categories/${category.name}`}
data-testid={`app-store-category-${category.name}`}
className="relative flex rounded-md"
style={{ background: "radial-gradient(farthest-side at top right, #a2abbe 0%, #E3E3E3 100%)" }}>
<div className="dark:bg-muted light:bg-[url('/noise.svg')] dark:from-subtle dark:to-muted w-full self-center bg-cover bg-center bg-no-repeat px-6 py-4 dark:bg-gradient-to-tr">
<Image
src={`/app-categories/${category.name}.svg`}
width={100}
height={100}
alt={category.name}
className="dark:invert"
/>
{isLocaleReady ? (
<h3 className="text-emphasis text-sm font-semibold capitalize">{category.name}</h3>
) : (
<SkeletonText invisible />
)}
<p className="text-subtle pt-2 text-sm font-medium">
{isLocaleReady ? t("number_apps", { count: category.count }) : <SkeletonText invisible />}{" "}
<Icon name="arrow-right" className="inline-block h-4 w-4" />
</p>
</div>
</Link>
)}
/>
</div>
);
}

View File

@@ -0,0 +1,26 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { AppFrontendPayload as App } from "@calcom/types/App";
import { AppCard } from "./AppCard";
import { Slider } from "./Slider";
export const PopularAppsSlider = <T extends App>({ items }: { items: T[] }) => {
const { t } = useLocale();
return (
<Slider<T>
title={t("most_popular")}
items={items.sort((a, b) => (b.installCount || 0) - (a.installCount || 0))}
itemKey={(app) => app.name}
options={{
perView: 3,
breakpoints: {
768 /* and below */: {
perView: 1,
},
},
}}
renderItem={(app) => <AppCard app={app} />}
/>
);
};

View File

@@ -0,0 +1,28 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { AppFrontendPayload as App } from "@calcom/types/App";
import { AppCard } from "./AppCard";
import { Slider } from "./Slider";
export const RecentAppsSlider = <T extends App>({ items }: { items: T[] }) => {
const { t } = useLocale();
return (
<Slider<T>
title={t("recently_added")}
items={items.sort(
(a, b) => new Date(b?.createdAt || 0).valueOf() - new Date(a?.createdAt || 0).valueOf()
)}
itemKey={(app) => app.name}
options={{
perView: 3,
breakpoints: {
768 /* and below */: {
perView: 1,
},
},
}}
renderItem={(app) => <AppCard app={app} />}
/>
);
};

View File

@@ -0,0 +1,51 @@
import { ShellSubHeading } from "../layout";
import Meta from "../meta/Meta";
import { SkeletonText } from "../skeleton";
export function SkeletonLoader({
className,
title,
description,
}: {
className?: string;
title?: string;
description?: string;
}) {
return (
<>
<ShellSubHeading title={<div className="bg-subtle h-6 w-32" />} {...{ className }} />
{title && description && <Meta title={title} description={description} />}
<ul className="bg-default border-subtle divide-subtle -mx-4 animate-pulse divide-y rounded-md border sm:mx-0 sm:overflow-hidden">
<SkeletonItem />
<SkeletonItem />
<SkeletonItem />
</ul>
</>
);
}
function SkeletonItem() {
return (
<li className="group flex w-full items-center justify-between p-3">
<div className="flex-grow truncate text-sm">
<div className="flex justify-start space-x-2 rtl:space-x-reverse">
<SkeletonText className="h-10 w-10" />
<div className="space-y-1">
<div>
<SkeletonText className="h-4 w-16" />
</div>
<div>
<SkeletonText className="h-4 w-32" />
</div>
</div>
</div>
</div>
<div className="mt-4 hidden flex-shrink-0 sm:ml-5 sm:mt-0 lg:flex">
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
<SkeletonText className="h-11 w-32" />
</div>
</div>
</li>
);
}

View File

@@ -0,0 +1,88 @@
import type { Options } from "@glidejs/glide";
import Glide from "@glidejs/glide";
import "@glidejs/glide/dist/css/glide.core.min.css";
import "@glidejs/glide/dist/css/glide.theme.min.css";
import type { ComponentProps, FC } from "react";
import { useEffect, useRef } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon } from "../..";
import { SkeletonText } from "../skeleton";
const SliderButton: FC<ComponentProps<"button">> = (props) => {
const { children, ...rest } = props;
return (
<button className="hover:bg-subtle text-default rounded p-2.5 transition" {...rest}>
{children}
</button>
);
};
export const Slider = <T extends string | unknown>({
title = "",
className = "",
items,
itemKey = (item) => `${item}`,
renderItem,
options = {},
}: {
title?: string;
className?: string;
items: T[];
itemKey?: (item: T) => string;
renderItem?: (item: T) => JSX.Element;
options?: Options;
}) => {
const glide = useRef(null);
const slider = useRef<Glide.Properties | null>(null);
const { isLocaleReady } = useLocale();
useEffect(() => {
if (glide.current) {
slider.current = new Glide(glide.current, {
type: "carousel",
...options,
}).mount();
}
return () => slider.current?.destroy();
}, [options]);
return (
<div className={`mb-2 ${className}`}>
<div className="glide" ref={glide}>
<div className="flex cursor-default items-center pb-3">
{isLocaleReady ? (
title && (
<div>
<h2 className="text-emphasis mt-0 text-base font-semibold leading-none">{title}</h2>
</div>
)
) : (
<SkeletonText className="h-4 w-24" />
)}
<div className="glide__arrows ml-auto flex items-center gap-x-1" data-glide-el="controls">
<SliderButton data-glide-dir="<">
<Icon name="arrow-left" className="h-5 w-5" />
</SliderButton>
<SliderButton data-glide-dir=">">
<Icon name="arrow-right" className="h-5 w-5" />
</SliderButton>
</div>
</div>
<div className="glide__track" data-glide-el="track">
<ul className="glide__slides">
{items.map((item) => {
if (typeof renderItem !== "function") return null;
return (
<li key={itemKey(item)} className="glide__slide h-auto pl-0">
{renderItem(item)}
</li>
);
})}
</ul>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,54 @@
import type { AppFrontendPayload as App } from "@calcom/types/App";
export const _SBApps: App[] = [
{
name: "Google Calendar",
description: "Google Calendar",
installed: true,
type: "google_calendar",
title: "Google Calendar",
variant: "calendar",
category: "calendar",
categories: ["calendar"],
logo: "/api/app-store/googlecalendar/icon.svg",
publisher: "BLS media",
slug: "google-calendar",
url: "https://bls.media/",
email: "hello@bls-media.de",
dirName: "googlecalendar",
},
{
name: "Zoom Video",
description: "Zoom Video",
type: "zoom_video",
categories: ["video"],
variant: "conferencing",
logo: "/api/app-store/zoomvideo/icon.svg",
publisher: "BLS media",
url: "https://zoom.us/",
category: "video",
slug: "zoom",
title: "Zoom Video",
email: "hello@bls-media.de",
appData: {
location: {
default: false,
linkType: "dynamic",
type: "integrations:zoom",
label: "Zoom Video",
},
},
dirName: "zoomvideo",
},
];
export const _SBAppCategoryList = [
{
name: "Calendar",
count: 1,
},
{
name: "Video",
count: 5,
},
];

View File

@@ -0,0 +1,73 @@
/* eslint-disable playwright/missing-playwright-await */
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import type { AppFrontendPayload } from "@calcom/types/App";
import { AppCard } from "./AppCard";
describe("Tests for AppCard component", () => {
const mockApp: AppFrontendPayload = {
logo: "/path/to/logo.png",
name: "Test App",
slug: "test-app",
description: "Test description for the app.",
categories: ["calendar"],
concurrentMeetings: true,
teamsPlanRequired: { upgradeUrl: "test" },
type: "test_calendar",
variant: "calendar",
publisher: "test",
url: "test",
email: "test",
};
// Abstracted render function
const renderAppCard = (appProps = {}, appCardProps = {}) => {
const appData = { ...mockApp, ...appProps };
const queryClient = new QueryClient();
render(
<QueryClientProvider client={queryClient}>
<AppCard app={appData} {...appCardProps} />;
</QueryClientProvider>
);
};
describe("Tests for app description", () => {
test("Should render the app name correctly and display app logo with correct alt text", () => {
renderAppCard();
const appLogo = screen.getByAltText("Test App Logo");
const appName = screen.getByText("Test App");
expect(appLogo).toBeInTheDocument();
expect(appLogo.getAttribute("src")).toBe("/path/to/logo.png");
expect(appName).toBeInTheDocument();
});
test("Should render details button with correct href", () => {
renderAppCard();
const detailsButton = screen.getByText("details");
expect(detailsButton).toBeInTheDocument();
expect(detailsButton.closest("a")).toHaveAttribute("href", "/apps/test-app");
});
test("Should highlight the app name based on searchText", () => {
renderAppCard({}, { searchText: "test" });
const highlightedText = screen.getByTestId("highlighted-text");
expect(highlightedText).toBeInTheDocument();
});
});
describe("Tests for app categories", () => {
test("Should show 'Template' badge if app is a template", () => {
renderAppCard({ isTemplate: true });
const templateBadge = screen.getByText("Template");
expect(templateBadge).toBeInTheDocument();
});
test("Should show 'default' badge if app is default or global", () => {
renderAppCard({ isDefault: true });
const defaultBadge = screen.getByText("default");
expect(defaultBadge).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,8 @@
export { useShouldShowArrows, AllApps } from "./AllApps";
export { AppCard } from "./AppCard";
export { Slider } from "./Slider";
export { SkeletonLoader as AppSkeletonLoader } from "./SkeletonLoader";
export { SkeletonLoader } from "./SkeletonLoader";
export { PopularAppsSlider } from "./PopularAppsSlider";
export { RecentAppsSlider } from "./RecentAppsSlider";
export { AppStoreCategories } from "./Categories";

View File

@@ -0,0 +1,26 @@
import { Icon } from "../..";
export type ArrowButtonProps = {
arrowDirection: "up" | "down";
onClick: () => void;
};
export function ArrowButton(props: ArrowButtonProps) {
return (
<>
{props.arrowDirection === "up" ? (
<button
className="bg-default text-muted hover:text-emphasis border-default hover:border-emphasis invisible absolute left-0 -ml-4 -mt-4 mb-4 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex lg:left-3"
onClick={props.onClick}>
<Icon name="arrow-up" className="h-5 w-5" />
</button>
) : (
<button
className="bg-default text-muted border-default hover:text-emphasis hover:border-emphasis invisible absolute left-0 -ml-4 mt-8 hidden h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all group-hover:visible group-hover:scale-100 sm:ml-0 sm:flex lg:left-3"
onClick={props.onClick}>
<Icon name="arrow-down" className="h-5 w-5" />
</button>
)}
</>
);
}

View File

@@ -0,0 +1,2 @@
export { ArrowButton } from "./ArrowButton";
export type { ArrowButtonProps } from "./ArrowButton";

View File

@@ -0,0 +1,83 @@
import * as AvatarPrimitive from "@radix-ui/react-avatar";
import { Provider as TooltipPrimitiveProvider } from "@radix-ui/react-tooltip";
import Link from "next/link";
import classNames from "@calcom/lib/classNames";
import { AVATAR_FALLBACK } from "@calcom/lib/constants";
import { Tooltip } from "../tooltip";
type Maybe<T> = T | null | undefined;
export type AvatarProps = {
className?: string;
size?: "xxs" | "xs" | "xsm" | "sm" | "md" | "mdLg" | "lg" | "xl";
imageSrc?: Maybe<string>;
title?: string;
alt: string;
href?: string | null;
fallback?: React.ReactNode;
accepted?: boolean;
asChild?: boolean; // Added to ignore the outer span on the fallback component - messes up styling
indicator?: React.ReactNode;
"data-testid"?: string;
};
const sizesPropsBySize = {
xxs: "w-3.5 h-3.5 min-w-3.5 min-h-3.5", // 14px
xs: "w-4 h-4 min-w-4 min-h-4 max-h-4", // 16px
xsm: "w-5 h-5 min-w-5 min-h-5", // 20px
sm: "w-6 h-6 min-w-6 min-h-6", // 24px
md: "w-8 h-8 min-w-8 min-h-8", // 32px
mdLg: "w-10 h-10 min-w-10 min-h-10", //40px
lg: "w-16 h-16 min-w-16 min-h-16", // 64px
xl: "w-24 h-24 min-w-24 min-h-24", // 96px
} as const;
export function Avatar(props: AvatarProps) {
const { imageSrc, size = "md", alt, title, href, indicator } = props;
const rootClass = classNames("aspect-square rounded-full", sizesPropsBySize[size]);
let avatar = (
<AvatarPrimitive.Root
data-testid={props?.["data-testid"]}
className={classNames(
"bg-emphasis item-center relative inline-flex aspect-square justify-center rounded-full align-top",
indicator ? "overflow-visible" : "overflow-hidden",
props.className,
sizesPropsBySize[size]
)}>
<>
<AvatarPrimitive.Image
src={imageSrc ?? undefined}
alt={alt}
className={classNames("aspect-square rounded-full", sizesPropsBySize[size])}
/>
<AvatarPrimitive.Fallback
delayMs={600}
asChild={props.asChild}
className="flex h-full items-center justify-center">
<>
{props.fallback ? props.fallback : <img src={AVATAR_FALLBACK} alt={alt} className={rootClass} />}
</>
</AvatarPrimitive.Fallback>
{indicator}
</>
</AvatarPrimitive.Root>
);
if (href) {
avatar = (
<Link data-testid="avatar-href" href={href}>
{avatar}
</Link>
);
}
return title ? (
<TooltipPrimitiveProvider>
<Tooltip content={title}>{avatar}</Tooltip>
</TooltipPrimitiveProvider>
) : (
<>{avatar}</>
);
}

View File

@@ -0,0 +1,61 @@
import classNames from "@calcom/lib/classNames";
import { Avatar } from "./Avatar";
export type AvatarGroupProps = {
size: "sm" | "lg";
items: {
image: string;
title?: string;
alt?: string;
href?: string | null;
}[];
className?: string;
truncateAfter?: number;
};
export const AvatarGroup = function AvatarGroup(props: AvatarGroupProps) {
const LENGTH = props.items.length;
const truncateAfter = props.truncateAfter || 4;
/**
* First, filter all the avatars object that have image
* Then, slice it until before `truncateAfter` index
*/
const displayedAvatars = props.items.filter((avatar) => avatar.image).slice(0, truncateAfter);
const numTruncatedAvatars = LENGTH - displayedAvatars.length;
if (!displayedAvatars.length) return <></>;
return (
<ul className={classNames("flex items-center", props.className)}>
{displayedAvatars.map((item, idx) => (
<li key={idx} className="-mr-1 inline-block">
<Avatar
data-testid="avatar"
className="border-subtle"
imageSrc={item.image}
title={item.title}
alt={item.alt || ""}
size={props.size}
href={item.href}
/>
</li>
))}
{numTruncatedAvatars > 0 && (
<li
className={classNames(
"bg-inverted relative -mr-1 inline-flex justify-center overflow-hidden rounded-full",
props.size === "sm" ? "min-w-6 h-6" : "min-w-16 h-16"
)}>
<span
className={classNames(
" text-inverted m-auto flex h-full w-full items-center justify-center text-center",
props.size === "sm" ? "text-[12px]" : "text-2xl"
)}>
+{numTruncatedAvatars}
</span>
</li>
)}
</ul>
);
};

View File

@@ -0,0 +1,53 @@
/* eslint-disable playwright/missing-playwright-await */
import { render } from "@testing-library/react";
import type { UserProfile } from "@calcom/types/UserProfile";
import { UserAvatar } from "./UserAvatar";
const mockUser = {
name: "John Doe",
username: "pro",
organizationId: null,
avatarUrl: "",
profile: {
id: 1,
username: "",
organizationId: null,
organization: null,
},
};
describe("tests for UserAvatar component", () => {
test("Should render the UsersAvatar Correctly", () => {
const { getByTestId } = render(<UserAvatar user={mockUser} data-testid="user-avatar-test" />);
const avatar = getByTestId("user-avatar-test");
expect(avatar).toBeInTheDocument();
});
test("It should render the organization logo if a organization is passed in", () => {
const profile: UserProfile = {
username: "",
id: 1,
upId: "1",
organizationId: 1,
organization: {
id: 1,
requestedSlug: "steve",
slug: "steve",
name: "Org1",
calVideoLogo: "",
},
};
const { getByTestId } = render(
<UserAvatar user={{ ...mockUser, profile }} data-testid="user-avatar-test" />
);
const avatar = getByTestId("user-avatar-test");
const organizationLogo = getByTestId("organization-logo");
expect(avatar).toBeInTheDocument();
expect(organizationLogo).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,70 @@
import { classNames } from "@calcom/lib";
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import type { User } from "@calcom/prisma/client";
import type { UserProfile } from "@calcom/types/UserProfile";
import { Avatar } from "@calcom/ui";
type Organization = {
id: number;
slug: string | null;
requestedSlug: string | null;
logoUrl?: string;
};
type UserAvatarProps = Omit<React.ComponentProps<typeof Avatar>, "alt" | "imageSrc"> & {
user: Pick<User, "name" | "username" | "avatarUrl"> & {
profile: Omit<UserProfile, "upId">;
};
noOrganizationIndicator?: boolean;
/**
* Useful when allowing the user to upload their own avatar and showing the avatar before it's uploaded
*/
previewSrc?: string | null;
alt?: string | null;
};
const indicatorBySize = {
xxs: "hidden", // 14px
xs: "hidden", // 16px
xsm: "hidden", // 20px
sm: "h-3 w-3", // 24px
md: "h-4 w-4", // 32px
mdLg: "h-5 w-5", //40px
lg: "h-6 w-6", // 64px
xl: "h-10 w-10", // 96px
} as const;
function OrganizationIndicator({
size,
organization,
user,
}: Pick<UserAvatarProps, "size" | "user"> & { organization: Organization }) {
const indicatorSize = size && indicatorBySize[size];
return (
<div className={classNames("absolute bottom-0 right-0 z-10", indicatorSize)}>
<img
data-testid="organization-logo"
src={getPlaceholderAvatar(organization.logoUrl, organization.slug)}
alt={user.username || ""}
className="flex h-full items-center justify-center rounded-full"
/>
</div>
);
}
/**
* It is aware of the user's organization to correctly show the avatar from the correct URL
*/
export function UserAvatar(props: UserAvatarProps) {
const { user, previewSrc = getUserAvatarUrl(user), noOrganizationIndicator, ...rest } = props;
const organization = user.profile?.organization ?? null;
const indicator =
organization && !noOrganizationIndicator ? (
<OrganizationIndicator size={props.size} organization={organization} user={props.user} />
) : (
props.indicator
);
return <Avatar {...rest} alt={user.name || "Nameless User"} imageSrc={previewSrc} indicator={indicator} />;
}

View File

@@ -0,0 +1,28 @@
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
import type { User } from "@calcom/prisma/client";
import type { UserProfile } from "@calcom/types/UserProfile";
import { AvatarGroup } from "@calcom/ui";
type UserAvatarProps = Omit<React.ComponentProps<typeof AvatarGroup>, "items"> & {
users: (Pick<User, "name" | "username" | "avatarUrl"> & {
profile: Omit<UserProfile, "upId">;
})[];
};
export function UserAvatarGroup(props: UserAvatarProps) {
const { users, ...rest } = props;
return (
<AvatarGroup
{...rest}
items={users.map((user) => ({
href: `${getBookerBaseUrlSync(user.profile?.organization?.slug ?? null)}/${
user.profile?.username
}?redirect=false`,
alt: user.name || "",
title: user.name || "",
image: getUserAvatarUrl(user),
}))}
/>
);
}

View File

@@ -0,0 +1,38 @@
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
import { getBookerBaseUrlSync } from "@calcom/lib/getBookerUrl/client";
import type { Team, User } from "@calcom/prisma/client";
import { AvatarGroup } from "@calcom/ui";
type UserAvatarProps = Omit<React.ComponentProps<typeof AvatarGroup>, "items"> & {
users: (Pick<User, "name" | "username" | "avatarUrl"> & {
bookerUrl: string;
})[];
organization: Pick<Team, "slug" | "name">;
};
export function UserAvatarGroupWithOrg(props: UserAvatarProps) {
const { users, organization, ...rest } = props;
const isEmbed = useIsEmbed();
const items = [
{
// We don't want booker to be able to see the list of other users or teams inside the embed
href: isEmbed ? null : getBookerBaseUrlSync(organization.slug),
image: `${WEBAPP_URL}/team/${organization.slug}/avatar.png`,
alt: organization.name || undefined,
title: organization.name,
},
].concat(
users.map((user) => {
return {
href: `${user.bookerUrl}/${user.username}?redirect=false`,
image: getUserAvatarUrl(user),
alt: user.name || undefined,
title: user.name || user.username || "",
};
})
);
return <AvatarGroup {...rest} items={items} />;
}

View File

@@ -0,0 +1,167 @@
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import {
Examples,
Example,
Note,
Title,
CustomArgsTable,
VariantsTable,
VariantRow,
} from "@calcom/storybook/components";
import { Avatar } from "./Avatar";
import { AvatarGroup } from "./AvatarGroup";
<Meta title="UI/Avatar" component={Avatar} />
<Title title="Avatar" suffix="Brief" subtitle="Version 2.0 — Last Update: 22 Aug 2022" />
## Definition
An avatar group signals that there is more than 1 person associated with an item
## Structure
Avatar group can be composed differently based on the number of user profile.
<CustomArgsTable of={Avatar} />
<Examples title="Avatar style">
<Example title="Small">
<Avatar size="sm" alt="Avatar Story" />
</Example>
<Example title="Large">
<Avatar size="lg" alt="Avatar Story" />
</Example>
</Examples>
<ArgsTable of={Avatar} />
### Avatar Group
<ArgsTable of={AvatarGroup} />
<Examples title="Avatar Group ">
<Example title="Small">
<AvatarGroup
size="sm"
items={[
{
image: "https://cal.com/stakeholder/peer.jpg",
alt: "Peer",
title: "Peer Richelsen",
},
{
image: "https://cal.com/stakeholder/bailey.jpg",
alt: "Bailey",
title: "Bailey Pumfleet",
},
{
image: "https://cal.com/stakeholder/alex-van-andel.jpg",
alt: "Alex",
title: "Alex Van Andel",
},
{
image: "https://cal.com/stakeholder/ciaran.jpg",
alt: "Ciarán",
title: "Ciarán Hanrahan",
},
{
image: "https://cal.com/stakeholder/peer.jpg",
alt: "Peer",
title: "Peer Richelsen",
},
{
image: "https://cal.com/stakeholder/bailey.jpg",
alt: "Bailey",
title: "Bailey Pumfleet",
},
{
image: "https://cal.com/stakeholder/alex-van-andel.jpg",
alt: "Alex",
title: "Alex Van Andel",
},
{
image: "https://cal.com/stakeholder/ciaran.jpg",
alt: "Ciarán",
title: "Ciarán Hanrahan",
},
]}
/>
</Example>
<Example title="large">
<AvatarGroup
size="lg"
items={[
{
image: "https://cal.com/stakeholder/peer.jpg",
alt: "Peer",
title: "Peer Richelsen",
},
{
image: "https://cal.com/stakeholder/bailey.jpg",
alt: "Bailey",
title: "Bailey Pumfleet",
},
{
image: "https://cal.com/stakeholder/alex-van-andel.jpg",
alt: "Alex",
title: "Alex Van Andel",
},
{
image: "https://cal.com/stakeholder/ciaran.jpg",
alt: "Ciarán",
title: "Ciarán Hanrahan",
},
{
image: "https://cal.com/stakeholder/peer.jpg",
alt: "Peer",
title: "Peer Richelsen",
},
{
image: "https://cal.com/stakeholder/bailey.jpg",
alt: "Bailey",
title: "Bailey Pumfleet",
},
{
image: "https://cal.com/stakeholder/alex-van-andel.jpg",
alt: "Alex",
title: "Alex Van Andel",
},
{
image: "https://cal.com/stakeholder/ciaran.jpg",
alt: "Ciarán",
title: "Ciarán Hanrahan",
},
]}
/>
</Example>
</Examples>
<Title offset title="Avatar" suffix="Variants" />
<Canvas>
<Story
name="Avatar"
args={{
size: "sm",
alt: "Avatar Story",
}}
argTypes={{
size: {
control: {
type: "inline-radio",
options: ["sm", "lg"],
},
},
alt: { control: "text" },
}}>
{({ size, alt }) => (
<VariantsTable titles={["Default"]} columnMinWidth={150}>
<VariantRow variant={size}>
<Avatar size={size} alt={alt} />
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>

View File

@@ -0,0 +1,7 @@
export { Avatar } from "./Avatar";
export { UserAvatar } from "./UserAvatar";
export type { AvatarProps } from "./Avatar";
export { AvatarGroup } from "./AvatarGroup";
export { UserAvatarGroup } from "./UserAvatarGroup";
export { UserAvatarGroupWithOrg } from "./UserAvatarGroupWithOrg";
export type { AvatarGroupProps } from "./AvatarGroup";

View File

@@ -0,0 +1,94 @@
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import React from "react";
import classNames from "@calcom/lib/classNames";
import { Icon, type IconName } from "../..";
export const badgeStyles = cva("font-medium inline-flex items-center justify-center rounded gap-x-1", {
variants: {
variant: {
default: "bg-attention text-attention",
warning: "bg-attention text-attention",
orange: "bg-attention text-attention",
success: "bg-success text-success",
green: "bg-success text-success",
gray: "bg-subtle text-emphasis",
blue: "bg-info text-info",
red: "bg-error text-error",
error: "bg-error text-error",
grayWithoutHover: "bg-gray-100 text-gray-800 dark:bg-darkgray-200 dark:text-darkgray-800",
},
size: {
sm: "px-1 py-0.5 text-xs leading-3",
md: "py-1 px-1.5 text-xs leading-3",
lg: "py-1 px-2 text-sm leading-4",
},
},
defaultVariants: {
variant: "default",
size: "md",
},
});
type InferredBadgeStyles = VariantProps<typeof badgeStyles>;
type IconOrDot =
| {
startIcon?: IconName;
withDot?: never;
}
| { startIcon?: never; withDot?: true };
export type BadgeBaseProps = InferredBadgeStyles & {
children: React.ReactNode;
rounded?: boolean;
customStartIcon?: React.ReactNode;
} & IconOrDot;
export type BadgeProps =
/**
* This union type helps TypeScript understand that there's two options for this component:
* Either it's a div element on which the onClick prop is not allowed, or it's a button element
* on which the onClick prop is required. This is because the onClick prop is used to determine
* whether the component should be a button or a div.
*/
| (BadgeBaseProps & Omit<React.HTMLAttributes<HTMLDivElement>, "onClick"> & { onClick?: never })
| (BadgeBaseProps & Omit<React.HTMLAttributes<HTMLButtonElement>, "onClick"> & { onClick: () => void });
export const Badge = function Badge(props: BadgeProps) {
const {
customStartIcon,
variant,
className,
size,
startIcon,
withDot,
children,
rounded,
...passThroughProps
} = props;
const isButton = "onClick" in passThroughProps && passThroughProps.onClick !== undefined;
const StartIcon = startIcon;
const classes = classNames(
badgeStyles({ variant, size }),
rounded && "h-5 w-5 rounded-full p-0",
className
);
const Children = () => (
<>
{withDot ? <Icon name="dot" data-testid="go-primitive-dot" className="h-3 w-3 stroke-[3px]" /> : null}
{customStartIcon ||
(StartIcon ? (
<Icon name={StartIcon} data-testid="start-icon" className="h-3 w-3 stroke-[3px]" />
) : null)}
{children}
</>
);
const Wrapper = isButton ? "button" : "div";
return React.createElement(Wrapper, { ...passThroughProps, className: classes }, <Children />);
};

View File

@@ -0,0 +1,14 @@
import { Icon } from "../..";
import { Tooltip } from "../tooltip/Tooltip";
export function InfoBadge({ content }: { content: string }) {
return (
<>
<Tooltip side="top" content={content}>
<span title={content}>
<Icon name="info" className="text-subtle relative left-1 right-1 top-px mt-px h-4 w-4" />
</span>
</Tooltip>
</>
);
}

View File

@@ -0,0 +1,16 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Tooltip } from "../tooltip";
import { Badge } from "./Badge";
export const UpgradeOrgsBadge = function UpgradeOrgsBadge() {
const { t } = useLocale();
return (
<Tooltip content={t("orgs_upgrade_to_enable_feature")}>
<a href="https://cal.com/enterprise" target="_blank">
<Badge variant="gray">{t("upgrade")}</Badge>
</a>
</Tooltip>
);
};

View File

@@ -0,0 +1,22 @@
import Link from "next/link";
import { useHasPaidPlan } from "@calcom/lib/hooks/useHasPaidPlan";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Tooltip } from "../tooltip";
import { Badge } from "./Badge";
export const UpgradeTeamsBadge = function UpgradeTeamsBadge() {
const { t } = useLocale();
const { hasPaidPlan } = useHasPaidPlan();
if (hasPaidPlan) return null;
return (
<Tooltip content={t("upgrade_to_enable_feature")}>
<Link href="/teams">
<Badge variant="gray">{t("upgrade")}</Badge>
</Link>
</Tooltip>
);
};

View File

@@ -0,0 +1,99 @@
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import {
Examples,
Example,
Note,
Title,
CustomArgsTable,
VariantsTable,
VariantRow,
} from "@calcom/storybook/components";
import { Plus } from "../icon";
import { Badge } from "./Badge";
<Meta title="UI/Badge" component={Badge} />
<Title title="Badge" suffix="Brief" subtitle="Version 2.0 — Last Update: 22 Aug 2022" />
## Definition
Badges are small status descriptors for UI elements. A badge consists of a small circle, typically containing a number or other short set of characters, that appears in proximity to another object. We provide three different types of badges such as status, alert, and brand badge.
Status badge communicate status information. It is generally used within a container such as accordion and tables to label status for easy scanning.
## Structure
<CustomArgsTable of={Badge} />
<Examples title="Badge style">
<Example title="Gray">
<Badge variant="gray">Badge text</Badge>
</Example>
<Example title="Green/Success">
<Badge variant="success">Badge text</Badge>
</Example>
<Example title="Orange/Default">
<Badge variant="default">Badge text</Badge>
</Example>
<Example title="Red/Error">
<Badge variant="red">Badge text</Badge>
</Example>
</Examples>
<Examples title="Variants">
<Example title="Default">
<Badge>Button text</Badge>
</Example>
<Example title="With Dot">
<Badge withDot>Button Text</Badge>
</Example>
<Example title="With Icon">
<Badge startIcon="plus">Button Text</Badge>
</Example>
</Examples>
## Alert Badges
## Usage
Alert badge is used in conjunction with an item, profile or label to indicate numeric value and messages associated with them.
<Title offset title="Badge" suffix="Variants" />
<Canvas>
<Story
name="All Variants"
args={{
severity: "default",
label: "Badge text",
}}
argTypes={{
severity: {
control: {
type: "inline-radio",
options: ["default", "success", "gray", "error"],
},
},
label: {
control: {
type: "text",
},
},
}}>
{({ severity, label }) => (
<VariantsTable titles={["Default", "With Dot", "With Icon"]} columnMinWidth={150}>
<VariantRow variant={severity}>
<Badge variant={severity}>{label}</Badge>
<Badge variant={severity} withDot>
{label}
</Badge>
<Badge variant={severity} startIcon="plus">
{label}
</Badge>
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>

View File

@@ -0,0 +1,91 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable playwright/missing-playwright-await */
import { render, screen, fireEvent } from "@testing-library/react";
import { vi } from "vitest";
import { Badge, badgeStyles } from "./Badge";
describe("Tests for Badge component", () => {
const variants = [
"default",
"warning",
"orange",
"success",
"green",
"gray",
"blue",
"red",
"error",
"grayWithoutHover",
];
const sizes = ["sm", "md", "lg"];
const children = "Test Badge";
test.each(variants)("Should apply variant class", (variant) => {
render(<Badge variant={variant as any}>{children}</Badge>);
const badgeClass = screen.getByText(children).className;
const badgeComponentClass = badgeStyles({ variant: variant as any });
expect(badgeClass).toEqual(badgeComponentClass);
});
test.each(sizes)("Should apply size class", (size) => {
render(<Badge size={size as any}>{children}</Badge>);
const badgeClass = screen.getByText(children).className;
const badgeComponentClass = badgeStyles({ size: size as any });
expect(badgeClass).toEqual(badgeComponentClass);
});
test("Should render without errors", () => {
render(<Badge>{children}</Badge>);
expect(screen.getByText(children)).toBeInTheDocument();
});
test("Should render WithDot if the prop is true and shouldn't render if is false", async () => {
const { rerender } = render(<Badge withDot>{children}</Badge>);
expect(await screen.findByTestId("go-primitive-dot")).toBeInTheDocument();
rerender(<Badge>{children}</Badge>);
expect(screen.queryByTestId("go-primitive-dot")).not.toBeInTheDocument();
});
test("Should render with a startIcon when startIcon prop is provided shouldn't render if is false", () => {
const { rerender } = render(<Badge customStartIcon={<svg data-testid="start-icon" />}>{children}</Badge>);
expect(screen.getByTestId("start-icon")).toBeInTheDocument();
rerender(<Badge>{children}</Badge>);
expect(screen.queryByTestId("start-icon")).not.toBeInTheDocument();
});
test("Should render as a button when onClick prop is provided and shouldn't if is not", () => {
const handleClick = vi.fn();
const { rerender } = render(<Badge onClick={handleClick}>{children}</Badge>);
const badge = screen.getByText(children);
expect(badge.tagName).toBe("BUTTON");
fireEvent.click(badge);
expect(handleClick).toHaveBeenCalledTimes(1);
rerender(<Badge>{children}</Badge>);
const updateBadge = screen.getByText(children);
expect(updateBadge.tagName).not.toBe("BUTTON");
});
test("Should render as a div when onClick prop is not provided", () => {
render(<Badge>{children}</Badge>);
const badge = screen.getByText(children);
expect(badge.tagName).toBe("DIV");
});
test("Should render children when provided", () => {
const { getByText } = render(
<Badge>
<span>Child element 1</span>
<span>Child element 2</span>
</Badge>
);
expect(getByText("Child element 1")).toBeInTheDocument();
expect(getByText("Child element 2")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,5 @@
export { Badge } from "./Badge";
export { UpgradeTeamsBadge } from "./UpgradeTeamsBadge";
export { UpgradeOrgsBadge } from "./UpgradeOrgsBadge";
export { InfoBadge } from "./InfoBadge";
export type { BadgeProps } from "./Badge";

View File

@@ -0,0 +1,69 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Children, Fragment, useEffect, useState } from "react";
type BreadcrumbProps = {
children: React.ReactNode;
};
export const Breadcrumb = ({ children }: BreadcrumbProps) => {
const childrenArray = Children.toArray(children);
const childrenSeperated = childrenArray.map((child, index) => {
// If not the last item in the array insert a /
if (index !== childrenArray.length - 1) {
return (
<Fragment key={index}>
{child}
<span>/</span>
</Fragment>
);
}
// Else return just the child
return child;
});
return (
<nav className="text-default text-sm font-normal leading-5">
<ol className="flex items-center space-x-2 rtl:space-x-reverse">{childrenSeperated}</ol>
</nav>
);
};
type BreadcrumbItemProps = {
children: React.ReactNode;
href: string;
listProps?: JSX.IntrinsicElements["li"];
};
export const BreadcrumbItem = ({ children, href, listProps }: BreadcrumbItemProps) => {
return (
<li {...listProps}>
<Link href={href}>{children}</Link>
</li>
);
};
export const BreadcrumbContainer = () => {
const pathname = usePathname();
const [, setBreadcrumbs] = useState<{ href: string; label: string }[]>();
useEffect(() => {
const rawPath = pathname; // Pathname doesn't include search params anymore
let pathArray = rawPath?.split("/") ?? [];
pathArray.shift();
pathArray = pathArray.filter((path) => path !== "");
const allBreadcrumbs = pathArray.map((path, idx) => {
const href = `/${pathArray.slice(0, idx + 1).join("/")}`;
return {
href,
label: path.charAt(0).toUpperCase() + path.slice(1),
};
});
setBreadcrumbs(allBreadcrumbs);
}, [pathname]);
};
export default Breadcrumb;

View File

@@ -0,0 +1,26 @@
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import { Examples, Example, Note, Title, CustomArgsTable } from "@calcom/storybook/components";
import { Breadcrumb, BreadcrumbItem } from "./Breadcrumb";
<Meta title="UI/Breadcrumbs" component={Breadcrumb} />
<Title title="Breadcrumbs" suffix="Brief" subtitle="Version 2.0 — Last Update: 22 Aug 2022" />
## Structure
<CustomArgsTable of={Breadcrumb} />
<Examples title="Breadcrumb style">
<Example title="Primary">
<Breadcrumb>
<BreadcrumbItem href="/">Home</BreadcrumbItem>
<BreadcrumbItem href="/">Test</BreadcrumbItem>
</Breadcrumb>
</Example>
</Examples>
## Usage
When hovering over the button, there should be a tooltip to explain the context of the button, so the user can understand the result of action.

View File

@@ -0,0 +1,105 @@
import { render, screen } from "@testing-library/react";
import { Breadcrumb, BreadcrumbItem } from "./Breadcrumb";
describe("Tests for Breadcrumb component", () => {
test("Should render correctly with no items", () => {
render(
<Breadcrumb>
<div>Dummy Child</div>
</Breadcrumb>
);
const breadcrumbNav = screen.getByRole("navigation");
expect(breadcrumbNav).toBeInTheDocument();
const separators = screen.queryAllByText("/");
expect(separators).toHaveLength(0);
});
test("Should render correctly with custom list props", () => {
render(
<Breadcrumb>
<BreadcrumbItem href="/" listProps={{ className: "custom-list" }}>
Home
</BreadcrumbItem>
<BreadcrumbItem href="/about" listProps={{ className: "custom-list" }}>
About
</BreadcrumbItem>
<BreadcrumbItem href="/contact" listProps={{ className: "custom-list" }}>
Contact
</BreadcrumbItem>
</Breadcrumb>
);
const customListItems = document.querySelectorAll(".custom-list");
expect(customListItems.length).toBe(3);
});
test("Should generate correct hrefs and labels", () => {
render(
<Breadcrumb>
<BreadcrumbItem href="/category">Category</BreadcrumbItem>
<BreadcrumbItem href="/category/item">Item</BreadcrumbItem>
</Breadcrumb>
);
const categoryLink = screen.getByText("Category");
const itemLink = screen.getByText("Item");
expect(categoryLink.getAttribute("href")).toBe("/category");
expect(itemLink.getAttribute("href")).toBe("/category/item");
});
test("Should /category be a anchor tag", async () => {
render(
<Breadcrumb>
<BreadcrumbItem href="/category">Category</BreadcrumbItem>
<BreadcrumbItem href="/category/item">Item</BreadcrumbItem>
</Breadcrumb>
);
const categoryLink = screen.getByText("Category");
const categoryAnchor = categoryLink.closest("a");
const categoryItem = categoryAnchor?.parentElement;
expect(categoryAnchor).toBeInTheDocument();
expect(categoryItem?.tagName).toBe("LI");
expect(categoryAnchor?.getAttribute("href")).toBe("/category");
});
test("Should not render separators when there is only one item", () => {
render(
<Breadcrumb>
<BreadcrumbItem href="/">Home</BreadcrumbItem>
</Breadcrumb>
);
const separators = screen.queryAllByText("/");
expect(separators).toHaveLength(0);
});
test("Should render breadcrumbs with correct order when rendered in reverse order", () => {
render(
<Breadcrumb>
<BreadcrumbItem href="/contact">Contact</BreadcrumbItem>
<BreadcrumbItem href="/about">About</BreadcrumbItem>
<BreadcrumbItem href="/">Home</BreadcrumbItem>
</Breadcrumb>
);
const breadcrumbList = screen.getByRole("list");
const breadcrumbItems = screen.getAllByRole("listitem");
expect(breadcrumbItems).toHaveLength(3);
expect(breadcrumbList).toContainElement(breadcrumbItems[2]);
expect(breadcrumbList).toContainElement(breadcrumbItems[1]);
expect(breadcrumbList).toContainElement(breadcrumbItems[0]);
expect(breadcrumbItems[2]).toHaveTextContent("Home");
expect(breadcrumbItems[1]).toHaveTextContent("About");
expect(breadcrumbItems[0]).toHaveTextContent("Contact");
const separators = screen.getAllByText("/");
expect(separators).toHaveLength(2);
});
});

View File

@@ -0,0 +1 @@
export { Breadcrumb, BreadcrumbContainer, BreadcrumbItem } from "./Breadcrumb";

View File

@@ -0,0 +1,257 @@
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import type { LinkProps } from "next/link";
import Link from "next/link";
import React, { forwardRef } from "react";
import classNames from "@calcom/lib/classNames";
import { Icon, type IconName } from "../..";
import { Tooltip } from "../tooltip";
type InferredVariantProps = VariantProps<typeof buttonClasses>;
export type ButtonColor = NonNullable<InferredVariantProps["color"]>;
export type ButtonBaseProps = {
/** Action that happens when the button is clicked */
onClick?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
/**Left aligned icon*/
CustomStartIcon?: React.ReactNode;
StartIcon?: IconName;
/**Right aligned icon */
EndIcon?: IconName;
shallow?: boolean;
/**Tool tip used when icon size is set to small */
tooltip?: string | React.ReactNode;
tooltipSide?: "top" | "right" | "bottom" | "left";
tooltipOffset?: number;
disabled?: boolean;
flex?: boolean;
} & Omit<InferredVariantProps, "color"> & {
color?: ButtonColor;
};
export type ButtonProps = ButtonBaseProps &
(
| (Omit<JSX.IntrinsicElements["a"], "href" | "onClick" | "ref"> & LinkProps)
| (Omit<JSX.IntrinsicElements["button"], "onClick" | "ref"> & { href?: never })
);
export const buttonClasses = cva(
"whitespace-nowrap inline-flex items-center text-sm font-medium relative rounded-md transition disabled:cursor-not-allowed",
{
variants: {
variant: {
button: "",
icon: "flex justify-center",
fab: "rounded-full justify-center md:rounded-md radix-state-open:rotate-45 md:radix-state-open:rotate-0 radix-state-open:shadown-none radix-state-open:ring-0 !shadow-none",
},
color: {
primary:
"bg-brand-default hover:bg-brand-emphasis focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset focus-visible:ring-brand-default text-brand disabled:bg-brand-subtle disabled:text-brand-subtle disabled:opacity-40 disabled:hover:bg-brand-subtle disabled:hover:text-brand-default disabled:hover:opacity-40",
secondary:
"text-emphasis border border-default bg-default hover:bg-muted hover:border-emphasis focus-visible:bg-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset focus-visible:ring-empthasis disabled:border-subtle disabled:bg-opacity-30 disabled:text-muted disabled:hover:bg-opacity-30 disabled:hover:text-muted disabled:hover:border-subtle disabled:hover:bg-default",
minimal:
"text-emphasis hover:bg-subtle focus-visible:bg-subtle focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset focus-visible:ring-empthasis disabled:border-subtle disabled:bg-opacity-30 disabled:text-muted disabled:hover:bg-transparent disabled:hover:text-muted disabled:hover:border-subtle",
destructive:
"border border-default text-emphasis hover:text-red-700 dark:hover:text-red-100 focus-visible:text-red-700 hover:border-red-100 focus-visible:border-red-100 hover:bg-error focus-visible:bg-error focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset focus-visible:ring-red-700 disabled:bg-red-100 disabled:border-red-200 disabled:text-red-700 disabled:hover:border-red-200 disabled:opacity-40",
},
size: {
sm: "px-3 py-2 leading-4 rounded-sm" /** For backwards compatibility */,
base: "h-9 px-4 py-2.5 ",
lg: "h-[36px] px-4 py-2.5 ",
},
loading: {
true: "cursor-wait",
},
},
compoundVariants: [
// Primary variants
{
loading: true,
color: "primary",
className: "bg-brand-subtle text-brand-subtle",
},
// Secondary variants
{
loading: true,
color: "secondary",
className: "bg-subtle text-emphasis/80",
},
// Minimal variants
{
loading: true,
color: "minimal",
className: "bg-subtle text-emphasis/30",
},
// Destructive variants
{
loading: true,
color: "destructive",
className:
"text-red-700/30 dark:text-red-700/30 hover:text-red-700/30 border border-default text-emphasis",
},
{
variant: "icon",
size: "base",
className: "min-h-[36px] min-w-[36px] !p-2 hover:border-default",
},
{
variant: "icon",
size: "sm",
className: "h-6 w-6 !p-1",
},
{
variant: "fab",
size: "base",
className: "h-14 md:h-9 md:w-auto md:px-4 md:py-2.5",
},
],
defaultVariants: {
variant: "button",
color: "primary",
size: "base",
},
}
);
export const Button = forwardRef<HTMLAnchorElement | HTMLButtonElement, ButtonProps>(function Button(
props: ButtonProps,
forwardedRef
) {
const {
loading = false,
color = "primary",
size,
variant = "button",
type = "button",
tooltipSide = "top",
tooltipOffset = 4,
StartIcon,
CustomStartIcon,
EndIcon,
shallow,
// attributes propagated from `HTMLAnchorProps` or `HTMLButtonProps`
...passThroughProps
} = props;
// Buttons are **always** disabled if we're in a `loading` state
const disabled = props.disabled || loading;
// If pass an `href`-attr is passed it's `<a>`, otherwise it's a `<button />`
const isLink = typeof props.href !== "undefined";
const elementType = isLink ? "a" : "button";
const element = React.createElement(
elementType,
{
...passThroughProps,
disabled,
type: !isLink ? type : undefined,
ref: forwardedRef,
className: classNames(buttonClasses({ color, size, loading, variant }), props.className),
// if we click a disabled button, we prevent going through the click handler
onClick: disabled
? (e: React.MouseEvent<HTMLElement, MouseEvent>) => {
e.preventDefault();
}
: props.onClick,
},
<>
{CustomStartIcon ||
(StartIcon && (
<>
{variant === "fab" ? (
<>
<Icon
name={StartIcon}
className="hidden h-4 w-4 stroke-[1.5px] ltr:-ml-1 ltr:mr-2 rtl:-mr-1 rtl:ml-2 md:inline-flex"
/>
<Icon name="plus" data-testid="plus" className="inline h-6 w-6 md:hidden" />
</>
) : (
<Icon
name={StartIcon}
className={classNames(
variant === "icon" && "h-4 w-4",
variant === "button" && "h-4 w-4 stroke-[1.5px] ltr:-ml-1 ltr:mr-2 rtl:-mr-1 rtl:ml-2"
)}
/>
)}
</>
))}
{variant === "fab" ? <span className="hidden md:inline">{props.children}</span> : props.children}
{loading && (
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform">
<svg
className={classNames(
"mx-4 h-5 w-5 animate-spin",
color === "primary" ? "text-inverted" : "text-emphasis"
)}
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path
className="opacity-75"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
/>
</svg>
</div>
)}
{EndIcon && (
<>
{variant === "fab" ? (
<>
<Icon name={EndIcon} className="-mr-1 me-2 ms-2 hidden h-5 w-5 md:inline" />
<Icon name="plus" data-testid="plus" className="inline h-6 w-6 md:hidden" />
</>
) : (
<Icon
name={EndIcon}
className={classNames(
"inline-flex",
variant === "icon" && "h-4 w-4",
variant === "button" && "h-4 w-4 stroke-[1.5px] ltr:-mr-1 ltr:ml-2 rtl:-ml-1 rtl:mr-2"
)}
/>
)}
</>
)}
</>
);
return props.href ? (
<Link data-testid="link-component" passHref href={props.href} shallow={shallow && shallow} legacyBehavior>
{element}
</Link>
) : (
<Wrapper
data-testid="wrapper"
tooltip={props.tooltip}
tooltipSide={tooltipSide}
tooltipOffset={tooltipOffset}>
{element}
</Wrapper>
);
});
const Wrapper = ({
children,
tooltip,
tooltipSide,
tooltipOffset,
}: {
tooltip?: string | React.ReactNode;
children: React.ReactNode;
tooltipSide?: "top" | "right" | "bottom" | "left";
tooltipOffset?: number;
}) => {
if (!tooltip) {
return <>{children}</>;
}
return (
<Tooltip data-testid="tooltip" content={tooltip} side={tooltipSide} sideOffset={tooltipOffset}>
{children}
</Tooltip>
);
};

View File

@@ -0,0 +1,21 @@
import React from "react";
import { Icon, type IconName } from "@calcom/ui";
interface LinkIconButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
Icon: IconName;
}
export default function LinkIconButton(props: LinkIconButtonProps) {
return (
<div className="-ml-2">
<button
type="button"
{...props}
className="text-md hover:bg-emphasis hover:text-emphasis text-default flex items-center rounded-md px-2 py-1 text-sm font-medium">
<Icon name={props.Icon} className="text-subtle h-4 w-4 ltr:mr-2 rtl:ml-2" />
{props.children}
</button>
</div>
);
}

View File

@@ -0,0 +1,244 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import {
Examples,
Example,
Note,
Title,
VariantsTable,
VariantColumn,
RowTitles,
CustomArgsTable,
VariantRow,
} from "@calcom/storybook/components";
import { Plus, X } from "../icon";
import { Button } from "./Button";
<Meta title="UI/Button" component={Button} />
<Title title="Buttons" suffix="Brief" subtitle="Version 2.1 — Last Update: 24 Aug 2023" />
## Definition
Button are clickable elements that initiates user actions. Labels in the button guide users to what action will occur when the user interacts with it.
## Structure
<CustomArgsTable of={Button} />
<Examples
title="Button style"
footnote={
<ul>
<li>Primary: Signals most important actions at any given point in the application.</li>
<li>Secondary: Gives visual weight to actions that are important</li>
<li>Minimal: Used for actions that we want to give very little significane to</li>
</ul>
}>
<Example title="Primary">
<Button className="sb-fake-pseudo--focus">Button text</Button>
</Example>
<Example title="Secondary">
<Button color="secondary">Button text</Button>
</Example>
<Example title="Minimal">
<Button color="minimal">Button text</Button>
</Example>
</Examples>
<Examples title="State">
<Example title="Default">
<Button>Button text</Button>
</Example>
<Example title="Hover">
<Button className="sb-pseudo--hover">Button text</Button>
</Example>
<Example title="Focus">
<Button className="sb-pseudo--focus">Button text</Button>
</Example>
<Example title="Disabled">
<Button disabled>Button text</Button>
</Example>
<Example title="Loading">
<Button loading>Button text</Button>
</Example>
</Examples>
<Examples title="Icons">
<Example title="Default">
<Button>Button text</Button>
</Example>
<Example title="Icon left">
<Button StartIcon="plus">Button text</Button>
</Example>
<Example title="Icon right">
<Button EndIcon="plus">Button text</Button>
</Example>
</Examples>
<Examples title="Icons">
<Example title="Icon Normal">
<Button StartIcon="x" variant="icon" size="base" color="minimal"></Button>
</Example>
<Example title="Icon Small">
<Button StartIcon="x" variant="icon" size="sm" color="minimal"></Button>
</Example>
<Example title="Icon Loading">
<Button StartIcon="x" variant="icon" size="sm" color="minimal" loading></Button>
</Example>
</Examples>
## Anatomy
Button are clickable elements that initiates user actions. Labels in the button guide users to what action will occur when the user interacts with it.
## Usage
<Note>In general, there should be only one Primary button in any application context</Note>
<Note>
Aim to use maximum 2 words for the button label. Button size can be flexible based on the visual hierarchy
and devices.{" "}
</Note>
<Note>Hover state variant for Mobile button is an option for assistive device.</Note>
<Title offset title="Buttons" suffix="Variants" />
<Canvas>
<Story name="All variants">
<VariantsTable titles={["Primary", "Secondary", "Minimal", "Destructive", "Icon"]} columnMinWidth={150}>
<VariantRow variant="Default">
<Button>Button text</Button>
<Button color="secondary">Button text</Button>
<Button color="minimal">Button text</Button>
<Button color="destructive">Button text</Button>
<Button color="destructive" variant="icon" StartIcon="x"></Button>
</VariantRow>
<VariantRow variant="Hover">
<Button className="sb-pseudo--hover">Button text</Button>
<Button className="sb-pseudo--hover" color="secondary">
Button text
</Button>
<Button className="sb-pseudo--hover" color="minimal">
Button text
</Button>
<Button className="sb-pseudo--hover" color="destructive">
Button text
</Button>
<Button className="sb-pseudo--hover" color="destructive" variant="icon" StartIcon="x"></Button>
</VariantRow>
<VariantRow variant="Focus">
<Button className="sb-pseudo--focus">Button text</Button>
<Button className="sb-pseudo--focus" color="secondary">
Button text
</Button>
<Button className="sb-pseudo--focus" color="minimal">
Button text
</Button>
<Button className="sb-pseudo--focus" color="destructive">
Button text
</Button>
<Button className="sb-pseudo--focus" color="destructive" variant="icon" StartIcon="x"></Button>
</VariantRow>
<VariantRow variant="Loading">
<Button loading>Button text</Button>
<Button loading color="secondary">
Button text
</Button>
<Button loading color="minimal">
Button text
</Button>
<Button loading color="destructive">
Button text
</Button>
<Button loading color="destructive" variant="icon" StartIcon="x"></Button>
</VariantRow>
<VariantRow variant="Disabled">
<Button disabled>Button text</Button>
<Button disabled color="secondary">
Button text
</Button>
<Button disabled color="minimal">
Button text
</Button>
<Button disabled color="destructive">
Button text
</Button>
<Button disabled color="minimal" variant="icon" StartIcon="x"></Button>
</VariantRow>
</VariantsTable>
</Story>
<Story
name="Button Playground"
play={({ canvasElement }) => {
const darkVariantContainer = canvasElement.querySelector('[data-mode="dark"]');
const buttonElement = darkVariantContainer.querySelector("button");
buttonElement?.addEventListener("mouseover", () => {
setTimeout(() => {
document.querySelector('[data-testid="tooltip"]').classList.add("dark");
}, 55);
});
}}
args={{
color: "primary",
size: "base",
loading: false,
disabled: false,
children: "Button text",
className: "",
tooltip: "tooltip",
}}
argTypes={{
color: {
control: {
type: "inline-radio",
options: ["primary", "secondary", "minimal", "destructive"],
},
},
size: {
control: {
type: "inline-radio",
options: ["base", "sm"],
},
},
loading: {
control: {
type: "boolean",
},
},
disabled: {
control: {
type: "boolean",
},
},
children: {
control: {
type: "text",
},
},
className: {
control: {
type: "inline-radio",
options: ["", "sb-pseudo--hover", "sb-pseudo--focus"],
},
},
tooltip: {
control: {
type: "text",
},
},
}}>
{({ children, ...args }) => (
<VariantsTable titles={["Light & Dark Modes"]} columnMinWidth={150}>
<VariantRow variant="Button">
<TooltipProvider>
<Button variant="default" {...args}>
{children}
</Button>
</TooltipProvider>
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>

View File

@@ -0,0 +1,248 @@
/* eslint-disable playwright/missing-playwright-await */
import { fireEvent, render, screen } from "@testing-library/react";
import { useState } from "react";
import { vi } from "vitest";
import { Button, buttonClasses } from "./Button";
const observeMock = vi.fn();
window.ResizeObserver = vi.fn().mockImplementation(() => ({
disconnect: vi.fn(),
observe: observeMock,
unobserve: vi.fn(),
}));
vi.mock("../tooltip", async () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const actual = (await vi.importActual("../tooltip")) as any;
const TooltipMock = (props: object) => {
const [open, setOpen] = useState(false);
return (
<actual.Tooltip
{...props}
open={open}
onOpenChange={(isOpen: boolean) => {
setOpen(isOpen);
}}
/>
);
};
return {
Tooltip: TooltipMock,
};
});
describe("Tests for Button component", () => {
test("Should apply the icon variant class", () => {
render(<Button variant="icon">Test Button</Button>);
const buttonClass = screen.getByText("Test Button");
const buttonComponentClass = buttonClasses({ variant: "icon" });
const buttonClassArray = buttonClass.className.split(" ");
const hasMatchingClassNames = buttonComponentClass
.split(" ")
.some((className) => buttonClassArray.includes(className));
expect(hasMatchingClassNames).toBe(true);
});
test("Should apply the fab variant class", () => {
render(<Button variant="fab">Test Button</Button>);
expect(screen.getByText("Test Button")).toHaveClass("hidden md:inline");
});
test("Should apply the secondary color class", () => {
render(<Button color="secondary">Test Button</Button>);
const buttonClass = screen.getByText("Test Button");
const buttonComponentClass = buttonClasses({ color: "secondary" });
const buttonClassArray = buttonClass.className.split(" ");
const hasMatchingClassNames = buttonComponentClass
.split(" ")
.some((className) => buttonClassArray.includes(className));
expect(hasMatchingClassNames).toBe(true);
});
test("Should apply the minimal color class", () => {
render(<Button color="minimal">Test Button</Button>);
const buttonClass = screen.getByText("Test Button");
const buttonComponentClass = buttonClasses({ color: "minimal" });
const buttonClassArray = buttonClass.className.split(" ");
const hasMatchingClassNames = buttonComponentClass
.split(" ")
.some((className) => buttonClassArray.includes(className));
expect(hasMatchingClassNames).toBe(true);
});
test("Should apply the sm size class", () => {
render(<Button size="sm">Test Button</Button>);
const buttonClass = screen.getByText("Test Button");
const buttonComponentClass = buttonClasses({ size: "sm" });
const buttonClassArray = buttonClass.className.split(" ");
const hasMatchingClassNames = buttonComponentClass
.split(" ")
.some((className) => buttonClassArray.includes(className));
expect(hasMatchingClassNames).toBe(true);
});
test("Should apply the base size class", () => {
render(<Button size="base">Test Button</Button>);
const buttonClass = screen.getByText("Test Button");
const buttonComponentClass = buttonClasses({ size: "base" });
const buttonClassArray = buttonClass.className.split(" ");
const hasMatchingClassNames = buttonComponentClass
.split(" ")
.some((className) => buttonClassArray.includes(className));
expect(hasMatchingClassNames).toBe(true);
});
test("Should apply the lg size class", () => {
render(<Button size="lg">Test Button</Button>);
const buttonClass = screen.getByText("Test Button");
const buttonComponentClass = buttonClasses({ size: "lg" });
const buttonClassArray = buttonClass.className.split(" ");
const hasMatchingClassNames = buttonComponentClass
.split(" ")
.some((className) => buttonClassArray.includes(className));
expect(hasMatchingClassNames).toBe(true);
});
test("Should apply the loading class", () => {
render(<Button loading>Test Button</Button>);
const buttonClass = screen.getByText("Test Button");
const buttonComponentClass = buttonClasses({ loading: true });
const buttonClassArray = buttonClass.className.split(" ");
const hasMatchingClassNames = buttonComponentClass
.split(" ")
.some((className) => buttonClassArray.includes(className));
expect(hasMatchingClassNames).toBe(true);
});
test("Should apply the disabled class when disabled prop is true", () => {
render(<Button disabled>Test Button</Button>);
const buttonClass = screen.getByText("Test Button").className;
const expectedClassName = "disabled:cursor-not-allowed";
expect(buttonClass.includes(expectedClassName)).toBe(true);
});
test("Should apply the custom class", () => {
const className = "custom-class";
render(<Button className={className}>Test Button</Button>);
expect(screen.getByText("Test Button")).toHaveClass(className);
});
test("Should render as a button by default", () => {
render(<Button>Test Button</Button>);
const button = screen.getByText("Test Button");
expect(button.tagName).toBe("BUTTON");
});
test("Should render StartIcon and Plus icon if is fab variant", async () => {
render(
<Button variant="fab" StartIcon="plus" data-testid="start-icon">
Test Button
</Button>
);
expect(await screen.findByTestId("start-icon")).toBeInTheDocument();
expect(await screen.findByTestId("plus")).toBeInTheDocument();
});
test("Should render just StartIcon if is not fab variant", async () => {
render(
<Button StartIcon="plus" data-testid="start-icon">
Test Button
</Button>
);
expect(await screen.findByTestId("start-icon")).toBeInTheDocument();
expect(screen.queryByTestId("plus")).not.toBeInTheDocument();
});
test("Should render EndIcon and Plus icon if is fab variant", async () => {
render(
<Button variant="fab" EndIcon="plus" data-testid="end-icon">
Test Button
</Button>
);
expect(await screen.findByTestId("end-icon")).toBeInTheDocument();
expect(await screen.findByTestId("plus")).toBeInTheDocument();
});
test("Should render just EndIcon if is not fab variant", async () => {
render(
<Button EndIcon="plus" data-testid="end-icon">
Test Button
</Button>
);
expect(await screen.findByTestId("end-icon")).toBeInTheDocument();
expect(screen.queryByTestId("plus")).not.toBeInTheDocument();
});
test("Should render Link if have href", () => {
render(<Button href="/test">Test Button</Button>);
const buttonElement = screen.getByText("Test Button");
expect(buttonElement).toHaveAttribute("href", "/test");
expect(buttonElement.closest("a")).toBeInTheDocument();
test("Should render Wrapper if don't have href", () => {
render(<Button>Test Button</Button>);
expect(screen.queryByTestId("link-component")).not.toBeInTheDocument();
expect(screen.getByText("Test Button")).toBeInTheDocument();
});
test("Should render Tooltip if exists", () => {
render(<Button tooltip="Hi, Im a tooltip">Test Button</Button>);
const tooltip = screen.getByTestId("tooltip");
expect(tooltip.getAttribute("data-state")).toEqual("closed");
expect(tooltip.getAttribute("data-state")).toEqual("instant-open");
expect(observeMock).toBeCalledWith(tooltip);
});
test("Should not render Tooltip if no exists", () => {
render(<Button>Test Button</Button>);
expect(screen.queryByTestId("tooltip")).not.toBeInTheDocument();
expect(screen.getByText("Test Button")).toBeInTheDocument();
});
test("Should render as a button with a custom type", () => {
render(<Button type="submit">Test Button</Button>);
const button = screen.getByText("Test Button");
expect(button.tagName).toBe("BUTTON");
expect(button).toHaveAttribute("type", "submit");
});
test("Should render as an anchor when href prop is provided", () => {
render(<Button href="/path">Test Button</Button>);
const button = screen.getByText("Test Button");
expect(button.tagName).toBe("A");
expect(button).toHaveAttribute("href", "/path");
});
test("Should call onClick callback when clicked", () => {
const handleClick = vi.fn();
render(<Button onClick={handleClick}>Test Button</Button>);
const button = screen.getByText("Test Button");
fireEvent.click(button);
expect(handleClick).toHaveBeenCalledTimes(1);
});
test("Should render default button correctly", () => {
render(<Button loading={false}>Default Button</Button>);
const buttonClass = screen.getByText("Default Button").className;
const buttonComponentClass = buttonClasses({ variant: "button", color: "primary", size: "base" });
const buttonClassArray = buttonClass.split(" ");
const hasMatchingClassNames = buttonComponentClass
.split(" ")
.every((className) => buttonClassArray.includes(className));
expect(hasMatchingClassNames).toBe(true);
expect(screen.getByText("Default Button")).toHaveAttribute("type", "button");
});
test("Should pass the shallow prop to Link component when href prop is passed", () => {
const href = "https://example.com";
render(<Button href={href} shallow />);
const linkComponent = screen.getByTestId("link-component");
expect(linkComponent).toHaveAttribute("shallow", "true");
});
});
});

View File

@@ -0,0 +1,3 @@
export { Button } from "./Button";
export type { ButtonBaseProps, ButtonProps } from "./Button";
export { default as LinkIconButton } from "./LinkIconButton";

View File

@@ -0,0 +1,30 @@
import React from "react";
import classNames from "@calcom/lib/classNames";
type Props = { children: React.ReactNode; combined?: boolean; containerProps?: JSX.IntrinsicElements["div"] };
/**
* Breakdown of Tailwind Magic below
* [&_button]:border-l-0 [&_a]:border-l-0 -> Selects all buttons/a tags and applies a border left of 0
* [&>*:first-child]:rounded-l-md [&>*:first-child]:border-l -> Selects the first child of the content
* ounds the left side
* [&>*:last-child]:rounded-r-md -> Selects the last child of the content and rounds the right side
* We dont need to add border to the right as we never remove it
*/
export function ButtonGroup({ children, combined = false, containerProps }: Props) {
return (
<div
{...containerProps}
className={classNames(
"flex",
!combined
? "space-x-2 rtl:space-x-reverse"
: "ltr:[&>*:first-child]:ml-0 ltr:[&>*:first-child]:rounded-l-md ltr:[&>*:first-child]:border-l rtl:[&>*:first-child]:rounded-r-md rtl:[&>*:first-child]:border-r ltr:[&>*:last-child]:rounded-r-md rtl:[&>*:last-child]:rounded-l-md [&>a]:-ml-[1px] hover:[&>a]:z-[1] [&>button]:-ml-[1px] hover:[&>button]:z-[1] [&_a]:rounded-none [&_button]:rounded-none",
containerProps?.className
)}>
{children}
</div>
);
}

View File

@@ -0,0 +1,124 @@
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import {
Examples,
Example,
Note,
Title,
CustomArgsTable,
VariantsTable,
VariantRow,
} from "@calcom/storybook/components";
import { Icon } from "../..";
import { Button } from "../button/Button";
import { ButtonGroup } from "./ButtonGroup";
<Meta title="UI/Button Group" component={ButtonGroup} />
<Title title="Button Group" suffix="Brief" subtitle="Version 2.0 — Last Update: 22 Aug 2022" />
## Definition
Button group enables multiple buttons to be combined into a single unit. It offers users access to frequently performed, related actions.
## Structure
<CustomArgsTable of={ButtonGroup} />
<Examples
title="Breadcrumb style"
footNote={
<ul>
<li>
Seperated: In general, seperated button group style can be included in container such as card, modal,
and page.
</li>
<li>Combined: Combined button group can be used standalone e.g. mini toggles for calendars</li>
</ul>
}>
<Example title="Default">
<ButtonGroup>
<Button StartIcon="trash" variant="icon" color="secondary" />
<Button StartIcon="navigation" variant="icon" color="secondary" />
<Button StartIcon="clipboard" variant="icon" color="secondary" />
</ButtonGroup>
</Example>
<Example title="Combined">
<ButtonGroup combined>
<Button StartIcon="trash" variant="icon" color="secondary" />
<Button StartIcon="navigation" variant="icon" color="secondary" />
<Button StartIcon="clipboard" variant="icon" color="secondary" />
</ButtonGroup>
</Example>
</Examples>
<Canvas>
<Story name="All Variants">
<VariantsTable titles={["Default", "Secondary", "Minimal"]} columnMinWidth={150}>
<VariantRow variant="Default">
<ButtonGroup>
<Button StartIcon="trash" variant="icon" />
<Button StartIcon="navigation" variant="icon" />
<Button StartIcon="clipboard" variant="icon" />
</ButtonGroup>
<ButtonGroup>
<Button StartIcon="trash" variant="icon" color="secondary" />
<Button StartIcon="navigation" variant="icon" color="secondary" />
<Button StartIcon="clipboard" variant="icon" color="secondary" />
</ButtonGroup>
<ButtonGroup>
<Button StartIcon="trash" variant="icon" color="minimal" />
<Button StartIcon="navigation" variant="icon" color="minimal" />
<Button StartIcon="clipboard" variant="icon" color="minimal" />
</ButtonGroup>
</VariantRow>
<VariantRow variant="Combined">
<ButtonGroup combined>
<Button StartIcon="trash" variant="icon" />
<Button StartIcon="navigation" variant="icon" />
<Button StartIcon="clipboard" variant="icon" />
</ButtonGroup>
<ButtonGroup combined>
<Button StartIcon="trash" variant="icon" color="secondary" />
<Button StartIcon="navigation" variant="icon" color="secondary" />
<Button StartIcon="clipboard" variant="icon" color="secondary" />
</ButtonGroup>
</VariantRow>
</VariantsTable>
</Story>
</Canvas>
<Canvas>
<Story
name="ButtonGroup Playground"
args={{
color: "secondary",
combined: false,
}}
argTypes={{
color: {
control: {
type: "select",
options: ["primary", "secondary", "minimal"],
},
},
combined: {
control: {
type: "boolean",
},
},
}}>
{({ color, combined }) => (
<VariantsTable titles={[`${color}`]} columnMinWidth={150}>
<VariantRow variant="">
<ButtonGroup combined={combined}>
<Button StartIcon="trash" variant="icon" color={color} />
<Button StartIcon="navigation" variant="icon" color={color} />
<Button StartIcon="clipboard" variant="icon" color={color} />
</ButtonGroup>
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>

View File

@@ -0,0 +1 @@
export { ButtonGroup } from "./ButtonGroup";

View File

@@ -0,0 +1,246 @@
// @TODO: turn this into a more generic component that has the same Props API as MUI https://mui.com/material-ui/react-card/
import type { VariantProps } from "class-variance-authority";
import { cva } from "class-variance-authority";
import Link from "next/link";
import type { ReactNode } from "react";
import React from "react";
import classNames from "@calcom/lib/classNames";
import { Button } from "../button";
const cvaCardTypeByVariant = cva("", {
// Variants won't have any style by default. Style will only be applied if the variants are combined.
// So, style is defined in compoundVariants.
variants: {
variant: {
basic: "",
ProfileCard: "",
SidebarCard: "",
},
structure: {
image: "",
card: "",
title: "",
description: "",
},
},
compoundVariants: [
// Style for Basic Variants types
{
variant: "basic",
structure: "image",
className: "w-10 h-auto",
},
{
variant: "basic",
structure: "card",
className: "p-5",
},
{
variant: "basic",
structure: "title",
className: "text-base mt-4",
},
{
variant: "basic",
structure: "description",
className: "text-sm leading-[18px] text-subtle font-normal",
},
// Style for ProfileCard Variant Types
{
variant: "ProfileCard",
structure: "image",
className: "w-9 h-auto rounded-full mb-4s",
},
{
variant: "ProfileCard",
structure: "card",
className: "w-80 p-4 hover:bg-subtle",
},
{
variant: "ProfileCard",
structure: "title",
className: "text-base",
},
{
variant: "ProfileCard",
structure: "description",
className: "text-sm leading-[18px] text-subtle font-normal",
},
// Style for SidebarCard Variant Types
{
variant: "SidebarCard",
structure: "image",
className: "w-9 h-auto rounded-full mb-4s",
},
{
variant: "SidebarCard",
structure: "card",
className: "w-full p-3 border border-subtle",
},
{
variant: "SidebarCard",
structure: "title",
className: "text-sm font-cal",
},
{
variant: "SidebarCard",
structure: "description",
className: "text-xs text-default line-clamp-2",
},
],
});
type CVACardType = Required<Pick<VariantProps<typeof cvaCardTypeByVariant>, "variant">>;
export interface BaseCardProps extends CVACardType {
image?: string;
icon?: ReactNode;
imageProps?: JSX.IntrinsicElements["img"];
title: string;
description: ReactNode;
containerProps?: JSX.IntrinsicElements["div"];
actionButton?: {
href?: string;
child: ReactNode;
onClick?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
"data-testId"?: string;
};
learnMore?: {
href: string;
text: string;
};
mediaLink?: string;
thumbnailUrl?: string;
structure?: string;
}
export function Card({
image,
title,
icon,
description,
variant,
actionButton,
containerProps,
imageProps,
mediaLink,
thumbnailUrl,
learnMore,
}: BaseCardProps) {
const LinkComponent = learnMore && learnMore.href.startsWith("https") ? "a" : Link;
return (
<div
className={classNames(
containerProps?.className,
cvaCardTypeByVariant({ variant, structure: "card" }),
"bg-default border-subtle text-default flex flex-col justify-between rounded-md border"
)}
data-testid="card-container"
{...containerProps}>
<div>
{icon && icon}
{image && (
<img
src={image}
// Stops eslint complaining - not smart enough to realise it comes from ...imageProps
alt={imageProps?.alt}
className={classNames(
imageProps?.className,
cvaCardTypeByVariant({ variant, structure: "image" })
)}
{...imageProps}
/>
)}
<h5
title={title}
className={classNames(
cvaCardTypeByVariant({ variant, structure: "title" }),
"text-emphasis line-clamp-1 font-bold leading-5"
)}>
{title}
</h5>
{description && (
<p
title={description.toString()}
className={classNames(cvaCardTypeByVariant({ variant, structure: "description" }), "pt-1")}>
{description}
</p>
)}
</div>
{variant === "SidebarCard" && (
<a
onClick={actionButton?.onClick}
target="_blank"
rel="noreferrer"
href={mediaLink}
data-testId={actionButton?.["data-testId"]}
className="group relative my-3 flex aspect-video items-center overflow-hidden rounded">
<div className="absolute inset-0 bg-black bg-opacity-50 transition group-hover:bg-opacity-40" />
<svg
className="text-inverted absolute left-1/2 top-1/2 h-8 w-8 -translate-x-1/2 -translate-y-1/2 transform rounded-full shadow-lg hover:-mt-px"
viewBox="0 0 32 32"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<path
d="M16 32C24.8366 32 32 24.8366 32 16C32 7.16344 24.8366 0 16 0C7.16344 0 0 7.16344 0 16C0 24.8366 7.16344 32 16 32Z"
fill="white"
/>
<path
d="M12.1667 8.5L23.8334 16L12.1667 23.5V8.5Z"
fill="#111827"
stroke="#111827"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<img alt="play feature video" src={thumbnailUrl} />
</a>
)}
{/* TODO: this should be CardActions https://mui.com/material-ui/api/card-actions/ */}
{variant === "basic" && actionButton && (
<div>
<Button
color="secondary"
href={actionButton?.href}
className="mt-10"
EndIcon="arrow-right"
data-testId={actionButton["data-testId"]}>
{actionButton?.child}
</Button>
</div>
)}
{variant === "SidebarCard" && (
<div className="mt-2 flex items-center justify-between">
{learnMore && (
<LinkComponent
href={learnMore.href}
onClick={actionButton?.onClick}
target="_blank"
rel="noreferrer"
className="text-default text-xs font-medium">
{learnMore.text}
</LinkComponent>
)}
{actionButton?.child && (
<button
className="text-default hover:text-emphasis p-0 text-xs font-normal"
color="minimal"
data-testId={actionButton?.["data-testId"]}
onClick={actionButton?.onClick}>
{actionButton?.child}
</button>
)}
</div>
)}
</div>
);
}
export default Card;

View File

@@ -0,0 +1,79 @@
import Link from "next/link";
import { classNames } from "@calcom/lib";
import type { BadgeProps } from "../..";
import { Badge, Icon } from "../..";
import { Divider } from "../divider";
type Action = { check: () => boolean; fn: () => void };
export default function FormCard({
children,
label,
deleteField,
moveUp,
moveDown,
className,
badge,
...restProps
}: {
children: React.ReactNode;
label?: React.ReactNode;
deleteField?: Action | null;
moveUp?: Action | null;
moveDown?: Action | null;
className?: string;
badge?: { text: string; href?: string; variant: BadgeProps["variant"] } | null;
} & JSX.IntrinsicElements["div"]) {
className = classNames(
className,
"flex items-center group relative w-full rounded-md p-4 border border-subtle"
);
return (
<div className={className} {...restProps}>
<div>
{moveUp?.check() ? (
<button
type="button"
className="bg-default text-muted hover:text-emphasis invisible absolute left-0 -ml-[13px] -mt-10 flex h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all hover:border-transparent hover:shadow group-hover:visible group-hover:scale-100 "
onClick={() => moveUp?.fn()}>
<Icon name="arrow-up" />
</button>
) : null}
{moveDown?.check() ? (
<button
type="button"
className="bg-default text-muted hover:text-emphasis invisible absolute left-0 -ml-[13px] -mt-2 flex h-6 w-6 scale-0 items-center justify-center rounded-md border p-1 transition-all hover:border-transparent hover:shadow group-hover:visible group-hover:scale-100"
onClick={() => moveDown?.fn()}>
<Icon name="arrow-down" />
</button>
) : null}
</div>
<div className="w-full">
<div className="flex items-center justify-between">
<div>
<span className="text-emphasis text-sm font-semibold">{label}</span>
{badge && (
<Badge className="ml-2" variant={badge.variant}>
{badge.href ? <Link href={badge.href}>{badge.text}</Link> : badge.text}
</Badge>
)}
</div>
{deleteField?.check() ? (
<button
type="button"
onClick={() => {
deleteField?.fn();
}}
color="secondary">
<Icon name="trash-2" className="text-default h-4 w-4" />
</button>
) : null}
</div>
<Divider className="mb-6 mt-3" />
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,9 @@
const StepCard: React.FC<{ children: React.ReactNode }> = (props) => {
return (
<div className="sm:border-subtle bg-default mt-10 border p-4 dark:bg-black sm:rounded-md sm:p-8">
{props.children}
</div>
);
};
export { StepCard };

View File

@@ -0,0 +1,69 @@
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import {
Examples,
Example,
Note,
Title,
CustomArgsTable,
VariantsTable,
VariantRow,
} from "@calcom/storybook/components";
import Card from "./Card";
<Meta title="UI/Card" component={Card} />
<Title title="Card" suffix="Brief" subtitle="Version 2.0 — Last Update: 06 Jan 2023" />
## Definition
All Cards used in Cal.com
<CustomArgsTable of={Card} />
export const tip = {
id: 1,
thumbnailUrl: "https://img.youtube.com/vi/60HJt8DOVNo/0.jpg",
mediaLink: "https://go.cal.com/dynamic-video",
title: "Dynamic booking links",
description: "Booking link that allows people to quickly schedule meetings.",
href: "https://cal.com/blog/cal-v-1-9",
};
<Canvas>
<Story
name="Card"
args={{
thumbnailUrl: tip.thumbnailUrl,
mediaLink: tip.mediaLink,
title: tip.title,
description: tip.description,
learnMoreHref: tip.href,
learnMoreText: "learn more",
}}
argTypes={{
thumbnailUrl: { control: { type: "text" } },
mediaLink: { control: { type: "text" } },
title: { control: { type: "text" } },
description: { control: { type: "text" } },
learnMoreHref: { control: { type: "text" } },
learnMoreText: { control: { type: "text" } },
}}>
{({ thumbnailUrl, mediaLink, title, description, learnMoreText }) => (
<VariantsTable titles={[""]} columnMinWidth={150}>
<VariantRow variant="Sidebar Card">
<Card
variant="SidebarCard"
thumbnailUrl={thumbnailUrl}
mediaLink={mediaLink}
title={title}
description={description}
learnMore={{ href: tip.href, text: learnMoreText }}
actionButton={{ onClick: () => console.log("Clicked") }}
/>
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>

View File

@@ -0,0 +1,99 @@
/* eslint-disable playwright/missing-playwright-await */
import { render, screen, fireEvent } from "@testing-library/react";
import { Card } from "./Card";
const title = "Card Title";
const description = "Card Description";
describe("Tests for Card component", () => {
test("Should render the card with basic variant and image structure", () => {
const variant = "basic";
render(<Card title={title} description={description} variant={variant} structure="image" />);
const cardTitle = screen.getByText(title);
const cardDescription = screen.getByText(description);
expect(cardTitle).toBeInTheDocument();
expect(cardDescription).toBeInTheDocument();
});
test("Should render the card with ProfileCard variant and card structure", () => {
const variant = "ProfileCard";
const structure = "card";
render(<Card title={title} description={description} variant={variant} structure={structure} />);
const cardTitle = screen.getByText(title);
const cardDescription = screen.getByText(description);
expect(cardTitle).toBeInTheDocument();
expect(cardDescription).toBeInTheDocument();
});
test("Should render the card with SidebarCard variant and title structure", () => {
const variant = "SidebarCard";
const structure = "title";
render(<Card title={title} description={description} variant={variant} structure={structure} />);
const cardTitle = screen.getByText(title);
const cardDescription = screen.getByText(description);
expect(cardTitle).toBeInTheDocument();
expect(cardDescription).toBeInTheDocument();
});
test("Should render button click", () => {
render(
<Card title={title} description={description} variant="basic" actionButton={{ child: "Button" }} />
);
const buttonElement = screen.getByRole("button", { name: "Button" });
expect(buttonElement).toBeInTheDocument();
});
test("Should handle link click", () => {
render(
<Card
title={title}
description={description}
variant="basic"
learnMore={{
href: "http://localhost:3000/",
text: "Learn More",
}}
actionButton={{ child: "Button" }}
/>
);
const linkElement = screen.getByRole("button", { name: "Button" });
fireEvent.click(linkElement);
expect(window.location.href).toBe("http://localhost:3000/");
});
test("Should render card with SidebarCard variant and learn more link", () => {
render(
<Card
title={title}
description={description}
variant="SidebarCard"
learnMore={{ href: "http://example.com", text: "Learn More" }}
/>
);
const cardContainer = screen.getByTestId("card-container");
const titleElement = screen.getByText(title);
const descriptionElement = screen.getByText(description);
const learnMoreLink = screen.getByRole("link", { name: "Learn More" });
expect(cardContainer).toBeInTheDocument();
expect(titleElement).toBeInTheDocument();
expect(descriptionElement).toBeInTheDocument();
expect(learnMoreLink).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,4 @@
export { default as Card } from "./Card";
export type { BaseCardProps } from "./Card";
export { StepCard } from "./StepCard";
export { default as FormCard } from "./FormCard";

View File

@@ -0,0 +1,136 @@
import type { DialogProps } from "@radix-ui/react-dialog";
import { Command as CommandPrimitive } from "cmdk";
import * as React from "react";
import { classNames } from "@calcom/lib";
import { Dialog, DialogContent } from "../dialog";
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={classNames(
"bg-popover text-default flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
));
Command.displayName = CommandPrimitive.displayName;
type CommandDialogProps = DialogProps;
const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:text-muted hover:bg-subtle [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
);
};
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3 py-2" cmdk-input-wrapper="">
<CommandPrimitive.Input
ref={ref}
className={classNames(
"placeholder:text-muted hover:border-emphasis dark:focus:border-emphasis border-default bg-default placeholder:text-muted text-emphasis disabled:hover:border-default disabled:bg-subtle focus:ring-brand-default focus:border-subtle block flex h-[28px] w-full rounded-md rounded-md border bg-transparent px-3 py-1.5 text-sm text-sm leading-4 outline-none focus:outline-none focus:ring-2 disabled:cursor-not-allowed disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
));
CommandInput.displayName = CommandPrimitive.Input.displayName;
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={classNames("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
{...props}
/>
));
CommandList.displayName = CommandPrimitive.List.displayName;
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
>((props, ref) => <CommandPrimitive.Empty ref={ref} className="py-6 text-center text-sm" {...props} />);
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={classNames(
"text-default [&_[cmdk-group-heading]]:text-muted overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
));
CommandGroup.displayName = CommandPrimitive.Group.displayName;
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={classNames("bg-subtle -mx-1 mb-2 h-px", className)}
{...props}
/>
));
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={classNames(
"aria-selected:bg-muted aria-selected:text-emphasis relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
/>
));
CommandItem.displayName = CommandPrimitive.Item.displayName;
const CommandShortcut = ({ className, ...props }: React.HTMLAttributes<HTMLSpanElement>) => {
return <span className={classNames("text-muted ml-auto text-xs tracking-widest", className)} {...props} />;
};
CommandShortcut.displayName = "CommandShortcut";
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
};

View File

@@ -0,0 +1,135 @@
import { usePathname, useRouter } from "next/navigation";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { ButtonColor } from "@calcom/ui";
import {
Avatar,
Button,
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger,
} from "@calcom/ui";
export interface Option {
platform?: boolean;
teamId: number | null | undefined; // if undefined, then it's a profile
label: string | null;
image: string | null;
slug: string | null;
}
export type CreateBtnProps = {
options: Option[];
createDialog?: () => JSX.Element;
createFunction?: (teamId?: number, platform?: boolean) => void;
subtitle?: string;
buttonText?: string;
isPending?: boolean;
disableMobileButton?: boolean;
"data-testid"?: string;
color?: ButtonColor;
};
/**
* @deprecated use CreateButtonWithTeamsList instead
*/
export function CreateButton(props: CreateBtnProps) {
const { t } = useLocale();
const router = useRouter();
const searchParams = useCompatSearchParams();
const pathname = usePathname();
const {
createDialog,
options,
isPending,
createFunction,
buttonText,
disableMobileButton,
subtitle,
...restProps
} = props;
const CreateDialog = createDialog ? createDialog() : null;
const hasTeams = !!options.find((option) => option.teamId);
const platform = !!options.find((option) => option.platform);
// inject selection data into url for correct router history
const openModal = (option: Option) => {
const _searchParams = new URLSearchParams(searchParams ?? undefined);
function setParamsIfDefined(key: string, value: string | number | boolean | null | undefined) {
if (value !== undefined && value !== null) _searchParams.set(key, value.toString());
}
setParamsIfDefined("dialog", "new");
setParamsIfDefined("eventPage", option.slug);
setParamsIfDefined("teamId", option.teamId);
if (!option.teamId) {
_searchParams.delete("teamId");
}
router.push(`${pathname}?${_searchParams.toString()}`);
};
return (
<>
{!hasTeams && !platform ? (
<Button
onClick={() =>
!!CreateDialog
? openModal(options[0])
: createFunction
? createFunction(options[0].teamId || undefined)
: null
}
data-testid="create-button"
StartIcon="plus"
loading={isPending}
variant={disableMobileButton ? "button" : "fab"}
{...restProps}>
{buttonText ? buttonText : t("new")}
</Button>
) : (
<Dropdown>
<DropdownMenuTrigger asChild>
<Button
variant={disableMobileButton ? "button" : "fab"}
StartIcon="plus"
data-testid="create-button-dropdown"
loading={isPending}
{...restProps}>
{buttonText ? buttonText : t("new")}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={14} align="end">
<DropdownMenuLabel>
<div className="w-48 text-left text-xs">{subtitle}</div>
</DropdownMenuLabel>
{options.map((option, idx) => (
<DropdownMenuItem key={option.label}>
<DropdownItem
type="button"
data-testid={`option${option.teamId ? "-team" : ""}-${idx}`}
CustomStartIcon={<Avatar alt={option.label || ""} imageSrc={option.image} size="sm" />}
onClick={() =>
!!CreateDialog
? openModal(option)
: createFunction
? createFunction(option.teamId || undefined, option.platform)
: null
}>
{" "}
{/*improve this code */}
<span>{option.label}</span>
</DropdownItem>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</Dropdown>
)}
{searchParams?.get("dialog") === "new" && CreateDialog}
</>
);
}

View File

@@ -0,0 +1,43 @@
import { trpc } from "@calcom/trpc/react";
import type { CreateBtnProps, Option } from "./CreateButton";
import { CreateButton } from "./CreateButton";
export function CreateButtonWithTeamsList(
props: Omit<CreateBtnProps, "options"> & {
onlyShowWithTeams?: boolean;
onlyShowWithNoTeams?: boolean;
isAdmin?: boolean;
includeOrg?: boolean;
}
) {
const query = trpc.viewer.teamsAndUserProfilesQuery.useQuery({ includeOrg: props.includeOrg });
if (!query.data) return null;
const teamsAndUserProfiles: Option[] = query.data
.filter((profile) => !profile.readOnly)
.map((profile) => {
return {
teamId: profile.teamId,
label: profile.name || profile.slug,
image: profile.image,
slug: profile.slug,
};
});
if (props.isAdmin) {
teamsAndUserProfiles.push({
platform: true,
label: "Platform",
image: null,
slug: null,
teamId: null,
});
}
if (props.onlyShowWithTeams && teamsAndUserProfiles.length < 2) return null;
if (props.onlyShowWithNoTeams && teamsAndUserProfiles.length > 1) return null;
return <CreateButton {...props} options={teamsAndUserProfiles} />;
}

View File

@@ -0,0 +1,122 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { render, screen, fireEvent } from "@testing-library/react";
import { vi } from "vitest";
import type { CreateBtnProps } from "./CreateButton";
import { CreateButtonWithTeamsList } from "./CreateButtonWithTeamsList";
const runtimeMock = async (data: Array<any>) => {
const updatedTrpc = {
viewer: {
teamsAndUserProfilesQuery: {
useQuery() {
return {
data: data,
};
},
},
},
};
const mockedLib = (await import("@calcom/trpc/react")) as any;
mockedLib.trpc = updatedTrpc;
};
const renderCreateButton = (
props: Omit<CreateBtnProps, "options"> & { onlyShowWithTeams?: boolean; onlyShowWithNoTeams?: boolean }
) => {
return render(<CreateButtonWithTeamsList {...props} />);
};
describe("Create Button Tests", () => {
describe("Create Button Tests With Valid Team", () => {
beforeAll(async () => {
await runtimeMock([
{
teamId: 1,
name: "test",
slug: "create-button-test",
image: "image",
},
]);
});
test("Should render the create-button-dropdown button", () => {
const createFunction = vi.fn();
renderCreateButton({ createFunction });
const createButton = screen.getByTestId("create-button-dropdown");
expect(createButton).toBeInTheDocument();
});
});
describe("Create Button Tests With One Null Team", () => {
beforeAll(async () => {
await runtimeMock([
{
teamId: null,
name: "test",
slug: "create-button-test",
image: "image",
readOnly: false,
},
]);
});
test("Should render only the create-button button", () => {
const createFunction = vi.fn();
renderCreateButton({ createFunction });
const createButton = screen.getByTestId("create-button");
expect(screen.queryByTestId("create-button-dropdown")).not.toBeInTheDocument();
expect(createButton).toBeInTheDocument();
fireEvent.click(createButton);
expect(createFunction).toBeCalled();
});
test("Should render nothing when teamsAndUserProfiles is less than 2 and onlyShowWithTeams is true", () => {
renderCreateButton({ onlyShowWithTeams: true });
expect(screen.queryByTestId("create-button")).not.toBeInTheDocument();
expect(screen.queryByTestId("create-button-dropdown")).not.toBeInTheDocument();
});
describe("Create Button Tests With Multiple Null Teams", () => {
beforeAll(async () => {
await runtimeMock([
{
teamId: null,
name: "test",
slug: "create-button-test",
image: "image",
readOnly: false,
},
{
teamId: null,
name: "test2",
slug: "create-button-test2",
image: "image2",
readOnly: false,
},
]);
});
test("Should render only the create-button button", () => {
const createFunction = vi.fn();
renderCreateButton({ createFunction });
const createButton = screen.getByTestId("create-button");
expect(screen.queryByTestId("create-button-dropdown")).not.toBeInTheDocument();
expect(createButton).toBeInTheDocument();
fireEvent.click(createButton);
expect(createFunction).toBeCalled();
});
test("Should render nothing when teamsAndUserProfiles is greater than 1 and onlyShowWithNoTeams is true", () => {
renderCreateButton({ onlyShowWithNoTeams: true });
expect(screen.queryByTestId("create-button")).not.toBeInTheDocument();
expect(screen.queryByTestId("create-button-dropdown")).not.toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,2 @@
export { CreateButton } from "./CreateButton";
export { CreateButtonWithTeamsList } from "./CreateButtonWithTeamsList";

View File

@@ -0,0 +1,43 @@
import Link from "next/link";
import { useEffect, useState } from "react";
import { CALCOM_VERSION, COMPANY_NAME, IS_CALCOM, IS_SELF_HOSTED } from "@calcom/lib/constants";
// eslint-disable-next-line turbo/no-undeclared-env-vars
const vercelCommitHash = process.env.NEXT_PUBLIC_VERCEL_GIT_COMMIT_SHA;
const commitHash = vercelCommitHash ? `-${vercelCommitHash.slice(0, 7)}` : "";
const CalComVersion = `v.${CALCOM_VERSION}-${!IS_SELF_HOSTED ? "h" : "sh"}`;
export default function Credits() {
const [hasMounted, setHasMounted] = useState(false);
useEffect(() => {
setHasMounted(true);
}, []);
return (
<small className="text-default mx-3 mb-2 mt-1 hidden text-[0.5rem] opacity-50 lg:block">
&copy; {new Date().getFullYear()}{" "}
<Link href="https://bls.media" target="_blank" className="hover:underline">
{COMPANY_NAME}
</Link>{" "}
{hasMounted && (
<>
<Link href="https://bls.media/cal" target="_blank" className="hover:underline">
{CalComVersion}
</Link>
{vercelCommitHash && IS_CALCOM ? (
<Link
href={`https://github.com/calcom/cal.com/commit/${vercelCommitHash}`}
target="_blank"
className="hover:underline">
{commitHash}
</Link>
) : (
commitHash
)}
</>
)}
</small>
);
}

View File

@@ -0,0 +1,31 @@
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import {
Examples,
Example,
Note,
Title,
CustomArgsTable,
VariantsTable,
VariantRow,
} from "@calcom/storybook/components";
import Credits from "./Credits";
<Meta title="UI/Credits" component={Credits} />
<Title title="Credits" suffix="Brief" subtitle="Version 2.0 — Last Update: 05 jan 2023" />
## Definition
Shows the current copy right as well as app version name.
<Canvas>
<Story name="Credits">
<VariantsTable titles={[]} columnMinWidth={150}>
<VariantRow variant="Default">
<Credits />
</VariantRow>
</VariantsTable>
</Story>
</Canvas>

View File

@@ -0,0 +1,35 @@
/* eslint-disable playwright/missing-playwright-await */
import { render, screen } from "@testing-library/react";
import { vi } from "vitest";
import Credits from "./Credits";
vi.mock("@calcom/lib/constants", async () => {
const actual = (await vi.importActual("@calcom/lib/constants")) as typeof import("@calcom/lib/constants");
return {
...actual,
CALCOM_VERSION: "mockedVersion",
};
});
describe("Tests for Credits component", () => {
test("Should render credits section with links", () => {
render(<Credits />);
const creditsLinkElement = screen.getByRole("link", { name: /Cal\.com, Inc\./i });
expect(creditsLinkElement).toBeInTheDocument();
expect(creditsLinkElement).toHaveAttribute("href", "https://go.cal.com/credits");
const versionLinkElement = screen.getByRole("link", { name: /mockedVersion/i });
expect(versionLinkElement).toBeInTheDocument();
expect(versionLinkElement).toHaveAttribute("href", "https://go.cal.com/releases");
});
test("Should render credits section with correct text", () => {
render(<Credits />);
const currentYear = new Date().getFullYear();
const copyrightElement = screen.getByText(`© ${currentYear}`);
expect(copyrightElement).toHaveTextContent(`${currentYear}`);
});
});

View File

@@ -0,0 +1 @@
export { default as Credits } from "./Credits";

View File

@@ -0,0 +1,116 @@
import type { Column } from "@tanstack/react-table";
import type { LucideIcon } from "lucide-react";
import { classNames } from "@calcom/lib";
import { Icon } from "../..";
import { Badge } from "../badge";
import { Button } from "../button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "../command";
import { Popover, PopoverContent, PopoverTrigger } from "../popover";
interface DataTableFilter<TData, TValue> {
column?: Column<TData, TValue>;
title?: string;
options: {
label: string;
value: string;
icon?: LucideIcon;
}[];
}
export function DataTableFilter<TData, TValue>({ column, title, options }: DataTableFilter<TData, TValue>) {
const facets = column?.getFacetedUniqueValues();
const selectedValues = new Set(column?.getFilterValue() as string[]);
return (
<Popover>
<PopoverTrigger asChild>
<Button color="secondary" size="sm" className="border-subtle h-8 rounded-md">
<Icon name="filter" className="mr-2 h-4 w-4" />
{title}
{selectedValues?.size > 0 && (
<>
<div className="ml-2 hidden space-x-1 md:flex">
{selectedValues.size > 2 ? (
<Badge color="gray" className="rounded-sm px-1 font-normal">
{selectedValues.size}
</Badge>
) : (
options
.filter((option) => selectedValues.has(option.value))
.map((option) => (
<Badge color="gray" key={option.value} className="rounded-sm px-1 font-normal">
{option.label}
</Badge>
))
)}
</div>
</>
)}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[200px] p-0" align="start">
<Command>
<CommandInput placeholder={title} />
<CommandList>
<CommandEmpty>No results found.</CommandEmpty>
<CommandGroup>
{options.map((option) => {
// TODO: It would be nice to pull these from data instead of options
const isSelected = selectedValues.has(option.value);
return (
<CommandItem
key={option.value}
onSelect={() => {
if (isSelected) {
selectedValues.delete(option.value);
} else {
selectedValues.add(option.value);
}
const filterValues = Array.from(selectedValues);
column?.setFilterValue(filterValues.length ? filterValues : undefined);
}}>
<div
className={classNames(
"border-subtle mr-2 flex h-4 w-4 items-center justify-center rounded-sm border",
isSelected ? "text-emphasis" : "opacity-50 [&_svg]:invisible"
)}>
<Icon name="check" className={classNames("h-4 w-4")} />
</div>
{option.icon && <option.icon className="text-muted mr-2 h-4 w-4" />}
<span>{option.label}</span>
{facets?.get(option.value) && (
<span className="ml-auto flex h-4 w-4 items-center justify-center font-mono text-xs">
{facets.get(option.value)}
</span>
)}
</CommandItem>
);
})}
</CommandGroup>
{selectedValues.size > 0 && (
<>
<CommandSeparator />
<CommandGroup>
<CommandItem
onSelect={() => column?.setFilterValue(undefined)}
className="justify-center text-center">
Clear filters
</CommandItem>
</CommandGroup>
</>
)}
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}

View File

@@ -0,0 +1,59 @@
import type { Table } from "@tanstack/react-table";
import { Button } from "../button";
interface DataTablePaginationProps<TData> {
table: Table<TData>;
}
export function DataTablePagination<TData>({ table }: DataTablePaginationProps<TData>) {
return (
<div className="flex items-center justify-between px-2">
<div className="text-muted-foreground flex-1 text-sm">
{table.getFilteredSelectedRowModel().rows.length} of {table.getFilteredRowModel().rows.length} row(s)
selected.
</div>
<div className="flex items-center space-x-6 lg:space-x-8">
<div className="flex w-[100px] items-center justify-center text-sm font-medium">
Page {table.getState().pagination.pageIndex} of {table.getPageCount() - 1}
</div>
<div className="flex items-center space-x-2">
<Button
color="secondary"
variant="icon"
StartIcon="chevrons-left"
className="hidden h-8 w-8 p-0 lg:flex"
onClick={() => table.setPageIndex(0)}>
<span className="sr-only">Go to first page</span>
</Button>
<Button
color="secondary"
variant="icon"
className="h-8 w-8 p-0"
onClick={() => table.previousPage()}
disabled={!table.getCanPreviousPage()}
StartIcon="chevron-left">
<span className="sr-only">Go to previous page</span>
</Button>
<Button
color="secondary"
variant="icon"
StartIcon="chevron-right"
className="h-8 w-8 p-0"
disabled={!table.getCanNextPage()}
onClick={() => table.nextPage()}>
<span className="sr-only">Go to next page</span>
</Button>
<Button
color="secondary"
variant="icon"
className="hidden h-8 w-8 p-0 lg:flex"
StartIcon="chevrons-right"
onClick={() => table.setPageIndex(table.getPageCount())}>
<span className="sr-only">Go to last page</span>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,70 @@
import type { Table } from "@tanstack/react-table";
import type { Table as TableType } from "@tanstack/table-core/build/lib/types";
import { AnimatePresence } from "framer-motion";
import { Fragment } from "react";
import type { IconName } from "../..";
import { Button } from "../button";
export type ActionItem<TData> =
| {
type: "action";
label: string;
onClick: () => void;
icon?: IconName;
needsXSelected?: number;
}
| {
type: "render";
render: (table: Table<TData>) => React.ReactNode;
needsXSelected?: number;
};
interface DataTableSelectionBarProps<TData> {
table: Table<TData>;
actions?: ActionItem<TData>[];
renderAboveSelection?: (table: TableType<TData>) => React.ReactNode;
}
export function DataTableSelectionBar<TData>({
table,
actions,
renderAboveSelection,
}: DataTableSelectionBarProps<TData>) {
const numberOfSelectedRows = table.getSelectedRowModel().rows.length;
const isVisible = numberOfSelectedRows > 0;
// Hacky left % to center
const actionsVisible = actions?.filter((a) => {
if (!a.needsXSelected) return true;
return a.needsXSelected <= numberOfSelectedRows;
});
return (
<AnimatePresence>
{isVisible ? (
<div className="fade-in fixed bottom-6 left-1/2 hidden -translate-x-1/2 gap-1 md:flex md:flex-col">
{renderAboveSelection && renderAboveSelection(table)}
<div className="bg-brand-default text-brand hidden items-center justify-between rounded-lg p-2 md:flex">
<p className="text-brand-subtle w-full px-2 text-center leading-none">
{numberOfSelectedRows} selected
</p>
{actionsVisible?.map((action, index) => {
return (
<Fragment key={index}>
{action.type === "action" ? (
<Button aria-label={action.label} onClick={action.onClick} StartIcon={action.icon}>
{action.label}
</Button>
) : action.type === "render" ? (
action.render(table)
) : null}
</Fragment>
);
})}
</div>
</div>
) : null}
</AnimatePresence>
);
}

View File

@@ -0,0 +1,99 @@
"use client";
import type { Table } from "@tanstack/react-table";
import type { LucideIcon } from "lucide-react";
import { useEffect, useState } from "react";
import { useDebounce } from "@calcom/lib/hooks/useDebounce";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "../button";
import { Input } from "../form";
import { DataTableFilter } from "./DataTableFilter";
export type FilterableItems = {
title: string;
tableAccessor: string;
options: {
label: string;
value: string;
icon?: LucideIcon;
}[];
}[];
interface DataTableToolbarProps<TData> {
table: Table<TData>;
filterableItems?: FilterableItems;
searchKey?: string;
tableCTA?: React.ReactNode;
onSearch?: (value: string) => void;
}
export function DataTableToolbar<TData>({
table,
filterableItems,
tableCTA,
searchKey,
onSearch,
}: DataTableToolbarProps<TData>) {
// TODO: Is there a better way to check if the table is filtered?
// If you select ALL filters for a column, the table is not filtered and we dont get a reset button
const isFiltered = table.getState().columnFilters.length > 0;
const [searchTerm, setSearchTerm] = useState("");
const debouncedSearchTerm = useDebounce(searchTerm, 500);
useEffect(() => {
onSearch?.(debouncedSearchTerm);
}, [debouncedSearchTerm, onSearch]);
const { t } = useLocale();
return (
<div className="flex items-center justify-end py-4">
{searchKey && (
<Input
className="max-w-64 mb-0 mr-auto rounded-md"
placeholder="Search"
value={(table.getColumn(searchKey)?.getFilterValue() as string) ?? ""}
onChange={(event) => table.getColumn(searchKey)?.setFilterValue(event.target.value.trim())}
/>
)}
{onSearch && (
<Input
className="max-w-64 mb-0 mr-auto rounded-md"
placeholder="Search"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
}}
/>
)}
{isFiltered && (
<Button
color="minimal"
EndIcon="x"
onClick={() => table.resetColumnFilters()}
className="h-8 px-2 lg:px-3">
{t("clear")}
</Button>
)}
{filterableItems &&
filterableItems?.map((item) => {
const foundColumn = table.getColumn(item.tableAccessor);
if (foundColumn?.getCanFilter()) {
return (
<DataTableFilter
column={foundColumn}
title={item.title}
options={item.options}
key={item.title}
/>
);
}
})}
{tableCTA ? tableCTA : null}
</div>
);
}

View File

@@ -0,0 +1,78 @@
import type { ColumnDef } from "@tanstack/react-table";
import { Badge } from "../../badge";
import { Checkbox } from "../../form";
import type { FilterableItems } from "../DataTableToolbar";
import type { DataTableUserStorybook } from "./data";
export const columns: ColumnDef<DataTableUserStorybook>[] = [
{
id: "select",
header: ({ table }) => (
<Checkbox
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
className="translate-y-[2px]"
/>
),
},
{
accessorKey: "username",
header: "Username",
},
{
accessorKey: "email",
header: "Email",
},
{
accessorKey: "role",
header: "Role",
cell: ({ row, table }) => {
const user = row.original;
const BadgeColor = user.role === "admin" ? "blue" : "gray";
return (
<Badge
color={BadgeColor}
onClick={() => {
table.getColumn("role")?.setFilterValue(user.role);
}}>
{user.role}
</Badge>
);
},
filterFn: (rows, id, filterValue) => {
return filterValue.includes(rows.getValue(id));
},
},
];
export const filterableItems: FilterableItems = [
{
title: "Role",
tableAccessor: "role",
options: [
{
label: "Admin",
value: "admin",
},
{
label: "User",
value: "user",
},
{
label: "Owner",
value: "owner",
},
],
},
];

View File

@@ -0,0 +1,98 @@
export type DataTableUserStorybook = {
id: string;
username: string;
email: string;
role: "admin" | "user";
};
export const dataTableSelectionActions = [
{
label: "Add To Team",
onClick: () => {
console.log("Add To Team");
},
icon: "users",
},
{
label: "Delete",
onClick: () => {
console.log("Delete");
},
icon: "stop-circle",
},
];
export const dataTableDemousers: DataTableUserStorybook[] = [
{
id: "728ed52f",
email: "m@example.com",
username: "m",
role: "admin",
},
{
id: "489e1d42",
email: "example@gmail.com",
username: "e",
role: "user",
},
{
id: "7b8a6f1d-2d2d-4d29-9c1a-0a8b3f5f9d2f",
email: "Keshawn_Schroeder@hotmail.com",
username: "Ava_Waelchi",
role: "user",
},
{
id: "f4d9e2a3-7e3c-4d6e-8e4c-8d0d7d1c2c9b",
email: "Jovanny_Grant@hotmail.com",
username: "Kamren_Gerhold",
role: "admin",
},
{
id: "1b2a4b6e-5b2d-4c38-9c7e-9d5e8f9c0a6a",
email: "Emilie.McKenzie@yahoo.com",
username: "Lennie_Harber",
role: "user",
},
{
id: "d6f3e6e9-9c2a-4c8a-8f3c-0d63a0eaf5a5",
email: "Jolie_Beatty@hotmail.com",
username: "Lorenzo_Will",
role: "admin",
},
{
id: "7c1e5d1d-8b9c-4b1c-9d1b-7d9f8b5a7e3e",
email: "Giovanny_Cruickshank@hotmail.com",
username: "Monserrat_Lang",
role: "user",
},
{
id: "f7d8b7a2-0a5c-4f8d-9f4f-8d1a2c3e4b3e",
email: "Lela_Haag@hotmail.com",
username: "Eddie_Effertz",
role: "user",
},
{
id: "2f8b9c8d-1a5c-4e3d-9b7a-6c5d4e3f2b1a",
email: "Lura_Kohler@gmail.com",
username: "Alyce_Olson",
role: "user",
},
{
id: "d8c7b6a5-4e3d-2b1a-9c8d-1f2e3d4c5b6a",
email: "Maurice.Koch@hotmail.com",
username: "Jovanny_Kiehn",
role: "admin",
},
{
id: "3c2b1a5d-4e3d-8c7b-9a6f-0d1e2f3g4h5i",
email: "Brenda_Bernhard@yahoo.com",
username: "Aurelia_Kemmer",
role: "user",
},
{
id: "e4d3c2b1-5e4d-3c2b-1a9f-8g7h6i5j4k3l",
email: "Lorenzo_Rippin@hotmail.com",
username: "Waino_Lang",
role: "admin",
},
];

View File

@@ -0,0 +1,67 @@
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import {
Examples,
Example,
Note,
Title,
CustomArgsTable,
VariantsTable,
VariantRow,
} from "@calcom/storybook/components";
import { DataTable } from "../";
import { columns, filterableItems } from "./columns";
import { dataTableDemousers, dataTableSelectionActions } from "./data";
<Meta title="UI/table/DataTable" component={DataTable} />
<Title title="DataTable" suffix="Brief" subtitle="Version 3.0 — Last Update: 28 Aug 2023" />
## Definition
The `DataTable` component facilitates tabular data display with configurable columns, virtual scrolling, filtering, and interactive features for seamless dynamic table creation.
## Structure
The `DataTable` setup for tabular data, with columns, virtual scroll, sticky headers, and interactive features like filtering and row selection.
<CustomArgsTable of={DataTable} />
## Dialog Story
<Canvas>
<Story
name="DataTable"
args={{
columns: columns,
data: dataTableDemousers,
isPending: false,
searchKey: "username",
filterableItems: filterableItems,
tableContainerRef: { current: null },
selectionOptions: dataTableSelectionActions,
}}
argTypes={{
tableContainerRef: { table: { disable: true } },
searchKey: {
control: {
type: "select",
options: ["username", "email"],
},
},
selectionOptions: { control: { type: "object" } },
onScroll: { table: { disable: true } },
tableOverlay: { table: { disable: true } },
CTA: { table: { disable: true } },
tableCTA: { table: { disable: true } },
}}>
{(args) => (
<VariantsTable titles={["Default"]} columnMinWidth={1000}>
<VariantRow>
<DataTable {...args} />
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>

View File

@@ -0,0 +1,184 @@
import type {
ColumnDef,
ColumnFiltersState,
Row,
SortingState,
VisibilityState,
Table as TableType,
} from "@tanstack/react-table";
import {
flexRender,
getCoreRowModel,
getFacetedRowModel,
getFacetedUniqueValues,
getFilteredRowModel,
getSortedRowModel,
useReactTable,
} from "@tanstack/react-table";
import { useState } from "react";
import { useVirtual } from "react-virtual";
import classNames from "@calcom/lib/classNames";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../table/TableNew";
import type { ActionItem } from "./DataTableSelectionBar";
import { DataTableSelectionBar } from "./DataTableSelectionBar";
import type { FilterableItems } from "./DataTableToolbar";
import { DataTableToolbar } from "./DataTableToolbar";
export interface DataTableProps<TData, TValue> {
tableContainerRef: React.RefObject<HTMLDivElement>;
columns: ColumnDef<TData, TValue>[];
data: TData[];
searchKey?: string;
onSearch?: (value: string) => void;
filterableItems?: FilterableItems;
selectionOptions?: ActionItem<TData>[];
renderAboveSelection?: (table: TableType<TData>) => React.ReactNode;
tableCTA?: React.ReactNode;
isPending?: boolean;
onRowMouseclick?: (row: Row<TData>) => void;
onScroll?: (e: React.UIEvent<HTMLDivElement, UIEvent>) => void;
CTA?: React.ReactNode;
tableOverlay?: React.ReactNode;
variant?: "default" | "compact";
"data-testId"?: string;
}
export function DataTable<TData, TValue>({
columns,
data,
filterableItems,
tableCTA,
searchKey,
selectionOptions,
tableContainerRef,
isPending,
tableOverlay,
variant,
renderAboveSelection,
/** This should only really be used if you dont have actions in a row. */
onSearch,
onRowMouseclick,
onScroll,
...rest
}: DataTableProps<TData, TValue>) {
const [rowSelection, setRowSelection] = useState({});
const [columnVisibility, setColumnVisibility] = useState<VisibilityState>({});
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
const [sorting, setSorting] = useState<SortingState>([]);
const table = useReactTable({
data,
columns,
state: {
sorting,
columnVisibility,
rowSelection,
columnFilters,
},
enableRowSelection: true,
debugTable: true,
manualPagination: true,
onRowSelectionChange: setRowSelection,
onSortingChange: setSorting,
onColumnFiltersChange: setColumnFilters,
onColumnVisibilityChange: setColumnVisibility,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
getSortedRowModel: getSortedRowModel(),
getFacetedRowModel: getFacetedRowModel(),
getFacetedUniqueValues: getFacetedUniqueValues(),
});
const { rows } = table.getRowModel();
const rowVirtualizer = useVirtual({
parentRef: tableContainerRef,
size: rows.length,
overscan: 10,
});
const { virtualItems: virtualRows, totalSize } = rowVirtualizer;
const paddingTop = virtualRows.length > 0 ? virtualRows?.[0]?.start || 0 : 0;
const paddingBottom =
virtualRows.length > 0 ? totalSize - (virtualRows?.[virtualRows.length - 1]?.end || 0) : 0;
return (
<div className="relative space-y-4">
<DataTableToolbar
table={table}
filterableItems={filterableItems}
searchKey={searchKey}
onSearch={onSearch}
tableCTA={tableCTA}
/>
<div ref={tableContainerRef} onScroll={onScroll} data-testId={rest["data-testId"] ?? "data-table"}>
<Table data-testId="">
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => {
return (
<TableHead key={header.id}>
{header.isPlaceholder
? null
: flexRender(header.column.columnDef.header, header.getContext())}
</TableHead>
);
})}
</TableRow>
))}
</TableHeader>
<TableBody>
{paddingTop > 0 && (
<tr>
<td style={{ height: `${paddingTop}px` }} />
</tr>
)}
{virtualRows && !isPending ? (
virtualRows.map((virtualRow) => {
const row = rows[virtualRow.index] as Row<TData>;
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
onClick={() => onRowMouseclick && onRowMouseclick(row)}
className={classNames(
onRowMouseclick && "hover:cursor-pointer",
variant === "compact" && "!border-0"
)}>
{row.getVisibleCells().map((cell) => {
return (
<TableCell key={cell.id} className={classNames(variant === "compact" && "p-1.5")}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
);
})}
</TableRow>
);
})
) : (
<TableRow>
<TableCell colSpan={columns.length} className="h-24 text-center">
No results.
</TableCell>
</TableRow>
)}
{paddingBottom > 0 && (
<tr>
<td style={{ height: `${paddingBottom}px` }} />
</tr>
)}
</TableBody>
{tableOverlay && tableOverlay}
</Table>
</div>
{/* <DataTablePagination table={table} /> */}
<DataTableSelectionBar
table={table}
actions={selectionOptions}
renderAboveSelection={renderAboveSelection}
/>
</div>
);
}

View File

@@ -0,0 +1,92 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import type { PropsWithChildren, ReactElement } from "react";
import React from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon } from "../..";
import { DialogClose, DialogContent } from "./Dialog";
type ConfirmBtnType =
| { confirmBtn?: never; confirmBtnText?: string }
| { confirmBtnText?: never; confirmBtn?: ReactElement };
export type ConfirmationDialogContentProps = {
cancelBtnText?: string;
isPending?: boolean;
loadingText?: string;
onConfirm?: (event: React.MouseEvent<HTMLElement, MouseEvent>) => void;
title: string;
variety?: "danger" | "warning" | "success";
} & ConfirmBtnType;
export function ConfirmationDialogContent(props: PropsWithChildren<ConfirmationDialogContentProps>) {
return (
<DialogContent type="creation">
<ConfirmationContent {...props} />
</DialogContent>
);
}
export const ConfirmationContent = (props: PropsWithChildren<ConfirmationDialogContentProps>) => {
const { t } = useLocale();
const {
title,
variety,
confirmBtn = null,
confirmBtnText = t("confirm"),
cancelBtnText = t("cancel"),
loadingText = t("loading"),
isPending = false,
onConfirm,
children,
} = props;
return (
<>
<div className="flex">
{variety && (
<div className="mt-0.5 ltr:mr-3">
{variety === "danger" && (
<div className="bg-error mx-auto rounded-full p-2 text-center">
<Icon name="circle-alert" className="h-5 w-5 text-red-600 dark:text-red-100" />
</div>
)}
{variety === "warning" && (
<div className="bg-attention mx-auto rounded-full p-2 text-center">
<Icon name="circle-alert" className="h-5 w-5 text-orange-600" />
</div>
)}
{variety === "success" && (
<div className="bg-success mx-auto rounded-full p-2 text-center">
<Icon name="check" className="h-5 w-5 text-green-600" />
</div>
)}
</div>
)}
<div>
<DialogPrimitive.Title className="font-cal text-emphasis mt-2 text-xl">
{title}
</DialogPrimitive.Title>
<DialogPrimitive.Description className="text-subtle text-sm">
{children}
</DialogPrimitive.Description>
</div>
</div>
<div className="my-5 flex flex-row-reverse gap-x-2 sm:my-8">
{confirmBtn ? (
confirmBtn
) : (
<DialogClose
color="primary"
loading={isPending}
onClick={(e) => onConfirm && onConfirm(e)}
data-testid="dialog-confirmation">
{isPending ? loadingText : confirmBtnText}
</DialogClose>
)}
<DialogClose disabled={isPending}>{cancelBtnText}</DialogClose>
</div>
</>
);
};

View File

@@ -0,0 +1,52 @@
import { Meta } from "@storybook/blocks";
import { Title, CustomArgsTable } from "@calcom/storybook/components";
import { Dialog, DialogContent, DialogFooter, DialogClose, DialogHeader } from "./Dialog";
import * as DialogStories from "./Dialog.stories";
<Meta of={DialogStories} />
<Title title="Dialog" suffix="Brief" subtitle="Version 1.0 — Last Update: 18 Aug 2023" />
## Definition
The `Dialog` component provides a flexible way to create dialogs in your application.
## Structure
The `Dialog` component is composed of the following components:
- `Dialog`: The main component that wraps the entire dialog. It manages the dialog's open and close states.
- `DialogContent`: Represents the content of the dialog. It can have different sizes, types, and an optional icon.
- `DialogHeader`: Renders the header of the dialog, including the title and subtitle.
- `DialogFooter`: Renders the footer of the dialog, which can contain action buttons.
- `DialogClose`: Renders a close button for the dialog.
## Components Arguments
### Dialog
<CustomArgsTable of={Dialog} />
### DialogContent
<CustomArgsTable of={DialogContent} />
### DialogHeader
<CustomArgsTable of={DialogHeader} />
### DialogFooter
<CustomArgsTable of={DialogFooter} />
### DialogClose
<CustomArgsTable of={DialogClose} />
{/* ## Dialog Story
<Canvas of={DialogStories.Default}/> */}

View File

@@ -0,0 +1,87 @@
import type { Meta, StoryObj } from "@storybook/react";
import type { ComponentProps } from "react";
import { Dialog, DialogContent, DialogFooter, DialogClose, DialogHeader } from "./Dialog";
type StoryArgs = ComponentProps<typeof Dialog> &
ComponentProps<typeof DialogContent> &
ComponentProps<typeof DialogHeader> &
ComponentProps<typeof DialogFooter> &
ComponentProps<typeof DialogClose> & {
onClick: (...args: unknown[]) => void;
};
const meta: Meta<StoryArgs> = {
component: Dialog,
parameters: {
nextjs: {
appDirectory: true,
},
},
title: "UI/Dialog",
argTypes: {
title: {
control: "text",
},
description: {
control: "text",
},
type: {
options: ["creation", "confirmation"],
control: {
type: "select",
},
},
open: {
control: "boolean",
},
showDivider: {
control: "boolean",
},
disabled: {
control: "boolean",
},
color: {
options: ["minimal", "primary", "secondary", "emphasis"],
control: {
type: "select",
},
},
onClick: { action: "clicked" }, // this is a storybook action addons action
},
render: ({ title, description, type, open, showDivider, disabled, color, onClick }) => (
<Dialog open={open}>
<DialogContent type={type}>
<DialogHeader title={title} subtitle={description} />
<DialogFooter showDivider={showDivider}>
<DialogClose
disabled={disabled}
color={color}
onClick={() => {
const currentUrl = new URL(window.location.href);
currentUrl.searchParams.set("args", "open:false");
window.open(currentUrl.toString(), "_self");
onClick();
}}
/>
</DialogFooter>
</DialogContent>
</Dialog>
),
};
export default meta;
type Story = StoryObj<StoryArgs>;
export const Default: Story = {
name: "Dialog",
args: {
title: "Example Dialog",
description: "Example Dialog Description",
type: "creation",
open: true,
showDivider: false,
disabled: false,
color: "minimal",
},
};

View File

@@ -0,0 +1,253 @@
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { usePathname, useRouter } from "next/navigation";
import type { ForwardRefExoticComponent, ReactElement, ReactNode } from "react";
import React, { useMemo, useState } from "react";
import { Dialog as PlatformDialogPrimitives, useIsPlatform } from "@calcom/atoms/monorepo";
import classNames from "@calcom/lib/classNames";
import { useCompatSearchParams } from "@calcom/lib/hooks/useCompatSearchParams";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import type { IconName } from "../..";
import { Icon } from "../..";
import type { ButtonProps } from "../../components/button";
import { Button } from "../../components/button";
export type DialogProps = React.ComponentProps<(typeof DialogPrimitive)["Root"]> & {
name?: string;
clearQueryParamsOnClose?: string[];
};
const enum DIALOG_STATE {
// Dialog is there in the DOM but not visible.
CLOSED = "CLOSED",
// State from the time b/w the Dialog is dismissed and the time the "dialog" query param is removed from the URL.
CLOSING = "CLOSING",
// Dialog is visible.
OPEN = "OPEN",
}
export function Dialog(props: DialogProps) {
const isPlatform = useIsPlatform();
return !isPlatform ? <WebDialog {...props} /> : <PlatformDialogPrimitives.Dialog {...props} />;
}
function WebDialog(props: DialogProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useCompatSearchParams();
const newSearchParams = new URLSearchParams(searchParams ?? undefined);
const { children, name, ...dialogProps } = props;
// only used if name is set
const [dialogState, setDialogState] = useState(dialogProps.open ? DIALOG_STATE.OPEN : DIALOG_STATE.CLOSED);
const shouldOpenDialog = newSearchParams.get("dialog") === name;
if (name) {
const clearQueryParamsOnClose = ["dialog", ...(props.clearQueryParamsOnClose || [])];
dialogProps.onOpenChange = (open) => {
if (props.onOpenChange) {
props.onOpenChange(open);
}
// toggles "dialog" query param
if (open) {
newSearchParams.set("dialog", name);
} else {
clearQueryParamsOnClose.forEach((queryParam) => {
newSearchParams.delete(queryParam);
});
router.push(`${pathname}?${newSearchParams.toString()}`);
}
setDialogState(open ? DIALOG_STATE.OPEN : DIALOG_STATE.CLOSING);
};
if (dialogState === DIALOG_STATE.CLOSED && shouldOpenDialog) {
setDialogState(DIALOG_STATE.OPEN);
}
if (dialogState === DIALOG_STATE.CLOSING && !shouldOpenDialog) {
setDialogState(DIALOG_STATE.CLOSED);
}
// allow overriding
if (!("open" in dialogProps)) {
dialogProps.open = dialogState === DIALOG_STATE.OPEN ? true : false;
}
}
return <DialogPrimitive.Root {...dialogProps}>{children}</DialogPrimitive.Root>;
}
type DialogContentProps = React.ComponentProps<(typeof DialogPrimitive)["Content"]> & {
size?: "xl" | "lg" | "md";
type?: "creation" | "confirmation";
title?: string;
description?: string | JSX.Element | null;
closeText?: string;
actionDisabled?: boolean;
Icon?: IconName;
enableOverflow?: boolean;
};
// enableOverflow:- use this prop whenever content inside DialogContent could overflow and require scrollbar
export const DialogContent = React.forwardRef<HTMLDivElement, DialogContentProps>(
({ children, title, Icon: icon, enableOverflow, type = "creation", ...props }, forwardedRef) => {
const isPlatform = useIsPlatform();
const [Portal, Overlay, Content] = useMemo(
() =>
isPlatform
? [
({ children }: { children: ReactElement | ReactElement[] }) => <>{children}</>,
PlatformDialogPrimitives.DialogOverlay,
PlatformDialogPrimitives.DialogContent,
]
: [DialogPrimitive.Portal, DialogPrimitive.Overlay, DialogPrimitive.Content],
[isPlatform]
);
return (
<Portal>
<Overlay className="fadeIn fixed inset-0 z-50 bg-neutral-800 bg-opacity-70 transition-opacity dark:bg-opacity-70 " />
<Content
{...props}
className={classNames(
"fadeIn bg-default scroll-bar fixed left-1/2 top-1/2 z-50 w-full max-w-[22rem] -translate-x-1/2 -translate-y-1/2 rounded-md text-left shadow-xl focus-visible:outline-none sm:align-middle",
props.size == "xl"
? "px-8 pt-8 sm:max-w-[90rem]"
: props.size == "lg"
? "px-8 pt-8 sm:max-w-[70rem]"
: props.size == "md"
? "px-8 pt-8 sm:max-w-[48rem]"
: "px-8 pt-8 sm:max-w-[35rem]",
"max-h-[95vh]",
enableOverflow ? "overflow-auto" : "overflow-visible",
`${props.className || ""}`
)}
ref={forwardedRef}>
{type === "creation" && (
<div>
<DialogHeader title={title} subtitle={props.description} />
<div data-testid="dialog-creation" className="flex flex-col">
{children}
</div>
</div>
)}
{type === "confirmation" && (
<div className="flex">
{icon && (
<div className="bg-emphasis flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-full">
<Icon name={icon} className="text-emphasis h-4 w-4" />
</div>
)}
<div className="ml-4 flex-grow">
<DialogHeader title={title} subtitle={props.description} />
<div data-testid="dialog-confirmation">{children}</div>
</div>
</div>
)}
{!type && children}
</Content>
</Portal>
);
}
);
type DialogHeaderProps = {
title: React.ReactNode;
subtitle?: React.ReactNode;
} & React.HTMLAttributes<HTMLDivElement>;
export function DialogHeader(props: DialogHeaderProps) {
if (!props.title) return null;
return (
<div className="mb-4">
<h3
data-testid="dialog-title"
className="leading-20 text-semibold font-cal text-emphasis pb-1 text-xl"
id="modal-title">
{props.title}
</h3>
{props.subtitle && <p className="text-subtle text-sm">{props.subtitle}</p>}
</div>
);
}
type DialogFooterProps = {
children: React.ReactNode;
showDivider?: boolean;
noSticky?: boolean;
} & React.HTMLAttributes<HTMLDivElement>;
export function DialogFooter(props: DialogFooterProps) {
return (
<div className={classNames("bg-default bottom-0", props?.noSticky ? "" : "sticky", props.className)}>
{props.showDivider && (
// TODO: the -mx-8 is causing overflow in the dialog buttons
<hr data-testid="divider" className="border-subtle -mx-8" />
)}
<div
className={classNames(
"flex justify-end space-x-2 pb-4 pt-4 rtl:space-x-reverse",
!props.showDivider && "pb-8"
)}>
{props.children}
</div>
</div>
);
}
DialogContent.displayName = "DialogContent";
export const DialogTrigger: ForwardRefExoticComponent<
DialogPrimitive.DialogTriggerProps & React.RefAttributes<HTMLButtonElement>
> = React.forwardRef((props, ref) => {
const isPlatform = useIsPlatform();
return !isPlatform ? (
<DialogPrimitive.Trigger {...props} ref={ref} />
) : (
<PlatformDialogPrimitives.DialogTrigger {...props} ref={ref} />
);
});
DialogTrigger.displayName = "DialogTrigger";
type DialogCloseProps = {
"data-testid"?: string;
dialogCloseProps?: React.ComponentProps<(typeof DialogPrimitive)["Close"]>;
children?: ReactNode;
onClick?: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void;
disabled?: boolean;
color?: ButtonProps["color"];
} & React.ComponentProps<typeof Button>;
export function DialogClose(
props: {
"data-testid"?: string;
dialogCloseProps?: React.ComponentProps<(typeof DialogPrimitive)["Close"]>;
children?: ReactNode;
onClick?: (e: React.MouseEvent<HTMLElement, MouseEvent>) => void;
disabled?: boolean;
color?: ButtonProps["color"];
} & React.ComponentProps<typeof Button>
) {
const { t } = useLocale();
const isPlatform = useIsPlatform();
const Close = useMemo(
() => (isPlatform ? PlatformDialogPrimitives.DialogClose : DialogPrimitive.Close),
[isPlatform]
);
return (
<Close asChild {...props.dialogCloseProps}>
{/* This will require the i18n string passed in */}
<Button
data-testid={props["data-testid"] || "dialog-rejection"}
color={props.color || "minimal"}
{...props}>
{props.children ? props.children : t("close")}
</Button>
</Close>
);
}
DialogClose.displayName = "WebDialogClose";

View File

@@ -0,0 +1,140 @@
import { render, screen } from "@testing-library/react";
import { vi } from "vitest";
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader } from "./Dialog";
vi.mock("@calcom/lib/hooks/useCompatSearchParams", () => ({
useCompatSearchParams() {
return new URLSearchParams();
},
}));
vi.mock("next/navigation", () => ({
usePathname() {
return "";
},
useSearchParams() {
return new URLSearchParams();
},
useRouter() {
return {
push: vi.fn(),
beforePopState: vi.fn(() => null),
prefetch: vi.fn(() => null),
};
},
}));
const title = "Dialog Header";
const subtitle = "Dialog Subtitle";
const DialogComponent = (props: {
open: boolean;
title?: string;
subtitle?: string;
type?: "creation" | "confirmation";
showDivider?: boolean;
color?: "primary" | "secondary" | "minimal" | "destructive";
}) => {
return (
<Dialog open={props.open}>
<DialogContent type={props.type}>
<div className="flex flex-row justify-center align-middle ">
<DialogHeader title={props.title} subtitle={props.subtitle} />
<p>Dialog Content</p>
<DialogFooter showDivider={props.showDivider}>
<DialogClose color={props.color} />
<p>Dialog Footer</p>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
);
};
describe("Tests for Dialog component", () => {
test("Should render Dialog with header", () => {
render(<DialogComponent open title={title} />);
expect(screen.queryByText("Dialog Header")).toBeInTheDocument();
expect(screen.getByText("Dialog Content")).toBeInTheDocument();
expect(screen.getByText("Dialog Footer")).toBeInTheDocument();
});
test("Should render Dialog without header", () => {
render(<DialogComponent open />);
expect(screen.queryByTestId("dialog-title")).toBeNull();
expect(screen.getByText("Dialog Content")).toBeInTheDocument();
expect(screen.getByText("Dialog Footer")).toBeInTheDocument();
});
test("Should render Dialog with header and subtitle", () => {
render(<DialogComponent open title={title} subtitle={subtitle} />);
expect(screen.queryByText("Dialog Header")).toBeInTheDocument();
expect(screen.queryByText("Dialog Subtitle")).toBeInTheDocument();
expect(screen.getByText("Dialog Content")).toBeInTheDocument();
expect(screen.getByText("Dialog Footer")).toBeInTheDocument();
});
test("Should render Dialog with default type creation", () => {
render(<DialogComponent open />);
expect(screen.getByTestId("dialog-creation")).toBeInTheDocument();
expect(screen.getByText("Dialog Content")).toBeInTheDocument();
expect(screen.getByText("Dialog Footer")).toBeInTheDocument();
});
test("Should render Dialog with type creation", () => {
render(<DialogComponent open type="creation" />);
expect(screen.getByTestId("dialog-creation")).toBeInTheDocument();
expect(screen.getByText("Dialog Content")).toBeInTheDocument();
expect(screen.getByText("Dialog Footer")).toBeInTheDocument();
});
test("Should render Dialog with type confirmation", () => {
render(<DialogComponent open type="confirmation" />);
expect(screen.getByTestId("dialog-confirmation")).toBeInTheDocument();
expect(screen.getByText("Dialog Content")).toBeInTheDocument();
expect(screen.getByText("Dialog Footer")).toBeInTheDocument();
});
test("Should open Dialog", async () => {
const { rerender } = render(<DialogComponent open={false} />);
expect(screen.queryByText("Dialog Content")).not.toBeInTheDocument();
expect(screen.queryByText("Dialog Footer")).not.toBeInTheDocument();
rerender(<DialogComponent open />);
expect(screen.getByText("Dialog Content")).toBeInTheDocument();
expect(screen.getByText("Dialog Footer")).toBeInTheDocument();
});
test("Should close Dialog", async () => {
const { rerender } = render(<DialogComponent open />);
expect(screen.getByText("Dialog Content")).toBeInTheDocument();
expect(screen.getByText("Dialog Footer")).toBeInTheDocument();
rerender(<DialogComponent open={false} />);
expect(screen.queryByText("Dialog Content")).not.toBeInTheDocument();
expect(screen.queryByText("Dialog Footer")).not.toBeInTheDocument();
});
test("Should use color from props in CloseDialog", async () => {
render(<DialogComponent open color="destructive" />);
const closeBtn = screen.getByText("close");
expect(closeBtn.classList.toString()).toContain("hover:text-red-700");
});
test("Should show divider with showDivider", async () => {
render(<DialogComponent open showDivider />);
expect(screen.getByTestId("divider")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,4 @@
export { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTrigger } from "./Dialog";
export { ConfirmationDialogContent, ConfirmationContent } from "./ConfirmationDialogContent";
export type { ConfirmationDialogContentProps } from "./ConfirmationDialogContent";
export type { DialogProps } from "./Dialog";

View File

@@ -0,0 +1,23 @@
import { classNames } from "@calcom/lib";
export function Divider({ className, ...props }: JSX.IntrinsicElements["hr"]) {
className = classNames("border-subtle my-1", className);
return <hr className={className} {...props} />;
}
export function VerticalDivider({ className, ...props }: JSX.IntrinsicElements["svg"]) {
className = classNames("mx-3 text-muted", className);
return (
<svg
className={className}
{...props}
width="2"
height="16"
viewBox="0 0 2 16"
ry="6"
fill="none"
xmlns="http://www.w3.org/2000/svg">
<rect width="2" height="16" rx="1" fill="currentColor" />
</svg>
);
}

View File

@@ -0,0 +1,42 @@
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import {
Examples,
Example,
Note,
Title,
CustomArgsTable,
VariantsTable,
VariantRow,
} from "@calcom/storybook/components";
import { Divider, VerticalDivider } from "./Divider";
<Meta title="UI/Divider" component={Divider} />
<Title title="Divider" suffix="Brief" subtitle="Version 2.0 — Last Update: 05 jan 2023" />
## Definition
Shows the current copy right as well as app version name.
<Canvas>
<Story name="Divider">
<VariantsTable titles={[]} columnMinWidth={150}>
<VariantRow variant="Horizontal">
<div className="dark:text-inverted">
Text above
<Divider />
Text below
</div>
</VariantRow>
<VariantRow variant="Vertical">
<div className="dark:text-inverted flex">
Text left
<VerticalDivider />
Text right
</div>
</VariantRow>
</VariantsTable>
</Story>
</Canvas>

View File

@@ -0,0 +1 @@
export { Divider, VerticalDivider } from "./Divider";

View File

@@ -0,0 +1,56 @@
import classNames from "classnames";
import { useState } from "react";
import type { ControllerRenderProps } from "react-hook-form";
import { Icon } from "../..";
export const EditableHeading = function EditableHeading({
value,
onChange,
isReady,
disabled = false,
...passThroughProps
}: {
isReady?: boolean;
disabled?: boolean;
} & Omit<JSX.IntrinsicElements["input"], "name" | "onChange"> &
ControllerRenderProps) {
const [isEditing, setIsEditing] = useState(false);
const enableEditing = () => setIsEditing(!disabled);
return (
<div
className="group pointer-events-auto relative truncate"
onClick={enableEditing}>
<div className={classNames(!disabled && "cursor-pointer", "flex items-center")}>
<label className="min-w-8 relative inline-block">
<span className="whitespace-pre text-xl tracking-normal text-transparent">{value}&nbsp;</span>
<input
{...passThroughProps}
disabled={disabled}
type="text"
value={value}
required
className={classNames(
!disabled &&
"hover:text-default focus:text-emphasis cursor-pointer focus:outline-none focus:ring-0",
"text-emphasis absolute left-0 top-0 w-full truncate border-none bg-transparent p-0 align-top text-xl ",
passThroughProps.className
)}
onFocus={(e) => {
setIsEditing(!disabled);
passThroughProps.onFocus && passThroughProps.onFocus(e);
}}
onBlur={(e) => {
setIsEditing(false);
passThroughProps.onBlur && passThroughProps.onBlur(e);
}}
onChange={(e) => onChange && onChange(e.target.value)}
/>
{!isEditing && isReady && !disabled && (
<Icon name="pencil" className="text-subtle group-hover:text-subtle -mt-px ml-1 inline h-3 w-3" />
)}
</label>
</div>
</div>
);
};

View File

@@ -0,0 +1 @@
export { EditableHeading } from "./EditableHeading";

View File

@@ -0,0 +1,122 @@
import { CodeHighlightNode, CodeNode } from "@lexical/code";
import { AutoLinkNode, LinkNode } from "@lexical/link";
import { ListItemNode, ListNode } from "@lexical/list";
import { TRANSFORMERS } from "@lexical/markdown";
import { LexicalComposer } from "@lexical/react/LexicalComposer";
import { ContentEditable } from "@lexical/react/LexicalContentEditable";
import LexicalErrorBoundary from "@lexical/react/LexicalErrorBoundary";
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin";
import { ListPlugin } from "@lexical/react/LexicalListPlugin";
import { MarkdownShortcutPlugin } from "@lexical/react/LexicalMarkdownShortcutPlugin";
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
import { HeadingNode, QuoteNode } from "@lexical/rich-text";
import { TableCellNode, TableNode, TableRowNode } from "@lexical/table";
import type { Dispatch, SetStateAction } from "react";
import { classNames } from "@calcom/lib";
import ExampleTheme from "./ExampleTheme";
import { VariableNode } from "./nodes/VariableNode";
import AddVariablesPlugin from "./plugins/AddVariablesPlugin";
import AutoLinkPlugin from "./plugins/AutoLinkPlugin";
import ToolbarPlugin from "./plugins/ToolbarPlugin";
import "./stylesEditor.css";
/*
Detault toolbar items:
- blockType
- bold
- italic
- link
*/
export type TextEditorProps = {
getText: () => string;
setText: (text: string) => void;
excludedToolbarItems?: string[];
variables?: string[];
height?: string;
placeholder?: string;
disableLists?: boolean;
updateTemplate?: boolean;
firstRender?: boolean;
setFirstRender?: Dispatch<SetStateAction<boolean>>;
editable?: boolean;
};
const editorConfig = {
theme: ExampleTheme,
onError(error: Error) {
throw error;
},
namespace: "",
nodes: [
HeadingNode,
ListNode,
ListItemNode,
QuoteNode,
CodeNode,
CodeHighlightNode,
TableNode,
TableCellNode,
TableRowNode,
AutoLinkNode,
LinkNode,
VariableNode,
],
};
export const Editor = (props: TextEditorProps) => {
const editable = props.editable ?? true;
return (
<div className="editor rounded-md">
<LexicalComposer initialConfig={{ ...editorConfig, editable }}>
<div className="editor-container hover:border-emphasis focus-within:ring-brand-default rounded-md p-0 transition focus-within:ring-2">
<ToolbarPlugin
getText={props.getText}
setText={props.setText}
editable={editable}
excludedToolbarItems={props.excludedToolbarItems}
variables={props.variables}
updateTemplate={props.updateTemplate}
firstRender={props.firstRender}
setFirstRender={props.setFirstRender}
/>
<div
className={classNames("editor-inner scroll-bar", !editable && "!bg-subtle")}
style={{ height: props.height }}>
<RichTextPlugin
contentEditable={
<ContentEditable
readOnly={!editable}
style={{ height: props.height }}
className="editor-input"
/>
}
placeholder={
props?.placeholder ? (
<div className="text-muted -mt-11 p-3 text-sm">{props.placeholder}</div>
) : null
}
ErrorBoundary={LexicalErrorBoundary}
/>
<ListPlugin />
<LinkPlugin />
<AutoLinkPlugin />
{props?.variables ? <AddVariablesPlugin variables={props.variables} /> : null}
<HistoryPlugin />
<MarkdownShortcutPlugin
transformers={
props.disableLists
? TRANSFORMERS.filter((value, index) => {
if (index !== 3 && index !== 4) return value;
})
: TRANSFORMERS
}
/>
</div>
</div>
</LexicalComposer>
</div>
);
};

View File

@@ -0,0 +1,25 @@
const exampleTheme = {
placeholder: "editor-placeholder",
paragraph: "editor-paragraph",
heading: {
h1: "editor-heading-h1",
h2: "editor-heading-h2",
h6: "editor-heading-h6",
},
list: {
nested: {
listitem: "editor-nested-listitem",
},
ol: "editor-list-ol",
ul: "editor-list-ul",
listitem: "editor-listitem",
},
image: "editor-image",
link: "editor-link",
text: {
bold: "editor-text-bold",
italic: "editor-text-italic",
},
};
export default exampleTheme;

View File

@@ -0,0 +1,68 @@
import { Canvas, Meta, Story } from "@storybook/addon-docs";
import {
Examples,
Example,
Title,
VariantsTable,
CustomArgsTable,
VariantRow,
} from "@calcom/storybook/components";
import { Editor } from "./Editor";
<Meta title="UI/Editor" component={Editor} />
<Title title="Editor" suffix="Brief" subtitle="Version 1.0 — Last Update: 16 Aug 2023" />
## Definition
The `Editor` component is a versatile rich text editor that provides a customizable environment for creating and editing content with various formatting options and plugins.
## Structure
The `Editor` component offers a flexible rich text editing interface, complete with a configurable toolbar
<CustomArgsTable of={Editor} />
<Examples title="States">
<Example title="Default">
<Editor setText={() => {}} getText={() => "Text"} />
</Example>
<Example title="With placeholder">
<Editor setText={() => {}} getText={() => ""} placeholder="placeholder" />
</Example>
<Example title="With variables">
<Editor setText={() => {}} getText={() => "Text"} variables={["variable1", "variable2", "variable3"]} />
</Example>
</Examples>
<Title offset title="Editor" suffix="Variants" />
<Canvas>
<Story
name="Editor"
args={{
setText: () => {},
getText: () => "Text",
editable: true,
}}
argTypes={{
excludedToolbarItems: { control: { type: "check", options: ["blockType", "bold", "italic", "link"] } },
variables: { control: { type: "check", options: ["variable1", "variable2", "variable3"] } },
height: { control: { type: "text" } },
placeholder: { control: { type: "text" } },
disableLists: { control: { type: "boolean" } },
updateTemplate: { control: { type: "boolean" } },
firstRender: { control: { type: "boolean" } },
editable: { control: { type: "boolean" } },
}}>
{(props) => (
<VariantsTable titles={["Default"]} columnMinWidth={150}>
<VariantRow>
<Editor {...props} />
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-link" viewBox="0 0 16 16">
<path d="M6.354 5.5H4a3 3 0 0 0 0 6h3a3 3 0 0 0 2.83-4H9c-.086 0-.17.01-.25.031A2 2 0 0 1 7 10.5H4a2 2 0 1 1 0-4h1.535c.218-.376.495-.714.82-1z"/>
<path d="M9 5.5a3 3 0 0 0-2.83 4h1.098A2 2 0 0 1 9 6.5h3a2 2 0 1 1 0 4h-1.535a4.02 4.02 0 0 1-.82 1H12a3 3 0 1 0 0-6H9z"/>
</svg>

After

Width:  |  Height:  |  Size: 404 B

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-list-ol" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M5 11.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5z"/>
<path d="M1.713 11.865v-.474H2c.217 0 .363-.137.363-.317 0-.185-.158-.31-.361-.31-.223 0-.367.152-.373.31h-.59c.016-.467.373-.787.986-.787.588-.002.954.291.957.703a.595.595 0 0 1-.492.594v.033a.615.615 0 0 1 .569.631c.003.533-.502.8-1.051.8-.656 0-1-.37-1.008-.794h.582c.008.178.186.306.422.309.254 0 .424-.145.422-.35-.002-.195-.155-.348-.414-.348h-.3zm-.004-4.699h-.604v-.035c0-.408.295-.844.958-.844.583 0 .96.326.96.756 0 .389-.257.617-.476.848l-.537.572v.03h1.054V9H1.143v-.395l.957-.99c.138-.142.293-.304.293-.508 0-.18-.147-.32-.342-.32a.33.33 0 0 0-.342.338v.041zM2.564 5h-.635V2.924h-.031l-.598.42v-.567l.629-.443h.635V5z"/>
</svg>

After

Width:  |  Height:  |  Size: 984 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-list-ul" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M5 11.5a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm0-4a.5.5 0 0 1 .5-.5h9a.5.5 0 0 1 0 1h-9a.5.5 0 0 1-.5-.5zm-3 1a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm0 4a1 1 0 1 0 0-2 1 1 0 0 0 0 2zm0 4a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/>
</svg>

After

Width:  |  Height:  |  Size: 448 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-pencil-fill" viewBox="0 0 16 16">
<path d="M12.854.146a.5.5 0 0 0-.707 0L10.5 1.793 14.207 5.5l1.647-1.646a.5.5 0 0 0 0-.708l-3-3zm.646 6.061L9.793 2.5 3.293 9H3.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.5h.5a.5.5 0 0 1 .5.5v.207l6.5-6.5zm-7.468 7.468A.5.5 0 0 1 6 13.5V13h-.5a.5.5 0 0 1-.5-.5V12h-.5a.5.5 0 0 1-.5-.5V11h-.5a.5.5 0 0 1-.5-.5V10h-.5a.499.499 0 0 1-.175-.032l-.179.178a.5.5 0 0 0-.11.168l-2 5a.5.5 0 0 0 .65.65l5-2a.5.5 0 0 0 .168-.11l.178-.178z"/>
</svg>

After

Width:  |  Height:  |  Size: 590 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-text-paragraph" viewBox="0 0 16 16">
<path fill-rule="evenodd" d="M2 12.5a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5zm0-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm0-3a.5.5 0 0 1 .5-.5h11a.5.5 0 0 1 0 1h-11a.5.5 0 0 1-.5-.5zm4-3a.5.5 0 0 1 .5-.5h7a.5.5 0 0 1 0 1h-7a.5.5 0 0 1-.5-.5z"/>
</svg>

After

Width:  |  Height:  |  Size: 418 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-type-bold" viewBox="0 0 16 16">
<path d="M8.21 13c2.106 0 3.412-1.087 3.412-2.823 0-1.306-.984-2.283-2.324-2.386v-.055a2.176 2.176 0 0 0 1.852-2.14c0-1.51-1.162-2.46-3.014-2.46H3.843V13H8.21zM5.908 4.674h1.696c.963 0 1.517.451 1.517 1.244 0 .834-.629 1.32-1.73 1.32H5.908V4.673zm0 6.788V8.598h1.73c1.217 0 1.88.492 1.88 1.415 0 .943-.643 1.449-1.832 1.449H5.907z"/>
</svg>

After

Width:  |  Height:  |  Size: 471 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-type-h1" viewBox="0 0 16 16">
<path d="M8.637 13V3.669H7.379V7.62H2.758V3.67H1.5V13h1.258V8.728h4.62V13h1.259zm5.329 0V3.669h-1.244L10.5 5.316v1.265l2.16-1.565h.062V13h1.244z"/>
</svg>

After

Width:  |  Height:  |  Size: 283 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-type-h2" viewBox="0 0 16 16">
<path d="M7.638 13V3.669H6.38V7.62H1.759V3.67H.5V13h1.258V8.728h4.62V13h1.259zm3.022-6.733v-.048c0-.889.63-1.668 1.716-1.668.957 0 1.675.608 1.675 1.572 0 .855-.554 1.504-1.067 2.085l-3.513 3.999V13H15.5v-1.094h-4.245v-.075l2.481-2.844c.875-.998 1.586-1.784 1.586-2.953 0-1.463-1.155-2.556-2.919-2.556-1.941 0-2.966 1.326-2.966 2.74v.049h1.223z"/>
</svg>

After

Width:  |  Height:  |  Size: 483 B

View File

@@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-type-italic" viewBox="0 0 16 16">
<path d="M7.991 11.674 9.53 4.455c.123-.595.246-.71 1.347-.807l.11-.52H7.211l-.11.52c1.06.096 1.128.212 1.005.807L6.57 11.674c-.123.595-.246.71-1.346.806l-.11.52h3.774l.11-.52c-1.06-.095-1.129-.211-1.006-.806z"/>
</svg>

After

Width:  |  Height:  |  Size: 352 B

View File

@@ -0,0 +1,2 @@
export { Editor } from "./Editor";
export { AddVariablesDropdown } from "./plugins/AddVariablesDropdown";

View File

@@ -0,0 +1,53 @@
import type { EditorConfig, LexicalNode, NodeKey, SerializedTextNode } from "lexical";
import { $applyNodeReplacement, TextNode } from "lexical";
export class VariableNode extends TextNode {
static getType(): string {
return "variable";
}
static clone(node: VariableNode): VariableNode {
return new VariableNode(node.__text, node.__key);
}
constructor(text: string, key?: NodeKey) {
super(text, key);
}
createDOM(config: EditorConfig): HTMLElement {
const dom = super.createDOM(config);
dom.className = "bg-info";
dom.setAttribute("data-lexical-variable", "true");
return dom;
}
exportJSON(): SerializedTextNode {
return {
...super.exportJSON(),
type: "variable",
version: 1,
};
}
isTextEntity(): true {
return true;
}
canInsertTextBefore(): boolean {
return false;
}
canInsertTextAfter(): boolean {
return false;
}
}
export function $createVariableNode(text = ""): VariableNode {
const node = new VariableNode(text);
node.setMode("segmented").toggleDirectionless();
return $applyNodeReplacement(node);
}
export function $isVariableNode(node: LexicalNode | null | undefined): node is VariableNode {
return node instanceof VariableNode;
}

View File

@@ -0,0 +1,64 @@
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Icon } from "../../..";
import { Dropdown, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../../form/dropdown";
interface IAddVariablesDropdown {
addVariable: (variable: string) => void;
isTextEditor?: boolean;
variables: string[];
}
export const AddVariablesDropdown = (props: IAddVariablesDropdown) => {
const { t } = useLocale();
return (
<Dropdown>
<DropdownMenuTrigger className="focus:bg-muted pt-[6px]">
<div className="items-center ">
{props.isTextEditor ? (
<>
<div className="hidden sm:flex">
{t("add_variable")}
<Icon name="chevron-down" className="ml-1 mt-[2px] h-4 w-4" />
</div>
<div className="block sm:hidden">+</div>
</>
) : (
<div className="flex">
{t("add_variable")}
<Icon name="chevron-down" className="ml-1 mt-[2px] h-4 w-4" />
</div>
)}
</div>
</DropdownMenuTrigger>
<DropdownMenuContent>
<div className="pb-1 pt-4">
<div className="text-subtle mb-2 px-4 text-left text-xs">
{t("add_dynamic_variables").toLocaleUpperCase()}
</div>
<div className="h-64 overflow-scroll md:h-80">
{props.variables.map((variable) => (
<DropdownMenuItem key={variable} className="hover:ring-0">
<button
key={variable}
type="button"
className="w-full px-4 py-2"
onClick={() => props.addVariable(t(`${variable}_variable`))}>
<div className="sm:grid sm:grid-cols-2">
<div className="mr-3 text-left md:col-span-1">
{`{${t(`${variable}_variable`).toUpperCase().replace(/ /g, "_")}}`}
</div>
<div className="text-default hidden text-left sm:col-span-1 sm:flex">
{t(`${variable}_info`)}
</div>
</div>
</button>
</DropdownMenuItem>
))}
</div>
</div>
</DropdownMenuContent>
</Dropdown>
);
};

View File

@@ -0,0 +1,159 @@
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
import {
LexicalTypeaheadMenuPlugin,
TypeaheadOption,
useBasicTypeaheadTriggerMatch,
} from "@lexical/react/LexicalTypeaheadMenuPlugin";
import type { LexicalEditor } from "lexical";
import { TextNode } from "lexical";
import { useCallback, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { VariableNode, $createVariableNode } from "../nodes/VariableNode";
function $findAndTransformVariable(node: TextNode): null | TextNode {
const text = node.getTextContent();
const regex = /\{[^}]+\}/g; // Regular expression to match {VARIABLE_NAME <whatever>}
let match;
// Iterate over the text content
for (let i = 0; i < text.length; i++) {
regex.lastIndex = i; // Set the regex search position to the current index
match = regex.exec(text);
if (match !== null) {
const matchedText = match[0]; // The entire matched text {VARIABLE_NAME <whatever here>}
const matchIndex = match.index; // Start index of the matched text
// Ensure that we move the loop index past the current match
i = matchIndex + matchedText.length - 1;
let targetNode;
if (matchIndex === 0) {
[targetNode] = node.splitText(matchIndex + matchedText.length);
} else {
[, targetNode] = node.splitText(matchIndex, matchIndex + matchedText.length);
}
const variableNode = $createVariableNode(matchedText);
targetNode.replace(variableNode);
return variableNode;
}
}
return null;
}
function useVariablesTransform(editor: LexicalEditor): void {
useEffect(() => {
if (!editor.hasNodes([VariableNode])) {
console.error("VariableNode is not registered in the editor");
return;
}
return editor.registerNodeTransform(TextNode, (node) => {
let targetNode: TextNode | null = node;
while (targetNode !== null) {
if (!targetNode.isSimpleText()) {
return;
}
targetNode = $findAndTransformVariable(targetNode);
}
});
}, [editor]);
}
class VariableTypeaheadOption extends TypeaheadOption {
name: string;
constructor(name: string) {
super(name);
this.name = name;
}
}
interface AddVariablesPluginProps {
variables: string[];
}
export default function AddVariablesPlugin({ variables }: AddVariablesPluginProps): JSX.Element | null {
const { t } = useLocale();
const [editor] = useLexicalComposerContext();
useVariablesTransform(editor);
const [queryString, setQueryString] = useState<string | null>(null);
const checkForTriggerMatch = useBasicTypeaheadTriggerMatch("{", {
minLength: 0,
});
const options = useMemo(() => {
return variables
.filter((variable) => variable.toLowerCase().includes(queryString?.toLowerCase() ?? ""))
.map((result) => new VariableTypeaheadOption(result));
}, [variables, queryString]);
const onSelectOption = useCallback(
(selectedOption: VariableTypeaheadOption, nodeToReplace: TextNode | null, closeMenu: () => void) => {
editor.update(() => {
const variableNode = $createVariableNode(
`{${t(`${selectedOption.name}_variable`).toUpperCase().replace(/ /g, "_")}}`
);
if (nodeToReplace) {
nodeToReplace.replace(variableNode);
}
variableNode.select();
closeMenu();
});
},
[editor, t]
);
return (
<LexicalTypeaheadMenuPlugin<VariableTypeaheadOption>
onQueryChange={setQueryString}
onSelectOption={onSelectOption}
triggerFn={checkForTriggerMatch}
options={options}
menuRenderFn={(anchorElementRef, { selectedIndex, selectOptionAndCleanUp, setHighlightedIndex }) =>
anchorElementRef.current && options.length
? createPortal(
<div
className={classNames(
"shadow-dropdown bg-default border-subtle mt-5 w-64 overflow-hidden rounded-md border",
"typeahead-popover" // class required by Lexical
)}>
<ul className="max-h-64 list-none overflow-y-scroll md:max-h-80">
{options.map((option, index) => (
<li
id={`typeahead-item-${index}`}
key={option.key}
aria-selected={selectedIndex === index}
tabIndex={-1}
className="focus:ring-brand-800 hover:bg-subtle hover:text-emphasis text-default aria-selected:bg-subtle aria-selected:text-emphasis cursor-pointer px-4 py-2 text-sm outline-none ring-inset first-of-type:rounded-t-[inherit] last-of-type:rounded-b-[inherit] focus:outline-none focus:ring-1"
ref={option.setRefElement}
role="option"
onClick={() => {
setHighlightedIndex(index);
selectOptionAndCleanUp(option);
}}
onMouseEnter={() => {
setHighlightedIndex(index);
}}>
<p className="text-sm font-semibold">
{`{${t(`${option.name}_variable`).toUpperCase().replace(/ /g, "_")}}`}
</p>
<span className="text-default text-sm">{t(`${option.name}_info`)}</span>
</li>
))}
</ul>
</div>,
anchorElementRef.current
)
: null
}
/>
);
}

Some files were not shown because too many files have changed in this diff Show More