merge with remote main

This commit is contained in:
Alan 2023-09-03 10:59:15 -07:00
commit 4652b3ee01
110 changed files with 3360 additions and 3004 deletions

View File

@ -9,12 +9,11 @@
"langchain": "^0.0.131",
"mailparser": "^3.6.5",
"next": "^13.4.6",
"zod": "^3.20.2"
"supports-color": "8.1.1",
"zod": "^3.22.2"
},
"devDependencies": {
"@types/mailparser": "^3.4.0",
"@types/node": "^20.5.1",
"typescript": "^4.9.4"
"@types/mailparser": "^3.4.0"
},
"scripts": {
"build": "next build",

View File

@ -23,6 +23,7 @@ export const env = createEnv({
NODE_ENV: process.env.NODE_ENV,
OPENAI_API_KEY: process.env.OPENAI_API_KEY,
SENDGRID_API_KEY: process.env.SENDGRID_API_KEY,
DATABASE_URL: process.env.DATABASE_URL,
},
/**
@ -37,5 +38,6 @@ export const env = createEnv({
NODE_ENV: z.enum(["development", "test", "production"]),
OPENAI_API_KEY: z.string().min(1),
SENDGRID_API_KEY: z.string().min(1),
DATABASE_URL: z.string().url(),
},
});

View File

@ -40,6 +40,6 @@
"typescript": "^4.9.4",
"tzdata": "^1.0.30",
"uuid": "^8.3.2",
"zod": "^3.20.2"
"zod": "^3.22.2"
}
}

View File

@ -248,10 +248,10 @@
--cal-bg-inverted: #f3f4f6;
/* background -> components*/
--cal-bg-info: #dee9fc;
--cal-bg-success: #e2fbe8;
--cal-bg-attention: #fceed8;
--cal-bg-error: #f9e3e2;
--cal-bg-info: #263fa9;
--cal-bg-success: #306339;
--cal-bg-attention: #8e3b1f;
--cal-bg-error: #8c2822;
--cal-bg-dark-error: #752522;
/* Borders */
@ -269,10 +269,10 @@
--cal-text-inverted: #101010;
/* Content/Text -> components */
--cal-text-info: #253985;
--cal-text-success: #285231;
--cal-text-attention: #73321b;
--cal-text-error: #752522;
--cal-text-info: #dee9fc;
--cal-text-success: #e2fbe8;
--cal-text-attention: #fceed8;
--cal-text-error: #f9e3e2;
/* Brand shenanigans
-> These will be computed for the users theme at runtime.

View File

@ -14,7 +14,8 @@ const {
if (!process.env.NEXTAUTH_SECRET) throw new Error("Please set NEXTAUTH_SECRET");
if (!process.env.CALENDSO_ENCRYPTION_KEY) throw new Error("Please set CALENDSO_ENCRYPTION_KEY");
const isOrganizationsEnabled =
process.env.ORGANIZATIONS_ENABLED === "1" || process.env.ORGANIZATIONS_ENABLED === "true";
// To be able to use the version in the app without having to import package.json
process.env.NEXT_PUBLIC_CALCOM_VERSION = version;
@ -226,7 +227,7 @@ const nextConfig = {
async rewrites() {
const beforeFiles = [
// These rewrites are other than booking pages rewrites and so that they aren't redirected to org pages ensure that they happen in beforeFiles
...(process.env.ORGANIZATIONS_ENABLED
...(isOrganizationsEnabled
? [
{
...matcherConfigRootPath,
@ -333,44 +334,46 @@ const nextConfig = {
},
],
},
...[
{
...matcherConfigRootPath,
headers: [
...(isOrganizationsEnabled
? [
{
key: "X-Cal-Org-path",
value: "/team/:orgSlug",
...matcherConfigRootPath,
headers: [
{
key: "X-Cal-Org-path",
value: "/team/:orgSlug",
},
],
},
],
},
{
...matcherConfigUserRoute,
headers: [
{
key: "X-Cal-Org-path",
value: "/org/:orgSlug/:user",
...matcherConfigUserRoute,
headers: [
{
key: "X-Cal-Org-path",
value: "/org/:orgSlug/:user",
},
],
},
],
},
{
...matcherConfigUserTypeRoute,
headers: [
{
key: "X-Cal-Org-path",
value: "/org/:orgSlug/:user/:type",
...matcherConfigUserTypeRoute,
headers: [
{
key: "X-Cal-Org-path",
value: "/org/:orgSlug/:user/:type",
},
],
},
],
},
{
...matcherConfigUserTypeEmbedRoute,
headers: [
{
key: "X-Cal-Org-path",
value: "/org/:orgSlug/:user/:type/embed",
...matcherConfigUserTypeEmbedRoute,
headers: [
{
key: "X-Cal-Org-path",
value: "/org/:orgSlug/:user/:type/embed",
},
],
},
],
},
],
]
: []),
];
},
async redirects() {
@ -447,6 +450,13 @@ const nextConfig = {
},
{
source: "/support",
missing: [
{
type: "header",
key: "host",
value: orgHostPath,
},
],
destination: "/event-types?openIntercom=true",
permanent: true,
},
@ -463,7 +473,7 @@ const nextConfig = {
// OAuth callbacks when sent to localhost:3000(w would be expected) should be redirected to corresponding to WEBAPP_URL
...(process.env.NODE_ENV === "development" &&
// Safer to enable the redirect only when the user is opting to test out organizations
process.env.ORGANIZATIONS_ENABLED &&
isOrganizationsEnabled &&
// Prevent infinite redirect by checking that we aren't already on localhost
process.env.NEXT_PUBLIC_WEBAPP_URL !== "http://localhost:3000"
? [

View File

@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.2.6",
"version": "3.2.7.1",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
@ -129,7 +129,7 @@
"turndown": "^7.1.1",
"uuid": "^8.3.2",
"web3": "^1.7.5",
"zod": "^3.20.2"
"zod": "^3.22.2"
},
"devDependencies": {
"@babel/core": "^7.19.6",

View File

@ -104,7 +104,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
});
const attendeesList = await Promise.all(attendeesListPromises);
const selectedDestinationCalendar = booking.destinationCalendar || user.destinationCalendar;
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
@ -127,7 +127,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
attendees: attendeesList,
uid: booking.uid,
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
destinationCalendar: booking.destinationCalendar || user.destinationCalendar,
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
};
await sendOrganizerRequestReminderEmail(evt);

View File

@ -6,6 +6,7 @@ import dayjs from "@calcom/dayjs";
import { DateOverrideInputDialog, DateOverrideList } from "@calcom/features/schedules";
import Schedule from "@calcom/features/schedules/components/Schedule";
import Shell from "@calcom/features/shell/Shell";
import { classNames } from "@calcom/lib";
import { availabilityAsString } from "@calcom/lib/availability";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { HttpError } from "@calcom/lib/http-error";
@ -17,11 +18,6 @@ import {
ConfirmationDialogContent,
Dialog,
DialogTrigger,
Dropdown,
DropdownItem,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
Form,
Label,
showToast,
@ -32,7 +28,7 @@ import {
Tooltip,
VerticalDivider,
} from "@calcom/ui";
import { Info, MoreHorizontal, Plus, Trash } from "@calcom/ui/components/icon";
import { Info, MoreVertical, ArrowLeft, Plus, Trash } from "@calcom/ui/components/icon";
import PageWrapper from "@components/PageWrapper";
import { SelectSkeletonLoader } from "@components/availability/SkeletonLoader";
@ -95,7 +91,7 @@ export default function Availability() {
const scheduleId = searchParams?.get("schedule") ? Number(searchParams.get("schedule")) : -1;
const fromEventType = searchParams?.get("fromEventType");
const { timeFormat } = me.data || { timeFormat: null };
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [openSidebar, setOpenSidebar] = useState(false);
const { data: schedule, isLoading } = trpc.viewer.availability.schedule.get.useQuery(
{ scheduleId },
{
@ -225,33 +221,60 @@ export default function Availability() {
</ConfirmationDialogContent>
</Dialog>
<VerticalDivider className="hidden sm:inline" />
<Dropdown>
<DropdownMenuTrigger asChild>
<Button className="sm:hidden" StartIcon={MoreHorizontal} variant="icon" color="secondary" />
</DropdownMenuTrigger>
<DropdownMenuContent style={{ minWidth: "200px" }}>
<DropdownItem
type="button"
color="destructive"
StartIcon={Trash}
onClick={() => setDeleteDialogOpen(true)}>
{t("delete")}
</DropdownItem>
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<ConfirmationDialogContent
isLoading={deleteMutation.isLoading}
variety="danger"
title={t("delete_schedule")}
confirmBtnText={t("delete")}
loadingText={t("delete")}
onConfirm={() => {
schedule !== undefined && deleteMutation.mutate({ scheduleId: schedule.id });
}}>
{t("delete_schedule_description")}
</ConfirmationDialogContent>
</Dialog>
<DropdownMenuSeparator />
<div className="flex h-9 flex-row items-center justify-between px-4 py-2 hover:bg-gray-100">
<div
className={classNames(
openSidebar
? "fadeIn fixed inset-0 z-50 bg-neutral-800 bg-opacity-70 transition-opacity dark:bg-opacity-70 sm:hidden"
: ""
)}>
<div
className={classNames(
"bg-default fixed right-0 z-20 flex h-screen w-80 flex-col space-y-2 overflow-x-hidden rounded-md px-2 pb-3 transition-transform",
openSidebar ? "translate-x-0 opacity-100" : "translate-x-full opacity-0"
)}>
<div className="flex flex-row items-center pt-5">
<Button StartIcon={ArrowLeft} color="minimal" onClick={() => setOpenSidebar(false)} />
<p className="-ml-2">{t("availability_settings")}</p>
<Dialog>
<DialogTrigger asChild>
<Button
StartIcon={Trash}
variant="icon"
color="destructive"
aria-label={t("delete")}
className="ml-16 inline"
disabled={schedule?.isLastSchedule}
tooltip={schedule?.isLastSchedule ? t("requires_at_least_one_schedule") : t("delete")}
/>
</DialogTrigger>
<ConfirmationDialogContent
isLoading={deleteMutation.isLoading}
variety="danger"
title={t("delete_schedule")}
confirmBtnText={t("delete")}
loadingText={t("delete")}
onConfirm={() => {
scheduleId && deleteMutation.mutate({ scheduleId });
setOpenSidebar(false);
}}>
{t("delete_schedule_description")}
</ConfirmationDialogContent>
</Dialog>
</div>
<div className="flex flex-col px-2 py-2">
<Skeleton as={Label}>{t("name")}</Skeleton>
<Controller
control={form.control}
name="name"
render={({ field }) => (
<input
className="hover:border-emphasis dark:focus:border-emphasis border-default bg-default placeholder:text-muted text-emphasis focus:ring-brand-default disabled:bg-subtle disabled:hover:border-subtle mb-2 block h-9 w-full rounded-md border px-3 py-2 text-sm leading-4 focus:border-neutral-300 focus:outline-none focus:ring-2 disabled:cursor-not-allowed"
{...field}
/>
)}
/>
</div>
<div className="flex h-9 flex-row-reverse items-center justify-end gap-3 px-2">
<Skeleton
as={Label}
htmlFor="hiddenSwitch"
@ -267,9 +290,44 @@ export default function Availability() {
}}
/>
</div>
</DropdownMenuContent>
</Dropdown>
<div className="min-w-40 col-span-3 space-y-2 px-2 py-4 lg:col-span-1">
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
<div>
<Skeleton as={Label} htmlFor="timeZone" className="mb-0 inline-block leading-none">
{t("timezone")}
</Skeleton>
<Controller
control={form.control}
name="timeZone"
render={({ field: { onChange, value } }) =>
value ? (
<TimezoneSelect
value={value}
className="focus:border-brand-default border-default mt-1 block w-72 rounded-md text-sm"
onChange={(timezone) => onChange(timezone.value)}
/>
) : (
<SelectSkeletonLoader className="mt-1 w-72" />
)
}
/>
</div>
<hr className="border-subtle my-7" />
<div className="rounded-md md:block">
<Skeleton as="h3" className="mb-0 inline-block text-sm font-medium">
{t("something_doesnt_look_right")}
</Skeleton>
<div className="mt-3 flex">
<Skeleton as={Button} href="/availability/troubleshoot" color="secondary">
{t("launch_troubleshooter")}
</Skeleton>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="border-default border-l-2" />
<Button
className="ml-4 lg:ml-0"
@ -278,6 +336,13 @@ export default function Availability() {
loading={updateMutation.isLoading}>
{t("save")}
</Button>
<Button
className="ml-3 sm:hidden"
StartIcon={MoreVertical}
variant="icon"
color="secondary"
onClick={() => setOpenSidebar(true)}
/>
</div>
}>
<div className="mt-4 w-full md:mt-0">
@ -313,7 +378,7 @@ export default function Availability() {
{schedule?.workingHours && <DateOverride workingHours={schedule.workingHours} />}
</div>
</div>
<div className="min-w-40 col-span-3 space-y-2 lg:col-span-1">
<div className="min-w-40 col-span-3 hidden space-y-2 md:block lg:col-span-1">
<div className="xl:max-w-80 w-full pr-4 sm:ml-0 sm:mr-36 sm:p-0">
<div>
<Skeleton as={Label} htmlFor="timeZone" className="mb-0 inline-block leading-none">
@ -335,7 +400,7 @@ export default function Availability() {
/>
</div>
<hr className="border-subtle my-6 mr-8" />
<div className="hidden rounded-md md:block">
<div className="rounded-md">
<Skeleton as="h3" className="mb-0 inline-block text-sm font-medium">
{t("something_doesnt_look_right")}
</Skeleton>

View File

@ -55,10 +55,9 @@ import {
Skeleton,
Switch,
Tooltip,
ArrowButton,
} from "@calcom/ui";
import {
ArrowDown,
ArrowUp,
Clipboard,
Code,
Copy,
@ -393,18 +392,10 @@ export const EventTypeList = ({ data }: EventTypeListProps): JSX.Element => {
<div className="hover:bg-muted flex w-full items-center justify-between">
<div className="group flex w-full max-w-full items-center justify-between overflow-hidden px-4 py-4 sm:px-6">
{!(firstItem && firstItem.id === eventType.id) && (
<button
className="bg-default text-muted hover:text-emphasis border-default hover:border-emphasis invisible absolute left-[5px] -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-[36px]"
onClick={() => moveEventType(index, -1)}>
<ArrowUp className="h-5 w-5" />
</button>
<ArrowButton onClick={() => moveEventType(index, -1)} arrowDirection="up" />
)}
{!(lastItem && lastItem.id === eventType.id) && (
<button
className="bg-default text-muted border-default hover:text-emphasis hover:border-emphasis invisible absolute left-[5px] -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-[36px]"
onClick={() => moveEventType(index, 1)}>
<ArrowDown className="h-5 w-5" />
</button>
<ArrowButton onClick={() => moveEventType(index, 1)} arrowDirection="down" />
)}
<MemoizedItem eventType={eventType} />
<div className="mt-4 hidden sm:mt-0 sm:flex">
@ -887,7 +878,7 @@ const Main = ({
{isMobile ? (
<MobileTeamsTab eventTypeGroups={data} />
) : (
<div className="flex flex-col">
<div className="mt-4 flex flex-col" key={group.profile.slug}>
<EventTypeListHeading
profile={data[0].users[0] || data[0].team}
membershipCount={data[0].team?.members.length || 0}

View File

@ -25,7 +25,7 @@ const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel"];
let subdomainRegExp = (exports.subdomainRegExp = getSubdomainRegExp(
process.env.NEXT_PUBLIC_WEBAPP_URL || "https://" + process.env.VERCEL_URL
));
exports.orgHostPath = `^(?<orgSlug>${subdomainRegExp})\\..*`;
exports.orgHostPath = `^(?<orgSlug>${subdomainRegExp})\\.(?!vercel\.app).*`;
let beforeRewriteExcludePages = pages.concat(otherNonExistingRoutePrefixes);
exports.orgUserRoutePath = `/:user((?!${beforeRewriteExcludePages.join("|")}|_next|public)[a-zA-Z0-9\-_]+)`;

View File

@ -268,3 +268,60 @@ test.describe("prefill", () => {
});
});
});
test.describe("Booking on different layouts", () => {
test.beforeEach(async ({ page, users }) => {
const user = await users.create();
await page.goto(`/${user.username}`);
});
test("Book on week layout", async ({ page }) => {
// Click first event type
await page.click('[data-testid="event-type-link"]');
await page.click('[data-testid="toggle-group-item-week_view"]');
await page.click('[data-testid="incrementMonth"]');
await page.locator('[data-testid="calendar-empty-cell"]').nth(0).click();
// Fill what is this meeting about? name email and notes
await page.locator('[name="name"]').fill("Test name");
await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`);
await page.locator('[name="notes"]').fill("Test notes");
await page.click('[data-testid="confirm-book-button"]');
await page.waitForURL((url) => {
return url.pathname.startsWith("/booking");
});
// expect page to be booking page
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
test("Book on column layout", async ({ page }) => {
// Click first event type
await page.click('[data-testid="event-type-link"]');
await page.click('[data-testid="toggle-group-item-column_view"]');
await page.click('[data-testid="incrementMonth"]');
await page.locator('[data-testid="time"]').nth(0).click();
// Fill what is this meeting about? name email and notes
await page.locator('[name="name"]').fill("Test name");
await page.locator('[name="email"]').fill(`${randomString(4)}@example.com`);
await page.locator('[name="notes"]').fill("Test notes");
await page.click('[data-testid="confirm-book-button"]');
await page.waitForURL((url) => {
return url.pathname.startsWith("/booking");
});
// expect page to be booking page
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
});

View File

@ -246,7 +246,7 @@ test.describe("BOOKING_REJECTED", async () => {
},
],
location: "[redacted/dynamic]",
destinationCalendar: null,
destinationCalendar: [],
// hideCalendarNotes: false,
requiresConfirmation: "[redacted/dynamic]",
eventTypeId: "[redacted/dynamic]",

View File

@ -529,7 +529,7 @@
"location": "Ort",
"address": "Adresse",
"enter_address": "Geben Sie eine Adresse ein",
"in_person_attendee_address": "In Person (Adresse von Ihnen)",
"in_person_attendee_address": "Vor Ort (Adresse von Ihnen)",
"yes": "Ja",
"no": "Nein",
"additional_notes": "Zusätzliche Notizen",
@ -539,7 +539,7 @@
"booking_confirmation": "Bestätigen Sie {{eventTypeTitle}} mit {{profileName}}",
"booking_reschedule_confirmation": "Planen Sie Ihr {{eventTypeTitle}} mit {{profileName}} um",
"in_person_meeting": "Vor-Ort-Termin",
"in_person": "Persönlich (Organisator-Adresse)",
"in_person": "Vor Ort (Organisator-Adresse)",
"link_meeting": "Termin verknüpfen",
"phone_number": "Telefonnummer",
"attendee_phone_number": "Telefonnummer",

View File

@ -266,6 +266,7 @@
"nearly_there_instructions": "Last thing, a brief description about you and a photo really helps you get bookings and let people know who theyre booking with.",
"set_availability_instructions": "Define ranges of time when you are available on a recurring basis. You can create more of these later and assign them to different calendars.",
"set_availability": "Set your availability",
"availability_settings": "Availability Settings",
"continue_without_calendar": "Continue without calendar",
"connect_your_calendar": "Connect your calendar",
"connect_your_video_app": "Connect your video apps",
@ -1107,6 +1108,7 @@
"email_attendee_action": "send email to attendees",
"sms_attendee_action": "Send SMS to attendee",
"sms_number_action": "send SMS to a specific number",
"send_reminder_sms": "Easily send meeting reminders via SMS to your attendees",
"whatsapp_number_action": "send WhatsApp message to a specific number",
"whatsapp_attendee_action": "send WhatsApp message to attendee",
"workflows": "Workflows",

View File

@ -266,6 +266,7 @@
"nearly_there_instructions": "Pour finir, une brève description de vous et une photo vous aideront vraiment à obtenir des réservations et à faire savoir aux gens avec qui ils prennent rendez-vous.",
"set_availability_instructions": "Définissez des plages de temps pendant lesquelles vous êtes disponible de manière récurrente. Vous pourrez en créer d'autres ultérieurement et les assigner à différents calendriers.",
"set_availability": "Définissez vos disponibilités",
"availability_settings": "Paramètres de disponibilité",
"continue_without_calendar": "Continuer sans calendrier",
"connect_your_calendar": "Connectez votre calendrier",
"connect_your_video_app": "Connectez vos applications vidéo",
@ -1107,6 +1108,7 @@
"email_attendee_action": "envoyer un e-mail aux participants",
"sms_attendee_action": "Envoyer un SMS au participant",
"sms_number_action": "envoyer un SMS à un numéro spécifique",
"send_reminder_sms": "Envoyez facilement des rappels de rendez-vous par SMS à vos participants",
"whatsapp_number_action": "envoyer un message WhatsApp à un numéro spécifique",
"whatsapp_attendee_action": "envoyer un message WhatsApp au participant",
"workflows": "Workflows",

View File

@ -58,10 +58,10 @@
--cal-bg-inverted: #f3f4f6;
/* background -> components*/
--cal-bg-info: #dee9fc;
--cal-bg-success: #e2fbe8;
--cal-bg-attention: #fceed8;
--cal-bg-error: #f9e3e2;
--cal-bg-info: #263fa9;
--cal-bg-success: #306339;
--cal-bg-attention: #8e3b1f;
--cal-bg-error: #8c2822;
--cal-bg-dark-error: #752522;
/* Borders */
@ -80,10 +80,10 @@
--cal-text-inverted: #101010;
/* Content/Text -> components */
--cal-text-info: #253985;
--cal-text-success: #285231;
--cal-text-attention: #73321b;
--cal-text-error: #752522;
--cal-text-info: #dee9fc;
--cal-text-success: #e2fbe8;
--cal-text-attention: #fceed8;
--cal-text-error: #f9e3e2;
/* Brand shenanigans
-> These will be computed for the users theme at runtime.

View File

@ -31,7 +31,7 @@ beforeAll(async () => {
describe("next.config.js - Org Rewrite", () => {
const orgHostRegExp = (subdomainRegExp: string) =>
// RegExp copied from pagesAndRewritePaths.js orgHostPath. Do make the change there as well.
new RegExp(`^(?<orgSlug>${subdomainRegExp})\\..*`);
new RegExp(`^(?<orgSlug>${subdomainRegExp})\\.(?!vercel\.app).*`);
describe("Host matching based on NEXT_PUBLIC_WEBAPP_URL", () => {
it("https://app.cal.com", () => {
@ -87,6 +87,11 @@ describe("next.config.js - Org Rewrite", () => {
?.orgSlug
).toEqual("some-other");
});
it("Should ignore Vercel preview URLs", () => {
const subdomainRegExp = getSubdomainRegExp("https://cal-xxxxxxxx-cal.vercel.app");
expect(orgHostRegExp(subdomainRegExp).exec("https://cal-xxxxxxxx-cal.vercel.app")).toMatchInlineSnapshot('null')
expect(orgHostRegExp(subdomainRegExp).exec("cal-xxxxxxxx-cal.vercel.app")).toMatchInlineSnapshot('null')
});
});
describe("Rewrite", () => {

View File

@ -84,7 +84,7 @@ export default class GoogleCalendarService implements Calendar {
};
};
async createEvent(calEventRaw: CalendarEvent): Promise<NewCalendarEventType> {
async createEvent(calEventRaw: CalendarEvent, credentialId: number): Promise<NewCalendarEventType> {
const eventAttendees = calEventRaw.attendees.map(({ id: _id, ...rest }) => ({
...rest,
responseStatus: "accepted",
@ -97,6 +97,10 @@ export default class GoogleCalendarService implements Calendar {
responseStatus: "accepted",
})) || [];
return new Promise(async (resolve, reject) => {
const [mainHostDestinationCalendar] =
calEventRaw?.destinationCalendar && calEventRaw?.destinationCalendar.length > 0
? calEventRaw.destinationCalendar
: [];
const myGoogleAuth = await this.auth.getToken();
const payload: calendar_v3.Schema$Event = {
summary: calEventRaw.title,
@ -115,8 +119,8 @@ export default class GoogleCalendarService implements Calendar {
id: String(calEventRaw.organizer.id),
responseStatus: "accepted",
organizer: true,
email: calEventRaw.destinationCalendar?.externalId
? calEventRaw.destinationCalendar.externalId
email: mainHostDestinationCalendar?.externalId
? mainHostDestinationCalendar.externalId
: calEventRaw.organizer.email,
},
...eventAttendees,
@ -138,13 +142,16 @@ export default class GoogleCalendarService implements Calendar {
const calendar = google.calendar({
version: "v3",
});
const selectedCalendar = calEventRaw.destinationCalendar?.externalId
? calEventRaw.destinationCalendar.externalId
: "primary";
// Find in calEventRaw.destinationCalendar the one with the same credentialId
const selectedCalendar = calEventRaw.destinationCalendar?.find(
(cal) => cal.credentialId === credentialId
)?.externalId;
calendar.events.insert(
{
auth: myGoogleAuth,
calendarId: selectedCalendar,
calendarId: selectedCalendar || "primary",
requestBody: payload,
conferenceDataVersion: 1,
sendUpdates: "none",
@ -188,6 +195,8 @@ export default class GoogleCalendarService implements Calendar {
async updateEvent(uid: string, event: CalendarEvent, externalCalendarId: string): Promise<any> {
return new Promise(async (resolve, reject) => {
const [mainHostDestinationCalendar] =
event?.destinationCalendar && event?.destinationCalendar.length > 0 ? event.destinationCalendar : [];
const myGoogleAuth = await this.auth.getToken();
const eventAttendees = event.attendees.map(({ ...rest }) => ({
...rest,
@ -216,8 +225,8 @@ export default class GoogleCalendarService implements Calendar {
id: String(event.organizer.id),
organizer: true,
responseStatus: "accepted",
email: event.destinationCalendar?.externalId
? event.destinationCalendar.externalId
email: mainHostDestinationCalendar?.externalId
? mainHostDestinationCalendar.externalId
: event.organizer.email,
},
...(eventAttendees as any),
@ -244,7 +253,7 @@ export default class GoogleCalendarService implements Calendar {
const selectedCalendar = externalCalendarId
? externalCalendarId
: event.destinationCalendar?.externalId;
: event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
calendar.events.update(
{
@ -303,7 +312,9 @@ export default class GoogleCalendarService implements Calendar {
});
const defaultCalendarId = "primary";
const calendarId = externalCalendarId ? externalCalendarId : event.destinationCalendar?.externalId;
const calendarId = externalCalendarId
? externalCalendarId
: event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
calendar.events.delete(
{

View File

@ -125,7 +125,8 @@ export default class LarkCalendarService implements Calendar {
async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
let eventId = "";
let eventRespData;
const calendarId = event.destinationCalendar?.externalId;
const [mainHostDestinationCalendar] = event.destinationCalendar ?? [];
const calendarId = mainHostDestinationCalendar?.externalId;
if (!calendarId) {
throw new Error("no calendar id");
}
@ -160,7 +161,8 @@ export default class LarkCalendarService implements Calendar {
}
private createAttendees = async (event: CalendarEvent, eventId: string) => {
const calendarId = event.destinationCalendar?.externalId;
const [mainHostDestinationCalendar] = event.destinationCalendar ?? [];
const calendarId = mainHostDestinationCalendar?.externalId;
if (!calendarId) {
this.log.error("no calendar id provided in createAttendees");
throw new Error("no calendar id provided in createAttendees");
@ -187,7 +189,8 @@ export default class LarkCalendarService implements Calendar {
async updateEvent(uid: string, event: CalendarEvent, externalCalendarId?: string) {
const eventId = uid;
let eventRespData;
const calendarId = externalCalendarId || event.destinationCalendar?.externalId;
const [mainHostDestinationCalendar] = event.destinationCalendar ?? [];
const calendarId = externalCalendarId || mainHostDestinationCalendar?.externalId;
if (!calendarId) {
this.log.error("no calendar id provided in updateEvent");
throw new Error("no calendar id provided in updateEvent");
@ -231,7 +234,8 @@ export default class LarkCalendarService implements Calendar {
* @returns
*/
async deleteEvent(uid: string, event: CalendarEvent, externalCalendarId?: string) {
const calendarId = externalCalendarId || event.destinationCalendar?.externalId;
const [mainHostDestinationCalendar] = event.destinationCalendar ?? [];
const calendarId = externalCalendarId || mainHostDestinationCalendar?.externalId;
if (!calendarId) {
this.log.error("no calendar id provided in deleteEvent");
throw new Error("no calendar id provided in deleteEvent");

View File

@ -70,9 +70,10 @@ export default class Office365CalendarService implements Calendar {
}
async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
const [mainHostDestinationCalendar] = event.destinationCalendar ?? [];
try {
const eventsUrl = event.destinationCalendar?.externalId
? `/me/calendars/${event.destinationCalendar?.externalId}/events`
const eventsUrl = mainHostDestinationCalendar?.externalId
? `/me/calendars/${mainHostDestinationCalendar?.externalId}/events`
: "/me/calendar/events";
const response = await this.fetcher(eventsUrl, {

View File

@ -4,7 +4,6 @@ import z from "zod";
import Paypal from "@calcom/app-store/paypal/lib/Paypal";
import { findPaymentCredentials } from "@calcom/features/ee/payments/api/paypal-webhook";
import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import prisma from "@calcom/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
@ -78,12 +77,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
return;
} catch (_err) {
const err = getErrorFromUnknown(_err);
res.status(200).send({
message: err.message,
stack: IS_PRODUCTION ? undefined : err.stack,
});
res.redirect(`/booking/${req.query.bookingUid}?paypalPaymentStatus=failed`);
}
}

View File

@ -19,7 +19,7 @@ class Paypal {
}
private fetcher = async (endpoint: string, init?: RequestInit | undefined) => {
this.getAccessToken();
await this.getAccessToken();
return fetch(`${this.url}${endpoint}`, {
method: "get",
...init,
@ -173,7 +173,7 @@ class Paypal {
}
} catch (error) {
console.error(error);
return false;
throw error;
}
return false;
}

View File

@ -41,25 +41,6 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
}}
/>
</div>
<TextField
name="Plausible URL"
defaultValue="https://plausible.io/js/script.js"
placeholder="https://plausible.io/js/script.js"
value={plausibleUrl}
disabled={disabled}
onChange={(e) => {
setAppData("PLAUSIBLE_URL", e.target.value);
}}
/>
<TextField
disabled={disabled}
name="Tracked Domain"
placeholder="yourdomain.com"
value={trackingId}
onChange={(e) => {
setAppData("trackingId", e.target.value);
}}
/>
</AppCard>
);
};

View File

@ -7,7 +7,7 @@
"description": "It would allow a booker to connect with the right person or choose the right event, faster. It would work by taking inputs from the booker and using that data to route to the correct booker/event as configured by Cal user ",
"dependencies": {
"@calcom/lib": "*",
"dotenv": "^16.0.1",
"dotenv": "^16.3.1",
"json-logic-js": "^2.0.2",
"react-awesome-query-builder": "^5.1.2"
},

View File

@ -1,4 +1,5 @@
// TODO: i18n
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useEffect } from "react";
import { useFormContext } from "react-hook-form";
@ -30,6 +31,7 @@ import {
List,
ListLinkItem,
Tooltip,
ArrowButton,
} from "@calcom/ui";
import {
BarChart,
@ -83,6 +85,20 @@ export default function RoutingForms({
const { hasPaidPlan } = useHasPaidPlan();
const routerQuery = useRouterQuery();
const hookForm = useFormContext<RoutingFormWithResponseCount>();
const utils = trpc.useContext();
const [parent] = useAutoAnimate<HTMLUListElement>();
const mutation = trpc.viewer.routingFormOrder.useMutation({
onError: async (err) => {
console.error(err.message);
await utils.viewer.appRoutingForms.forms.cancel();
await utils.viewer.appRoutingForms.invalidate();
},
onSettled: () => {
utils.viewer.appRoutingForms.invalidate();
},
});
useEffect(() => {
hookForm.reset({});
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -128,6 +144,29 @@ export default function RoutingForms({
},
];
async function moveRoutingForm(index: number, increment: 1 | -1) {
const types = forms?.map((type) => {
return type.form;
});
if (types?.length) {
const newList = [...types];
const type = types[index];
const tmp = types[index + increment];
if (tmp) {
newList[index] = tmp;
newList[index + increment] = type;
}
await utils.viewer.appRoutingForms.forms.cancel();
mutation.mutate({
ids: newList?.map((type) => type.id),
});
}
}
return (
<LicenseRequired>
<ShellMain
@ -177,8 +216,8 @@ export default function RoutingForms({
}
SkeletonLoader={SkeletonLoaderTeamList}>
<div className="bg-default mb-16 overflow-hidden">
<List data-testid="routing-forms-list">
{forms?.map(({ form, readOnly }) => {
<List data-testid="routing-forms-list" ref={parent}>
{forms?.map(({ form, readOnly }, index) => {
if (!form) {
return null;
}
@ -187,116 +226,129 @@ export default function RoutingForms({
form.routes = form.routes || [];
const fields = form.fields || [];
const userRoutes = form.routes.filter((route) => !isFallbackRoute(route));
const firstItem = forms[0].form;
const lastItem = forms[forms.length - 1].form;
return (
<ListLinkItem
key={form.id}
href={appUrl + "/form-edit/" + form.id}
heading={form.name}
disabled={readOnly}
subHeading={description}
className="space-x-2 rtl:space-x-reverse"
actions={
<>
{form.team?.name && (
<div className="border-r-2 border-neutral-300">
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
{form.team.name}
</Badge>
</div>
)}
<FormAction
disabled={readOnly}
className="self-center"
action="toggle"
routingForm={form}
/>
<ButtonGroup combined>
<Tooltip content={t("preview")}>
<div
className="group flex w-full max-w-full items-center justify-between overflow-hidden"
key={form.id}>
{!(firstItem && firstItem.id === form.id) && (
<ArrowButton onClick={() => moveRoutingForm(index, -1)} arrowDirection="up" />
)}
{!(lastItem && lastItem.id === form.id) && (
<ArrowButton onClick={() => moveRoutingForm(index, 1)} arrowDirection="down" />
)}
<ListLinkItem
href={appUrl + "/form-edit/" + form.id}
heading={form.name}
disabled={readOnly}
subHeading={description}
className="space-x-2 rtl:space-x-reverse"
actions={
<>
{form.team?.name && (
<div className="border-r-2 border-neutral-300">
<Badge className="ltr:mr-2 rtl:ml-2" variant="gray">
{form.team.name}
</Badge>
</div>
)}
<FormAction
disabled={readOnly}
className="self-center"
action="toggle"
routingForm={form}
/>
<ButtonGroup combined>
<Tooltip content={t("preview")}>
<FormAction
action="preview"
routingForm={form}
target="_blank"
StartIcon={ExternalLink}
color="secondary"
variant="icon"
/>
</Tooltip>
<FormAction
action="preview"
routingForm={form}
target="_blank"
StartIcon={ExternalLink}
action="copyLink"
color="secondary"
variant="icon"
StartIcon={LinkIcon}
tooltip={t("copy_link_to_form")}
/>
</Tooltip>
<FormAction
routingForm={form}
action="copyLink"
color="secondary"
variant="icon"
StartIcon={LinkIcon}
tooltip={t("copy_link_to_form")}
/>
<FormAction
routingForm={form}
action="embed"
color="secondary"
variant="icon"
StartIcon={Code}
tooltip={t("embed")}
/>
<FormActionsDropdown disabled={readOnly}>
<FormAction
action="edit"
routingForm={form}
color="minimal"
className="!flex"
StartIcon={Edit}>
{t("edit")}
</FormAction>
<FormAction
action="download"
routingForm={form}
color="minimal"
StartIcon={Download}>
{t("download_responses")}
</FormAction>
<FormAction
action="duplicate"
routingForm={form}
color="minimal"
className="w-full"
StartIcon={Copy}>
{t("duplicate")}
</FormAction>
{typeformApp?.isInstalled ? (
action="embed"
color="secondary"
variant="icon"
StartIcon={Code}
tooltip={t("embed")}
/>
<FormActionsDropdown disabled={readOnly}>
<FormAction
data-testid="copy-redirect-url"
action="edit"
routingForm={form}
action="copyRedirectUrl"
color="minimal"
type="button"
StartIcon={LinkIcon}>
{t("Copy Typeform Redirect Url")}
className="!flex"
StartIcon={Edit}>
{t("edit")}
</FormAction>
) : null}
<FormAction
action="_delete"
routingForm={form}
color="destructive"
className="w-full"
StartIcon={Trash}>
{t("delete")}
</FormAction>
</FormActionsDropdown>
</ButtonGroup>
</>
}>
<div className="flex flex-wrap gap-1">
<Badge variant="gray" startIcon={Menu}>
{fields.length} {fields.length === 1 ? "field" : "fields"}
</Badge>
<Badge variant="gray" startIcon={GitMerge}>
{userRoutes.length} {userRoutes.length === 1 ? "route" : "routes"}
</Badge>
<Badge variant="gray" startIcon={MessageCircle}>
{form._count.responses}{" "}
{form._count.responses === 1 ? "response" : "responses"}
</Badge>
</div>
</ListLinkItem>
<FormAction
action="download"
routingForm={form}
color="minimal"
StartIcon={Download}>
{t("download_responses")}
</FormAction>
<FormAction
action="duplicate"
routingForm={form}
color="minimal"
className="w-full"
StartIcon={Copy}>
{t("duplicate")}
</FormAction>
{typeformApp?.isInstalled ? (
<FormAction
data-testid="copy-redirect-url"
routingForm={form}
action="copyRedirectUrl"
color="minimal"
type="button"
StartIcon={LinkIcon}>
{t("Copy Typeform Redirect Url")}
</FormAction>
) : null}
<FormAction
action="_delete"
routingForm={form}
color="destructive"
className="w-full"
StartIcon={Trash}>
{t("delete")}
</FormAction>
</FormActionsDropdown>
</ButtonGroup>
</>
}>
<div className="flex flex-wrap gap-1">
<Badge variant="gray" startIcon={Menu}>
{fields.length} {fields.length === 1 ? "field" : "fields"}
</Badge>
<Badge variant="gray" startIcon={GitMerge}>
{userRoutes.length} {userRoutes.length === 1 ? "route" : "routes"}
</Badge>
<Badge variant="gray" startIcon={MessageCircle}>
{form._count.responses}{" "}
{form._count.responses === 1 ? "response" : "responses"}
</Badge>
</div>
</ListLinkItem>
</div>
);
})}
</List>

View File

@ -21,7 +21,7 @@ test.describe("Routing Forms", () => {
await page.waitForSelector('[data-testid="routing-forms-list"]');
// Ensure that it's visible in forms list
expect(await page.locator('[data-testid="routing-forms-list"] > li').count()).toBe(1);
expect(await page.locator('[data-testid="routing-forms-list"] > div').count()).toBe(1);
await gotoRoutingLink({ page, formId });
await expect(page.locator("text=Test Form Name")).toBeVisible();

View File

@ -59,6 +59,7 @@ export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOpt
fields: true,
settings: true,
teamId: true,
position: true,
},
});

View File

@ -26,9 +26,14 @@ export const formsHandler = async ({ ctx, input }: FormsHandlerOptions) => {
const forms = await prisma.app_RoutingForms_Form.findMany({
where,
orderBy: {
createdAt: "desc",
},
orderBy: [
{
position: "desc",
},
{
createdAt: "asc",
},
],
include: {
team: {
include: {

View File

@ -50,7 +50,8 @@ export class PaymentService implements IAbstractPaymentService {
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
bookingId: Booking["id"],
bookerEmail: string,
paymentOption: PaymentOption
paymentOption: PaymentOption,
eventTitle?: string
) {
try {
// Ensure that the payment service can support the passed payment option
@ -78,6 +79,12 @@ export class PaymentService implements IAbstractPaymentService {
currency: this.credentials.default_currency,
payment_method_types: ["card"],
customer: customer.id,
metadata: {
identifier: "cal.com",
bookingId,
bookerEmail,
eventName: eventTitle || "",
},
};
const paymentIntent = await this.stripe.paymentIntents.create(params, {

View File

@ -21,7 +21,7 @@
"@stripe/stripe-js": "^1.35.0",
"stripe": "^9.16.0",
"uuid": "^8.3.2",
"zod": "^3.20.2"
"zod": "^3.22.2"
},
"devDependencies": {
"@calcom/types": "*",

View File

@ -217,7 +217,8 @@ export const getBusyCalendarTimes = async (
export const createEvent = async (
credential: CredentialWithAppName,
calEvent: CalendarEvent
calEvent: CalendarEvent,
externalId?: string
): Promise<EventResult<NewCalendarEventType>> => {
const uid: string = getUid(calEvent);
const calendar = await getCalendar(credential);
@ -226,29 +227,31 @@ export const createEvent = async (
// Check if the disabledNotes flag is set to true
if (calEvent.hideCalendarNotes) {
calEvent.additionalNotes = "Notes have been hidden by the organiser"; // TODO: i18n this string?
calEvent.additionalNotes = "Notes have been hidden by the organizer"; // TODO: i18n this string?
}
// TODO: Surface success/error messages coming from apps to improve end user visibility
const creationResult = calendar
? await calendar.createEvent(calEvent).catch(async (error: { code: number; calError: string }) => {
success = false;
/**
* There is a time when selectedCalendar externalId doesn't match witch certain credential
* so google returns 404.
* */
if (error?.code === 404) {
? await calendar
.createEvent(calEvent, credential.id)
.catch(async (error: { code: number; calError: string }) => {
success = false;
/**
* There is a time when selectedCalendar externalId doesn't match witch certain credential
* so google returns 404.
* */
if (error?.code === 404) {
return undefined;
}
if (error?.calError) {
calError = error.calError;
}
log.error("createEvent failed", JSON.stringify(error), calEvent);
// @TODO: This code will be off till we can investigate an error with it
//https://github.com/calcom/cal.com/issues/3949
// await sendBrokenIntegrationEmail(calEvent, "calendar");
return undefined;
}
if (error?.calError) {
calError = error.calError;
}
log.error("createEvent failed", JSON.stringify(error), calEvent);
// @TODO: This code will be off till we can investigate an error with it
//https://github.com/calcom/cal.com/issues/3949
// await sendBrokenIntegrationEmail(calEvent, "calendar");
return undefined;
})
})
: undefined;
return {
@ -261,6 +264,8 @@ export const createEvent = async (
originalEvent: calEvent,
calError,
calWarnings: creationResult?.additionalInfo?.calWarnings || [],
externalId,
credentialId: credential.id,
};
};

View File

@ -114,7 +114,9 @@ export default class EventManager {
}
// Fallback to Cal Video if Google Meet is selected w/o a Google Cal
if (evt.location === MeetLocationType && evt.destinationCalendar?.integration !== "google_calendar") {
// @NOTE: destinationCalendar it's an array now so as a fallback we will only check the first one
const [mainHostDestinationCalendar] = evt.destinationCalendar ?? [];
if (evt.location === MeetLocationType && mainHostDestinationCalendar.integration !== "google_calendar") {
evt["location"] = "integrations:daily";
}
const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null;
@ -164,8 +166,8 @@ export default class EventManager {
meetingId: createdEventObj ? createdEventObj.id : result.createdEvent?.id?.toString(),
meetingPassword: createdEventObj ? createdEventObj.password : result.createdEvent?.password,
meetingUrl: createdEventObj ? createdEventObj.onlineMeetingUrl : result.createdEvent?.url,
externalCalendarId: isCalendarType ? evt.destinationCalendar?.externalId : undefined,
credentialId: isCalendarType ? evt.destinationCalendar?.credentialId : result.credentialId,
externalCalendarId: isCalendarType ? result.externalId : undefined,
credentialId: isCalendarType ? result.credentialId : undefined,
};
});
@ -203,8 +205,8 @@ export default class EventManager {
meetingId: result.createdEvent?.id?.toString(),
meetingPassword: result.createdEvent?.password,
meetingUrl: result.createdEvent?.url,
externalCalendarId: evt.destinationCalendar?.externalId,
credentialId: result.credentialId ?? evt.destinationCalendar?.credentialId,
externalCalendarId: result.externalId,
credentialId: result.credentialId ?? undefined,
};
});
@ -332,29 +334,52 @@ export default class EventManager {
* @private
*/
private async createAllCalendarEvents(event: CalendarEvent) {
/** Can I use destinationCalendar here? */
/* How can I link a DC to a cred? */
let createdEvents: EventResult<NewCalendarEventType>[] = [];
if (event.destinationCalendar) {
if (event.destinationCalendar.credentialId) {
const credential = this.calendarCredentials.find(
(c) => c.id === event.destinationCalendar?.credentialId
);
if (credential) {
const createdEvent = await createEvent(credential, event);
if (createdEvent) {
createdEvents.push(createdEvent);
if (event.destinationCalendar && event.destinationCalendar.length > 0) {
for (const destination of event.destinationCalendar) {
if (destination.credentialId) {
let credential = this.calendarCredentials.find((c) => c.id === destination.credentialId);
if (!credential) {
// Fetch credential from DB
const credentialFromDB = await prisma.credential.findUnique({
include: {
app: {
select: {
slug: true,
},
},
},
where: {
id: destination.credentialId,
},
});
if (credentialFromDB && credentialFromDB.app?.slug) {
credential = {
appName: credentialFromDB?.app.slug ?? "",
id: credentialFromDB.id,
type: credentialFromDB.type,
key: credentialFromDB.key,
userId: credentialFromDB.userId,
teamId: credentialFromDB.teamId,
invalid: credentialFromDB.invalid,
appId: credentialFromDB.appId,
};
}
}
if (credential) {
const createdEvent = await createEvent(credential, event, destination.externalId);
if (createdEvent) {
createdEvents.push(createdEvent);
}
}
} else {
const destinationCalendarCredentials = this.calendarCredentials.filter(
(c) => c.type === destination.integration
);
createdEvents = createdEvents.concat(
await Promise.all(destinationCalendarCredentials.map(async (c) => await createEvent(c, event)))
);
}
} else {
const destinationCalendarCredentials = this.calendarCredentials.filter(
(c) => c.type === event.destinationCalendar?.integration
);
createdEvents = createdEvents.concat(
await Promise.all(destinationCalendarCredentials.map(async (c) => await createEvent(c, event)))
);
}
} else {
/**
@ -451,7 +476,7 @@ export default class EventManager {
booking: PartialBooking,
newBookingId?: number
): Promise<Array<EventResult<NewCalendarEventType>>> {
let calendarReference: PartialReference | undefined = undefined,
let calendarReference: PartialReference[] | undefined = undefined,
credential;
try {
// If a newBookingId is given, update that calendar event
@ -468,33 +493,62 @@ export default class EventManager {
}
calendarReference = newBooking?.references.length
? newBooking.references.find((reference) => reference.type.includes("_calendar"))
: booking.references.find((reference) => reference.type.includes("_calendar"));
? newBooking.references.filter((reference) => reference.type.includes("_calendar"))
: booking.references.filter((reference) => reference.type.includes("_calendar"));
if (!calendarReference) {
if (calendarReference.length === 0) {
return [];
}
const { uid: bookingRefUid, externalCalendarId: bookingExternalCalendarId } = calendarReference;
let calenderExternalId: string | null = null;
if (bookingExternalCalendarId) {
calenderExternalId = bookingExternalCalendarId;
}
// process all calendar references
let result = [];
if (calendarReference.credentialId) {
credential = this.calendarCredentials.filter(
(credential) => credential.id === calendarReference?.credentialId
)[0];
result.push(updateEvent(credential, event, bookingRefUid, calenderExternalId));
} else {
const credentials = this.calendarCredentials.filter(
(credential) => credential.type === calendarReference?.type
);
for (const credential of credentials) {
for (const reference of calendarReference) {
const { uid: bookingRefUid, externalCalendarId: bookingExternalCalendarId } = reference;
let calenderExternalId: string | null = null;
if (bookingExternalCalendarId) {
calenderExternalId = bookingExternalCalendarId;
}
if (reference.credentialId) {
credential = this.calendarCredentials.filter(
(credential) => credential.id === reference?.credentialId
)[0];
if (!credential) {
// Fetch credential from DB
const credentialFromDB = await prisma.credential.findUnique({
include: {
app: {
select: {
slug: true,
},
},
},
where: {
id: reference.credentialId,
},
});
if (credentialFromDB && credentialFromDB.app?.slug) {
credential = {
appName: credentialFromDB?.app.slug ?? "",
id: credentialFromDB.id,
type: credentialFromDB.type,
key: credentialFromDB.key,
userId: credentialFromDB.userId,
teamId: credentialFromDB.teamId,
invalid: credentialFromDB.invalid,
appId: credentialFromDB.appId,
};
}
}
result.push(updateEvent(credential, event, bookingRefUid, calenderExternalId));
} else {
const credentials = this.calendarCredentials.filter(
(credential) => credential.type === reference?.type
);
for (const credential of credentials) {
result.push(updateEvent(credential, event, bookingRefUid, calenderExternalId));
}
}
}
// If we are merging two calendar events we should delete the old calendar event
if (newBookingId) {
const oldCalendarEvent = booking.references.find((reference) => reference.type.includes("_calendar"));
@ -516,17 +570,17 @@ export default class EventManager {
.filter((cred) => cred.type.includes("other_calendar"))
.map(async (cred) => {
const calendarReference = booking.references.find((ref) => ref.type === cred.type);
if (!calendarReference)
if (!calendarReference) {
return {
appName: cred.appName,
type: cred.type,
success: false,
uid: "",
originalEvent: event,
credentialId: cred.id,
};
}
if (!calendarReference) {
return {
appName: cred.appName,
type: cred.type,
success: false,
uid: "",
originalEvent: event,
credentialId: cred.id,
};
}
const { externalCalendarId: bookingExternalCalendarId, meetingId: bookingRefUid } =
calendarReference;
return await updateEvent(cred, event, bookingRefUid ?? null, bookingExternalCalendarId ?? null);
@ -539,17 +593,19 @@ export default class EventManager {
if (error instanceof Error) {
message = message.replace("{thing}", error.message);
}
console.error(message);
return Promise.resolve([
{
appName: "none",
type: calendarReference?.type || "calendar",
success: false,
uid: "",
originalEvent: event,
credentialId: 0,
},
]);
return Promise.resolve(
calendarReference?.map((reference) => {
return {
appName: "none",
type: reference?.type || "calendar",
success: false,
uid: "",
originalEvent: event,
credentialId: 0,
};
}) ?? ([] as Array<EventResult<NewCalendarEventType>>)
);
}
}

View File

@ -1,4 +1,5 @@
import { Prisma, Booking } from "@prisma/client";
import type { Booking } from "@prisma/client";
import { Prisma } from "@prisma/client";
import short from "short-uuid";
import { v5 as uuidv5 } from "uuid";

View File

@ -23,7 +23,7 @@ class CalendarEventClass implements CalendarEvent {
uid?: string | null;
videoCallData?: VideoCallData;
paymentInfo?: any;
destinationCalendar?: DestinationCalendar | null;
destinationCalendar?: DestinationCalendar[] | null;
cancellationReason?: string | null;
rejectionReason?: string | null;
hideCalendarNotes?: boolean;

View File

@ -85,8 +85,9 @@ export const BrokenIntegrationEmail = (
if (type === "calendar") {
// The calendar name is stored as name_calendar
let calendar = calEvent.destinationCalendar
? calEvent.destinationCalendar?.integration.split("_")
const [mainHostDestinationCalendar] = calEvent.destinationCalendar ?? [];
let calendar = mainHostDestinationCalendar
? mainHostDestinationCalendar?.integration.split("_")
: "calendar";
if (Array.isArray(calendar)) {

View File

@ -387,7 +387,7 @@ export const AUTH_OPTIONS: AuthOptions = {
if (trigger === "update") {
return {
...token,
locale: session?.locale ?? token.locale,
locale: session?.locale ?? token.locale ?? "en",
name: session?.name ?? token.name,
username: session?.username ?? token.username,
email: session?.email ?? token.email,

View File

@ -1,5 +1,6 @@
import { shallow } from "zustand/shallow";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { default as DatePickerComponent } from "@calcom/features/calendars/DatePicker";
import { useNonEmptyScheduleDays } from "@calcom/features/schedules";
@ -23,8 +24,13 @@ export const DatePicker = () => {
return (
<DatePickerComponent
isLoading={schedule.isLoading}
onChange={(date) => setSelectedDate(date ? date.format("YYYY-MM-DD") : date)}
onMonthChange={(date) => setMonth(date.format("YYYY-MM"))}
onChange={(date: Dayjs | null) => {
setSelectedDate(date === null ? date : date.format("YYYY-MM-DD"));
}}
onMonthChange={(date: Dayjs) => {
setMonth(date.format("YYYY-MM"));
setSelectedDate(date.format("YYYY-MM-DD"));
}}
includedDates={nonEmptyScheduleDays}
locale={i18n.language}
browsingDate={month ? dayjs(month) : undefined}

View File

@ -154,14 +154,19 @@ export const useBookerStore = create<BookerStore>((set, get) => ({
},
selectedDate: getQueryParam("date") || null,
setSelectedDate: (selectedDate: string | null) => {
// unset selected date
if (!selectedDate) {
removeQueryParam("date");
return;
}
const currentSelection = dayjs(get().selectedDate);
const newSelection = dayjs(selectedDate);
set({ selectedDate });
updateQueryParam("date", selectedDate ?? "");
// Setting month make sure small calendar in fullscreen layouts also updates.
// If selectedDate is null, prevents setting month to Invalid-Date
if (selectedDate && newSelection.month() !== currentSelection.month()) {
if (newSelection.month() !== currentSelection.month()) {
set({ month: newSelection.format("YYYY-MM") });
updateQueryParam("month", newSelection.format("YYYY-MM"));
}
@ -194,6 +199,7 @@ export const useBookerStore = create<BookerStore>((set, get) => ({
setMonth: (month: string | null) => {
set({ month, selectedTimeslot: null });
updateQueryParam("month", month ?? "");
get().setSelectedDate(null);
},
isTeamEvent: false,
seatedEventData: {

View File

@ -1,3 +1,5 @@
import { usePathname } from "next/navigation";
import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains";
import { CAL_URL, WEBAPP_URL } from "@calcom/lib/constants";
import { SchedulingType } from "@calcom/prisma/enums";
@ -26,6 +28,7 @@ type Avatar = {
type AvatarWithRequiredImage = Avatar & { image: string };
export const EventMembers = ({ schedulingType, users, profile, entity }: EventMembersProps) => {
const pathname = usePathname();
const showMembers = schedulingType !== SchedulingType.ROUND_ROBIN;
const shownUsers = showMembers ? users : [];
@ -57,7 +60,9 @@ export const EventMembers = ({ schedulingType, users, profile, entity }: EventMe
title: `${profile.name || profile.username}`,
image: "logo" in profile && profile.logo ? `${profile.logo}` : undefined,
alt: profile.name || undefined,
href: profile.username ? `${CAL_URL}/${profile.username}` : undefined,
href: profile.username
? `${CAL_URL}` + (pathname.indexOf("/team/") !== -1 ? "/team" : "") + `/${profile.username}`
: undefined,
});
const uniqueAvatars = avatars

View File

@ -248,7 +248,11 @@ async function handler(req: CustomRequest) {
? parseRecurringEvent(bookingToDelete.eventType?.recurringEvent)
: undefined,
location: bookingToDelete?.location,
destinationCalendar: bookingToDelete?.destinationCalendar || bookingToDelete?.user.destinationCalendar,
destinationCalendar: bookingToDelete?.destinationCalendar
? [bookingToDelete?.destinationCalendar]
: bookingToDelete?.user.destinationCalendar
? [bookingToDelete?.user.destinationCalendar]
: [],
cancellationReason: cancellationReason,
...(teamMembers && { team: { name: "", members: teamMembers } }),
seatsPerTimeSlot: bookingToDelete.eventType?.seatsPerTimeSlot,
@ -411,58 +415,71 @@ async function handler(req: CustomRequest) {
const apiDeletes = [];
const bookingCalendarReference = bookingToDelete.references.find((reference) =>
const bookingCalendarReference = bookingToDelete.references.filter((reference) =>
reference.type.includes("_calendar")
);
if (bookingCalendarReference) {
const { credentialId, uid, externalCalendarId } = bookingCalendarReference;
// If the booking calendar reference contains a credentialId
if (credentialId) {
// Find the correct calendar credential under user credentials
const calendarCredential = bookingToDelete.user.credentials.find(
(credential) => credential.id === credentialId
);
if (calendarCredential) {
const calendar = await getCalendar(calendarCredential);
if (
bookingToDelete.eventType?.recurringEvent &&
bookingToDelete.recurringEventId &&
allRemainingBookings
) {
const promises = bookingToDelete.user.credentials
.filter((credential) => credential.type.endsWith("_calendar"))
.map(async (credential) => {
const calendar = await getCalendar(credential);
for (const updBooking of updatedBookings) {
const bookingRef = updBooking.references.find((ref) => ref.type.includes("_calendar"));
if (bookingRef) {
const { uid, externalCalendarId } = bookingRef;
const deletedEvent = await calendar?.deleteEvent(uid, evt, externalCalendarId);
apiDeletes.push(deletedEvent);
}
}
});
try {
await Promise.all(promises);
} catch (error) {
if (error instanceof Error) {
logger.error(error.message);
}
if (bookingCalendarReference.length > 0) {
for (const reference of bookingCalendarReference) {
const { credentialId, uid, externalCalendarId } = reference;
// If the booking calendar reference contains a credentialId
if (credentialId) {
// Find the correct calendar credential under user credentials
let calendarCredential = bookingToDelete.user.credentials.find(
(credential) => credential.id === credentialId
);
if (!calendarCredential) {
// get credential from DB
const foundCalendarCredential = await prisma.credential.findUnique({
where: {
id: credentialId,
},
});
if (foundCalendarCredential) {
calendarCredential = foundCalendarCredential;
}
} else {
}
if (calendarCredential) {
const calendar = await getCalendar(calendarCredential);
if (
bookingToDelete.eventType?.recurringEvent &&
bookingToDelete.recurringEventId &&
allRemainingBookings
) {
const promises = bookingToDelete.user.credentials
.filter((credential) => credential.type.endsWith("_calendar"))
.map(async (credential) => {
const calendar = await getCalendar(credential);
for (const updBooking of updatedBookings) {
const bookingRef = updBooking.references.find((ref) => ref.type.includes("_calendar"));
if (bookingRef) {
const { uid, externalCalendarId } = bookingRef;
const deletedEvent = await calendar?.deleteEvent(uid, evt, externalCalendarId);
apiDeletes.push(deletedEvent);
}
}
});
try {
await Promise.all(promises);
} catch (error) {
if (error instanceof Error) {
logger.error(error.message);
}
}
} else {
apiDeletes.push(calendar?.deleteEvent(uid, evt, externalCalendarId) as Promise<unknown>);
}
}
} else {
// For bookings made before the refactor we go through the old behavior of running through each calendar credential
const calendarCredentials = bookingToDelete.user.credentials.filter((credential) =>
credential.type.endsWith("_calendar")
);
for (const credential of calendarCredentials) {
const calendar = await getCalendar(credential);
apiDeletes.push(calendar?.deleteEvent(uid, evt, externalCalendarId) as Promise<unknown>);
}
}
} else {
// For bookings made before the refactor we go through the old behaviour of running through each calendar credential
const calendarCredentials = bookingToDelete.user.credentials.filter((credential) =>
credential.type.endsWith("_calendar")
);
for (const credential of calendarCredentials) {
const calendar = await getCalendar(credential);
apiDeletes.push(calendar?.deleteEvent(uid, evt, externalCalendarId) as Promise<unknown>);
}
}
}
@ -508,7 +525,11 @@ async function handler(req: CustomRequest) {
attendees: attendeesList,
location: bookingToDelete.location ?? "",
uid: bookingToDelete.uid ?? "",
destinationCalendar: bookingToDelete?.destinationCalendar || bookingToDelete?.user.destinationCalendar,
destinationCalendar: bookingToDelete?.destinationCalendar
? [bookingToDelete?.destinationCalendar]
: bookingToDelete?.user.destinationCalendar
? [bookingToDelete?.user.destinationCalendar]
: [],
};
const successPayment = bookingToDelete.payment.find((payment) => payment.success);

View File

@ -1,4 +1,4 @@
import type { App, Attendee, Credential, EventTypeCustomInput } from "@prisma/client";
import type { App, Attendee, Credential, EventTypeCustomInput, DestinationCalendar } from "@prisma/client";
import { Prisma } from "@prisma/client";
import async from "async";
import { isValidPhoneNumber } from "libphonenumber-js";
@ -367,7 +367,7 @@ async function ensureAvailableUsers(
) {
const availableUsers: IsFixedAwareUser[] = [];
const orginalBookingDuration = input.originalRescheduledBooking
const originalBookingDuration = input.originalRescheduledBooking
? dayjs(input.originalRescheduledBooking.endTime).diff(
dayjs(input.originalRescheduledBooking.startTime),
"minutes"
@ -380,7 +380,7 @@ async function ensureAvailableUsers(
{
userId: user.id,
eventTypeId: eventType.id,
duration: orginalBookingDuration,
duration: originalBookingDuration,
...input,
},
{
@ -686,8 +686,7 @@ async function handler(
if (!eventType) throw new HttpError({ statusCode: 404, message: "eventType.notFound" });
const isTeamEventType =
eventType.schedulingType === SchedulingType.COLLECTIVE ||
eventType.schedulingType === SchedulingType.ROUND_ROBIN;
!!eventType.schedulingType && ["COLLECTIVE", "ROUND_ROBIN"].includes(eventType.schedulingType);
const paymentAppData = getPaymentAppData(eventType);
@ -722,31 +721,46 @@ async function handler(
throw new HttpError({ statusCode: 400, message: error.message });
}
const loadUsers = async () =>
!eventTypeId
? await prisma.user.findMany({
const loadUsers = async () => {
try {
if (!eventTypeId) {
if (!Array.isArray(dynamicUserList) || dynamicUserList.length === 0) {
throw new Error("dynamicUserList is not properly defined or empty.");
}
const users = await prisma.user.findMany({
where: {
username: {
in: dynamicUserList,
},
username: { in: dynamicUserList },
},
select: {
...userSelect.select,
credentials: true, // Don't leak to client
credentials: true,
metadata: true,
organization: {
select: {
slug: true,
},
},
},
})
: eventType.hosts?.length
? eventType.hosts.map(({ user, isFixed }) => ({
});
return users;
} else {
const hosts = eventType.hosts || [];
if (!Array.isArray(hosts)) {
throw new Error("eventType.hosts is not properly defined.");
}
const users = hosts.map(({ user, isFixed }) => ({
...user,
isFixed,
}))
: eventType.users || [];
}));
return users.length ? users : eventType.users;
}
} catch (error) {
if (error instanceof HttpError || error instanceof Prisma.PrismaClientKnownRequestError) {
throw new HttpError({ statusCode: 400, message: error.message });
}
throw new HttpError({ statusCode: 500, message: "Unable to load users" });
}
};
// loadUsers allows type inferring
let users: (Awaited<ReturnType<typeof loadUsers>>[number] & {
isFixed?: boolean;
@ -970,20 +984,26 @@ async function handler(
: getLocationValueForDB(locationBodyString, eventType.locations);
const customInputs = getCustomInputsResponses(reqBody, eventType.customInputs);
const teamMemberPromises =
users.length > 1
? users.slice(1).map(async function (user) {
return {
email: user.email || "",
name: user.name || "",
timeZone: user.timeZone,
language: {
translate: await getTranslation(user.locale ?? "en", "common"),
locale: user.locale ?? "en",
},
};
})
: [];
const teamDestinationCalendars: DestinationCalendar[] = [];
// Organizer or user owner of this event type it's not listed as a team member.
const teamMemberPromises = users.slice(1).map(async (user) => {
// push to teamDestinationCalendars if it's a team event but collective only
if (isTeamEventType && eventType.schedulingType === "COLLECTIVE" && user.destinationCalendar) {
teamDestinationCalendars.push(user.destinationCalendar);
}
return {
email: user.email ?? "",
name: user.name ?? "",
firstName: "",
lastName: "",
timeZone: user.timeZone,
language: {
translate: await getTranslation(user.locale ?? "en", "common"),
locale: user.locale ?? "en",
},
};
});
const teamMembers = await Promise.all(teamMemberPromises);
@ -1040,16 +1060,24 @@ async function handler(
attendees: attendeesList,
location: bookingLocation, // Will be processed by the EventManager later.
conferenceCredentialId,
/** For team events & dynamic collective events, we will need to handle each member destinationCalendar eventually */
destinationCalendar: eventType.destinationCalendar || organizerUser.destinationCalendar,
destinationCalendar: eventType.destinationCalendar
? [eventType.destinationCalendar]
: organizerUser.destinationCalendar
? [organizerUser.destinationCalendar]
: null,
hideCalendarNotes: eventType.hideCalendarNotes,
requiresConfirmation: requiresConfirmation ?? false,
eventTypeId: eventType.id,
// if seats are not enabled we should default true
seatsShowAttendees: eventType.seatsPerTimeSlot ? eventType.seatsShowAttendees : true,
seatsPerTimeSlot: eventType.seatsPerTimeSlot,
schedulingType: eventType.schedulingType,
};
if (isTeamEventType && eventType.schedulingType === "COLLECTIVE") {
evt.destinationCalendar?.push(...teamDestinationCalendars);
}
/* Used for seats bookings to update evt object with video data */
const addVideoCallDataToEvt = (bookingReferences: BookingReference[]) => {
const videoCallReference = bookingReferences.find((reference) => reference.type.includes("_video"));
@ -1843,11 +1871,12 @@ async function handler(
id: organizerUser.id,
},
},
destinationCalendar: evt.destinationCalendar
? {
connect: { id: evt.destinationCalendar.id },
}
: undefined,
destinationCalendar:
evt.destinationCalendar && evt.destinationCalendar.length > 0
? {
connect: { id: evt.destinationCalendar[0].id },
}
: undefined,
};
if (reqBody.recurringEventId) {

View File

@ -30,7 +30,7 @@ export type DatePickerProps = {
/** Defaults to [], which dates are not bookable. Array of valid dates like: ["2022-04-23", "2022-04-24"] */
excludedDates?: string[];
/** defaults to all, which dates are bookable (inverse of excludedDates) */
includedDates?: string[] | null;
includedDates?: string[];
/** allows adding classes to the container */
className?: string;
/** Shows a small loading spinner next to the month name */
@ -100,6 +100,40 @@ const NoAvailabilityOverlay = ({
);
};
/**
* Takes care of selecting a valid date in the month if the selected date is not available in the month
*/
const useHandleInitialDateSelection = ({
daysToRenderForTheMonth,
selected,
onChange,
}: {
daysToRenderForTheMonth: { day: Dayjs | null; disabled: boolean }[];
selected: Dayjs | Dayjs[] | null | undefined;
onChange: (date: Dayjs | null) => void;
}) => {
// Let's not do something for now in case of multiple selected dates as behaviour is unclear and it's not needed at the moment
if (selected instanceof Array) {
return;
}
const firstAvailableDateOfTheMonth = daysToRenderForTheMonth.find((day) => !day.disabled)?.day;
const isSelectedDateAvailable = selected
? daysToRenderForTheMonth.some(({ day, disabled }) => {
if (day && yyyymmdd(day) === yyyymmdd(selected) && !disabled) return true;
})
: false;
if (!isSelectedDateAvailable && firstAvailableDateOfTheMonth) {
// If selected date not available in the month, select the first available date of the month
onChange(firstAvailableDateOfTheMonth);
}
if (!firstAvailableDateOfTheMonth) {
onChange(null);
}
};
const Days = ({
minDate = dayjs.utc(),
excludedDates = [],
@ -121,7 +155,7 @@ const Days = ({
// Create placeholder elements for empty days in first week
const weekdayOfFirst = browsingDate.date(1).day();
const currentDate = minDate.utcOffset(browsingDate.utcOffset());
const availableDates = (includedDates: string[] | undefined | null) => {
const availableDates = (includedDates: string[] | undefined) => {
const dates = [];
const lastDateOfMonth = browsingDate.date(daysInMonth(browsingDate));
for (
@ -148,21 +182,6 @@ const Days = ({
days.push(date);
}
const daysToRenderForTheMonth = days.map((day) => {
if (!day) return { day: null, disabled: true };
return {
day: day,
disabled:
(includedDates && !includedDates.includes(yyyymmdd(day))) || excludedDates.includes(yyyymmdd(day)),
};
});
useHandleInitialDateSelection({
daysToRenderForTheMonth,
selected,
onChange: props.onChange,
});
const [selectedDatesAndTimes] = useBookerStore((state) => [state.selectedDatesAndTimes], shallow);
const isActive = (day: dayjs.Dayjs) => {
@ -190,6 +209,21 @@ const Days = ({
return false;
};
const daysToRenderForTheMonth = days.map((day) => {
if (!day) return { day: null, disabled: true };
return {
day: day,
disabled:
(includedDates && !includedDates.includes(yyyymmdd(day))) || excludedDates.includes(yyyymmdd(day)),
};
});
useHandleInitialDateSelection({
daysToRenderForTheMonth,
selected,
onChange: props.onChange,
});
return (
<>
{daysToRenderForTheMonth.map(({ day, disabled }, idx) => (
@ -305,41 +339,4 @@ const DatePicker = ({
);
};
/**
* Takes care of selecting a valid date in the month if the selected date is not available in the month
*/
const useHandleInitialDateSelection = ({
daysToRenderForTheMonth,
selected,
onChange,
}: {
daysToRenderForTheMonth: { day: Dayjs | null; disabled: boolean }[];
selected: Dayjs | Dayjs[] | null | undefined;
onChange: (date: Dayjs | null) => void;
}) => {
// Let's not do something for now in case of multiple selected dates as behaviour is unclear and it's not needed at the moment
if (selected instanceof Array) {
return;
}
const firstAvailableDateOfTheMonth = daysToRenderForTheMonth.find((day) => !day.disabled)?.day;
const isSelectedDateAvailable = selected
? daysToRenderForTheMonth.some(({ day, disabled }) => {
if (day && yyyymmdd(day) === yyyymmdd(selected) && !disabled) return true;
})
: false;
if (firstAvailableDateOfTheMonth) {
// If selected date not available in the month, select the first available date of the month
if (!isSelectedDateAvailable) {
onChange(firstAvailableDateOfTheMonth);
}
} else {
// No date is available and if we were asked to select something inform that it couldn't be selected. This would actually help in not showing the timeslots section(with No Time Available) when no date in the month is available
if (selected) {
onChange(null);
}
}
};
export default DatePicker;

View File

@ -96,6 +96,7 @@ function Cell({ isDisabled, topOffsetMinutes, timeSlot }: CellProps) {
)}
data-disabled={isDisabled}
data-slot={timeSlot.toISOString()}
data-testid="calendar-empty-cell"
style={{
height: `calc(${hoverEventDuration}*var(--one-minute-height))`,
overflow: "visible",

View File

@ -59,7 +59,9 @@ function AdminOrgTable() {
</div>
</Cell>
<Cell widthClassNames="w-auto">
<span className="break-all">{org.members[0].user.email}</span>
<span className="break-all">
{org.members.length ? org.members[0].user.email : "No members"}
</span>
</Cell>
<Cell>
<div className="space-x-2">

View File

@ -14,7 +14,7 @@
"@sendgrid/mail": "^7.6.2",
"libphonenumber-js": "^1.10.12",
"twilio": "^3.80.1",
"zod": "^3.20.2"
"zod": "^3.22.2"
},
"devDependencies": {
"@calcom/tsconfig": "*"

View File

@ -149,7 +149,11 @@ export async function handlePaymentSuccess(
},
attendees: attendeesList,
uid: booking.uid,
destinationCalendar: booking.destinationCalendar || user.destinationCalendar,
destinationCalendar: booking.destinationCalendar
? [booking.destinationCalendar]
: user.destinationCalendar
? [user.destinationCalendar]
: [],
recurringEvent: parseRecurringEvent(eventTypeRaw?.recurringEvent),
};

View File

@ -98,7 +98,7 @@ async function getBooking(bookingId: number) {
});
const attendeesList = await Promise.all(attendeesListPromises);
const selectedDestinationCalendar = booking.destinationCalendar || user.destinationCalendar;
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
@ -116,7 +116,7 @@ async function getBooking(bookingId: number) {
},
attendees: attendeesList,
uid: booking.uid,
destinationCalendar: booking.destinationCalendar || user.destinationCalendar,
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
recurringEvent: parseRecurringEvent(eventType?.recurringEvent),
};
@ -204,7 +204,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
});
const attendeesList = await Promise.all(attendeesListPromises);
const selectedDestinationCalendar = booking.destinationCalendar || user.destinationCalendar;
const evt: CalendarEvent = {
type: booking.title,
title: booking.title,
@ -226,7 +226,7 @@ async function handlePaymentSuccess(event: Stripe.Event) {
attendees: attendeesList,
location: booking.location,
uid: booking.uid,
destinationCalendar: booking.destinationCalendar || user.destinationCalendar,
destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
recurringEvent: parseRecurringEvent(eventTypeRaw?.recurringEvent),
};

View File

@ -32,6 +32,7 @@ import { GoogleWorkspaceInviteButton } from "./GoogleWorkspaceInviteButton";
type MemberInvitationModalProps = {
isOpen: boolean;
justEmailInvites?: boolean;
onExit: () => void;
orgMembers?: RouterOutputs["viewer"]["organizations"]["getMembers"];
onSubmit: (values: NewMemberForm, resetFields: () => void) => void;
@ -206,7 +207,7 @@ export default function MemberInvitationModal(props: MemberInvitationModalProps)
render={({ field: { onChange }, fieldState: { error } }) => (
<>
<TextField
label={t("email_or_username")}
label={props.justEmailInvites ? t("email") : t("email_or_username")}
id="inviteUser"
name="inviteUser"
placeholder="email@example.com"

View File

@ -15,9 +15,9 @@ export default function TeamPill(props: Props) {
<div
className={classNames("text-medium self-center rounded-md px-1 py-0.5 text-xs ltr:mr-1 rtl:ml-1", {
" bg-subtle text-emphasis": !props.color,
" bg-info text-blue-800": props.color === "blue",
" bg-error text-red-800 ": props.color === "red",
" bg-attention text-orange-800": props.color === "orange",
" bg-info text-info": props.color === "blue",
" bg-error text-error ": props.color === "red",
" bg-attention text-attention": props.color === "orange",
})}>
{props.text}
</div>

View File

@ -65,7 +65,7 @@ export function TeamsListing() {
{
icon: <Mail className="h-5 w-5 text-orange-500" />,
title: t("sms_attendee_action"),
description: t("make_it_easy_to_book"),
description: t("send_reminder_sms"),
},
{
icon: <Video className="h-5 w-5 text-purple-500" />,

View File

@ -117,10 +117,8 @@ export const updateQuantitySubscriptionFromStripe = async (teamId: number) => {
return;
}
const newQuantity = membershipCount - subscriptionQuantity;
await stripe.subscriptions.update(subscriptionId, {
items: [{ quantity: membershipCount + newQuantity, id: subscriptionItemId }],
items: [{ quantity: membershipCount, id: subscriptionItemId }],
});
console.info(
`Updated subscription ${subscriptionId} for team ${teamId} to ${team.members.length} seats.`

View File

@ -1,3 +1,4 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import type { Workflow, WorkflowStep, Membership } from "@prisma/client";
import Link from "next/link";
import { useRouter } from "next/navigation";
@ -19,6 +20,7 @@ import {
Tooltip,
Badge,
Avatar,
ArrowButton,
} from "@calcom/ui";
import { Edit2, Link as LinkIcon, MoreHorizontal, Trash2 } from "@calcom/ui/components/icon";
@ -56,192 +58,240 @@ export default function WorkflowListPage({ workflows }: Props) {
const utils = trpc.useContext();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [workflowToDeleteId, setwWorkflowToDeleteId] = useState(0);
const [parent] = useAutoAnimate<HTMLUListElement>();
const router = useRouter();
const orgBranding = useOrgBranding();
const urlPrefix = orgBranding ? `${orgBranding.slug}.${subdomainSuffix()}` : CAL_URL;
const mutation = trpc.viewer.workflowOrder.useMutation({
onError: async (err) => {
console.error(err.message);
await utils.viewer.workflows.filteredList.cancel();
await utils.viewer.workflows.filteredList.invalidate();
},
onSettled: () => {
utils.viewer.workflows.filteredList.invalidate();
},
});
async function moveWorkflow(index: number, increment: 1 | -1) {
const types = workflows!;
const newList = [...types];
const type = types[index];
const tmp = types[index + increment];
if (tmp) {
newList[index] = tmp;
newList[index + increment] = type;
}
await utils.viewer.appRoutingForms.forms.cancel();
mutation.mutate({
ids: newList?.map((type) => type.id),
});
}
return (
<>
{workflows && workflows.length > 0 ? (
<div className="bg-default border-subtle overflow-hidden rounded-md border sm:mx-0">
<ul className="divide-subtle divide-y" data-testid="workflow-list">
{workflows.map((workflow) => (
<li key={workflow.id}>
<div className="first-line:group hover:bg-muted flex w-full items-center justify-between p-4 sm:px-6">
<Link href={"/workflows/" + workflow.id} className="flex-grow cursor-pointer">
<div className="rtl:space-x-reverse">
<div className="flex">
<div
className={classNames(
"max-w-56 text-emphasis truncate text-sm font-medium leading-6 md:max-w-max",
workflow.name ? "text-emphasis" : "text-subtle"
)}>
{workflow.name
? workflow.name
: workflow.steps[0]
? "Untitled (" +
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`
.charAt(0)
.toUpperCase() +
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.slice(1) +
")"
: "Untitled"}
<ul className="divide-subtle !static w-full divide-y" data-testid="workflow-list" ref={parent}>
{workflows.map((workflow, index) => {
const firstItem = workflows[0];
const lastItem = workflows[workflows.length - 1];
return (
<li
key={workflow.id}
className="group flex w-full max-w-full items-center justify-between overflow-hidden">
{!(firstItem && firstItem.id === workflow.id) && (
<ArrowButton onClick={() => moveWorkflow(index, -1)} arrowDirection="up" />
)}
{!(lastItem && lastItem.id === workflow.id) && (
<ArrowButton onClick={() => moveWorkflow(index, 1)} arrowDirection="down" />
)}
<div className="first-line:group hover:bg-muted flex w-full items-center justify-between p-4 sm:px-6">
<Link href={"/workflows/" + workflow.id} className="flex-grow cursor-pointer">
<div className="rtl:space-x-reverse">
<div className="flex">
<div
className={classNames(
"max-w-56 text-emphasis truncate text-sm font-medium leading-6 md:max-w-max",
workflow.name ? "text-emphasis" : "text-subtle"
)}>
{workflow.name
? workflow.name
: workflow.steps[0]
? "Untitled (" +
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`
.charAt(0)
.toUpperCase() +
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.slice(1) +
")"
: "Untitled"}
</div>
<div>
{workflow.readOnly && (
<Badge variant="gray" className="ml-2 ">
{t("readonly")}
</Badge>
)}
</div>
</div>
<div>
{workflow.readOnly && (
<Badge variant="gray" className="ml-2 ">
{t("readonly")}
<ul className="mt-1 flex flex-wrap space-x-2 sm:flex-nowrap ">
<li>
<Badge variant="gray">
<div>
{getActionIcon(workflow.steps)}
<span className="mr-1">{t("triggers")}</span>
{workflow.timeUnit && workflow.time && (
<span className="mr-1">
{t(`${workflow.timeUnit.toLowerCase()}`, { count: workflow.time })}
</span>
)}
<span>{t(`${workflow.trigger.toLowerCase()}_trigger`)}</span>
</div>
</Badge>
)}
</div>
</div>
<ul className="mt-1 flex flex-wrap space-x-2 sm:flex-nowrap ">
<li>
<Badge variant="gray">
<div>
{getActionIcon(workflow.steps)}
<span className="mr-1">{t("triggers")}</span>
{workflow.timeUnit && workflow.time && (
<span className="mr-1">
{t(`${workflow.timeUnit.toLowerCase()}`, { count: workflow.time })}
</span>
)}
<span>{t(`${workflow.trigger.toLowerCase()}_trigger`)}</span>
</div>
</Badge>
</li>
<li>
<Badge variant="gray">
{workflow.activeOn && workflow.activeOn.length > 0 ? (
<Tooltip
content={workflow.activeOn
.filter((wf) => (workflow.teamId ? wf.eventType.parentId === null : true))
.map((activeOn, key) => (
<p key={key}>
{activeOn.eventType.title}
{activeOn.eventType._count.children > 0
? ` (+${activeOn.eventType._count.children})`
: ""}
</p>
))}>
</li>
<li>
<Badge variant="gray">
{workflow.activeOn && workflow.activeOn.length > 0 ? (
<Tooltip
content={workflow.activeOn
.filter((wf) => (workflow.teamId ? wf.eventType.parentId === null : true))
.map((activeOn, key) => (
<p key={key}>
{activeOn.eventType.title}
{activeOn.eventType._count.children > 0
? ` (+${activeOn.eventType._count.children})`
: ""}
</p>
))}>
<div>
<LinkIcon className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{t("active_on_event_types", {
count: workflow.activeOn.filter((wf) =>
workflow.teamId ? wf.eventType.parentId === null : true
).length,
})}
</div>
</Tooltip>
) : (
<div>
<LinkIcon className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{t("active_on_event_types", {
count: workflow.activeOn.filter((wf) =>
workflow.teamId ? wf.eventType.parentId === null : true
).length,
})}
{t("no_active_event_types")}
</div>
</Tooltip>
) : (
<div>
<LinkIcon className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{t("no_active_event_types")}
</div>
)}
</Badge>
</li>
<div className="block md:hidden">
{workflow.team?.name && (
<li>
<Badge variant="gray">
<>{workflow.team.name}</>
</Badge>
</li>
)}
</div>
</ul>
</div>
</Link>
<div>
<div className="hidden md:block">
{workflow.team?.name && (
<Badge className="mr-4 mt-1 p-[1px] px-2" variant="gray">
<Avatar
alt={workflow.team?.name || ""}
href={
workflow.team?.id
? `/settings/teams/${workflow.team?.id}/profile`
: "/settings/my-account/profile"
}
imageSrc={getPlaceholderAvatar(
workflow?.team.logo,
workflow.team?.name as string
)}
size="xxs"
className="mt-[3px] inline-flex justify-center"
/>
<div>{workflow.team.name}</div>
</Badge>
</li>
<div className="block md:hidden">
{workflow.team?.name && (
<li>
<Badge variant="gray">
<>{workflow.team.name}</>
</Badge>
</li>
)}
</div>
</ul>
)}
</div>
</div>
</Link>
<div>
<div className="hidden md:block">
{workflow.team?.name && (
<Badge className="mr-4 mt-1 p-[1px] px-2" variant="gray">
<Avatar
alt={workflow.team?.name || ""}
href={
workflow.team?.id
? `/settings/teams/${workflow.team?.id}/profile`
: "/settings/my-account/profile"
}
imageSrc={getPlaceholderAvatar(
workflow?.team.logo,
workflow.team?.name as string
)}
size="xxs"
className="mt-[3px] inline-flex justify-center"
/>
<div>{workflow.team.name}</div>
</Badge>
<div className="flex flex-shrink-0">
<div className="hidden sm:block">
<ButtonGroup combined>
<Tooltip content={t("edit") as string}>
<Button
type="button"
color="secondary"
variant="icon"
StartIcon={Edit2}
disabled={workflow.readOnly}
onClick={async () => await router.replace("/workflows/" + workflow.id)}
/>
</Tooltip>
<Tooltip content={t("delete") as string}>
<Button
onClick={() => {
setDeleteDialogOpen(true);
setwWorkflowToDeleteId(workflow.id);
}}
color="secondary"
variant="icon"
disabled={workflow.readOnly}
StartIcon={Trash2}
/>
</Tooltip>
</ButtonGroup>
</div>
{!workflow.readOnly && (
<div className="block sm:hidden">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button
type="button"
color="minimal"
variant="icon"
StartIcon={MoreHorizontal}
/>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon={Edit2}
onClick={async () => await router.replace("/workflows/" + workflow.id)}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem
type="button"
color="destructive"
StartIcon={Trash2}
onClick={() => {
setDeleteDialogOpen(true);
setwWorkflowToDeleteId(workflow.id);
}}>
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
)}
</div>
</div>
<div className="flex flex-shrink-0">
<div className="hidden sm:block">
<ButtonGroup combined>
<Tooltip content={t("edit") as string}>
<Button
type="button"
color="secondary"
variant="icon"
StartIcon={Edit2}
disabled={workflow.readOnly}
onClick={async () => await router.replace("/workflows/" + workflow.id)}
/>
</Tooltip>
<Tooltip content={t("delete") as string}>
<Button
onClick={() => {
setDeleteDialogOpen(true);
setwWorkflowToDeleteId(workflow.id);
}}
color="secondary"
variant="icon"
disabled={workflow.readOnly}
StartIcon={Trash2}
/>
</Tooltip>
</ButtonGroup>
</div>
{!workflow.readOnly && (
<div className="block sm:hidden">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button type="button" color="minimal" variant="icon" StartIcon={MoreHorizontal} />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem
type="button"
StartIcon={Edit2}
onClick={async () => await router.replace("/workflows/" + workflow.id)}>
{t("edit")}
</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem
type="button"
color="destructive"
StartIcon={Trash2}
onClick={() => {
setDeleteDialogOpen(true);
setwWorkflowToDeleteId(workflow.id);
}}>
{t("delete")}
</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</div>
)}
</div>
</div>
</li>
))}
</li>
);
})}
</ul>
<DeleteDialog
isOpenDialog={deleteDialogOpen}

View File

@ -8,6 +8,7 @@ import type { ControlProps } from "react-select";
import { components } from "react-select";
import { shallow } from "zustand/shallow";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { AvailableTimes } from "@calcom/features/bookings";
import { useBookerStore, useInitializeBookerStore } from "@calcom/features/bookings/Booker/store";
@ -38,7 +39,7 @@ import {
TextField,
TimezoneSelect,
} from "@calcom/ui";
import { ArrowDown, ArrowLeft, ArrowUp, Sun } from "@calcom/ui/components/icon";
import { ArrowLeft, Sun } from "@calcom/ui/components/icon";
import { getDimension } from "./lib/getDimension";
import type { EmbedTabs, EmbedType, EmbedTypes, PreviewState } from "./types";
@ -228,8 +229,10 @@ const EmailEmbed = ({ eventType, username }: { eventType?: EventType; username:
<div className="text-default text-sm">{t("select_date")}</div>
<DatePicker
isLoading={schedule.isLoading}
onChange={(date) => setSelectedDate(date ? date.format("YYYY-MM-DD") : date)}
onMonthChange={(date) => {
onChange={(date: Dayjs | null) => {
setSelectedDate(date === null ? date : date.format("YYYY-MM-DD"));
}}
onMonthChange={(date: Dayjs) => {
setMonth(date.format("YYYY-MM"));
setSelectedDate(date.format("YYYY-MM-DD"));
}}
@ -245,36 +248,24 @@ const EmailEmbed = ({ eventType, username }: { eventType?: EventType; username:
</div>
{selectedDate ? (
<div className="mt-[9px] font-medium ">
<Collapsible open>
<CollapsibleContent>
<div
className="text-default mb-[9px] flex cursor-pointer items-center justify-between text-sm"
onClick={() => setSelectTime((prev) => !prev)}>
<p>{t("select_time")}</p>{" "}
<>
{!selectedDate || !selectTime ? <ArrowDown className="w-4" /> : <ArrowUp className="w-4" />}
</>
</div>
{selectTime && selectedDate ? (
<div className="flex h-full w-full flex-row gap-4">
<AvailableTimes
className="w-full"
date={dayjs(selectedDate)}
selectedSlots={
eventType.slug &&
selectedDatesAndTimes &&
selectedDatesAndTimes[eventType.slug] &&
selectedDatesAndTimes[eventType.slug][selectedDate as string]
? selectedDatesAndTimes[eventType.slug][selectedDate as string]
: undefined
}
onTimeSelect={onTimeSelect}
slots={slots}
/>
</div>
) : null}
</CollapsibleContent>
</Collapsible>
{selectTime && selectedDate ? (
<div className="flex h-full w-full flex-row gap-4">
<AvailableTimes
className="w-full"
date={dayjs(selectedDate)}
selectedSlots={
eventType.slug &&
selectedDatesAndTimes &&
selectedDatesAndTimes[eventType.slug] &&
selectedDatesAndTimes[eventType.slug][selectedDate as string]
? selectedDatesAndTimes[eventType.slug][selectedDate as string]
: undefined
}
onTimeSelect={onTimeSelect}
slots={slots}
/>
</div>
) : null}
</div>
) : null}
<div className="mb-[9px] font-medium ">

View File

@ -51,11 +51,7 @@ const DateOverrideForm = ({
const [selectedDates, setSelectedDates] = useState<Dayjs[]>(value ? [dayjs.utc(value[0].start)] : []);
const onDateChange = (newDate: Dayjs | null) => {
// If no date is selected, do nothing
if (!newDate) {
return;
}
const onDateChange = (newDate: Dayjs) => {
// If clicking on a selected date unselect it
if (selectedDates.some((date) => yyyymmdd(date) === yyyymmdd(newDate))) {
setSelectedDates(selectedDates.filter((date) => yyyymmdd(date) !== yyyymmdd(newDate)));
@ -154,7 +150,9 @@ const DateOverrideForm = ({
excludedDates={excludedDates}
weekStart={0}
selected={selectedDates}
onChange={(day) => onDateChange(day)}
onChange={(day) => {
if (day) onDateChange(day);
}}
onMonthChange={(newMonth) => {
setBrowsingDate(newMonth);
}}

View File

@ -369,7 +369,7 @@ function UserDropdown({ small }: UserDropdownProps) {
<span
className={classNames(
small ? "h-4 w-4" : "h-5 w-5 ltr:mr-2 rtl:ml-2",
"relative flex-shrink-0 rounded-full bg-gray-300"
"relative flex-shrink-0 rounded-full "
)}>
<Avatar
size={small ? "xs" : "xsm"}
@ -668,7 +668,7 @@ const NavigationItem: React.FC<{
aria-current={current ? "page" : undefined}>
{item.icon && (
<item.icon
className="mr-2 h-4 w-4 flex-shrink-0 ltr:mr-2 rtl:ml-2 [&[aria-current='page']]:text-inherit"
className="mr-2 h-4 w-4 flex-shrink-0 rtl:ml-2 md:ltr:mx-auto lg:ltr:mr-2 [&[aria-current='page']]:text-inherit"
aria-hidden="true"
aria-current={current ? "page" : undefined}
/>
@ -906,7 +906,7 @@ function SideBar({ bannersHeight, user }: SideBarProps) {
<Icon
className={classNames(
"h-4 w-4 flex-shrink-0 [&[aria-current='page']]:text-inherit",
"me-3 md:ltr:mr-2 md:rtl:ml-2"
"me-3 md:mx-auto lg:ltr:mr-2 lg:rtl:ml-2"
)}
aria-hidden="true"
/>

View File

@ -35,7 +35,7 @@ function MoreInfoFooter() {
return (
<>
<SheetClose asChild>
<Button color="secondary" type="button" className="justify-center md:w-1/5">
<Button color="secondary" type="button" className="w-full justify-center lg:w-1/5">
{t("close")}
</Button>
</SheetClose>

View File

@ -60,6 +60,7 @@ export function InviteMemberModal(props: Props) {
});
}}
teamId={orgId}
justEmailInvites={!!orgId}
isLoading={inviteMemberMutation.isLoading}
onSubmit={(values) => {
inviteMemberMutation.mutate({

View File

@ -153,12 +153,14 @@ export default abstract class BaseCalendarService implements Calendar {
if (error || !iCalString)
throw new Error(`Error creating iCalString:=> ${error?.message} : ${error?.name} `);
const [mainHostDestinationCalendar] = event.destinationCalendar ?? [];
// We create the event directly on iCal
const responses = await Promise.all(
calendars
.filter((c) =>
event.destinationCalendar?.externalId
? c.externalId === event.destinationCalendar.externalId
mainHostDestinationCalendar?.externalId
? c.externalId === mainHostDestinationCalendar.externalId
: true
)
.map((calendar) =>
@ -504,13 +506,13 @@ export default abstract class BaseCalendarService implements Calendar {
return calendars.reduce<IntegrationCalendar[]>((newCalendars, calendar) => {
if (!calendar.components?.includes("VEVENT")) return newCalendars;
const [mainHostDestinationCalendar] = event?.destinationCalendar ?? [];
newCalendars.push({
externalId: calendar.url,
/** @url https://github.com/calcom/cal.com/issues/7186 */
name: typeof calendar.displayName === "string" ? calendar.displayName : "",
primary: event?.destinationCalendar?.externalId
? event.destinationCalendar.externalId === calendar.url
primary: mainHostDestinationCalendar?.externalId
? mainHostDestinationCalendar.externalId === calendar.url
: false,
integration: this.integrationName,
email: this.credentials.username ?? "",

View File

@ -59,7 +59,8 @@ const handlePayment = async (
},
booking.id,
bookerEmail,
paymentOption
paymentOption,
evt.title
);
}

View File

@ -1,10 +1,9 @@
import { Prisma } from "@prisma/client";
import { getAppFromSlug } from "@calcom/app-store/utils";
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
import { getSlugOrRequestedSlug } from "@calcom/ee/organizations/lib/orgDomains";
import prisma, { baseEventTypeSelect } from "@calcom/prisma";
import { AppCategories, SchedulingType } from "@calcom/prisma/enums";
import { SchedulingType } from "@calcom/prisma/enums";
import { EventTypeMetaDataSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { WEBAPP_URL } from "../../../constants";
@ -32,7 +31,17 @@ export async function getTeamWithMembers(args: {
selectedCalendars: true,
credentials: {
include: {
app: true,
app: {
select: {
slug: true,
categories: true,
},
},
destinationCalendars: {
select: {
externalId: true,
},
},
},
},
});
@ -124,43 +133,39 @@ export async function getTeamWithMembers(args: {
});
if (!team) return null;
const members = await Promise.all(
team.members.map(async (obj) => {
const calendarCredentials = getCalendarCredentials(obj.user.credentials);
const { connectedCalendars } = await getConnectedCalendars(
calendarCredentials,
obj.user.selectedCalendars,
obj.user.destinationCalendar?.externalId
);
const connectedApps = obj.user.credentials
.map(({ app, id }) => {
const appMetaData = getAppFromSlug(app?.slug);
// This should improve performance saving already app data found.
const appDataMap = new Map();
const members = team.members.map((obj) => {
return {
...obj.user,
role: obj.role,
accepted: obj.accepted,
disableImpersonation: obj.disableImpersonation,
avatar: `${WEBAPP_URL}/${obj.user.username}/avatar.png`,
connectedApps: obj.user.credentials.map((cred) => {
const appSlug = cred.app?.slug;
let appData = appDataMap.get(appSlug);
if (!appData) {
appData = getAppFromSlug(appSlug);
appDataMap.set(appSlug, appData);
}
const isCalendar = cred?.app?.categories.includes("calendar");
const externalId = isCalendar ? cred.destinationCalendars[0]?.externalId : undefined;
return {
name: appData?.name,
logo: appData?.logo,
app: cred.app,
externalId,
};
}),
};
});
if (app?.categories.includes(AppCategories.calendar)) {
const externalId = connectedCalendars.find((cal) => cal.credentialId == id)?.primary?.email;
return { name: appMetaData?.name, logo: appMetaData?.logo, slug: appMetaData?.slug, externalId };
}
return { name: appMetaData?.name, logo: appMetaData?.logo, slug: appMetaData?.slug };
})
.sort((a, b) => (a.slug ?? "").localeCompare(b.slug ?? ""));
// Prevent credentials from leaking to frontend
const {
credentials: _credentials,
destinationCalendar: _destinationCalendar,
selectedCalendars: _selectedCalendars,
...rest
} = {
...obj.user,
role: obj.role,
accepted: obj.accepted,
disableImpersonation: obj.disableImpersonation,
avatar: `${WEBAPP_URL}/${obj.user.username}/avatar.png`,
connectedApps,
};
return rest;
})
);
const eventTypes = team.eventTypes.map((eventType) => ({
...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata),

View File

@ -19,9 +19,6 @@ export const telemetryEventTypes = {
onboardingStarted: "onboarding_started",
signup: "signup",
team_created: "team_created",
website: {
pageView: "website_page_view",
},
slugReplacementAction: "slug_replacement_action",
org_created: "org_created",
};

View File

@ -0,0 +1,5 @@
-- AlterTable
ALTER TABLE "App_RoutingForms_Form" ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0;
-- AlterTable
ALTER TABLE "Workflow" ADD COLUMN "position" INTEGER NOT NULL DEFAULT 0;

View File

@ -28,7 +28,7 @@
"@prisma/generator-helper": "^5.0.0",
"prisma": "^5.0.0",
"ts-node": "^10.9.1",
"zod": "^3.20.2",
"zod": "^3.22.2",
"zod-prisma": "^0.5.4"
},
"main": "index.ts",

View File

@ -662,6 +662,7 @@ model App {
model App_RoutingForms_Form {
id String @id @default(cuid())
description String?
position Int @default(0)
routes Json?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@ -746,6 +747,7 @@ model WorkflowStep {
model Workflow {
id Int @id @default(autoincrement())
position Int @default(0)
name String
userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade)

View File

@ -4,6 +4,7 @@ import z, { ZodNullable, ZodObject, ZodOptional } from "zod";
/* eslint-disable no-underscore-dangle */
import type {
AnyZodObject,
objectInputType,
objectOutputType,
ZodNullableDef,
@ -528,11 +529,13 @@ export const optionToValueSchema = <T extends z.ZodTypeAny>(valueSchema: T) =>
* @url https://github.com/colinhacks/zod/discussions/1655#discussioncomment-4367368
*/
export const getParserWithGeneric =
<T extends z.ZodTypeAny>(valueSchema: T) =>
<T extends AnyZodObject>(valueSchema: T) =>
<Data>(data: Data) => {
type Output = z.infer<typeof valueSchema>;
type Output = z.infer<T>;
type SimpleFormValues = string | number | null | undefined;
return valueSchema.parse(data) as {
[key in keyof Data]: key extends keyof Output ? Output[key] : Data[key];
// TODO: Invesitage why this broke on zod 3.22.2 upgrade
[key in keyof Data]: Data[key] extends SimpleFormValues ? Data[key] : Output[key];
};
};
export const sendDailyVideoRecordingEmailsSchema = z.object({

View File

@ -17,6 +17,6 @@
"@trpc/react-query": "^10.13.0",
"@trpc/server": "^10.13.0",
"superjson": "1.9.1",
"zod": "^3.20.2"
"zod": "^3.22.2"
}
}

View File

@ -11,10 +11,12 @@ import { ZGetCalVideoRecordingsInputSchema } from "./getCalVideoRecordings.schem
import { ZGetDownloadLinkOfCalVideoRecordingsInputSchema } from "./getDownloadLinkOfCalVideoRecordings.schema";
import { ZIntegrationsInputSchema } from "./integrations.schema";
import { ZLocationOptionsInputSchema } from "./locationOptions.schema";
import { ZRoutingFormOrderInputSchema } from "./routingFormOrder.schema";
import { ZSetDestinationCalendarInputSchema } from "./setDestinationCalendar.schema";
import { ZSubmitFeedbackInputSchema } from "./submitFeedback.schema";
import { ZUpdateProfileInputSchema } from "./updateProfile.schema";
import { ZUpdateUserDefaultConferencingAppInputSchema } from "./updateUserDefaultConferencingApp.schema";
import { ZWorkflowOrderInputSchema } from "./workflowOrder.schema";
type AppsRouterHandlerCache = {
me?: typeof import("./me.handler").meHandler;
@ -31,6 +33,8 @@ type AppsRouterHandlerCache = {
stripeCustomer?: typeof import("./stripeCustomer.handler").stripeCustomerHandler;
updateProfile?: typeof import("./updateProfile.handler").updateProfileHandler;
eventTypeOrder?: typeof import("./eventTypeOrder.handler").eventTypeOrderHandler;
routingFormOrder?: typeof import("./routingFormOrder.handler").routingFormOrderHandler;
workflowOrder?: typeof import("./workflowOrder.handler").workflowOrderHandler;
submitFeedback?: typeof import("./submitFeedback.handler").submitFeedbackHandler;
locationOptions?: typeof import("./locationOptions.handler").locationOptionsHandler;
deleteCredential?: typeof import("./deleteCredential.handler").deleteCredentialHandler;
@ -230,6 +234,34 @@ export const loggedInViewerRouter = router({
return UNSTABLE_HANDLER_CACHE.eventTypeOrder({ ctx, input });
}),
routingFormOrder: authedProcedure.input(ZRoutingFormOrderInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.routingFormOrder) {
UNSTABLE_HANDLER_CACHE.routingFormOrder = (
await import("./routingFormOrder.handler")
).routingFormOrderHandler;
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.routingFormOrder) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.routingFormOrder({ ctx, input });
}),
workflowOrder: authedProcedure.input(ZWorkflowOrderInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.workflowOrder) {
UNSTABLE_HANDLER_CACHE.workflowOrder = (await import("./workflowOrder.handler")).workflowOrderHandler;
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.workflowOrder) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.workflowOrder({ ctx, input });
}),
//Comment for PR: eventTypePosition is not used anywhere
submitFeedback: authedProcedure.input(ZSubmitFeedbackInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.submitFeedback) {

View File

@ -287,7 +287,11 @@ export const deleteCredentialHandler = async ({ ctx, input }: DeleteCredentialOp
uid: booking.uid,
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
location: booking.location,
destinationCalendar: booking.destinationCalendar || booking.user?.destinationCalendar,
destinationCalendar: booking.destinationCalendar
? [booking.destinationCalendar]
: booking.user?.destinationCalendar
? [booking.user?.destinationCalendar]
: [],
cancellationReason: "Payment method removed by organizer",
seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot,
seatsShowAttendees: booking.eventType?.seatsShowAttendees,

View File

@ -0,0 +1,72 @@
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import type { TRoutingFormOrderInputSchema } from "./routingFormOrder.schema";
type RoutingFormOrderOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TRoutingFormOrderInputSchema;
};
export const routingFormOrderHandler = async ({ ctx, input }: RoutingFormOrderOptions) => {
const { user } = ctx;
const forms = await prisma.app_RoutingForms_Form.findMany({
where: {
OR: [
{
userId: user.id,
},
{
team: {
members: {
some: {
userId: user.id,
accepted: true,
},
},
},
},
],
},
orderBy: {
createdAt: "desc",
},
include: {
team: {
include: {
members: true,
},
},
_count: {
select: {
responses: true,
},
},
},
});
const allFormIds = new Set(forms.map((form) => form.id));
if (input.ids.some((id) => !allFormIds.has(id))) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
await Promise.all(
input.ids.reverse().map((id, position) => {
return prisma.app_RoutingForms_Form.update({
where: {
id: id,
},
data: {
position,
},
});
})
);
};

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const ZRoutingFormOrderInputSchema = z.object({
ids: z.array(z.string()),
});
export type TRoutingFormOrderInputSchema = z.infer<typeof ZRoutingFormOrderInputSchema>;

View File

@ -0,0 +1,173 @@
import type { TFormSchema } from "@calcom/app-store/routing-forms/trpc/forms.schema";
import { hasFilter } from "@calcom/features/filters/lib/hasFilter";
import { prisma } from "@calcom/prisma";
import type { Prisma } from "@calcom/prisma/client";
import { entries } from "@calcom/prisma/zod-utils";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";
import type { TWorkflowOrderInputSchema } from "./workflowOrder.schema";
type RoutingFormOrderOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TWorkflowOrderInputSchema;
};
export const workflowOrderHandler = async ({ ctx, input }: RoutingFormOrderOptions) => {
const { user } = ctx;
const includedFields = {
activeOn: {
select: {
eventType: {
select: {
id: true,
title: true,
parentId: true,
_count: {
select: {
children: true,
},
},
},
},
},
},
steps: true,
team: {
select: {
id: true,
slug: true,
name: true,
members: true,
logo: true,
},
},
};
const allWorkflows = await prisma.workflow.findMany({
where: {
OR: [
{
userId: user.id,
},
{
team: {
members: {
some: {
userId: user.id,
accepted: true,
},
},
},
},
],
},
include: includedFields,
orderBy: [
{
position: "desc",
},
{
id: "asc",
},
],
});
const allWorkflowIds = new Set(allWorkflows.map((workflow) => workflow.id));
if (input.ids.some((id) => !allWorkflowIds.has(id))) {
throw new TRPCError({
code: "UNAUTHORIZED",
});
}
await Promise.all(
input.ids.reverse().map((id, position) => {
return prisma.workflow.update({
where: {
id: id,
},
data: {
position,
},
});
})
);
};
export function getPrismaWhereFromFilters(
user: {
id: number;
},
filters: NonNullable<TFormSchema>["filters"]
) {
const where = {
OR: [] as Prisma.App_RoutingForms_FormWhereInput[],
};
const prismaQueries: Record<
keyof NonNullable<typeof filters>,
(...args: [number[]]) => Prisma.App_RoutingForms_FormWhereInput
> & {
all: () => Prisma.App_RoutingForms_FormWhereInput;
} = {
userIds: (userIds: number[]) => ({
userId: {
in: userIds,
},
teamId: null,
}),
teamIds: (teamIds: number[]) => ({
team: {
id: {
in: teamIds ?? [],
},
members: {
some: {
userId: user.id,
accepted: true,
},
},
},
}),
all: () => ({
OR: [
{
userId: user.id,
},
{
team: {
members: {
some: {
userId: user.id,
accepted: true,
},
},
},
},
],
}),
};
if (!filters || !hasFilter(filters)) {
where.OR.push(prismaQueries.all());
} else {
for (const entry of entries(filters)) {
if (!entry) {
continue;
}
const [filterName, filter] = entry;
const getPrismaQuery = prismaQueries[filterName];
// filter might be accidentally set undefined as well
if (!getPrismaQuery || !filter) {
continue;
}
where.OR.push(getPrismaQuery(filter));
}
}
return where;
}

View File

@ -0,0 +1,7 @@
import { z } from "zod";
export const ZWorkflowOrderInputSchema = z.object({
ids: z.array(z.number()),
});
export type TWorkflowOrderInputSchema = z.infer<typeof ZWorkflowOrderInputSchema>;

View File

@ -172,7 +172,11 @@ export const confirmHandler = async ({ ctx, input }: ConfirmOptions) => {
attendees: attendeesList,
location: booking.location ?? "",
uid: booking.uid,
destinationCalendar: booking?.destinationCalendar || user.destinationCalendar,
destinationCalendar: booking?.destinationCalendar
? [booking.destinationCalendar]
: user.destinationCalendar
? [user.destinationCalendar]
: [],
requiresConfirmation: booking?.eventType?.requiresConfirmation ?? false,
eventTypeId: booking.eventType?.id,
};

View File

@ -82,7 +82,11 @@ export const editLocationHandler = async ({ ctx, input }: EditLocationOptions) =
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
location,
conferenceCredentialId: details?.credentialId,
destinationCalendar: booking?.destinationCalendar || booking?.user?.destinationCalendar,
destinationCalendar: booking?.destinationCalendar
? [booking?.destinationCalendar]
: booking?.user?.destinationCalendar
? [booking?.user?.destinationCalendar]
: [],
seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot,
seatsShowAttendees: booking.eventType?.seatsShowAttendees,
};

View File

@ -237,7 +237,9 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule
),
uid: bookingToReschedule?.uid,
location: bookingToReschedule?.location,
destinationCalendar: bookingToReschedule?.destinationCalendar || bookingToReschedule?.destinationCalendar,
destinationCalendar: bookingToReschedule?.destinationCalendar
? [bookingToReschedule?.destinationCalendar]
: [],
cancellationReason: `Please reschedule. ${cancellationReason}`, // TODO::Add i18-next for this
};

View File

@ -260,6 +260,9 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
const { default_currency } = stripeDataSchema.parse(paymentCredential.key);
data.currency = default_currency;
}
if (paymentCredential?.type === "paypal_payment" && input.metadata?.apps?.paypal?.currency) {
data.currency = input.metadata?.apps?.paypal?.currency.toLowerCase();
}
}
const connectedLink = await ctx.prisma.hashedLink.findFirst({

View File

@ -148,9 +148,9 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
organization: {
create: {
name,
...(!IS_TEAM_BILLING_ENABLED && { slug }),
...(IS_TEAM_BILLING_ENABLED ? { slug } : {}),
metadata: {
...(IS_TEAM_BILLING_ENABLED && { requestedSlug: slug }),
...(IS_TEAM_BILLING_ENABLED ? { requestedSlug: slug } : {}),
isOrganization: true,
isOrganizationVerified: false,
isOrganizationConfigured,

View File

@ -24,11 +24,12 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
// A user can only have one org so we pass in their currentOrgId here
const currentOrgId = ctx.user?.organization?.id || input.orgId;
if (!currentOrgId || ctx.user.role !== UserPermissionRole.ADMIN)
throw new TRPCError({ code: "UNAUTHORIZED" });
const isUserOrganizationAdmin = currentOrgId && (await isOrganisationAdmin(ctx.user?.id, currentOrgId));
const isUserRoleAdmin = ctx.user.role === UserPermissionRole.ADMIN;
if (!(await isOrganisationAdmin(ctx.user?.id, currentOrgId)) || ctx.user.role !== UserPermissionRole.ADMIN)
throw new TRPCError({ code: "UNAUTHORIZED" });
const isUserAuthorizedToUpdate = !!(isUserOrganizationAdmin || isUserRoleAdmin);
if (!currentOrgId || !isUserAuthorizedToUpdate) throw new TRPCError({ code: "UNAUTHORIZED" });
if (input.slug) {
const userConflict = await prisma.team.findMany({

View File

@ -56,7 +56,7 @@ export const inviteMemberHandler = async ({ ctx, input }: InviteMemberOptions) =
});
const invitee = await getUserToInviteOrThrowIfExists({
usernameOrEmail,
orgId: input.teamId,
teamId: input.teamId,
isOrg: input.isOrg,
});

View File

@ -66,17 +66,21 @@ export async function getEmailsToInvite(usernameOrEmail: string | string[]) {
export async function getUserToInviteOrThrowIfExists({
usernameOrEmail,
orgId,
teamId,
isOrg,
}: {
usernameOrEmail: string;
orgId: number;
teamId: number;
isOrg?: boolean;
}) {
// Check if user exists in ORG or exists all together
const orgWhere = isOrg && {
organizationId: teamId,
};
const invitee = await prisma.user.findFirst({
where: {
OR: [{ username: usernameOrEmail, organizationId: orgId }, { email: usernameOrEmail }],
OR: [{ username: usernameOrEmail, ...orgWhere }, { email: usernameOrEmail }],
},
});

View File

@ -70,9 +70,14 @@ export const filteredListHandler = async ({ ctx, input }: FilteredListOptions) =
],
},
include: includedFields,
orderBy: {
id: "asc",
},
orderBy: [
{
position: "desc",
},
{
id: "asc",
},
],
});
if (!filtered) {

View File

@ -8,6 +8,7 @@ import type z from "zod";
import type { bookingResponse } from "@calcom/features/bookings/lib/getBookingResponsesSchema";
import type { Calendar } from "@calcom/features/calendars/weeklyview";
import type { TimeFormat } from "@calcom/lib/timeFormat";
import type { SchedulingType } from "@calcom/prisma/enums";
import type { Frequency } from "@calcom/prisma/zod-utils";
import type { CredentialPayload } from "@calcom/types/Credential";
@ -167,7 +168,7 @@ export interface CalendarEvent {
videoCallData?: VideoCallData;
paymentInfo?: PaymentInfo | null;
requiresConfirmation?: boolean | null;
destinationCalendar?: DestinationCalendar | null;
destinationCalendar?: DestinationCalendar[] | null;
cancellationReason?: string | null;
rejectionReason?: string | null;
hideCalendarNotes?: boolean;
@ -178,6 +179,7 @@ export interface CalendarEvent {
seatsShowAttendees?: boolean | null;
attendeeSeatId?: string;
seatsPerTimeSlot?: number | null;
schedulingType?: SchedulingType | null;
iCalUID?: string | null;
// It has responses to all the fields(system + user)
@ -216,7 +218,7 @@ export interface IntegrationCalendar extends Ensure<Partial<SelectedCalendar>, "
}
export interface Calendar {
createEvent(event: CalendarEvent): Promise<NewCalendarEventType>;
createEvent(event: CalendarEvent, credentialId: number): Promise<NewCalendarEventType>;
updateEvent(
uid: string,

View File

@ -23,6 +23,7 @@ export interface EventResult<T> {
calError?: string;
calWarnings?: string[];
credentialId?: number;
externalId?: string | null;
}
export interface CreateUpdateResult {

View File

@ -14,7 +14,8 @@ export interface IAbstractPaymentService {
payment: Pick<Prisma.PaymentUncheckedCreateInput, "amount" | "currency">,
bookingId: Booking["id"],
bookerEmail: string,
paymentOption: PaymentOption
paymentOption: PaymentOption,
eventTitle?: string
): Promise<Payment>;
/* This method is to collect card details to charge at a later date ex. no-show fees */
collectCard(

View File

@ -0,0 +1,26 @@
import { ArrowUp, ArrowDown } from "@calcom/ui/components/icon";
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-[5px] -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-[36px]"
onClick={props.onClick}>
<ArrowUp className="h-5 w-5" />
</button>
) : (
<button
className="bg-default text-muted border-default hover:text-emphasis hover:border-emphasis invisible absolute left-[5px] -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-[36px]"
onClick={props.onClick}>
<ArrowDown className="h-5 w-5" />
</button>
)}
</>
);
}

View File

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

View File

@ -40,7 +40,7 @@ export function Avatar(props: AvatarProps) {
let avatar = (
<AvatarPrimitive.Root
className={classNames(
"bg-emphasis item-center relative aspect-square justify-center overflow-hidden rounded-full",
"bg-emphasis item-center relative inline-flex aspect-square justify-center overflow-hidden rounded-full",
props.className,
sizesPropsBySize[size]
)}>

View File

@ -52,7 +52,7 @@ export const buttonClasses = cva(
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 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",
"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 */,

View File

@ -1,3 +1,4 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import {
@ -17,7 +18,7 @@ import { Button } from "./Button";
<Meta title="UI/Button" component={Button} />
<Title title="Buttons" suffix="Brief" subtitle="Version 2.0 — Last Update: 22 Aug 2022" />
<Title title="Buttons" suffix="Brief" subtitle="Version 2.1 — Last Update: 24 Aug 2023" />
## Definition
@ -170,6 +171,15 @@ Button are clickable elements that initiates user actions. Labels in the button
</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",
@ -177,6 +187,7 @@ Button are clickable elements that initiates user actions. Labels in the button
disabled: false,
children: "Button text",
className: "",
tooltip: "tooltip",
}}
argTypes={{
color: {
@ -212,19 +223,20 @@ Button are clickable elements that initiates user actions. Labels in the button
options: ["", "sb-pseudo--hover", "sb-pseudo--focus"],
},
},
tooltip: {
control: {
type: "text",
},
},
}}>
{({ color, size, loading, disabled, children, className }) => (
{({ children, ...args }) => (
<VariantsTable titles={["Light & Dark Modes"]} columnMinWidth={150}>
<VariantRow variant="Button">
<Button
color={color}
size={size}
variant="default"
loading={loading}
disabled={disabled}
className={className}>
{children}
</Button>
<TooltipProvider>
<Button variant="default" {...args}>
{children}
</Button>
</TooltipProvider>
</VariantRow>
</VariantsTable>
)}

View File

@ -41,7 +41,7 @@ export function ConfirmationDialogContent(props: PropsWithChildren<ConfirmationD
<div className="mt-0.5 ltr:mr-3">
{variety === "danger" && (
<div className="bg-error mx-auto rounded-full p-2 text-center">
<AlertCircle className="h-5 w-5 text-red-600" />
<AlertCircle className="h-5 w-5 text-red-600 dark:text-red-100" />
</div>
)}
{variety === "warning" && (

View File

@ -0,0 +1,64 @@
import { Canvas, Meta, Story } from "@storybook/addon-docs";
import {
Examples,
Example,
Title,
VariantsTable,
CustomArgsTable,
VariantRow,
} from "@calcom/storybook/components";
import ErrorBoundary from "./ErrorBoundary";
import { Tooltip } from "../tooltip";
<Meta title="UI/ErrorBoundary" component={ErrorBoundary} />
<Title title="ErrorBoundary" suffix="Brief" subtitle="Version 1.0 — Last Update: 17 Aug 2023" />
## Definition
ErrorBoundary is an element that catches JavaScript errors in their child component tree, log those errors and display a fallback UI.
## Structure
ErrorBoundary offers a flexible component capable of catching JavaScript errors and displaying it in the UI.
<CustomArgsTable of={ErrorBoundary} />
<Examples title="ErrorBoundary">
<Example title="Default">
<ErrorBoundary message="There is a problem with the App">
<p>Child Component</p>
</ErrorBoundary>
</Example>
<Example title="With Error">
<ErrorBoundary message="There is a problem with the App">
<Tooltip />
</ErrorBoundary>
</Example>
</Examples>
<Title offset title="ErrorBoundary" suffix="Variants" />
<Canvas>
<Story
name="ErrorBoundary"
args={{
children: <p class="text-default">Child Component</p>,
message: "There is a problem with the App",
}}>
{({ children, message }) => (
<VariantsTable titles={["Default"]} columnMinWidth={150}>
<VariantRow variant="Default">
<ErrorBoundary message={message}>{children}</ErrorBoundary>
</VariantRow>
<VariantRow variant="With Error">
<ErrorBoundary message={message}>
<Tooltip />
</ErrorBoundary>
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>

View File

@ -0,0 +1,84 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { Canvas, Meta, Story } from "@storybook/addon-docs";
import {
Example,
Title,
CustomArgsTable,
VariantRow,
VariantsTable,
} from "@calcom/storybook/components";
import ColorPicker from "./colorpicker";
<Meta title="UI/Form/ColorPicker" component={ColorPicker} />
<Title title="ColorPicker" suffix="Brief" subtitle="Version 1.0 — Last Update: 16 Aug 2023" />
## Definitions
`Color Picker` is used to select custom hex colors for from a range of values.
## Structure
The `Color Picker` takes in several props
<CustomArgsTable of={ColorPicker} />
## Default:
<Example title="Default">
<ColorPicker defaultValue="#000000" />
</Example>
## ColorPicker Story
<Canvas>
<Story
name="Default"
args={{
defaultValue: "#21aef3",
onChange: (value) => {
console.debug(value);
},
resetDefaultValue: "#000000",
className: "w-[200px]",
popoverAlign: "start",
}}
argTypes={{
defaultValue: {
control: {
type: "text",
},
},
resetDefaultValue: {
control: {
type: "text",
},
},
popoverAlign: {
control: {
type: "inline-radio",
options: ["center", "start", "end"],
},
},
}}>
{({ defaultValue, onChange, resetDefaultValue, className, popoverAlign }) => (
<VariantsTable titles={["Default"]} columnMinWidth={150}>
<VariantRow>
<TooltipProvider>
<Tooltip content="color picker">
<ColorPicker
defaultValue={defaultValue}
onChange={onChange}
resetDefaultValue={resetDefaultValue}
className={className}
popoverAlign={popoverAlign}
/>
</Tooltip>
</TooltipProvider>
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>

View File

@ -138,7 +138,9 @@ export const DropdownItem = (props: DropdownItemProps) => {
{...rest}
className={classNames(
"hover:text-emphasis text-default inline-flex w-full items-center space-x-2 px-3 py-2 disabled:cursor-not-allowed",
color === "destructive" ? "hover:bg-error hover:text-red-700" : "hover:bg-subtle",
color === "destructive"
? "hover:bg-error hover:text-red-700 dark:hover:text-red-100"
: "hover:bg-subtle",
props.className
)}>
<>

View File

@ -0,0 +1,361 @@
import { Canvas, Story, Meta } from "@storybook/addon-docs";
import { Button } from "@calcom/ui";
import {
Examples,
Example,
Title,
CustomArgsTable,
VariantRow,
VariantsTable,
} from "@calcom/storybook/components";
import { Plus, Trash, Copy } from "@calcom/ui/components/icon";
import {
Dropdown,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownItem,
DropdownMenuLabel,
DropdownMenuSeparator,
} from "./Dropdown";
<Meta title="UI/Form/Dropdown" component={Dropdown} />
<Title title="Dropdown" suffix="Brief" subtitle="Version 1.0 — Last Update: 29 Aug 2023" />
## Definition
`Dropdown` is an element that displays a menu to the user—such as a set of actions or functions.
## Structure
The `Dropdown` component can be used to display a menu to the user.
<CustomArgsTable of={Dropdown} />
### Dropdown components that have arguments:
#### DropdownMenuTrigger
<CustomArgsTable of={DropdownMenuTrigger} />
#### DropdownMenuContent
<CustomArgsTable of={DropdownMenuContent} />
#### DropdownMenuItem
<CustomArgsTable of={DropdownMenuItem} />
#### DropdownMenuSeparator
<CustomArgsTable of={DropdownMenuSeparator} />
## Examples
<Examples title="Dropdown">
<Example title="Simple">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
<Example title="No modal">
<Dropdown modal={false}>
<DropdownMenuTrigger asChild>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
</Examples>
<Examples title="Dropdown Menu Trigger">
<Example title="Simple Button">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
<Example title="Button Icon">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button StartIcon={Plus} />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
<Example title="Disabled">
<Dropdown>
<DropdownMenuTrigger disabled={true} asChild>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
<Example title="Not as child">
<Dropdown>
<DropdownMenuTrigger>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
</Examples>
<Examples title="Dropdown Menu Content">
<Example title="Custom width">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent style={{ minWidth: "200px" }}>
<DropdownMenuItem>
<DropdownItem>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
<Example title="Align start">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent style={{ minWidth: "80px" }} align={"start"}>
<DropdownMenuItem>
<DropdownItem>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
<Example title="Align center">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent style={{ minWidth: "80px" }} align={"center"}>
<DropdownMenuItem>
<DropdownItem>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
<Example title="With Menu Separator">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<DropdownItem>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
<Example title="With side offset">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={50}>
<DropdownMenuItem>
<DropdownItem>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
<Example title="With Some icons">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem>
<DropdownItem StartIcon={Trash}>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem StartIcon={Plus}>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem>
<DropdownItem StartIcon={Copy}>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
</Examples>
<Examples title="Dropdown Menu Label">
<Example title="Simple">
<Dropdown>
<DropdownMenuTrigger asChild>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={"start"}>
<DropdownMenuLabel>Number</DropdownMenuLabel>
<DropdownMenuItem>
<DropdownItem>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</Example>
</Examples>
## Dropdown Story
<Canvas>
<Story
name="Dropdown"
args={{
asChild: false,
align: "start",
disabled: false,
sideOffset: 0
}}
argTypes={{
sideOffset: {
control: {
type: "number",
},
},
asChild: {
control: {
type: "boolean"
}
},
align: {
control: {
type: "inline-radio",
options: ["center", "start", "end"],
},
},
disabled: {
control: {
type: "boolean"
}
}
}}>
{({ dir, asChild, sideOffset, align, disabled }) => (
<VariantsTable titles={["Default"]} columnMinWidth={150}>
<VariantRow>
<Dropdown>
<DropdownMenuTrigger disabled={disabled} asChild>
<Button>more</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align={align} sideOffset={sideOffset}>
<DropdownMenuItem>
<DropdownItem StartIcon={Trash}>First</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem StartIcon={Plus}>Second</DropdownItem>
</DropdownMenuItem>
<DropdownMenuItem>
<DropdownItem StartIcon={Copy}>Third</DropdownItem>
</DropdownMenuItem>
</DropdownMenuContent>
</Dropdown>
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>

View File

@ -1,9 +1,8 @@
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import { Canvas, Meta, Story } from "@storybook/addon-docs";
import {
Examples,
Example,
Note,
Title,
CustomArgsTable,
VariantRow,
@ -15,7 +14,7 @@ import { SelectField } from "./Select";
<Meta title="UI/Form/Select Field" component={SelectField} />
<Title title="Select" suffix="Brief" subtitle="Version 2.0 — Last Update: 22 Aug 2022" />
<Title title="Select" suffix="Brief" subtitle="Version 2.0 — Last Update: 29 Aug 2022" />
## Definition
@ -101,11 +100,31 @@ const GoodSelect = (props) => <Select {...props} components={{ Control }} />;
## Select Story
<Canvas>
<Story name="Default">
<VariantsTable titles={["Default"]} columnMinWidth={450}>
<VariantRow>
<SelectField options={options} label={"Default Select"} />
</VariantRow>
</VariantsTable>
<Story
name="Default"
args={{
required: false,
name: "select-field",
error: "Some error",
variant: "default",
label: "Select an item",
isMulti: false,
options,
}}
argTypes={{
variant: {
control: {
type: "select",
options: ["default", "checkbox"],
},
},
}}>
{(args) => (
<VariantsTable titles={["Default"]} columnMinWidth={300}>
<VariantRow>
<SelectField {...args} />
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>

View File

@ -0,0 +1,97 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import {
Examples,
Example,
Title,
VariantsTable,
CustomArgsTable,
VariantRow,
} from "@calcom/storybook/components";
import Switch from "./Switch";
<Meta title="UI/Form/Switch" component={Switch} />
<Title title="Switch" suffix="Brief" subtitle="Version 1.0 — Last Update: 16 Aug 2023" />
## Definition
Switch is a customizable toggle switch component that allows users to change between two states.
## Structure
The `Switch` component can be used to create toggle switches for various purposes. It provides options for adding labels, icons, and tooltips.
<CustomArgsTable of={Switch} />
<Examples title="States">
<Example title="Default">
<Switch />
</Example>
<Example title="Disabled">
<Switch disabled />
</Example>
<Example title="Checked">
<Switch checked />
</Example>
</Examples>
<Examples title="Labels">
<Example title="With Label and labelOnLeading">
<Switch label="Enable Feature" labelOnLeading />
</Example>
<Example title="With Label">
<Switch label="Enable Feature" />
</Example>
</Examples>
<Examples title="Hover">
<Example title="With Tooltip (Hover me)">
<TooltipProvider>
<Switch tooltip="Toggle to enable/disable the feature" />
</TooltipProvider>
</Example>
<Example title="Without Tooltip (Hover me)">
<TooltipProvider>
<Switch />
</TooltipProvider>
</Example>
</Examples>
<Title offset title="Switch" suffix="Variants" />
<Canvas>
<Story
name="Switch"
args={{
label: "Enable Feature",
tooltip: "Toggle to enable/disable the feature",
checked: false,
disabled: false,
fitToHeight: false,
labelOnLeading: false,
}}
argTypes={{
label: { control: { type: "text" } },
tooltip: { control: { type: "text" } },
checked: { control: { type: "boolean" } },
disabled: { control: { type: "boolean" } },
fitToHeight: { control: { type: "boolean" } },
labelOnLeading: { control: { type: "boolean" } },
}}>
{(props) => (
<TooltipProvider>
<VariantsTable titles={["Default"]} columnMinWidth={150}>
<VariantRow>
<Switch
{...props}
onCheckedChange={(checkedValue) => console.log("Switch value:", checkedValue)}
/>
</VariantRow>
</VariantsTable>
</TooltipProvider>
)}
</Story>
</Canvas>

View File

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

View File

@ -49,6 +49,7 @@ export const ToggleGroup = ({ options, onValueChange, isFullWidth, ...props }: T
<RadixToggleGroup.Item
disabled={option.disabled}
value={option.value}
data-testid={`toggle-group-item-${option.value}`}
className={classNames(
"aria-checked:bg-emphasis relative rounded-[4px] px-3 py-1 text-sm leading-tight transition-colors",
option.disabled

View File

@ -0,0 +1,108 @@
import { TooltipProvider } from "@radix-ui/react-tooltip";
import { Canvas, Meta, Story } from "@storybook/addon-docs";
import {
CustomArgsTable,
Examples,
Example,
Title,
VariantsTable,
VariantRow,
} from "@calcom/storybook/components";
import { ArrowRight } from "@calcom/ui/components/icon";
import { ToggleGroup } from "./ToggleGroup";
<Meta title="UI/Form/ToggleGroup" component={ToggleGroup} />
<Title title="ToggleGroup" suffix="Brief" subtitle="Version 1.0 — Last Update: 17 Aug 2023" />
## Definition
The `ToggleGroup` component is used to create a group of toggle items with optional tooltips.
## Structure
<CustomArgsTable of={ToggleGroup} />
## Examples
<Examples title="Toggle Group With Icon Left">
<Example>
<TooltipProvider>
<ToggleGroup
options={[
{ value: "option1", label: "Option 1", tooltip: "Tooltip for Option 1", iconLeft: <ArrowRight /> },
{ value: "option2", label: "Option 2", iconLeft: <ArrowRight /> },
{ value: "option3", label: "Option 3", iconLeft: <ArrowRight /> },
{
value: "option4",
label: "Option 4",
tooltip: "Tooltip for Option 4",
iconLeft: <ArrowRight />,
},
{ value: "option5", label: "Option 5", iconLeft: <ArrowRight />, disabled: true },
]}
/>
</TooltipProvider>
</Example>
</Examples>
## ToggleGroup Story
<Canvas>
<Story
name="Default"
args={{
options: [
{ value: "option1", label: "Option 1", tooltip: "Tooltip for Option 1" },
{ value: "option2", label: "Option 2" },
{ value: "option3", label: "Option 3" },
{
value: "option4",
label: "Option 4",
tooltip: "Tooltip for Option 4",
},
{ value: "option5", label: "Option 5", disabled: true },
],
}}
argTypes={{
options: {
value: {
control: {
type: "text",
},
},
lable: {
control: {
type: "text",
},
},
tooltip: {
control: {
type: "text",
},
},
disabled: {
control: {
type: "boolean",
},
},
isFullWidth: {
control: {
type: "boolean",
},
},
},
}}>
{({ options, isFullWidth }) => (
<VariantsTable titles={["Default"]} columnMinWidth={150}>
<VariantRow>
<TooltipProvider>
<ToggleGroup options={options} isFullWidth={isFullWidth} />
</TooltipProvider>
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>

View File

@ -10,20 +10,88 @@ import {
VariantRow,
} from "@calcom/storybook/components";
import { List, ListItem } from "./List";
import { List, ListItem, ListItemTitle, ListItemText } from "./List";
export const listItems = [
{ title: "Title 1", description: "Description 1" },
{ title: "Title 2", description: "Description 2" },
{ title: "Title 3", description: "Description 3" },
];
<Meta title="UI/List" component={List} />
<Title title="List" suffix="Brief" subtitle="Version 2.0 — Last Update: 05 jan 2023" />
<Title title="List" suffix="Brief" subtitle="Version 2.0 — Last Update: 24 Aug 2023" />
## Definition
Sums it up nicely.
The List component is used to render an unordered list with default styling
## Structure
List takes an array of objects to display a list in the UI
### List
<CustomArgsTable of={List} />
### ListItem
<CustomArgsTable of={ListItem} />
<Examples>
<Example title="Default">
<List>
{listItems.map((item) => (
<ListItem rounded={false}>
<ListItemTitle className="mr-2">{item.title}</ListItemTitle>
<ListItemText>{item.description}</ListItemText>
</ListItem>
))}
</List>
</Example>
<Example title="Round Container">
<List roundContainer={false}>
{listItems.map((item) => (
<ListItem rounded={false}>
<ListItemTitle className="mr-2">{item.title}</ListItemTitle>
<ListItemText>{item.description}</ListItemText>
</ListItem>
))}
</List>
</Example>
<Example title="No Border Treatment">
<List noBorderTreatment={true}>
{listItems.map((item) => (
<ListItem rounded={false}>
<ListItemTitle className="mr-2">{item.title}</ListItemTitle>
<ListItemText>{item.description}</ListItemText>
</ListItem>
))}
</List>
</Example>
</Examples>
<Title offset title="List" suffix="Variants" />
<Canvas>
<Story name="List">
<VariantsTable titles={[]} columnMinWidth={150}>
<VariantRow variant="Default">TODO!</VariantRow>
</VariantsTable>
<Story
name="List"
args={{
roundContainer: true,
noBorderTreatment: false,
rounded: false,
expanded: false
}}>
{({ roundContainer, noBorderTreatment, rounded, expanded }) => (
<VariantsTable titles={["Default"]} columnMinWidth={150}>
<VariantRow>
<List roundContainer={roundContainer} noBorderTreatment={noBorderTreatment}>
{listItems.map((item) => (
<ListItem rounded={rounded} expanded={expanded}>
<ListItemTitle className="mr-2">{item.title}</ListItemTitle>
<ListItemText>{item.description}</ListItemText>
</ListItem>
))}
</List>
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>

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