Compare commits

...
29 Commits
Author SHA1 Message Date
Omar López ccc814eb54 v3.2.6
release.yml / release (push) Canceled after 0s
2023-08-28 17:04:09 -07:00
Joe Au-Yeung 2ee0ec11d6 Install Stripe on team from team event type (#10920) 2023-08-28 16:39:57 -07:00
Syed Ali Shahbaz 3223beb9ac omit id as it is autocreated, should not be req (#10982) 2023-08-28 06:57:01 +00:00
nicktrnandzomars c1bcfcfa3d fix: replace all async foreach callbacks (#10157)
Co-authored-by: zomars <zomars@me.com>
2023-08-26 00:43:10 +00:00
83aea7d28c fix: webhooks for managed event types (#10958)
Co-authored-by: CarinaWolli <wollencarina@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2023-08-26 00:27:05 +00:00
Alex van Andel a810a538e9 v3.2.5
release.yml / release (push) Canceled after 0s
2023-08-25 17:23:22 +01:00
sean-brydon 1cd0d7b5ca use team id :/ (#10957) 2023-08-25 16:14:28 +00:00
Alex van Andel 4540c79580 v3.2.4
release.yml / release (push) Canceled after 0s
2023-08-25 15:32:55 +01:00
Udit Takkar 00d01882d4 fix: private team regression (#10914)
Signed-off-by: Udit Takkar <udit.07814802719@cse.mait.ac.in>
2023-08-25 14:43:48 +01:00
Omar López 6ce4122528 fix: allows patching booking and period limits from API (#10935) 2023-08-25 14:29:04 +01:00
7ddbcfcdcf feat: Adds filter-by-email on /users/_get endpoint for admin calls (#10928)
* adds filter by email on users get for admins

* swagger update

* Update apps/api/pages/api/users/_get.ts

Co-authored-by: Omar López <zomars@me.com>

* .parse is required, also fixed some other issues

---------

Co-authored-by: Omar López <zomars@me.com>
Co-authored-by: Alex van Andel <me@alexvanandel.com>
2023-08-25 14:15:13 +01:00
b1b01ed383 remove close button from TopBanner (#10705)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
2023-08-25 15:50:39 +03:00
6f5d9dc41f test: Create unit tests for react components in packages/ui/components/form/select (#10459)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2023-08-25 12:18:53 +00:00
GitStart-Cal.comandgitstart-calcom ac190b89dd chore: add UnpublishedEntity in storybook (CALCOM-10760) (#10864)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
2023-08-25 15:12:19 +03:00
990441295c chore: add ImageUploader in storybook (CALCOM-10760) (#10865)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2023-08-25 15:06:55 +03:00
1e4c20073c chore: fix all @typescript-eslint/no-non-null-assertion warnings (#10635)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2023-08-25 14:56:20 +03:00
80353d67a0 chore: fix all @typescript-eslint/no-unused-vars warnings (#10645)
Co-authored-by: gitstart-calcom <gitstart@users.noreply.github.com>
Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>
2023-08-25 11:48:24 +00:00
Keith Williams d55b4b051a perf: Fix slowest db query (again) (#10954) 2023-08-25 14:27:37 +03:00
Hariom Balhara 25d90e3e12 test: Upcoming bookings sorting order (#10950)
* Fix order by for bookings array
Fix orderBy for bookings

* Test

* Text change

* Add comments
2023-08-25 12:55:41 +03:00
Hariom Balhara 1727eb5625 fix: Unordered bookings (#10943) 2023-08-25 15:06:57 +05:30
sean-brydon 58f4224c66 Fix invite flow (#10951) 2023-08-25 09:19:03 +00:00
Udit Takkar 5d491e0a12 fix: unauthorized after email change (#10821) 2023-08-24 16:47:34 -07:00
alannncandOmar López 1eef5a56c3 fix: include orgs slug in getUserByUsername just like [user]/[type] page (#10723)
Co-authored-by: Omar López <zomars@me.com>
2023-08-24 16:41:06 -07:00
d6740503be feat: Column View Available Slots Fix (#9815)
Co-authored-by: Vinoth Kumar V <vinoth_kumar_v@Vinoths-MacBook-Pro.local>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: Udit Takkar <53316345+Udit-takkar@users.noreply.github.com>
Co-authored-by: alannnc <alannnc@gmail.com>
Co-authored-by: Shivam Kalra <shivamkalra98@gmail.com>
Co-authored-by: Omar López <zomars@me.com>
2023-08-24 16:39:45 -07:00
David Oksman 9f647ead46 fix: missing timezones in dropdown list (#10823) 2023-08-24 13:57:11 -07:00
Thibault Mathian f480afcace fix: caching and fetching of feature flags (#10837) 2023-08-24 13:43:52 -07:00
Hariom Balhara 8d73d15b8a perf: Lazy load framer motion stuff as much as we can (#10876) 2023-08-24 13:40:01 -07:00
Hariom Balhara e8370ef950 perf: Remove non booker apps from booker page (#10905) 2023-08-24 13:38:15 -07:00
Hariom Balhara e859c8dc58 test: Add attendee phone number test (#10932) 2023-08-24 11:21:49 -07:00
92 changed files with 1543 additions and 608 deletions
+7 -1
View File
@@ -33,12 +33,14 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
position: true,
eventName: true,
timeZone: true,
schedulingType: true,
// START Limit future bookings
periodType: true,
periodStartDate: true,
schedulingType: true,
periodEndDate: true,
periodDays: true,
periodCountCalendarDays: true,
// END Limit future bookings
requiresConfirmation: true,
disableGuests: true,
hideCalendarNotes: true,
@@ -51,6 +53,8 @@ export const schemaEventTypeBaseBodyParams = EventType.pick({
slotInterval: true,
successRedirectUrl: true,
locations: true,
bookingLimits: true,
durationLimits: true,
})
.merge(z.object({ hosts: z.array(hostSchema).optional().default([]) }))
.partial()
@@ -126,6 +130,8 @@ export const schemaEventTypeReadPublic = EventType.pick({
seatsPerTimeSlot: true,
seatsShowAttendees: true,
bookingFields: true,
bookingLimits: true,
durationLimits: true,
}).merge(
z.object({
locations: z
+12 -10
View File
@@ -13,16 +13,18 @@ const schemaMembershipRequiredParams = z.object({
teamId: z.number(),
});
export const membershipCreateBodySchema = Membership.partial({
accepted: true,
role: true,
disableImpersonation: true,
}).transform((v) => ({
accepted: false,
role: MembershipRole.MEMBER,
disableImpersonation: false,
...v,
}));
export const membershipCreateBodySchema = Membership.omit({ id: true })
.partial({
accepted: true,
role: true,
disableImpersonation: true,
})
.transform((v) => ({
accepted: false,
role: MembershipRole.MEMBER,
disableImpersonation: false,
...v,
}));
export const membershipEditBodySchema = Membership.omit({
/** To avoid complication, let's avoid updating these, instead you can delete and create a new invite */
@@ -0,0 +1,20 @@
import { withValidation } from "next-validations";
import { z } from "zod";
import { baseApiParams } from "./baseApiParams";
// Extracted out as utility function so can be reused
// at different endpoints that require this validation.
export const schemaQueryUserEmail = baseApiParams.extend({
email: z.string().email(),
});
export const schemaQuerySingleOrMultipleUserEmails = z.object({
email: z.union([z.string().email(), z.array(z.string().email())]),
});
export const withValidQueryUserEmail = withValidation({
schema: schemaQueryUserEmail,
type: "Zod",
mode: "query",
});
@@ -1,4 +1,4 @@
import type { Prisma } from "@prisma/client";
import { Prisma } from "@prisma/client";
import type { NextApiRequest } from "next";
import type { z } from "zod";
@@ -202,10 +202,17 @@ import checkTeamEventEditPermission from "../_utils/checkTeamEventEditPermission
export async function patchHandler(req: NextApiRequest) {
const { prisma, query, body } = req;
const { id } = schemaQueryIdParseInt.parse(query);
const { hosts = [], ...parsedBody } = schemaEventTypeEditBodyParams.parse(body);
const {
hosts = [],
bookingLimits,
durationLimits,
...parsedBody
} = schemaEventTypeEditBodyParams.parse(body);
const data: Prisma.EventTypeUpdateArgs["data"] = {
...parsedBody,
bookingLimits: bookingLimits === null ? Prisma.DbNull : bookingLimits,
durationLimits: durationLimits === null ? Prisma.DbNull : durationLimits,
};
if (hosts) {
+9 -2
View File
@@ -1,4 +1,4 @@
import type { Prisma } from "@prisma/client";
import { Prisma } from "@prisma/client";
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
@@ -259,12 +259,19 @@ import ensureOnlyMembersAsHosts from "./_utils/ensureOnlyMembersAsHosts";
async function postHandler(req: NextApiRequest) {
const { userId, isAdmin, prisma, body } = req;
const { hosts = [], ...parsedBody } = schemaEventTypeCreateBodyParams.parse(body || {});
const {
hosts = [],
bookingLimits,
durationLimits,
...parsedBody
} = schemaEventTypeCreateBodyParams.parse(body || {});
let data: Prisma.EventTypeCreateArgs["data"] = {
...parsedBody,
userId,
users: { connect: { id: userId } },
bookingLimits: bookingLimits === null ? Prisma.DbNull : bookingLimits,
durationLimits: durationLimits === null ? Prisma.DbNull : durationLimits,
};
await checkPermissions(req);
+20
View File
@@ -4,6 +4,7 @@ import type { NextApiRequest } from "next";
import { defaultResponder } from "@calcom/lib/server";
import { withMiddleware } from "~/lib/helpers/withMiddleware";
import { schemaQuerySingleOrMultipleUserEmails } from "~/lib/validations/shared/queryUserEmail";
import { schemaUsersReadPublic } from "~/lib/validations/user";
/**
@@ -19,6 +20,17 @@ import { schemaUsersReadPublic } from "~/lib/validations/user";
* schema:
* type: string
* description: Your API key
* - in: query
* name: email
* required: false
* schema:
* type: array
* items:
* type: string
* format: email
* style: form
* explode: true
* description: The email address or an array of email addresses to filter by
* tags:
* - users
* responses:
@@ -39,6 +51,14 @@ export async function getHandler(req: NextApiRequest) {
const where: Prisma.UserWhereInput = {};
// If user is not ADMIN, return only his data.
if (!isAdmin) where.id = userId;
if (req.query.email) {
const validationResult = schemaQuerySingleOrMultipleUserEmails.parse(req.query);
where.email = {
in: Array.isArray(validationResult.email) ? validationResult.email : [validationResult.email],
};
}
const [total, data] = await prisma.$transaction([
prisma.user.count({ where }),
prisma.user.findMany({ where, take, skip }),
+21 -1
View File
@@ -1,9 +1,29 @@
import i18n from "i18next";
import { initReactI18next } from "react-i18next";
const ns = ["common"];
const supportedLngs = ["en", "fr"];
const resources = ns.reduce((acc, n) => {
supportedLngs.forEach((lng) => {
if (!acc[lng]) acc[lng] = {};
acc[lng] = {
...acc[lng],
[n]: require(`../../web/public/static/locales/${lng}/${n}.json`),
};
});
return acc;
}, {});
i18n.use(initReactI18next).init({
resources: [],
debug: true,
fallbackLng: "en",
defaultNS: "common",
ns,
interpolation: {
escapeValue: false,
},
react: { useSuspense: true },
resources,
});
export default i18n;
+8
View File
@@ -21,6 +21,14 @@ export const parameters = {
push() {},
Provider: AppRouterContext.Provider,
},
globals: {
locale: "en",
locales: {
en: "English",
fr: "Français",
},
},
i18n,
};
addDecorator((storyFn) => (
+2 -1
View File
@@ -18,6 +18,7 @@ export function VariantsTable({
const columns = React.Children.toArray(children) as ReactElement<RowProps>[];
return (
<div
id="light-variant"
className={classNames(
isDark &&
"relative py-8 before:absolute before:left-0 before:top-0 before:block before:h-full before:w-screen before:bg-[#1C1C1C]"
@@ -43,7 +44,7 @@ export function VariantsTable({
</table>
</div>
{!isDark && (
<div data-mode="dark" className="dark">
<div id="dark-variant" data-mode="dark" className="dark">
<VariantsTable titles={titles} isDark columnMinWidth={columnMinWidth}>
{children}
</VariantsTable>
@@ -336,7 +336,7 @@ function BookingListItem(booking: BookingItemProps) {
</DialogContent>
</Dialog>
<tr className="hover:bg-muted group flex flex-col sm:flex-row">
<tr data-testid="booking-item" className="hover:bg-muted group flex flex-col sm:flex-row">
<td
className="hidden align-top ltr:pl-6 rtl:pr-6 sm:table-cell sm:min-w-[12rem]"
onClick={onClickTableData}>
@@ -22,7 +22,7 @@ const SetupAvailability = (props: ISetupAvailabilityProps) => {
const scheduleId = defaultScheduleId === null ? undefined : defaultScheduleId;
const queryAvailability = trpc.viewer.availability.schedule.get.useQuery(
{ scheduleId },
{ scheduleId: defaultScheduleId ?? undefined },
{
enabled: !!scheduleId,
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "3.2.3",
"version": "3.2.6",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+1
View File
@@ -75,6 +75,7 @@ export default function Custom404() {
)}`
);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const isSuccessPage = pathname?.startsWith("/booking");
-1
View File
@@ -53,7 +53,6 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
orgSlug: _orgSlug,
...query
} = useRouterQuery();
const nameOrUsername = user.name || user.username || "";
/*
const telemetry = useTelemetry();
+6 -16
View File
@@ -39,19 +39,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
const username = slugify(data.username);
const userEmail = email.toLowerCase();
const validationResponse = (
incomingEmail: string,
validation: { isValid: boolean; email: string | undefined }
) => {
const { isValid, email } = validation;
if (!isValid) {
const message: string =
email !== incomingEmail ? "Username already taken" : "Email address is already registered";
return res.status(409).json({ message });
}
};
if (!username) {
res.status(422).json({ message: "Invalid username" });
return;
@@ -79,11 +66,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
}
if (foundToken?.teamId) {
const teamUserValidation = await validateUsernameInTeam(username, userEmail, foundToken?.teamId);
return validationResponse(userEmail, teamUserValidation);
if (!teamUserValidation.isValid) {
return res.status(409).json({ message: "Username or email is already taken" });
}
}
} else {
const userValidation = await validateUsername(username, userEmail);
return validationResponse(userEmail, userValidation);
if (!userValidation.isValid) {
return res.status(409).json({ message: "Username or email is already taken" });
}
}
const hashedPassword = await hashPassword(password);
@@ -94,7 +85,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
id: foundToken.teamId,
},
});
if (team) {
const teamMetadata = teamMetadataSchema.parse(team?.metadata);
-48
View File
@@ -7,54 +7,6 @@ import { getTranslation } from "@calcom/lib/server/i18n";
const handler = async (req: NextApiRequest, res: NextApiResponse) => {
if (IS_PRODUCTION) return res.write("Only for development purposes"), res.end();
const t = await getTranslation("en", "common");
const language = { translate: t, locale: "en" };
const evt = {
type: "30min",
title: "30min between Pro Example and pro@example.com",
description: null,
additionalNotes: "asdasdas",
startTime: "2022-06-03T09:00:00-06:00",
endTime: "2022-06-03T09:30:00-06:00",
organizer: {
name: "Pro Example",
email: "pro@example.com",
timeZone: "Europe/London",
language,
},
attendees: [
{
email: "pro@example.com",
name: "pro@example.com",
timeZone: "America/Chihuahua",
language,
},
],
location: "Zoom video",
destinationCalendar: null,
hideCalendarNotes: false,
uid: "xxyPr4cg2xx4XoS2KeMEQy",
metadata: {},
recurringEvent: null,
appsStatus: [
{
appName: "Outlook Calendar",
type: "office365_calendar",
success: 1,
failures: 0,
errors: [],
warnings: [],
},
{
appName: "Google Meet",
type: "conferencing",
success: 0,
failures: 1,
errors: [],
warnings: ["In order to use Google Meet you must set your destination calendar to a Google Calendar"],
},
],
};
res.statusCode = 200;
+6 -1
View File
@@ -34,13 +34,17 @@ const triggerWebhook = async ({
booking: {
userId: number | undefined;
eventTypeId: number | null;
eventTypeParentId: number | null | undefined;
teamId?: number | null;
};
}) => {
const eventTrigger: WebhookTriggerEvents = "RECORDING_READY";
// Send Webhook call if hooked to BOOKING.RECORDING_READY
const triggerForUser = !booking.teamId || (booking.teamId && booking.eventTypeParentId);
const subscriberOptions = {
userId: booking.userId,
userId: triggerForUser ? booking.userId : null,
eventTypeId: booking.eventTypeId,
triggerEvent: eventTrigger,
teamId: booking.teamId,
@@ -183,6 +187,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
booking: {
userId: booking?.user?.id,
eventTypeId: booking.eventTypeId,
eventTypeParentId: booking.eventType?.parentId,
teamId,
},
});
+1 -1
View File
@@ -179,7 +179,7 @@ export default function Bookings() {
)}
<div className="pt-2 xl:pt-0">
<div className="border-subtle overflow-hidden rounded-md border">
<table className="w-full max-w-full table-fixed">
<table data-testid={`${status}-bookings`} className="w-full max-w-full table-fixed">
<tbody className="bg-default divide-subtle divide-y" data-testid="bookings">
{query.data.pages.map((page, index) => (
<Fragment key={index}>
@@ -411,6 +411,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
throw new Error(t("seats_and_no_show_fee_error"));
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { availability, ...rest } = input;
updateMutation.mutate({
...rest,
@@ -495,6 +496,7 @@ const EventTypePage = (props: EventTypeSetupProps) => {
}
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { availability, ...rest } = input;
updateMutation.mutate({
...rest,
+17 -15
View File
@@ -77,26 +77,25 @@ type FormValues = {
const ProfileView = () => {
const { t } = useLocale();
const utils = trpc.useContext();
const { data: _session, update } = useSession();
const { update } = useSession();
const { data: user, isLoading } = trpc.viewer.me.useQuery();
const updateProfileMutation = trpc.viewer.updateProfile.useMutation({
onSuccess: async (res) => {
await update(res);
showToast(t("settings_updated_successfully"), "success");
if (res.signOutUser && tempFormValues) {
if (res.passwordReset) {
showToast(t("password_reset_email", { email: tempFormValues.email }), "success");
// sign out the user to avoid unauthorized access error
await signOut({ callbackUrl: "/auth/logout?passReset=true" });
} else {
// sign out the user to avoid unauthorized access error
await signOut({ callbackUrl: "/auth/logout?emailChange=true" });
}
// signout user only in case of password reset
if (res.signOutUser && tempFormValues && res.passwordReset) {
showToast(t("password_reset_email", { email: tempFormValues.email }), "success");
await signOut({ callbackUrl: "/auth/logout?passReset=true" });
} else {
utils.viewer.me.invalidate();
utils.viewer.avatar.invalidate();
utils.viewer.shouldVerifyEmail.invalidate();
}
utils.viewer.me.invalidate();
utils.viewer.avatar.invalidate();
setConfirmAuthEmailChangeWarningDialogOpen(false);
update(res);
setTempFormValues(null);
},
onError: () => {
@@ -325,7 +324,10 @@ const ProfileView = () => {
{confirmPasswordErrorMessage && <Alert severity="error" title={confirmPasswordErrorMessage} />}
</div>
<DialogFooter showDivider>
<Button color="primary" onClick={(e) => onConfirmPassword(e)}>
<Button
color="primary"
loading={confirmPasswordMutation.isLoading}
onClick={(e) => onConfirmPassword(e)}>
{t("confirm")}
</Button>
<DialogClose />
@@ -345,7 +347,7 @@ const ProfileView = () => {
<DialogFooter>
<Button
color="primary"
disabled={updateProfileMutation.isLoading}
loading={updateProfileMutation.isLoading}
onClick={(e) => onConfirmAuthEmailChange(e)}>
{t("confirm")}
</Button>
+11 -3
View File
@@ -199,19 +199,27 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
<SubTeams />
) : (
<>
{(showMembers.isOn || !team.eventTypes.length) && <Team team={team} />}
{(showMembers.isOn || !team.eventTypes.length) &&
(team.isPrivate ? (
<div className="w-full text-center">
<h2 className="text-emphasis font-semibold">{t("you_cannot_see_team_members")}</h2>
</div>
) : (
<Team team={team} />
))}
{!showMembers.isOn && team.eventTypes.length > 0 && (
<div className="mx-auto max-w-3xl ">
<EventTypes />
{!team.hideBookATeamMember && (
{/* Hide "Book a team member button when team is private or hideBookATeamMember is true" */}
{(!team.hideBookATeamMember || team.isPrivate) && (
<div>
<div className="relative mt-12">
<div className="absolute inset-0 flex items-center" aria-hidden="true">
<div className="border-subtle w-full border-t" />
</div>
<div className="relative flex justify-center">
<span className="dark:bg-darkgray-50 bg-subtle text-subtle dark:text-inverted px-2 text-sm">
<span className="dark:bg-darkgray-50 bg-subtle text-subtle px-2 text-sm">
{t("or")}
</span>
</div>
+2 -1
View File
@@ -158,9 +158,10 @@ test.describe("pro user", () => {
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
additionalGuests.forEach(async (email) => {
const promises = additionalGuests.map(async (email) => {
await expect(page.locator(`[data-testid="attendee-email-${email}"]`)).toHaveText(email);
});
await Promise.all(promises);
});
test("Time slots should be reserved when selected", async ({ context, page }) => {
+113
View File
@@ -0,0 +1,113 @@
import { expect } from "@playwright/test";
import { BookingStatus } from "@calcom/prisma/client";
import type { Fixtures } from "./lib/fixtures";
import { test } from "./lib/fixtures";
test.afterEach(({ users }) => users.deleteAll());
test.describe("Bookings", () => {
test.describe("Upcoming bookings", () => {
test("show attendee bookings and organizer bookings in asc order by startDate", async ({
page,
users,
bookings,
}) => {
const firstUser = await users.create();
const secondUser = await users.create();
const bookingWhereFirstUserIsOrganizerFixture = await createBooking({
title: "Booking as organizer",
bookingsFixture: bookings,
// Create a booking 3 days from today
relativeDate: 3,
organizer: firstUser,
organizerEventType: firstUser.eventTypes[0],
attendees: [
{ name: "First", email: "first@cal.com", timeZone: "Europe/Berlin" },
{ name: "Second", email: "second@cal.com", timeZone: "Europe/Berlin" },
{ name: "Third", email: "third@cal.com", timeZone: "Europe/Berlin" },
],
});
const bookingWhereFirstUserIsOrganizer = await bookingWhereFirstUserIsOrganizerFixture.self();
const bookingWhereFirstUserIsAttendeeFixture = await createBooking({
title: "Booking as attendee",
bookingsFixture: bookings,
organizer: secondUser,
// Booking created 2 days from today
relativeDate: 2,
organizerEventType: secondUser.eventTypes[0],
attendees: [
{ name: "OrganizerAsBooker", email: firstUser.email, timeZone: "Europe/Berlin" },
{ name: "Second", email: "second@cal.com", timeZone: "Europe/Berlin" },
{ name: "Third", email: "third@cal.com", timeZone: "Europe/Berlin" },
],
});
const bookingWhereFirstUserIsAttendee = await bookingWhereFirstUserIsAttendeeFixture.self();
await firstUser.apiLogin();
await page.goto(`/bookings/upcoming`);
const upcomingBookings = page.locator('[data-testid="upcoming-bookings"]');
const firstUpcomingBooking = upcomingBookings.locator('[data-testid="booking-item"]').nth(0);
const secondUpcomingBooking = upcomingBookings.locator('[data-testid="booking-item"]').nth(1);
await expect(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
firstUpcomingBooking.locator(`text=${bookingWhereFirstUserIsAttendee!.title}`)
).toBeVisible();
await expect(
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
secondUpcomingBooking.locator(`text=${bookingWhereFirstUserIsOrganizer!.title}`)
).toBeVisible();
});
});
});
async function createBooking({
bookingsFixture,
organizer,
organizerEventType,
attendees,
/**
* Relative date from today
* 0 means today
* 1 means tomorrow
*/
relativeDate = 0,
durationMins = 30,
title,
}: {
bookingsFixture: Fixtures["bookings"];
organizer: {
id: number;
username: string | null;
};
organizerEventType: {
id: number;
};
attendees: {
name: string;
email: string;
timeZone: string;
}[];
relativeDate?: number;
durationMins?: number;
title: string;
}) {
const DAY_MS = 24 * 60 * 60 * 1000;
const bookingDurationMs = durationMins * 60 * 1000;
const startTime = new Date(Date.now() + relativeDate * DAY_MS);
const endTime = new Date(Date.now() + relativeDate * DAY_MS + bookingDurationMs);
return await bookingsFixture.create(organizer.id, organizer.username, organizerEventType.id, {
title,
status: BookingStatus.ACCEPTED,
startTime,
endTime,
attendees: {
createMany: {
data: [...attendees],
},
},
});
}
+43
View File
@@ -1,3 +1,4 @@
import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { WEBAPP_URL } from "@calcom/lib/constants";
@@ -91,6 +92,7 @@ test.describe("Event Types tests", () => {
expect(formTitle).toBe(firstTitle);
expect(formSlug).toContain(firstSlug);
});
test("edit first event", async ({ page }) => {
const $eventTypes = page.locator("[data-testid=event-types] > li a");
const firstEventTypeElement = $eventTypes.first();
@@ -152,5 +154,46 @@ test.describe("Event Types tests", () => {
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
});
test.describe("Different Locations Tests", () => {
test("can add Attendee Phone Number location and book with it", async ({ page }) => {
await gotoFirstEventType(page);
await selectAttendeePhoneNumber(page);
await saveEventType(page);
await gotoBookingPage(page);
await selectFirstAvailableTimeSlotNextMonth(page);
await page.locator(`[data-fob-field-name="location"] input`).fill("9199999999");
await bookTimeSlot(page);
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
await expect(page.locator("text=+19199999999")).toBeVisible();
});
});
});
});
const selectAttendeePhoneNumber = async (page: Page) => {
const locationOptionText = "Attendee Phone Number";
await page.locator("#location-select").click();
await page.locator(`text=${locationOptionText}`).click();
};
async function gotoFirstEventType(page: Page) {
const $eventTypes = page.locator("[data-testid=event-types] > li a");
const firstEventTypeElement = $eventTypes.first();
await firstEventTypeElement.click();
await page.waitForURL((url) => {
return !!url.pathname.match(/\/event-types\/.+/);
});
}
async function saveEventType(page: Page) {
await page.locator("[data-testid=update-eventtype]").click();
}
async function gotoBookingPage(page: Page) {
const previewLink = await page.locator("[data-testid=preview-button]").getAttribute("href");
await page.goto(previewLink ?? "");
}
+6 -3
View File
@@ -19,9 +19,12 @@ export const createBookingsFixture = (page: Page) => {
username: string | null,
eventTypeId = -1,
{
title = "",
rescheduled = false,
paid = false,
status = "ACCEPTED",
startTime,
endTime,
attendees = {
create: {
email: "attendee@example.com",
@@ -39,9 +42,9 @@ export const createBookingsFixture = (page: Page) => {
const booking = await prisma.booking.create({
data: {
uid: uid,
title: "30min",
startTime: startDate,
endTime: endDateParam || dayjs().add(1, "day").add(30, "minutes").toDate(),
title: title || "30min",
startTime: startTime || startDate,
endTime: endTime || endDateParam || dayjs().add(1, "day").add(30, "minutes").toDate(),
user: {
connect: {
id: userId,
+1 -1
View File
@@ -542,7 +542,7 @@ export async function apiLogin(
.then((json) => json.csrfToken);
const data = {
email: user.email ?? `${user.username}@example.com`,
password: user.password ?? user.username!,
password: user.password ?? user.username,
callbackURL: "http://localhost:3000/",
redirect: "false",
json: "true",
+129 -97
View File
@@ -269,12 +269,14 @@ describe("getSchedule", () => {
createBookingScenario(scenarioData);
const scheduleForDayWithAGoogleCalendarBooking = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
},
});
// As per Google Calendar Availability, only 4PM(4-4:45PM) GMT slot would be available
@@ -353,13 +355,15 @@ describe("getSchedule", () => {
// Day Plus 2 is completely free - It only has non accepted bookings
const scheduleOnCompletelyFreeDay = await getSchedule({
eventTypeId: 1,
// EventTypeSlug doesn't matter for non-dynamic events
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
input: {
eventTypeId: 1,
// EventTypeSlug doesn't matter for non-dynamic events
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
}
});
// getSchedule returns timeslots in GMT
@@ -384,12 +388,14 @@ describe("getSchedule", () => {
// Day plus 3
const scheduleForDayWithOneBooking = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus2DateString}T18:30:00.000Z`,
endTime: `${plus3DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus2DateString}T18:30:00.000Z`,
endTime: `${plus3DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
}
});
expect(scheduleForDayWithOneBooking).toHaveTimeSlots(
@@ -448,12 +454,14 @@ describe("getSchedule", () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const scheduleForEventWith30Length = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
}
});
expect(scheduleForEventWith30Length).toHaveTimeSlots(
@@ -482,12 +490,14 @@ describe("getSchedule", () => {
);
const scheduleForEventWith30minsLengthAndSlotInterval2hrs = await getSchedule({
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
input: {
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
}
});
// `slotInterval` takes precedence over `length`
// 4:30 is utc so it is 10:00 in IST
@@ -545,12 +555,14 @@ describe("getSchedule", () => {
const { dateString: todayDateString } = getDate();
const { dateString: minus1DateString } = getDate({ dateIncrement: -1 });
const scheduleForEventWithBookingNotice13Hrs = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${minus1DateString}T18:30:00.000Z`,
endTime: `${todayDateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${minus1DateString}T18:30:00.000Z`,
endTime: `${todayDateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
}
});
expect(scheduleForEventWithBookingNotice13Hrs).toHaveTimeSlots(
[
@@ -564,12 +576,14 @@ describe("getSchedule", () => {
);
const scheduleForEventWithBookingNotice10Hrs = await getSchedule({
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${minus1DateString}T18:30:00.000Z`,
endTime: `${todayDateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
input: {
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${minus1DateString}T18:30:00.000Z`,
endTime: `${todayDateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
}
});
expect(scheduleForEventWithBookingNotice10Hrs).toHaveTimeSlots(
[
@@ -627,12 +641,14 @@ describe("getSchedule", () => {
createBookingScenario(scenarioData);
const scheduleForEventOnADayWithNonCalBooking = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus2DateString}T18:30:00.000Z`,
endTime: `${plus3DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus2DateString}T18:30:00.000Z`,
endTime: `${plus3DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
}
});
expect(scheduleForEventOnADayWithNonCalBooking).toHaveTimeSlots(
@@ -700,12 +716,14 @@ describe("getSchedule", () => {
createBookingScenario(scenarioData);
const scheduleForEventOnADayWithCalBooking = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
}
});
expect(scheduleForEventOnADayWithCalBooking).toHaveTimeSlots(
@@ -757,12 +775,14 @@ describe("getSchedule", () => {
createBookingScenario(scenarioData);
const schedule = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
}
});
expect(schedule).toHaveTimeSlots(
@@ -820,12 +840,14 @@ describe("getSchedule", () => {
createBookingScenario(scenarioData);
const scheduleForEventOnADayWithDateOverride = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
}
});
expect(scheduleForEventOnADayWithDateOverride).toHaveTimeSlots(
@@ -905,12 +927,14 @@ describe("getSchedule", () => {
// Requesting this user's availability for their
// individual Event Type
const thisUserAvailability = await getSchedule({
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
input: {
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: false,
}
});
expect(thisUserAvailability).toHaveTimeSlots(
@@ -1002,12 +1026,14 @@ describe("getSchedule", () => {
});
const scheduleForTeamEventOnADayWithNoBooking = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${todayDateString}T18:30:00.000Z`,
endTime: `${plus1DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${todayDateString}T18:30:00.000Z`,
endTime: `${plus1DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
}
});
expect(scheduleForTeamEventOnADayWithNoBooking).toHaveTimeSlots(
@@ -1030,12 +1056,14 @@ describe("getSchedule", () => {
);
const scheduleForTeamEventOnADayWithOneBookingForEachUser = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
}
});
// A user with blocked time in another event, still affects Team Event availability
@@ -1137,12 +1165,14 @@ describe("getSchedule", () => {
hosts: [],
});
const scheduleForTeamEventOnADayWithOneBookingForEachUserButOnDifferentTimeslots = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T18:30:00.000Z`,
endTime: `${plus2DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
}
});
// A user with blocked time in another event, still affects Team Event availability
expect(scheduleForTeamEventOnADayWithOneBookingForEachUserButOnDifferentTimeslots).toHaveTimeSlots(
@@ -1163,12 +1193,14 @@ describe("getSchedule", () => {
);
const scheduleForTeamEventOnADayWithOneBookingForEachUserOnSameTimeSlot = await getSchedule({
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus2DateString}T18:30:00.000Z`,
endTime: `${plus3DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus2DateString}T18:30:00.000Z`,
endTime: `${plus3DateString}T18:29:59.999Z`,
timeZone: Timezones["+5:30"],
isTeamEvent: true,
}
});
// A user with blocked time in another event, still affects Team Event availability
expect(scheduleForTeamEventOnADayWithOneBookingForEachUserOnSameTimeSlot).toHaveTimeSlots(
@@ -303,6 +303,7 @@ describe("handleChildrenEventTypes", () => {
timeZone: _timeZone,
parentId: _parentId,
userId: _userId,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
requiresBookerEmailVerification,
...evType
} = mockFindFirstEventType({
+44 -6
View File
@@ -40,6 +40,7 @@ type App = Partial<AppMeta> & {
function generateFiles() {
const browserOutput = [`import dynamic from "next/dynamic"`];
const metadataOutput = [];
const bookerMetadataOutput = [];
const schemasOutput = [];
const appKeysSchemasOutput = [];
const serverOutput = [];
@@ -70,22 +71,30 @@ function generateFiles() {
}
});
function forEachAppDir(callback: (arg: App) => void) {
function forEachAppDir(callback: (arg: App) => void, filter: (arg: App) => boolean = () => true) {
for (let i = 0; i < appDirs.length; i++) {
const configPath = path.join(APP_STORE_PATH, appDirs[i].path, "config.json");
const metadataPath = path.join(APP_STORE_PATH, appDirs[i].path, "_metadata.ts");
let app;
if (fs.existsSync(configPath)) {
app = JSON.parse(fs.readFileSync(configPath).toString());
} else if (fs.existsSync(metadataPath)) {
// eslint-disable-next-line @typescript-eslint/no-var-requires
app = require(metadataPath).metadata;
} else {
app = {};
}
callback({
const finalApp = {
...app,
name: appDirs[i].name,
path: appDirs[i].path,
});
};
if (filter(finalApp)) {
callback(finalApp);
}
}
}
@@ -130,7 +139,8 @@ function generateFiles() {
lazyImport?: boolean;
importConfig: ImportConfig;
entryObjectKeyGetter?: (arg: App, importName?: string) => string;
}
},
filter?: (arg: App) => boolean
) {
const output: string[] = [];
@@ -174,7 +184,7 @@ function generateFiles() {
}
}
}
});
}, filter);
}
function createExportObject() {
@@ -201,7 +211,7 @@ function generateFiles() {
}
}
}
});
}, filter);
output.push(`};`);
}
@@ -247,6 +257,25 @@ function generateFiles() {
})
);
bookerMetadataOutput.push(
...getExportedObject(
"appStoreMetadata",
{
// Try looking for config.json and if it's not found use _metadata.ts to generate appStoreMetadata
importConfig: [
{
fileToBeImported: "config.json",
importName: "default",
},
{
fileToBeImported: "_metadata.ts",
importName: "metadata",
},
],
},
isBookerApp
)
);
schemasOutput.push(
...getExportedObject("appDataSchemas", {
// Import path must have / even for windows and not \
@@ -312,6 +341,7 @@ function generateFiles() {
["apps.browser.generated.tsx", browserOutput],
["apps.schemas.generated.ts", schemasOutput],
["apps.keys-schemas.generated.ts", appKeysSchemasOutput],
["bookerApps.metadata.generated.ts", bookerMetadataOutput],
];
filesToGenerate.forEach(([fileName, output]) => {
fs.writeFileSync(`${APP_STORE_PATH}/${fileName}`, formatOutput(`${banner}${output.join("\n")}`));
@@ -347,3 +377,11 @@ if (isInWatchMode) {
} else {
generateFiles();
}
function isBookerApp(app: App) {
// Right now there are only two types of Apps that booker needs.
// Note that currently payment apps' meta don't need to be accessed on booker. We just access from DB eventType.metadata
// 1. It is a location app(e.g. any Conferencing App)
// 2. It is a tag manager app(e.g. Google Analytics, GTM, Fathom)
return !!(app.appData?.location || app.appData?.tag);
}
+1 -1
View File
@@ -1,7 +1,7 @@
import Script from "next/script";
import { getEventTypeAppData } from "@calcom/app-store/_utils/getEventTypeAppData";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { appStoreMetadata } from "@calcom/app-store/bookerAppsMetaData";
import type { appDataSchemas } from "./apps.schemas.generated";
+2 -15
View File
@@ -1,7 +1,7 @@
import type { AppMeta } from "@calcom/types/App";
import { appStoreMetadata as rawAppStoreMetadata } from "./apps.metadata.generated";
import { getAppAssetFullPath } from "./getAppAssetFullPath";
import { getNormalizedAppMetadata } from "./getNormalizedAppMetadata";
type RawAppStoreMetaData = typeof rawAppStoreMetadata;
type AppStoreMetaData = {
@@ -10,18 +10,5 @@ type AppStoreMetaData = {
export const appStoreMetadata = {} as AppStoreMetaData;
for (const [key, value] of Object.entries(rawAppStoreMetadata)) {
const dirName = "dirName" in value ? value.dirName : value.slug;
if (!dirName) {
throw new Error(`Couldn't derive dirName for app ${key}`);
}
const metadata = (appStoreMetadata[key as keyof typeof appStoreMetadata] = {
appData: null,
dirName,
__template: "",
...value,
} as AppStoreMetaData[keyof AppStoreMetaData]);
metadata.logo = getAppAssetFullPath(metadata.logo, {
dirName,
isTemplate: metadata.isTemplate,
});
appStoreMetadata[key as keyof typeof appStoreMetadata] = getNormalizedAppMetadata(value);
}
@@ -0,0 +1,67 @@
/**
This file is autogenerated using the command `yarn app-store:build --watch`.
Don't modify this file manually.
**/
import around_config_json from "./around/config.json";
import campfire_config_json from "./campfire/config.json";
import { metadata as dailyvideo__metadata_ts } from "./dailyvideo/_metadata";
import discord_config_json from "./discord/config.json";
import eightxeight_config_json from "./eightxeight/config.json";
import element_call_config_json from "./element-call/config.json";
import facetime_config_json from "./facetime/config.json";
import fathom_config_json from "./fathom/config.json";
import ga4_config_json from "./ga4/config.json";
import { metadata as googlevideo__metadata_ts } from "./googlevideo/_metadata";
import gtm_config_json from "./gtm/config.json";
import { metadata as huddle01video__metadata_ts } from "./huddle01video/_metadata";
import { metadata as jitsivideo__metadata_ts } from "./jitsivideo/_metadata";
import metapixel_config_json from "./metapixel/config.json";
import mirotalk_config_json from "./mirotalk/config.json";
import office365video_config_json from "./office365video/config.json";
import ping_config_json from "./ping/config.json";
import plausible_config_json from "./plausible/config.json";
import riverside_config_json from "./riverside/config.json";
import signal_config_json from "./signal/config.json";
import sirius_video_config_json from "./sirius_video/config.json";
import sylapsvideo_config_json from "./sylapsvideo/config.json";
import { metadata as tandemvideo__metadata_ts } from "./tandemvideo/_metadata";
import telegram_config_json from "./telegram/config.json";
import booking_pages_tag_config_json from "./templates/booking-pages-tag/config.json";
import event_type_location_video_static_config_json from "./templates/event-type-location-video-static/config.json";
import webex_config_json from "./webex/config.json";
import whatsapp_config_json from "./whatsapp/config.json";
import whereby_config_json from "./whereby/config.json";
import { metadata as zoomvideo__metadata_ts } from "./zoomvideo/_metadata";
export const appStoreMetadata = {
around: around_config_json,
campfire: campfire_config_json,
dailyvideo: dailyvideo__metadata_ts,
discord: discord_config_json,
eightxeight: eightxeight_config_json,
"element-call": element_call_config_json,
facetime: facetime_config_json,
fathom: fathom_config_json,
ga4: ga4_config_json,
googlevideo: googlevideo__metadata_ts,
gtm: gtm_config_json,
huddle01video: huddle01video__metadata_ts,
jitsivideo: jitsivideo__metadata_ts,
metapixel: metapixel_config_json,
mirotalk: mirotalk_config_json,
office365video: office365video_config_json,
ping: ping_config_json,
plausible: plausible_config_json,
riverside: riverside_config_json,
signal: signal_config_json,
sirius_video: sirius_video_config_json,
sylapsvideo: sylapsvideo_config_json,
tandemvideo: tandemvideo__metadata_ts,
telegram: telegram_config_json,
"booking-pages-tag": booking_pages_tag_config_json,
"event-type-location-video-static": event_type_location_video_static_config_json,
webex: webex_config_json,
whatsapp: whatsapp_config_json,
whereby: whereby_config_json,
zoomvideo: zoomvideo__metadata_ts,
};
+16
View File
@@ -0,0 +1,16 @@
import type { AppMeta } from "@calcom/types/App";
// We have to import all the booker-apps config/metadata in here as without importing that we can't dig into their config and read their props
// It isn't a significant bundle-size impact as we are only importing the metadata of only the booker apps, but when it becomes a problem we can figure out a solution
import { appStoreMetadata as rawBookerAppsMetadata } from "./bookerApps.metadata.generated";
import { getNormalizedAppMetadata } from "./getNormalizedAppMetadata";
type RawAppStoreMetaData = typeof rawBookerAppsMetadata;
type AppStoreMetaData = {
[key in keyof RawAppStoreMetaData]: AppMeta;
};
export const appStoreMetadata = {} as AppStoreMetaData;
for (const [key, value] of Object.entries(rawBookerAppsMetadata)) {
appStoreMetadata[key as keyof typeof appStoreMetadata] = getNormalizedAppMetadata(value);
}
@@ -0,0 +1,28 @@
import type { AppMeta } from "@calcom/types/App";
// We have to import all the booker-apps config/metadata in here as without that we couldn't
import type { appStoreMetadata as rawAppStoreMetadata } from "./apps.metadata.generated";
import { getAppAssetFullPath } from "./getAppAssetFullPath";
type RawAppStoreMetaData = typeof rawAppStoreMetadata;
type AppStoreMetaData = {
[key in keyof RawAppStoreMetaData]: AppMeta;
};
export const getNormalizedAppMetadata = (appMeta: RawAppStoreMetaData[keyof RawAppStoreMetaData]) => {
const dirName = "dirName" in appMeta ? appMeta.dirName : appMeta.slug;
if (!dirName) {
throw new Error(`Couldn't derive dirName for app ${appMeta.name}`);
}
const metadata = {
appData: null,
dirName,
__template: "",
...appMeta,
} as AppStoreMetaData[keyof AppStoreMetaData];
metadata.logo = getAppAssetFullPath(metadata.logo, {
dirName,
isTemplate: metadata.isTemplate,
});
return metadata;
};
+1 -1
View File
@@ -1,7 +1,7 @@
import type { TFunction } from "next-i18next";
import { z } from "zod";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import { appStoreMetadata } from "@calcom/app-store/bookerAppsMetaData";
import logger from "@calcom/lib/logger";
import { BookingStatus } from "@calcom/prisma/enums";
import type { Ensure, Optional } from "@calcom/types/utils";
@@ -11,7 +11,7 @@ import type { appDataSchema } from "../zod";
const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({ eventType, app }) {
const { t } = useLocale();
const [getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
const [_getAppData, setAppData, LockedIcon, disabled] = useAppContextWithSchema<typeof appDataSchema>();
const [additionalParameters, setAdditionalParameters] = useState("");
const { enabled, updateEnabled } = useIsAppEnabled(app);
@@ -66,7 +66,6 @@ function Field({
hookForm,
hookFieldNamespace,
deleteField,
fieldIndex,
moveUp,
moveDown,
appUrl,
@@ -3,7 +3,6 @@ import type { NextApiRequest, NextApiResponse } from "next";
import { stringify } from "querystring";
import createOAuthAppCredential from "../../_utils/createOAuthAppCredential";
import { decodeOAuthState } from "../../_utils/decodeOAuthState";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import type { StripeData } from "../lib/server";
import stripe from "../lib/server";
@@ -31,8 +30,6 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
return res.status(401).json({ message: "You must be logged in to do this" });
}
const state = decodeOAuthState(req);
const response = await stripe.oauth.token({
grant_type: "authorization_code",
code: code?.toString(),
@@ -9,7 +9,6 @@ import stripe from "../lib/server";
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST" && req.method !== "GET")
return res.status(405).json({ message: "Method not allowed" });
const { referer } = req.headers;
// if (!referer) return res.status(400).json({ message: "Missing referrer" });
@@ -37,7 +37,7 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
.trim();
return (
<AppCard
returnTo={WEBAPP_URL + pathname}
returnTo={WEBAPP_URL + pathname + "?tabName=apps"}
setAppData={setAppData}
app={app}
disableSwitch={disabled}
@@ -45,7 +45,8 @@ const EventTypeAppCard: EventTypeAppCardComponent = function EventTypeAppCard({
switchChecked={requirePayment}
switchOnClick={(enabled) => {
setRequirePayment(enabled);
}}>
}}
teamId={eventType.team?.id || undefined}>
<>
{recurringEventDefined && (
<Alert className="mt-2" severity="warning" title={t("warning_recurring_event_payment")} />
+13 -10
View File
@@ -121,18 +121,21 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
const bookingRefsFiltered: BookingReference[] = bookingToReschedule.references.filter(
(ref) => !!credentialsMap.get(ref.type)
);
const promises = bookingRefsFiltered.map(async (bookingRef) => {
if (!bookingRef.uid) return;
if (bookingRef.type.endsWith("_calendar")) {
const calendar = await getCalendar(credentialsMap.get(bookingRef.type));
return calendar?.deleteEvent(bookingRef.uid, builder.calendarEvent);
} else if (bookingRef.type.endsWith("_video")) {
return deleteMeeting(credentialsMap.get(bookingRef.type), bookingRef.uid);
}
});
try {
bookingRefsFiltered.forEach(async (bookingRef) => {
if (bookingRef.uid) {
if (bookingRef.type.endsWith("_calendar")) {
const calendar = await getCalendar(credentialsMap.get(bookingRef.type));
return calendar?.deleteEvent(bookingRef.uid, builder.calendarEvent);
} else if (bookingRef.type.endsWith("_video")) {
return deleteMeeting(credentialsMap.get(bookingRef.type), bookingRef.uid);
}
}
});
await Promise.all(promises);
} catch (error) {
// FIXME: error logging - non-Error type errors are currently discarded
if (error instanceof Error) {
logger.error(error.message);
}
@@ -121,17 +121,19 @@ const Reschedule = async (bookingUid: string, cancellationReason: string) => {
const bookingRefsFiltered: BookingReference[] = bookingToReschedule.references.filter(
(ref) => !!credentialsMap.get(ref.type)
);
const promises = bookingRefsFiltered.map(async (bookingRef) => {
if (!bookingRef.uid) return;
if (bookingRef.type.endsWith("_calendar")) {
const calendar = await getCalendar(credentialsMap.get(bookingRef.type));
return calendar?.deleteEvent(bookingRef.uid, builder.calendarEvent);
} else if (bookingRef.type.endsWith("_video")) {
return deleteMeeting(credentialsMap.get(bookingRef.type), bookingRef.uid);
}
});
try {
bookingRefsFiltered.forEach(async (bookingRef) => {
if (bookingRef.uid) {
if (bookingRef.type.endsWith("_calendar")) {
const calendar = await getCalendar(credentialsMap.get(bookingRef.type));
return calendar?.deleteEvent(bookingRef.uid, builder.calendarEvent);
} else if (bookingRef.type.endsWith("_video")) {
return deleteMeeting(credentialsMap.get(bookingRef.type), bookingRef.uid);
}
}
});
await Promise.all(promises);
} catch (error) {
if (error instanceof Error) {
logger.error(error.message);
@@ -22,8 +22,14 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
try {
const where: Prisma.BookingWhereInput = {};
if (validKey.teamId) where.eventType = { teamId: validKey.teamId };
else where.userId = validKey.userId;
if (validKey.teamId) {
where.eventType = {
OR: [{ teamId: validKey.teamId }, { parent: { teamId: validKey.teamId } }],
};
} else {
where.userId = validKey.userId;
}
const bookings = await prisma.booking.findMany({
take: 3,
where,
+29 -27
View File
@@ -10,6 +10,7 @@ export async function scheduleTrigger(
) {
try {
//schedule job to call subscriber url at the end of meeting
// FIXME: in-process scheduling - job will vanish on server crash / restart
const job = schedule.scheduleJob(
`${subscriber.appId}_${subscriber.id}`,
booking.endTime,
@@ -57,38 +58,39 @@ export async function cancelScheduledJobs(
appId?: string | null,
isReschedule?: boolean
) {
try {
let scheduledJobs = booking.scheduledJobs || [];
if (!booking.scheduledJobs) return;
if (booking.scheduledJobs) {
booking.scheduledJobs.forEach(async (scheduledJob) => {
if (appId) {
if (scheduledJob.startsWith(appId)) {
if (schedule.scheduledJobs[scheduledJob]) {
schedule.scheduledJobs[scheduledJob].cancel();
}
scheduledJobs = scheduledJobs?.filter((job) => scheduledJob !== job) || [];
}
} else {
//if no specific appId given, delete all scheduled jobs of booking
if (schedule.scheduledJobs[scheduledJob]) {
schedule.scheduledJobs[scheduledJob].cancel();
}
scheduledJobs = [];
let scheduledJobs = booking.scheduledJobs || [];
const promises = booking.scheduledJobs.map(async (scheduledJob) => {
if (appId) {
if (scheduledJob.startsWith(appId)) {
if (schedule.scheduledJobs[scheduledJob]) {
schedule.scheduledJobs[scheduledJob].cancel();
}
scheduledJobs = scheduledJobs?.filter((job) => scheduledJob !== job) || [];
}
} else {
//if no specific appId given, delete all scheduled jobs of booking
if (schedule.scheduledJobs[scheduledJob]) {
schedule.scheduledJobs[scheduledJob].cancel();
}
scheduledJobs = [];
}
if (!isReschedule) {
await prisma.booking.update({
where: {
uid: booking.uid,
},
data: {
scheduledJobs: scheduledJobs,
},
});
}
if (!isReschedule) {
await prisma.booking.update({
where: {
uid: booking.uid,
},
data: {
scheduledJobs: scheduledJobs,
},
});
}
});
try {
await Promise.all(promises);
} catch (error) {
console.error("Error cancelling scheduled jobs", error);
}
+2 -1
View File
@@ -12,7 +12,7 @@ const libraries = [
},
];
libraries.forEach(async (lib) => {
const promises = libraries.map(async (lib) => {
await build({
build: {
outDir: `./dist/${lib.fileName}`,
@@ -29,3 +29,4 @@ libraries.forEach(async (lib) => {
},
});
});
await Promise.all(promises);
@@ -1,6 +1,7 @@
import type { Page, Frame } from "@playwright/test";
import { test, expect } from "@playwright/test";
// eslint-disable-next-line no-restricted-imports
import prisma from "@calcom/prisma";
export function todo(title: string) {
-1
View File
@@ -562,7 +562,6 @@ class CalApi {
modal({
calLink,
calOrigin,
config = {},
uid,
}: {
+40 -4
View File
@@ -1,11 +1,13 @@
import { LazyMotion, domAnimation, m, AnimatePresence } from "framer-motion";
import { LazyMotion, m, AnimatePresence } from "framer-motion";
import dynamic from "next/dynamic";
import { useEffect, useRef } from "react";
import StickyBox from "react-sticky-box";
import { shallow } from "zustand/shallow";
import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
import dayjs from "@calcom/dayjs";
import { useEmbedType, useEmbedUiConfig, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { useNonEmptyScheduleDays } from "@calcom/features/schedules";
import classNames from "@calcom/lib/classNames";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { BookerLayouts, defaultBookerLayoutSettings } from "@calcom/prisma/zod-utils";
@@ -26,6 +28,7 @@ import { validateLayout } from "./utils/layout";
import { getQueryParam } from "./utils/query-param";
import { useBrandColors } from "./utils/use-brand-colors";
const loadFramerFeatures = () => import("./framer-features").then((res) => res.default);
const PoweredBy = dynamic(() => import("@calcom/ee/components/PoweredBy"));
const UnpublishedEntity = dynamic(() => import("@calcom/ui").then((mod) => mod.UnpublishedEntity));
const DatePicker = dynamic(() => import("./components/DatePicker").then((mod) => mod.DatePicker), {
@@ -73,6 +76,9 @@ const BookerComponent = ({
// In Embed we give preference to embed configuration for the layout.If that's not set, we use the App configuration for the event layout
// But if it's mobile view, there is only one layout supported which is 'mobile'
const layout = isEmbed ? (isMobile ? "mobile" : validateLayout(embedUiConfig.layout) || _layout) : _layout;
const columnViewExtraDays = useRef<number>(
isTablet ? extraDaysConfig[layout].tablet : extraDaysConfig[layout].desktop
);
const [bookerState, setBookerState] = useBookerStore((state) => [state.state, state.setState], shallow);
const selectedDate = useBookerStore((state) => state.selectedDate);
@@ -86,9 +92,36 @@ const BookerComponent = ({
shallow
);
const date = dayjs(selectedDate).format("YYYY-MM-DD");
const schedule = useScheduleForEvent({ prefetchNextMonth: true });
const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.data?.slots).filter(
(slot) => dayjs(selectedDate).diff(slot, "day") <= 0
);
const extraDays = isTablet ? extraDaysConfig[layout].tablet : extraDaysConfig[layout].desktop;
const bookerLayouts = event.data?.profile?.bookerLayouts || defaultBookerLayoutSettings;
const animationScope = useBookerResizeAnimation(layout, bookerState);
const totalWeekDays = 7;
const addonDays =
nonEmptyScheduleDays.length < extraDays
? (extraDays - nonEmptyScheduleDays.length + 1) * totalWeekDays
: nonEmptyScheduleDays.length === extraDays
? totalWeekDays
: 0;
//Taking one more avaliable slot(extraDays + 1) to claculate the no of days in between, that next and prev button need to shift.
const availableSlots = nonEmptyScheduleDays.slice(0, extraDays + 1);
if (nonEmptyScheduleDays.length !== 0)
columnViewExtraDays.current =
Math.abs(dayjs(selectedDate).diff(availableSlots[availableSlots.length - 2], "day")) + addonDays;
const prefetchNextMonth =
dayjs(date).month() !== dayjs(date).add(columnViewExtraDays.current, "day").month();
const monthCount =
dayjs(date).add(1, "month").month() !== dayjs(date).add(columnViewExtraDays.current, "day").month()
? 2
: undefined;
const nextSlots =
Math.abs(dayjs(selectedDate).diff(availableSlots[availableSlots.length - 1], "day")) + addonDays;
// I would expect isEmbed to be not needed here as it's handled in derived variable layout, but somehow removing it breaks the views.
const defaultLayout = isEmbed
@@ -213,8 +246,9 @@ const BookerComponent = ({
)}>
<Header
enabledLayouts={bookerLayouts.enabledLayouts}
extraDays={extraDays}
extraDays={layout === BookerLayouts.COLUMN_VIEW ? columnViewExtraDays.current : extraDays}
isMobile={isMobile}
nextSlots={nextSlots}
/>
</BookerSection>
<StickyOnDesktop
@@ -280,7 +314,7 @@ const BookerComponent = ({
layout === BookerLayouts.COLUMN_VIEW
}
className={classNames(
"border-subtle rtl:border-default flex h-full w-full flex-col px-5 py-3 pb-0 rtl:border-r ltr:md:border-l",
"border-subtle rtl:border-default flex h-full w-full flex-col overflow-x-auto px-5 py-3 pb-0 rtl:border-r ltr:md:border-l",
layout === BookerLayouts.MONTH_VIEW &&
"scroll-bar h-full overflow-auto md:w-[var(--booker-timeslots-width)]",
layout !== BookerLayouts.MONTH_VIEW && "sticky top-0"
@@ -290,6 +324,8 @@ const BookerComponent = ({
<AvailableTimeSlots
extraDays={extraDays}
limitHeight={layout === BookerLayouts.MONTH_VIEW}
prefetchNextMonth={prefetchNextMonth}
monthCount={monthCount}
seatsPerTimeSlot={event.data?.seatsPerTimeSlot}
/>
</BookerSection>
@@ -319,7 +355,7 @@ export const Booker = (props: BookerProps) => {
if (props.isAway) return <Away />;
return (
<LazyMotion features={domAnimation}>
<LazyMotion strict features={loadFramerFeatures}>
<BookerComponent {...props} />
</LazyMotion>
);
@@ -1,11 +1,14 @@
import { useMemo, useRef, useEffect } from "react";
import { useRef, useEffect } from "react";
import dayjs from "@calcom/dayjs";
import { useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { AvailableTimes, AvailableTimesSkeleton } from "@calcom/features/bookings";
import { useSlotsForMultipleDates } from "@calcom/features/schedules/lib/use-schedule/useSlotsForDate";
import { useNonEmptyScheduleDays } from "@calcom/features/schedules";
import { useSlotsForAvailableDates } from "@calcom/features/schedules/lib/use-schedule/useSlotsForDate";
import { classNames } from "@calcom/lib";
import useMediaQuery from "@calcom/lib/hooks/useMediaQuery";
import { BookerLayouts } from "@calcom/prisma/zod-utils";
import { trpc } from "@calcom/trpc";
import { useBookerStore } from "../store";
import { useEvent, useScheduleForEvent } from "../utils/event";
@@ -13,6 +16,8 @@ import { useEvent, useScheduleForEvent } from "../utils/event";
type AvailableTimeSlotsProps = {
extraDays?: number;
limitHeight?: boolean;
prefetchNextMonth: boolean;
monthCount: number | undefined;
seatsPerTimeSlot?: number | null;
};
@@ -23,7 +28,14 @@ type AvailableTimeSlotsProps = {
* will also fetch the next `extraDays` days and show multiple days
* in columns next to each other.
*/
export const AvailableTimeSlots = ({ extraDays, limitHeight, seatsPerTimeSlot }: AvailableTimeSlotsProps) => {
export const AvailableTimeSlots = ({
extraDays,
limitHeight,
seatsPerTimeSlot,
prefetchNextMonth,
monthCount,
}: AvailableTimeSlotsProps) => {
const reserveSlotMutation = trpc.viewer.public.slots.reserveSlot.useMutation();
const isMobile = useMediaQuery("(max-width: 768px)");
const selectedDate = useBookerStore((state) => state.selectedDate);
const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot);
@@ -31,6 +43,8 @@ export const AvailableTimeSlots = ({ extraDays, limitHeight, seatsPerTimeSlot }:
const isEmbed = useIsEmbed();
const event = useEvent();
const date = selectedDate || dayjs().format("YYYY-MM-DD");
const [layout] = useBookerStore((state) => [state.layout]);
const isColumnView = layout === BookerLayouts.COLUMN_VIEW;
const containerRef = useRef<HTMLDivElement | null>(null);
const onTimeSelect = (
@@ -57,29 +71,23 @@ export const AvailableTimeSlots = ({ extraDays, limitHeight, seatsPerTimeSlot }:
};
const schedule = useScheduleForEvent({
prefetchNextMonth: !!extraDays && dayjs(date).month() !== dayjs(date).add(extraDays, "day").month(),
prefetchNextMonth,
monthCount,
});
const nonEmptyScheduleDays = useNonEmptyScheduleDays(schedule?.data?.slots);
const nonEmptyScheduleDaysFromSelectedDate = nonEmptyScheduleDays.filter(
(slot) => dayjs(selectedDate).diff(slot, "day") <= 0
);
// Creates an array of dates to fetch slots for.
// If `extraDays` is passed in, we will extend the array with the next `extraDays` days.
const dates = useMemo(
() =>
!extraDays
? [date]
: [
// If NO date is selected yet, we show by default the upcomming `nextDays` days.
date,
...Array.from({ length: extraDays }).map((_, index) =>
dayjs(date)
.add(index + 1, "day")
.format("YYYY-MM-DD")
),
],
[date, extraDays]
);
const dates = !extraDays
? [date]
: nonEmptyScheduleDaysFromSelectedDate.length > 0
? nonEmptyScheduleDaysFromSelectedDate.slice(0, extraDays)
: [];
const isMultipleDates = dates.length > 1;
const slotsPerDay = useSlotsForMultipleDates(dates, schedule?.data?.slots);
const slotsPerDay = useSlotsForAvailableDates(dates, schedule?.data?.slots);
useEffect(() => {
if (isEmbed) return;
@@ -103,11 +111,16 @@ export const AvailableTimeSlots = ({ extraDays, limitHeight, seatsPerTimeSlot }:
<AvailableTimes
className="w-full"
key={slots.date}
showTimeFormatToggle={!isColumnView}
onTimeSelect={onTimeSelect}
date={dayjs(slots.date)}
slots={slots.slots}
onTimeSelect={onTimeSelect}
seatsPerTimeSlot={seatsPerTimeSlot}
showTimeFormatToggle={!isMultipleDates}
availableMonth={
dayjs(selectedDate).format("MM") !== dayjs(slots.date).format("MM")
? dayjs(slots.date).format("MMM")
: undefined
}
/>
))}
</div>
@@ -16,10 +16,12 @@ export function Header({
extraDays,
isMobile,
enabledLayouts,
nextSlots,
}: {
extraDays: number;
isMobile: boolean;
enabledLayouts: BookerLayouts[];
nextSlots: number;
}) {
const { t, i18n } = useLocale();
const [layout, setLayout] = useBookerStore((state) => [state.layout, state.setLayout], shallow);
@@ -56,8 +58,7 @@ export function Header({
if (isMonthView) {
return <LayoutToggleWithData />;
}
const endDate = selectedDate.add(extraDays - 1, "days");
const endDate = selectedDate.add(layout === BookerLayouts.COLUMN_VIEW ? extraDays : extraDays - 1, "days");
const isSameMonth = () => {
return selectedDate.format("MMM") === endDate.format("MMM");
@@ -91,7 +92,7 @@ export function Header({
color="minimal"
StartIcon={ChevronLeft}
aria-label="Previous Day"
onClick={() => addToSelectedDate(-extraDays)}
onClick={() => addToSelectedDate(layout === BookerLayouts.COLUMN_VIEW ? -nextSlots : -extraDays)}
/>
<Button
className="group rtl:mr-1 rtl:rotate-180"
@@ -99,7 +100,7 @@ export function Header({
color="minimal"
StartIcon={ChevronRight}
aria-label="Next Day"
onClick={() => addToSelectedDate(extraDays)}
onClick={() => addToSelectedDate(layout === BookerLayouts.COLUMN_VIEW ? nextSlots : extraDays)}
/>
{selectedDateMin3DaysDifference && (
<Button
+1 -1
View File
@@ -224,7 +224,7 @@ export const extraDaysConfig = {
tablet: 4,
},
[BookerLayouts.COLUMN_VIEW]: {
desktop: 4,
desktop: 6,
tablet: 2,
},
};
@@ -0,0 +1,3 @@
import { domAnimation } from "framer-motion";
export default domAnimation;
@@ -45,6 +45,7 @@ export const useScheduleForEvent = ({
eventId,
month,
duration,
monthCount,
}: {
prefetchNextMonth?: boolean;
username?: string | null;
@@ -52,6 +53,7 @@ export const useScheduleForEvent = ({
eventId?: number | null;
month?: string | null;
duration?: number | null;
monthCount?: number;
} = {}) => {
const { timezone } = useTimePreferences();
const event = useEvent();
@@ -70,6 +72,7 @@ export const useScheduleForEvent = ({
eventId: event.data?.id ?? eventId,
timezone,
prefetchNextMonth,
monthCount,
rescheduleUid,
month: monthFromStore ?? month,
duration: durationFromStore ?? duration,
@@ -26,6 +26,7 @@ type AvailableTimesProps = {
seatsPerTimeSlot?: number | null;
showTimeFormatToggle?: boolean;
className?: string;
availableMonth?: string | undefined;
selectedSlots?: string[];
};
@@ -36,6 +37,7 @@ export const AvailableTimes = ({
seatsPerTimeSlot,
showTimeFormatToggle = true,
className,
availableMonth,
selectedSlots,
}: AvailableTimesProps) => {
const { t, i18n } = useLocale();
@@ -65,6 +67,7 @@ export const AvailableTimes = ({
isMonthView ? "text-default text-sm" : "text-xs"
)}>
{date.format("DD")}
{availableMonth && `, ${availableMonth}`}
</span>
</span>
@@ -157,8 +157,10 @@ async function handler(req: CustomRequest) {
},
});
const triggerForUser = !teamId || (teamId && bookingToDelete.eventType?.parentId);
const subscriberOptions = {
userId: bookingToDelete.userId,
userId: triggerForUser ? bookingToDelete.userId : null,
eventTypeId: bookingToDelete.eventTypeId as number,
triggerEvent: eventTrigger,
teamId,
@@ -428,9 +430,9 @@ async function handler(req: CustomRequest) {
bookingToDelete.recurringEventId &&
allRemainingBookings
) {
bookingToDelete.user.credentials
const promises = bookingToDelete.user.credentials
.filter((credential) => credential.type.endsWith("_calendar"))
.forEach(async (credential) => {
.map(async (credential) => {
const calendar = await getCalendar(credential);
for (const updBooking of updatedBookings) {
const bookingRef = updBooking.references.find((ref) => ref.type.includes("_calendar"));
@@ -441,6 +443,13 @@ async function handler(req: CustomRequest) {
}
}
});
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>);
}
@@ -601,11 +610,13 @@ async function handler(req: CustomRequest) {
});
// delete scheduled jobs of cancelled bookings
// FIXME: async calls into ether
updatedBookings.forEach((booking) => {
cancelScheduledJobs(booking);
});
//Workflows - cancel all reminders for cancelled bookings
// FIXME: async calls into ether
updatedBookings.forEach((booking) => {
booking.workflowReminders.forEach((reminder) => {
if (reminder.method === WorkflowMethods.EMAIL) {
@@ -620,11 +631,14 @@ async function handler(req: CustomRequest) {
const prismaPromises: Promise<unknown>[] = [bookingReferenceDeletes];
// @TODO: find a way in the future if a promise fails don't stop the rest of the promises
// Also if emails fails try to requeue them
try {
await Promise.all(prismaPromises.concat(apiDeletes));
const settled = await Promise.allSettled(prismaPromises.concat(apiDeletes));
const rejected = settled.filter(({ status }) => status === "rejected") as PromiseRejectedResult[];
if (rejected.length) {
throw new Error(`Reasons: ${rejected.map(({ reason }) => reason)}`);
}
// TODO: if emails fail try to requeue them
await sendCancelledEmails(evt, { eventName: bookingToDelete?.eventType?.eventName });
} catch (error) {
console.error("Error deleting event", error);
@@ -293,14 +293,16 @@ export async function handleConfirmation(args: {
},
});
const triggerForUser = !teamId || (teamId && booking.eventType?.parentId);
const subscribersBookingCreated = await getWebhooks({
userId: booking.userId,
userId: triggerForUser ? booking.userId : null,
eventTypeId: booking.eventTypeId,
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
teamId,
});
const subscribersMeetingEnded = await getWebhooks({
userId: booking.userId,
userId: triggerForUser ? booking.userId : null,
eventTypeId: booking.eventTypeId,
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
teamId: booking.eventType?.teamId,
@@ -1126,8 +1126,10 @@ async function handler(
const teamId = await getTeamIdFromEventType({ eventType });
const triggerForUser = !teamId || (teamId && eventType.parentId);
const subscriberOptions: GetSubscriberOptions = {
userId: organizerUser.id,
userId: triggerForUser ? organizerUser.id : null,
eventTypeId,
triggerEvent: WebhookTriggerEvents.BOOKING_CREATED,
teamId,
@@ -1140,7 +1142,7 @@ async function handler(
subscriberOptions.triggerEvent = eventTrigger;
const subscriberOptionsMeetingEnded = {
userId: organizerUser.id,
userId: triggerForUser ? organizerUser.id : null,
eventTypeId,
triggerEvent: WebhookTriggerEvents.MEETING_ENDED,
teamId,
+4 -2
View File
@@ -1,10 +1,12 @@
import type { AppFlags } from "@calcom/features/flags/config";
import { trpc } from "@calcom/trpc/react";
export function useFlags() {
const query = trpc.viewer.features.map.useQuery(undefined, {
initialData: process.env.NEXT_PUBLIC_IS_E2E
? { "managed-event-types": true, organizations: true, teams: true }
: {},
: undefined,
placeholderData: {},
});
return query.data;
return query.data ?? ({} as AppFlags);
}
@@ -10,6 +10,7 @@ type UseScheduleWithCacheArgs = {
timezone?: string | null;
prefetchNextMonth?: boolean;
duration?: number | null;
monthCount?: number | null;
rescheduleUid?: string | null;
isTeamEvent?: boolean;
};
@@ -22,11 +23,12 @@ export const useSchedule = ({
eventId,
prefetchNextMonth,
duration,
monthCount,
rescheduleUid,
isTeamEvent,
}: UseScheduleWithCacheArgs) => {
const monthDayjs = month ? dayjs(month) : dayjs();
const nextMonthDayjs = monthDayjs.add(1, "month");
const nextMonthDayjs = monthDayjs.add(monthCount ? monthCount : 1, "month");
// Why the non-null assertions? All of these arguments are checked in the enabled condition,
// and the query will not run if they are null. However, the check in `enabled` does
// no satisfy typscript.
@@ -7,6 +7,7 @@ import type { Slots } from "./types";
* @param date Format YYYY-MM-DD
* @param scheduleCache Instance of useScheduleWithCache
*/
export const useSlotsForDate = (date: string | null, slots?: Slots) => {
const slotsForDate = useMemo(() => {
if (!date || typeof slots === "undefined") return [];
@@ -16,9 +17,9 @@ export const useSlotsForDate = (date: string | null, slots?: Slots) => {
return slotsForDate;
};
export const useSlotsForMultipleDates = (dates: (string | null)[], slots?: Slots) => {
export const useSlotsForAvailableDates = (dates: (string | null)[], slots?: Slots) => {
const slotsForDates = useMemo(() => {
if (typeof slots === "undefined") return [];
if (slots === undefined) return [];
return dates
.filter((date) => date !== null)
.map((date) => ({
@@ -26,6 +27,5 @@ export const useSlotsForMultipleDates = (dates: (string | null)[], slots?: Slots
date,
}));
}, [dates, slots]);
return slotsForDates;
};
@@ -10,16 +10,15 @@ export type GetSubscriberOptions = {
};
const getWebhooks = async (options: GetSubscriberOptions, prisma: PrismaClient = defaultPrisma) => {
const userId = options.teamId ? 0 : options.userId ?? 0;
const userId = options.userId ?? 0;
const eventTypeId = options.eventTypeId ?? 0;
const teamId = options.teamId ?? 0;
// if we have userId and teamId it is a managed event type and should trigger for team and user
const allWebhooks = await prisma.webhook.findMany({
where: {
OR: [
{
userId,
teamId: null,
},
{
eventTypeId,
@@ -30,7 +30,9 @@ export type WebhookDataType = CalendarEvent &
downloadLink?: string;
};
function getZapierPayload(data: CalendarEvent & EventTypeInfo & { status?: string }): string {
function getZapierPayload(
data: CalendarEvent & EventTypeInfo & { status?: string; createdAt: string }
): string {
const attendees = data.attendees.map((attendee) => {
return {
name: attendee.name,
@@ -69,6 +71,7 @@ function getZapierPayload(data: CalendarEvent & EventTypeInfo & { status?: strin
length: data.length,
},
attendees: attendees,
createdAt: data.createdAt,
};
return JSON.stringify(body);
}
@@ -112,7 +115,7 @@ const sendPayload = async (
/* Zapier id is hardcoded in the DB, we send the raw data for this case */
if (appId === "zapier") {
body = getZapierPayload(data);
body = getZapierPayload({ ...data, createdAt });
} else if (template) {
body = applyTemplate(template, { ...data, triggerEvent, createdAt }, contentType);
} else {
+1
View File
@@ -57,6 +57,7 @@ export function useTypedQuery<T extends z.AnyZodObject>(schema: T) {
search.set(String(key), String(value));
router.replace(`${pathname}?${search.toString()}`);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[parsedQuery, router]
);
+2
View File
@@ -79,7 +79,9 @@ model EventType {
bookingFields Json?
timeZone String?
periodType PeriodType @default(UNLIMITED)
/// @zod.custom(imports.coerceToDate)
periodStartDate DateTime?
/// @zod.custom(imports.coerceToDate)
periodEndDate DateTime?
periodDays Int?
periodCountCalendarDays Boolean?
+2
View File
@@ -608,3 +608,5 @@ export const ZVerifyCodeInputSchema = z.object({
});
export type ZVerifyCodeInputSchema = z.infer<typeof ZVerifyCodeInputSchema>;
export const coerceToDate = z.coerce.date();
@@ -11,7 +11,7 @@ type GetOptions = {
input: TListMembersSchema;
};
export const listPaginatedHandler = async ({ ctx, input }: GetOptions) => {
export const listPaginatedHandler = async ({ input }: GetOptions) => {
const { cursor, limit, searchTerm } = input;
const getTotalUsers = await prisma.user.count();
@@ -56,7 +56,7 @@ export const listPaginatedHandler = async ({ ctx, input }: GetOptions) => {
let nextCursor: typeof cursor | undefined = undefined;
if (users && users.length > limit) {
const nextItem = users.pop();
nextCursor = nextItem!.id;
nextCursor = nextItem?.id;
}
return {
@@ -14,7 +14,7 @@ type GetOptions = {
input: TAdminPasswordResetSchema;
};
export const sendPasswordResetHandler = async ({ ctx, input }: GetOptions) => {
export const sendPasswordResetHandler = async ({ input }: GetOptions) => {
const { userId } = input;
const user = await prisma.user.findUnique({
@@ -10,7 +10,7 @@ type checkForGlobalKeys = {
input: CheckGlobalKeysSchemaType;
};
export const checkForGlobalKeysHandler = async ({ ctx, input }: checkForGlobalKeys) => {
export const checkForGlobalKeysHandler = async ({ input }: checkForGlobalKeys) => {
const appIsGloballyInstalled = await prisma.app.findUnique({
where: {
slug: input.slug,
@@ -1,5 +1,4 @@
import { sendEmailVerificationByCode } from "@calcom/features/auth/lib/verifyEmail";
import logger from "@calcom/lib/logger";
import type { TSendVerifyEmailCodeSchema } from "./sendVerifyEmailCode.schema";
@@ -7,8 +6,6 @@ type SendVerifyEmailCode = {
input: TSendVerifyEmailCodeSchema;
};
const log = logger.getChildLogger({ prefix: [`[[Auth] `] });
export const sendVerifyEmailCodeHandler = async ({ input }: SendVerifyEmailCode) => {
const email = await sendEmailVerificationByCode({
email: input.email,
@@ -32,7 +32,6 @@ export const getHandler = async ({ ctx, input }: GetOptions) => {
timeZone: true,
eventType: {
select: {
_count: true,
id: true,
eventName: true,
team: {
@@ -1,5 +1,3 @@
import { Prisma } from "@prisma/client";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import type { DateRange } from "@calcom/lib/date-ranges";
@@ -29,22 +27,11 @@ async function getTeamMembers({
cursor: number | null | undefined;
limit: number;
}) {
let whereQuery: Prisma.MembershipWhereInput = {
teamId,
};
if (teamIds) {
whereQuery = {
teamId: {
in: teamIds,
},
};
}
return await prisma.membership.findMany({
where: {
...whereQuery,
accepted: true,
teamId: {
in: teamId ? [teamId] : teamIds,
},
},
select: {
id: true,
@@ -116,23 +103,15 @@ async function getInfoForAllTeams({ ctx, input }: GetOptions) {
},
select: {
id: true,
teamId: true,
},
})
.then((memberships) => memberships.map((membership) => membership.id));
.then((memberships) => memberships.map((membership) => membership.teamId));
if (!teamIds.length) {
throw new TRPCError({ code: "NOT_FOUND", message: "User is not part of any organization or team." });
}
const getTotalMembers = await prisma.$queryRaw<{
count: number;
}>(Prisma.sql`
SELECT
COUNT(DISTINCT "userId") as "count"
FROM "Membership"
WHERE "teamId" IN (${Prisma.join(teamIds)})
`);
const teamMembers = await getTeamMembers({
teamIds,
cursor,
@@ -141,7 +120,7 @@ async function getInfoForAllTeams({ ctx, input }: GetOptions) {
return {
teamMembers,
totalTeamMembers: getTotalMembers.count,
totalTeamMembers: teamMembers.length,
};
}
@@ -383,6 +383,7 @@ async function getBookings({
);
const plainBookings = getUniqueBookings(
// It's going to mess up the orderBy as we are concatenating independent queries results
bookingsQueryUserId
.concat(bookingsQueryAttendees)
.concat(bookingsQueryTeamMember)
@@ -399,6 +400,8 @@ async function getBookings({
},
},
select: bookingSelect,
// We need to get the sorted bookings here as well because plainBookings array is not correctly sorted
orderBy,
})
).map((booking) => {
// If seats are enabled and the event is not set to show attendees, filter out attendees that are not the current user
@@ -102,168 +102,172 @@ export const requestRescheduleHandler = async ({ ctx, input }: RequestReschedule
throw new TRPCError({ code: "FORBIDDEN", message: "User isn't owner of the current booking" });
}
if (bookingToReschedule) {
let event: Partial<EventType> = {};
if (bookingToReschedule.eventTypeId) {
event = await prisma.eventType.findFirstOrThrow({
select: {
title: true,
users: true,
schedulingType: true,
recurringEvent: true,
},
where: {
id: bookingToReschedule.eventTypeId,
},
});
}
await prisma.booking.update({
if (!bookingToReschedule) return;
let event: Partial<EventType> = {};
if (bookingToReschedule.eventTypeId) {
event = await prisma.eventType.findFirstOrThrow({
select: {
title: true,
users: true,
schedulingType: true,
recurringEvent: true,
},
where: {
id: bookingToReschedule.id,
},
data: {
rescheduled: true,
cancellationReason,
status: BookingStatus.CANCELLED,
updatedAt: dayjs().toISOString(),
id: bookingToReschedule.eventTypeId,
},
});
// delete scheduled jobs of previous booking
cancelScheduledJobs(bookingToReschedule);
//cancel workflow reminders of previous booking
bookingToReschedule.workflowReminders.forEach((reminder) => {
if (reminder.method === WorkflowMethods.EMAIL) {
deleteScheduledEmailReminder(reminder.id, reminder.referenceId);
} else if (reminder.method === WorkflowMethods.SMS) {
deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
} else if (reminder.method === WorkflowMethods.WHATSAPP) {
deleteScheduledWhatsappReminder(reminder.id, reminder.referenceId);
}
});
const [mainAttendee] = bookingToReschedule.attendees;
// @NOTE: Should we assume attendees language?
const tAttendees = await getTranslation(mainAttendee.locale ?? "en", "common");
const usersToPeopleType = (
users: PersonAttendeeCommonFields[],
selectedLanguage: TFunction
): Person[] => {
return users?.map((user) => {
return {
email: user.email || "",
name: user.name || "",
username: user?.username || "",
language: { translate: selectedLanguage, locale: user.locale || "en" },
timeZone: user?.timeZone,
};
});
};
const userTranslation = await getTranslation(user.locale ?? "en", "common");
const [userAsPeopleType] = usersToPeopleType([user], userTranslation);
const builder = new CalendarEventBuilder();
builder.init({
title: bookingToReschedule.title,
type: event && event.title ? event.title : bookingToReschedule.title,
startTime: bookingToReschedule.startTime.toISOString(),
endTime: bookingToReschedule.endTime.toISOString(),
attendees: usersToPeopleType(
// username field doesn't exists on attendee but could be in the future
bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[],
tAttendees
),
organizer: userAsPeopleType,
});
const director = new CalendarEventDirector();
director.setBuilder(builder);
director.setExistingBooking(bookingToReschedule);
cancellationReason && director.setCancellationReason(cancellationReason);
if (event) {
await director.buildForRescheduleEmail();
} else {
await director.buildWithoutEventTypeForRescheduleEmail();
}
// Handling calendar and videos cancellation
// This can set previous time as available, until virtual calendar is done
const credentials = await getUsersCredentials(user.id);
const credentialsMap = new Map();
credentials.forEach((credential) => {
credentialsMap.set(credential.type, credential);
});
const bookingRefsFiltered: BookingReference[] = bookingToReschedule.references.filter((ref) =>
credentialsMap.has(ref.type)
);
bookingRefsFiltered.forEach(async (bookingRef) => {
if (bookingRef.uid) {
if (bookingRef.type.endsWith("_calendar")) {
const calendar = await getCalendar(credentialsMap.get(bookingRef.type));
return calendar?.deleteEvent(bookingRef.uid, builder.calendarEvent, bookingRef.externalCalendarId);
} else if (bookingRef.type.endsWith("_video")) {
return deleteMeeting(credentialsMap.get(bookingRef.type), bookingRef.uid);
}
}
});
// Send emails
await sendRequestRescheduleEmail(builder.calendarEvent, {
rescheduleLink: builder.rescheduleLink,
});
const evt: CalendarEvent = {
title: bookingToReschedule?.title,
type: event && event.title ? event.title : bookingToReschedule.title,
description: bookingToReschedule?.description || "",
customInputs: isPrismaObjOrUndefined(bookingToReschedule.customInputs),
...getCalEventResponses({
booking: bookingToReschedule,
bookingFields: bookingToReschedule.eventType?.bookingFields ?? null,
}),
startTime: bookingToReschedule?.startTime ? dayjs(bookingToReschedule.startTime).format() : "",
endTime: bookingToReschedule?.endTime ? dayjs(bookingToReschedule.endTime).format() : "",
organizer: userAsPeopleType,
attendees: usersToPeopleType(
// username field doesn't exists on attendee but could be in the future
bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[],
tAttendees
),
uid: bookingToReschedule?.uid,
location: bookingToReschedule?.location,
destinationCalendar:
bookingToReschedule?.destinationCalendar || bookingToReschedule?.destinationCalendar,
cancellationReason: `Please reschedule. ${cancellationReason}`, // TODO::Add i18-next for this
};
// Send webhook
const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED";
const teamId = await getTeamIdFromEventType({
eventType: {
team: { id: bookingToReschedule.eventType?.teamId ?? null },
parentId: bookingToReschedule?.eventType?.parentId ?? null,
},
});
// Send Webhook call if hooked to BOOKING.CANCELLED
const subscriberOptions = {
userId: bookingToReschedule.userId,
eventTypeId: bookingToReschedule.eventTypeId as number,
triggerEvent: eventTrigger,
teamId,
};
const webhooks = await getWebhooks(subscriberOptions);
const promises = webhooks.map((webhook) =>
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, {
...evt,
smsReminderNumber: bookingToReschedule.smsReminderNumber || undefined,
}).catch((e) => {
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, e);
})
);
await Promise.all(promises);
}
await prisma.booking.update({
where: {
id: bookingToReschedule.id,
},
data: {
rescheduled: true,
cancellationReason,
status: BookingStatus.CANCELLED,
updatedAt: dayjs().toISOString(),
},
});
// delete scheduled jobs of previous booking
// FIXME: async fn off into the ether
cancelScheduledJobs(bookingToReschedule);
//cancel workflow reminders of previous booking
// FIXME: more async fns off into the ether
bookingToReschedule.workflowReminders.forEach((reminder) => {
if (reminder.method === WorkflowMethods.EMAIL) {
deleteScheduledEmailReminder(reminder.id, reminder.referenceId);
} else if (reminder.method === WorkflowMethods.SMS) {
deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
} else if (reminder.method === WorkflowMethods.WHATSAPP) {
deleteScheduledWhatsappReminder(reminder.id, reminder.referenceId);
}
});
const [mainAttendee] = bookingToReschedule.attendees;
// @NOTE: Should we assume attendees language?
const tAttendees = await getTranslation(mainAttendee.locale ?? "en", "common");
const usersToPeopleType = (users: PersonAttendeeCommonFields[], selectedLanguage: TFunction): Person[] => {
return users?.map((user) => {
return {
email: user.email || "",
name: user.name || "",
username: user?.username || "",
language: { translate: selectedLanguage, locale: user.locale || "en" },
timeZone: user?.timeZone,
};
});
};
const userTranslation = await getTranslation(user.locale ?? "en", "common");
const [userAsPeopleType] = usersToPeopleType([user], userTranslation);
const builder = new CalendarEventBuilder();
builder.init({
title: bookingToReschedule.title,
type: event && event.title ? event.title : bookingToReschedule.title,
startTime: bookingToReschedule.startTime.toISOString(),
endTime: bookingToReschedule.endTime.toISOString(),
attendees: usersToPeopleType(
// username field doesn't exists on attendee but could be in the future
bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[],
tAttendees
),
organizer: userAsPeopleType,
});
const director = new CalendarEventDirector();
director.setBuilder(builder);
director.setExistingBooking(bookingToReschedule);
cancellationReason && director.setCancellationReason(cancellationReason);
if (event) {
await director.buildForRescheduleEmail();
} else {
await director.buildWithoutEventTypeForRescheduleEmail();
}
// Handling calendar and videos cancellation
// This can set previous time as available, until virtual calendar is done
const credentials = await getUsersCredentials(user.id);
const credentialsMap = new Map();
credentials.forEach((credential) => {
credentialsMap.set(credential.type, credential);
});
const bookingRefsFiltered: BookingReference[] = bookingToReschedule.references.filter((ref) =>
credentialsMap.has(ref.type)
);
// FIXME: error-handling
await Promise.allSettled(
bookingRefsFiltered.map(async (bookingRef) => {
if (!bookingRef.uid) return;
if (bookingRef.type.endsWith("_calendar")) {
const calendar = await getCalendar(credentialsMap.get(bookingRef.type));
return calendar?.deleteEvent(bookingRef.uid, builder.calendarEvent, bookingRef.externalCalendarId);
} else if (bookingRef.type.endsWith("_video")) {
return deleteMeeting(credentialsMap.get(bookingRef.type), bookingRef.uid);
}
})
);
// Send emails
await sendRequestRescheduleEmail(builder.calendarEvent, {
rescheduleLink: builder.rescheduleLink,
});
const evt: CalendarEvent = {
title: bookingToReschedule?.title,
type: event && event.title ? event.title : bookingToReschedule.title,
description: bookingToReschedule?.description || "",
customInputs: isPrismaObjOrUndefined(bookingToReschedule.customInputs),
...getCalEventResponses({
booking: bookingToReschedule,
bookingFields: bookingToReschedule.eventType?.bookingFields ?? null,
}),
startTime: bookingToReschedule?.startTime ? dayjs(bookingToReschedule.startTime).format() : "",
endTime: bookingToReschedule?.endTime ? dayjs(bookingToReschedule.endTime).format() : "",
organizer: userAsPeopleType,
attendees: usersToPeopleType(
// username field doesn't exists on attendee but could be in the future
bookingToReschedule.attendees as unknown as PersonAttendeeCommonFields[],
tAttendees
),
uid: bookingToReschedule?.uid,
location: bookingToReschedule?.location,
destinationCalendar: bookingToReschedule?.destinationCalendar || bookingToReschedule?.destinationCalendar,
cancellationReason: `Please reschedule. ${cancellationReason}`, // TODO::Add i18-next for this
};
// Send webhook
const eventTrigger: WebhookTriggerEvents = "BOOKING_CANCELLED";
const teamId = await getTeamIdFromEventType({
eventType: {
team: { id: bookingToReschedule.eventType?.teamId ?? null },
parentId: bookingToReschedule?.eventType?.parentId ?? null,
},
});
const triggerForUser = !teamId || (teamId && bookingToReschedule.eventType?.parentId);
// Send Webhook call if hooked to BOOKING.CANCELLED
const subscriberOptions = {
userId: triggerForUser ? bookingToReschedule.userId : null,
eventTypeId: bookingToReschedule.eventTypeId as number,
triggerEvent: eventTrigger,
teamId,
};
const webhooks = await getWebhooks(subscriberOptions);
const promises = webhooks.map((webhook) =>
sendPayload(webhook.secret, eventTrigger, new Date().toISOString(), webhook, {
...evt,
smsReminderNumber: bookingToReschedule.smsReminderNumber || undefined,
}).catch((e) => {
console.error(`Error executing webhook for event: ${eventTrigger}, URL: ${webhook.subscriberUrl}`, e);
})
);
await Promise.all(promises);
};
@@ -32,7 +32,7 @@ export const checkForGWorkspace = async ({ ctx }: CheckForGCalOptions) => {
return { id: gWorkspacePresent?.id };
};
export const getUsersFromGWorkspace = async ({ ctx }: CheckForGCalOptions) => {
export const getUsersFromGWorkspace = async ({}: CheckForGCalOptions) => {
const { client_id, client_secret } = await getAppKeysFromSlug("google-calendar");
if (!client_id || typeof client_id !== "string") throw new Error("Google client_id missing.");
if (!client_secret || typeof client_secret !== "string") throw new Error("Google client_secret missing.");
@@ -9,7 +9,7 @@ type AdminGetAllOptions = {
};
};
export const adminGetUnverifiedHandler = async ({ ctx }: AdminGetAllOptions) => {
export const adminGetUnverifiedHandler = async ({}: AdminGetAllOptions) => {
const allOrgs = await prisma.team.findMany({
where: {
AND: [
@@ -161,12 +161,14 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => {
},
});
if (!createOwnerOrg.organizationId) throw Error("User not created");
await prisma.membership.create({
data: {
userId: createOwnerOrg.id,
role: MembershipRole.OWNER,
accepted: true,
teamId: createOwnerOrg.organizationId!,
teamId: createOwnerOrg.organizationId,
},
});
@@ -68,7 +68,7 @@ export const listMembersHandler = async ({ ctx, input }: GetOptions) => {
let nextCursor: typeof cursor | undefined = undefined;
if (teamMembers && teamMembers.length > limit) {
const nextItem = teamMembers.pop();
nextCursor = nextItem!.id;
nextCursor = nextItem?.id;
}
const members = teamMembers?.map((member) => {
@@ -21,7 +21,7 @@ type ListOptions = {
input: TListOtherTeamMembersSchema;
};
export const listOtherTeamMembers = async ({ ctx, input }: ListOptions) => {
export const listOtherTeamMembers = async ({ input }: ListOptions) => {
const whereConditional: Prisma.MembershipWhereInput = {
teamId: input.teamId,
};
@@ -1,11 +1,17 @@
import type { IncomingMessage } from "http";
import type { TGetScheduleInputSchema } from "./getSchedule.schema";
import { getAvailableSlots } from "./util";
type GetScheduleOptions = {
ctx: Record<string, unknown>;
export type GetScheduleOptions = {
ctx?: ContextForGetSchedule;
input: TGetScheduleInputSchema;
};
export const getScheduleHandler = async ({ input }: GetScheduleOptions) => {
return await getAvailableSlots(input);
interface ContextForGetSchedule extends Record<string, unknown> {
req?: (IncomingMessage & { cookies: Partial<{ [key: string]: string }> }) | undefined;
}
export const getScheduleHandler = async ({ ctx, input }: GetScheduleOptions) => {
return await getAvailableSlots({ ctx, input });
};
@@ -7,6 +7,7 @@ import type { CurrentSeats } from "@calcom/core/getUserAvailability";
import { getUserAvailability } from "@calcom/core/getUserAvailability";
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/ee/organizations/lib/orgDomains";
import { getDefaultEvent } from "@calcom/lib/defaultEvents";
import isTimeOutOfBounds from "@calcom/lib/isOutOfBounds";
import logger from "@calcom/lib/logger";
@@ -19,6 +20,7 @@ import type { EventBusyDate } from "@calcom/types/Calendar";
import { TRPCError } from "@trpc/server";
import type { GetScheduleOptions } from "./getSchedule.handler";
import type { TGetScheduleInputSchema } from "./getSchedule.schema";
export const checkIfIsAvailable = ({
@@ -74,19 +76,27 @@ async function getEventTypeId({
slug,
eventTypeSlug,
isTeamEvent,
organizationDetails,
}: {
slug?: string;
eventTypeSlug?: string;
isTeamEvent: boolean;
organizationDetails?: { currentOrgDomain: string | null; isValidOrgDomain: boolean };
}) {
if (!eventTypeSlug || !slug) return null;
let teamId;
let userId;
if (isTeamEvent) {
teamId = await getTeamIdFromSlug(slug);
teamId = await getTeamIdFromSlug(
slug,
organizationDetails ?? { currentOrgDomain: null, isValidOrgDomain: false }
);
} else {
userId = await getUserIdFromUsername(slug);
userId = await getUserIdFromUsername(
slug,
organizationDetails ?? { currentOrgDomain: null, isValidOrgDomain: false }
);
}
const eventType = await prisma.eventType.findFirst({
where: {
@@ -104,7 +114,10 @@ async function getEventTypeId({
return eventType?.id;
}
export async function getEventType(input: TGetScheduleInputSchema) {
export async function getEventType(
input: TGetScheduleInputSchema,
organizationDetails: { currentOrgDomain: string | null; isValidOrgDomain: boolean }
) {
const { eventTypeSlug, usernameList, isTeamEvent } = input;
const eventTypeId =
input.eventTypeId ||
@@ -113,6 +126,7 @@ export async function getEventType(input: TGetScheduleInputSchema) {
slug: usernameList?.[0],
eventTypeSlug: eventTypeSlug,
isTeamEvent,
organizationDetails,
}));
if (!eventTypeId) {
@@ -223,12 +237,17 @@ export async function getDynamicEventType(input: TGetScheduleInputSchema) {
});
}
export function getRegularOrDynamicEventType(input: TGetScheduleInputSchema) {
export function getRegularOrDynamicEventType(
input: TGetScheduleInputSchema,
organizationDetails: { currentOrgDomain: string | null; isValidOrgDomain: boolean }
) {
const isDynamicBooking = input.usernameList && input.usernameList.length > 1;
return isDynamicBooking ? getDynamicEventType(input) : getEventType(input);
return isDynamicBooking ? getDynamicEventType(input) : getEventType(input, organizationDetails);
}
export async function getAvailableSlots(input: TGetScheduleInputSchema) {
export async function getAvailableSlots({ input, ctx }: GetScheduleOptions) {
const orgDetails = orgDomainConfig(ctx?.req?.headers.host ?? "");
if (input.debug === true) {
logger.setSettings({ minLevel: "debug" });
}
@@ -236,7 +255,7 @@ export async function getAvailableSlots(input: TGetScheduleInputSchema) {
logger.setSettings({ minLevel: "silly" });
}
const startPrismaEventTypeGet = performance.now();
const eventType = await getRegularOrDynamicEventType(input);
const eventType = await getRegularOrDynamicEventType(input, orgDetails);
const endPrismaEventTypeGet = performance.now();
logger.debug(
`Prisma eventType get took ${endPrismaEventTypeGet - startPrismaEventTypeGet}ms for event:${
@@ -492,10 +511,15 @@ export async function getAvailableSlots(input: TGetScheduleInputSchema) {
};
}
async function getUserIdFromUsername(username: string) {
async function getUserIdFromUsername(
username: string,
organizationDetails: { currentOrgDomain: string | null; isValidOrgDomain: boolean }
) {
const { currentOrgDomain, isValidOrgDomain } = organizationDetails;
const user = await prisma.user.findFirst({
where: {
username,
organization: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
},
select: {
id: true,
@@ -504,10 +528,15 @@ async function getUserIdFromUsername(username: string) {
return user?.id;
}
async function getTeamIdFromSlug(slug: string) {
async function getTeamIdFromSlug(
slug: string,
organizationDetails: { currentOrgDomain: string | null; isValidOrgDomain: boolean }
) {
const { currentOrgDomain, isValidOrgDomain } = organizationDetails;
const team = await prisma.team.findFirst({
where: {
slug,
parent: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
},
select: {
id: true,
@@ -264,13 +264,13 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
},
});
steps.forEach(async (step) => {
const promiseSteps = steps.map(async (step) => {
if (
step.action !== WorkflowActions.SMS_ATTENDEE &&
step.action !== WorkflowActions.WHATSAPP_ATTENDEE
) {
//as we do not have attendees phone number (user is notified about that when setting this action)
bookingsForReminders.forEach(async (booking) => {
const promiseScheduleReminders = bookingsForReminders.map(async (booking) => {
const defaultLocale = "en";
const bookingInfo = {
uid: booking.uid,
@@ -367,29 +367,28 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
);
}
});
await Promise.all(promiseScheduleReminders);
}
});
await Promise.all(promiseSteps);
}
//create all workflow - eventtypes relationships
activeOnEventTypes.forEach(async (eventType) => {
await ctx.prisma.workflowsOnEventTypes.createMany({
data: {
workflowId: id,
eventTypeId: eventType.id,
},
});
if (eventType.children.length) {
eventType.children.forEach(async (chEventType) => {
await ctx.prisma.workflowsOnEventTypes.createMany({
data: {
workflowId: id,
eventTypeId: chEventType.id,
},
});
});
}
await ctx.prisma.workflowsOnEventTypes.createMany({
data: activeOnEventTypes.map((eventType) => ({
workflowId: id,
eventTypeId: eventType.id,
})),
});
await Promise.all(
activeOnEventTypes.map((eventType) =>
ctx.prisma.workflowsOnEventTypes.createMany({
data: eventType.children.map((chEventType) => ({
workflowId: id,
eventTypeId: chEventType.id,
})),
})
)
);
}
userWorkflow.steps.map(async (oldStep) => {
@@ -465,11 +464,14 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
});
//cancel all workflow reminders from steps that were edited
remindersToUpdate.forEach(async (reminder) => {
// FIXME: async calls into ether
remindersToUpdate.forEach((reminder) => {
if (reminder.method === WorkflowMethods.EMAIL) {
deleteScheduledEmailReminder(reminder.id, reminder.referenceId);
} else if (reminder.method === WorkflowMethods.SMS) {
deleteScheduledSMSReminder(reminder.id, reminder.referenceId);
} else if (reminder.method === WorkflowMethods.WHATSAPP) {
deleteScheduledWhatsappReminder(reminder.id, reminder.referenceId);
}
});
const eventTypesToUpdateReminders = activeOn.filter((eventTypeId) => {
@@ -497,7 +499,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
user: true,
},
});
bookingsOfEventTypes.forEach(async (booking) => {
const promiseScheduleReminders = bookingsOfEventTypes.map(async (booking) => {
const defaultLocale = "en";
const bookingInfo = {
uid: booking.uid,
@@ -594,6 +596,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
);
}
});
await Promise.all(promiseScheduleReminders);
}
}
});
@@ -617,7 +620,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
return activeEventType;
}
});
addedSteps.forEach(async (step) => {
const promiseAddedSteps = addedSteps.map(async (step) => {
if (step) {
const { senderName, ...newStep } = step;
newStep.sender = getSender({
@@ -749,6 +752,7 @@ export const updateHandler = async ({ ctx, input }: UpdateOptions) => {
}
}
});
await Promise.all(promiseAddedSteps);
}
//update trigger, name, time, timeUnit
@@ -141,7 +141,7 @@ export function SelectWithValidation<
}: SelectProps<Option, IsMulti, Group> & { required?: boolean }) {
const [hiddenInputValue, _setHiddenInputValue] = React.useState(() => {
if (value instanceof Array || !value) {
return;
return "";
}
return value.value || "";
});
@@ -0,0 +1,308 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable playwright/missing-playwright-await */
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
import { vi } from "vitest";
import { Select, SelectField, SelectWithValidation } from "./Select";
const options = [
{ label: "Option 1", value: "option1" },
{ label: "Option 2", value: "option2" },
{ label: "Option 3", value: "option3" },
];
const onChangeMock = vi.fn();
const props: any = {
name: "test",
options: options,
defaultValue: { label: "Option 3", value: "option3" },
onChange: onChangeMock,
};
const classNames = {
singleValue: () => "w-1",
valueContainer: () => "w-2",
control: () => "w-3",
input: () => "w-4",
option: () => "w-5",
menuList: () => "w-6",
menu: () => "w-7",
multiValue: () => "w-8",
};
const renderSelectWithForm = (newProps?: any) => {
render(
<form aria-label="test-form">
<label htmlFor="test">Test</label>
<Select {...props} {...newProps} inputId="test" />
<p>Click Outside</p>
</form>
);
};
const selectOption = async (optionText: string) => {
const element = screen.getByLabelText("Test");
element.focus();
fireEvent.keyDown(element, { key: "ArrowDown", code: "ArrowDown" });
screen.getByText(optionText);
const option = screen.getByText(optionText);
fireEvent.click(option);
};
describe("Tests for Select File", () => {
beforeEach(() => {
vi.clearAllMocks();
});
describe("Test Select Component", () => {
describe("Tests the default Behavior of Select component", () => {
beforeEach(() => {
renderSelectWithForm();
});
test("Should render with the correct default value", async () => {
expect(screen.getByText("Option 3")).toBeInTheDocument();
expect(screen.getByRole("form")).toHaveFormValues({ test: "option3" });
});
test("Should select a correct option", async () => {
await waitFor(async () => {
await selectOption("Option 2");
});
expect(onChangeMock).toHaveBeenCalledWith(
expect.objectContaining({
label: "Option 2",
value: "option2",
}),
expect.objectContaining({
action: "select-option",
name: "test",
option: undefined,
})
);
expect(screen.getByRole("form")).toHaveFormValues({ test: "option2" });
});
test("Should keep the default value after selections are shown and the user clicks outside the selection element", async () => {
await waitFor(async () => {
const element = screen.getByLabelText("Test");
element.focus();
fireEvent.keyDown(element, { key: "ArrowDown", code: "ArrowDown" });
screen.getByText("Option 2");
const outsideButton = screen.getByText("Click Outside");
fireEvent.click(outsideButton);
});
expect(screen.getByRole("form")).toHaveFormValues({ test: "option3" });
});
test("Should keep the selected value after the user has selected an option and clicked out of the selection element", async () => {
await waitFor(async () => {
await selectOption("Option 2");
const outsideButton = screen.getByText("Click Outside");
fireEvent.click(outsideButton);
});
expect(screen.getByRole("form")).toHaveFormValues({ test: "option2" });
});
});
describe("Tests the Select Component with isMulti", () => {
test("Should have the right behavior when it has the prop isMulti", async () => {
renderSelectWithForm({ isMulti: true });
await waitFor(async () => {
const optionText = options[1].label;
const element = screen.getByLabelText("Test");
element.focus();
fireEvent.keyDown(element, { key: "ArrowDown", code: "ArrowDown" });
const option = screen.getByText(optionText);
fireEvent.click(option);
fireEvent.keyDown(element, { key: "ArrowDown", code: "ArrowDown" });
});
const option2Selected = screen.getByLabelText("Remove Option 2");
const option3Selected = screen.getAllByLabelText("Remove Option 3");
expect(option2Selected).toBeInTheDocument();
expect(option3Selected.length).toBeGreaterThan(0);
fireEvent.click(option2Selected);
expect(option2Selected).not.toBeInTheDocument();
});
});
describe("Tests the classes and CSS of the Select component", () => {
test("Should render classes correctly when isDisabled is true", async () => {
renderSelectWithForm({ isDisabled: true });
const singleValueEl = screen.getByText("Option 3");
const valueContainerEl = singleValueEl.parentElement;
const cotrolEl = valueContainerEl?.parentElement;
expect(cotrolEl).toHaveClass("bg-subtle");
});
test("Should render classes correctly when classNames props is passed", async () => {
renderSelectWithForm({ classNames });
const singleValueEl = screen.getByText("Option 3");
const valueContainerEl = singleValueEl.parentElement;
const cotrolEl = valueContainerEl?.parentElement;
const inputEl = screen.getByRole("combobox", { hidden: true }).parentElement;
expect(singleValueEl).toHaveClass("w-1");
expect(valueContainerEl).toHaveClass("w-2");
expect(cotrolEl).toHaveClass("w-3");
expect(inputEl).toHaveClass("w-4");
await waitFor(async () => {
const optionText = options[1].label;
const element = screen.getByLabelText("Test");
element.focus();
fireEvent.keyDown(element, { key: "ArrowDown", code: "ArrowDown" });
screen.getByText(optionText);
});
const optionEl = screen.getByText("Option 2").parentElement?.parentElement;
const menuListEl = optionEl?.parentElement;
const menuEl = menuListEl?.parentElement;
expect(optionEl).toHaveClass("w-5");
expect(menuListEl).toHaveClass("w-6");
expect(menuEl).toHaveClass("w-7");
});
test("Should render classes correctly for multiValue when classNames and isMulti props are passed and menu is open", async () => {
renderSelectWithForm({ classNames, isMulti: true });
const singleValueEl = screen.getByText("Option 3");
const multiValueEl = singleValueEl.parentElement;
expect(singleValueEl).not.toHaveClass("w-1");
expect(multiValueEl).toHaveClass("w-8");
await waitFor(async () => {
const optionText = options[1].label;
const element = screen.getByLabelText("Test");
element.focus();
fireEvent.keyDown(element, { key: "ArrowDown", code: "ArrowDown" });
const option = screen.getByText(optionText);
fireEvent.click(option);
fireEvent.keyDown(element, { key: "ArrowDown", code: "ArrowDown" });
});
const option2 = screen.getByText(options[1].label);
const menuIsOpenEl = option2.parentElement?.parentElement?.nextSibling;
expect(menuIsOpenEl).toHaveClass(
"[&>*:last-child]:rotate-180 [&>*:last-child]:transition-transform "
);
});
test("Should render classes correctly when focused, selected and menu is open", async () => {
renderSelectWithForm();
await waitFor(async () => {
const optionText = options[1].label;
const element = screen.getByLabelText("Test");
element.focus();
fireEvent.keyDown(element, { key: "ArrowDown", code: "ArrowDown" });
const option = screen.getByText(optionText);
option.focus();
});
const option1 = screen.getByText("Option 1");
const option1Parent = option1.parentElement?.parentElement;
const option3 = screen.getAllByText("Option 3");
const option3Parent = option3[1].parentElement?.parentElement;
const menuIsOpenEl = option3[0].parentElement?.nextSibling;
expect(option1).toBeInTheDocument();
expect(option1Parent).toHaveClass("bg-subtle");
expect(option3[1]).toBeInTheDocument();
expect(option3Parent).toHaveClass("bg-emphasis text-default");
expect(menuIsOpenEl).toHaveClass("rotate-180 transition-transform");
});
});
});
describe("Tests for SelectField component", () => {
const renderSelectField = (newProps?: any) => {
render(
<form aria-label="test-form">
<SelectField {...{ ...props, ...newProps }} />
</form>
);
};
test("Should render name as fallback label", () => {
renderSelectField();
expect(screen.getByText(props.name)).toBeInTheDocument();
});
test("Should not render the label element when label not passed and name is undefined", () => {
renderSelectField({ name: undefined });
expect(screen.queryByRole("label")).not.toBeInTheDocument();
});
test("Should render with the default value and label", () => {
renderSelectField({ label: "Test SelectField", name: "test" });
expect(screen.getByText("Option 3")).toBeInTheDocument();
expect(screen.getByRole("form")).toHaveFormValues({ test: "option3" });
const labelElement = screen.getByText("Test SelectField");
expect(labelElement).toBeInTheDocument();
});
});
describe("Tests for SelectWithValidation component", () => {
const handleSubmit = vi.fn((event) => {
event.preventDefault();
});
const renderSelectWithValidation = (required: boolean) => {
render(
<form onSubmit={handleSubmit} aria-label="test-form">
<label htmlFor="test">Test</label>
<SelectWithValidation {...props} required={required} inputId="test" />
<button type="submit">Submit</button>
</form>
);
};
test("Should render with the default value", () => {
renderSelectWithValidation(true);
expect(screen.getByText("Option 3")).toBeInTheDocument();
expect(screen.getByRole("form")).toHaveFormValues({ test: "option3" });
});
test("Should render an input element with the value passed as prop", () => {
renderSelectWithValidation(false);
expect(screen.getAllByDisplayValue("option3")).toHaveLength(1);
});
test("Should submit the form with the selected value after validation", async () => {
renderSelectWithValidation(true);
await waitFor(async () => {
await selectOption("Option 2");
const submitButton = screen.getByRole("button", { name: "Submit" });
fireEvent.click(submitButton);
});
expect(handleSubmit).toHaveBeenCalled();
});
test("Should fail to submit if nothing is selected", async () => {
renderSelectWithValidation(true);
const submitButton = screen.getByRole("button", { name: "Submit" });
fireEvent.click(submitButton);
expect(handleSubmit).not.toHaveBeenCalled();
});
});
});
@@ -1,6 +1,6 @@
import { useMemo, useState } from "react";
import type { ITimezoneOption, ITimezone, Props as SelectProps } from "react-timezone-select";
import BaseSelect, { allTimezones } from "react-timezone-select";
import BaseSelect from "react-timezone-select";
import { classNames } from "@calcom/lib";
import { filterByCities, addCitiesToDropdown, handleOptionLabel } from "@calcom/lib/timezone";
@@ -34,13 +34,6 @@ export function TimezoneSelect({
});
}, [components]);
// We use modifiedTimezones in place of the allTimezones object replacing any underscores in the curly braces
// with spaces and removing the America/Detroit timezone, adding the America/New_York timezone instead.
const modifiedTimezones = useMemo(() => {
const { "America/Detroit": _, ...rest } = allTimezones;
return { ...rest, "America/New_York": "New York" };
}, []);
return (
<BaseSelect
className={className}
@@ -48,9 +41,8 @@ export function TimezoneSelect({
isDisabled={isLoading}
{...reactSelectProps}
timezones={{
...modifiedTimezones,
...(data ? addCitiesToDropdown(data) : {}),
...addCitiesToDropdown(cities),
"America/Asuncion": "Asuncion",
}}
onInputChange={handleInputChange}
{...props}
+1 -1
View File
@@ -1,6 +1,6 @@
import React, { forwardRef } from "react";
export const Discord = forwardRef<SVGSVGElement, React.SVGProps<SVGSVGElement>>(function Discord(props, ref) {
export const Discord = forwardRef<SVGSVGElement, React.SVGProps<SVGSVGElement>>(function Discord(props) {
return (
<svg
className={props.className}
@@ -0,0 +1,57 @@
import { Canvas, Meta, Story } from "@storybook/addon-docs";
import { Title, VariantRow, VariantsTable, CustomArgsTable } from "@calcom/storybook/components";
import ImageUploader from "./ImageUploader";
<Meta title="UI/ImageUploader" component={ImageUploader} />
<Title title="ImageUploader" suffix="Brief" subtitle="Version 1.0 — Last Update: 21 Aug 2023" />
## Definitions
`ImageUploader` is used to upload images of all image types
## Structure
Below are the props for `Image uploader`
<CustomArgsTable of={ImageUploader} />
## ImageUploader Story
<Canvas>
<Story
name="ImageUploader"
play={({ canvasElement }) => {
const darkVariantContainer = canvasElement.querySelector("#dark-variant");
const imageUploaderElement = darkVariantContainer.querySelector("button");
imageUploaderElement?.addEventListener("click", () => {
setTimeout(() => {
document.querySelector('[role="dialog"]')?.classList.add("dark");
}, 1);
});
}}
args={{
id: "image-1",
buttonMsg: "upload",
target: "target",
handleAvatarChange: (src) => {
console.debug(src);
},
}}>
{({ id, buttonMsg, handleAvatarChange, imageSrc, target }) => (
<VariantsTable titles={["Default"]} columnMinWidth={150}>
<VariantRow>
<ImageUploader
id={id}
buttonMsg={buttonMsg}
handleAvatarChange={handleAvatarChange}
imageSrc={imageSrc}
target={target}
/>
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>
@@ -1,10 +1,8 @@
import { render } from "@testing-library/react";
import { vi } from "vitest";
import { ScrollableArea } from "./ScrollableArea";
describe("Tests for ScrollableArea Component", () => {
const toJSONMock = vi.fn();
test("Should render children inside the scrollable area", () => {
const { getByText } = render(
<ScrollableArea>
+1
View File
@@ -35,6 +35,7 @@ SheetPortal.displayName = SheetPrimitive.Portal.displayName;
const SheetOverlay = React.forwardRef<
React.ElementRef<typeof SheetPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
// eslint-disable-next-line @typescript-eslint/no-unused-vars
>(({ className, children, ...props }, ref) => (
<SheetPrimitive.Overlay
className={classNames(
+8
View File
@@ -44,6 +44,14 @@ vi.mock("@calcom/lib/OgImages", async () => {
return {};
});
vi.mock("@calcom/lib/hooks/useLocale", () => ({
useLocale: () => {
return {
t: (str: string) => str,
};
},
}));
expect.extend({
tabToBeDisabled(received) {
const isDisabled = received.classList.contains("pointer-events-none");
@@ -1,17 +1,14 @@
import classNames from "classnames";
// eslint-disable-next-line no-restricted-imports
import { noop } from "lodash";
import type { ComponentType, ReactNode } from "react";
import type { LucideIcon, LucideProps } from "@calcom/ui/components/icon";
import { X, AlertTriangle, Info } from "@calcom/ui/components/icon";
import { AlertTriangle, Info } from "@calcom/ui/components/icon";
export type TopBannerProps = {
Icon?: ComponentType<LucideProps> & LucideIcon;
text: string;
variant?: keyof typeof variantClassName;
actions?: ReactNode;
onClose?: () => void;
};
const variantClassName = {
@@ -26,14 +23,14 @@ const defaultIconProps = {
} as LucideProps;
export function TopBanner(props: TopBannerProps) {
const { Icon, variant = "default", text, actions, onClose } = props;
const { Icon, variant = "default", text, actions } = props;
const renderDefaultIconByVariant = () => {
switch (variant) {
case "error":
return <AlertTriangle {...defaultIconProps} />;
return <AlertTriangle {...defaultIconProps} data-testid="variant-error" />;
case "warning":
return <Info {...defaultIconProps} />;
return <Info {...defaultIconProps} data-testid="variant-warning" />;
default:
return null;
}
@@ -49,19 +46,11 @@ export function TopBanner(props: TopBannerProps) {
)}>
<div className="flex flex-1 flex-col items-start justify-center gap-2 p-1 lg:flex-row lg:items-center">
<p className="text-emphasis flex flex-col items-start justify-center gap-2 text-left font-sans text-sm font-medium leading-4 lg:flex-row lg:items-center">
{Icon ? <Icon {...defaultIconProps} /> : defaultIcon}
{Icon ? <Icon data-testid="variant-default" {...defaultIconProps} /> : defaultIcon}
{text}
</p>
{actions && <div className="text-sm font-medium">{actions}</div>}
</div>
{typeof onClose === "function" && (
<button
type="button"
onClick={noop}
className="hover:bg-gray-20 text-muted flex items-center rounded-lg p-1.5 text-sm">
<X className="text-emphasis h-4 w-4" />
</button>
)}
</div>
);
}
@@ -0,0 +1,45 @@
import { render, screen } from "@testing-library/react";
import { ArrowDown } from "@calcom/ui/components/icon";
import { TopBanner } from "./TopBanner";
describe("Tests for TopBanner component", () => {
test("Should render the component properly", () => {
render(<TopBanner text="the banner test" />);
const bannerElt = screen.getByTestId("banner");
expect(bannerElt).toBeInTheDocument();
const btnElt = screen.queryByRole("button");
expect(btnElt).toBeNull();
});
test("Should render actions", () => {
render(<TopBanner text="the banner test" actions={<div>cta</div>} />);
const ctaElt = screen.getByText("cta");
expect(ctaElt).toBeInTheDocument();
});
test("Should render the default variant", () => {
render(<TopBanner text="the banner test" Icon={ArrowDown} />);
const variant = screen.getByTestId("variant-default");
expect(variant).toBeInTheDocument();
});
test("Should render the alert variant", () => {
render(<TopBanner text="the banner test" variant="error" />);
const variant = screen.getByTestId("variant-error");
expect(variant).toBeInTheDocument();
});
test("Should render the warning variant", () => {
render(<TopBanner text="the banner test" variant="warning" />);
const variant = screen.getByTestId("variant-warning");
expect(variant).toBeInTheDocument();
});
});
@@ -0,0 +1,37 @@
import { Canvas, Meta, Story } from "@storybook/addon-docs";
import { Title, CustomArgsTable, VariantsTable, VariantRow } from "@calcom/storybook/components";
import { UnpublishedEntity } from "./UnpublishedEntity";
<Meta title="UI/UnpublishedEntity" component={UnpublishedEntity} />
<Title title="UnpublishedEntity" suffix="Brief" subtitle="Version 1.0 — Last Update: 17 Aug 2023" />
## Definition
The `UnpublishedEntity` component is used to display an "empty screen" with relevant information for unpublished teams or organizations.
## Structure
The `UnpublishedEntity` component consists of the following parts:
- An `EmptyScreen` component from `@calcom/ui` that displays a visual representation of an "empty screen."
- An `Avatar` component from `@calcom/ui` that shows an avatar image associated with the organization or team.
- Translated text obtained using the `useLocale` hook for displaying messages based on the entity's status.
<CustomArgsTable of={UnpublishedEntity} />
## UnpublishedEntity Story
<Canvas>
<Story name="UnpublishedEntity" args={{ name: "TeamExample", teamSlug: "team-example" }}>
{(args) => (
<VariantsTable titles={["Default"]} columnMinWidth={150}>
<VariantRow>
<UnpublishedEntity {...args} />
</VariantRow>
</VariantsTable>
)}
</Story>
</Canvas>