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", "langchain": "^0.0.131",
"mailparser": "^3.6.5", "mailparser": "^3.6.5",
"next": "^13.4.6", "next": "^13.4.6",
"zod": "^3.20.2" "supports-color": "8.1.1",
"zod": "^3.22.2"
}, },
"devDependencies": { "devDependencies": {
"@types/mailparser": "^3.4.0", "@types/mailparser": "^3.4.0"
"@types/node": "^20.5.1",
"typescript": "^4.9.4"
}, },
"scripts": { "scripts": {
"build": "next build", "build": "next build",

View File

@ -23,6 +23,7 @@ export const env = createEnv({
NODE_ENV: process.env.NODE_ENV, NODE_ENV: process.env.NODE_ENV,
OPENAI_API_KEY: process.env.OPENAI_API_KEY, OPENAI_API_KEY: process.env.OPENAI_API_KEY,
SENDGRID_API_KEY: process.env.SENDGRID_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"]), NODE_ENV: z.enum(["development", "test", "production"]),
OPENAI_API_KEY: z.string().min(1), OPENAI_API_KEY: z.string().min(1),
SENDGRID_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", "typescript": "^4.9.4",
"tzdata": "^1.0.30", "tzdata": "^1.0.30",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"zod": "^3.20.2" "zod": "^3.22.2"
} }
} }

View File

