first commit
1
calcom/packages/ui/.eslintignore
Normal file
@@ -0,0 +1 @@
|
||||
components/icon/dynamicIconImports.tsx
|
||||
36
calcom/packages/ui/components/TokenHandler/TokenHandler.tsx
Normal 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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
103
calcom/packages/ui/components/alert/Alert.tsx
Normal 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";
|
||||
94
calcom/packages/ui/components/alert/alert.stories.mdx
Normal 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>
|
||||
41
calcom/packages/ui/components/alert/alert.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
2
calcom/packages/ui/components/alert/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Alert } from "./Alert";
|
||||
export type { AlertProps } from "./Alert";
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
});
|
||||
106
calcom/packages/ui/components/all-questions/tests/utils.ts
Normal 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();
|
||||
});
|
||||
},
|
||||
};
|
||||
202
calcom/packages/ui/components/apps/AllApps.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
233
calcom/packages/ui/components/apps/AppCard.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
30
calcom/packages/ui/components/apps/Categories.stories.mdx
Normal 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} />
|
||||
63
calcom/packages/ui/components/apps/Categories.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
calcom/packages/ui/components/apps/PopularAppsSlider.tsx
Normal 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} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
28
calcom/packages/ui/components/apps/RecentAppsSlider.tsx
Normal 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} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
51
calcom/packages/ui/components/apps/SkeletonLoader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
88
calcom/packages/ui/components/apps/Slider.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
54
calcom/packages/ui/components/apps/_storybookData.ts
Normal 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,
|
||||
},
|
||||
];
|
||||
73
calcom/packages/ui/components/apps/appCard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
8
calcom/packages/ui/components/apps/index.ts
Normal 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";
|
||||
26
calcom/packages/ui/components/arrow-button/ArrowButton.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
2
calcom/packages/ui/components/arrow-button/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ArrowButton } from "./ArrowButton";
|
||||
export type { ArrowButtonProps } from "./ArrowButton";
|
||||
83
calcom/packages/ui/components/avatar/Avatar.tsx
Normal 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}</>
|
||||
);
|
||||
}
|
||||
61
calcom/packages/ui/components/avatar/AvatarGroup.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
53
calcom/packages/ui/components/avatar/UserAvatar.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
70
calcom/packages/ui/components/avatar/UserAvatar.tsx
Normal 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} />;
|
||||
}
|
||||
28
calcom/packages/ui/components/avatar/UserAvatarGroup.tsx
Normal 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),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
167
calcom/packages/ui/components/avatar/avatar.stories.mdx
Normal 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>
|
||||
7
calcom/packages/ui/components/avatar/index.ts
Normal 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";
|
||||
94
calcom/packages/ui/components/badge/Badge.tsx
Normal 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 />);
|
||||
};
|
||||
14
calcom/packages/ui/components/badge/InfoBadge.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
16
calcom/packages/ui/components/badge/UpgradeOrgsBadge.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
22
calcom/packages/ui/components/badge/UpgradeTeamsBadge.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
99
calcom/packages/ui/components/badge/badge.stories.mdx
Normal 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>
|
||||
91
calcom/packages/ui/components/badge/badge.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
5
calcom/packages/ui/components/badge/index.ts
Normal 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";
|
||||
69
calcom/packages/ui/components/breadcrumb/Breadcrumb.tsx
Normal 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;
|
||||
@@ -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.
|
||||
105
calcom/packages/ui/components/breadcrumb/breadcrumb.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
1
calcom/packages/ui/components/breadcrumb/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Breadcrumb, BreadcrumbContainer, BreadcrumbItem } from "./Breadcrumb";
|
||||
257
calcom/packages/ui/components/button/Button.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
21
calcom/packages/ui/components/button/LinkIconButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
244
calcom/packages/ui/components/button/button.stories.mdx
Normal 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>
|
||||
248
calcom/packages/ui/components/button/button.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
3
calcom/packages/ui/components/button/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { Button } from "./Button";
|
||||
export type { ButtonBaseProps, ButtonProps } from "./Button";
|
||||
export { default as LinkIconButton } from "./LinkIconButton";
|
||||
30
calcom/packages/ui/components/buttonGroup/ButtonGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
1
calcom/packages/ui/components/buttonGroup/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ButtonGroup } from "./ButtonGroup";
|
||||
246
calcom/packages/ui/components/card/Card.tsx
Normal 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;
|
||||
79
calcom/packages/ui/components/card/FormCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
9
calcom/packages/ui/components/card/StepCard.tsx
Normal 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 };
|
||||
69
calcom/packages/ui/components/card/card.stories.mdx
Normal 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>
|
||||
99
calcom/packages/ui/components/card/card.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
4
calcom/packages/ui/components/card/index.ts
Normal 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";
|
||||
136
calcom/packages/ui/components/command/index.tsx
Normal 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,
|
||||
};
|
||||
135
calcom/packages/ui/components/createButton/CreateButton.tsx
Normal 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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
2
calcom/packages/ui/components/createButton/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { CreateButton } from "./CreateButton";
|
||||
export { CreateButtonWithTeamsList } from "./CreateButtonWithTeamsList";
|
||||
43
calcom/packages/ui/components/credits/Credits.tsx
Normal 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">
|
||||
© {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>
|
||||
);
|
||||
}
|
||||
31
calcom/packages/ui/components/credits/credits.stories.mdx
Normal 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>
|
||||
35
calcom/packages/ui/components/credits/credits.test.tsx
Normal 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}`);
|
||||
});
|
||||
});
|
||||
1
calcom/packages/ui/components/credits/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as Credits } from "./Credits";
|
||||
116
calcom/packages/ui/components/data-table/DataTableFilter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -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",
|
||||
},
|
||||
];
|
||||
@@ -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>
|
||||
184
calcom/packages/ui/components/data-table/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
52
calcom/packages/ui/components/dialog/Dialog.docs.mdx
Normal 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}/> */}
|
||||
87
calcom/packages/ui/components/dialog/Dialog.stories.tsx
Normal 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",
|
||||
},
|
||||
};
|
||||
253
calcom/packages/ui/components/dialog/Dialog.tsx
Normal 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";
|
||||
140
calcom/packages/ui/components/dialog/dialog.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
4
calcom/packages/ui/components/dialog/index.ts
Normal 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";
|
||||
23
calcom/packages/ui/components/divider/Divider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
42
calcom/packages/ui/components/divider/divider.stories.mdx
Normal 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>
|
||||
1
calcom/packages/ui/components/divider/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { Divider, VerticalDivider } from "./Divider";
|
||||
@@ -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} </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>
|
||||
);
|
||||
};
|
||||
1
calcom/packages/ui/components/editable-heading/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { EditableHeading } from "./EditableHeading";
|
||||
122
calcom/packages/ui/components/editor/Editor.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
25
calcom/packages/ui/components/editor/ExampleTheme.ts
Normal 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;
|
||||
68
calcom/packages/ui/components/editor/editor.stories.mdx
Normal 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>
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
@@ -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 |
2
calcom/packages/ui/components/editor/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { Editor } from "./Editor";
|
||||
export { AddVariablesDropdown } from "./plugins/AddVariablesDropdown";
|
||||
53
calcom/packages/ui/components/editor/nodes/VariableNode.ts
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||