Merge branch 'main' into testE2E-timezone

This commit is contained in:
GitStart-Cal.com 2023-10-10 12:08:23 +00:00 committed by GitHub
commit 41b4c48921
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
56 changed files with 3765 additions and 1110 deletions

View File

@ -209,6 +209,8 @@ export async function patchHandler(req: NextApiRequest) {
hosts = [],
bookingLimits,
durationLimits,
/** FIXME: Updating event-type children from API not supported for now */
children: _,
...parsedBody
} = schemaEventTypeEditBodyParams.parse(body);

View File

@ -268,6 +268,8 @@ async function postHandler(req: NextApiRequest) {
hosts = [],
bookingLimits,
durationLimits,
/** FIXME: Adding event-type children from API not supported for now */
children: _,
...parsedBody
} = schemaEventTypeCreateBodyParams.parse(body || {});

View File

@ -11,7 +11,7 @@ import { HttpError } from "@calcom/lib/http-error";
* if the event type doesn't belong to any team,
* or if the user isn't a member of the associated team.
*/
export default async function checkUserMembership(parentId: number, userId: number) {
export default async function checkUserMembership(parentId: number, userId?: number) {
const parentEventType = await prisma.eventType.findUnique({
where: {
id: parentId,

View File

@ -5,7 +5,6 @@ import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
@ -33,7 +32,13 @@ import { ssrInit } from "@server/lib/ssr";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }: PageProps) {
function TeamPage({
team,
isUnpublished,
markdownStrippedBio,
isValidOrgDomain,
currentOrgDomain,
}: PageProps) {
useTheme(team.theme);
const routerQuery = useRouterQuery();
const pathname = usePathname();
@ -44,7 +49,6 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
const teamName = team.name || "Nameless Team";
const isBioEmpty = !team.bio || !team.bio.replace("<p><br></p>", "").length;
const metadata = teamMetadataSchema.parse(team.metadata);
const orgBranding = useOrgBranding();
useEffect(() => {
telemetry.event(
@ -182,8 +186,8 @@ function TeamPage({ team, isUnpublished, markdownStrippedBio, isValidOrgDomain }
<Avatar
alt={teamName}
imageSrc={
!!team.parent && !!orgBranding
? `${orgBranding?.fullDomain}/org/${orgBranding?.slug}/avatar.png`
isValidOrgDomain
? `/org/${currentOrgDomain}/avatar.png`
: `${WEBAPP_URL}/${team.metadata?.isOrganization ? "org" : "team"}/${team.slug}/avatar.png`
}
size="lg"
@ -354,6 +358,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
trpcState: ssr.dehydrate(),
markdownStrippedBio,
isValidOrgDomain,
currentOrgDomain,
},
} as const;
};

View File

@ -1,81 +1,90 @@
import type { Page } from "@playwright/test";
export const createEmbedsFixture = (page: Page) => {
return async (calNamespace: string) => {
await page.addInitScript(
({ calNamespace }: { calNamespace: string }) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
window.eventsFiredStoreForPlaywright = window.eventsFiredStoreForPlaywright || {};
document.addEventListener("DOMContentLoaded", function tryAddingListener() {
if (parent !== window) {
// Firefox seems to execute this snippet for iframe as well. Avoid that. It must be executed only for parent frame.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
window.initialBodyVisibility = document.body.style.visibility;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
window.initialBodyBackground = document.body.style.background;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
window.initialValuesSet = true;
return;
}
return {
/**
* @deprecated Use gotoPlayground instead
*/
async addEmbedListeners(calNamespace: string) {
await page.addInitScript(
({ calNamespace }: { calNamespace: string }) => {
console.log("PlaywrightTest:", "Adding listener for __iframeReady on namespace:", calNamespace);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
let api = window.Cal;
window.eventsFiredStoreForPlaywright = window.eventsFiredStoreForPlaywright || {};
document.addEventListener("DOMContentLoaded", function tryAddingListener() {
if (parent !== window) {
// Firefox seems to execute this snippet for iframe as well. Avoid that. It must be executed only for parent frame.
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
window.initialBodyVisibility = document.body.style.visibility;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
window.initialBodyBackground = document.body.style.background;
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
window.initialValuesSet = true;
return;
}
if (!api) {
setTimeout(tryAddingListener, 500);
return;
}
if (calNamespace) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
api = window.Cal.ns[calNamespace];
}
console.log("PlaywrightTest:", "Adding listener for __iframeReady");
if (!api) {
throw new Error(`namespace "${calNamespace}" not found`);
}
api("on", {
action: "*",
callback: (e) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
window.iframeReady = true; // Technically if there are multiple cal embeds, it can be set due to some other iframe. But it works for now. Improve it when it doesn't work
let api = window.Cal;
if (!api) {
console.log("PlaywrightTest:", "window.Cal not available yet, trying again");
setTimeout(tryAddingListener, 500);
return;
}
if (calNamespace) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const store = window.eventsFiredStoreForPlaywright;
const eventStore = (store[`${e.detail.type}-${e.detail.namespace}`] =
store[`${e.detail.type}-${e.detail.namespace}`] || []);
eventStore.push(e.detail);
},
//@ts-ignore
api = window.Cal.ns[calNamespace];
}
console.log("PlaywrightTest:", `Adding listener for __iframeReady on namespace:${calNamespace}`);
if (!api) {
throw new Error(`namespace "${calNamespace}" not found`);
}
api("on", {
action: "*",
callback: (e) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
window.iframeReady = true; // Technically if there are multiple cal embeds, it can be set due to some other iframe. But it works for now. Improve it when it doesn't work
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
const store = window.eventsFiredStoreForPlaywright;
const eventStore = (store[`${e.detail.type}-${e.detail.namespace}`] =
store[`${e.detail.type}-${e.detail.namespace}`] || []);
eventStore.push(e.detail);
},
});
});
});
},
{ calNamespace }
);
};
};
export const createGetActionFiredDetails = (page: Page) => {
return async ({ calNamespace, actionType }: { calNamespace: string; actionType: string }) => {
if (!page.isClosed()) {
return await page.evaluate(
({ actionType, calNamespace }) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
return window.eventsFiredStoreForPlaywright[`${actionType}-${calNamespace}`];
},
{ actionType, calNamespace }
{ calNamespace }
);
}
},
async getActionFiredDetails({ calNamespace, actionType }: { calNamespace: string; actionType: string }) {
if (!page.isClosed()) {
return await page.evaluate(
({ actionType, calNamespace }) => {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
return window.eventsFiredStoreForPlaywright[`${actionType}-${calNamespace}`];
},
{ actionType, calNamespace }
);
}
},
async gotoPlayground({ calNamespace, url }: { calNamespace: string; url: string }) {
await this.addEmbedListeners(calNamespace);
await page.goto(url);
},
};
};

View File

@ -8,7 +8,7 @@ import prisma from "@calcom/prisma";
import type { ExpectedUrlDetails } from "../../../../playwright.config";
import { createBookingsFixture } from "../fixtures/bookings";
import { createEmbedsFixture, createGetActionFiredDetails } from "../fixtures/embeds";
import { createEmbedsFixture } from "../fixtures/embeds";
import { createPaymentsFixture } from "../fixtures/payments";
import { createRoutingFormsFixture } from "../fixtures/routingForms";
import { createServersFixture } from "../fixtures/servers";
@ -19,8 +19,7 @@ export interface Fixtures {
users: ReturnType<typeof createUsersFixture>;
bookings: ReturnType<typeof createBookingsFixture>;
payments: ReturnType<typeof createPaymentsFixture>;
addEmbedListeners: ReturnType<typeof createEmbedsFixture>;
getActionFiredDetails: ReturnType<typeof createGetActionFiredDetails>;
embeds: ReturnType<typeof createEmbedsFixture>;
servers: ReturnType<typeof createServersFixture>;
prisma: typeof prisma;
emails?: API;
@ -36,7 +35,8 @@ declare global {
calNamespace: string,
// eslint-disable-next-line
getActionFiredDetails: (a: { calNamespace: string; actionType: string }) => Promise<any>,
expectedUrlDetails?: ExpectedUrlDetails
expectedUrlDetails?: ExpectedUrlDetails,
isPrendered?: boolean
): Promise<R>;
}
}
@ -58,14 +58,10 @@ export const test = base.extend<Fixtures>({
const payemntsFixture = createPaymentsFixture(page);
await use(payemntsFixture);
},
addEmbedListeners: async ({ page }, use) => {
embeds: async ({ page }, use) => {
const embedsFixture = createEmbedsFixture(page);
await use(embedsFixture);
},
getActionFiredDetails: async ({ page }, use) => {
const getActionFiredDetailsFixture = createGetActionFiredDetails(page);
await use(getActionFiredDetailsFixture);
},
servers: async ({}, use) => {
const servers = createServersFixture();
await use(servers);

View File

@ -1,4 +1,4 @@
import type { Page } from "@playwright/test";
import type { Frame, Page } from "@playwright/test";
import { expect } from "@playwright/test";
import type { IncomingMessage, ServerResponse } from "http";
import { createServer } from "http";
@ -86,7 +86,7 @@ export async function waitFor(fn: () => Promise<unknown> | unknown, opts: { time
}
}
export async function selectFirstAvailableTimeSlotNextMonth(page: Page) {
export async function selectFirstAvailableTimeSlotNextMonth(page: Page | Frame) {
// Let current month dates fully render.
await page.click('[data-testid="incrementMonth"]');

View File

@ -0,0 +1,39 @@
export {};
// TODO: @sean - I can't run E2E locally - causing me a lot of pain to try and debug.
// Will tackle in follow up once i reset my system.
// test.describe("User can overlay their calendar", async () => {
// test.afterAll(async ({ users }) => {
// await users.deleteAll();
// });
// test("Continue with Cal.com flow", async ({ page, users }) => {
// await users.create({
// username: "overflow-user-test",
// });
// await test.step("toggles overlay without a session", async () => {
// await page.goto("/overflow-user-test/30-min");
// const switchLocator = page.locator(`[data-testid=overlay-calendar-switch]`);
// await switchLocator.click();
// const continueWithCalCom = page.locator(`[data-testid=overlay-calendar-continue-button]`);
// await expect(continueWithCalCom).toBeVisible();
// await continueWithCalCom.click();
// });
// // log in trail user
// await test.step("Log in and return to booking page", async () => {
// const user = await users.create();
// await user.login();
// // Expect page to be redirected to the test users booking page
// await page.waitForURL("/overflow-user-test/30-min");
// });
// await test.step("Expect settings cog to be visible when session exists", async () => {
// const settingsCog = page.locator(`[data-testid=overlay-calendar-settings-button]`);
// await expect(settingsCog).toBeVisible();
// });
// await test.step("Settings should so no calendars connected", async () => {
// const settingsCog = page.locator(`[data-testid=overlay-calendar-settings-button]`);
// await settingsCog.click();
// await page.waitForLoadState("networkidle");
// const emptyScreenLocator = page.locator(`[data-testid=empty-screen]`);
// await expect(emptyScreenLocator).toBeVisible();
// });
// });
// });

View File

@ -268,6 +268,7 @@
"set_availability": "Set your availability",
"availability_settings": "Availability Settings",
"continue_without_calendar": "Continue without calendar",
"continue_with": "Continue with {{appName}}",
"connect_your_calendar": "Connect your calendar",
"connect_your_video_app": "Connect your video apps",
"connect_your_video_app_instructions": "Connect your video apps to use them on your event types.",
@ -2085,5 +2086,8 @@
"copy_client_secret_info": "After copying the secret you won't be able to view it anymore",
"add_new_client": "Add new Client",
"this_app_is_not_setup_already": "This app has not been setup yet",
"overlay_my_calendar":"Overlay my calendar",
"overlay_my_calendar_toc":"By connecting to your calendar, you accept our privacy policy and terms of use. You may revoke access at any time.",
"view_overlay_calendar_events":"View your calendar events to prevent clashed booking.",
"ADD_NEW_STRINGS_ABOVE_THIS_LINE_TO_PREVENT_MERGE_CONFLICTS": "↑↑↑↑↑↑↑↑↑↑↑↑↑ Add your new strings above here ↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑↑"
}

View File

@ -9,12 +9,14 @@ import { v4 as uuidv4 } from "uuid";
import "vitest-fetch-mock";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import type { getMockRequestDataForBooking } from "@calcom/features/bookings/lib/handleNewBooking/test/lib/getMockRequestDataForBooking";
import { handleStripePaymentSuccess } from "@calcom/features/ee/payments/api/webhook";
import type { HttpError } from "@calcom/lib/http-error";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { SchedulingType } from "@calcom/prisma/enums";
import type { BookingStatus } from "@calcom/prisma/enums";
import type { AppMeta } from "@calcom/types/App";
import type { NewCalendarEventType } from "@calcom/types/Calendar";
import type { EventBusyDate } from "@calcom/types/Calendar";
@ -22,10 +24,6 @@ import { getMockPaymentService } from "./MockPaymentService";
logger.setSettings({ minLevel: "silly" });
const log = logger.getChildLogger({ prefix: ["[bookingScenario]"] });
type App = {
slug: string;
dirName: string;
};
type InputWebhook = {
appId: string | null;
@ -52,24 +50,27 @@ type ScenarioData = {
/**
* Prisma would return these apps
*/
apps?: App[];
apps?: Partial<AppMeta>[];
bookings?: InputBooking[];
webhooks?: InputWebhook[];
};
type InputCredential = typeof TestData.credentials.google;
type InputCredential = typeof TestData.credentials.google & {
id?: number;
};
type InputSelectedCalendar = typeof TestData.selectedCalendars.google;
type InputUser = typeof TestData.users.example & { id: number } & {
type InputUser = Omit<typeof TestData.users.example, "defaultScheduleId"> & {
id: number;
defaultScheduleId?: number | null;
credentials?: InputCredential[];
selectedCalendars?: InputSelectedCalendar[];
schedules: {
id: number;
// Allows giving id in the input directly so that it can be referenced somewhere else as well
id?: number;
name: string;
availability: {
userId: number | null;
eventTypeId: number | null;
days: number[];
startTime: Date;
endTime: Date;
@ -97,7 +98,8 @@ export type InputEventType = {
afterEventBuffer?: number;
requiresConfirmation?: boolean;
destinationCalendar?: Prisma.DestinationCalendarCreateInput;
} & Partial<Omit<Prisma.EventTypeCreateInput, "users">>;
schedule?: InputUser["schedules"][number];
} & Partial<Omit<Prisma.EventTypeCreateInput, "users" | "schedule">>;
type InputBooking = {
id?: number;
@ -122,37 +124,75 @@ type InputBooking = {
}[];
};
const Timezones = {
export const Timezones = {
"+5:30": "Asia/Kolkata",
"+6:00": "Asia/Dhaka",
};
async function addEventTypesToDb(
eventTypes: (Omit<Prisma.EventTypeCreateInput, "users" | "worflows" | "destinationCalendar"> & {
eventTypes: (Omit<
Prisma.EventTypeCreateInput,
"users" | "worflows" | "destinationCalendar" | "schedule"
> & {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
users?: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
workflows?: any[];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
destinationCalendar?: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schedule?: any;
})[]
) {
log.silly("TestData: Add EventTypes to DB", JSON.stringify(eventTypes));
await prismock.eventType.createMany({
data: eventTypes,
});
const allEventTypes = await prismock.eventType.findMany({
include: {
users: true,
workflows: true,
destinationCalendar: true,
schedule: true,
},
});
/**
* This is a hack to get the relationship of schedule to be established with eventType. Looks like a prismock bug that creating eventType along with schedule.create doesn't establish the relationship.
* HACK STARTS
*/
log.silly("Fixed possible prismock bug by creating schedule separately");
for (let i = 0; i < eventTypes.length; i++) {
const eventType = eventTypes[i];
const createdEventType = allEventTypes[i];
if (eventType.schedule) {
log.silly("TestData: Creating Schedule for EventType", JSON.stringify(eventType));
await prismock.schedule.create({
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
data: {
...eventType.schedule.create,
eventType: {
connect: {
id: createdEventType.id,
},
},
},
});
}
}
/***
* HACK ENDS
*/
log.silly(
"TestData: All EventTypes in DB are",
JSON.stringify({
eventTypes: await prismock.eventType.findMany({
include: {
users: true,
workflows: true,
destinationCalendar: true,
},
}),
eventTypes: allEventTypes,
})
);
return allEventTypes;
}
async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser[]) {
@ -197,10 +237,22 @@ async function addEventTypes(eventTypes: InputEventType[], usersStore: InputUser
create: eventType.destinationCalendar,
}
: eventType.destinationCalendar,
schedule: eventType.schedule
? {
create: {
...eventType.schedule,
availability: {
createMany: {
data: eventType.schedule.availability,
},
},
},
}
: eventType.schedule,
};
});
log.silly("TestData: Creating EventType", JSON.stringify(eventTypesWithUsers));
await addEventTypesToDb(eventTypesWithUsers);
return await addEventTypesToDb(eventTypesWithUsers);
}
function addBookingReferencesToDB(bookingReferences: Prisma.BookingReferenceCreateManyInput[]) {
@ -289,10 +341,21 @@ async function addUsersToDb(users: (Prisma.UserCreateInput & { schedules: Prisma
await prismock.user.createMany({
data: users,
});
log.silly(
"Added users to Db",
safeStringify({
allUsers: await prismock.user.findMany(),
allUsers: await prismock.user.findMany({
include: {
credentials: true,
schedules: {
include: {
availability: true,
},
},
destinationCalendar: true,
},
}),
})
);
}
@ -343,16 +406,28 @@ async function addUsers(users: InputUser[]) {
await addUsersToDb(prismaUsersCreate);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function addAppsToDb(apps: any[]) {
log.silly("TestData: Creating Apps", JSON.stringify({ apps }));
await prismock.app.createMany({
data: apps,
});
const allApps = await prismock.app.findMany();
log.silly("TestData: Apps as in DB", JSON.stringify({ apps: allApps }));
}
export async function createBookingScenario(data: ScenarioData) {
log.silly("TestData: Creating Scenario", JSON.stringify({ data }));
await addUsers(data.users);
const eventType = await addEventTypes(data.eventTypes, data.users);
if (data.apps) {
prismock.app.createMany({
data: data.apps,
});
await addAppsToDb(
data.apps.map((app) => {
// Enable the app by default
return { enabled: true, ...app };
})
);
}
const eventTypes = await addEventTypes(data.eventTypes, data.users);
data.bookings = data.bookings || [];
// allowSuccessfulBookingCreation();
await addBookings(data.bookings);
@ -360,7 +435,7 @@ export async function createBookingScenario(data: ScenarioData) {
await addWebhooks(data.webhooks || []);
// addPaymentMock();
return {
eventType,
eventTypes,
};
}
@ -483,12 +558,11 @@ export const TestData = {
},
schedules: {
IstWorkHours: {
id: 1,
name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT",
availability: [
{
userId: null,
eventTypeId: null,
// userId: null,
// eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T09:30:00.000Z"),
endTime: new Date("1970-01-01T18:00:00.000Z"),
@ -497,21 +571,50 @@ export const TestData = {
],
timeZone: Timezones["+5:30"],
},
/**
* Has an overlap with IstEveningShift from 5PM to 6PM IST(11:30AM to 12:30PM GMT)
*/
IstMorningShift: {
name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT",
availability: [
{
// userId: null,
// eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T09:30:00.000Z"),
endTime: new Date("1970-01-01T18:00:00.000Z"),
date: null,
},
],
timeZone: Timezones["+5:30"],
},
/**
* Has an overlap with IstMorningShift from 5PM to 6PM IST(11:30AM to 12:30PM GMT)
*/
IstEveningShift: {
name: "5:00PM to 10PM in India - 11:30AM to 16:30PM in GMT",
availability: [
{
// userId: null,
// eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T17:00:00.000Z"),
endTime: new Date("1970-01-01T22:00:00.000Z"),
date: null,
},
],
timeZone: Timezones["+5:30"],
},
IstWorkHoursWithDateOverride: (dateString: string) => ({
id: 1,
name: "9:30AM to 6PM in India - 4:00AM to 12:30PM in GMT but with a Date Override for 2PM to 6PM IST(in GST time it is 8:30AM to 12:30PM)",
availability: [
{
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T09:30:00.000Z"),
endTime: new Date("1970-01-01T18:00:00.000Z"),
date: null,
},
{
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date(`1970-01-01T14:00:00.000Z`),
endTime: new Date(`1970-01-01T18:00:00.000Z`),
@ -532,9 +635,7 @@ export const TestData = {
},
apps: {
"google-calendar": {
slug: "google-calendar",
enabled: true,
dirName: "whatever",
...appStoreMetadata.googlecalendar,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
keys: {
@ -545,9 +646,7 @@ export const TestData = {
},
},
"daily-video": {
slug: "daily-video",
dirName: "whatever",
enabled: true,
...appStoreMetadata.dailyvideo,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
keys: {
@ -560,9 +659,7 @@ export const TestData = {
},
},
zoomvideo: {
slug: "zoom",
enabled: true,
dirName: "whatever",
...appStoreMetadata.zoomvideo,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
keys: {
@ -575,10 +672,7 @@ export const TestData = {
},
},
"stripe-payment": {
//TODO: Read from appStoreMeta
slug: "stripe",
enabled: true,
dirName: "stripepayment",
...appStoreMetadata.stripepayment,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
keys: {
@ -608,6 +702,7 @@ export function getOrganizer({
credentials,
selectedCalendars,
destinationCalendar,
defaultScheduleId,
}: {
name: string;
email: string;
@ -615,6 +710,7 @@ export function getOrganizer({
schedules: InputUser["schedules"];
credentials?: InputCredential[];
selectedCalendars?: InputSelectedCalendar[];
defaultScheduleId?: number | null;
destinationCalendar?: Prisma.DestinationCalendarCreateInput;
}) {
return {
@ -626,6 +722,7 @@ export function getOrganizer({
credentials,
selectedCalendars,
destinationCalendar,
defaultScheduleId,
};
}
@ -856,7 +953,9 @@ export function mockVideoApp({
url: `http://mock-${metadataLookupKey}.example.com`,
};
log.silly("mockSuccessfulVideoMeetingCreation", JSON.stringify({ metadataLookupKey, appStoreLookupKey }));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const createMeetingCalls: any[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const updateMeetingCalls: any[] = [];
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
@ -866,42 +965,50 @@ export function mockVideoApp({
lib: {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
//@ts-ignore
VideoApiAdapter: () => ({
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createMeeting: (...rest: any[]) => {
if (creationCrash) {
throw new Error("MockVideoApiAdapter.createMeeting fake error");
}
createMeetingCalls.push(rest);
VideoApiAdapter: (credential) => {
return {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createMeeting: (...rest: any[]) => {
if (creationCrash) {
throw new Error("MockVideoApiAdapter.createMeeting fake error");
}
createMeetingCalls.push({
credential,
args: rest,
});
return Promise.resolve({
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
...videoMeetingData,
});
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateMeeting: async (...rest: any[]) => {
if (updationCrash) {
throw new Error("MockVideoApiAdapter.updateMeeting fake error");
}
const [bookingRef, calEvent] = rest;
updateMeetingCalls.push(rest);
if (!bookingRef.type) {
throw new Error("bookingRef.type is not defined");
}
if (!calEvent.organizer) {
throw new Error("calEvent.organizer is not defined");
}
log.silly(
"mockSuccessfulVideoMeetingCreation.updateMeeting",
JSON.stringify({ bookingRef, calEvent })
);
return Promise.resolve({
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
...videoMeetingData,
});
},
}),
return Promise.resolve({
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
...videoMeetingData,
});
},
// eslint-disable-next-line @typescript-eslint/no-explicit-any
updateMeeting: async (...rest: any[]) => {
if (updationCrash) {
throw new Error("MockVideoApiAdapter.updateMeeting fake error");
}
const [bookingRef, calEvent] = rest;
updateMeetingCalls.push({
credential,
args: rest,
});
if (!bookingRef.type) {
throw new Error("bookingRef.type is not defined");
}
if (!calEvent.organizer) {
throw new Error("calEvent.organizer is not defined");
}
log.silly(
"mockSuccessfulVideoMeetingCreation.updateMeeting",
JSON.stringify({ bookingRef, calEvent })
);
return Promise.resolve({
type: appStoreMetadata[metadataLookupKey as keyof typeof appStoreMetadata].type,
...videoMeetingData,
});
},
};
},
},
});
});
@ -1029,3 +1136,25 @@ export async function mockPaymentSuccessWebhookFromStripe({ externalId }: { exte
}
return { webhookResponse };
}
export function getExpectedCalEventForBookingRequest({
bookingRequest,
eventType,
}: {
bookingRequest: ReturnType<typeof getMockRequestDataForBooking>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
eventType: any;
}) {
return {
// keep adding more fields as needed, so that they can be verified in all scenarios
type: eventType.title,
// Not sure why, but milliseconds are missing in cal Event.
startTime: bookingRequest.start.replace(".000Z", "Z"),
endTime: bookingRequest.end.replace(".000Z", "Z"),
};
}
export const enum BookingLocations {
CalVideo = "integrations:daily",
ZoomVideo = "integrations:zoom",
}

View File

@ -1,6 +1,6 @@
import prismaMock from "../../../../../tests/libs/__mocks__/prisma";
import type { WebhookTriggerEvents, Booking, BookingReference } from "@prisma/client";
import type { WebhookTriggerEvents, Booking, BookingReference, DestinationCalendar } from "@prisma/client";
import ical from "node-ical";
import { expect } from "vitest";
import "vitest-fetch-mock";
@ -182,11 +182,15 @@ export function expectSuccessfulBookingCreationEmails({
emails,
organizer,
booker,
guests,
otherTeamMembers,
iCalUID,
}: {
emails: Fixtures["emails"];
organizer: { email: string; name: string };
booker: { email: string; name: string };
guests?: { email: string; name: string }[];
otherTeamMembers?: { email: string; name: string }[];
iCalUID: string;
}) {
expect(emails).toHaveEmail(
@ -212,6 +216,39 @@ export function expectSuccessfulBookingCreationEmails({
},
`${booker.name} <${booker.email}>`
);
if (otherTeamMembers) {
otherTeamMembers.forEach((otherTeamMember) => {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>confirmed_event_type_subject</title>",
// Don't know why but organizer and team members of the eventType don'thave their name here like Booker
to: `${otherTeamMember.email}`,
ics: {
filename: "event.ics",
iCalUID: iCalUID,
},
},
`${otherTeamMember.email}`
);
});
}
if (guests) {
guests.forEach((guest) => {
expect(emails).toHaveEmail(
{
htmlToContain: "<title>confirmed_event_type_subject</title>",
to: `${guest.email}`,
ics: {
filename: "event.ics",
iCalUID: iCalUID,
},
},
`${guest.name} <${guest.email}`
);
});
}
}
export function expectBrokenIntegrationEmails({
@ -537,8 +574,9 @@ export function expectSuccessfulCalendarEventCreationInCalendar(
updateEventCalls: any[];
},
expected: {
calendarId: string | null;
calendarId?: string | null;
videoCallUrl: string;
destinationCalendars: Partial<DestinationCalendar>[];
}
) {
expect(calendarMock.createEventCalls.length).toBe(1);
@ -553,6 +591,8 @@ export function expectSuccessfulCalendarEventCreationInCalendar(
externalId: expected.calendarId,
}),
]
: expected.destinationCalendars
? expect.arrayContaining(expected.destinationCalendars.map((cal) => expect.objectContaining(cal)))
: null,
videoCallData: expect.objectContaining({
url: expected.videoCallUrl,
@ -584,7 +624,7 @@ export function expectSuccessfulCalendarEventUpdationInCalendar(
expect(externalId).toBe(expected.externalCalendarId);
}
export function expectSuccessfulVideoMeetingCreationInCalendar(
export function expectSuccessfulVideoMeetingCreation(
videoMock: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
createMeetingCalls: any[];
@ -592,19 +632,20 @@ export function expectSuccessfulVideoMeetingCreationInCalendar(
updateMeetingCalls: any[];
},
expected: {
externalCalendarId: string;
calEvent: Partial<CalendarEvent>;
uid: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
credential: any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
calEvent: any;
}
) {
expect(videoMock.createMeetingCalls.length).toBe(1);
const call = videoMock.createMeetingCalls[0];
const uid = call[0];
const calendarEvent = call[1];
const externalId = call[2];
expect(uid).toBe(expected.uid);
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
expect(externalId).toBe(expected.externalCalendarId);
const callArgs = call.args;
const calEvent = callArgs[0];
const credential = call.credential;
expect(credential).toEqual(expected.credential);
expect(calEvent).toEqual(expected.calEvent);
}
export function expectSuccessfulVideoMeetingUpdationInCalendar(
@ -622,8 +663,8 @@ export function expectSuccessfulVideoMeetingUpdationInCalendar(
) {
expect(videoMock.updateMeetingCalls.length).toBe(1);
const call = videoMock.updateMeetingCalls[0];
const bookingRef = call[0];
const calendarEvent = call[1];
const bookingRef = call.args[0];
const calendarEvent = call.args[1];
expect(bookingRef).toEqual(expect.objectContaining(expected.bookingRef));
expect(calendarEvent).toEqual(expect.objectContaining(expected.calEvent));
}

View File

@ -5,7 +5,7 @@ import { getNormalizedAppMetadata } from "./getNormalizedAppMetadata";
type RawAppStoreMetaData = typeof rawAppStoreMetadata;
type AppStoreMetaData = {
[key in keyof RawAppStoreMetaData]: AppMeta;
[key in keyof RawAppStoreMetaData]: Omit<AppMeta, "dirName"> & { dirName: string };
};
export const appStoreMetadata = {} as AppStoreMetaData;

View File

@ -19,7 +19,7 @@ export const getNormalizedAppMetadata = (appMeta: RawAppStoreMetaData[keyof RawA
dirName,
__template: "",
...appMeta,
} as AppStoreMetaData[keyof AppStoreMetaData];
} as Omit<AppStoreMetaData[keyof AppStoreMetaData], "dirName"> & { dirName: string };
metadata.logo = getAppAssetFullPath(metadata.logo, {
dirName,
isTemplate: metadata.isTemplate,

View File

@ -4,6 +4,9 @@ import type { AppCategories } from "@prisma/client";
// import appStore from "./index";
import { appStoreMetadata } from "@calcom/app-store/appStoreMetaData";
import type { EventLocationType } from "@calcom/app-store/locations";
import logger from "@calcom/lib/logger";
import { getPiiFreeCredential } from "@calcom/lib/piiFreeData";
import { safeStringify } from "@calcom/lib/safeStringify";
import type { App, AppMeta } from "@calcom/types/App";
import type { CredentialPayload } from "@calcom/types/Credential";
@ -52,7 +55,7 @@ function getApps(credentials: CredentialDataWithTeamName[], filterOnCredentials?
/** If the app is a globally installed one, let's inject it's key */
if (appMeta.isGlobal) {
appCredentials.push({
const credential = {
id: 0,
type: appMeta.type,
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@ -65,7 +68,12 @@ function getApps(credentials: CredentialDataWithTeamName[], filterOnCredentials?
team: {
name: "Global",
},
});
};
logger.debug(
`${appMeta.type} is a global app, injecting credential`,
safeStringify(getPiiFreeCredential(credential))
);
appCredentials.push(credential);
}
/** Check if app has location option AND add it if user has credentials for it */

View File

@ -460,16 +460,23 @@ export default class EventManager {
/** @fixme potential bug since Google Meet are saved as `integrations:google:meet` and there are no `google:meet` type in our DB */
const integrationName = event.location.replace("integrations:", "");
let videoCredential = event.conferenceCredentialId
? this.videoCredentials.find((credential) => credential.id === event.conferenceCredentialId)
: this.videoCredentials
// Whenever a new video connection is added, latest credentials are added with the highest ID.
// Because you can't rely on having them in the highest first order here, ensure this by sorting in DESC order
.sort((a, b) => {
return b.id - a.id;
})
.find((credential: CredentialPayload) => credential.type.includes(integrationName));
let videoCredential;
if (event.conferenceCredentialId) {
videoCredential = this.videoCredentials.find(
(credential) => credential.id === event.conferenceCredentialId
);
} else {
videoCredential = this.videoCredentials
// Whenever a new video connection is added, latest credentials are added with the highest ID.
// Because you can't rely on having them in the highest first order here, ensure this by sorting in DESC order
.sort((a, b) => {
return b.id - a.id;
})
.find((credential: CredentialPayload) => credential.type.includes(integrationName));
log.warn(
`Could not find conferenceCredentialId for event with location: ${event.location}, trying to use last added video credential`
);
}
/**
* This might happen if someone tries to use a location with a missing credential, so we fallback to Cal Video.

View File

@ -9,6 +9,7 @@ import { buildDateRanges, subtract } from "@calcom/lib/date-ranges";
import { HttpError } from "@calcom/lib/http-error";
import { descendingLimitKeys, intervalLimitKeyToUnit } from "@calcom/lib/intervalLimit";
import logger from "@calcom/lib/logger";
import { safeStringify } from "@calcom/lib/safeStringify";
import { checkBookingLimit } from "@calcom/lib/server";
import { performance } from "@calcom/lib/server/perfObserver";
import { getTotalBookingDuration } from "@calcom/lib/server/queries";
@ -25,6 +26,7 @@ import type {
import { getBusyTimes, getBusyTimesForLimitChecks } from "./getBusyTimes";
const log = logger.getChildLogger({ prefix: ["getUserAvailability"] });
const availabilitySchema = z
.object({
dateFrom: stringToDayjs,
@ -161,7 +163,12 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni
if (userId) where.id = userId;
const user = initialData?.user || (await getUser(where));
if (!user) throw new HttpError({ statusCode: 404, message: "No user found" });
log.debug(
"getUserAvailability for user",
safeStringify({ user: { id: user.id }, slot: { dateFrom, dateTo } })
);
let eventType: EventType | null = initialData?.eventType || null;
if (!eventType && eventTypeId) eventType = await getEventType(eventTypeId);
@ -225,10 +232,17 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni
(schedule) => !user?.defaultScheduleId || schedule.id === user?.defaultScheduleId
)[0];
const schedule =
!eventType?.metadata?.config?.useHostSchedulesForTeamEvent && eventType?.schedule
? eventType.schedule
: userSchedule;
const useHostSchedulesForTeamEvent = eventType?.metadata?.config?.useHostSchedulesForTeamEvent;
const schedule = !useHostSchedulesForTeamEvent && eventType?.schedule ? eventType.schedule : userSchedule;
log.debug(
"Using schedule:",
safeStringify({
chosenSchedule: schedule,
eventTypeSchedule: eventType?.schedule,
userSchedule: userSchedule,
useHostSchedulesForTeamEvent: eventType?.metadata?.config?.useHostSchedulesForTeamEvent,
})
);
const startGetWorkingHours = performance.now();
@ -270,7 +284,7 @@ export const getUserAvailability = async function getUsersWorkingHoursLifeTheUni
const dateRangesInWhichUserIsAvailable = subtract(dateRanges, formattedBusyTimes);
logger.debug(
log.debug(
`getWorkingHours took ${endGetWorkingHours - startGetWorkingHours}ms for userId ${userId}`,
JSON.stringify({
workingHoursInUtc: workingHours,

View File

@ -55,7 +55,7 @@ const getBusyVideoTimes = async (withCredentials: CredentialPayload[]) =>
const createMeeting = async (credential: CredentialPayload, calEvent: CalendarEvent) => {
const uid: string = getUid(calEvent);
log.silly(
log.debug(
"createMeeting",
safeStringify({
credential: getPiiFreeCredential(credential),
@ -100,11 +100,13 @@ const createMeeting = async (credential: CredentialPayload, calEvent: CalendarEv
},
});
if (!enabledApp?.enabled) throw "Current location app is not enabled";
if (!enabledApp?.enabled)
throw `Location app ${credential.appId} is either disabled or not seeded at all`;
createdMeeting = await firstVideoAdapter?.createMeeting(calEvent);
returnObject = { ...returnObject, createdEvent: createdMeeting, success: true };
log.debug("created Meeting", safeStringify(returnObject));
} catch (err) {
await sendBrokenIntegrationEmail(calEvent, "video");
log.error("createMeeting failed", safeStringify({ err, calEvent: getPiiFreeCalendarEvent(calEvent) }));

View File

@ -1,3 +1,4 @@
/** @type {import("eslint").Linter.Config} */
module.exports = {
extends: ["../../.eslintrc.js"],
rules: {

View File

@ -85,20 +85,30 @@
<span style="display: block"><a href="?color-scheme=dark">With Dark Color Scheme for the Page</a></span>
<span style="display: block"><a href="?nonResponsive">Non responsive version of this page here</a></span>
<span style="display: block"
><a href="?only=prerender-test">Go to Pre-render test page only</a><small></small
><a href="?only=prerender-test">Go to Prerender test page only</a><small></small
></span>
<span style="display: block"
><a href="?only=preload-test">Go to Preload test page only</a><small></small
></span>
<button onclick="document.documentElement.style.colorScheme='dark'">Toggle Dark Scheme</button>
<button onclick="document.documentElement.style.colorScheme='light'">Toggle Light Scheme</button>
<div>
<script>
if (only === "all" || only === "prerender-test") {
document.write(`
<button data-cal-namespace="prerendertestLightTheme" data-cal-config='{"theme":"light"}' data-cal-link="free?light&popup">Book with Free User[Light Theme]</button>
<button data-cal-namespace="e2ePrerenderLightTheme" data-cal-config='{"theme":"dark", "email":"preloaded-prefilled@example.com", "name": "Preloaded Prefilled"}' data-cal-link="free/30min">Book with Free User[Dark Theme]</button>
<i
>Corresponding Cal Link is being preloaded. Assuming that it would take you some time to click this
>Corresponding Cal Link is being prerendered. Assuming that it would take you some time to click this
as you are reading this text, it would open up super fast[If you are running a production build on
local]. Try switching to slow 3G or create a custom Network configuration which is impossibly
slow</i
slow. This should be used if you know beforehand which type of embed is going to be opened.</i
>`);
}
if (only === "all" || only === "preload-test") {
document.write(`
<button data-cal-namespace="preloadTest" data-cal-config='{"theme":"dark", "email":"preloaded-prefilled@example.com", "name": "Preloaded Prefilled"}' data-cal-link="free/30min">Book with Free User[Dark Theme]</button>
<i
>Corresponding Cal Link is being preloaded. That means that all the resources would be preloaded. This could be useful in preloading possible resources if you don't know before hand which type of embed you want to show</i
>`);
}
</script>
@ -110,6 +120,7 @@
<a href="?only=ns:floatingButton">Floating Popup</a>
<h2>Popup Examples</h2>
<button data-cal-namespace="e2ePopupLightTheme" data-cal-link="free" data-cal-config='{"theme":"light"}'>Book an event with Free[Light Theme]</button>
<button data-cal-namespace="popupAutoTheme" data-cal-link="free">
Book with Free User[Auto Theme]
</button>

View File

@ -24,7 +24,7 @@ document.addEventListener("click", (e) => {
const searchParams = new URL(document.URL).searchParams;
const only = searchParams.get("only");
const colorScheme = searchParams.get("color-scheme");
const prerender = searchParams.get("prerender");
if (colorScheme) {
document.documentElement.style.colorScheme = colorScheme;
}
@ -211,13 +211,25 @@ if (only === "all" || only === "ns:fifth") {
callback,
});
}
if (only === "all" || only === "prerender-test") {
Cal("init", "prerendertestLightTheme", {
Cal("init", "e2ePrerenderLightTheme", {
debug: true,
origin: "http://localhost:3000",
});
Cal.ns.prerendertestLightTheme("preload", {
calLink: "free",
Cal.ns.e2ePrerenderLightTheme("prerender", {
calLink: "free/30min",
type: "modal",
});
}
if (only === "all" || only === "preload-test") {
Cal("init", "preloadTest", {
debug: true,
origin: "http://localhost:3000",
});
Cal.ns.preloadTest("preload", {
calLink: "free/30min",
});
}
@ -300,6 +312,11 @@ Cal("init", "popupDarkTheme", {
origin: "http://localhost:3000",
});
Cal("init", "e2ePopupLightTheme", {
debug: true,
origin: "http://localhost:3000",
});
Cal("init", "popupHideEventTypeDetails", {
debug: true,
origin: "http://localhost:3000",
@ -360,6 +377,12 @@ Cal("init", "routingFormDark", {
});
if (only === "all" || only == "ns:floatingButton") {
if (prerender == "true") {
Cal.ns.floatingButton("prerender", {
calLink: calLink || "pro",
type: "floatingButton",
});
}
Cal.ns.floatingButton("floatingButton", {
calLink: calLink || "pro",
config: {

View File

@ -56,9 +56,13 @@ export const getEmbedIframe = async ({
clearInterval(interval);
resolve(true);
} else {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
console.log("Iframe Status:", !!iframe, !!iframe?.contentWindow, window.iframeReady);
console.log("Waiting for all three to be true:", {
iframeElement: iframe,
contentWindow: iframe?.contentWindow,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
iframeReady: window.iframeReady,
});
}
}, 500);

View File

@ -3,6 +3,7 @@ import { expect } from "@playwright/test";
import { test } from "@calcom/web/playwright/lib/fixtures";
import type { Fixtures } from "@calcom/web/playwright/lib/fixtures";
import { selectFirstAvailableTimeSlotNextMonth } from "@calcom/web/playwright/lib/testUtils";
import {
todo,
@ -18,9 +19,9 @@ async function bookFirstFreeUserEventThroughEmbed({
page,
getActionFiredDetails,
}: {
addEmbedListeners: Fixtures["addEmbedListeners"];
addEmbedListeners: Fixtures["embeds"]["addEmbedListeners"];
page: Page;
getActionFiredDetails: Fixtures["getActionFiredDetails"];
getActionFiredDetails: Fixtures["embeds"]["getActionFiredDetails"];
}) {
const embedButtonLocator = page.locator('[data-cal-link="free"]').first();
await page.goto("/");
@ -50,24 +51,16 @@ test.describe("Popup Tests", () => {
await deleteAllBookingsByEmail("embed-user@example.com");
});
test("should open embed iframe on click - Configured with light theme", async ({
page,
addEmbedListeners,
getActionFiredDetails,
}) => {
test("should open embed iframe on click - Configured with light theme", async ({ page, embeds }) => {
await deleteAllBookingsByEmail("embed-user@example.com");
const calNamespace = "e2ePopupLightTheme";
await embeds.gotoPlayground({ calNamespace, url: "/" });
const calNamespace = "prerendertestLightTheme";
await addEmbedListeners(calNamespace);
await page.goto("/?only=prerender-test");
let embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/free" });
expect(embedIframe).toBeFalsy();
await page.click(`[data-cal-namespace="${calNamespace}"]`);
await page.click('[data-cal-link="free?light&popup"]');
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/free" });
embedIframe = await getEmbedIframe({ calNamespace, page, pathname: "/free" });
await expect(embedIframe).toBeEmbedCalLink(calNamespace, getActionFiredDetails, {
await expect(embedIframe).toBeEmbedCalLink(calNamespace, embeds.getActionFiredDetails, {
pathname: "/free",
});
// expect(await page.screenshot()).toMatchSnapshot("event-types-list.png");
@ -82,7 +75,10 @@ test.describe("Popup Tests", () => {
await deleteAllBookingsByEmail("embed-user@example.com");
});
test("should be able to reschedule", async ({ page, addEmbedListeners, getActionFiredDetails }) => {
test("should be able to reschedule", async ({
page,
embeds: { addEmbedListeners, getActionFiredDetails },
}) => {
const booking = await test.step("Create a booking", async () => {
return await bookFirstFreeUserEventThroughEmbed({
page,
@ -108,8 +104,7 @@ test.describe("Popup Tests", () => {
test("should open Routing Forms embed on click", async ({
page,
addEmbedListeners,
getActionFiredDetails,
embeds: { addEmbedListeners, getActionFiredDetails },
}) => {
await deleteAllBookingsByEmail("embed-user@example.com");
@ -143,8 +138,7 @@ test.describe("Popup Tests", () => {
test.describe("Pro User - Configured in App with default setting of system theme", () => {
test("should open embed iframe according to system theme when no theme is configured through Embed API", async ({
page,
addEmbedListeners,
getActionFiredDetails,
embeds: { addEmbedListeners, getActionFiredDetails },
}) => {
const calNamespace = "floatingButton";
await addEmbedListeners(calNamespace);
@ -175,8 +169,7 @@ test.describe("Popup Tests", () => {
test("should open embed iframe according to system theme when configured with 'auto' theme using Embed API", async ({
page,
addEmbedListeners,
getActionFiredDetails,
embeds: { addEmbedListeners, getActionFiredDetails },
}) => {
const calNamespace = "floatingButton";
await addEmbedListeners(calNamespace);
@ -203,8 +196,7 @@ test.describe("Popup Tests", () => {
test("should open embed iframe(Booker Profile Page) with dark theme when configured with dark theme using Embed API", async ({
page,
addEmbedListeners,
getActionFiredDetails,
embeds: { addEmbedListeners, getActionFiredDetails },
}) => {
const calNamespace = "floatingButton";
await addEmbedListeners(calNamespace);
@ -227,8 +219,7 @@ test.describe("Popup Tests", () => {
test("should open embed iframe(Event Booking Page) with dark theme when configured with dark theme using Embed API", async ({
page,
addEmbedListeners,
getActionFiredDetails,
embeds: { addEmbedListeners, getActionFiredDetails },
}) => {
const calNamespace = "floatingButton";
await addEmbedListeners(calNamespace);
@ -250,4 +241,52 @@ test.describe("Popup Tests", () => {
});
});
});
test("prendered embed should be loaded and apply the config given to it", async ({ page, embeds }) => {
const calNamespace = "e2ePrerenderLightTheme";
const calLink = "/free/30min";
await embeds.gotoPlayground({ calNamespace, url: "/?only=prerender-test" });
await expectPrerenderedIframe({ calNamespace, calLink, embeds, page });
await page.click(`[data-cal-namespace="${calNamespace}"]`);
const embedIframe = await getEmbedIframe({ calNamespace, page, pathname: calLink });
// eslint-disable-next-line playwright/no-conditional-in-test
if (!embedIframe) {
throw new Error("Embed iframe not found");
}
await selectFirstAvailableTimeSlotNextMonth(embedIframe);
await expect(embedIframe.locator('[name="name"]')).toHaveValue("Preloaded Prefilled");
await expect(embedIframe.locator('[name="email"]')).toHaveValue("preloaded-prefilled@example.com");
await expect(embedIframe).toBeEmbedCalLink(calNamespace, embeds.getActionFiredDetails, {
pathname: calLink,
});
});
});
async function expectPrerenderedIframe({
page,
calNamespace,
calLink,
embeds,
}: {
page: Page;
calNamespace: string;
calLink: string;
embeds: Fixtures["embeds"];
}) {
const prerenderedIframe = await getEmbedIframe({ calNamespace, page, pathname: calLink });
if (!prerenderedIframe) {
throw new Error("Prerendered iframe not found");
}
await expect(prerenderedIframe).toBeEmbedCalLink(
calNamespace,
embeds.getActionFiredDetails,
{
pathname: calLink,
},
true
);
}

View File

@ -7,8 +7,7 @@ import { bookFirstEvent, deleteAllBookingsByEmail, getEmbedIframe, todo } from "
test.describe("Inline Iframe", () => {
test("Inline Iframe - Configured with Dark Theme", async ({
page,
getActionFiredDetails,
addEmbedListeners,
embeds: { addEmbedListeners, getActionFiredDetails },
}) => {
await deleteAllBookingsByEmail("embed-user@example.com");
await addEmbedListeners("");

View File

@ -41,6 +41,21 @@ export class ModalBox extends HTMLElement {
this.dispatchEvent(event);
}
hideIframe() {
const iframe = this.querySelector("iframe");
if (iframe) {
iframe.style.visibility = "hidden";
}
}
showIframe() {
const iframe = this.querySelector("iframe");
if (iframe) {
// Don't use visibility visible as that will make the iframe visible even when the modal is closed
iframe.style.visibility = "";
}
}
getLoaderElement() {
this.assertHasShadowRoot();
const loaderEl = this.shadowRoot.querySelector<HTMLElement>(".loader");
@ -68,10 +83,14 @@ export class ModalBox extends HTMLElement {
return;
}
if (newValue == "loaded") {
this.getLoaderElement().style.display = "none";
} else if (newValue === "started") {
if (newValue === "loading") {
this.open();
this.hideIframe();
this.getLoaderElement().style.display = "block";
} else if (newValue == "loaded" || newValue === "reopening") {
this.open();
this.showIframe();
this.getLoaderElement().style.display = "none";
} else if (newValue == "closed") {
this.close();
} else if (newValue === "failed") {
@ -79,6 +98,8 @@ export class ModalBox extends HTMLElement {
this.getErrorElement().style.display = "inline-block";
const errorString = getErrorString(this.dataset.errorCode);
this.getErrorElement().innerText = errorString;
} else if (newValue === "prerendering") {
this.close();
}
}

View File

@ -1,3 +1,4 @@
import { useRouter } from "next/navigation";
import { useSearchParams } from "next/navigation";
import { useEffect, useRef, useState, useCallback } from "react";
@ -7,6 +8,29 @@ import type { EmbedThemeConfig, UiConfig, EmbedNonStylesConfig, BookerLayouts, E
type SetStyles = React.Dispatch<React.SetStateAction<EmbedStyles>>;
type setNonStylesConfig = React.Dispatch<React.SetStateAction<EmbedNonStylesConfig>>;
const enum EMBED_IFRAME_STATE {
NOT_INITIALIZED,
INITIALIZED,
}
/**
* All types of config that are critical to be processed as soon as possible are provided as query params to the iframe
*/
export type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Record<string, string>> & {
// TODO: iframeAttrs shouldn't be part of it as that configures the iframe element and not the iframed app.
iframeAttrs?: Record<string, string> & {
id?: string;
};
// TODO: It should have a dedicated prefill prop
// prefill: {},
// TODO: Move layout and theme as nested props of ui as it makes it clear that these two can be configured using `ui` instruction as well any time.
// ui: {layout; theme}
layout?: BookerLayouts;
// TODO: Rename layout and theme as ui.layout and ui.theme as it makes it clear that these two can be configured using `ui` instruction as well any time.
"ui.color-scheme"?: string;
theme?: EmbedThemeConfig;
};
declare global {
interface Window {
@ -17,10 +41,34 @@ declare global {
};
}
}
/**
* This is in-memory persistence needed so that when user browses through the embed, the configurations from the instructions aren't lost.
*/
const embedStore = {
// Handles the commands of routing received from parent even when React hasn't initialized and nextRouter isn't available
router: {
setNextRouter(nextRouter: ReturnType<typeof useRouter>) {
this.nextRouter = nextRouter;
// Empty the queue after running push on nextRouter. This is important because setNextRouter is be called multiple times
this.queue.forEach((url) => {
nextRouter.push(url);
this.queue.splice(0, 1);
});
},
nextRouter: null as null | ReturnType<typeof useRouter>,
queue: [] as string[],
goto(url: string) {
if (this.nextRouter) {
this.nextRouter.push(url.toString());
} else {
this.queue.push(url);
}
},
},
state: EMBED_IFRAME_STATE.NOT_INITIALIZED,
// Store all embed styles here so that as and when new elements are mounted, styles can be applied to it.
styles: {} as EmbedStyles | undefined,
nonStyles: {} as EmbedNonStylesConfig | undefined,
@ -148,6 +196,8 @@ const useUrlChange = (callback: (newUrl: string) => void) => {
const pathname = currentFullUrl?.pathname ?? "";
const searchParams = currentFullUrl?.searchParams ?? null;
const lastKnownUrl = useRef(`${pathname}?${searchParams}`);
const router = useRouter();
embedStore.router.setNextRouter(router);
useEffect(() => {
const newUrl = `${pathname}?${searchParams}`;
if (lastKnownUrl.current !== newUrl) {
@ -340,9 +390,28 @@ const methods = {
}
// No UI change should happen in sight. Let the parent height adjust and in next cycle show it.
unhideBody();
sdkActionManager?.fire("linkReady", {});
if (!isPrerendering()) {
sdkActionManager?.fire("linkReady", {});
}
});
},
connect: function connect(queryObject: PrefillAndIframeAttrsConfig) {
const currentUrl = new URL(document.URL);
const searchParams = currentUrl.searchParams;
searchParams.delete("preload");
for (const [key, value] of Object.entries(queryObject)) {
if (value === undefined) {
continue;
}
if (value instanceof Array) {
value.forEach((val) => searchParams.append(key, val));
} else {
searchParams.set(key, value as string);
}
}
connectPreloadedEmbed({ url: currentUrl });
},
};
export type InterfaceWithParent = {
@ -451,58 +520,71 @@ if (isBrowser) {
};
actOnColorScheme(embedStore.uiConfig.colorScheme);
if (url.searchParams.get("prerender") !== "true" && window?.isEmbed?.()) {
log("Initializing embed-iframe");
// HACK
const pageStatus = window.CalComPageStatus;
// If embed link is opened in top, and not in iframe. Let the page be visible.
if (top === window) {
unhideBody();
}
sdkActionManager?.on("*", (e) => {
const detail = e.detail;
log(detail);
messageParent(detail);
});
window.addEventListener("message", (e) => {
const data: Message = e.data;
if (!data) {
return;
}
const method: keyof typeof interfaceWithParent = data.method;
if (data.originator === "CAL" && typeof method === "string") {
interfaceWithParent[method]?.(data.arg as never);
}
});
document.addEventListener("click", (e) => {
if (!e.target || !(e.target instanceof Node)) {
return;
}
const mainElement =
document.getElementsByClassName("main")[0] ||
document.getElementsByTagName("main")[0] ||
document.documentElement;
if (e.target.contains(mainElement)) {
sdkActionManager?.fire("__closeIframe", {});
}
});
if (!pageStatus || pageStatus == "200") {
keepParentInformedAboutDimensionChanges();
sdkActionManager?.fire("__iframeReady", {});
} else
sdkActionManager?.fire("linkFailed", {
code: pageStatus,
msg: "Problem loading the link",
data: {
url: document.URL,
},
});
// If embed link is opened in top, and not in iframe. Let the page be visible.
if (top === window) {
unhideBody();
}
window.addEventListener("message", (e) => {
const data: Message = e.data;
if (!data) {
return;
}
const method: keyof typeof interfaceWithParent = data.method;
if (data.originator === "CAL" && typeof method === "string") {
interfaceWithParent[method]?.(data.arg as never);
}
});
document.addEventListener("click", (e) => {
if (!e.target || !(e.target instanceof Node)) {
return;
}
const mainElement =
document.getElementsByClassName("main")[0] ||
document.getElementsByTagName("main")[0] ||
document.documentElement;
if (e.target.contains(mainElement)) {
sdkActionManager?.fire("__closeIframe", {});
}
});
sdkActionManager?.on("*", (e) => {
const detail = e.detail;
log(detail);
messageParent(detail);
});
if (url.searchParams.get("preload") !== "true" && window?.isEmbed?.()) {
initializeAndSetupEmbed();
} else {
log(`Preloaded scenario - Skipping initialization and setup`);
}
}
function initializeAndSetupEmbed() {
sdkActionManager?.fire("__iframeReady", {});
// Only NOT_INITIALIZED -> INITIALIZED transition is allowed
if (embedStore.state !== EMBED_IFRAME_STATE.NOT_INITIALIZED) {
log("Embed Iframe already initialized");
return;
}
embedStore.state = EMBED_IFRAME_STATE.INITIALIZED;
log("Initializing embed-iframe");
// HACK
const pageStatus = window.CalComPageStatus;
if (!pageStatus || pageStatus == "200") {
keepParentInformedAboutDimensionChanges();
} else
sdkActionManager?.fire("linkFailed", {
code: pageStatus,
msg: "Problem loading the link",
data: {
url: document.URL,
},
});
}
function runAllUiSetters(uiConfig: UiConfig) {
@ -517,3 +599,22 @@ function actOnColorScheme(colorScheme: string | null | undefined) {
}
document.documentElement.style.colorScheme = colorScheme;
}
/**
* Apply configurations to the preloaded page and then ask parent to show the embed
* url has the config as params
*/
function connectPreloadedEmbed({ url }: { url: URL }) {
// TODO: Use a better way to detect that React has initialized. Currently, we are using setTimeout which is a hack.
const MAX_TIME_TO_LET_REACT_APPLY_UI_CHANGES = 700;
// It can be fired before React has initialized, so use embedStore.router(which is a nextRouter wrapper that supports a queue)
embedStore.router.goto(url.toString());
setTimeout(() => {
// Firing this event would stop the loader and show the embed
sdkActionManager?.fire("linkReady", {});
}, MAX_TIME_TO_LET_REACT_APPLY_UI_CHANGES);
}
const isPrerendering = () => {
return new URL(document.URL).searchParams.get("prerender") === "true";
};

View File

@ -2,13 +2,14 @@
import { FloatingButton } from "./FloatingButton/FloatingButton";
import { Inline } from "./Inline/inline";
import { ModalBox } from "./ModalBox/ModalBox";
import type { InterfaceWithParent, interfaceWithParent } from "./embed-iframe";
import type { InterfaceWithParent, interfaceWithParent, PrefillAndIframeAttrsConfig } from "./embed-iframe";
import css from "./embed.css";
import { SdkActionManager } from "./sdk-action-manager";
import type { EventData, EventDataMap } from "./sdk-action-manager";
import allCss from "./tailwind.generated.css?inline";
import type { UiConfig, EmbedThemeConfig, BookerLayouts } from "./types";
import type { UiConfig } from "./types";
export type { PrefillAndIframeAttrsConfig } from "./embed-iframe";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Rest<T extends any[]> = T extends [any, ...infer U] ? U : never;
export type Message = {
@ -151,34 +152,14 @@ type SingleInstruction = SingleInstructionMap[keyof SingleInstructionMap];
export type Instruction = SingleInstruction | SingleInstruction[];
export type InstructionQueue = Instruction[];
/**
* All types of config that are critical to be processed as soon as possible are provided as query params to the iframe
*/
export type PrefillAndIframeAttrsConfig = Record<string, string | string[] | Record<string, string>> & {
// TODO: iframeAttrs shouldn't be part of it as that configures the iframe element and not the iframed app.
iframeAttrs?: Record<string, string> & {
id?: string;
};
// TODO: It should have a dedicated prefill prop
// prefill: {},
// TODO: Move layout and theme as nested props of ui as it makes it clear that these two can be configured using `ui` instruction as well any time.
// ui: {layout; theme}
layout?: BookerLayouts;
// TODO: Rename layout and theme as ui.layout and ui.theme as it makes it clear that these two can be configured using `ui` instruction as well any time.
"ui.color-scheme"?: string;
theme?: EmbedThemeConfig;
};
export class Cal {
iframe?: HTMLIFrameElement;
__config: Config;
modalBox!: Element;
modalBox?: Element;
inlineEl!: Element;
inlineEl?: Element;
namespace: string;
@ -190,6 +171,8 @@ export class Cal {
api: CalApi;
isPerendering?: boolean;
static actionsManagers: Record<Namespace, SdkActionManager>;
static getQueryObject(config: PrefillAndIframeAttrsConfig) {
@ -389,6 +372,9 @@ export class Cal {
});
this.actionManager.on("__routeChanged", () => {
if (!this.inlineEl) {
return;
}
const { top, height } = this.inlineEl.getBoundingClientRect();
// Try to readjust and scroll into view if more than 25% is hidden.
// Otherwise we assume that user might have positioned the content appropriately already
@ -398,6 +384,10 @@ export class Cal {
});
this.actionManager.on("linkReady", () => {
if (this.isPerendering) {
// Absolute check to ensure that we don't mark embed as loaded if it's prerendering otherwise prerendered embed would showup without any user action
return;
}
this.modalBox?.setAttribute("state", "loaded");
this.inlineEl?.setAttribute("loading", "done");
});
@ -418,6 +408,8 @@ export class Cal {
class CalApi {
cal: Cal;
static initializedNamespaces = [] as string[];
modalUid?: string;
preloadedModalUid?: string;
constructor(cal: Cal) {
this.cal = cal;
}
@ -563,41 +555,71 @@ class CalApi {
modal({
calLink,
config = {},
uid,
__prerender = false,
}: {
calLink: string;
config?: PrefillAndIframeAttrsConfig;
uid?: string | number;
calOrigin?: string;
__prerender?: boolean;
}) {
uid = uid || 0;
const uid = this.modalUid || this.preloadedModalUid || String(Date.now()) || "0";
const isConnectingToPreloadedModal = this.preloadedModalUid && !this.modalUid;
const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`);
if (existingModalEl) {
existingModalEl.setAttribute("state", "started");
return;
const containerEl = document.body;
this.cal.isPerendering = !!__prerender;
if (__prerender) {
// Add preload query param
config.prerender = "true";
}
const queryObject = withColorScheme(Cal.getQueryObject(config), containerEl);
const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`);
if (existingModalEl) {
if (isConnectingToPreloadedModal) {
this.cal.doInIframe({
method: "connect",
arg: queryObject,
});
this.modalUid = uid;
existingModalEl.setAttribute("state", "loading");
return;
} else {
existingModalEl.setAttribute("state", "reopening");
return;
}
}
if (__prerender) {
this.preloadedModalUid = uid;
}
if (typeof config.iframeAttrs === "string" || config.iframeAttrs instanceof Array) {
throw new Error("iframeAttrs should be an object");
}
config.embedType = "modal";
const containerEl = document.body;
const iframe = this.cal.createIframe({
calLink,
queryObject: withColorScheme(Cal.getQueryObject(config), containerEl),
});
let iframe = null;
if (!iframe) {
iframe = this.cal.createIframe({
calLink,
queryObject,
});
}
iframe.style.borderRadius = "8px";
iframe.style.height = "100%";
iframe.style.width = "100%";
const template = document.createElement("template");
template.innerHTML = `<cal-modal-box uid="${uid}"></cal-modal-box>`;
this.cal.modalBox = template.content.children[0];
this.cal.modalBox.appendChild(iframe);
if (__prerender) {
this.cal.modalBox.setAttribute("state", "prerendering");
}
this.handleClose();
containerEl.appendChild(template.content);
}
@ -605,7 +627,7 @@ class CalApi {
private handleClose() {
// A request, to close from the iframe, should close the modal
this.cal.actionManager.on("__closeIframe", () => {
this.cal.modalBox.setAttribute("state", "closed");
this.cal.modalBox?.setAttribute("state", "closed");
});
}
@ -642,8 +664,24 @@ class CalApi {
}) {
this.cal.actionManager.off(action, callback);
}
preload({ calLink }: { calLink: string }) {
/**
*
* type is provided and prerenderIframe not set. We would assume prerenderIframe to be true
* type is provided and prerenderIframe set to false. We would ignore the type and preload assets only
* type is not provided and prerenderIframe set to true. We would throw error as we don't know what to prerender
* type is not provided and prerenderIframe set to false. We would preload assets only
*/
preload({
calLink,
type,
options = {},
}: {
calLink: string;
type?: "modal" | "floatingButton";
options?: {
prerenderIframe?: boolean;
};
}) {
// eslint-disable-next-line prefer-rest-params
validate(arguments[0], {
required: true,
@ -652,17 +690,58 @@ class CalApi {
type: "string",
required: true,
},
type: {
type: "string",
required: false,
},
options: {
type: Object,
required: false,
},
},
});
const iframe = document.body.appendChild(document.createElement("iframe"));
const config = this.cal.getConfig();
let api: GlobalCalWithoutNs = globalCal;
const namespace = this.cal.namespace;
if (namespace) {
api = globalCal.ns[namespace];
}
const urlInstance = new URL(`${config.calOrigin}/${calLink}`);
urlInstance.searchParams.set("prerender", "true");
iframe.src = urlInstance.toString();
iframe.style.width = "0";
iframe.style.height = "0";
iframe.style.display = "none";
if (!api) {
throw new Error(`Namespace ${namespace} isn't defined`);
}
const config = this.cal.getConfig();
let prerenderIframe = options.prerenderIframe;
if (type && prerenderIframe === undefined) {
prerenderIframe = true;
}
if (!type && prerenderIframe) {
throw new Error("You should provide 'type'");
}
if (prerenderIframe) {
if (type === "modal" || type === "floatingButton") {
this.cal.isPerendering = true;
this.modal({
calLink,
calOrigin: config.calOrigin,
__prerender: true,
});
} else {
console.warn("Ignoring - full preload for inline embed and instead preloading assets only");
preloadAssetsForCalLink({ calLink, config });
}
} else {
preloadAssetsForCalLink({ calLink, config });
}
}
prerender({ calLink, type }: { calLink: string; type: "modal" | "floatingButton" }) {
this.preload({
calLink,
type,
});
}
ui(uiConfig: UiConfig) {
@ -755,7 +834,6 @@ document.addEventListener("click", (e) => {
return;
}
const modalUniqueId = (targetEl.dataset.uniqueId = targetEl.dataset.uniqueId || String(Date.now()));
const namespace = targetEl.dataset.calNamespace;
const configString = targetEl.dataset.calConfig || "";
const calOrigin = targetEl.dataset.calOrigin || "";
@ -779,7 +857,6 @@ document.addEventListener("click", (e) => {
api("modal", {
calLink: path,
config,
uid: modalUniqueId,
calOrigin,
});
});
@ -812,3 +889,14 @@ function getEmbedApiFn(ns: string) {
}
return api;
}
function preloadAssetsForCalLink({ config, calLink }: { config: Config; calLink: string }) {
const iframe = document.body.appendChild(document.createElement("iframe"));
const urlInstance = new URL(`${config.calOrigin}/${calLink}`);
urlInstance.searchParams.set("preload", "true");
iframe.src = urlInstance.toString();
iframe.style.width = "0";
iframe.style.height = "0";
iframe.style.display = "none";
}

View File

@ -6,8 +6,7 @@ import { test } from "@calcom/web/playwright/lib/fixtures";
test.describe("Inline Embed", () => {
test("should verify that the iframe got created with correct URL", async ({
page,
getActionFiredDetails,
addEmbedListeners,
embeds: { getActionFiredDetails, addEmbedListeners },
}) => {
//TODO: Do it with page.goto automatically
await addEmbedListeners("");

View File

@ -133,7 +133,7 @@ export const AvailableTimeSlots = ({
: slotsPerDay.length > 0 &&
slotsPerDay.map((slots) => (
<AvailableTimes
className="scroll-bar w-full overflow-auto"
className="scroll-bar w-full overflow-y-auto overflow-x-hidden"
key={slots.date}
showTimeFormatToggle={!isColumnView}
onTimeSelect={onTimeSelect}

View File

@ -1,5 +1,6 @@
import { m } from "framer-motion";
import dynamic from "next/dynamic";
import { useEffect } from "react";
import { shallow } from "zustand/shallow";
import { useEmbedUiConfig, useIsEmbed } from "@calcom/embed-core/embed-iframe";
@ -22,6 +23,7 @@ const TimezoneSelect = dynamic(() => import("@calcom/ui").then((mod) => mod.Time
export const EventMeta = () => {
const { setTimezone, timeFormat, timezone } = useTimePreferences();
const selectedDuration = useBookerStore((state) => state.selectedDuration);
const setSelectedDuration = useBookerStore((state) => state.setSelectedDuration);
const selectedTimeslot = useBookerStore((state) => state.selectedTimeslot);
const bookerState = useBookerStore((state) => state.state);
const bookingData = useBookerStore((state) => state.bookingData);
@ -36,6 +38,13 @@ export const EventMeta = () => {
const isEmbed = useIsEmbed();
const hideEventTypeDetails = isEmbed ? embedUiConfig.hideEventTypeDetails : false;
useEffect(() => {
if (!selectedDuration && event?.length) {
setSelectedDuration(event.length);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [event?.length, selectedDuration]);
if (hideEventTypeDetails) {
return null;
}

View File

@ -11,6 +11,7 @@ import { Calendar, Columns, Grid } from "@calcom/ui/components/icon";
import { TimeFormatToggle } from "../../components/TimeFormatToggle";
import { useBookerStore } from "../store";
import type { BookerLayout } from "../types";
import { OverlayCalendarContainer } from "./OverlayCalendar/OverlayCalendarContainer";
export function Header({
extraDays,
@ -56,7 +57,12 @@ export function Header({
// In month view we only show the layout toggle.
if (isMonthView) {
return <LayoutToggleWithData />;
return (
<div className="flex gap-2">
<OverlayCalendarContainer />
<LayoutToggleWithData />
</div>
);
}
const endDate = selectedDate.add(layout === BookerLayouts.COLUMN_VIEW ? extraDays : extraDays - 1, "days");
@ -113,6 +119,7 @@ export function Header({
</ButtonGroup>
</div>
<div className="ml-auto flex gap-2">
<OverlayCalendarContainer />
<TimeFormatToggle />
<div className="fixed top-4 ltr:right-4 rtl:left-4">
<LayoutToggleWithData />

View File

@ -1,20 +1,25 @@
import { useMemo } from "react";
import { useMemo, useEffect } from "react";
import dayjs from "@calcom/dayjs";
import { Calendar } from "@calcom/features/calendars/weeklyview";
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
import type { CalendarAvailableTimeslots } from "@calcom/features/calendars/weeklyview/types/state";
import { useBookerStore } from "../store";
import { useEvent, useScheduleForEvent } from "../utils/event";
import { getQueryParam } from "../utils/query-param";
import { useOverlayCalendarStore } from "./OverlayCalendar/store";
export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
const selectedDate = useBookerStore((state) => state.selectedDate);
const date = selectedDate || dayjs().format("YYYY-MM-DD");
const setSelectedTimeslot = useBookerStore((state) => state.setSelectedTimeslot);
const selectedEventDuration = useBookerStore((state) => state.selectedDuration);
const overlayEvents = useOverlayCalendarStore((state) => state.overlayBusyDates);
const schedule = useScheduleForEvent({
prefetchNextMonth: !!extraDays && dayjs(date).month() !== dayjs(date).add(extraDays, "day").month(),
});
const displayOverlay = getQueryParam("overlayCalendar") === "true";
const event = useEvent();
const eventDuration = selectedEventDuration || event?.data?.length || 30;
@ -39,6 +44,24 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
.add(extraDays - 1, "day")
.toDate();
// HACK: force rerender when overlay events change
// Sine we dont use react router here we need to force rerender (ATOM SUPPORT)
// eslint-disable-next-line @typescript-eslint/no-empty-function
useEffect(() => {}, [displayOverlay]);
const overlayEventsForDate = useMemo(() => {
if (!overlayEvents || !displayOverlay) return [];
return overlayEvents.map((event, id) => {
return {
id,
start: dayjs(event.start).toDate(),
end: dayjs(event.end).toDate(),
title: "Busy",
status: "ACCEPTED",
} as CalendarEvent;
});
}, [overlayEvents, displayOverlay]);
return (
<div className="h-full [--calendar-dates-sticky-offset:66px]">
<Calendar
@ -46,7 +69,7 @@ export const LargeCalendar = ({ extraDays }: { extraDays: number }) => {
availableTimeslots={availableSlots}
startHour={0}
endHour={23}
events={[]}
events={overlayEventsForDate}
startDate={startDate}
endDate={endDate}
onEmptyCellClick={(date) => setSelectedTimeslot(date.toISOString())}

View File

@ -0,0 +1,154 @@
import { useSession } from "next-auth/react";
import { useRouter, useSearchParams, usePathname } from "next/navigation";
import { useState, useCallback, useEffect } from "react";
import dayjs from "@calcom/dayjs";
import { useTimePreferences } from "@calcom/features/bookings/lib";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button, Switch } from "@calcom/ui";
import { Settings } from "@calcom/ui/components/icon";
import { useBookerStore } from "../../store";
import { OverlayCalendarContinueModal } from "../OverlayCalendar/OverlayCalendarContinueModal";
import { OverlayCalendarSettingsModal } from "../OverlayCalendar/OverlayCalendarSettingsModal";
import { useLocalSet } from "../hooks/useLocalSet";
import { useOverlayCalendarStore } from "./store";
export function OverlayCalendarContainer() {
const { t } = useLocale();
const [continueWithProvider, setContinueWithProvider] = useState(false);
const [calendarSettingsOverlay, setCalendarSettingsOverlay] = useState(false);
const { data: session } = useSession();
const setOverlayBusyDates = useOverlayCalendarStore((state) => state.setOverlayBusyDates);
const layout = useBookerStore((state) => state.layout);
const selectedDate = useBookerStore((state) => state.selectedDate);
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
const { timezone } = useTimePreferences();
// Move this to a hook
const { set, clearSet } = useLocalSet<{
credentialId: number;
externalId: string;
}>("toggledConnectedCalendars", []);
const overlayCalendarQueryParam = searchParams.get("overlayCalendar");
const { data: overlayBusyDates } = trpc.viewer.availability.calendarOverlay.useQuery(
{
loggedInUsersTz: timezone || "Europe/London",
dateFrom: selectedDate,
dateTo: selectedDate,
calendarsToLoad: Array.from(set).map((item) => ({
credentialId: item.credentialId,
externalId: item.externalId,
})),
},
{
enabled: !!session && set.size > 0 && overlayCalendarQueryParam === "true",
onError: () => {
clearSet();
},
}
);
useEffect(() => {
if (overlayBusyDates) {
const nowDate = dayjs();
const usersTimezoneDate = nowDate.tz(timezone);
const offset = (usersTimezoneDate.utcOffset() - nowDate.utcOffset()) / 60;
const offsettedArray = overlayBusyDates.map((item) => {
return {
...item,
start: dayjs(item.start).add(offset, "hours").toDate(),
end: dayjs(item.end).add(offset, "hours").toDate(),
};
});
setOverlayBusyDates(offsettedArray);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [overlayBusyDates]);
// Toggle query param for overlay calendar
const toggleOverlayCalendarQueryParam = useCallback(
(state: boolean) => {
const current = new URLSearchParams(Array.from(searchParams.entries()));
if (state) {
current.set("overlayCalendar", "true");
} else {
current.delete("overlayCalendar");
}
// cast to string
const value = current.toString();
const query = value ? `?${value}` : "";
router.push(`${pathname}${query}`);
},
[searchParams, pathname, router]
);
/**
* If a user is not logged in and the overlay calendar query param is true,
* show the continue modal so they can login / create an account
*/
useEffect(() => {
if (!session && overlayCalendarQueryParam === "true") {
toggleOverlayCalendarQueryParam(false);
setContinueWithProvider(true);
}
}, [session, overlayCalendarQueryParam, toggleOverlayCalendarQueryParam]);
return (
<>
<div className={classNames("hidden gap-2", layout === "week_view" ? "lg:flex" : "md:flex")}>
<div className="flex items-center gap-2 pr-2">
<Switch
data-testid="overlay-calendar-switch"
checked={overlayCalendarQueryParam === "true"}
id="overlayCalendar"
onCheckedChange={(state) => {
if (!session) {
setContinueWithProvider(state);
} else {
toggleOverlayCalendarQueryParam(state);
}
}}
/>
<label
htmlFor="overlayCalendar"
className="text-emphasis text-sm font-medium leading-none hover:cursor-pointer">
{t("overlay_my_calendar")}
</label>
</div>
{session && (
<Button
size="base"
data-testid="overlay-calendar-settings-button"
variant="icon"
color="secondary"
StartIcon={Settings}
onClick={() => {
setCalendarSettingsOverlay(true);
}}
/>
)}
</div>
<OverlayCalendarContinueModal
open={continueWithProvider}
onClose={(val) => {
setContinueWithProvider(val);
}}
/>
<OverlayCalendarSettingsModal
open={calendarSettingsOverlay}
onClose={(val) => {
setCalendarSettingsOverlay(val);
}}
/>
</>
);
}

View File

@ -0,0 +1,47 @@
import { CalendarSearch } from "lucide-react";
import { useRouter } from "next/navigation";
import { APP_NAME } from "@calcom/lib/constants";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, Dialog, DialogContent, DialogFooter } from "@calcom/ui";
interface IOverlayCalendarContinueModalProps {
open?: boolean;
onClose?: (state: boolean) => void;
}
export function OverlayCalendarContinueModal(props: IOverlayCalendarContinueModalProps) {
const router = useRouter();
const { t } = useLocale();
return (
<>
<Dialog open={props.open} onOpenChange={props.onClose}>
<DialogContent
type="creation"
title={t("overlay_my_calendar")}
description={t("overlay_my_calendar_toc")}>
<div className="flex flex-col gap-2">
<Button
data-testid="overlay-calendar-continue-button"
onClick={() => {
const currentUrl = new URL(window.location.href);
currentUrl.pathname = "/login/";
currentUrl.searchParams.set("callbackUrl", window.location.pathname);
currentUrl.searchParams.set("overlayCalendar", "true");
router.push(currentUrl.toString());
}}
className="gap w-full items-center justify-center font-semibold"
StartIcon={CalendarSearch}>
{t("continue_with", { appName: APP_NAME })}
</Button>
</div>
<DialogFooter>
{/* Agh modal hacks */}
<></>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -0,0 +1,155 @@
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Fragment } from "react";
import { classNames } from "@calcom/lib";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import {
Alert,
Dialog,
DialogContent,
EmptyScreen,
ListItem,
ListItemText,
ListItemTitle,
Switch,
DialogClose,
SkeletonContainer,
SkeletonText,
} from "@calcom/ui";
import { Calendar } from "@calcom/ui/components/icon";
import { useLocalSet } from "../hooks/useLocalSet";
import { useOverlayCalendarStore } from "./store";
interface IOverlayCalendarContinueModalProps {
open?: boolean;
onClose?: (state: boolean) => void;
}
const SkeletonLoader = () => {
return (
<SkeletonContainer>
<div className="border-subtle mt-3 space-y-4 rounded-xl border px-4 py-4 ">
<SkeletonText className="h-4 w-full" />
<SkeletonText className="h-4 w-full" />
<SkeletonText className="h-4 w-full" />
<SkeletonText className="h-4 w-full" />
</div>
</SkeletonContainer>
);
};
export function OverlayCalendarSettingsModal(props: IOverlayCalendarContinueModalProps) {
const utils = trpc.useContext();
const setOverlayBusyDates = useOverlayCalendarStore((state) => state.setOverlayBusyDates);
const { data, isLoading } = trpc.viewer.connectedCalendars.useQuery(undefined, {
enabled: !!props.open,
});
const { toggleValue, hasItem } = useLocalSet<{
credentialId: number;
externalId: string;
}>("toggledConnectedCalendars", []);
const router = useRouter();
const { t } = useLocale();
return (
<>
<Dialog open={props.open} onOpenChange={props.onClose}>
<DialogContent
enableOverflow
type="creation"
title="Calendar Settings"
className="pb-4"
description={t("view_overlay_calendar_events")}>
<div className="no-scrollbar max-h-full overflow-y-scroll ">
{isLoading ? (
<SkeletonLoader />
) : (
<>
{data?.connectedCalendars.length === 0 ? (
<EmptyScreen
Icon={Calendar}
headline={t("no_calendar_installed")}
description={t("no_calendar_installed_description")}
buttonText={t("add_a_calendar")}
buttonOnClick={() => router.push("/apps/categories/calendar")}
/>
) : (
<>
{data?.connectedCalendars.map((item) => (
<Fragment key={item.credentialId}>
{item.error && !item.calendars && (
<Alert severity="error" title={item.error.message} />
)}
{item?.error === undefined && item.calendars && (
<ListItem className="flex-col rounded-md">
<div className="flex w-full flex-1 items-center space-x-3 pb-4 rtl:space-x-reverse">
{
// eslint-disable-next-line @next/next/no-img-element
item.integration.logo && (
<img
className={classNames(
"h-10 w-10",
item.integration.logo.includes("-dark") && "dark:invert"
)}
src={item.integration.logo}
alt={item.integration.title}
/>
)
}
<div className="flex-grow truncate pl-2">
<ListItemTitle component="h3" className="space-x-2 rtl:space-x-reverse">
<Link href={`/apps/${item.integration.slug}`}>
{item.integration.name || item.integration.title}
</Link>
</ListItemTitle>
<ListItemText component="p">{item.primary.email}</ListItemText>
</div>
</div>
<div className="border-subtle w-full border-t pt-4">
<ul className="space-y-4">
{item.calendars.map((cal, index) => {
const id = cal.integrationTitle ?? `calendar-switch-${index}`;
return (
<li className="flex gap-3" key={id}>
<Switch
id={id}
checked={hasItem({
credentialId: item.credentialId,
externalId: cal.externalId,
})}
onCheckedChange={() => {
toggleValue({
credentialId: item.credentialId,
externalId: cal.externalId,
});
setOverlayBusyDates([]);
utils.viewer.availability.calendarOverlay.reset();
}}
/>
<label htmlFor={id}>{cal.name}</label>
</li>
);
})}
</ul>
</div>
</ListItem>
)}
</Fragment>
))}
</>
)}
</>
)}
</div>
<div className="mt-4 flex gap-2 self-end">
<DialogClose>{t("done")}</DialogClose>
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@ -0,0 +1,15 @@
import { create } from "zustand";
import type { EventBusyDate } from "@calcom/types/Calendar";
interface IOverlayCalendarStore {
overlayBusyDates: EventBusyDate[] | undefined;
setOverlayBusyDates: (busyDates: EventBusyDate[]) => void;
}
export const useOverlayCalendarStore = create<IOverlayCalendarStore>((set) => ({
overlayBusyDates: undefined,
setOverlayBusyDates: (busyDates: EventBusyDate[]) => {
set({ overlayBusyDates: busyDates });
},
}));

View File

@ -0,0 +1,64 @@
import { useEffect, useState } from "react";
export interface HasExternalId {
externalId: string;
}
export function useLocalSet<T extends HasExternalId>(key: string, initialValue: T[]) {
const [set, setSet] = useState<Set<T>>(() => {
const storedValue = localStorage.getItem(key);
return storedValue ? new Set(JSON.parse(storedValue)) : new Set(initialValue);
});
useEffect(() => {
localStorage.setItem(key, JSON.stringify(Array.from(set)));
}, [key, set]);
const addValue = (value: T) => {
setSet((prevSet) => new Set(prevSet).add(value));
};
const removeById = (id: string) => {
setSet((prevSet) => {
const updatedSet = new Set(prevSet);
updatedSet.forEach((item) => {
if (item.externalId === id) {
updatedSet.delete(item);
}
});
return updatedSet;
});
};
const toggleValue = (value: T) => {
setSet((prevSet) => {
const updatedSet = new Set(prevSet);
let itemFound = false;
updatedSet.forEach((item) => {
if (item.externalId === value.externalId) {
itemFound = true;
updatedSet.delete(item);
}
});
if (!itemFound) {
updatedSet.add(value);
}
return updatedSet;
});
};
const hasItem = (value: T) => {
return Array.from(set).some((item) => item.externalId === value.externalId);
};
const clearSet = () => {
setSet(() => new Set());
// clear local storage too
localStorage.removeItem(key);
};
return { set, addValue, removeById, toggleValue, hasItem, clearSet };
}

View File

@ -28,6 +28,17 @@ export const fadeInUp = {
transition: { ease: "easeInOut", delay: 0.1 },
};
export const fadeInRight = {
variants: {
visible: { opacity: 1, x: 0 },
hidden: { opacity: 0, x: -20 },
},
initial: "hidden",
exit: "hidden",
animate: "visible",
transition: { ease: "easeInOut", delay: 0.1 },
};
type ResizeAnimationConfig = {
[key in BookerLayout]: {
[key in BookerState | "default"]?: React.CSSProperties;

View File

@ -1,4 +1,8 @@
import { CalendarX2 } from "lucide-react";
// We do not need to worry about importing framer-motion here as it is lazy imported in Booker.
import * as HoverCard from "@radix-ui/react-hover-card";
import { AnimatePresence, m } from "framer-motion";
import { CalendarX2, ChevronRight } from "lucide-react";
import { useCallback, useState } from "react";
import dayjs from "@calcom/dayjs";
import type { Slots } from "@calcom/features/schedules";
@ -7,17 +11,21 @@ import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button, SkeletonText } from "@calcom/ui";
import { useBookerStore } from "../Booker/store";
import { getQueryParam } from "../Booker/utils/query-param";
import { useTimePreferences } from "../lib";
import { useCheckOverlapWithOverlay } from "../lib/useCheckOverlapWithOverlay";
import { SeatsAvailabilityText } from "./SeatsAvailabilityText";
type TOnTimeSelect = (
time: string,
attendees: number,
seatsPerTimeSlot?: number | null,
bookingUid?: string
) => void;
type AvailableTimesProps = {
slots: Slots[string];
onTimeSelect: (
time: string,
attendees: number,
seatsPerTimeSlot?: number | null,
bookingUid?: string
) => void;
onTimeSelect: TOnTimeSelect;
seatsPerTimeSlot?: number | null;
showAvailableSeatsCount?: boolean | null;
showTimeFormatToggle?: boolean;
@ -25,6 +33,148 @@ type AvailableTimesProps = {
selectedSlots?: string[];
};
const SlotItem = ({
slot,
seatsPerTimeSlot,
selectedSlots,
onTimeSelect,
showAvailableSeatsCount,
}: {
slot: Slots[string][number];
seatsPerTimeSlot?: number | null;
selectedSlots?: string[];
onTimeSelect: TOnTimeSelect;
showAvailableSeatsCount?: boolean | null;
}) => {
const { t } = useLocale();
const overlayCalendarToggled = getQueryParam("overlayCalendar") === "true";
const [timeFormat, timezone] = useTimePreferences((state) => [state.timeFormat, state.timezone]);
const selectedDuration = useBookerStore((state) => state.selectedDuration);
const bookingData = useBookerStore((state) => state.bookingData);
const layout = useBookerStore((state) => state.layout);
const hasTimeSlots = !!seatsPerTimeSlot;
const computedDateWithUsersTimezone = dayjs.utc(slot.time).tz(timezone);
const bookingFull = !!(hasTimeSlots && slot.attendees && slot.attendees >= seatsPerTimeSlot);
const isHalfFull = slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.5;
const isNearlyFull = slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.83;
const colorClass = isNearlyFull ? "bg-rose-600" : isHalfFull ? "bg-yellow-500" : "bg-emerald-400";
const nowDate = dayjs();
const usersTimezoneDate = nowDate.tz(timezone);
const offset = (usersTimezoneDate.utcOffset() - nowDate.utcOffset()) / 60;
const { isOverlapping, overlappingTimeEnd, overlappingTimeStart } = useCheckOverlapWithOverlay(
computedDateWithUsersTimezone,
selectedDuration,
offset
);
const [overlapConfirm, setOverlapConfirm] = useState(false);
const onButtonClick = useCallback(() => {
if (!overlayCalendarToggled) {
onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid);
return;
}
if (isOverlapping && overlapConfirm) {
setOverlapConfirm(false);
return;
}
if (isOverlapping && !overlapConfirm) {
setOverlapConfirm(true);
return;
}
if (!overlapConfirm) {
onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid);
}
}, [
overlayCalendarToggled,
isOverlapping,
overlapConfirm,
onTimeSelect,
slot.time,
slot?.attendees,
slot.bookingUid,
seatsPerTimeSlot,
]);
return (
<AnimatePresence>
<div className="flex gap-2">
<Button
key={slot.time}
disabled={bookingFull || !!(slot.bookingUid && slot.bookingUid === bookingData?.uid)}
data-testid="time"
data-disabled={bookingFull}
data-time={slot.time}
onClick={onButtonClick}
className={classNames(
"min-h-9 hover:border-brand-default mb-2 flex h-auto w-full flex-grow flex-col justify-center py-2",
selectedSlots?.includes(slot.time) && "border-brand-default"
)}
color="secondary">
<div className="flex items-center gap-2">
{!hasTimeSlots && overlayCalendarToggled && (
<span
className={classNames(
"inline-block h-2 w-2 rounded-full",
isOverlapping ? "bg-rose-600" : "bg-emerald-400"
)}
/>
)}
{computedDateWithUsersTimezone.format(timeFormat)}
</div>
{bookingFull && <p className="text-sm">{t("booking_full")}</p>}
{hasTimeSlots && !bookingFull && (
<p className="flex items-center text-sm">
<span
className={classNames(colorClass, "mr-1 inline-block h-2 w-2 rounded-full")}
aria-hidden
/>
<SeatsAvailabilityText
showExact={!!showAvailableSeatsCount}
totalSeats={seatsPerTimeSlot}
bookedSeats={slot.attendees || 0}
/>
</p>
)}
</Button>
{overlapConfirm && isOverlapping && (
<HoverCard.Root>
<HoverCard.Trigger asChild>
<m.div initial={{ width: 0 }} animate={{ width: "auto" }} exit={{ width: 0 }}>
<Button
variant={layout === "column_view" ? "icon" : "button"}
StartIcon={layout === "column_view" ? ChevronRight : undefined}
onClick={() =>
onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid)
}>
{layout !== "column_view" && t("confirm")}
</Button>
</m.div>
</HoverCard.Trigger>
<HoverCard.Portal>
<HoverCard.Content side="top" align="end" sideOffset={2}>
<div className="text-emphasis bg-inverted text-inverted w-[var(--booker-timeslots-width)] rounded-md p-3">
<div className="flex items-center gap-2">
<p>Busy</p>
</div>
<p className="text-muted">
{overlappingTimeStart} - {overlappingTimeEnd}
</p>
</div>
</HoverCard.Content>
</HoverCard.Portal>
</HoverCard.Root>
)}
</div>
</AnimatePresence>
);
};
export const AvailableTimes = ({
slots,
onTimeSelect,
@ -34,10 +184,7 @@ export const AvailableTimes = ({
className,
selectedSlots,
}: AvailableTimesProps) => {
const { t, i18n } = useLocale();
const [timeFormat, timezone] = useTimePreferences((state) => [state.timeFormat, state.timezone]);
const bookingData = useBookerStore((state) => state.bookingData);
const hasTimeSlots = !!seatsPerTimeSlot;
const { t } = useLocale();
return (
<div className={classNames("text-default flex flex-col", className)}>
@ -50,45 +197,16 @@ export const AvailableTimes = ({
</p>
</div>
)}
{slots.map((slot) => {
const bookingFull = !!(hasTimeSlots && slot.attendees && slot.attendees >= seatsPerTimeSlot);
const isHalfFull = slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.5;
const isNearlyFull =
slot.attendees && seatsPerTimeSlot && slot.attendees / seatsPerTimeSlot >= 0.83;
const colorClass = isNearlyFull ? "bg-rose-600" : isHalfFull ? "bg-yellow-500" : "bg-emerald-400";
return (
<Button
key={slot.time}
disabled={bookingFull || !!(slot.bookingUid && slot.bookingUid === bookingData?.uid)}
data-testid="time"
data-disabled={bookingFull}
data-time={slot.time}
onClick={() => onTimeSelect(slot.time, slot?.attendees || 0, seatsPerTimeSlot, slot.bookingUid)}
className={classNames(
"min-h-9 hover:border-brand-default mb-2 flex h-auto w-full flex-col justify-center py-2",
selectedSlots?.includes(slot.time) && "border-brand-default"
)}
color="secondary">
{dayjs.utc(slot.time).tz(timezone).format(timeFormat)}
{bookingFull && <p className="text-sm">{t("booking_full")}</p>}
{hasTimeSlots && !bookingFull && (
<p className="flex items-center text-sm">
<span
className={classNames(colorClass, "mr-1 inline-block h-2 w-2 rounded-full")}
aria-hidden
/>
<SeatsAvailabilityText
showExact={!!showAvailableSeatsCount}
totalSeats={seatsPerTimeSlot}
bookedSeats={slot.attendees || 0}
/>
</p>
)}
</Button>
);
})}
{slots.map((slot) => (
<SlotItem
key={slot.time}
onTimeSelect={onTimeSelect}
slot={slot}
selectedSlots={selectedSlots}
seatsPerTimeSlot={seatsPerTimeSlot}
showAvailableSeatsCount={showAvailableSeatsCount}
/>
))}
</div>
</div>
);

View File

@ -379,7 +379,6 @@ async function ensureAvailableUsers(
)
: undefined;
log.debug("getUserAvailability for users", JSON.stringify({ users: eventType.users.map((u) => u.id) }));
/** Let's start checking for availability */
for (const user of eventType.users) {
const { dateRanges, busy: bufferedBusyTimes } = await getUserAvailability(
@ -968,7 +967,7 @@ async function handler(
if (
availableUsers.filter((user) => user.isFixed).length !== users.filter((user) => user.isFixed).length
) {
throw new Error("Some users are unavailable for booking.");
throw new Error("Some of the hosts are unavailable for booking.");
}
// Pushing fixed user before the luckyUser guarantees the (first) fixed user as the organizer.
users = [...availableUsers.filter((user) => user.isFixed), ...luckyUsers];

View File

@ -0,0 +1,7 @@
import { describe } from "vitest";
import { test } from "@calcom/web/test/fixtures/fixtures";
describe("Booking Limits", () => {
test.todo("Test these cases that were failing earlier https://github.com/calcom/cal.com/pull/10480");
});

View File

@ -0,0 +1,10 @@
import { describe } from "vitest";
import { test } from "@calcom/web/test/fixtures/fixtures";
import { setupAndTeardown } from "./lib/setupAndTeardown";
describe("handleNewBooking", () => {
setupAndTeardown();
test.todo("Dynamic Group Booking");
});

View File

@ -0,0 +1,7 @@
import { createMocks } from "node-mocks-http";
import type { CustomNextApiRequest, CustomNextApiResponse } from "../fresh-booking.test";
export function createMockNextJsRequest(...args: Parameters<typeof createMocks>) {
return createMocks<CustomNextApiRequest, CustomNextApiResponse>(...args);
}

View File

@ -0,0 +1,34 @@
import { getDate } from "@calcom/web/test/utils/bookingScenario/bookingScenario";
export function getBasicMockRequestDataForBooking() {
return {
start: `${getDate({ dateIncrement: 1 }).dateString}T04:00:00.000Z`,
end: `${getDate({ dateIncrement: 1 }).dateString}T04:30:00.000Z`,
eventTypeSlug: "no-confirmation",
timeZone: "Asia/Calcutta",
language: "en",
user: "teampro",
metadata: {},
hasHashedBookingLink: false,
hashedLink: null,
};
}
export function getMockRequestDataForBooking({
data,
}: {
data: Partial<ReturnType<typeof getBasicMockRequestDataForBooking>> & {
eventTypeId: number;
rescheduleUid?: string;
bookingUid?: string;
responses: {
email: string;
name: string;
location: { optionValue: ""; value: string };
};
};
}) {
return {
...getBasicMockRequestDataForBooking(),
...data,
};
}

View File

@ -0,0 +1,29 @@
import { beforeEach, afterEach } from "vitest";
import {
enableEmailFeature,
mockNoTranslations,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
export function setupAndTeardown() {
beforeEach(() => {
// Required to able to generate token in email in some cases
process.env.CALENDSO_ENCRYPTION_KEY = "abcdefghjnmkljhjklmnhjklkmnbhjui";
process.env.STRIPE_WEBHOOK_SECRET = "MOCK_STRIPE_WEBHOOK_SECRET";
// We are setting it in vitest.config.ts because otherwise it's too late to set it.
// process.env.DAILY_API_KEY = "MOCK_DAILY_API_KEY";
mockNoTranslations();
// mockEnableEmailFeature();
enableEmailFeature();
globalThis.testEmails = [];
fetchMock.resetMocks();
});
afterEach(() => {
delete process.env.CALENDSO_ENCRYPTION_KEY;
delete process.env.STRIPE_WEBHOOK_SECRET;
delete process.env.DAILY_API_KEY;
globalThis.testEmails = [];
fetchMock.resetMocks();
// process.env.DAILY_API_KEY = "MOCK_DAILY_API_KEY";
});
}

View File

@ -0,0 +1,11 @@
import { describe } from "vitest";
import { test } from "@calcom/web/test/fixtures/fixtures";
import { setupAndTeardown } from "./lib/setupAndTeardown";
describe("handleNewBooking", () => {
setupAndTeardown();
test.todo("Managed Event Type booking");
});

View File

@ -0,0 +1,608 @@
import prismaMock from "../../../../../../tests/libs/__mocks__/prisma";
import { describe, expect } from "vitest";
import { appStoreMetadata } from "@calcom/app-store/apps.metadata.generated";
import { WEBAPP_URL } from "@calcom/lib/constants";
import logger from "@calcom/lib/logger";
import { BookingStatus } from "@calcom/prisma/enums";
import { test } from "@calcom/web/test/fixtures/fixtures";
import {
createBookingScenario,
getDate,
getGoogleCalendarCredential,
TestData,
getOrganizer,
getBooker,
getScenarioData,
mockSuccessfulVideoMeetingCreation,
mockCalendarToHaveNoBusySlots,
mockCalendarToCrashOnUpdateEvent,
BookingLocations,
} from "@calcom/web/test/utils/bookingScenario/bookingScenario";
import {
expectWorkflowToBeTriggered,
expectBookingToBeInDatabase,
expectBookingRescheduledWebhookToHaveBeenFired,
expectSuccessfulBookingRescheduledEmails,
expectSuccessfulCalendarEventUpdationInCalendar,
expectSuccessfulVideoMeetingUpdationInCalendar,
expectBookingInDBToBeRescheduledFromTo,
} from "@calcom/web/test/utils/bookingScenario/expects";
import { createMockNextJsRequest } from "./lib/createMockNextJsRequest";
import { getMockRequestDataForBooking } from "./lib/getMockRequestDataForBooking";
import { setupAndTeardown } from "./lib/setupAndTeardown";
// Local test runs sometime gets too slow
const timeout = process.env.CI ? 5000 : 20000;
describe("handleNewBooking", () => {
setupAndTeardown();
describe("Reschedule", () => {
test(
`should rechedule an existing booking successfully with Cal Video(Daily Video)
1. Should cancel the existing booking
2. Should create a new booking in the database
3. Should send emails to the booker as well as organizer
4. Should trigger BOOKING_RESCHEDULED webhook
`,
async ({ emails }) => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP";
await createBookingScenario(
getScenarioData({
webhooks: [
{
userId: organizer.id,
eventTriggers: ["BOOKING_CREATED"],
subscriberUrl: "http://my-webhook.example.com",
active: true,
eventTypeId: 1,
appId: null,
},
],
eventTypes: [
{
id: 1,
slotInterval: 45,
length: 45,
users: [
{
id: 101,
},
],
},
],
bookings: [
{
uid: uidOfBookingToBeRescheduled,
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:15:00.000Z`,
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
},
{
type: appStoreMetadata.googlecalendar.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASSWORD",
meetingUrl: "https://UNUSED_URL",
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
credentialId: undefined,
},
],
},
],
organizer,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
})
);
const videoMock = mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
});
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
uid: "MOCK_ID",
},
update: {
uid: "UPDATED_MOCK_ID",
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
},
});
const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
rescheduleUid: uidOfBookingToBeRescheduled,
start: `${plus1DateString}T04:00:00.000Z`,
end: `${plus1DateString}T04:15:00.000Z`,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
const { req } = createMockNextJsRequest({
method: "POST",
body: mockBookingData,
});
const createdBooking = await handleNewBooking(req);
const previousBooking = await prismaMock.booking.findUnique({
where: {
uid: uidOfBookingToBeRescheduled,
},
});
logger.silly({
previousBooking,
allBookings: await prismaMock.booking.findMany(),
});
// Expect previous booking to be cancelled
await expectBookingToBeInDatabase({
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
uid: uidOfBookingToBeRescheduled,
status: BookingStatus.CANCELLED,
});
expect(previousBooking?.status).toBe(BookingStatus.CANCELLED);
/**
* Booking Time should be new time
*/
expect(createdBooking.startTime?.toISOString()).toBe(`${plus1DateString}T04:00:00.000Z`);
expect(createdBooking.endTime?.toISOString()).toBe(`${plus1DateString}T04:15:00.000Z`);
await expectBookingInDBToBeRescheduledFromTo({
from: {
uid: uidOfBookingToBeRescheduled,
},
to: {
description: "",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
uid: createdBooking.uid!,
eventTypeId: mockBookingData.eventTypeId,
status: BookingStatus.ACCEPTED,
location: BookingLocations.CalVideo,
responses: expect.objectContaining({
email: booker.email,
name: booker.name,
}),
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
},
{
type: appStoreMetadata.googlecalendar.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASSWORD",
meetingUrl: "https://UNUSED_URL",
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
},
],
},
});
expectWorkflowToBeTriggered();
expectSuccessfulVideoMeetingUpdationInCalendar(videoMock, {
calEvent: {
location: "http://mock-dailyvideo.example.com",
},
bookingRef: {
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
},
});
expectSuccessfulCalendarEventUpdationInCalendar(calendarMock, {
externalCalendarId: "MOCK_EXTERNAL_CALENDAR_ID",
calEvent: {
videoCallData: expect.objectContaining({
url: "http://mock-dailyvideo.example.com",
}),
},
uid: "MOCK_ID",
});
expectSuccessfulBookingRescheduledEmails({
booker,
organizer,
emails,
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
});
expectBookingRescheduledWebhookToHaveBeenFired({
booker,
organizer,
location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`,
});
},
timeout
);
test(
`should rechedule a booking successfully and update the event in the same externalCalendarId as was used in the booking earlier.
1. Should cancel the existing booking
2. Should create a new booking in the database
3. Should send emails to the booker as well as organizer
4. Should trigger BOOKING_RESCHEDULED webhook
`,
async ({ emails }) => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
});
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP";
await createBookingScenario(
getScenarioData({
webhooks: [
{
userId: organizer.id,
eventTriggers: ["BOOKING_CREATED"],
subscriberUrl: "http://my-webhook.example.com",
active: true,
eventTypeId: 1,
appId: null,
},
],
eventTypes: [
{
id: 1,
slotInterval: 45,
length: 45,
users: [
{
id: 101,
},
],
destinationCalendar: {
integration: "google_calendar",
externalId: "event-type-1@example.com",
},
},
],
bookings: [
{
uid: uidOfBookingToBeRescheduled,
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:15:00.000Z`,
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
},
{
type: appStoreMetadata.googlecalendar.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASSWORD",
meetingUrl: "https://UNUSED_URL",
externalCalendarId: "existing-event-type@example.com",
credentialId: undefined,
},
],
},
],
organizer,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
})
);
const videoMock = mockSuccessfulVideoMeetingCreation({
metadataLookupKey: "dailyvideo",
});
const calendarMock = mockCalendarToHaveNoBusySlots("googlecalendar", {
create: {
uid: "MOCK_ID",
},
update: {
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
uid: "UPDATED_MOCK_ID",
},
});
const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
rescheduleUid: uidOfBookingToBeRescheduled,
start: `${plus1DateString}T04:00:00.000Z`,
end: `${plus1DateString}T04:15:00.000Z`,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: BookingLocations.CalVideo },
},
},
});
const { req } = createMockNextJsRequest({
method: "POST",
body: mockBookingData,
});
const createdBooking = await handleNewBooking(req);
/**
* Booking Time should be new time
*/
expect(createdBooking.startTime?.toISOString()).toBe(`${plus1DateString}T04:00:00.000Z`);
expect(createdBooking.endTime?.toISOString()).toBe(`${plus1DateString}T04:15:00.000Z`);
await expectBookingInDBToBeRescheduledFromTo({
from: {
uid: uidOfBookingToBeRescheduled,
},
to: {
description: "",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
uid: createdBooking.uid!,
eventTypeId: mockBookingData.eventTypeId,
status: BookingStatus.ACCEPTED,
location: BookingLocations.CalVideo,
responses: expect.objectContaining({
email: booker.email,
name: booker.name,
}),
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
},
{
type: appStoreMetadata.googlecalendar.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASSWORD",
meetingUrl: "https://UNUSED_URL",
externalCalendarId: "existing-event-type@example.com",
},
],
},
});
expectWorkflowToBeTriggered();
expectSuccessfulVideoMeetingUpdationInCalendar(videoMock, {
calEvent: {
location: "http://mock-dailyvideo.example.com",
},
bookingRef: {
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
},
});
// updateEvent uses existing booking's externalCalendarId to update the event in calendar.
// and not the event-type's organizer's which is event-type-1@example.com
expectSuccessfulCalendarEventUpdationInCalendar(calendarMock, {
externalCalendarId: "existing-event-type@example.com",
calEvent: {
location: "http://mock-dailyvideo.example.com",
},
uid: "MOCK_ID",
});
expectSuccessfulBookingRescheduledEmails({
booker,
organizer,
emails,
iCalUID: "MOCKED_GOOGLE_CALENDAR_ICS_ID",
});
expectBookingRescheduledWebhookToHaveBeenFired({
booker,
organizer,
location: BookingLocations.CalVideo,
subscriberUrl: "http://my-webhook.example.com",
videoCallUrl: `${WEBAPP_URL}/video/DYNAMIC_UID`,
});
},
timeout
);
test(
`an error in updating a calendar event should not stop the rescheduling - Current behaviour is wrong as the booking is resheduled but no-one is notified of it`,
async ({}) => {
const handleNewBooking = (await import("@calcom/features/bookings/lib/handleNewBooking")).default;
const booker = getBooker({
email: "booker@example.com",
name: "Booker",
});
const organizer = getOrganizer({
name: "Organizer",
email: "organizer@example.com",
id: 101,
schedules: [TestData.schedules.IstWorkHours],
credentials: [getGoogleCalendarCredential()],
selectedCalendars: [TestData.selectedCalendars.google],
destinationCalendar: {
integration: "google_calendar",
externalId: "organizer@google-calendar.com",
},
});
const uidOfBookingToBeRescheduled = "n5Wv3eHgconAED2j4gcVhP";
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
await createBookingScenario(
getScenarioData({
webhooks: [
{
userId: organizer.id,
eventTriggers: ["BOOKING_CREATED"],
subscriberUrl: "http://my-webhook.example.com",
active: true,
eventTypeId: 1,
appId: null,
},
],
eventTypes: [
{
id: 1,
slotInterval: 45,
length: 45,
users: [
{
id: 101,
},
],
},
],
bookings: [
{
uid: uidOfBookingToBeRescheduled,
eventTypeId: 1,
status: BookingStatus.ACCEPTED,
startTime: `${plus1DateString}T05:00:00.000Z`,
endTime: `${plus1DateString}T05:15:00.000Z`,
references: [
{
type: appStoreMetadata.dailyvideo.type,
uid: "MOCK_ID",
meetingId: "MOCK_ID",
meetingPassword: "MOCK_PASS",
meetingUrl: "http://mock-dailyvideo.example.com",
},
{
type: appStoreMetadata.googlecalendar.type,
uid: "ORIGINAL_BOOKING_UID",
meetingId: "ORIGINAL_MEETING_ID",
meetingPassword: "ORIGINAL_MEETING_PASSWORD",
meetingUrl: "https://ORIGINAL_MEETING_URL",
externalCalendarId: "existing-event-type@example.com",
credentialId: undefined,
},
],
},
],
organizer,
apps: [TestData.apps["google-calendar"], TestData.apps["daily-video"]],
})
);
const _calendarMock = mockCalendarToCrashOnUpdateEvent("googlecalendar");
const mockBookingData = getMockRequestDataForBooking({
data: {
eventTypeId: 1,
rescheduleUid: uidOfBookingToBeRescheduled,
responses: {
email: booker.email,
name: booker.name,
location: { optionValue: "", value: "New York" },
},
},
});
const { req } = createMockNextJsRequest({
method: "POST",
body: mockBookingData,
});
const createdBooking = await handleNewBooking(req);
await expectBookingInDBToBeRescheduledFromTo({
from: {
uid: uidOfBookingToBeRescheduled,
},
to: {
description: "",
location: "New York",
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
uid: createdBooking.uid!,
eventTypeId: mockBookingData.eventTypeId,
status: BookingStatus.ACCEPTED,
responses: expect.objectContaining({
email: booker.email,
name: booker.name,
}),
references: [
{
type: appStoreMetadata.googlecalendar.type,
// A reference is still created in case of event creation failure, with nullish values. Not sure what's the purpose for this.
uid: "ORIGINAL_BOOKING_UID",
meetingId: "ORIGINAL_MEETING_ID",
meetingPassword: "ORIGINAL_MEETING_PASSWORD",
meetingUrl: "https://ORIGINAL_MEETING_URL",
},
],
},
});
expectWorkflowToBeTriggered();
// FIXME: We should send Broken Integration emails on calendar event updation failure
// expectBrokenIntegrationEmails({ booker, organizer, emails });
expectBookingRescheduledWebhookToHaveBeenFired({
booker,
organizer,
location: "New York",
subscriberUrl: "http://my-webhook.example.com",
});
},
timeout
);
});
});

View File

@ -0,0 +1,41 @@
import type { Dayjs } from "@calcom/dayjs";
import dayjs from "@calcom/dayjs";
import { useOverlayCalendarStore } from "../Booker/components/OverlayCalendar/store";
function getCurrentTime(date: Date) {
const hours = date.getHours().toString().padStart(2, "0");
const minutes = date.getMinutes().toString().padStart(2, "0");
return `${hours}:${minutes}`;
}
export function useCheckOverlapWithOverlay(start: Dayjs, selectedDuration: number | null, offset: number) {
const overlayBusyDates = useOverlayCalendarStore((state) => state.overlayBusyDates);
let overlappingTimeStart: string | null = null;
let overlappingTimeEnd: string | null = null;
const isOverlapping =
overlayBusyDates &&
overlayBusyDates.some((busyDate) => {
const busyDateStart = dayjs(busyDate.start);
const busyDateEnd = dayjs(busyDate.end);
const selectedEndTime = dayjs(start.add(offset, "hours")).add(selectedDuration ?? 0, "minute");
const isOverlapping =
(selectedEndTime.isSame(busyDateStart) || selectedEndTime.isAfter(busyDateStart)) &&
start.add(offset, "hours") < busyDateEnd &&
selectedEndTime > busyDateStart;
overlappingTimeStart = isOverlapping ? getCurrentTime(busyDateStart.toDate()) : null;
overlappingTimeEnd = isOverlapping ? getCurrentTime(busyDateEnd.toDate()) : null;
return isOverlapping;
});
return { isOverlapping, overlappingTimeStart, overlappingTimeEnd } as {
isOverlapping: boolean;
overlappingTimeStart: string | null;
overlappingTimeEnd: string | null;
};
}

View File

@ -3,6 +3,14 @@ import type { Credential, SelectedCalendar, DestinationCalendar } from "@prisma/
import type { EventType } from "@calcom/prisma/client";
import type { CalendarEvent } from "@calcom/types/Calendar";
function getBooleanStatus(val: unknown) {
if (process.env.NODE_ENV === "production") {
return `PiiFree:${!!val}`;
} else {
return val;
}
}
export function getPiiFreeCalendarEvent(calEvent: CalendarEvent) {
return {
eventTypeId: calEvent.eventTypeId,
@ -16,12 +24,13 @@ export function getPiiFreeCalendarEvent(calEvent: CalendarEvent) {
recurrence: calEvent.recurrence,
requiresConfirmation: calEvent.requiresConfirmation,
uid: calEvent.uid,
conferenceCredentialId: calEvent.conferenceCredentialId,
iCalUID: calEvent.iCalUID,
/**
* Let's just get a boolean value for PII sensitive fields so that we atleast know if it's present or not
*/
// Not okay to have title which can have Booker and Organizer names
title: !!calEvent.title,
title: getBooleanStatus(calEvent.title),
// .... Add all other props here that we don't want to be logged. It prevents those properties from being logged accidentally
};
}
@ -44,7 +53,7 @@ export function getPiiFreeBooking(booking: {
* Let's just get a boolean value for PII sensitive fields so that we atleast know if it's present or not
*/
// Not okay to have title which can have Booker and Organizer names
title: !!booking.title,
title: getBooleanStatus(booking.title),
// .... Add all other props here that we don't want to be logged. It prevents those properties from being logged accidentally
};
}
@ -60,7 +69,7 @@ export function getPiiFreeCredential(credential: Partial<Credential>) {
/**
* Let's just get a boolean value for PII sensitive fields so that we atleast know if it's present or not
*/
key: !!credential.key,
key: getBooleanStatus(credential.key),
};
}
@ -82,7 +91,7 @@ export function getPiiFreeDestinationCalendar(destinationCalendar: Partial<Desti
/**
* Let's just get a boolean value for PII sensitive fields so that we atleast know if it's present or not
*/
externalId: !!destinationCalendar.externalId,
externalId: getBooleanStatus(destinationCalendar.externalId),
};
}

View File

@ -1,5 +1,6 @@
import authedProcedure from "../../../procedures/authedProcedure";
import { router } from "../../../trpc";
import { ZCalendarOverlayInputSchema } from "./calendarOverlay.schema";
import { scheduleRouter } from "./schedule/_router";
import { ZListTeamAvailaiblityScheme } from "./team/listTeamAvailability.schema";
import { ZUserInputSchema } from "./user.schema";
@ -7,6 +8,7 @@ import { ZUserInputSchema } from "./user.schema";
type AvailabilityRouterHandlerCache = {
list?: typeof import("./list.handler").listHandler;
user?: typeof import("./user.handler").userHandler;
calendarOverlay?: typeof import("./calendarOverlay.handler").calendarOverlayHandler;
listTeamAvailability?: typeof import("./team/listTeamAvailability.handler").listTeamAvailabilityHandler;
};
@ -60,6 +62,22 @@ export const availabilityRouter = router({
input,
});
}),
schedule: scheduleRouter,
calendarOverlay: authedProcedure.input(ZCalendarOverlayInputSchema).query(async ({ ctx, input }) => {
if (!UNSTABLE_HANDLER_CACHE.calendarOverlay) {
UNSTABLE_HANDLER_CACHE.calendarOverlay = await import("./calendarOverlay.handler").then(
(mod) => mod.calendarOverlayHandler
);
}
// Unreachable code but required for type safety
if (!UNSTABLE_HANDLER_CACHE.calendarOverlay) {
throw new Error("Failed to load handler");
}
return UNSTABLE_HANDLER_CACHE.calendarOverlay({
ctx,
input,
});
}),
});

View File

@ -0,0 +1,102 @@
import { getBusyCalendarTimes } from "@calcom/core/CalendarManager";
import dayjs from "@calcom/dayjs";
import type { EventBusyDate } from "@calcom/types/Calendar";
import { TRPCError } from "@trpc/server";
import type { TrpcSessionUser } from "../../../trpc";
import type { TCalendarOverlayInputSchema } from "./calendarOverlay.schema";
type ListOptions = {
ctx: {
user: NonNullable<TrpcSessionUser>;
};
input: TCalendarOverlayInputSchema;
};
export const calendarOverlayHandler = async ({ ctx, input }: ListOptions) => {
const { user } = ctx;
const { calendarsToLoad, dateFrom, dateTo } = input;
if (!dateFrom || !dateTo) {
return [] as EventBusyDate[];
}
// get all unique credentialIds from calendarsToLoad
const uniqueCredentialIds = Array.from(new Set(calendarsToLoad.map((item) => item.credentialId)));
// To call getCalendar we need
// Ensure that the user has access to all of the credentialIds
const credentials = await prisma.credential.findMany({
where: {
id: {
in: uniqueCredentialIds,
},
userId: user.id,
},
select: {
id: true,
type: true,
key: true,
userId: true,
teamId: true,
appId: true,
invalid: true,
user: {
select: {
email: true,
},
},
},
});
if (credentials.length !== uniqueCredentialIds.length) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Unauthorized - These credentials do not belong to you",
});
}
const composedSelectedCalendars = calendarsToLoad.map((calendar) => {
const credential = credentials.find((item) => item.id === calendar.credentialId);
if (!credential) {
throw new TRPCError({
code: "UNAUTHORIZED",
message: "Unauthorized - These credentials do not belong to you",
});
}
return {
...calendar,
userId: user.id,
integration: credential.type,
};
});
// get all clanedar services
const calendarBusyTimes = await getBusyCalendarTimes(
"",
credentials,
dateFrom,
dateTo,
composedSelectedCalendars
);
// Convert to users timezone
const userTimeZone = input.loggedInUsersTz;
const calendarBusyTimesConverted = calendarBusyTimes.map((busyTime) => {
const busyTimeStart = dayjs(busyTime.start);
const busyTimeEnd = dayjs(busyTime.end);
const busyTimeStartDate = busyTimeStart.tz(userTimeZone).toDate();
const busyTimeEndDate = busyTimeEnd.tz(userTimeZone).toDate();
return {
...busyTime,
start: busyTimeStartDate,
end: busyTimeEndDate,
} as EventBusyDate;
});
return calendarBusyTimesConverted;
};

View File

@ -0,0 +1,15 @@
import { z } from "zod";
export const ZCalendarOverlayInputSchema = z.object({
loggedInUsersTz: z.string(),
dateFrom: z.string().nullable(),
dateTo: z.string().nullable(),
calendarsToLoad: z.array(
z.object({
credentialId: z.number(),
externalId: z.string(),
})
),
});
export type TCalendarOverlayInputSchema = z.infer<typeof ZCalendarOverlayInputSchema>;

View File

@ -1,6 +1,7 @@
import { sendTeamInviteEmail } from "@calcom/emails";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { getTranslation } from "@calcom/lib/server/i18n";
import { prisma } from "@calcom/prisma";
import type { TrpcSessionUser } from "@calcom/trpc/server/trpc";
import { TRPCError } from "@trpc/server";

View File

@ -159,7 +159,8 @@ expect.extend({
//TODO: Move it to testUtil, so that it doesn't need to be passed
// eslint-disable-next-line
getActionFiredDetails: (a: { calNamespace: string; actionType: string }) => Promise<any>,
expectedUrlDetails: ExpectedUrlDetails = {}
expectedUrlDetails: ExpectedUrlDetails = {},
isPrerendered?: boolean
) {
if (!iframe || !iframe.url) {
return {
@ -169,14 +170,7 @@ expect.extend({
}
const u = new URL(iframe.url());
const frameElement = await iframe.frameElement();
if (!(await frameElement.isVisible())) {
return {
pass: false,
message: () => `Expected iframe to be visible`,
};
}
const pathname = u.pathname;
const expectedPathname = `${expectedUrlDetails.pathname}/embed`;
if (expectedPathname && expectedPathname !== pathname) {
@ -206,20 +200,41 @@ expect.extend({
};
}
}
let iframeReadyCheckInterval;
const frameElement = await iframe.frameElement();
if (isPrerendered) {
if (await frameElement.isVisible()) {
return {
pass: false,
message: () => `Expected prerender iframe to be not visible`,
};
}
return {
pass: true,
message: () => `is prerendered`,
};
}
const iframeReadyEventDetail = await new Promise(async (resolve) => {
iframeReadyCheckInterval = setInterval(async () => {
const iframeReadyCheckInterval = setInterval(async () => {
const iframeReadyEventDetail = await getActionFiredDetails({
calNamespace,
actionType: "linkReady",
});
if (iframeReadyEventDetail) {
clearInterval(iframeReadyCheckInterval);
resolve(iframeReadyEventDetail);
}
}, 500);
});
clearInterval(iframeReadyCheckInterval);
if (!(await frameElement.isVisible())) {
return {
pass: false,
message: () => `Expected iframe to be visible`,
};
}
//At this point we know that window.initialBodyVisibility would be set as DOM would already have been ready(because linkReady event can only fire after that)
const {

View File

@ -2,6 +2,9 @@ import { defineConfig } from "vitest/config";
process.env.INTEGRATION_TEST_MODE = "true";
// We can't set it during tests because it is used as soon as _metadata.ts is imported which happens before tests start running
process.env.DAILY_API_KEY = "MOCK_DAILY_API_KEY";
export default defineConfig({
test: {
coverage: {