@ -248,10 +248,10 @@
--cal-bg-inverted: #f3f4f6; --cal-bg-inverted: #f3f4f6;
/* background -> components*/ /* background -> components*/
--cal-bg-info: #dee9fc; --cal-bg-info: #263fa9;
--cal-bg-success: #e2fbe8; --cal-bg-success: #306339;
--cal-bg-attention: #fceed8; --cal-bg-attention: #8e3b1f;
--cal-bg-error: #f9e3e2; --cal-bg-error: #8c2822;
--cal-bg-dark-error: #752522; --cal-bg-dark-error: #752522;
/* Borders */ /* Borders */
@ -269,10 +269,10 @@
--cal-text-inverted: #101010; --cal-text-inverted: #101010;
/* Content/Text -> components */ /* Content/Text -> components */
--cal-text-info: #253985; --cal-text-info: #dee9fc;
--cal-text-success: #285231; --cal-text-success: #e2fbe8;
--cal-text-attention: #73321b; --cal-text-attention: #fceed8;
--cal-text-error: #752522; --cal-text-error: #f9e3e2;
/* Brand shenanigans /* Brand shenanigans
-> These will be computed for the users theme at runtime. -> 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.NEXTAUTH_SECRET) throw new Error("Please set NEXTAUTH_SECRET");
if (!process.env.CALENDSO_ENCRYPTION_KEY) throw new Error("Please set CALENDSO_ENCRYPTION_KEY"); 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 // To be able to use the version in the app without having to import package.json
process.env.NEXT_PUBLIC_CALCOM_VERSION = version; process.env.NEXT_PUBLIC_CALCOM_VERSION = version;
@ -226,7 +227,7 @@ const nextConfig = {
async rewrites() { async rewrites() {
const beforeFiles = [ 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 // 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, ...matcherConfigRootPath,
@ -333,44 +334,46 @@ const nextConfig = {
}, },
], ],
}, },
...[ ...(isOrganizationsEnabled
{ ? [
...matcherConfigRootPath,
headers: [
{ {
key: "X-Cal-Org-path", ...matcherConfigRootPath,
value: "/team/:orgSlug", headers: [
{
key: "X-Cal-Org-path",
value: "/team/:orgSlug",
},
],
}, },
],
},
{
...matcherConfigUserRoute,
headers: [
{ {
key: "X-Cal-Org-path", ...matcherConfigUserRoute,
value: "/org/:orgSlug/:user", headers: [
{
key: "X-Cal-Org-path",
value: "/org/:orgSlug/:user",
},
],
}, },
],
},
{
...matcherConfigUserTypeRoute,
headers: [
{ {
key: "X-Cal-Org-path", ...matcherConfigUserTypeRoute,
value: "/org/:orgSlug/:user/:type", headers: [
{
key: "X-Cal-Org-path",
value: "/org/:orgSlug/:user/:type",
},
],
}, },
],
},
{
...matcherConfigUserTypeEmbedRoute,
headers: [
{ {
key: "X-Cal-Org-path", ...matcherConfigUserTypeEmbedRoute,
value: "/org/:orgSlug/:user/:type/embed", headers: [
{
key: "X-Cal-Org-path",
value: "/org/:orgSlug/:user/:type/embed",
},
],
}, },
], ]
}, : []),
],
]; ];
}, },
async redirects() { async redirects() {
@ -447,6 +450,13 @@ const nextConfig = {
}, },
{ {
source: "/support", source: "/support",
missing: [
{
type: "header",
key: "host",
value: orgHostPath,
},
],
destination: "/event-types?openIntercom=true", destination: "/event-types?openIntercom=true",
permanent: 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 // OAuth callbacks when sent to localhost:3000(w would be expected) should be redirected to corresponding to WEBAPP_URL
...(process.env.NODE_ENV === "development" && ...(process.env.NODE_ENV === "development" &&
// Safer to enable the redirect only when the user is opting to test out organizations // 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 // Prevent infinite redirect by checking that we aren't already on localhost
process.env.NEXT_PUBLIC_WEBAPP_URL !== "http://localhost:3000" process.env.NEXT_PUBLIC_WEBAPP_URL !== "http://localhost:3000"
? [ ? [

View File

@ -1,6 +1,6 @@
{ {
"name": "@calcom/web", "name": "@calcom/web",
"version": "3.2.6", "version": "3.2.7.1",
"private": true, "private": true,
"scripts": { "scripts": {
"analyze": "ANALYZE=true next build", "analyze": "ANALYZE=true next build",
@ -129,7 +129,7 @@
"turndown": "^7.1.1", "turndown": "^7.1.1",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"web3": "^1.7.5", "web3": "^1.7.5",
"zod": "^3.20.2" "zod": "^3.22.2"
}, },
"devDependencies": { "devDependencies": {
"@babel/core": "^7.19.6", "@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 attendeesList = await Promise.all(attendeesListPromises);
const selectedDestinationCalendar = booking.destinationCalendar || user.destinationCalendar;
const evt: CalendarEvent = { const evt: CalendarEvent = {
type: booking.title, type: booking.title,
title: booking.title, title: booking.title,
@ -127,7 +127,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
attendees: attendeesList, attendees: attendeesList,
uid: booking.uid, uid: booking.uid,
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent), recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
destinationCalendar: booking.destinationCalendar || user.destinationCalendar, destinationCalendar: selectedDestinationCalendar ? [selectedDestinationCalendar] : [],
}; };
await sendOrganizerRequestReminderEmail(evt); await sendOrganizerRequestReminderEmail(evt);

View File

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

View File

@ -55,10 +55,9 @@ import {
Skeleton, Skeleton,
Switch, Switch,
Tooltip, Tooltip,
ArrowButton,
} from "@calcom/ui"; } from "@calcom/ui";
import { import {
ArrowDown,
ArrowUp,
Clipboard, Clipboard,
Code, Code,
Copy, 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="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"> <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) && ( {!(firstItem && firstItem.id === eventType.id) && (
<button <ArrowButton onClick={() => moveEventType(index, -1)} arrowDirection="up" />
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>
)} )}
{!(lastItem && lastItem.id === eventType.id) && ( {!(lastItem && lastItem.id === eventType.id) && (
<button <ArrowButton onClick={() => moveEventType(index, 1)} arrowDirection="down" />
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>
)} )}
<MemoizedItem eventType={eventType} /> <MemoizedItem eventType={eventType} />
<div className="mt-4 hidden sm:mt-0 sm:flex"> <div className="mt-4 hidden sm:mt-0 sm:flex">
@ -887,7 +878,7 @@ const Main = ({
{isMobile ? ( {isMobile ? (
<MobileTeamsTab eventTypeGroups={data} /> <MobileTeamsTab eventTypeGroups={data} />
) : ( ) : (
<div className="flex flex-col"> <div className="mt-4 flex flex-col" key={group.profile.slug}>
<EventTypeListHeading <EventTypeListHeading
profile={data[0].users[0] || data[0].team} profile={data[0].users[0] || data[0].team}
membershipCount={data[0].team?.members.length || 0} membershipCount={data[0].team?.members.length || 0}

View File

@ -25,7 +25,7 @@ const otherNonExistingRoutePrefixes = ["forms", "router", "success", "cancel"];
let subdomainRegExp = (exports.subdomainRegExp = getSubdomainRegExp( let subdomainRegExp = (exports.subdomainRegExp = getSubdomainRegExp(
process.env.NEXT_PUBLIC_WEBAPP_URL || "https://" + process.env.VERCEL_URL 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); let beforeRewriteExcludePages = pages.concat(otherNonExistingRoutePrefixes);
exports.orgUserRoutePath = `/:user((?!${beforeRewriteExcludePages.join("|")}|_next|public)[a-zA-Z0-9\-_]+)`; 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]", location: "[redacted/dynamic]",
destinationCalendar: null, destinationCalendar: [],
// hideCalendarNotes: false, // hideCalendarNotes: false,
requiresConfirmation: "[redacted/dynamic]", requiresConfirmation: "[redacted/dynamic]",
eventTypeId: "[redacted/dynamic]", eventTypeId: "[redacted/dynamic]",

View File

@ -529,7 +529,7 @@
"location": "Ort", "location": "Ort",
"address": "Adresse", "address": "Adresse",
"enter_address": "Geben Sie eine Adresse ein", "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", "yes": "Ja",
"no": "Nein", "no": "Nein",
"additional_notes": "Zusätzliche Notizen", "additional_notes": "Zusätzliche Notizen",
@ -539,7 +539,7 @@
"booking_confirmation": "Bestätigen Sie {{eventTypeTitle}} mit {{profileName}}", "booking_confirmation": "Bestätigen Sie {{eventTypeTitle}} mit {{profileName}}",
"booking_reschedule_confirmation": "Planen Sie Ihr {{eventTypeTitle}} mit {{profileName}} um", "booking_reschedule_confirmation": "Planen Sie Ihr {{eventTypeTitle}} mit {{profileName}} um",
"in_person_meeting": "Vor-Ort-Termin", "in_person_meeting": "Vor-Ort-Termin",
"in_person": "Persönlich (Organisator-Adresse)", "in_person": "Vor Ort (Organisator-Adresse)",
"link_meeting": "Termin verknüpfen", "link_meeting": "Termin verknüpfen",
"phone_number": "Telefonnummer", "phone_number": "Telefonnummer",
"attendee_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.", "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_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", "set_availability": "Set your availability",
"availability_settings": "Availability Settings",
"continue_without_calendar": "Continue without calendar", "continue_without_calendar": "Continue without calendar",
"connect_your_calendar": "Connect your calendar", "connect_your_calendar": "Connect your calendar",
"connect_your_video_app": "Connect your video apps", "connect_your_video_app": "Connect your video apps",
@ -1107,6 +1108,7 @@
"email_attendee_action": "send email to attendees", "email_attendee_action": "send email to attendees",
"sms_attendee_action": "Send SMS to attendee", "sms_attendee_action": "Send SMS to attendee",
"sms_number_action": "send SMS to a specific number", "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_number_action": "send WhatsApp message to a specific number",
"whatsapp_attendee_action": "send WhatsApp message to attendee", "whatsapp_attendee_action": "send WhatsApp message to attendee",
"workflows": "Workflows", "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.", "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_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", "set_availability": "Définissez vos disponibilités",
"availability_settings": "Paramètres de disponibilité",
"continue_without_calendar": "Continuer sans calendrier", "continue_without_calendar": "Continuer sans calendrier",
"connect_your_calendar": "Connectez votre calendrier", "connect_your_calendar": "Connectez votre calendrier",
"connect_your_video_app": "Connectez vos applications vidéo", "connect_your_video_app": "Connectez vos applications vidéo",
@ -1107,6 +1108,7 @@
"email_attendee_action": "envoyer un e-mail aux participants", "email_attendee_action": "envoyer un e-mail aux participants",
"sms_attendee_action": "Envoyer un SMS au participant", "sms_attendee_action": "Envoyer un SMS au participant",
"sms_number_action": "envoyer un SMS à un numéro spécifique", "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_number_action": "envoyer un message WhatsApp à un numéro spécifique",
"whatsapp_attendee_action": "envoyer un message WhatsApp au participant", "whatsapp_attendee_action": "envoyer un message WhatsApp au participant",
"workflows": "Workflows", "workflows": "Workflows",

View File

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

View File

@ -31,7 +31,7 @@ beforeAll(async () => {
describe("next.config.js - Org Rewrite", () => { describe("next.config.js - Org Rewrite", () => {
const orgHostRegExp = (subdomainRegExp: string) => const orgHostRegExp = (subdomainRegExp: string) =>
// RegExp copied from pagesAndRewritePaths.js orgHostPath. Do make the change there as well. // 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", () => { describe("Host matching based on NEXT_PUBLIC_WEBAPP_URL", () => {
it("https://app.cal.com", () => { it("https://app.cal.com", () => {
@ -87,6 +87,11 @@ describe("next.config.js - Org Rewrite", () => {
?.orgSlug ?.orgSlug
).toEqual("some-other"); ).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", () => { 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 }) => ({ const eventAttendees = calEventRaw.attendees.map(({ id: _id, ...rest }) => ({
...rest, ...rest,
responseStatus: "accepted", responseStatus: "accepted",
@ -97,6 +97,10 @@ export default class GoogleCalendarService implements Calendar {
responseStatus: "accepted", responseStatus: "accepted",
})) || []; })) || [];
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
const [mainHostDestinationCalendar] =
calEventRaw?.destinationCalendar && calEventRaw?.destinationCalendar.length > 0
? calEventRaw.destinationCalendar
: [];
const myGoogleAuth = await this.auth.getToken(); const myGoogleAuth = await this.auth.getToken();
const payload: calendar_v3.Schema$Event = { const payload: calendar_v3.Schema$Event = {
summary: calEventRaw.title, summary: calEventRaw.title,
@ -115,8 +119,8 @@ export default class GoogleCalendarService implements Calendar {
id: String(calEventRaw.organizer.id), id: String(calEventRaw.organizer.id),
responseStatus: "accepted", responseStatus: "accepted",
organizer: true, organizer: true,
email: calEventRaw.destinationCalendar?.externalId email: mainHostDestinationCalendar?.externalId
? calEventRaw.destinationCalendar.externalId ? mainHostDestinationCalendar.externalId
: calEventRaw.organizer.email, : calEventRaw.organizer.email,
}, },
...eventAttendees, ...eventAttendees,
@ -138,13 +142,16 @@ export default class GoogleCalendarService implements Calendar {
const calendar = google.calendar({ const calendar = google.calendar({
version: "v3", version: "v3",
}); });
const selectedCalendar = calEventRaw.destinationCalendar?.externalId // Find in calEventRaw.destinationCalendar the one with the same credentialId
? calEventRaw.destinationCalendar.externalId
: "primary"; const selectedCalendar = calEventRaw.destinationCalendar?.find(
(cal) => cal.credentialId === credentialId
)?.externalId;
calendar.events.insert( calendar.events.insert(
{ {
auth: myGoogleAuth, auth: myGoogleAuth,
calendarId: selectedCalendar, calendarId: selectedCalendar || "primary",
requestBody: payload, requestBody: payload,
conferenceDataVersion: 1, conferenceDataVersion: 1,
sendUpdates: "none", sendUpdates: "none",
@ -188,6 +195,8 @@ export default class GoogleCalendarService implements Calendar {
async updateEvent(uid: string, event: CalendarEvent, externalCalendarId: string): Promise<any> { async updateEvent(uid: string, event: CalendarEvent, externalCalendarId: string): Promise<any> {
return new Promise(async (resolve, reject) => { return new Promise(async (resolve, reject) => {
const [mainHostDestinationCalendar] =
event?.destinationCalendar && event?.destinationCalendar.length > 0 ? event.destinationCalendar : [];
const myGoogleAuth = await this.auth.getToken(); const myGoogleAuth = await this.auth.getToken();
const eventAttendees = event.attendees.map(({ ...rest }) => ({ const eventAttendees = event.attendees.map(({ ...rest }) => ({
...rest, ...rest,
@ -216,8 +225,8 @@ export default class GoogleCalendarService implements Calendar {
id: String(event.organizer.id), id: String(event.organizer.id),
organizer: true, organizer: true,
responseStatus: "accepted", responseStatus: "accepted",
email: event.destinationCalendar?.externalId email: mainHostDestinationCalendar?.externalId
? event.destinationCalendar.externalId ? mainHostDestinationCalendar.externalId
: event.organizer.email, : event.organizer.email,
}, },
...(eventAttendees as any), ...(eventAttendees as any),
@ -244,7 +253,7 @@ export default class GoogleCalendarService implements Calendar {
const selectedCalendar = externalCalendarId const selectedCalendar = externalCalendarId
? externalCalendarId ? externalCalendarId
: event.destinationCalendar?.externalId; : event.destinationCalendar?.find((cal) => cal.externalId === externalCalendarId)?.externalId;
calendar.events.update( calendar.events.update(
{ {
@ -303,7 +312,9 @@ export default class GoogleCalendarService implements Calendar {
}); });
const defaultCalendarId = "primary"; 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( calendar.events.delete(
{ {

View File

@ -125,7 +125,8 @@ export default class LarkCalendarService implements Calendar {
async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> { async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
let eventId = ""; let eventId = "";
let eventRespData; let eventRespData;
const calendarId = event.destinationCalendar?.externalId; const [mainHostDestinationCalendar] = event.destinationCalendar ?? [];
const calendarId = mainHostDestinationCalendar?.externalId;
if (!calendarId) { if (!calendarId) {
throw new Error("no calendar id"); throw new Error("no calendar id");
} }
@ -160,7 +161,8 @@ export default class LarkCalendarService implements Calendar {
} }
private createAttendees = async (event: CalendarEvent, eventId: string) => { private createAttendees = async (event: CalendarEvent, eventId: string) => {
const calendarId = event.destinationCalendar?.externalId; const [mainHostDestinationCalendar] = event.destinationCalendar ?? [];
const calendarId = mainHostDestinationCalendar?.externalId;
if (!calendarId) { if (!calendarId) {
this.log.error("no calendar id provided in createAttendees"); this.log.error("no calendar id provided in createAttendees");
throw new 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) { async updateEvent(uid: string, event: CalendarEvent, externalCalendarId?: string) {
const eventId = uid; const eventId = uid;
let eventRespData; let eventRespData;
const calendarId = externalCalendarId || event.destinationCalendar?.externalId; const [mainHostDestinationCalendar] = event.destinationCalendar ?? [];
const calendarId = externalCalendarId || mainHostDestinationCalendar?.externalId;
if (!calendarId) { if (!calendarId) {
this.log.error("no calendar id provided in updateEvent"); this.log.error("no calendar id provided in updateEvent");
throw new 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 * @returns
*/ */
async deleteEvent(uid: string, event: CalendarEvent, externalCalendarId?: string) { 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) { if (!calendarId) {
this.log.error("no calendar id provided in deleteEvent"); this.log.error("no calendar id provided in deleteEvent");
throw new 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> { async createEvent(event: CalendarEvent): Promise<NewCalendarEventType> {
const [mainHostDestinationCalendar] = event.destinationCalendar ?? [];
try { try {
const eventsUrl = event.destinationCalendar?.externalId const eventsUrl = mainHostDestinationCalendar?.externalId
? `/me/calendars/${event.destinationCalendar?.externalId}/events` ? `/me/calendars/${mainHostDestinationCalendar?.externalId}/events`
: "/me/calendar/events"; : "/me/calendar/events";
const response = await this.fetcher(eventsUrl, { 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 Paypal from "@calcom/app-store/paypal/lib/Paypal";
import { findPaymentCredentials } from "@calcom/features/ee/payments/api/paypal-webhook"; import { findPaymentCredentials } from "@calcom/features/ee/payments/api/paypal-webhook";
import { IS_PRODUCTION } from "@calcom/lib/constants"; import { IS_PRODUCTION } from "@calcom/lib/constants";
import { getErrorFromUnknown } from "@calcom/lib/errors";
import prisma from "@calcom/prisma"; import prisma from "@calcom/prisma";
export default async function handler(req: NextApiRequest, res: NextApiResponse) { export default async function handler(req: NextApiRequest, res: NextApiResponse) {
@ -78,12 +77,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
} }
return; return;
} catch (_err) { } 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`); res.redirect(`/booking/${req.query.bookingUid}?paypalPaymentStatus=failed`);
} }
} }

View File

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

View File

@ -41,25 +41,6 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
}} }}
/> />
</div> </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> </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 ", "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": { "dependencies": {
"@calcom/lib": "*", "@calcom/lib": "*",
"dotenv": "^16.0.1", "dotenv": "^16.3.1",
"json-logic-js": "^2.0.2", "json-logic-js": "^2.0.2",
"react-awesome-query-builder": "^5.1.2" "react-awesome-query-builder": "^5.1.2"
}, },

View File

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

View File

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

View File

@ -59,6 +59,7 @@ export const formMutationHandler = async ({ ctx, input }: FormMutationHandlerOpt
fields: true, fields: true,
settings: true, settings: true,
teamId: 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({ const forms = await prisma.app_RoutingForms_Form.findMany({
where, where,
orderBy: { orderBy: [
createdAt: "desc", {
}, position: "desc",
},
{
createdAt: "asc",
},
],
include: { include: {
team: { team: {
include: { include: {

View File

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

View File

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

View File

@ -217,7 +217,8 @@ export const getBusyCalendarTimes = async (
export const createEvent = async ( export const createEvent = async (
credential: CredentialWithAppName, credential: CredentialWithAppName,
calEvent: CalendarEvent calEvent: CalendarEvent,
externalId?: string
): Promise<EventResult<NewCalendarEventType>> => { ): Promise<EventResult<NewCalendarEventType>> => {
const uid: string = getUid(calEvent); const uid: string = getUid(calEvent);
const calendar = await getCalendar(credential); const calendar = await getCalendar(credential);
@ -226,29 +227,31 @@ export const createEvent = async (
// Check if the disabledNotes flag is set to true // Check if the disabledNotes flag is set to true
if (calEvent.hideCalendarNotes) { 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 // TODO: Surface success/error messages coming from apps to improve end user visibility
const creationResult = calendar const creationResult = calendar
? await calendar.createEvent(calEvent).catch(async (error: { code: number; calError: string }) => { ? await calendar
success = false; .createEvent(calEvent, credential.id)
/** .catch(async (error: { code: number; calError: string }) => {
* There is a time when selectedCalendar externalId doesn't match witch certain credential success = false;
* so google returns 404. /**
* */ * There is a time when selectedCalendar externalId doesn't match witch certain credential
if (error?.code === 404) { * 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; 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; : undefined;
return { return {
@ -261,6 +264,8 @@ export const createEvent = async (
originalEvent: calEvent, originalEvent: calEvent,
calError, calError,
calWarnings: creationResult?.additionalInfo?.calWarnings || [], 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 // 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"; evt["location"] = "integrations:daily";
} }
const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null; const isDedicated = evt.location ? isDedicatedIntegration(evt.location) : null;
@ -164,8 +166,8 @@ export default class EventManager {
meetingId: createdEventObj ? createdEventObj.id : result.createdEvent?.id?.toString(), meetingId: createdEventObj ? createdEventObj.id : result.createdEvent?.id?.toString(),
meetingPassword: createdEventObj ? createdEventObj.password : result.createdEvent?.password, meetingPassword: createdEventObj ? createdEventObj.password : result.createdEvent?.password,
meetingUrl: createdEventObj ? createdEventObj.onlineMeetingUrl : result.createdEvent?.url, meetingUrl: createdEventObj ? createdEventObj.onlineMeetingUrl : result.createdEvent?.url,
externalCalendarId: isCalendarType ? evt.destinationCalendar?.externalId : undefined, externalCalendarId: isCalendarType ? result.externalId : undefined,
credentialId: isCalendarType ? evt.destinationCalendar?.credentialId : result.credentialId, credentialId: isCalendarType ? result.credentialId : undefined,
}; };
}); });
@ -203,8 +205,8 @@ export default class EventManager {
meetingId: result.createdEvent?.id?.toString(), meetingId: result.createdEvent?.id?.toString(),
meetingPassword: result.createdEvent?.password, meetingPassword: result.createdEvent?.password,
meetingUrl: result.createdEvent?.url, meetingUrl: result.createdEvent?.url,
externalCalendarId: evt.destinationCalendar?.externalId, externalCalendarId: result.externalId,
credentialId: result.credentialId ?? evt.destinationCalendar?.credentialId, credentialId: result.credentialId ?? undefined,
}; };
}); });
@ -332,29 +334,52 @@ export default class EventManager {
* @private * @private
*/ */
private async createAllCalendarEvents(event: CalendarEvent) { private async createAllCalendarEvents(event: CalendarEvent) {
/** Can I use destinationCalendar here? */
/* How can I link a DC to a cred? */
let createdEvents: EventResult<NewCalendarEventType>[] = []; let createdEvents: EventResult<NewCalendarEventType>[] = [];
if (event.destinationCalendar) { if (event.destinationCalendar && event.destinationCalendar.length > 0) {
if (event.destinationCalendar.credentialId) { for (const destination of event.destinationCalendar) {
const credential = this.calendarCredentials.find( if (destination.credentialId) {
(c) => c.id === event.destinationCalendar?.credentialId let credential = this.calendarCredentials.find((c) => c.id === destination.credentialId);
); if (!credential) {
// Fetch credential from DB
if (credential) { const credentialFromDB = await prisma.credential.findUnique({
const createdEvent = await createEvent(credential, event); include: {
if (createdEvent) { app: {
createdEvents.push(createdEvent); 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 { } else {
/** /**
@ -451,7 +476,7 @@ export default class EventManager {
booking: PartialBooking, booking: PartialBooking,
newBookingId?: number newBookingId?: number
): Promise<Array<EventResult<NewCalendarEventType>>> { ): Promise<Array<EventResult<NewCalendarEventType>>> {
let calendarReference: PartialReference | undefined = undefined, let calendarReference: PartialReference[] | undefined = undefined,
credential; credential;
try { try {
// If a newBookingId is given, update that calendar event // If a newBookingId is given, update that calendar event
@ -468,33 +493,62 @@ export default class EventManager {
} }
calendarReference = newBooking?.references.length calendarReference = newBooking?.references.length
? newBooking.references.find((reference) => reference.type.includes("_calendar")) ? newBooking.references.filter((reference) => reference.type.includes("_calendar"))
: booking.references.find((reference) => reference.type.includes("_calendar")); : booking.references.filter((reference) => reference.type.includes("_calendar"));
if (!calendarReference) { if (calendarReference.length === 0) {
return []; return [];
} }
const { uid: bookingRefUid, externalCalendarId: bookingExternalCalendarId } = calendarReference; // process all calendar references
let calenderExternalId: string | null = null;
if (bookingExternalCalendarId) {
calenderExternalId = bookingExternalCalendarId;
}
let result = []; let result = [];
if (calendarReference.credentialId) { for (const reference of calendarReference) {
credential = this.calendarCredentials.filter( const { uid: bookingRefUid, externalCalendarId: bookingExternalCalendarId } = reference;
(credential) => credential.id === calendarReference?.credentialId let calenderExternalId: string | null = null;
)[0]; if (bookingExternalCalendarId) {
result.push(updateEvent(credential, event, bookingRefUid, calenderExternalId)); calenderExternalId = bookingExternalCalendarId;
} else { }
const credentials = this.calendarCredentials.filter(
(credential) => credential.type === calendarReference?.type if (reference.credentialId) {
); credential = this.calendarCredentials.filter(
for (const credential of credentials) { (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)); 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 we are merging two calendar events we should delete the old calendar event
if (newBookingId) { if (newBookingId) {
const oldCalendarEvent = booking.references.find((reference) => reference.type.includes("_calendar")); 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")) .filter((cred) => cred.type.includes("other_calendar"))
.map(async (cred) => { .map(async (cred) => {
const calendarReference = booking.references.find((ref) => ref.type === cred.type); const calendarReference = booking.references.find((ref) => ref.type === cred.type);
if (!calendarReference)
if (!calendarReference) { if (!calendarReference) {
return { return {
appName: cred.appName, appName: cred.appName,
type: cred.type, type: cred.type,
success: false, success: false,
uid: "", uid: "",
originalEvent: event, originalEvent: event,
credentialId: cred.id, credentialId: cred.id,
}; };
} }
const { externalCalendarId: bookingExternalCalendarId, meetingId: bookingRefUid } = const { externalCalendarId: bookingExternalCalendarId, meetingId: bookingRefUid } =
calendarReference; calendarReference;
return await updateEvent(cred, event, bookingRefUid ?? null, bookingExternalCalendarId ?? null); return await updateEvent(cred, event, bookingRefUid ?? null, bookingExternalCalendarId ?? null);
@ -539,17 +593,19 @@ export default class EventManager {
if (error instanceof Error) { if (error instanceof Error) {
message = message.replace("{thing}", error.message); message = message.replace("{thing}", error.message);
} }
console.error(message);
return Promise.resolve([ return Promise.resolve(
{ calendarReference?.map((reference) => {
appName: "none", return {
type: calendarReference?.type || "calendar", appName: "none",
success: false, type: reference?.type || "calendar",
uid: "", success: false,
originalEvent: event, uid: "",
credentialId: 0, 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 short from "short-uuid";
import { v5 as uuidv5 } from "uuid"; import { v5 as uuidv5 } from "uuid";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -248,7 +248,11 @@ async function handler(req: CustomRequest) {
? parseRecurringEvent(bookingToDelete.eventType?.recurringEvent) ? parseRecurringEvent(bookingToDelete.eventType?.recurringEvent)
: undefined, : undefined,
location: bookingToDelete?.location, location: bookingToDelete?.location,
destinationCalendar: bookingToDelete?.destinationCalendar || bookingToDelete?.user.destinationCalendar, destinationCalendar: bookingToDelete?.destinationCalendar
? [bookingToDelete?.destinationCalendar]
: bookingToDelete?.user.destinationCalendar
? [bookingToDelete?.user.destinationCalendar]
: [],
cancellationReason: cancellationReason, cancellationReason: cancellationReason,
...(teamMembers && { team: { name: "", members: teamMembers } }), ...(teamMembers && { team: { name: "", members: teamMembers } }),
seatsPerTimeSlot: bookingToDelete.eventType?.seatsPerTimeSlot, seatsPerTimeSlot: bookingToDelete.eventType?.seatsPerTimeSlot,
@ -411,58 +415,71 @@ async function handler(req: CustomRequest) {
const apiDeletes = []; const apiDeletes = [];
const bookingCalendarReference = bookingToDelete.references.find((reference) => const bookingCalendarReference = bookingToDelete.references.filter((reference) =>
reference.type.includes("_calendar") reference.type.includes("_calendar")
); );
if (bookingCalendarReference) { if (bookingCalendarReference.length > 0) {
const { credentialId, uid, externalCalendarId } = bookingCalendarReference; for (const reference of bookingCalendarReference) {
// If the booking calendar reference contains a credentialId const { credentialId, uid, externalCalendarId } = reference;
if (credentialId) { // If the booking calendar reference contains a credentialId
// Find the correct calendar credential under user credentials if (credentialId) {
const calendarCredential = bookingToDelete.user.credentials.find( // Find the correct calendar credential under user credentials
(credential) => credential.id === credentialId let calendarCredential = bookingToDelete.user.credentials.find(
); (credential) => credential.id === credentialId
if (calendarCredential) { );
const calendar = await getCalendar(calendarCredential); if (!calendarCredential) {
if ( // get credential from DB
bookingToDelete.eventType?.recurringEvent && const foundCalendarCredential = await prisma.credential.findUnique({
bookingToDelete.recurringEventId && where: {
allRemainingBookings id: credentialId,
) { },
const promises = bookingToDelete.user.credentials });
.filter((credential) => credential.type.endsWith("_calendar")) if (foundCalendarCredential) {
.map(async (credential) => { calendarCredential = foundCalendarCredential;
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 { }
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>); 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, attendees: attendeesList,
location: bookingToDelete.location ?? "", location: bookingToDelete.location ?? "",
uid: bookingToDelete.uid ?? "", 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); 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 { Prisma } from "@prisma/client";
import async from "async"; import async from "async";
import { isValidPhoneNumber } from "libphonenumber-js"; import { isValidPhoneNumber } from "libphonenumber-js";
@ -367,7 +367,7 @@ async function ensureAvailableUsers(
) { ) {
const availableUsers: IsFixedAwareUser[] = []; const availableUsers: IsFixedAwareUser[] = [];
const orginalBookingDuration = input.originalRescheduledBooking const originalBookingDuration = input.originalRescheduledBooking
? dayjs(input.originalRescheduledBooking.endTime).diff( ? dayjs(input.originalRescheduledBooking.endTime).diff(
dayjs(input.originalRescheduledBooking.startTime), dayjs(input.originalRescheduledBooking.startTime),
"minutes" "minutes"
@ -380,7 +380,7 @@ async function ensureAvailableUsers(
{ {
userId: user.id, userId: user.id,
eventTypeId: eventType.id, eventTypeId: eventType.id,
duration: orginalBookingDuration, duration: originalBookingDuration,
...input, ...input,
}, },
{ {
@ -686,8 +686,7 @@ async function handler(
if (!eventType) throw new HttpError({ statusCode: 404, message: "eventType.notFound" }); if (!eventType) throw new HttpError({ statusCode: 404, message: "eventType.notFound" });
const isTeamEventType = const isTeamEventType =
eventType.schedulingType === SchedulingType.COLLECTIVE || !!eventType.schedulingType && ["COLLECTIVE", "ROUND_ROBIN"].includes(eventType.schedulingType);
eventType.schedulingType === SchedulingType.ROUND_ROBIN;
const paymentAppData = getPaymentAppData(eventType); const paymentAppData = getPaymentAppData(eventType);
@ -722,31 +721,46 @@ async function handler(
throw new HttpError({ statusCode: 400, message: error.message }); throw new HttpError({ statusCode: 400, message: error.message });
} }
const loadUsers = async () => const loadUsers = async () => {
!eventTypeId try {
? await prisma.user.findMany({ 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: { where: {
username: { username: { in: dynamicUserList },
in: dynamicUserList,
},
}, },
select: { select: {
...userSelect.select, ...userSelect.select,
credentials: true, // Don't leak to client credentials: true,
metadata: 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, ...user,
isFixed, 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 // loadUsers allows type inferring
let users: (Awaited<ReturnType<typeof loadUsers>>[number] & { let users: (Awaited<ReturnType<typeof loadUsers>>[number] & {
isFixed?: boolean; isFixed?: boolean;
@ -970,20 +984,26 @@ async function handler(
: getLocationValueForDB(locationBodyString, eventType.locations); : getLocationValueForDB(locationBodyString, eventType.locations);
const customInputs = getCustomInputsResponses(reqBody, eventType.customInputs); const customInputs = getCustomInputsResponses(reqBody, eventType.customInputs);
const teamMemberPromises = const teamDestinationCalendars: DestinationCalendar[] = [];
users.length > 1
? users.slice(1).map(async function (user) { // Organizer or user owner of this event type it's not listed as a team member.
return { const teamMemberPromises = users.slice(1).map(async (user) => {
email: user.email || "", // push to teamDestinationCalendars if it's a team event but collective only
name: user.name || "", if (isTeamEventType && eventType.schedulingType === "COLLECTIVE" && user.destinationCalendar) {
timeZone: user.timeZone, teamDestinationCalendars.push(user.destinationCalendar);
language: { }
translate: await getTranslation(user.locale ?? "en", "common"), return {
locale: user.locale ?? "en", 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); const teamMembers = await Promise.all(teamMemberPromises);
@ -1040,16 +1060,24 @@ async function handler(
attendees: attendeesList, attendees: attendeesList,
location: bookingLocation, // Will be processed by the EventManager later. location: bookingLocation, // Will be processed by the EventManager later.
conferenceCredentialId, conferenceCredentialId,
/** For team events & dynamic collective events, we will need to handle each member destinationCalendar eventually */ destinationCalendar: eventType.destinationCalendar
destinationCalendar: eventType.destinationCalendar || organizerUser.destinationCalendar, ? [eventType.destinationCalendar]
: organizerUser.destinationCalendar
? [organizerUser.destinationCalendar]
: null,
hideCalendarNotes: eventType.hideCalendarNotes, hideCalendarNotes: eventType.hideCalendarNotes,
requiresConfirmation: requiresConfirmation ?? false, requiresConfirmation: requiresConfirmation ?? false,
eventTypeId: eventType.id, eventTypeId: eventType.id,
// if seats are not enabled we should default true // if seats are not enabled we should default true
seatsShowAttendees: eventType.seatsPerTimeSlot ? eventType.seatsShowAttendees : true, seatsShowAttendees: eventType.seatsPerTimeSlot ? eventType.seatsShowAttendees : true,
seatsPerTimeSlot: eventType.seatsPerTimeSlot, 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 */ /* Used for seats bookings to update evt object with video data */
const addVideoCallDataToEvt = (bookingReferences: BookingReference[]) => { const addVideoCallDataToEvt = (bookingReferences: BookingReference[]) => {
const videoCallReference = bookingReferences.find((reference) => reference.type.includes("_video")); const videoCallReference = bookingReferences.find((reference) => reference.type.includes("_video"));
@ -1843,11 +1871,12 @@ async function handler(
id: organizerUser.id, id: organizerUser.id,
}, },
}, },
destinationCalendar: evt.destinationCalendar destinationCalendar:
? { evt.destinationCalendar && evt.destinationCalendar.length > 0
connect: { id: evt.destinationCalendar.id }, ? {
} connect: { id: evt.destinationCalendar[0].id },
: undefined, }
: undefined,
}; };
if (reqBody.recurringEventId) { 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"] */ /** Defaults to [], which dates are not bookable. Array of valid dates like: ["2022-04-23", "2022-04-24"] */
excludedDates?: string[]; excludedDates?: string[];
/** defaults to all, which dates are bookable (inverse of excludedDates) */ /** defaults to all, which dates are bookable (inverse of excludedDates) */
includedDates?: string[] | null; includedDates?: string[];
/** allows adding classes to the container */ /** allows adding classes to the container */
className?: string; className?: string;
/** Shows a small loading spinner next to the month name */ /** 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 = ({ const Days = ({
minDate = dayjs.utc(), minDate = dayjs.utc(),
excludedDates = [], excludedDates = [],
@ -121,7 +155,7 @@ const Days = ({
// Create placeholder elements for empty days in first week // Create placeholder elements for empty days in first week
const weekdayOfFirst = browsingDate.date(1).day(); const weekdayOfFirst = browsingDate.date(1).day();
const currentDate = minDate.utcOffset(browsingDate.utcOffset()); const currentDate = minDate.utcOffset(browsingDate.utcOffset());
const availableDates = (includedDates: string[] | undefined | null) => { const availableDates = (includedDates: string[] | undefined) => {
const dates = []; const dates = [];
const lastDateOfMonth = browsingDate.date(daysInMonth(browsingDate)); const lastDateOfMonth = browsingDate.date(daysInMonth(browsingDate));
for ( for (
@ -148,21 +182,6 @@ const Days = ({
days.push(date); 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 [selectedDatesAndTimes] = useBookerStore((state) => [state.selectedDatesAndTimes], shallow);
const isActive = (day: dayjs.Dayjs) => { const isActive = (day: dayjs.Dayjs) => {
@ -190,6 +209,21 @@ const Days = ({
return false; 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 ( return (
<> <>
{daysToRenderForTheMonth.map(({ day, disabled }, idx) => ( {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; export default DatePicker;

View File

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

View File

@ -59,7 +59,9 @@ function AdminOrgTable() {
</div> </div>
</Cell> </Cell>
<Cell widthClassNames="w-auto"> <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>
<Cell> <Cell>
<div className="space-x-2"> <div className="space-x-2">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -117,10 +117,8 @@ export const updateQuantitySubscriptionFromStripe = async (teamId: number) => {
return; return;
} }
const newQuantity = membershipCount - subscriptionQuantity;
await stripe.subscriptions.update(subscriptionId, { await stripe.subscriptions.update(subscriptionId, {
items: [{ quantity: membershipCount + newQuantity, id: subscriptionItemId }], items: [{ quantity: membershipCount, id: subscriptionItemId }],
}); });
console.info( console.info(
`Updated subscription ${subscriptionId} for team ${teamId} to ${team.members.length} seats.` `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 type { Workflow, WorkflowStep, Membership } from "@prisma/client";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@ -19,6 +20,7 @@ import {
Tooltip, Tooltip,
Badge, Badge,
Avatar, Avatar,
ArrowButton,
} from "@calcom/ui"; } from "@calcom/ui";
import { Edit2, Link as LinkIcon, MoreHorizontal, Trash2 } from "@calcom/ui/components/icon"; 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 utils = trpc.useContext();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [workflowToDeleteId, setwWorkflowToDeleteId] = useState(0); const [workflowToDeleteId, setwWorkflowToDeleteId] = useState(0);
const [parent] = useAutoAnimate<HTMLUListElement>();
const router = useRouter(); const router = useRouter();
const orgBranding = useOrgBranding(); const orgBranding = useOrgBranding();
const urlPrefix = orgBranding ? `${orgBranding.slug}.${subdomainSuffix()}` : CAL_URL; 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 ( return (
<> <>
{workflows && workflows.length > 0 ? ( {workflows && workflows.length > 0 ? (
<div className="bg-default border-subtle overflow-hidden rounded-md border sm:mx-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"> <ul className="divide-subtle !static w-full divide-y" data-testid="workflow-list" ref={parent}>
{workflows.map((workflow) => ( {workflows.map((workflow, index) => {
<li key={workflow.id}> const firstItem = workflows[0];
<div className="first-line:group hover:bg-muted flex w-full items-center justify-between p-4 sm:px-6"> const lastItem = workflows[workflows.length - 1];
<Link href={"/workflows/" + workflow.id} className="flex-grow cursor-pointer"> return (
<div className="rtl:space-x-reverse"> <li
<div className="flex"> key={workflow.id}
<div className="group flex w-full max-w-full items-center justify-between overflow-hidden">
className={classNames( {!(firstItem && firstItem.id === workflow.id) && (
"max-w-56 text-emphasis truncate text-sm font-medium leading-6 md:max-w-max", <ArrowButton onClick={() => moveWorkflow(index, -1)} arrowDirection="up" />
workflow.name ? "text-emphasis" : "text-subtle" )}
)}> {!(lastItem && lastItem.id === workflow.id) && (
{workflow.name <ArrowButton onClick={() => moveWorkflow(index, 1)} arrowDirection="down" />
? workflow.name )}
: workflow.steps[0] <div className="first-line:group hover:bg-muted flex w-full items-center justify-between p-4 sm:px-6">
? "Untitled (" + <Link href={"/workflows/" + workflow.id} className="flex-grow cursor-pointer">
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}` <div className="rtl:space-x-reverse">
.charAt(0) <div className="flex">
.toUpperCase() + <div
`${t(`${workflow.steps[0].action.toLowerCase()}_action`)}`.slice(1) + className={classNames(
")" "max-w-56 text-emphasis truncate text-sm font-medium leading-6 md:max-w-max",
: "Untitled"} 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>
<div>
{workflow.readOnly && ( <ul className="mt-1 flex flex-wrap space-x-2 sm:flex-nowrap ">
<Badge variant="gray" className="ml-2 "> <li>
{t("readonly")} <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> </Badge>
)} </li>
</div> <li>
</div> <Badge variant="gray">
{workflow.activeOn && workflow.activeOn.length > 0 ? (
<ul className="mt-1 flex flex-wrap space-x-2 sm:flex-nowrap "> <Tooltip
<li> content={workflow.activeOn
<Badge variant="gray"> .filter((wf) => (workflow.teamId ? wf.eventType.parentId === null : true))
<div> .map((activeOn, key) => (
{getActionIcon(workflow.steps)} <p key={key}>
{activeOn.eventType.title}
<span className="mr-1">{t("triggers")}</span> {activeOn.eventType._count.children > 0
{workflow.timeUnit && workflow.time && ( ? ` (+${activeOn.eventType._count.children})`
<span className="mr-1"> : ""}
{t(`${workflow.timeUnit.toLowerCase()}`, { count: workflow.time })} </p>
</span> ))}>
)} <div>
<span>{t(`${workflow.trigger.toLowerCase()}_trigger`)}</span> <LinkIcon className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
</div> {t("active_on_event_types", {
</Badge> count: workflow.activeOn.filter((wf) =>
</li> workflow.teamId ? wf.eventType.parentId === null : true
<li> ).length,
<Badge variant="gray"> })}
{workflow.activeOn && workflow.activeOn.length > 0 ? ( </div>
<Tooltip </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> <div>
<LinkIcon className="mr-1.5 inline h-3 w-3" aria-hidden="true" /> <LinkIcon className="mr-1.5 inline h-3 w-3" aria-hidden="true" />
{t("active_on_event_types", { {t("no_active_event_types")}
count: workflow.activeOn.filter((wf) =>
workflow.teamId ? wf.eventType.parentId === null : true
).length,
})}
</div> </div>
</Tooltip> )}
) : ( </Badge>
<div> </li>
<LinkIcon className="mr-1.5 inline h-3 w-3" aria-hidden="true" /> <div className="block md:hidden">
{t("no_active_event_types")} {workflow.team?.name && (
</div> <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> </Badge>
</li> )}
<div className="block md:hidden"> </div>
{workflow.team?.name && (
<li>
<Badge variant="gray">
<>{workflow.team.name}</>
</Badge>
</li>
)}
</div>
</ul>
</div> </div>
</Link>
<div> <div className="flex flex-shrink-0">
<div className="hidden md:block"> <div className="hidden sm:block">
{workflow.team?.name && ( <ButtonGroup combined>
<Badge className="mr-4 mt-1 p-[1px] px-2" variant="gray"> <Tooltip content={t("edit") as string}>
<Avatar <Button
alt={workflow.team?.name || ""} type="button"
href={ color="secondary"
workflow.team?.id variant="icon"
? `/settings/teams/${workflow.team?.id}/profile` StartIcon={Edit2}
: "/settings/my-account/profile" disabled={workflow.readOnly}
} onClick={async () => await router.replace("/workflows/" + workflow.id)}
imageSrc={getPlaceholderAvatar( />
workflow?.team.logo, </Tooltip>
workflow.team?.name as string <Tooltip content={t("delete") as string}>
)} <Button
size="xxs" onClick={() => {
className="mt-[3px] inline-flex justify-center" setDeleteDialogOpen(true);
/> setwWorkflowToDeleteId(workflow.id);
<div>{workflow.team.name}</div> }}
</Badge> 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> </div>
</li>
<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>
))}
</ul> </ul>
<DeleteDialog <DeleteDialog
isOpenDialog={deleteDialogOpen} isOpenDialog={deleteDialogOpen}

View File

@ -8,6 +8,7 @@ import type { ControlProps } from "react-select";
import { components } from "react-select"; import { components } from "react-select";
import { shallow } from "zustand/shallow"; import { shallow } from "zustand/shallow";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs"; import dayjs from "@calcom/dayjs";
import { AvailableTimes } from "@calcom/features/bookings"; import { AvailableTimes } from "@calcom/features/bookings";
import { useBookerStore, useInitializeBookerStore } from "@calcom/features/bookings/Booker/store"; import { useBookerStore, useInitializeBookerStore } from "@calcom/features/bookings/Booker/store";
@ -38,7 +39,7 @@ import {
TextField, TextField,
TimezoneSelect, TimezoneSelect,
} from "@calcom/ui"; } 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 { getDimension } from "./lib/getDimension";
import type { EmbedTabs, EmbedType, EmbedTypes, PreviewState } from "./types"; 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> <div className="text-default text-sm">{t("select_date")}</div>
<DatePicker <DatePicker
isLoading={schedule.isLoading} isLoading={schedule.isLoading}
onChange={(date) => setSelectedDate(date ? date.format("YYYY-MM-DD") : date)} onChange={(date: Dayjs | null) => {
onMonthChange={(date) => { setSelectedDate(date === null ? date : date.format("YYYY-MM-DD"));
}}
onMonthChange={(date: Dayjs) => {
setMonth(date.format("YYYY-MM")); setMonth(date.format("YYYY-MM"));
setSelectedDate(date.format("YYYY-MM-DD")); setSelectedDate(date.format("YYYY-MM-DD"));
}} }}
@ -245,36 +248,24 @@ const EmailEmbed = ({ eventType, username }: { eventType?: EventType; username:
</div> </div>
{selectedDate ? ( {selectedDate ? (
<div className="mt-[9px] font-medium "> <div className="mt-[9px] font-medium ">
<Collapsible open> {selectTime && selectedDate ? (
<CollapsibleContent> <div className="flex h-full w-full flex-row gap-4">
<div <AvailableTimes
className="text-default mb-[9px] flex cursor-pointer items-center justify-between text-sm" className="w-full"
onClick={() => setSelectTime((prev) => !prev)}> date={dayjs(selectedDate)}
<p>{t("select_time")}</p>{" "} selectedSlots={
<> eventType.slug &&
{!selectedDate || !selectTime ? <ArrowDown className="w-4" /> : <ArrowUp className="w-4" />} selectedDatesAndTimes &&
</> selectedDatesAndTimes[eventType.slug] &&
</div> selectedDatesAndTimes[eventType.slug][selectedDate as string]
{selectTime && selectedDate ? ( ? selectedDatesAndTimes[eventType.slug][selectedDate as string]
<div className="flex h-full w-full flex-row gap-4"> : undefined
<AvailableTimes }
className="w-full" onTimeSelect={onTimeSelect}
date={dayjs(selectedDate)} slots={slots}
selectedSlots={ />
eventType.slug && </div>
selectedDatesAndTimes && ) : null}
selectedDatesAndTimes[eventType.slug] &&
selectedDatesAndTimes[eventType.slug][selectedDate as string]
? selectedDatesAndTimes[eventType.slug][selectedDate as string]
: undefined
}
onTimeSelect={onTimeSelect}
slots={slots}
/>
</div>
) : null}
</CollapsibleContent>
</Collapsible>
</div> </div>
) : null} ) : null}
<div className="mb-[9px] font-medium "> <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 [selectedDates, setSelectedDates] = useState<Dayjs[]>(value ? [dayjs.utc(value[0].start)] : []);
const onDateChange = (newDate: Dayjs | null) => { const onDateChange = (newDate: Dayjs) => {
// If no date is selected, do nothing
if (!newDate) {
return;
}
// If clicking on a selected date unselect it // If clicking on a selected date unselect it
if (selectedDates.some((date) => yyyymmdd(date) === yyyymmdd(newDate))) { if (selectedDates.some((date) => yyyymmdd(date) === yyyymmdd(newDate))) {
setSelectedDates(selectedDates.filter((date) => yyyymmdd(date) !== yyyymmdd(newDate))); setSelectedDates(selectedDates.filter((date) => yyyymmdd(date) !== yyyymmdd(newDate)));
@ -154,7 +150,9 @@ const DateOverrideForm = ({
excludedDates={excludedDates} excludedDates={excludedDates}
weekStart={0} weekStart={0}
selected={selectedDates} selected={selectedDates}
onChange={(day) => onDateChange(day)} onChange={(day) => {
if (day) onDateChange(day);
}}
onMonthChange={(newMonth) => { onMonthChange={(newMonth) => {
setBrowsingDate(newMonth); setBrowsingDate(newMonth);
}} }}

View File

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

View File

@ -35,7 +35,7 @@ function MoreInfoFooter() {
return ( return (
<> <>
<SheetClose asChild> <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")} {t("close")}
</Button> </Button>
</SheetClose> </SheetClose>

View File

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

View File

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

View File

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

View File

@ -1,10 +1,9 @@
import { Prisma } from "@prisma/client"; import { Prisma } from "@prisma/client";
import { getAppFromSlug } from "@calcom/app-store/utils"; import { getAppFromSlug } from "@calcom/app-store/utils";
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
import { getSlugOrRequestedSlug } from "@calcom/ee/organizations/lib/orgDomains"; import { getSlugOrRequestedSlug } from "@calcom/ee/organizations/lib/orgDomains";
import prisma, { baseEventTypeSelect } from "@calcom/prisma"; 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 { EventTypeMetaDataSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils";
import { WEBAPP_URL } from "../../../constants"; import { WEBAPP_URL } from "../../../constants";
@ -32,7 +31,17 @@ export async function getTeamWithMembers(args: {
selectedCalendars: true, selectedCalendars: true,
credentials: { credentials: {
include: { 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; if (!team) return null;
const members = await Promise.all(
team.members.map(async (obj) => {
const calendarCredentials = getCalendarCredentials(obj.user.credentials);
const { connectedCalendars } = await getConnectedCalendars( // This should improve performance saving already app data found.
calendarCredentials, const appDataMap = new Map();
obj.user.selectedCalendars,
obj.user.destinationCalendar?.externalId const members = team.members.map((obj) => {
); return {
const connectedApps = obj.user.credentials ...obj.user,
.map(({ app, id }) => { role: obj.role,
const appMetaData = getAppFromSlug(app?.slug); 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) => ({ const eventTypes = team.eventTypes.map((eventType) => ({
...eventType, ...eventType,
metadata: EventTypeMetaDataSchema.parse(eventType.metadata), metadata: EventTypeMetaDataSchema.parse(eventType.metadata),

View File

@ -19,9 +19,6 @@ export const telemetryEventTypes = {
onboardingStarted: "onboarding_started", onboardingStarted: "onboarding_started",
signup: "signup", signup: "signup",
team_created: "team_created", team_created: "team_created",
website: {
pageView: "website_page_view",
},
slugReplacementAction: "slug_replacement_action", slugReplacementAction: "slug_replacement_action",
org_created: "org_created", 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/generator-helper": "^5.0.0",
"prisma": "^5.0.0", "prisma": "^5.0.0",
"ts-node": "^10.9.1", "ts-node": "^10.9.1",
"zod": "^3.20.2", "zod": "^3.22.2",
"zod-prisma": "^0.5.4" "zod-prisma": "^0.5.4"
}, },
"main": "index.ts", "main": "index.ts",

View File

@ -662,6 +662,7 @@ model App {
model App_RoutingForms_Form { model App_RoutingForms_Form {
id String @id @default(cuid()) id String @id @default(cuid())
description String? description String?
position Int @default(0)
routes Json? routes Json?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@ -746,6 +747,7 @@ model WorkflowStep {
model Workflow { model Workflow {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
position Int @default(0)
name String name String
userId Int? userId Int?
user User? @relation(fields: [userId], references: [id], onDelete: Cascade) 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 */ /* eslint-disable no-underscore-dangle */
import type { import type {
AnyZodObject,
objectInputType, objectInputType,
objectOutputType, objectOutputType,
ZodNullableDef, ZodNullableDef,
@ -528,11 +529,13 @@ export const optionToValueSchema = <T extends z.ZodTypeAny>(valueSchema: T) =>
* @url https://github.com/colinhacks/zod/discussions/1655#discussioncomment-4367368 * @url https://github.com/colinhacks/zod/discussions/1655#discussioncomment-4367368
*/ */
export const getParserWithGeneric = export const getParserWithGeneric =
<T extends z.ZodTypeAny>(valueSchema: T) => <T extends AnyZodObject>(valueSchema: T) =>
<Data>(data: Data) => { <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 { 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({ export const sendDailyVideoRecordingEmailsSchema = z.object({

View File

@ -17,6 +17,6 @@
"@trpc/react-query": "^10.13.0", "@trpc/react-query": "^10.13.0",
"@trpc/server": "^10.13.0", "@trpc/server": "^10.13.0",
"superjson": "1.9.1", "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 { ZGetDownloadLinkOfCalVideoRecordingsInputSchema } from "./getDownloadLinkOfCalVideoRecordings.schema";
import { ZIntegrationsInputSchema } from "./integrations.schema"; import { ZIntegrationsInputSchema } from "./integrations.schema";
import { ZLocationOptionsInputSchema } from "./locationOptions.schema"; import { ZLocationOptionsInputSchema } from "./locationOptions.schema";
import { ZRoutingFormOrderInputSchema } from "./routingFormOrder.schema";
import { ZSetDestinationCalendarInputSchema } from "./setDestinationCalendar.schema"; import { ZSetDestinationCalendarInputSchema } from "./setDestinationCalendar.schema";
import { ZSubmitFeedbackInputSchema } from "./submitFeedback.schema"; import { ZSubmitFeedbackInputSchema } from "./submitFeedback.schema";
import { ZUpdateProfileInputSchema } from "./updateProfile.schema"; import { ZUpdateProfileInputSchema } from "./updateProfile.schema";
import { ZUpdateUserDefaultConferencingAppInputSchema } from "./updateUserDefaultConferencingApp.schema"; import { ZUpdateUserDefaultConferencingAppInputSchema } from "./updateUserDefaultConferencingApp.schema";
import { ZWorkflowOrderInputSchema } from "./workflowOrder.schema";
type AppsRouterHandlerCache = { type AppsRouterHandlerCache = {
me?: typeof import("./me.handler").meHandler; me?: typeof import("./me.handler").meHandler;
@ -31,6 +33,8 @@ type AppsRouterHandlerCache = {
stripeCustomer?: typeof import("./stripeCustomer.handler").stripeCustomerHandler; stripeCustomer?: typeof import("./stripeCustomer.handler").stripeCustomerHandler;
updateProfile?: typeof import("./updateProfile.handler").updateProfileHandler; updateProfile?: typeof import("./updateProfile.handler").updateProfileHandler;
eventTypeOrder?: typeof import("./eventTypeOrder.handler").eventTypeOrderHandler; eventTypeOrder?: typeof import("./eventTypeOrder.handler").eventTypeOrderHandler;
routingFormOrder?: typeof import("./routingFormOrder.handler").routingFormOrderHandler;
workflowOrder?: typeof import("./workflowOrder.handler").workflowOrderHandler;
submitFeedback?: typeof import("./submitFeedback.handler").submitFeedbackHandler; submitFeedback?: typeof import("./submitFeedback.handler").submitFeedbackHandler;
locationOptions?: typeof import("./locationOptions.handler").locationOptionsHandler; locationOptions?: typeof import("./locationOptions.handler").locationOptionsHandler;
deleteCredential?: typeof import("./deleteCredential.handler").deleteCredentialHandler; deleteCredential?: typeof import("./deleteCredential.handler").deleteCredentialHandler;
@ -230,6 +234,34 @@ export const loggedInViewerRouter = router({
return UNSTABLE_HANDLER_CACHE.eventTypeOrder({ ctx, input }); 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 //Comment for PR: eventTypePosition is not used anywhere
submitFeedback: authedProcedure.input(ZSubmitFeedbackInputSchema).mutation(async ({ ctx, input }) => { submitFeedback: authedProcedure.input(ZSubmitFeedbackInputSchema).mutation(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.submitFeedback) { if (!UNSTABLE_HANDLER_CACHE.submitFeedback) {

View File

@ -287,7 +287,11 @@ export const deleteCredentialHandler = async ({ ctx, input }: DeleteCredentialOp
uid: booking.uid, uid: booking.uid,
recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent), recurringEvent: parseRecurringEvent(booking.eventType?.recurringEvent),
location: booking.location, 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", cancellationReason: "Payment method removed by organizer",
seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot, seatsPerTimeSlot: booking.eventType?.seatsPerTimeSlot,
seatsShowAttendees: booking.eventType?.seatsShowAttendees, 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, attendees: attendeesList,
location: booking.location ?? "", location: booking.location ?? "",
uid: booking.uid, uid: booking.uid,
destinationCalendar: booking?.destinationCalendar || user.destinationCalendar, destinationCalendar: booking?.destinationCalendar
? [booking.destinationCalendar]
: user.destinationCalendar
? [user.destinationCalendar]
: [],
requiresConfirmation: booking?.eventType?.requiresConfirmation ?? false, requiresConfirmation: booking?.eventType?.requiresConfirmation ?? false,
eventTypeId: booking.eventType?.id, eventTypeId: booking.eventType?.id,
}; };

View File

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

View File

@ -237,7 +237,9 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule
), ),
uid: bookingToReschedule?.uid, uid: bookingToReschedule?.uid,
location: bookingToReschedule?.location, location: bookingToReschedule?.location,
destinationCalendar: bookingToReschedule?.destinationCalendar || bookingToReschedule?.destinationCalendar, destinationCalendar: bookingToReschedule?.destinationCalendar
? [bookingToReschedule?.destinationCalendar]
: [],
cancellationReason: `Please reschedule. ${cancellationReason}`, // TODO::Add i18-next for this 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); const { default_currency } = stripeDataSchema.parse(paymentCredential.key);
data.currency = default_currency; 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({ const connectedLink = await ctx.prisma.hashedLink.findFirst({

View File

@ -148,9 +148,9 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
organization: { organization: {
create: { create: {
name, name,
...(!IS_TEAM_BILLING_ENABLED && { slug }), ...(IS_TEAM_BILLING_ENABLED ? { slug } : {}),
metadata: { metadata: {
...(IS_TEAM_BILLING_ENABLED && { requestedSlug: slug }), ...(IS_TEAM_BILLING_ENABLED ? { requestedSlug: slug } : {}),
isOrganization: true, isOrganization: true,
isOrganizationVerified: false, isOrganizationVerified: false,
isOrganizationConfigured, 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 // A user can only have one org so we pass in their currentOrgId here
const currentOrgId = ctx.user?.organization?.id || input.orgId; const currentOrgId = ctx.user?.organization?.id || input.orgId;
if (!currentOrgId || ctx.user.role !== UserPermissionRole.ADMIN) const isUserOrganizationAdmin = currentOrgId && (await isOrganisationAdmin(ctx.user?.id, currentOrgId));
throw new TRPCError({ code: "UNAUTHORIZED" }); const isUserRoleAdmin = ctx.user.role === UserPermissionRole.ADMIN;
if (!(await isOrganisationAdmin(ctx.user?.id, currentOrgId)) || ctx.user.role !== UserPermissionRole.ADMIN) const isUserAuthorizedToUpdate = !!(isUserOrganizationAdmin || isUserRoleAdmin);
throw new TRPCError({ code: "UNAUTHORIZED" });
if (!currentOrgId || !isUserAuthorizedToUpdate) throw new TRPCError({ code: "UNAUTHORIZED" });
if (input.slug) { if (input.slug) {
const userConflict = await prisma.team.findMany({ const userConflict = await prisma.team.findMany({

View File

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

View File

@ -66,17 +66,21 @@ export async function getEmailsToInvite(usernameOrEmail: string | string[]) {
export async function getUserToInviteOrThrowIfExists({ export async function getUserToInviteOrThrowIfExists({
usernameOrEmail, usernameOrEmail,
orgId, teamId,
isOrg, isOrg,
}: { }: {
usernameOrEmail: string; usernameOrEmail: string;
orgId: number; teamId: number;
isOrg?: boolean; isOrg?: boolean;
}) { }) {
// Check if user exists in ORG or exists all together // Check if user exists in ORG or exists all together
const orgWhere = isOrg && {
organizationId: teamId,
};
const invitee = await prisma.user.findFirst({ const invitee = await prisma.user.findFirst({
where: { 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, include: includedFields,
orderBy: { orderBy: [
id: "asc", {
}, position: "desc",
},
{
id: "asc",
},
],
}); });
if (!filtered) { if (!filtered) {

View File

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

View File

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

View File

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

View File

@ -52,7 +52,7 @@ export const buttonClasses = cva(
minimal: 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", "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: 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: { size: {
sm: "px-3 py-2 leading-4 rounded-sm" /** For backwards compatibility */, 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 { Canvas, Meta, Story, ArgsTable } from "@storybook/addon-docs";
import { import {
@ -17,7 +18,7 @@ import { Button } from "./Button";
<Meta title="UI/Button" component={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 ## Definition
@ -170,6 +171,15 @@ Button are clickable elements that initiates user actions. Labels in the button
</Story> </Story>
<Story <Story
name="Button Playground" 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={{ args={{
color: "primary", color: "primary",
size: "base", size: "base",
@ -177,6 +187,7 @@ Button are clickable elements that initiates user actions. Labels in the button
disabled: false, disabled: false,
children: "Button text", children: "Button text",
className: "", className: "",
tooltip: "tooltip",
}} }}
argTypes={{ argTypes={{
color: { color: {
@ -212,19 +223,20 @@ Button are clickable elements that initiates user actions. Labels in the button
options: ["", "sb-pseudo--hover", "sb-pseudo--focus"], 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}> <VariantsTable titles={["Light & Dark Modes"]} columnMinWidth={150}>
<VariantRow variant="Button"> <VariantRow variant="Button">
<Button <TooltipProvider>
color={color} <Button variant="default" {...args}>
size={size} {children}
variant="default" </Button>
loading={loading} </TooltipProvider>
disabled={disabled}
className={className}>
{children}
</Button>
</VariantRow> </VariantRow>
</VariantsTable> </VariantsTable>
)} )}

View File

@ -41,7 +41,7 @@ export function ConfirmationDialogContent(props: PropsWithChildren<ConfirmationD
<div className="mt-0.5 ltr:mr-3"> <div className="mt-0.5 ltr:mr-3">
{variety === "danger" && ( {variety === "danger" && (
<div className="bg-error mx-auto rounded-full p-2 text-center"> <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> </div>
)} )}
{variety === "warning" && ( {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} {...rest}
className={classNames( className={classNames(
"hover:text-emphasis text-default inline-flex w-full items-center space-x-2 px-3 py-2 disabled:cursor-not-allowed", "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 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 { import {
Examples, Examples,
Example, Example,
Note,
Title, Title,
CustomArgsTable, CustomArgsTable,
VariantRow, VariantRow,
@ -15,7 +14,7 @@ import { SelectField } from "./Select";
<Meta title="UI/Form/Select Field" component={SelectField} /> <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 ## Definition
@ -101,11 +100,31 @@ const GoodSelect = (props) => <Select {...props} components={{ Control }} />;
## Select Story ## Select Story
<Canvas> <Canvas>
<Story name="Default"> <Story
<VariantsTable titles={["Default"]} columnMinWidth={450}> name="Default"
<VariantRow> args={{
<SelectField options={options} label={"Default Select"} /> required: false,
</VariantRow> name: "select-field",
</VariantsTable> 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> </Story>
</Canvas> </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 <RadixToggleGroup.Item
disabled={option.disabled} disabled={option.disabled}
value={option.value} value={option.value}
data-testid={`toggle-group-item-${option.value}`}
className={classNames( className={classNames(
"aria-checked:bg-emphasis relative rounded-[4px] px-3 py-1 text-sm leading-tight transition-colors", "aria-checked:bg-emphasis relative rounded-[4px] px-3 py-1 text-sm leading-tight transition-colors",
option.disabled 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, VariantRow,
} from "@calcom/storybook/components"; } 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} /> <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 ## 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> <Canvas>
<Story name="List"> <Story
<VariantsTable titles={[]} columnMinWidth={150}> name="List"
<VariantRow variant="Default">TODO!</VariantRow> args={{
</VariantsTable> 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> </Story>
</Canvas> </Canvas>

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