Compare commits

...
31 Commits
Author SHA1 Message Date
GitHub Actions 80c407a4b7 chore: release v5.2.13
Post release / release (push) Canceled after 0s
2025-05-08 21:44:17 +00:00
6e9ec113fe feat: Prerender booking link and reuse with headless router (#20720)
* mvp done

* wip

* fix ts errors and other code improvements

* fix ts errors

* ensure mobile layout support

* Make skeleton responsive on screen resize

* refactor

* Add test for EmbedElement

* make skeleton closer to pixel perfect

* Address PR feedback

* Router-preloading
## What does this PR do?

<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->

- Fixes #XXXX (GitHub issue number)
- Fixes CAL-XXXX (Linear issue number - should be visible at the bottom of the GitHub issue description)

## Visual Demo (For contributors especially)

A visual demonstration is strongly recommended, for both the original and new change **(video / image - any one)**.

#### Video Demo (if applicable):

- Show screen recordings of the issue or feature.
- Demonstrate how to reproduce the issue, the behavior before and after the change.

#### Image Demo (if applicable):

- Add side-by-side screenshots of the original and updated change.
- Highlight any significant change(s).

## Mandatory Tasks (DO NOT REMOVE)

- [ ] I have self-reviewed the code (A decent size PR without self-review might be rejected).
- [ ] I have updated the developer docs in /docs if this PR makes changes that would require a [documentation change](https://cal.com/docs). If N/A, write N/A here and check the checkbox.
- [ ] I confirm automated tests are in place that prove my fix is effective or that my feature works.

## How should this be tested?

<!-- Please describe the tests that you ran to verify your changes. Provide instructions so we can reproduce. Please also list any relevant details for your test configuration. Write details that help to start the tests -->

- Are there environment variables that should be set?
- What are the minimal test data to have?
- What is expected (happy path) to have (input and output)?
- Any other important info that could help to test that PR

## Checklist

<!-- Remove bullet points below that don't apply to you -->

- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
- My code doesn't follow the style guidelines of this project
- I haven't commented my code, particularly in hard-to-understand areas
- I haven't checked if my changes generate no new warnings

* wip\

* wip

* fix mrge.io feedback

* wip

* Add README and lifecycle

* Add README and lifecycle

* Update routing form-seed and some other fixes

* remove linkFailed fix from the branch

* self-review

* self-review-2

* self-review-3

* Handle soft connect\

* Update README and fix a bug with query parmas

* Add one more case in routing-html playground

---------

Co-authored-by: Benny Joo <sldisek783@gmail.com>
Co-authored-by: amrit <iamamrit27@gmail.com>
2025-05-08 19:59:26 +01:00
Hariom Balhara 3ee6a7ecde update delegation creential readme (#21178) 2025-05-08 17:40:38 +00:00
Somay Chauhan 8ad62d183c feat: booking references api type filter (#21179)
* feat: add `type` filter for booking references API endpointa

* Update openapi.json

* feat: add enum validation for booking reference types in filter input

* refactor: rename externalUid to eventUid and add destinationCalendarId in booking references

* refactor: replace enum with const array for BookingReferenceType

* fix: type safety for booking references where clause using Prisma types

* refactor: rename BookingReferenceType to BookingReferences
2025-05-08 17:23:09 +00:00
Benny Joo f047799299 chore: remove @calcom/lib/server barrel file (#21190) 2025-05-08 22:36:30 +05:30
Lauris SkraucisandMorgan 3a9e353443 fix: don't allow double reserving already reserved slot (#21139)
* feat: prevent double reserving slots

* tests

* remove log

* logs

* refactor: releaseAt utc check

* fix: check overlap when updating slots

---------

Co-authored-by: Morgan <33722304+ThyMinimalDev@users.noreply.github.com>
2025-05-08 14:39:33 +02:00
Udit Takkar b73a3aeb0c feat: enforce log in cal video (#21082)
* feat: enforce log in cal video

* chore: remove un necessary text

* fix: type err

* fix: pass user name
2025-05-08 12:37:50 +00:00
Anik Dhabal Babu b6ff64ed74 fix: attendees not visible for hosts (#21156) 2025-05-08 12:23:27 +00:00
Anik Dhabal Babu 1b281fe269 fix: unable to change the workflow action (#21185) 2025-05-08 10:06:02 +00:00
Somay Chauhan 024913592b feat: Add handleSlotSelect prop for custom timeslot selection/reservation handling (#21133)
* feat: Add `handleSlotSelect` prop for custom timeslot selection/reservation handling

* Update custom-slot-select-flow.mdx

* Update custom-slot-select-flow.mdx

* refactor: rename handleSlotSelect to handleSlotReservation for clearer intent

* docs: add API reference link for slot reservation api
2025-05-08 15:13:58 +05:30
Udit TakkarandAnik Dhabal Babu 78a34038d2 test: add e2e test for round robin event types (#21159)
Co-authored-by: Anik Dhabal Babu <81948346+anikdhabal@users.noreply.github.com>
2025-05-08 11:41:21 +05:30
Romit GabaniandTushar Bhatt 5f770a9789 fix: hiding And connect with specific team members option for non org teams (#21083)
* fix: hiding And connect with specific team members option for non org teams

* removed console logs

* added comment

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-05-08 05:09:11 +00:00
devin-ai-integration[bot]andmorgan@cal.com 24a43877ce perf: optimize event type query by using team IDs instead of complex join (#21162)
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: morgan@cal.com <morgan@cal.com>
2025-05-07 13:00:49 -07:00
GitHub Actions 4a96be4175 chore: release v5.2.12
Post release / release (push) Canceled after 0s
2025-05-07 19:34:41 +00:00
594bb606b8 perf: Refactor bookings queries for API (#16407)
* perf: Refactor bookings queries for API

* fix syntax

* fixing vars

* Inverted the filter by attendees if

* fixed prisma filter issue

* Defaulting list of emails to empty array

* Only running 2 queries if we have data to filter by

* Reverted changes that have been separated out to other PR

* fix: ts build error

* adds tests + extracts logic for units

* fix integration tests

* fix tests expectes

* Fix test

* switching back

* attempt : fix tests

* test: add test for duplicate bookings (#21165)

* feat: Add tests for booking and duration limits (#21158)

* Add tests for booking and duration limits in getSchedule.test.ts

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: use SchedulingType enum instead of string for schedulingType

Co-Authored-By: keith@cal.com <keith@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>

* test: add test to ensure no duplicate bookings when merging query results

Co-Authored-By: keith@cal.com <keith@cal.com>

---------

Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>
Co-authored-by: Keith Williams <keithwillcode@gmail.com>

* fix: deduplicate bookings when merging results from multiple queries

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: ensure pagination works correctly when merging results from multiple queries (#21167)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>

---------

Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
Co-authored-by: Tushar <tusharbhatt0135@gmail.com>
Co-authored-by: sean-brydon <sean@cal.com>
Co-authored-by: Peer Richelsen <peeroke@gmail.com>
Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>
2025-05-07 11:44:56 -07:00
Omar López 1c088f65c5 chore: Removes around app (#20650) 2025-05-07 14:41:46 -04:00
devin-ai-integration[bot]andkeith@cal.com 7d7a429780 feat: Add tests for booking and duration limits (#21158)
* Add tests for booking and duration limits in getSchedule.test.ts

Co-Authored-By: keith@cal.com <keith@cal.com>

* fix: use SchedulingType enum instead of string for schedulingType

Co-Authored-By: keith@cal.com <keith@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: keith@cal.com <keith@cal.com>
2025-05-07 13:06:10 -04:00
GiridharandTushar Bhatt d3228441aa fix:decline-booking-reason-missing (#21144)
Co-authored-by: Tushar Bhatt <95581504+TusharBhatt1@users.noreply.github.com>
2025-05-07 21:01:33 +05:30
Morgan 21d6bf1fca fix: api v2 routing form responses flaky tests (#21150) 2025-05-07 16:22:02 +03:00
a4134aa5a3 feat: add 'View Responses' menu item to Routing Form dropdown (#21114)
* feat: add 'View Responses' menu item to Routing Form dropdown

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: use nuqs createSerializer for URL generation

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* refactor: move serializer to data-table lib and use proper type constants

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: type errors in serializers and FormActions

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: use parseAsArrayOf with parseAsJson for proper nuqs serialization

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: update to use dataTableQueryParamsSerializer with proper nuqs pattern

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* refactor: extract parsers from DataTableProvider to be reused in serializers

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: remove deprecated activeFiltersSerializer and use empty array instead of null

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: only show View Responses menu when form id and teamId exist

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* remove teamId condition

* refactor: improve viewResponses URL generation with cleaner conditional

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* Update packages/features/data-table/lib/parsers.ts

* refactor: make constants in parsers.ts non-exported

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: add explicit type annotations to constants in parsers.ts

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: add explicit type annotations and export DEFAULT_PAGE_SIZE in parsers.ts

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

* fix: add explicit type assertions to DEFAULT_ACTIVE_FILTERS and DEFAULT_SORTING

Co-Authored-By: eunjae@cal.com <eunjae@cal.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: eunjae@cal.com <eunjae@cal.com>
Co-authored-by: Eunjae Lee <hey@eunjae.dev>
Co-authored-by: Benny Joo <sldisek783@gmail.com>
2025-05-07 13:10:21 +00:00
Anik Dhabal Babu 61397e5184 fix: e2e flakes (#20630)
* fix

* Update managed-event-types.e2e.ts

* fix
2025-05-07 18:04:06 +05:30
Somay Chauhan 6143cd60e5 feat: Add booking references API endpoints (#21127)
* feat: Add booking references API endpoints

* refactor: Implement booking-reference service and repositories

* Add description and reorder properties in BookingReference schema

* Refactor booking references service to separate output service
2025-05-07 12:30:53 +00:00
Eunjae Lee ac821f6b13 chore: convert booking_denormalized e2e test to integration test (#21148) 2025-05-07 14:23:25 +02:00
sean-brydon 2bce38fd2a feat(v3): table designs (#21147) 2025-05-07 09:54:36 +00:00
Hariom BalharaandOmar López 390ee79134 Reduce payload size (#21093)
Co-authored-by: Omar López <zomars@me.com>
2025-05-07 03:16:07 +00:00
cfc9f0bfc3 refactor: Consolidate date parameter extraction and booking period check logic (#21142)
Co-authored-by: keith@cal.com <keith@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: Omar López <zomars@me.com>
2025-05-06 16:33:22 -07:00
Morgan 58d5f2a17c fix: advanced text filters getBookings with kysely (#21143) 2025-05-06 21:08:14 +00:00
Anwar Sadath 37f271bd74 fix: delete organization user endpoint not working in api v2 (#21136) 2025-05-06 19:10:55 +00:00
devin-ai-integration[bot]andkeith@cal.com d770cfc65e perf: Optimize team bookings query by fetching data for multiple users at once (#21137)
Co-authored-by: keith@cal.com <keith@cal.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2025-05-06 11:47:06 -07:00
Eunjae Leeandsean-brydon ab8556790e feat: add denormalized table for BookingTimeStatus view (#20947)
* feat: add denormalized table for BookingTimeStatus

* Update packages/prisma/migrations/20240320000001_add_booking_time_status_denormalized/migration.sql

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>

* add more indexes

* improvement

* update schema

* improve indexes

* apply feedback from mrge

* split update trigger for better perf

* remove unnecessary checks

* split migration into two

* less logging

* address feedback

* add updatedAt and userUsername

* remove timeStatus

* add deletion trigger

* clean up denorm table on row deletion at EventType or users

* add timeStatus back as a part of a new view

* keep user and eventType nullable

* rename triggers

* add tests

* clean up migrations

* update tests

* update tests

* update test clean up

* one more try

* similar updates

* use e2e test instead

---------

Co-authored-by: sean-brydon <55134778+sean-brydon@users.noreply.github.com>
2025-05-06 20:34:45 +02:00
Hariom Balhara 3d89b37cc6 Adding already released embed version (#21033) 2025-05-06 15:19:47 +00:00
151 changed files with 6868 additions and 662 deletions
+2
View File
@@ -0,0 +1,2 @@
undecided:
- calcom-monorepo
+8
View File
@@ -0,0 +1,8 @@
undecided:
- calcom-monorepo
- "@calcom/app-store-cli"
- "@calcom/platform-constants"
- "@calcom/platform-enums"
- "@calcom/platform-types"
- "@calcom/platform-utils"
- "@calcom/prisma"
@@ -0,0 +1,37 @@
/**
* Constructs the WHERE clause for Prisma booking findMany operation.
*
* @param userId - The ID of the user making the request. This is used to filter bookings where the user is either the host or an attendee.
* @param attendeeEmails - An array of emails provided in the request for filtering bookings by attendee emails, used in case of Admin calls.
* @param userIds - An array of user IDs to be included in the filter. Defaults to an empty array, and an array of user IDs in case of Admin call containing it.
* @param userEmails - An array of user emails to be included in the filter if it is an Admin call and contains userId in query parameter. Defaults to an empty array.
*
* @returns An object that represents the WHERE clause for the findMany/findUnique operation.
*/
export function buildWhereClause(userId: number | null, attendeeEmails: string[], userIds: number[] = []) {
const filterByAttendeeEmails = attendeeEmails.length > 0;
const userFilter = userIds.length > 0 ? { userId: { in: userIds } } : !!userId ? { userId } : {};
let whereClause = {};
if (filterByAttendeeEmails) {
whereClause = {
AND: [
userFilter,
{
attendees: {
some: {
email: { in: attendeeEmails },
},
},
},
],
};
} else {
whereClause = {
...userFilter,
};
}
return whereClause;
}
+79 -57
View File
@@ -1,4 +1,4 @@
import type { Prisma } from "@prisma/client";
import type { Prisma, Booking } from "@prisma/client";
import type { NextApiRequest } from "next";
import { HttpError } from "@calcom/lib/http-error";
@@ -6,6 +6,7 @@ import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import prisma from "@calcom/prisma";
import { withMiddleware } from "~/lib/helpers/withMiddleware";
import { buildWhereClause } from "~/lib/utils/bookings/get/buildWhereClause";
import {
getAccessibleUsers,
retrieveOrgScopedAccessibleUsers,
@@ -151,58 +152,6 @@ type GetAdminArgsType = {
userId: number;
};
/**
* Constructs the WHERE clause for Prisma booking findMany operation.
*
* @param userId - The ID of the user making the request. This is used to filter bookings where the user is either the host or an attendee.
* @param attendeeEmails - An array of emails provided in the request for filtering bookings by attendee emails, used in case of Admin calls.
* @param userIds - An array of user IDs to be included in the filter. Defaults to an empty array, and an array of user IDs in case of Admin call containing it.
* @param userEmails - An array of user emails to be included in the filter if it is an Admin call and contains userId in query parameter. Defaults to an empty array.
*
* @returns An object that represents the WHERE clause for the findMany/findUnique operation.
*/
function buildWhereClause(
userId: number | null,
attendeeEmails: string[],
userIds: number[] = [],
userEmails: string[] = []
) {
const filterByAttendeeEmails = attendeeEmails.length > 0;
const userFilter = userIds.length > 0 ? { userId: { in: userIds } } : !!userId ? { userId } : {};
let whereClause = {};
if (filterByAttendeeEmails) {
whereClause = {
AND: [
userFilter,
{
attendees: {
some: {
email: { in: attendeeEmails },
},
},
},
],
};
} else {
whereClause = {
OR: [
userFilter,
{
attendees: {
some: {
email: { in: userEmails },
},
},
},
],
};
}
return whereClause;
}
export async function handler(req: NextApiRequest) {
const {
userId,
@@ -238,6 +187,7 @@ export async function handler(req: NextApiRequest) {
? [queryFilterForAttendeeEmails.attendeeEmail]
: [];
const filterByAttendeeEmails = attendeeEmails.length > 0;
let userEmailsToFilterBy: string[] = [];
/** Only admins can query other users */
if (isSystemWideAdmin) {
@@ -251,7 +201,8 @@ export async function handler(req: NextApiRequest) {
userId,
};
const { userId: argUserId, userIds, userEmails } = await handleSystemWideAdminArgs(systemWideAdminArgs);
args.where = buildWhereClause(argUserId, attendeeEmails, userIds, userEmails);
userEmailsToFilterBy = userEmails;
args.where = buildWhereClause(argUserId, attendeeEmails, userIds);
}
} else if (isOrganizationOwnerOrAdmin) {
let requestedUserIds = [userId];
@@ -265,7 +216,8 @@ export async function handler(req: NextApiRequest) {
userId,
};
const { userId: argUserId, userIds, userEmails } = await handleOrgWideAdminArgs(orgWideAdminArgs);
args.where = buildWhereClause(argUserId, attendeeEmails, userIds, userEmails);
userEmailsToFilterBy = userEmails;
args.where = buildWhereClause(argUserId, attendeeEmails, userIds);
} else {
const user = await prisma.user.findUnique({
where: { id: userId },
@@ -276,7 +228,7 @@ export async function handler(req: NextApiRequest) {
if (!user) {
throw new HttpError({ message: "User not found", statusCode: 404 });
}
args.where = buildWhereClause(userId, attendeeEmails, [], []);
args.where = buildWhereClause(userId, attendeeEmails, []);
}
if (dateFrom) {
@@ -317,7 +269,77 @@ export async function handler(req: NextApiRequest) {
}
}
const data = await prisma.booking.findMany(args);
let data: Booking[] = [];
if (!filterByAttendeeEmails && userEmailsToFilterBy.length > 0) {
const queryOne = prisma.booking.findMany(args);
const whereClauseForQueryTwo: Prisma.BookingWhereInput = {
attendees: {
some: {
email: { in: userEmailsToFilterBy },
},
},
};
if (dateFrom) {
whereClauseForQueryTwo.startTime = { gte: dateFrom };
}
if (dateTo) {
whereClauseForQueryTwo.endTime = { lte: dateTo };
}
if (status === "upcoming") {
whereClauseForQueryTwo.startTime = { gte: new Date().toISOString() };
}
const argsForQueryTwo: Prisma.BookingFindManyArgs = {
...args,
where: whereClauseForQueryTwo,
};
const queryTwo = prisma.booking.findMany(argsForQueryTwo);
const [resultOne, resultTwo] = await Promise.all([queryOne, queryTwo]);
const bookingMap = new Map();
[...resultOne, ...resultTwo].forEach((booking) => {
bookingMap.set(booking.id, booking);
});
const dedupedResults = Array.from(bookingMap.values());
if (args.orderBy) {
let sortField: keyof Booking;
let sortDirection: "asc" | "desc" = "asc";
if (typeof args.orderBy === "object" && !Array.isArray(args.orderBy)) {
const orderByKey = Object.keys(args.orderBy)[0] as keyof typeof args.orderBy;
sortField = orderByKey as keyof Booking;
sortDirection = args.orderBy[orderByKey] as "asc" | "desc";
} else {
sortField = "id";
}
const sortOrder = sortDirection === "desc" ? -1 : 1;
dedupedResults.sort((a, b) => {
const aValue = a[sortField];
const bValue = b[sortField];
if (aValue < bValue) return -1 * sortOrder;
if (aValue > bValue) return 1 * sortOrder;
return 0;
});
}
if (args.take !== undefined && args.skip !== undefined) {
data = dedupedResults.slice(args.skip, args.skip + args.take);
} else {
data = dedupedResults;
}
} else {
data = await prisma.booking.findMany(args);
}
return { bookings: data.map((booking) => schemaBookingReadPublic.parse(booking)) };
}
@@ -2,6 +2,7 @@ import type { Request, Response } from "express";
import type { NextApiRequest, NextApiResponse } from "next";
import { createMocks } from "node-mocks-http";
import { describe, expect, it } from "vitest";
import { ZodError } from "zod";
import prisma from "@calcom/prisma";
@@ -206,8 +207,155 @@ describe("GET /api/bookings", async () => {
console.log("bookings=>", responseData.bookings);
responseData.bookings.forEach((booking) => {
if (booking.id === 31) expect(booking.eventType?.team?.slug).toBe("team1");
if (booking.id === 19) expect(booking.eventType?.team).toBe(null);
if (booking.id === 19) {
// The team field can be either null or undefined due to nullish() in the schema
expect(booking.eventType?.team === null || booking.eventType?.team === undefined).toBe(true);
}
});
});
});
describe("Date filtering", () => {
it("filters bookings by dateFrom", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
dateFrom: "2023-01-01T00:00:00Z",
},
pagination: DefaultPagination,
});
req.userId = proUser.id;
const responseData = await handler(req);
responseData.bookings.forEach((booking) => {
expect(new Date(booking.startTime).getTime()).toBeGreaterThanOrEqual(
new Date("2023-01-01T00:00:00Z").getTime()
);
});
});
it("filters bookings by dateTo", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
dateTo: "2024-12-31T23:59:59Z",
},
pagination: DefaultPagination,
});
req.userId = proUser.id;
const responseData = await handler(req);
responseData.bookings.forEach((booking) => {
expect(new Date(booking.endTime).getTime()).toBeLessThanOrEqual(
new Date("2024-12-31T23:59:59Z").getTime()
);
});
});
});
describe("Sorting and ordering", () => {
it("sorts bookings by createdAt in descending order", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
sortBy: "createdAt",
order: "desc",
},
pagination: DefaultPagination,
});
req.userId = proUser.id;
const responseData = await handler(req);
const timestamps = responseData.bookings.map((b) => new Date(b.createdAt).getTime());
expect(timestamps).toEqual([...timestamps].sort((a, b) => b - a));
});
});
describe("Multiple attendee email filtering", () => {
it("filters bookings by multiple attendee emails", async () => {
const attendeeEmails = ["test1@example.com", "test2@example.com"];
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
attendeeEmail: attendeeEmails,
},
pagination: DefaultPagination,
});
req.userId = proUser.id;
const responseData = await handler(req);
responseData.bookings.forEach((booking) => {
const bookingAttendeeEmails = booking.attendees?.map((a) => a.email);
expect(bookingAttendeeEmails?.some((email) => attendeeEmails.includes(email))).toBe(true);
});
});
});
describe("Error cases", () => {
it("throws error for invalid status parameter", async () => {
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
status: "invalid_status",
},
pagination: DefaultPagination,
});
req.userId = proUser.id;
await expect(handler(req)).rejects.toThrow(ZodError);
});
});
describe("Result merging", () => {
it("does not return duplicate bookings when merging results from multiple queries", async () => {
const adminUser = await prisma.user.findFirstOrThrow({ where: { email: "owner1-acme@example.com" } });
const testUser = await prisma.user.findFirstOrThrow({ where: { email: "pro@example.com" } });
const testUserBooking = await prisma.booking.findFirstOrThrow({
where: { userId: testUser.id },
include: { attendees: true },
});
const { req } = createMocks<CustomNextApiRequest, CustomNextApiResponse>({
method: "GET",
query: {
userId: testUser.id, // This will make userEmailsToFilterBy contain the test user's email
},
pagination: {
take: 100,
skip: 0,
},
});
req.isSystemWideAdmin = true;
req.userId = adminUser.id;
const responseData = await handler(req);
const bookingIds = responseData.bookings.map((booking) => booking.id);
const uniqueBookingIds = new Set(bookingIds);
expect(uniqueBookingIds.size).toBe(bookingIds.length);
if (uniqueBookingIds.size !== bookingIds.length) {
const counts = bookingIds.reduce((acc, id) => {
acc[id] = (acc[id] || 0) + 1;
return acc;
}, {} as Record<number, number>);
const duplicates = Object.entries(counts)
.filter(([_, count]) => count > 1)
.map(([id]) => id);
console.log(`Found duplicate booking IDs: ${duplicates.join(", ")}`);
}
});
});
});
@@ -0,0 +1,75 @@
import { describe, it, expect } from "vitest";
import { buildWhereClause } from "~/lib/utils/bookings/get/buildWhereClause";
describe("buildWhereClause", () => {
it("should return empty object when no filters are provided", () => {
const result = buildWhereClause(null, [], []);
expect(result).toEqual({});
});
it("should filter by userId when only userId is provided", () => {
const userId = 123;
const result = buildWhereClause(userId, [], []);
expect(result).toEqual({ userId: 123 });
});
it("should filter by userIds when userIds array is provided", () => {
const userIds = [1, 2, 3];
const result = buildWhereClause(null, [], userIds);
expect(result).toEqual({ userId: { in: [1, 2, 3] } });
});
it("should filter by attendee emails when provided", () => {
const attendeeEmails = ["test@example.com", "user@example.com"];
const result = buildWhereClause(null, attendeeEmails, []);
expect(result).toEqual({
AND: [
{},
{
attendees: {
some: {
email: { in: ["test@example.com", "user@example.com"] },
},
},
},
],
});
});
it("should combine userId and attendee email filters", () => {
const userId = 123;
const attendeeEmails = ["test@example.com"];
const result = buildWhereClause(userId, attendeeEmails, []);
expect(result).toEqual({
AND: [
{ userId: 123 },
{
attendees: {
some: {
email: { in: ["test@example.com"] },
},
},
},
],
});
});
it("should combine userIds and attendee email filters", () => {
const userIds = [1, 2, 3];
const attendeeEmails = ["test@example.com"];
const result = buildWhereClause(null, attendeeEmails, userIds);
expect(result).toEqual({
AND: [
{ userId: { in: [1, 2, 3] } },
{
attendees: {
some: {
email: { in: ["test@example.com"] },
},
},
},
],
});
});
});
@@ -0,0 +1,27 @@
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { Injectable } from "@nestjs/common";
import type { Prisma } from "@prisma/client";
@Injectable()
export class BookingReferencesRepository_2024_08_13 {
constructor(private readonly dbRead: PrismaReadService) {}
async getBookingReferences(bookingId: number, filter?: BookingReferencesFilterInput_2024_08_13) {
const whereClause: Prisma.BookingReferenceWhereInput = { bookingId };
if (filter?.type) {
whereClause.type = filter.type;
}
return this.dbRead.prisma.bookingReference.findMany({
where: whereClause,
select: {
type: true,
uid: true,
id: true,
externalCalendarId: true,
},
});
}
}
@@ -1,8 +1,11 @@
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { BookingsController_2024_08_13 } from "@/ee/bookings/2024-08-13/controllers/bookings.controller";
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { ErrorsBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/errors.service";
import { InputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/input.service";
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
import { OutputBookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output.service";
import { PlatformBookingsService } from "@/ee/bookings/shared/platform-bookings.service";
import { CalendarsRepository } from "@/ee/calendars/calendars.repository";
@@ -58,6 +61,7 @@ import { Module } from "@nestjs/common";
BookingsService_2024_08_13,
InputBookingsService_2024_08_13,
OutputBookingsService_2024_08_13,
OutputBookingReferencesService_2024_08_13,
BookingsRepository_2024_08_13,
EventTypesRepository_2024_06_14,
BookingSeatRepository,
@@ -71,6 +75,8 @@ import { Module } from "@nestjs/common";
OrganizationsTeamsRepository,
OrganizationsRepository,
ErrorsBookingsService_2024_08_13,
BookingReferencesService_2024_08_13,
BookingReferencesRepository_2024_08_13,
],
controllers: [BookingsController_2024_08_13],
exports: [InputBookingsService_2024_08_13, OutputBookingsService_2024_08_13, BookingsService_2024_08_13],
@@ -1,15 +1,20 @@
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
import { BookingReferencesOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/booking-references.output";
import { CalendarLinksOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/calendar-links.output";
import { CancelBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/cancel-booking.output";
import { CreateBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/create-booking.output";
import { MarkAbsentBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/mark-absent.output";
import { ReassignBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reassign-booking.output";
import { RescheduleBookingOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/reschedule-booking.output";
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { VERSION_2024_08_13_VALUE, VERSION_2024_08_13 } from "@/lib/api-versions";
import { API_KEY_OR_ACCESS_TOKEN_HEADER } from "@/lib/docs/headers";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PermissionsGuard } from "@/modules/auth/guards/permissions/permissions.guard";
import { UsersService } from "@/modules/users/services/users.service";
@@ -82,7 +87,8 @@ export class BookingsController_2024_08_13 {
constructor(
private readonly bookingsService: BookingsService_2024_08_13,
private readonly usersService: UsersService
private readonly usersService: UsersService,
private readonly bookingReferencesService: BookingReferencesService_2024_08_13
) {}
@Post("/")
@@ -386,4 +392,30 @@ export class BookingsController_2024_08_13 {
data: calendarLinks,
};
}
@Get("/:bookingUid/references")
@PlatformPlan("SCALE")
@UseGuards(ApiAuthGuard, BookingUidGuard)
@Permissions([BOOKING_READ])
@ApiHeader(API_KEY_OR_ACCESS_TOKEN_HEADER)
@ApiOperation({
summary: "Get 'Booking References' for a booking",
})
@HttpCode(HttpStatus.OK)
async getBookingReferences(
@Param("bookingUid") bookingUid: string,
@GetUser("id") userId: number,
@Query() filter: BookingReferencesFilterInput_2024_08_13
): Promise<BookingReferencesOutput_2024_08_13> {
const bookingReferences = await this.bookingReferencesService.getBookingReferences(
bookingUid,
userId,
filter
);
return {
status: SUCCESS_STATUS,
data: bookingReferences,
};
}
}
@@ -0,0 +1,23 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsIn, IsOptional } from "class-validator";
export const BookingReferences = [
"google_calendar",
"office365_calendar",
"daily_video",
"google_video",
"office365_video",
"zoom_video",
] as const;
export class BookingReferencesFilterInput_2024_08_13 {
@ApiProperty({
description: "Filter booking references by type",
required: false,
enum: BookingReferences,
example: "google_calendar",
})
@IsOptional()
@IsIn(BookingReferences)
type?: (typeof BookingReferences)[number];
}
@@ -0,0 +1,46 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { IsEnum, IsNumber, IsString } from "class-validator";
import { SUCCESS_STATUS, ERROR_STATUS } from "@calcom/platform-constants";
export class BookingReference {
@IsString()
@ApiProperty({
description: "The type of the booking reference",
})
type!: string;
@IsString()
@ApiProperty({
description: "The event uid of the booking",
})
eventUid!: string;
@IsString()
@ApiProperty({
description: "The id of the calendar the event is created in",
nullable: true,
})
destinationCalendarId!: string | null;
@IsNumber()
@ApiProperty({
description: "The id of the booking reference",
})
id!: number;
}
export class BookingReferencesOutput_2024_08_13 {
@ApiProperty({
description: "The status of the request, always 'success' for successful responses",
example: SUCCESS_STATUS,
})
@IsEnum([SUCCESS_STATUS, ERROR_STATUS])
status!: typeof SUCCESS_STATUS | typeof ERROR_STATUS;
@ApiProperty({
description: "Booking References",
type: [BookingReference],
})
data!: BookingReference[];
}
@@ -0,0 +1,46 @@
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
import { Injectable, NotFoundException, BadRequestException } from "@nestjs/common";
@Injectable()
export class BookingReferencesService_2024_08_13 {
constructor(
private readonly bookingsRepository: BookingsRepository_2024_08_13,
private readonly bookingReferencesRepository: BookingReferencesRepository_2024_08_13,
private readonly outputBookingReferencesService: OutputBookingReferencesService_2024_08_13
) {}
async getBookingReferences(
bookingUid: string,
userId: number,
filter?: BookingReferencesFilterInput_2024_08_13
) {
const booking = await this.bookingsRepository.getByUidWithUser(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid ${bookingUid} not found`);
}
if (booking.user?.id !== userId) {
throw new BadRequestException(`Booking with uid ${bookingUid} does not belong to user`);
}
const bookingReferences = await this.bookingReferencesRepository.getBookingReferences(booking.id, filter);
return this.outputBookingReferencesService.getOutputBookingReferences(bookingReferences);
}
async getOrgBookingReferences(bookingUid: string, filter?: BookingReferencesFilterInput_2024_08_13) {
const booking = await this.bookingsRepository.getByUidWithUser(bookingUid);
if (!booking) {
throw new NotFoundException(`Booking with uid ${bookingUid} not found`);
}
const bookingReferences = await this.bookingReferencesRepository.getBookingReferences(booking.id, filter);
return this.outputBookingReferencesService.getOutputBookingReferences(bookingReferences);
}
}
@@ -0,0 +1,20 @@
import { Injectable } from "@nestjs/common";
interface IBookingReference {
type: string;
uid: string;
id: number;
externalCalendarId: string | null;
}
@Injectable()
export class OutputBookingReferencesService_2024_08_13 {
getOutputBookingReferences(bookingReferences: IBookingReference[]) {
return bookingReferences.map((bookingReference) => ({
type: bookingReference.type,
eventUid: bookingReference.uid,
id: bookingReference.id,
destinationCalendarId: bookingReference?.externalCalendarId,
}));
}
}
@@ -22,7 +22,7 @@ import {
RecurringBookingOutput_2024_08_13,
SeatedAttendee,
} from "@calcom/platform-types";
import { Booking, BookingSeat } from "@calcom/prisma/client";
import { Booking, BookingReference, BookingSeat } from "@calcom/prisma/client";
export const bookingResponsesSchema = z
.object({
@@ -126,36 +126,6 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
await app.init();
});
afterAll(async () => {
await prismaWriteService.prisma.app_RoutingForms_FormResponse.deleteMany({
where: {
formId: routingForm.id,
},
});
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
where: {
teamId: org.id,
},
});
await prismaWriteService.prisma.apiKey.deleteMany({
where: {
teamId: org.id,
},
});
await prismaWriteService.prisma.team.delete({
where: {
id: team.id,
},
});
await prismaWriteService.prisma.team.delete({
where: {
id: org.id,
},
});
await app.close();
});
describe(`GET /v2/organizations/:orgId/routing-forms/:routingFormId/responses`, () => {
it("should not get routing form responses for non existing org", async () => {
return request(app.getHttpServer())
@@ -261,4 +231,44 @@ describe("OrganizationsRoutingFormsResponsesController", () => {
});
});
});
afterAll(async () => {
await prismaWriteService.prisma.app_RoutingForms_FormResponse.delete({
where: {
id: routingFormResponse.id,
},
});
await prismaWriteService.prisma.app_RoutingForms_FormResponse.delete({
where: {
id: routingFormResponse2.id,
},
});
await prismaWriteService.prisma.app_RoutingForms_Form.deleteMany({
where: {
teamId: org.id,
},
});
await prismaWriteService.prisma.apiKey.deleteMany({
where: {
teamId: org.id,
},
});
await prismaWriteService.prisma.team.delete({
where: {
id: team.id,
},
});
await prismaWriteService.prisma.team.delete({
where: {
id: org.id,
},
});
await prismaWriteService.prisma.user.delete({
where: {
id: user.id,
},
});
await app.close();
});
});
@@ -1,13 +1,18 @@
import { BookingUidGuard } from "@/ee/bookings/2024-08-13/guards/booking-uid.guard";
import { BookingReferencesFilterInput_2024_08_13 } from "@/ee/bookings/2024-08-13/inputs/booking-references-filter.input";
import { BookingReferencesOutput_2024_08_13 } from "@/ee/bookings/2024-08-13/outputs/booking-references.output";
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
import { BookingsService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/bookings.service";
import { API_VERSIONS_VALUES } from "@/lib/api-versions";
import {
OPTIONAL_X_CAL_CLIENT_ID_HEADER,
OPTIONAL_X_CAL_SECRET_KEY_HEADER,
OPTIONAL_API_KEY_HEADER,
API_KEY_OR_ACCESS_TOKEN_HEADER,
} from "@/lib/docs/headers";
import { PlatformPlan } from "@/modules/auth/decorators/billing/platform-plan.decorator";
import { GetUser } from "@/modules/auth/decorators/get-user/get-user.decorator";
import { Permissions } from "@/modules/auth/decorators/permissions/permissions.decorator";
import { Roles } from "@/modules/auth/decorators/roles/roles.decorator";
import { ApiAuthGuard } from "@/modules/auth/guards/api-auth/api-auth.guard";
import { PlatformPlanGuard } from "@/modules/auth/guards/billing/platform-plan.guard";
@@ -20,7 +25,7 @@ import { UserWithProfile } from "@/modules/users/users.repository";
import { Controller, UseGuards, Get, Param, ParseIntPipe, Query, HttpStatus, HttpCode } from "@nestjs/common";
import { ApiHeader, ApiOperation, ApiTags as DocsTags } from "@nestjs/swagger";
import { SUCCESS_STATUS } from "@calcom/platform-constants";
import { SUCCESS_STATUS, BOOKING_READ } from "@calcom/platform-constants";
import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
@Controller({
@@ -33,7 +38,10 @@ import { GetBookingsOutput_2024_08_13 } from "@calcom/platform-types";
@ApiHeader(OPTIONAL_X_CAL_SECRET_KEY_HEADER)
@ApiHeader(OPTIONAL_API_KEY_HEADER)
export class OrganizationsTeamsBookingsController {
constructor(private readonly bookingsService: BookingsService_2024_08_13) {}
constructor(
private readonly bookingsService: BookingsService_2024_08_13,
private readonly bookingReferencesService: BookingReferencesService_2024_08_13
) {}
@Get("/")
@ApiOperation({ summary: "Get organization team bookings" })
@@ -56,4 +64,33 @@ export class OrganizationsTeamsBookingsController {
data: bookings,
};
}
@Get("/:bookingUid/references")
@PlatformPlan("SCALE")
@Roles("TEAM_ADMIN")
@Permissions([BOOKING_READ])
@UseGuards(
ApiAuthGuard,
BookingUidGuard,
IsOrgGuard,
RolesGuard,
IsTeamInOrg,
PlatformPlanGuard,
IsAdminAPIEnabledGuard
)
@ApiOperation({
summary: "Get 'Booking References' for a booking",
})
@HttpCode(HttpStatus.OK)
async getBookingReferences(
@Param("bookingUid") bookingUid: string,
@Query() filter: BookingReferencesFilterInput_2024_08_13
): Promise<BookingReferencesOutput_2024_08_13> {
const bookingReferences = await this.bookingReferencesService.getOrgBookingReferences(bookingUid, filter);
return {
status: SUCCESS_STATUS,
data: bookingReferences,
};
}
}
@@ -1,4 +1,8 @@
import { BookingReferencesRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/booking-references.repository";
import { BookingsModule_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.module";
import { BookingsRepository_2024_08_13 } from "@/ee/bookings/2024-08-13/bookings.repository";
import { BookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/booking-references.service";
import { OutputBookingReferencesService_2024_08_13 } from "@/ee/bookings/2024-08-13/services/output-booking-references.service";
import { MembershipsModule } from "@/modules/memberships/memberships.module";
import { OrganizationsRepository } from "@/modules/organizations/index/organizations.repository";
import { OrganizationsTeamsBookingsController } from "@/modules/organizations/teams/bookings/organizations-teams-bookings.controller";
@@ -10,7 +14,14 @@ import { Module } from "@nestjs/common";
@Module({
imports: [BookingsModule_2024_08_13, PrismaModule, StripeModule, RedisModule, MembershipsModule],
providers: [OrganizationsRepository, OrganizationsTeamsRepository],
providers: [
OrganizationsRepository,
OrganizationsTeamsRepository,
BookingReferencesService_2024_08_13,
BookingReferencesRepository_2024_08_13,
BookingsRepository_2024_08_13,
OutputBookingReferencesService_2024_08_13,
],
controllers: [OrganizationsTeamsBookingsController],
})
export class OrganizationsTeamsBookingsModule {}
@@ -35,7 +35,7 @@ describe("Organizations Teams Routing Forms Responses", () => {
let org: Team;
let orgTeam: Team;
let routingFormResponseId: string;
let routingFormResponseId: number;
const authEmail = `organizations-teams-routing-forms-responses-user-${randomString()}@api.com`;
let user: User;
let apiKeyString: string;
@@ -43,8 +43,7 @@ describe("Organizations Teams Routing Forms Responses", () => {
let routingFormId: string;
const routingFormResponses = [
{
id: 1,
formFillerId: "cm78tvkvd0001kh8jq0tu5iq9",
formFillerId: `${randomString()}`,
response: {
"participant-field": {
label: "participant",
@@ -184,7 +183,6 @@ describe("Organizations Teams Routing Forms Responses", () => {
const responseData = responseBody.data;
expect(responseData).toBeDefined();
expect(responseData.length).toEqual(1);
expect(responseData[0].id).toEqual(routingFormResponses[0].id);
expect(responseData[0].response).toEqual(routingFormResponses[0].response);
expect(responseData[0].formFillerId).toEqual(routingFormResponses[0].formFillerId);
expect(responseData[0].createdAt).toEqual(routingFormResponses[0].createdAt.toISOString());
@@ -248,6 +246,8 @@ describe("Organizations Teams Routing Forms Responses", () => {
});
afterAll(async () => {
await routingFormsRepositoryFixture.deleteResponse(routingFormResponseId);
await routingFormsRepositoryFixture.delete(routingFormId);
await userRepositoryFixture.deleteByEmail(user.email);
await organizationsRepositoryFixture.delete(org.id);
await app.close();
@@ -141,7 +141,16 @@ export class OrganizationsUsersRepository {
return await this.dbWrite.prisma.user.delete({
where: {
id: userId,
organizationId: orgId,
OR: [
{ organizationId: orgId },
{
profiles: {
some: {
organizationId: orgId,
},
},
},
],
},
include: {
profiles: {
@@ -181,7 +181,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
title: "frisbee match",
slug: `slots-2024-09-04-variable-length-event-type-${randomString()}`,
length: 15,
metadata: { multipleDuration: [15, 30, 45, 60] },
metadata: { multipleDuration: [15, 30, 45, 60, 180] },
},
user.id
);
@@ -506,6 +506,65 @@ describe("Slots 2024-09-04 Endpoints", () => {
expect(reserveResponseBody.data).toEqual(rest);
});
describe("overlapping slot reservations", () => {
it("start of request slot overlaps already existing reserved slot", async () => {
// Try to reserve 10:15-11:15 when 10:00-11:00 is taken (60 min event)
const newSlotStart = DateTime.fromISO(reservedSlot.slotStart).plus({ minutes: 15 }).toISO();
await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId,
slotStart: newSlotStart,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(422)
.then((response) => {
expect(response.body.error.message).toEqual(
"This time slot is already reserved by another user. Please choose a different time."
);
});
});
it("end of request slot overlaps already existing reserved slot", async () => {
// Try to reserve 9:45-10:45 when 10:00-11:00 is taken (60 min event)
const newSlotStart = DateTime.fromISO(reservedSlot.slotStart).minus({ minutes: 15 }).toISO();
await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId,
slotStart: newSlotStart,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(422)
.then((response) => {
expect(response.body.error.message).toEqual(
"This time slot is already reserved by another user. Please choose a different time."
);
});
});
it("request slot is inside already existing reserved slot", async () => {
// Try to reserve 10:10-11:10 when 10:00-11:00 is taken (60 min event)
const newSlotStart = DateTime.fromISO(reservedSlot.slotStart).plus({ minutes: 10 }).toISO();
await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId,
slotStart: newSlotStart,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.expect(422)
.then((response) => {
expect(response.body.error.message).toEqual(
"This time slot is already reserved by another user. Please choose a different time."
);
});
});
});
it("should update a reserved slot and it should not appear in available slots", async () => {
// note(Lauris): mock current date to test slots release time
const now = "2049-09-05T14:00:00.000Z";
@@ -1266,6 +1325,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
});
describe("variable length", () => {
let responseReservedVariableSlot: ReserveSlotOutputData_2024_09_04;
it("should not be able to reserve a slot for variable length event type with invalid duration", async () => {
const slotStartTime = "2050-09-05T10:00:00.000Z";
const reserveResponse = await request(app.getHttpServer())
@@ -1279,7 +1339,7 @@ describe("Slots 2024-09-04 Endpoints", () => {
.expect(400);
expect(reserveResponse.body.error.message).toEqual(
"Provided 'slotDuration' is not one of the possible lengths for the event type. The possible lengths for this variable length event type are: 15, 30, 45, 60"
"Provided 'slotDuration' is not one of the possible lengths for the event type. The possible lengths for this variable length event type are: 15, 30, 45, 60, 180"
);
});
@@ -1303,17 +1363,17 @@ describe("Slots 2024-09-04 Endpoints", () => {
const reserveResponseBody: ReserveSlotOutputResponse_2024_09_04 = reserveResponse.body;
expect(reserveResponseBody.status).toEqual(SUCCESS_STATUS);
const responseReservedSlot: ReserveSlotOutputData_2024_09_04 = reserveResponseBody.data;
expect(responseReservedSlot.reservationUid).toBeDefined();
expect(responseReservedSlot.eventTypeId).toEqual(variableLengthEventType.id);
expect(responseReservedSlot.slotStart).toEqual(slotStartTime);
expect(responseReservedSlot.slotDuration).toEqual(slotDuration);
expect(responseReservedSlot.slotEnd).toEqual(
responseReservedVariableSlot = reserveResponseBody.data;
expect(responseReservedVariableSlot.reservationUid).toBeDefined();
expect(responseReservedVariableSlot.eventTypeId).toEqual(variableLengthEventType.id);
expect(responseReservedVariableSlot.slotStart).toEqual(slotStartTime);
expect(responseReservedVariableSlot.slotDuration).toEqual(slotDuration);
expect(responseReservedVariableSlot.slotEnd).toEqual(
DateTime.fromISO(slotStartTime, { zone: "UTC" }).plus({ minutes: slotDuration }).toISO()
);
expect(responseReservedSlot.reservationDuration).toEqual(5);
expect(responseReservedVariableSlot.reservationDuration).toEqual(5);
if (!responseReservedSlot.reservationUid) {
if (!responseReservedVariableSlot.reservationUid) {
throw new Error("Reserved slot uid is undefined");
}
@@ -1337,16 +1397,41 @@ describe("Slots 2024-09-04 Endpoints", () => {
);
expect(slots).toEqual({ ...expectedSlotsUTC, "2050-09-05": expectedSlotsUTC2050_09_05 });
const dbSlot = await selectedSlotsRepositoryFixture.getByUid(reservedSlot.reservationUid);
const dbSlot = await selectedSlotsRepositoryFixture.getByUid(
responseReservedVariableSlot.reservationUid
);
expect(dbSlot).toBeDefined();
if (dbSlot) {
const dbReleaseAt = DateTime.fromJSDate(dbSlot.releaseAt, { zone: "UTC" }).toISO();
const expectedReleaseAt = DateTime.fromISO(now, { zone: "UTC" }).plus({ minutes: 5 }).toISO();
expect(dbReleaseAt).toEqual(expectedReleaseAt);
expect(responseReservedSlot.reservationUntil).toEqual(expectedReleaseAt);
expect(responseReservedVariableSlot.reservationUntil).toEqual(expectedReleaseAt);
}
clear();
});
it("request slot contains already existing reserved slot", async () => {
// Try to reserve 9:45-12:45 when 10:00-11:00 is taken
const newSlotStart = DateTime.fromISO(responseReservedVariableSlot.slotStart)
.minus({ minutes: 15 })
.toISO();
await request(app.getHttpServer())
.post(`/v2/slots/reservations`)
.send({
eventTypeId: variableLengthEventType.id,
slotStart: newSlotStart,
slotDuration: 180,
})
.set(CAL_API_VERSION_HEADER, VERSION_2024_09_04)
.set("Authorization", `Bearer cal_test_${apiKeyString}`)
.expect(422)
.then((response) => {
expect(response.body.error.message).toEqual(
"This time slot is already reserved by another user. Please choose a different time."
);
});
});
});
describe("out of office", () => {
@@ -124,6 +124,8 @@ export class SlotsService_2024_09_04 {
const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION;
await this.checkSlotOverlap(input.eventTypeId, startDate.toISO(), endDate.toISO());
if (eventType.userId) {
const slot = await this.slotsRepository.createSlot(
eventType.userId,
@@ -153,6 +155,20 @@ export class SlotsService_2024_09_04 {
return this.slotsOutputService.getReservationSlotCreated(slot, reservationDuration);
}
private async checkSlotOverlap(eventTypeId: number, startDate: string, endDate: string) {
const overlappingReservation = await this.slotsRepository.getOverlappingSlotReservation(
eventTypeId,
startDate,
endDate
);
if (overlappingReservation) {
throw new UnprocessableEntityException(
`This time slot is already reserved by another user. Please choose a different time.`
);
}
}
validateSlotDuration(eventType: EventType, inputSlotDuration: number) {
const eventTypeMetadata = eventTypeMetadataSchema.parse(eventType.metadata);
if (!eventTypeMetadata?.multipleDuration) {
@@ -257,6 +273,8 @@ export class SlotsService_2024_09_04 {
const reservationDuration = input.reservationDuration ?? DEFAULT_RESERVATION_DURATION;
await this.checkSlotOverlap(input.eventTypeId, startDate.toISO(), endDate.toISO());
const slot = await this.slotsRepository.updateSlot(
eventType.id,
startDate.toISO(),
@@ -19,6 +19,30 @@ export class SlotsRepository_2024_09_04 {
});
}
async getOverlappingSlotReservation(eventTypeId: number, startDate: string, endDate: string) {
return this.dbRead.prisma.selectedSlots.findFirst({
where: {
eventTypeId,
AND: [
{
OR: [
// Case 1: New slot starts during an existing slot
{ slotUtcStartDate: { lte: startDate }, slotUtcEndDate: { gt: startDate } },
// Case 2: New slot ends during an existing slot
{ slotUtcStartDate: { lt: endDate }, slotUtcEndDate: { gte: endDate } },
// Case 3: New slot is completely inside an existing slot
{ slotUtcStartDate: { lte: startDate }, slotUtcEndDate: { gte: endDate } },
// Case 4: New slot completely overlaps an existing slot
{ slotUtcStartDate: { gte: startDate }, slotUtcEndDate: { lte: endDate } },
],
},
// Only consider non-expired reservations
{ releaseAt: { gt: DateTime.utc().toJSDate() } },
],
},
});
}
async createSlot(
userId: number,
eventTypeId: number,
@@ -112,7 +112,7 @@ export class TeamsMembershipsController {
@Roles("TEAM_ADMIN")
@Patch("/:membershipId")
@ApiOperation({ summary: "Create a membership" })
@ApiOperation({ summary: "Update membership" })
async updateTeamMembership(
@Param("teamId", ParseIntPipe) teamId: number,
@Param("membershipId", ParseIntPipe) membershipId: number,
+197 -6
View File
@@ -3282,6 +3282,82 @@
]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/bookings/{bookingUid}/references": {
"get": {
"operationId": "OrganizationsTeamsBookingsController_getBookingReferences",
"summary": "Get 'Booking References' for a booking",
"parameters": [
{
"name": "Authorization",
"in": "header",
"description": "For non-platform customers - value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-secret-key",
"in": "header",
"description": "For platform customers - OAuth client secret key",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-client-id",
"in": "header",
"description": "For platform customers - OAuth client ID",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "type",
"required": false,
"in": "query",
"description": "Filter booking references by type",
"example": "google_calendar",
"schema": {
"enum": [
"google_calendar",
"office365_calendar",
"daily_video",
"google_video",
"office365_video",
"zoom_video"
],
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BookingReferencesOutput_2024_08_13"
}
}
}
}
},
"tags": [
"Orgs / Teams / Bookings"
]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/conferencing/{app}/connect": {
"post": {
"operationId": "OrganizationsConferencingController_connectTeamApp",
@@ -7448,6 +7524,74 @@
]
}
},
"/v2/bookings/{bookingUid}/references": {
"get": {
"operationId": "BookingsController_2024_08_13_getBookingReferences",
"summary": "Get 'Booking References' for a booking",
"parameters": [
{
"name": "cal-api-version",
"in": "header",
"description": "Must be set to 2024-08-13",
"required": true,
"schema": {
"type": "string",
"default": "2024-08-13"
}
},
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "type",
"required": false,
"in": "query",
"description": "Filter booking references by type",
"example": "google_calendar",
"schema": {
"enum": [
"google_calendar",
"office365_calendar",
"daily_video",
"google_video",
"office365_video",
"zoom_video"
],
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BookingReferencesOutput_2024_08_13"
}
}
}
}
},
"tags": [
"Bookings"
]
}
},
"/v2/calendars/ics-feed/save": {
"post": {
"operationId": "CalendarsController_createIcsFeed",
@@ -11506,7 +11650,7 @@
},
"patch": {
"operationId": "TeamsMembershipsController_updateTeamMembership",
"summary": "Create a membership",
"summary": "Update membership",
"parameters": [
{
"name": "Authorization",
@@ -14955,8 +15099,7 @@
"eightxeight-video",
"discord-video",
"demodesk-video",
"campfire-video",
"around-video"
"campfire-video"
],
"example": "cal-video"
},
@@ -21636,7 +21779,7 @@
"type": "object",
"properties": {
"id": {
"type": "string"
"type": "number"
},
"formId": {
"type": "string"
@@ -22189,8 +22332,7 @@
"eightxeight-video",
"discord-video",
"demodesk-video",
"campfire-video",
"around-video"
"campfire-video"
]
}
},
@@ -24144,6 +24286,55 @@
"data"
]
},
"BookingReference": {
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "The type of the booking reference"
},
"eventUid": {
"type": "string",
"description": "The event uid of the booking"
},
"destinationCalendarId": {
"type": "string",
"nullable": true,
"description": "The id of the calendar the event is created in"
},
"id": {
"type": "number",
"description": "The id of the booking reference"
}
},
"required": [
"type",
"eventUid",
"destinationCalendarId",
"id"
]
},
"BookingReferencesOutput_2024_08_13": {
"type": "object",
"properties": {
"status": {
"type": "object",
"description": "The status of the request, always 'success' for successful responses",
"example": "success"
},
"data": {
"description": "Booking References",
"type": "array",
"items": {
"$ref": "#/components/schemas/BookingReference"
}
}
},
"required": [
"status",
"data"
]
},
"CreateTeamMembershipInput": {
"type": "object",
"properties": {
@@ -1,7 +1,7 @@
import { PrismaReadService } from "@/modules/prisma/prisma-read.service";
import { PrismaWriteService } from "@/modules/prisma/prisma-write.service";
import { TestingModule } from "@nestjs/testing";
import { Prisma, App_RoutingForms_Form } from "@prisma/client";
import { Prisma, App_RoutingForms_Form, App_RoutingForms_FormResponse } from "@prisma/client";
export class RoutingFormsRepositoryFixture {
private prismaReadClient: PrismaReadService["prisma"];
@@ -23,4 +23,10 @@ export class RoutingFormsRepositoryFixture {
async delete(routingFormId: App_RoutingForms_Form["id"]) {
return this.prismaWriteClient.app_RoutingForms_Form.delete({ where: { id: routingFormId } });
}
async deleteResponse(routingFormResponseId: App_RoutingForms_FormResponse["id"]) {
return this.prismaWriteClient.app_RoutingForms_FormResponse.delete({
where: { id: routingFormResponseId },
});
}
}
+9
View File
@@ -1,5 +1,14 @@
# @calcom/web
## 5.2.8
### Patch Changes
- Updated dependencies
- @calcom/embed-core@1.5.3
- @calcom/embed-react@1.5.3
- @calcom/embed-snippet@1.3.3
## 4.8.7
### Patch Changes
@@ -2,22 +2,29 @@
## Setting up Delegation Credential for Google Calendar API
Step 1: Create a Google Cloud Project
Step 0: Create a Workspace Platform(to be done once for the Cal.com instance, by the Cal.com instance admin)
- Create a Workspace Platform through admin interface at <https://app.cal.com/settings/admin/workspace-platforms>
- Use slug="google". Slug has to be exactly this. This is how we know we need to use Google Calendar and Google Meet.
- Use "Google" as the name of the workspace platform. Change it as per your liking.
- Optionally provide description for the workspace platform.
Step 1: Create a Google Cloud Project or use existing one(to be done once for the Cal.com instance)
Before you can create a service account, you'll need to set up a Google Cloud project.
1. Create a Google Cloud Project:
- Go to the Google Cloud Console
- Select Create Project
- Give your project a name and select your billing account (if applicable)
- Click Create
1. Go to the Google Cloud Console
2. Select Create Project
3. Give your project a name and select your billing account (if applicable)
4. Click Create
2. Enable the Google Calendar API:
1. Go to the Google Cloud Console
2. Select API & Services → Library
3. Search for "Google Calendar API"
4. Click Enable
Step 2: Create a Service Account
Step 2: Create a Service Account in Google Cloud Console(to be done for every organization)
A service account is needed to act on behalf of users
@@ -27,21 +34,38 @@ A service account is needed to act on behalf of users
- Click on Create Service Account
- Give your service account a name and description
- Click Create and Continue(Optional steps can be skipped)
3. Download the Service Account Key JSON file
Step 3: To Be taken by Cal.com instance admin:
Step 3: Create Delegation Credential(To Be taken by Cal.com instance admin):
- Create a Workspace Platform with slug="google". Slug has to be exactly this. This is how we know we need to use Google Calendar and Google Meet.
- Impersonate the organization owner and go to https://app.cal.com/settings/organizations/delegation-credential to create a Delegation Credential
Last Step (To Be Taken By Cal.com organization Owner/Admin): Assign Specific API Permissions via OAuth Scopes:
- Create Delegation Credential with workspace platform "google"
- User must be a member of the Google Workspace to be able to enable Delegation Credential as there is a validation if the user's calendar can be accessed through the service account
- Get the Client ID from there
- Go to your Google Admin Console (admin-google-com)
- Use domain as "acme.com" if @acme.com is the email address for your Google Workspace
- Choose Workspace Platform as "Google"
- Add Service Account Key JSON file(obtained in Step 2)
- Click on "Create" button
Step 4: Copy the Client ID and OAuth Scope (To Be Taken By Cal.com organization Owner/Admin in Cal.com):
- Go to https://app.cal.com/settings/organizations/delegation-credential
- Copy the Client ID for your Google Workspace domain(e.g. acme.com)
- Client ID is a number like 123456789012345678901 that
Step 5: Add Client ID under Domain-Wide Delegation (To Be taken By Google Workspace Admin):
- Go to your Google Admin Console(admin.google.com)
- Navigate to Security → Access and Data Controls -> API controls -> Manage Domain-Wide Delegation
- Here, you'll authorize the Client ID(Unique ID) to access the Google Calendar API
- Add the following API scope for Google Calendar(Full access to Google Calendar)
- Here, you'll authorize the Client ID to access the Google Calendar API
- Add the following API scope for Google Calendar(Full access to Google Calendar. We use it to read freebusy time and create/update events in the members' calendars)
- `https://www.googleapis.com/auth/calendar`
Step 6: Enable Delegation Credential(To Be taken By Cal.com organization Owner/Admin in Cal.com):
- Prerequisite: The owner/admin must be part of the Google Workspace to enable Delegation Credential
- Go to https://app.cal.com/settings/organizations/delegation-credential
- Enable Delegation Credential
- If you have added the Client ID for correct Google Workspace, the Delegation Credential would be enabled, otherwise you would see an error message, that should help and contact support if you still face issues.
## Onboarding Improvements
- Just adding a member to the organization would do the following:
@@ -7,6 +7,7 @@ import {
updateMeetingTokenIfExpired,
} from "@calcom/app-store/dailyvideo/lib/VideoApiAdapter";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
import { getCalVideoReference } from "@calcom/features/get-cal-video-reference";
import { CAL_VIDEO_MEETING_LINK_FOR_TESTING } from "@calcom/lib/constants";
import { isENVDev } from "@calcom/lib/env";
@@ -135,6 +136,11 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const videoReference = getCalVideoReference(bookingObj.references);
const featureRepo = new FeaturesRepository();
const displayLogInOverlay = profile?.organizationId
? await featureRepo.checkIfTeamHasFeature(profile.organizationId, "cal-video-log-in-overlay")
: false;
return {
props: {
meetingUrl: videoReference.meetingUrl ?? "",
@@ -153,6 +159,8 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
},
hasTeamPlan: !!hasTeamPlan,
calVideoLogo,
displayLogInOverlay,
loggedInUserName: sessionUserId ? session?.user?.name : undefined,
},
};
}
@@ -152,28 +152,6 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
slug: eventType.team?.slug || eventType.users[0]?.username || null,
};
if (bookingInfo !== null && eventType.seatsPerTimeSlot) {
await handleSeatsEventTypeOnBooking(
eventType,
bookingInfo,
seatReferenceUid,
session?.user.id === eventType.userId
);
}
const payment = await prisma.payment.findFirst({
where: {
bookingId: bookingInfo.id,
},
select: {
success: true,
refunded: true,
currency: true,
amount: true,
paymentOption: true,
},
});
const userId = session?.user?.id;
const checkIfUserIsHost = (userId?: number | null) => {
@@ -194,6 +172,23 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
const isLoggedInUserHost = checkIfUserIsHost(userId);
if (bookingInfo !== null && eventType.seatsPerTimeSlot) {
await handleSeatsEventTypeOnBooking(eventType, bookingInfo, seatReferenceUid, isLoggedInUserHost);
}
const payment = await prisma.payment.findFirst({
where: {
bookingId: bookingInfo.id,
},
select: {
success: true,
refunded: true,
currency: true,
amount: true,
paymentOption: true,
},
});
if (!isLoggedInUserHost) {
// Removing hidden fields from responses
for (const key in bookingInfo.responses) {
@@ -7,12 +7,15 @@ import { useState, useEffect, useRef } from "react";
import dayjs from "@calcom/dayjs";
import { WEBSITE_URL } from "@calcom/lib/constants";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { TRANSCRIPTION_STOPPED_ICON, RECORDING_DEFAULT_ICON } from "@calcom/lib/constants";
import { formatToLocalizedDate, formatToLocalizedTime } from "@calcom/lib/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
import type { inferSSRProps } from "@calcom/types/inferSSRProps";
import classNames from "@calcom/ui/classNames";
import { Button } from "@calcom/ui/components/button";
import { Dialog, DialogContent } from "@calcom/ui/components/dialog";
import { Icon } from "@calcom/ui/components/icon";
import type { getServerSideProps } from "@lib/video/[uid]/getServerSideProps";
@@ -22,7 +25,15 @@ import { CalAiTranscribe } from "~/videos/ai/ai-transcribe";
export type PageProps = inferSSRProps<typeof getServerSideProps>;
export default function JoinCall(props: PageProps) {
const { meetingUrl, meetingPassword, booking, hasTeamPlan, calVideoLogo } = props;
const {
meetingUrl,
meetingPassword,
booking,
hasTeamPlan,
calVideoLogo,
displayLogInOverlay,
loggedInUserName,
} = props;
const [daily, setDaily] = useState<DailyCall | null>(null);
useEffect(() => {
@@ -50,6 +61,7 @@ export default function JoinCall(props: PageProps) {
height: "100%",
},
url: meetingUrl,
userName: loggedInUserName ? loggedInUserName ?? undefined : undefined,
...(typeof meetingPassword === "string" && { token: meetingPassword }),
...(hasTeamPlan && {
customTrayButtons: {
@@ -112,6 +124,8 @@ export default function JoinCall(props: PageProps) {
/>
)}
</div>
{displayLogInOverlay && <LogInOverlay isLoggedIn={!!loggedInUserName} bookingUid={booking.uid} />}
<VideoMeetingInfo booking={booking} />
</DailyProvider>
);
@@ -187,6 +201,58 @@ function ProgressBar(props: ProgressBarProps) {
);
}
interface LogInOverlayProps {
isLoggedIn: boolean;
bookingUid: string;
}
export function LogInOverlay(props: LogInOverlayProps) {
const { t } = useLocale();
const { isLoggedIn, bookingUid } = props;
const [open, setOpen] = useState(!isLoggedIn);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
title={t("join_video_call")}
description={t("choose_how_you_d_like_to_join_call")}
className="bg-black text-white sm:max-w-[480px]">
<div className="pb-8">
<div className="space-y-8">
<Button color="primary" className="mt-4 w-full justify-center " onClick={() => setOpen(false)}>
{t("continue_as_guest")}
</Button>
{/* Divider */}
<div className="relative py-2">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-gray-600" />
</div>
<div className="relative flex justify-center">
<span className="bg-black px-4 text-sm text-gray-400">{t("or")}</span>
</div>
</div>
<div className="space-y-3">
<h4 className="text-lg font-semibold text-white">{t("sign_in_to_cal_com")}</h4>
<p className="text-sm text-gray-300">{t("track_your_meetings")}</p>
<Button
color="primary"
className="mt-4 w-full justify-center"
onClick={() =>
(window.location.href = `${WEBAPP_URL}/auth/login?callbackUrl=${WEBAPP_URL}/video/${bookingUid}`)
}>
<Icon name="external-link" className="mr-2 h-4 w-4" />
{t("log_in_to_cal_com")}
</Button>
</div>
</div>
</div>
</DialogContent>
</Dialog>
);
}
interface VideoMeetingInfo {
booking: PageProps["booking"];
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/web",
"version": "5.2.11",
"version": "5.2.13",
"private": true,
"scripts": {
"analyze": "ANALYZE=true next build",
+43
View File
@@ -0,0 +1,43 @@
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import { getRoutedUrl } from "@calcom/lib/server/getRoutedUrl";
export default defaultHandler({
OPTIONS: Promise.resolve({
default: defaultResponder(async (req, res) => {
res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, cache-control, pragma");
res.status(204).end();
}),
}),
POST: Promise.resolve({
default: defaultResponder(async (req, res) => {
// getRoutedUrl has more detailed schema validation, we do a basic one here.
const params = req.body;
res.setHeader("Access-Control-Allow-Origin", "*");
const routedUrlData = await getRoutedUrl({ req, query: { ...params } });
if (routedUrlData?.notFound) {
return res.status(404).json({ status: "error", data: { message: "Form not found" } });
}
if (routedUrlData?.redirect?.destination) {
return res
.status(200)
.json({ status: "success", data: { redirect: routedUrlData.redirect.destination } });
}
if (routedUrlData?.props?.errorMessage) {
return res.status(400).json({ status: "error", data: { message: routedUrlData.props.errorMessage } });
}
if (routedUrlData?.props?.message) {
return res.status(200).json({ status: "success", data: { message: routedUrlData.props.message } });
}
return res
.status(500)
.json({ status: "error", data: { message: "Neither Route nor custom message found." } });
}),
}),
});
+2 -2
View File
@@ -8,7 +8,7 @@ import PageWrapper from "@components/PageWrapper";
import { getServerSideProps } from "../../server/lib/router/getServerSideProps";
export default function Router({ form, message }: inferSSRProps<typeof getServerSideProps>) {
export default function Router({ form, message, errorMessage }: inferSSRProps<typeof getServerSideProps>) {
return (
<>
<Head>
@@ -17,7 +17,7 @@ export default function Router({ form, message }: inferSSRProps<typeof getServer
<div className="mx-auto my-0 max-w-3xl md:my-24">
<div className="w-full max-w-4xl ltr:mr-2 rtl:ml-2">
<div className="text-default bg-default -mx-4 rounded-sm border border-neutral-200 p-4 py-6 sm:mx-0 sm:px-8">
<div>{message}</div>
<div>{message || errorMessage}</div>
</div>
</div>
</div>
@@ -1,6 +1,7 @@
import { test } from "../../lib/fixtures";
import type { TApp } from "./types";
// Around is not available in the app store anymore
const APP: TApp = {
slug: "around",
type: "integrations:around_video",
@@ -11,7 +12,8 @@ const APP: TApp = {
test.describe.configure({ mode: "parallel" });
test.afterEach(({ users }) => users.deleteAll());
test.describe("check non-oAuth link-based conferencing apps", () => {
// TODO: remove skip once more apps are added
test.skip("check non-oAuth link-based conferencing apps", () => {
test(`check conferencing app: ${APP.slug} by skipping the configure step`, async ({
appsPage,
page,
@@ -24,7 +26,7 @@ test.describe("check non-oAuth link-based conferencing apps", () => {
});
});
test.describe("check non-oAuth link-based conferencing apps using the new flow", () => {
test.skip("check non-oAuth link-based conferencing apps using the new flow", () => {
test(`can add ${APP.slug} app and book with it`, async ({ appsPage, page, users }) => {
const user = await users.create();
await user.apiLogin();
+5
View File
@@ -23,6 +23,11 @@ test.describe("hash my url", () => {
// We wait until loading is finished
await page.waitForSelector('[data-testid="event-types"]');
await page.locator("ul[data-testid=event-types] > li a").first().click();
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toHaveAttribute(
"aria-current",
"page"
); // fix the race condition
await expect(page.getByTestId("vertical-tab-event_setup_tab_title")).toContainText("Event Setup"); //fix the race condition
// We wait for the page to load
await page.locator(".primary-navigation >> text=Advanced").click();
// ignore if it is already checked, and click if unchecked
+2 -5
View File
@@ -50,16 +50,13 @@ test.describe("2FA Tests", async () => {
secret: secret!,
});
// FIXME: this passes even when switch is not checked, compare to test
// below which checks for data-state="checked" and works as expected
await page.waitForSelector(`[data-testid=two-factor-switch]`);
await expect(page.locator(`[data-testid=two-factor-switch]`).isChecked()).toBeTruthy();
await expect(page.getByTestId("backup-codes-download")).toBeVisible();
return user;
});
await test.step("Logout", async () => {
await page.goto("/auth/logout");
await users.logout();
});
await test.step("Login with 2FA enabled", async () => {
@@ -295,6 +295,167 @@ test.describe("Bookings", () => {
await page.goto(`/org/${org.slug}/${teamSlugUpperCase}/${teamEventSlugUpperCase}`);
await page.waitForSelector("[data-testid=day]");
});
test("Round robin event type properly rotates hosts", async ({ page, users, orgs }) => {
const org = await orgs.create({
name: "TestOrg",
});
const teamMatesObj = [{ name: "teammate-1" }, { name: "teammate-2" }, { name: "teammate-3" }];
const owner = await users.create(
{
username: "pro-user",
name: "pro-user",
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
},
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.ROUND_ROBIN,
}
);
const { team } = await owner.getFirstTeamMembership();
const teamEvent = await owner.getFirstTeamEvent(team.id);
const eventHostsObj = [...teamMatesObj, { name: "pro-user" }];
await doOnOrgDomain(
{
orgSlug: org.slug,
page,
},
async () => {
await bookTeamEvent({ page, team, event: teamEvent, teamMatesObj: eventHostsObj });
const firstHost = await page.getByTestId("booking-host-name").textContent();
expect(firstHost).not.toBeNull();
// Second booking - should get a different host
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.getByTestId("success-page")).toBeVisible();
const secondHost = await page.getByTestId("booking-host-name").textContent();
expect(secondHost).not.toBeNull();
expect(secondHost).not.toBe(firstHost);
// Third booking - should get a different host
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.getByTestId("success-page")).toBeVisible();
const thirdHost = await page.getByTestId("booking-host-name").textContent();
expect(thirdHost).not.toBeNull();
expect(thirdHost).not.toBe(firstHost);
expect(thirdHost).not.toBe(secondHost);
// Fourth booking - should get a different host
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.getByTestId("success-page")).toBeVisible();
const fourthHost = await page.getByTestId("booking-host-name").textContent();
expect(fourthHost).not.toBeNull();
expect(fourthHost).not.toBe(firstHost);
expect(fourthHost).not.toBe(secondHost);
expect(fourthHost).not.toBe(thirdHost);
// Verify all hosts were used
const allHosts = [firstHost, secondHost, thirdHost, fourthHost];
const uniqueHosts = new Set(allHosts);
expect(uniqueHosts.size).toBe(4);
}
);
});
test("Round robin event type with confirmation required handles cancellation and rebooking correctly with the same details and slot", async ({
page,
users,
orgs,
}) => {
const org = await orgs.create({
name: "TestOrg",
});
const teamMatesObj = [
{ name: "teammate-1" },
{ name: "teammate-2" },
{ name: "teammate-3" },
{ name: "teammate-4" },
];
const owner = await users.create(
{
username: "pro-user",
name: "pro-user",
organizationId: org.id,
roleInOrganization: MembershipRole.MEMBER,
},
{
hasTeam: true,
teammates: teamMatesObj,
schedulingType: SchedulingType.ROUND_ROBIN,
}
);
const { team } = await owner.getFirstTeamMembership();
const teamEvent = await owner.getFirstTeamEvent(team.id);
const eventHostsObj = [...teamMatesObj, { name: "pro-user" }];
// Enable confirmation required for the event
await owner.apiLogin();
await page.goto(`/event-types/${teamEvent.id}?tabName=advanced`);
await page.getByTestId("requires-confirmation").click();
await page.getByTestId("update-eventtype").click();
await page.waitForResponse((response) => response.url().includes("/api/trpc/eventTypes/update"));
await doOnOrgDomain(
{
orgSlug: org.slug,
page,
},
async () => {
await bookTeamEvent({ page, team, event: teamEvent, teamMatesObj: eventHostsObj });
const firstHost = await page.getByTestId("booking-host-name").textContent();
expect(firstHost).not.toBeNull();
const bookingUid = page.url().split("/booking/")[1];
expect(bookingUid).not.toBeNull();
// Login as the host to cancel the booking
await page.goto("/auth/logout");
const allUsers = await users.get();
const hostUser = allUsers.find((mate) => mate.name === firstHost);
if (!hostUser) throw new Error("Host not found");
await hostUser.apiLogin();
// Cancel the booking
await page.goto(`/booking/${bookingUid}`);
await page.getByTestId("cancel").click();
await page.getByTestId("confirm_cancel").click();
await page.waitForResponse((response) => response.url().includes("/api/cancel"));
// Logout and go back to booking page
await page.goto("/auth/logout");
await page.goto(`/org/${org.slug}/${team.slug}/${teamEvent.slug}`);
// Rebook with the same details
await selectFirstAvailableTimeSlotNextMonth(page);
await bookTimeSlot(page);
await expect(page.getByTestId("success-page")).toBeVisible();
// Verify a new host is assigned
const newHost = await page.getByTestId("booking-host-name").textContent();
expect(newHost).not.toBeNull();
expect(newHost).toBe(firstHost);
// Verify the booking was successful by checking the new booking UID
const newBookingUid = page.url().split("/booking/")[1];
expect(newBookingUid).not.toBeNull();
expect(newBookingUid).not.toBe(bookingUid);
}
);
});
});
test.describe("User Event", () => {
@@ -620,6 +620,7 @@
"your_full_name": "Your full name",
"no_name": "No name",
"enter_number_between_range": "Please enter a number between 1 and {{maxOccurences}}",
"continue_as_guest_description": "Quick and easy - no account required. Perfect for one-time meetings.",
"email_address": "Email address",
"enter_valid_email": "Please enter a valid email",
"please_schedule_future_call": " Please schedule a future call if we're not available in {{seconds}} seconds",
@@ -629,6 +630,7 @@
"in_person_attendee_address": "In Person (Attendee Address)",
"somewhere_else": "Somewhere Else",
"custom_attendee_location": "Custom attendee location",
"continue_as_guest": "Continue as Guest",
"any_location": "Any location",
"yes": "Yes",
"no": "No",
@@ -1297,6 +1299,11 @@
"event_cancelled_trigger": "when event is canceled",
"after_hosts_cal_video_no_show": "After hosts didn't join cal video",
"after_guests_cal_video_no_show": "After guests didn't join cal video",
"join_video_call": "Join the video call",
"sign_in_to_cal_com": "Sign in to your Cal.com account",
"log_in_to_cal_com": "Log in to Cal.com",
"track_your_meetings": "Track your meetings, get notifications, and manage your schedule more effectively.",
"choose_how_you_d_like_to_join_call": "Choose how you'd like to join this call",
"new_event_trigger": "when new event is booked",
"email_host_action": "send email to host",
"email_attendee_action": "send email to attendees",
@@ -1566,6 +1573,7 @@
"workflow_turned_on_successfully": "{{workflowName}} workflow turned {{offOn}} successfully",
"download_responses": "Download responses",
"download_responses_description": "Download all responses to your form in CSV format.",
"view_responses": "View Responses",
"download": "Download",
"downloading": "Downloading",
"download_recording": "Download Recording",
+664 -2
View File
@@ -1542,7 +1542,7 @@ describe("getSchedule", () => {
{
id: 1,
slotInterval: 45,
schedulingType: "COLLECTIVE",
schedulingType: SchedulingType.COLLECTIVE,
hosts: [
{
userId: 101,
@@ -1746,6 +1746,668 @@ describe("getSchedule", () => {
expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(23); // 2 booking per day as limit, only one booking on that
});
test("test that duration limit is working correctly if user is all day available", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
const scenarioData = {
eventTypes: [
{
id: 1,
length: 60,
beforeEventBuffer: 0,
afterEventBuffer: 0,
durationLimits: {
PER_DAY: 120, // 2 hours per day
},
users: [
{
id: 101,
},
],
},
{
id: 2,
length: 60,
beforeEventBuffer: 0,
afterEventBuffer: 0,
durationLimits: {
PER_DAY: 240, // 4 hours per day
},
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [
{
id: 1,
name: "All Day available",
availability: [
{
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T00:00:00.000Z"),
endTime: new Date("1970-01-01T23:59:59.999Z"),
date: null,
},
],
timeZone: Timezones["+6:00"],
},
],
},
],
bookings: [
{
userId: 101,
eventTypeId: 1,
startTime: `${plus2DateString}T08:00:00.000Z`,
endTime: `${plus2DateString}T09:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
{
userId: 101,
eventTypeId: 1,
startTime: `${plus2DateString}T10:00:00.000Z`,
endTime: `${plus2DateString}T11:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
{
userId: 101,
eventTypeId: 2,
startTime: `${plus2DateString}T12:00:00.000Z`,
endTime: `${plus2DateString}T13:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
],
};
await createBookingScenario(scenarioData);
const thisUserAvailabilityDurationLimitReached = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus3DateString}T23:59:59.999Z`,
timeZone: Timezones["+6:00"],
isTeamEvent: false,
orgSlug: null,
},
});
const thisUserAvailabilityDurationLimitNotReached = await getSchedule({
input: {
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus3DateString}T23:59:59.999Z`,
timeZone: Timezones["+6:00"],
isTeamEvent: false,
orgSlug: null,
},
});
let availableSlotsInTz: dayjs.Dayjs[] = [];
for (const date in thisUserAvailabilityDurationLimitReached.slots) {
thisUserAvailabilityDurationLimitReached.slots[date].forEach((timeObj) => {
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}
expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0);
availableSlotsInTz = [];
for (const date in thisUserAvailabilityDurationLimitNotReached.slots) {
thisUserAvailabilityDurationLimitNotReached.slots[date].forEach((timeObj) => {
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}
expect(
availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length
).toBeGreaterThan(0);
});
test("test that duration limit is working correctly with different timezone", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
const scenarioData = {
eventTypes: [
{
id: 1,
length: 60,
beforeEventBuffer: 0,
afterEventBuffer: 0,
durationLimits: {
PER_DAY: 120, // 2 hours per day
},
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [
{
id: 1,
name: "All Day available",
availability: [
{
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T00:00:00.000Z"),
endTime: new Date("1970-01-01T23:59:59.999Z"),
date: null,
},
],
timeZone: Timezones["+6:00"],
},
],
},
],
bookings: [
{
userId: 101,
eventTypeId: 1,
startTime: `${plus2DateString}T08:00:00.000Z`,
endTime: `${plus2DateString}T09:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
{
userId: 101,
eventTypeId: 1,
startTime: `${plus2DateString}T10:00:00.000Z`,
endTime: `${plus2DateString}T11:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
],
};
await createBookingScenario(scenarioData);
const thisUserAvailabilityDurationLimit = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus3DateString}T23:59:59.999Z`,
timeZone: Timezones["-11:00"], // attendee timezone
isTeamEvent: false,
orgSlug: null,
},
});
const availableSlotsInTz: dayjs.Dayjs[] = [];
for (const date in thisUserAvailabilityDurationLimit.slots) {
thisUserAvailabilityDurationLimit.slots[date].forEach((timeObj) => {
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}
expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0);
});
test.skip("test that PER_WEEK duration limits work correctly", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
const { dateString: plus4DateString } = getDate({ dateIncrement: 4 });
const { dateString: plus5DateString } = getDate({ dateIncrement: 5 });
const scenarioData = {
eventTypes: [
{
id: 1,
length: 60,
beforeEventBuffer: 0,
afterEventBuffer: 0,
durationLimits: {
PER_WEEK: 180, // 3 hours per week
},
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [
{
id: 1,
name: "All Day available",
availability: [
{
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T00:00:00.000Z"),
endTime: new Date("1970-01-01T23:59:59.999Z"),
date: null,
},
],
timeZone: Timezones["+6:00"],
},
],
},
],
bookings: [
{
userId: 101,
eventTypeId: 1,
startTime: `${plus1DateString}T08:00:00.000Z`,
endTime: `${plus1DateString}T09:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
{
userId: 101,
eventTypeId: 1,
startTime: `${plus2DateString}T10:00:00.000Z`,
endTime: `${plus2DateString}T11:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
{
userId: 101,
eventTypeId: 1,
startTime: `${plus3DateString}T12:00:00.000Z`,
endTime: `${plus3DateString}T13:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
],
};
await createBookingScenario(scenarioData);
const weeklyAvailability = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus5DateString}T23:59:59.999Z`,
timeZone: Timezones["+6:00"],
isTeamEvent: false,
orgSlug: null,
},
});
const availableSlotsInTz: dayjs.Dayjs[] = [];
for (const date in weeklyAvailability.slots) {
weeklyAvailability.slots[date].forEach((timeObj) => {
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}
expect(
availableSlotsInTz.filter(
(slot) =>
slot.format().startsWith(plus1DateString) ||
slot.format().startsWith(plus2DateString) ||
slot.format().startsWith(plus3DateString) ||
slot.format().startsWith(plus4DateString)
).length
).toBe(0);
});
test.skip("global team duration limit blocks slots if one fixed host reached limit", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
const scenarioData = {
eventTypes: [
{
id: 1,
length: 60,
beforeEventBuffer: 0,
afterEventBuffer: 0,
team: {
id: 1,
durationLimits: { PER_DAY: 120 }, // 2 hours per day
},
schedulingType: SchedulingType.COLLECTIVE,
users: [
{
id: 101,
},
{
id: 102,
},
],
},
{
id: 2,
length: 60,
beforeEventBuffer: 0,
afterEventBuffer: 0,
team: {
id: 1,
durationLimits: { PER_DAY: 120 }, // 2 hours per day
},
schedulingType: SchedulingType.COLLECTIVE,
users: [
{
id: 101,
},
{
id: 102,
},
],
},
{
id: 3,
length: 60,
beforeEventBuffer: 0,
afterEventBuffer: 0,
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [
{
id: 1,
name: "All Day available",
availability: [
{
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T00:00:00.000Z"),
endTime: new Date("1970-01-01T23:59:59.999Z"),
date: null,
},
],
timeZone: Timezones["+6:00"],
},
],
},
],
bookings: [
{
userId: 101,
eventTypeId: 1,
startTime: `${plus2DateString}T08:00:00.000Z`,
endTime: `${plus2DateString}T09:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
{
userId: 101,
eventTypeId: 1,
startTime: `${plus2DateString}T10:00:00.000Z`,
endTime: `${plus2DateString}T11:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
],
};
await createBookingScenario(scenarioData);
const availabilityEventTypeOne = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus3DateString}T23:59:59.999Z`,
timeZone: Timezones["+6:00"],
isTeamEvent: false,
orgSlug: null,
},
});
const availableSlotsInTz: dayjs.Dayjs[] = [];
for (const date in availabilityEventTypeOne.slots) {
availabilityEventTypeOne.slots[date].forEach((timeObj) => {
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}
expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0);
const availabilityEventTypeTwo = await getSchedule({
input: {
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus3DateString}T23:59:59.999Z`,
timeZone: Timezones["+6:00"],
isTeamEvent: false,
orgSlug: null,
},
});
const availableSlotsInTz2: dayjs.Dayjs[] = [];
for (const date in availabilityEventTypeTwo.slots) {
availabilityEventTypeTwo.slots[date].forEach((timeObj) => {
availableSlotsInTz2.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}
expect(availableSlotsInTz2.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0);
const availabilityUserEventType = await getSchedule({
input: {
eventTypeId: 3,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus3DateString}T23:59:59.999Z`,
timeZone: Timezones["+6:00"],
isTeamEvent: false,
orgSlug: null,
},
});
const availableSlotsInTz3: dayjs.Dayjs[] = [];
for (const date in availabilityUserEventType.slots) {
availabilityUserEventType.slots[date].forEach((timeObj) => {
availableSlotsInTz3.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}
expect(
availableSlotsInTz3.filter((slot) => slot.format().startsWith(plus2DateString)).length
).toBeGreaterThan(0);
});
test.skip("test that combined booking and duration limits work correctly", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
const { dateString: plus3DateString } = getDate({ dateIncrement: 3 });
const scenarioData = {
eventTypes: [
{
id: 1,
length: 60,
beforeEventBuffer: 0,
afterEventBuffer: 0,
bookingLimits: {
PER_DAY: 3, // 3 bookings per day
},
durationLimits: {
PER_DAY: 120, // 2 hours per day
},
users: [
{
id: 101,
},
],
},
{
id: 2,
length: 30, // 30 minute event
beforeEventBuffer: 0,
afterEventBuffer: 0,
bookingLimits: {
PER_DAY: 2, // 2 bookings per day
},
durationLimits: {
PER_DAY: 120, // 2 hours per day
},
users: [
{
id: 101,
},
],
},
],
users: [
{
...TestData.users.example,
id: 101,
schedules: [
{
id: 1,
name: "All Day available",
availability: [
{
userId: null,
eventTypeId: null,
days: [0, 1, 2, 3, 4, 5, 6],
startTime: new Date("1970-01-01T00:00:00.000Z"),
endTime: new Date("1970-01-01T23:59:59.999Z"),
date: null,
},
],
timeZone: Timezones["+6:00"],
},
],
},
],
bookings: [
{
userId: 101,
eventTypeId: 1,
startTime: `${plus2DateString}T08:00:00.000Z`,
endTime: `${plus2DateString}T09:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
{
userId: 101,
eventTypeId: 1,
startTime: `${plus2DateString}T10:00:00.000Z`,
endTime: `${plus2DateString}T11:00:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
{
userId: 101,
eventTypeId: 2,
startTime: `${plus2DateString}T12:00:00.000Z`,
endTime: `${plus2DateString}T12:30:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
],
};
await createBookingScenario(scenarioData);
const eventType1Availability = await getSchedule({
input: {
eventTypeId: 1,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus3DateString}T23:59:59.999Z`,
timeZone: Timezones["+6:00"],
isTeamEvent: false,
orgSlug: null,
},
});
const eventType2Availability = await getSchedule({
input: {
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus3DateString}T23:59:59.999Z`,
timeZone: Timezones["+6:00"],
isTeamEvent: false,
orgSlug: null,
},
});
let availableSlotsInTz: dayjs.Dayjs[] = [];
for (const date in eventType1Availability.slots) {
eventType1Availability.slots[date].forEach((timeObj) => {
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}
expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0);
availableSlotsInTz = [];
for (const date in eventType2Availability.slots) {
eventType2Availability.slots[date].forEach((timeObj) => {
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}
expect(
availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length
).toBeGreaterThan(0);
// Create a new booking scenario with an additional booking
await createBookingScenario({
eventTypes: scenarioData.eventTypes,
users: scenarioData.users,
bookings: [
...scenarioData.bookings,
{
userId: 101,
eventTypeId: 2,
startTime: `${plus2DateString}T14:00:00.000Z`,
endTime: `${plus2DateString}T14:30:00.000Z`,
status: "ACCEPTED" as BookingStatus,
},
],
});
const eventType2AvailabilityUpdated = await getSchedule({
input: {
eventTypeId: 2,
eventTypeSlug: "",
startTime: `${plus1DateString}T00:00:00.000Z`,
endTime: `${plus3DateString}T23:59:59.999Z`,
timeZone: Timezones["+6:00"],
isTeamEvent: false,
orgSlug: null,
},
});
availableSlotsInTz = [];
for (const date in eventType2AvailabilityUpdated.slots) {
eventType2AvailabilityUpdated.slots[date].forEach((timeObj) => {
availableSlotsInTz.push(dayjs(timeObj.time).tz(Timezones["+6:00"]));
});
}
expect(availableSlotsInTz.filter((slot) => slot.format().startsWith(plus2DateString)).length).toBe(0);
});
test("test that booking limit is working correctly if user is all day available and attendee is in different timezone than host", async () => {
const { dateString: plus1DateString } = getDate({ dateIncrement: 1 });
const { dateString: plus2DateString } = getDate({ dateIncrement: 2 });
@@ -2246,7 +2908,7 @@ describe("getSchedule", () => {
{
id: 1,
slotInterval: 45,
schedulingType: "COLLECTIVE",
schedulingType: SchedulingType.COLLECTIVE,
length: 45,
users: [
{
+185 -6
View File
@@ -3145,6 +3145,80 @@
"tags": ["Orgs / Teams / Bookings"]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/bookings/{bookingUid}/references": {
"get": {
"operationId": "OrganizationsTeamsBookingsController_getBookingReferences",
"summary": "Get 'Booking References' for a booking",
"parameters": [
{
"name": "Authorization",
"in": "header",
"description": "For non-platform customers - value must be `Bearer <token>` where `<token>` is api key prefixed with cal_",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-secret-key",
"in": "header",
"description": "For platform customers - OAuth client secret key",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "x-cal-client-id",
"in": "header",
"description": "For platform customers - OAuth client ID",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "type",
"required": false,
"in": "query",
"description": "Filter booking references by type",
"example": "google_calendar",
"schema": {
"enum": [
"google_calendar",
"office365_calendar",
"daily_video",
"google_video",
"office365_video",
"zoom_video"
],
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BookingReferencesOutput_2024_08_13"
}
}
}
}
},
"tags": ["Orgs / Teams / Bookings"]
}
},
"/v2/organizations/{orgId}/teams/{teamId}/conferencing/{app}/connect": {
"post": {
"operationId": "OrganizationsConferencingController_connectTeamApp",
@@ -7118,6 +7192,72 @@
"tags": ["Bookings"]
}
},
"/v2/bookings/{bookingUid}/references": {
"get": {
"operationId": "BookingsController_2024_08_13_getBookingReferences",
"summary": "Get 'Booking References' for a booking",
"parameters": [
{
"name": "cal-api-version",
"in": "header",
"description": "Must be set to 2024-08-13",
"required": true,
"schema": {
"type": "string",
"default": "2024-08-13"
}
},
{
"name": "bookingUid",
"required": true,
"in": "path",
"schema": {
"type": "string"
}
},
{
"name": "type",
"required": false,
"in": "query",
"description": "Filter booking references by type",
"example": "google_calendar",
"schema": {
"enum": [
"google_calendar",
"office365_calendar",
"daily_video",
"google_video",
"office365_video",
"zoom_video"
],
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
"description": "value must be `Bearer <token>` where `<token>` is api key prefixed with cal_ or managed user access token",
"required": true,
"schema": {
"type": "string"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BookingReferencesOutput_2024_08_13"
}
}
}
}
},
"tags": ["Bookings"]
}
},
"/v2/calendars/ics-feed/save": {
"post": {
"operationId": "CalendarsController_createIcsFeed",
@@ -10976,7 +11116,7 @@
},
"patch": {
"operationId": "TeamsMembershipsController_updateTeamMembership",
"summary": "Create a membership",
"summary": "Update membership",
"parameters": [
{
"name": "Authorization",
@@ -14059,8 +14199,7 @@
"eightxeight-video",
"discord-video",
"demodesk-video",
"campfire-video",
"around-video"
"campfire-video"
],
"example": "cal-video"
},
@@ -19837,7 +19976,7 @@
"type": "object",
"properties": {
"id": {
"type": "string"
"type": "number"
},
"formId": {
"type": "string"
@@ -20315,8 +20454,7 @@
"eightxeight-video",
"discord-video",
"demodesk-video",
"campfire-video",
"around-video"
"campfire-video"
]
}
},
@@ -22128,6 +22266,47 @@
},
"required": ["status", "data"]
},
"BookingReference": {
"type": "object",
"properties": {
"type": {
"type": "string",
"description": "The type of the booking reference"
},
"eventUid": {
"type": "string",
"description": "The event uid of the booking"
},
"destinationCalendarId": {
"type": "string",
"nullable": true,
"description": "The id of the calendar the event is created in"
},
"id": {
"type": "number",
"description": "The id of the booking reference"
}
},
"required": ["type", "eventUid", "destinationCalendarId", "id"]
},
"BookingReferencesOutput_2024_08_13": {
"type": "object",
"properties": {
"status": {
"type": "object",
"description": "The status of the request, always 'success' for successful responses",
"example": "success"
},
"data": {
"description": "Booking References",
"type": "array",
"items": {
"$ref": "#/components/schemas/BookingReference"
}
}
},
"required": ["status", "data"]
},
"CreateTeamMembershipInput": {
"type": "object",
"properties": {
@@ -0,0 +1,20 @@
## Using `handleSlotReservation` for Custom Slot Selection Flows
If you want to trigger a custom action immediately when a user selects a timeslot (before the booking form appears), you can use the `handleSlotReservation` prop. This prop allows you to intercept the slot selection event, enabling you to run custom logic—such as calling the reserve slot api's https://cal.com/docs/api-reference/v2/slots/reserve-a-slot.
**Example usage:**
```tsx
<Booker
handleSlotReservation={(timeslot) => {
// Your custom logic here
console.log("User selected timeslot:", timeslot);
// For example, call the reserve slot api's etc.
}}
// ...other props
/>
```
- When a user selects a slot, the `handleSlotReservation` function will be called with the selected timeslot as its argument.
- If you provide this prop, the default slot reservation flow will be interrupted until your custom logic is complete.
- This is useful for introducing custom slot reservation flows.
@@ -5,7 +5,6 @@
import alby_config_json from "./alby/config.json";
import amie_config_json from "./amie/config.json";
import { metadata as applecalendar__metadata_ts } from "./applecalendar/_metadata";
import around_config_json from "./around/config.json";
import attio_config_json from "./attio/config.json";
import autocheckin_config_json from "./autocheckin/config.json";
import baa_for_hipaa_config_json from "./baa-for-hipaa/config.json";
@@ -112,7 +111,6 @@ export const appStoreMetadata = {
alby: alby_config_json,
amie: amie_config_json,
applecalendar: applecalendar__metadata_ts,
around: around_config_json,
attio: attio_config_json,
autocheckin: autocheckin_config_json,
"baa-for-hipaa": baa_for_hipaa_config_json,
@@ -6,7 +6,6 @@ export const apiHandlers = {
alby: import("./alby/api"),
amie: import("./amie/api"),
applecalendar: import("./applecalendar/api"),
around: import("./around/api"),
attio: import("./attio/api"),
autocheckin: import("./autocheckin/api"),
"baa-for-hipaa": import("./baa-for-hipaa/api"),
-13
View File
@@ -1,13 +0,0 @@
---
items:
- 1.jpg
- 2.jpg
- 3.jpg
- 4.jpg
- 5.jpg
- 6.jpg
- 7.jpg
- 8.jpg
---
Discover radically unique video calls designed to help hybrid-remote teams create, collaborate and celebrate together.
-30
View File
@@ -1,30 +0,0 @@
import type { NextApiRequest } from "next";
import { defaultResponder } from "@calcom/lib/server/defaultResponder";
import checkSession from "../../_utils/auth";
import getInstalledAppPath from "../../_utils/getInstalledAppPath";
import { checkInstalled, createDefaultInstallation } from "../../_utils/installation";
import appConfig from "../config.json";
export async function getHandler(req: NextApiRequest) {
const session = checkSession(req);
const slug = appConfig.slug;
const variant = appConfig.variant;
const appType = appConfig.type;
const teamId = req.query.teamId ? Number(req.query.teamId) : undefined;
const returnTo = req.query?.returnTo;
await checkInstalled(slug, session.user.id);
await createDefaultInstallation({
appType,
user: session.user,
slug,
key: {},
teamId,
});
return { url: returnTo ?? getInstalledAppPath({ variant, slug }) };
}
export default defaultResponder(getHandler);
-5
View File
@@ -1,5 +0,0 @@
import { defaultHandler } from "@calcom/lib/server/defaultHandler";
export default defaultHandler({
GET: import("./_getAdd"),
});
-1
View File
@@ -1 +0,0 @@
export { default as add } from "./add";
-25
View File
@@ -1,25 +0,0 @@
{
"/*": "Don't modify slug - If required, do it using cli edit command",
"name": "Around",
"title": "Around",
"slug": "around",
"type": "around_video",
"logo": "icon.svg",
"url": "https://cal.com/",
"variant": "conferencing",
"categories": ["conferencing"],
"publisher": "Cal.com",
"email": "help@cal.com",
"description": "Discover radically unique video calls designed to help hybrid-remote teams create, collaborate and celebrate together.",
"__createdUsingCli": true,
"appData": {
"location": {
"linkType": "static",
"type": "integrations:around_video",
"label": "Around Video",
"urlRegExp": "^http(s)?:\\/\\/(www\\.)?around.co\\/[a-zA-Z0-9]*",
"organizerInputPlaceholder": "https://www.around.co/rick"
}
},
"isOAuth": false
}
-1
View File
@@ -1 +0,0 @@
export * as api from "./api";
-14
View File
@@ -1,14 +0,0 @@
{
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"name": "@calcom/around",
"version": "0.0.0",
"main": "./index.ts",
"description": "Discover radically unique video calls designed to help hybrid-remote teams create, collaborate and celebrate together.",
"dependencies": {
"@calcom/lib": "*"
},
"devDependencies": {
"@calcom/types": "*"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

-15
View File
@@ -1,15 +0,0 @@
<svg width="326" height="218" viewBox="0 0 326 218" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M254.422 100.655L251.422 95.6551L249.422 94.6551H248.422L247.422 96.6551L245.422 93.6551L249.422 88.6551H246.422V87.6551L251.422 85.6551C251.012 65.8501 275.185 49.8181 275.422 29.6551C281.938 27.4911 287.784 11.9091 289.422 5.6551L305.422 11.6551C285.484 -7.4233 249.601 -1.6615 232.89 18.6591C217.567 37.2901 217.397 65.6011 232.103 84.6551C237.927 92.2021 245.735 97.0381 254.422 100.655Z" fill="#BC45B0"/>
<path d="M30.4224 95.6551C33.1044 99.2261 37.2596 99.4561 41.4224 98.6551L45.4224 100.655V99.6551H43.4224V98.6551L61.4224 94.6551V93.6551H53.4224V92.6551H59.4224L58.4224 88.6551C65.0064 83.9361 70.3954 69.5191 70.4224 61.6551H73.4224C75.3534 53.9071 81.5524 42.0621 87.4224 36.6551L86.4224 33.6551L89.4224 35.6551H90.4224L86.4224 29.6551L88.4224 27.6551L86.4224 22.6551L88.4224 23.6551C61.5744 -11.6715 4.90777 3.88079 0.592173 48.6551C-0.979227 64.9571 10.6359 95.7931 30.4224 95.6551ZM154.422 3.65509L155.422 4.65509L154.422 3.65509Z" fill="#2D99EA"/>
<path d="M159.422 100.655C156.838 94.9811 157.606 91.4601 159.422 85.6551H160.422V100.655C165.409 99.0001 164.422 94.1951 164.422 89.6551C164.422 82.5361 160.982 62.5531 166.164 57.2581C169.725 53.6211 180.677 55.6551 185.422 55.6551V53.6551L178.422 52.6551C183.113 42.0121 189.805 32.3381 194.738 21.6551C196.932 16.9031 198.013 11.3471 203.422 9.65507V5.65507C210.27 7.73377 208.422 15.7791 208.422 21.6551C209.946 18.0551 211.985 8.41717 208.818 5.25767C205.545 1.99317 196.667 3.65507 192.422 3.65507C177.325 3.65507 159.88 1.25047 145.422 6.35417C114.318 17.3341 113.422 45.9141 113.422 73.6551C113.422 78.8941 110.673 96.1801 115.025 99.6271C117.807 101.83 124.094 100.655 127.422 100.655H159.422Z" fill="#5663D9"/>
<path d="M203.422 5.65509V9.65509C197.804 10.6211 196.887 15.9991 194.738 20.6551C189.836 31.2701 181.722 41.6131 178.422 52.6551C185.79 54.2491 201.255 59.9031 207.988 54.0521C211.089 51.3571 210.421 46.3221 210.422 42.6551V13.6551H209.422V21.6551C207.744 15.9791 210.865 6.89729 203.422 5.65509Z" fill="#BC45B0"/>
<path d="M289.422 5.65509C286.546 12.5581 282.218 25.9801 275.422 29.6551C275.061 41.0371 264.753 56.4611 258.968 66.0791C255.724 71.4721 250.414 79.2761 251.422 85.6551L246.422 88.6551H249.422V89.6551L245.422 93.6551L248.422 94.6551H249.422L251.422 95.6551L254.422 101.655L257.422 100.655V102.655L261.422 101.655C261.422 104.281 263.185 103.446 265.422 102.655C267.971 105.572 272.746 104.731 276.422 104.64C287.422 104.37 299.16 99.7151 307.422 92.4701C335.781 67.6031 330.738 12.8261 289.422 5.65509ZM297.422 5.65509L298.422 6.65509L297.422 5.65509ZM246.422 6.65509L247.422 7.65509L246.422 6.65509ZM299.422 6.65509L300.422 7.65509L299.422 6.65509ZM300.422 7.65509L301.422 8.65509L300.422 7.65509Z" fill="#F55857"/>
<path d="M86.4224 22.6551V29.6551L90.4224 35.6551H89.4224L86.4224 33.6551L87.4224 36.6551C81.3674 40.4521 74.4264 54.5951 73.4224 61.6551H70.4224C70.3284 70.0711 64.2364 82.5501 58.4224 88.6551L59.4224 92.6551H53.4224V93.6551H61.4224V94.6551L43.4224 98.6551V99.6551C55.6197 102.559 69.9164 100.655 82.4224 100.655C85.9534 100.655 92.8794 101.956 95.8194 99.6271C99.5734 96.6541 97.4224 84.0361 97.4224 79.6551C97.4224 61.9341 101.676 35.6351 86.4224 22.6551ZM84.7554 24.3221L85.0894 24.9881L84.7554 24.3221Z" fill="#5663D9"/>
<path d="M111.422 202.655H112.422C112.422 179.81 122.625 164.69 132.129 144.67C135.116 138.376 141.815 130.34 142.422 123.655L148.625 116.78L159.422 112.655C152.105 114.014 145.21 114.531 138.422 117.925C105.299 134.487 111.422 171.736 111.422 202.655Z" fill="#5663D9"/>
<path d="M159.422 112.655L147.777 116.815L142.422 123.655C140.044 136.739 127.321 150.579 121.866 162.651C114.209 179.595 112.411 193.837 111.422 212.655L141.422 213.655H202.422V212.655C198.838 212.655 191.089 214.207 190.422 209.655H185.422V208.655L189.422 207.655L205.422 173.655H206.422L207.422 198.655C210.123 192.219 208.422 182.606 208.422 175.655C208.422 151.554 204.299 127.728 179.422 116.993C172.952 114.201 166.253 113.833 159.422 112.655Z" fill="#BC45B0"/>
<path d="M0.422375 175.655H1.42238V166.655H2.42238V184.655H3.42238V182.655H4.42238V189.655H5.42238L4.42238 185.655L8.42238 190.655H9.42238L8.42238 181.655H13.4224C13.7901 176.059 16.7554 167.374 22.4224 165.655V157.655H25.4224C28.2315 146.85 35.2776 132.41 43.4224 124.655L41.4224 120.655L45.4224 119.655V118.655H33.4224V117.655H57.4224C46.2045 112.948 26.6283 116.655 14.4224 116.655C10.7318 116.655 3.85378 115.371 1.45018 118.973C-1.02942 122.689 0.422375 130.37 0.422375 134.655V175.655Z" fill="#2D99EA"/>
<path d="M33.4224 117.655V118.655H45.4224V119.655L41.4224 120.655L43.4224 124.655C34.9301 129.237 33.6138 140.337 29.7835 148.433C28.1052 151.98 25.7223 153.456 25.4224 157.655H22.4224V165.655C16.1837 166.695 13.5917 175.943 13.4224 181.655H8.42237L9.42237 190.655H8.42237L4.42237 185.655C12.612 220.117 58.4578 227.134 82.4184 205.481C98.2224 191.2 97.4224 172.14 97.4224 152.655V126.655C97.4214 123.753 98.2434 118.998 95.1044 117.397C90.5954 115.099 82.3794 116.655 77.4224 116.655C62.7084 116.655 48.1453 117.655 33.4224 117.655Z" fill="#5663D9"/>
<path d="M224.422 156.655H225.422L228.422 126.655C231.665 126.173 231.729 122.576 231.422 119.655L243.422 118.655C239.842 117.153 227.144 115.411 224.677 119.173C222.746 122.116 224.276 127.37 224.387 130.655C224.676 139.304 224.422 148.001 224.422 156.655Z" fill="#BC45B0"/>
<path d="M229.422 116.655V117.655H243.422V118.655L231.422 119.655C231.9 122.043 231.161 127.34 228.422 126.655L225.422 156.655H224.422V125.655C220.114 135.921 223.422 153.497 223.422 164.655V198.655C223.422 202.186 222.121 209.112 224.45 212.052C226.313 214.405 230.805 213.654 233.422 213.655H258.422C276.419 213.655 293.757 213.835 307.133 199.565C334.867 169.975 316.732 122.715 277.422 117.083C261.938 114.865 245.061 116.655 229.422 116.655ZM223.422 118.655L224.422 119.655L223.422 118.655Z" fill="#F55857"/>
<path d="M205.422 173.655L189.422 207.655L185.422 208.655V209.655H190.422C191.022 214.433 203.028 212.989 207.422 213.655C209.751 204.66 208.983 182.141 205.422 173.655Z" fill="#F55857"/>
</svg>

Before

Width:  |  Height:  |  Size: 6.0 KiB

@@ -2,7 +2,6 @@
This file is autogenerated using the command `yarn app-store:build --watch`.
Don't modify this file manually.
**/
import around_config_json from "./around/config.json";
import campfire_config_json from "./campfire/config.json";
import { metadata as dailyvideo__metadata_ts } from "./dailyvideo/_metadata";
import demodesk_config_json from "./demodesk/config.json";
@@ -48,7 +47,6 @@ import whereby_config_json from "./whereby/config.json";
import { metadata as zoomvideo__metadata_ts } from "./zoomvideo/_metadata";
export const appStoreMetadata = {
around: around_config_json,
campfire: campfire_config_json,
dailyvideo: dailyvideo__metadata_ts,
demodesk: demodesk_config_json,
-1
View File
@@ -1,7 +1,6 @@
const appStore = {
alby: createCachedImport(() => import("./alby")),
applecalendar: createCachedImport(() => import("./applecalendar")),
aroundvideo: createCachedImport(() => import("./around")),
caldavcalendar: createCachedImport(() => import("./caldavcalendar")),
closecom: createCachedImport(() => import("./closecom")),
dailyvideo: createCachedImport(() => import("./dailyvideo")),
@@ -4,6 +4,8 @@ import { useForm } from "react-hook-form";
import { v4 as uuidv4 } from "uuid";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import { dataTableQueryParamsSerializer } from "@calcom/features/data-table/lib/serializers";
import { ColumnFilterType } from "@calcom/features/data-table/lib/types";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { RoutingFormEmbedButton, RoutingFormEmbedDialog } from "@calcom/features/embed/RoutingFormEmbed";
import { EmbedDialogProvider } from "@calcom/features/embed/lib/hooks/useEmbedDialogCtx";
@@ -400,6 +402,7 @@ type FormActionType =
| "duplicate"
| "download"
| "copyRedirectUrl"
| "viewResponses"
| "create";
type FormActionProps<T> = {
@@ -484,6 +487,17 @@ export const FormAction = forwardRef(function FormAction<T extends typeof Button
create: {
onClick: () => setNewFormDialogState?.({ action: "new", target: "" }),
},
viewResponses: {
href: `/insights/routing${
routingForm?.id
? dataTableQueryParamsSerializer({
activeFilters: [
{ f: "formId", v: { type: ColumnFilterType.SINGLE_SELECT, data: routingForm.id } },
],
})
: ""
}`,
},
copyRedirectUrl: {
onClick: () => {
navigator.clipboard.writeText(redirectUrl);
@@ -141,6 +141,18 @@ const Actions = ({
StartIcon="download">
{t("download_responses")}
</FormAction>
{form?.id && (
<FormAction
action="viewResponses"
routingForm={form}
className="w-full"
color="minimal"
type="button"
data-testid="view-responses"
StartIcon="eye">
{t("view_responses")}
</FormAction>
)}
<FormAction
action="embed"
routingForm={form}
@@ -11,6 +11,7 @@ import type { UseFormReturn } from "react-hook-form";
import { Toaster } from "sonner";
import type { z } from "zod";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { areTheySiblingEntitites } from "@calcom/lib/entityPermissionUtils";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { buildEmptyQueryValue, raqbQueryValueUtils } from "@calcom/lib/raqb/raqbUtils";
@@ -385,6 +386,9 @@ const Route = ({
const { eventOptions } = buildEventsData({ eventTypesByGroup, form, route });
const orgBranding = useOrgBranding();
const isOrganization = !!orgBranding;
// /team/{TEAM_SLUG}/{EVENT_SLUG} -> /team/{TEAM_SLUG}
const eventTypePrefix =
eventOptions.length !== 0
@@ -554,7 +558,8 @@ const Route = ({
: null;
const attributesQueryBuilder =
route.action?.type === RouteActionType.EventTypeRedirectUrl && isTeamForm ? (
// team member attributes are only available for organization teams
route.action?.type === RouteActionType.EventTypeRedirectUrl && isTeamForm && isOrganization ? (
<div className="mt-4">
{/* TODO: */}
{eventTypeRedirectUrlSelectedOption?.eventTypeAppMetadata &&
@@ -68,6 +68,11 @@ export const BaseScheduledEmail = (
: props.callToAction || <ManageLink attendee={props.attendee} calEvent={props.calEvent} />
}
subtitle={props.subtitle || <>{t("emailed_you_and_any_other_attendees")}</>}>
{props.calEvent.rejectionReason && (
<>
<Info label={t("rejection_reason")} description={props.calEvent.rejectionReason} withSpacer />
</>
)}
{props.calEvent.cancellationReason && (
<Info
label={t(
+202
View File
@@ -0,0 +1,202 @@
# Cal.com Embed Lifecycle Events
This document details the lifecycle events and states of Cal.com embeds, showing the interaction flow between the parent page and the iframe.
## Core Lifecycle Sequence
```mermaid
sequenceDiagram
participant Parent as Parent (User Page)
participant Embed as Embed (iframe)
participant Store as EmbedStore(iframe)
participant UI as UI Components(iframe)
Note over Parent: embed.js loads
alt Inline Embed
Parent->>Parent: Create cal-element
Parent->>Parent: Create iframe (visibility: hidden)
Parent->>Parent: Show loader
else Modal Embed
Note over Parent: No action unless prerender
end
alt Prerender Flow
Note over Parent: Set prerender=true in URL
Parent->>Store: Set prerenderState="inProgress"
Note over Store: Limited events allowed
Parent->>Parent: Create hidden iframe
Parent->>Parent: load booker(no slots)
Note over Parent: Wait for connect() call
end
alt Modal CTA clicked
Parent->>Parent: Create cal-modal-box
Parent->>Parent: Create iframe (visibility: hidden)
Parent->>Parent: Show loader
end
Note over Embed: background: transparent(stays transparent)
Note over Embed: body tag set to visibility: hidden waiting to be shown
Note over Embed: iframe webpage starts rendering
Note over Store: Initialize EmbedStore state
Store->>Store: Set NOT_INITIALIZED state
Store->>Store: Initialize UI config & theme
Note over Parent: Process URL params for prefill
Parent->>Store: Set prefill data from URL
Note over Store: Auto-populate form fields
Embed->>Parent: __iframeReady event
Note over Parent,Embed: Embed ready to receive messages
Note over Store: Set state to INITIALIZED
Store->>UI: Apply theme configuration
Note over UI: DEPRECATED: styles prop
Note over UI: Use cssVarsPerTheme instead
Store->>UI: Apply cssVarsPerTheme
Embed->>Parent: __dimensionChanged event
Note over Parent: Calculate and adjust iframe dimensions
Note over Store: Update parentInformedAboutContentHeight
alt isBookerPage
Note over Embed: Wait for booker ready state
end
Embed->>Parent: linkReady event
Note over Parent: Changes loading state to done
Note over Parent: Removes loader
Note over Parent: Sets iframe visibility to visible
Parent->>Embed: parentKnowsIframeReady event
Note over Embed: Makes body visible
Note over Store: Update UI configuration
alt Prerendering Active
Note over Store: Set prerenderState to completed
Parent->>Store: connect() with new config
Store->>Store: Reset parentInformedAboutContentHeight
Store->>Parent: Update iframe with new params
Note over Store: Remove prerender params
end
loop Dimension Monitoring
Embed->>Embed: Monitor content size changes
alt Dimensions Changed
Embed->>Parent: __dimensionChanged event
Parent->>Parent: Adjust iframe size to avoid scrollbar
end
end
alt Route Changes
UI->>Store: Update UI state
Store->>Parent: __routeChanged event
Parent->>Parent: Handle navigation
Note over Store: Preserve prefill data
end
```
## Detailed State Management
### EmbedStore States
- `NOT_INITIALIZED`: Initial state when iframe is created
- `INITIALIZED`: After __iframeReady event is processed
- `prerenderState`: Can be null | "inProgress" | "completed"
### Visibility States
1. Initial Creation:
- iframe.style.visibility = "hidden"
- body.style.visibility = "hidden"
2. After __iframeReady:
- iframe becomes visible (unless prerendering)
3. After parentKnowsIframeReady:
- body becomes visible
- Background remains transparent
## Event Details
1. **Initial Load**
- embed.js loads in parent page
- For inline embeds: Creates elements immediately
- For modal embeds: Waits for CTA click (unless prerendering)
2. **iframe Creation**
- iframe is created with `visibility: hidden`
- Loader is shown (default or skeleton)
- EmbedStore initialized
3. **__iframeReady Event**
- Fired by: Iframe
- Indicates: Embed is ready to receive messages
- Actions:
- Sets iframeReady flag to true
- Makes iframe visible (unless prerendering)
- Processes queued iframe commands
4. **__dimensionChanged Event**
- Fired by: Iframe
- Purpose: Maintain proper iframe sizing
- Triggers:
- On initial load
- When content size changes
- After window load completes
5. **linkReady Event**
- Fired by: Iframe
- Indicates: iframe is fully ready for use
- Requirements:
- parentInformedAboutContentHeight must be true
- For booker pages: booker must be in ready state
- Actions:
- Parent removes loader
- Parent makes iframe visible
6. **parentKnowsIframeReady Event**
- Fired by: Parent
- Indicates: Parent acknowledges iframe readiness
- Actions:
- Makes body visible
- For prerendering: marks prerenderState as "completed"
## Prerendering Flow
The prerendering flow follows a special path:
1. Initial State:
- prerenderState: null
2. During Prerender:
- prerenderState: "inProgress"
- Limited events allowed (only __iframeReady, __dimensionChanged)
- iframe and body remain hidden
3. After Connect:
- prerenderState: "completed"
- Full event flow enabled
- Visibility states updated
## Command Queue System
The embed system implements a command queue to handle instructions before the iframe is ready:
1. Commands are queued if iframe isn't ready:
```typescript
if (!this.iframeReady) {
this.iframeDoQueue.push(doInIframeArg);
return;
}
```
2. Queue is processed after __iframeReady event:
- All queued commands are executed in order
- New commands are executed immediately
## Error Handling
Page Load Errors:
- System monitors CalComPageStatus
- On non-200 status: fires linkFailed event
- Includes error code and URL information
+157
View File
@@ -37,3 +37,160 @@ Status:
- [x] [Partially supported] team.event.booking.form - Shows skeleton but of the slots page
- [ ] user.profile
- [ ] team.profile
## How Routing Prerendering works
- Use API to prerender a booking link for "modal"
- When CTA is clicked by user, we check if there is a "prerendered"/"being prerendered" modal for this namespace.
- If yes, we open up the modal showing the skeleton loader and send the POST request to /api/router endpoint
- When we get the response from the endpoint, we pass on all the query params to the already rendered/being rendered iframe and embed-iframe updates the URL of the iframe to have the new query params through history.replaceState(i.e. without reloading the page)
## Prerendering vs Preloading
- Preloading loads the calLink in iframe with the sole purpose of preloading the static assets, so that when the embed actually opens, it uses the static assets from browser cache.
- Prerendering means continuing over the preloaded iframe, so that the user books on the prerendered iframe only. So, it is much more complex than preloading and gives much more benefits in terms of performance.
Note: API wise `prerender` delegates its task to `preload` API which then identifies whether to preload or prerender.
## Modalbox re-opening performance optimization
- ModalBox supports reusing the same cal-modal-box element and thus same iframe and thus providing a lightning fast experience when the same modal is opened multiple times [This feature is currently disabled in code because of stale booking page UI issues]
## Embed Core Architecture and Features
### Architecture Overview
#### Initialization and Bootstrap Process
The embed system initializes through a multi-step process:
1. The embed script is loaded on the parent page
2. It creates a global `Cal` object that acts as the entry point
3. The system initializes necessary custom elements (`cal-modal-box`, `cal-floating-button`, `cal-inline`)
4. A namespace-based action manager is created for event handling
#### Parent-Iframe Communication System
Communication between the parent page and the embedded iframe uses a message-based system:
```typescript
// Parent to Iframe communication example
interface InterfaceWithParent {
ui: (config: UiConfig) => void;
connect: (config: PrefillAndIframeAttrsConfig) => void;
}
// Event data structure
type EventData<T> = {
type: string;
namespace: string;
fullType: string;
data: EventDataMap[T];
};
```
The system uses namespaced events to ensure multiple embeds on the same page don't interfere with each other.
#### Instruction Queue System
Commands are queued before the iframe is ready:
```typescript
type Instruction = SingleInstruction | SingleInstruction[];
type InstructionQueue = Instruction[];
// Commands are queued if iframe isn't ready
if (!this.iframeReady) {
this.iframeDoQueue.push(doInIframeArg);
return;
}
```
### Embedding Methods
#### Inline Embedding
Embeds the calendar directly within the page flow:
```typescript
Cal.inline({
elementOrSelector: "#my-cal-inline",
calLink: "organization/event-type"
});
```
#### Modal Embedding
Creates a modal dialog with the calendar:
```typescript
Cal.modal({
calLink: "organization/event-type",
config: {
// Optional configuration
}
});
```
#### FloatingButton Embedding
Adds a floating action button that opens the calendar in a modal. It uses modal embedding under the hood.
```typescript
Cal.floatingButton({
calLink: "organization/event-type",
buttonText: "Book meeting",
buttonPosition: "bottom-right"
});
```
### Configuration and Customization
#### Prefill System
Allows pre-filling form fields:
```typescript
Cal.inline({
calLink: "organization/event-type",
config: {
name: "John Doe",
email: "john@example.com",
notes: "Initial discussion"
}
});
```
#### Query Parameter Handling
The system allows automatically forwarding query params to the iframe, by setting. This code must be present right after the embed snippet is added to the page.
```js
Cal.config = Cal.config || {};
Cal.config.forwardQueryParams=true
```
### Advanced Features
#### Routing Prerendering System
The prerendering system optimizes the initial load:
```typescript
Cal.prerender({
calLink: "organization/event-type",
type: "modal"
});
```
Key aspects:
- Creates a hidden iframe
- Loads the booking page but doesn't send the slots availability request
- Tries to reuse whenever it makes sense and do a fresh load otherwise
Iframe Reuse and Reload Conditions. There could be three situations:
1. Reuse
2. Reuse the iframe but refetch the slots
3. Do a fresh load in iframe
- **Reuse**:
- Modal opens when
- Modal is not in a failed state
- config, params are same as the last time
- No threshold violations
- **Reuse the iframe but refetch the slots**:
- Only embed `config` changes (handled via "connect" flow)
- Query query params changes (handled via "connect" flow)
- Crossed slots stale time threshold (EMBED_MODAL_IFRAME_SLOT_STALE_TIME)
- **Fresh Reload Conditions**:
- Different path being loaded(i.e. /pro vs /free)
- Modal is in a failed state
- Time since last render exceeds EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS
+6
View File
@@ -1,5 +1,11 @@
# @calcom/embed-core
## 1.5.3
### Patch Changes
- Added react-19 as peer dependency
## 1.5.2
### Patch Changes
+2 -1
View File
@@ -10,7 +10,8 @@ You can also see various example usages [here](https://github.com/calcom/cal.com
## Development
Run the following command and then you can test the embed in the automatically opened page `http://localhost:3100`
Run the following command and then you can test the embed in the automatically opened page `http://localhost:3100/embed/`
We have another html page to specifically test prerendering when using headless router in Demo `http://localhost:3100/embed/routing-playground.html`
```bash
yarn dev
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@calcom/embed-core",
"version": "1.5.2",
"version": "1.5.3",
"description": "This is the vanilla JS core script that embeds Cal Link",
"main": "./dist/embed/embed.js",
"types": "./dist/index.d.ts",
+24
View File
@@ -669,6 +669,30 @@ if (only === "all" || only === "ns:skeletonDemoElementClick") {
});
}
if (only === "all" || only === "ns:routingFormPrerender") {
Cal("init", "routingFormPrerender", {
debug: true,
origin,
});
Cal.ns.routingFormPrerender("on", {
action: "*",
callback,
});
}
if (only === "all" || only === "ns:routingFormWithoutPrerender") {
Cal("init", "routingFormWithoutPrerender", {
debug: true,
origin,
});
Cal.ns.routingFormWithoutPrerender("on", {
action: "*",
callback,
});
}
// Keep it at the bottom as it works on the API defined above for various cases
(function ensureScrolledToCorrectIframe() {
// Reset the hash so that we can scroll to correct iframe
@@ -0,0 +1,176 @@
<html>
<head>
<title>Embed Playground - Routing Form</title>
<!-- <link rel="prerender" href="http://localhost:3000/free"> -->
<!-- <script src="./src/embed.ts" type="module"></script> -->
<script>
const url = new URL(document.URL);
window.only = url.searchParams.get("only")
window.calOrigin = url.searchParams.get("calOrigin") || "http://localhost:3000";
window.calLink = url.searchParams.get("calLink");
if (!location.search.includes("nonResponsive")) {
document.write('<meta name="viewport" content="width=device-width"/>');
}
(function addParamsAutomatically() {
const newSearchParams = new URLSearchParams(location.search);
let paramsChanged = false;
if (!newSearchParams.has("only")) {
newSearchParams.set("only", "ns:routingFormPrerender");
paramsChanged = true;
}
if (!newSearchParams.has("calOrigin")) {
newSearchParams.set("calOrigin", "http://acme.cal.local:3000");
paramsChanged = true;
}
if (!newSearchParams.has("cal.embed.logging")) {
newSearchParams.set("cal.embed.logging", "1");
paramsChanged = true;
}
if (paramsChanged) {
location.href = "?" + newSearchParams.toString();
}
})();
</script>
<script>
function embedSnippet() {
(function (C, A, L) {
let p = function (a, ar) {
a.q.push(ar);
};
let d = C.document;
C.Cal =
C.Cal ||
function () {
let cal = C.Cal;
let ar = arguments;
if (!cal.loaded) {
cal.ns = {};
cal.q = cal.q || [];
d.head.appendChild(d.createElement("script")).src = A;
cal.loaded = true;
}
if (ar[0] === L) {
const api = function () {
p(api, arguments);
};
const namespace = ar[1];
api.q = api.q || [];
if (typeof namespace === "string") {
// Make sure that even after re-execution of the snippet, the namespace is not overridden
cal.ns[namespace] = cal.ns[namespace] || api;
p(cal.ns[namespace], ar);
p(cal, ['initNamespace', namespace])
} else p(cal, ar);
return;
}
p(cal, ar);
};
})(window, window.calOrigin + "/embed/embed.js", "init");
}
embedSnippet();
</script>
<style>
body {
background: linear-gradient(90deg,
rgba(120, 116, 186, 1) 0%,
rgba(221, 221, 255, 1) 41%,
rgba(148, 232, 249, 1) 100%);
}
.inline-embed-container {
/* border: 1px solid black; */
margin-bottom: 5px;
border-bottom: 1px solid;
}
.loader {
color: green;
}
* {
--cal-brand-color: gray;
}
</style>
</head>
<body>
<div style="display: flex; flex-direction: column; gap: 10px;">
<div id="cal-booking-place-routingFormPrerender">
<a href="?only=ns:routingFormPrerender">Routing Form Prerender Demo</a>
<p>1. As soon as you start typing in the form, the form will prerender the booking page(where redirect is supposed to happen)</p>
<p>2. Clicking on submit will open the prerendered/being prerendered iframe in the modal</p>
<p>3. Changing the form response and clicking submit, will re-submit the response and reopen the modal with the updated data(update slots call, updated prefill fields)</p>
<p>4. Clicking submit again without changing the form response will just re-show the hidden modal(as long as threshold of 1 min since last time the modal was shown is met). But, in case of an error it iframe always tries to rerender. Trying adding an invalid email to see the error</p>
<form id="cal-booking-place-routingFormPrerender-form">
<input type="text" name="name" placeholder="John Doe" />
<input type="email" name="email" placeholder="john@example.com" />
<select name="skills" placeholder="JavaScript, Node.js">
<option value="JavaScript">JavaScript</option>
<option value="Sales">Sales</option>
</select>
</form>
<button id="cal-booking-place-routingFormPrerender-submit" data-cal-namespace="routingFormPrerender" data-cal-config='{"cal.embed.pageType":"team.event.booking.slots", "guests":["guest1@example.com", "guest2@example.com"]}'>Submit</button>
<script>
document.getElementById("cal-booking-place-routingFormPrerender-form").addEventListener("input", function prerender(e) {
if (prerender.initiated) return;
prerender.initiated = true;
console.log("Playground: prerender initiated");
Cal.ns.routingFormPrerender("prerender", {
calLink: "team/insights-team/team-javascript",
type: "modal",
pageType: "user.event.booking.slots",
});
});
requestAnimationFrame(function updateSubmitButtonLink() {
const seededFormAcmeId = "948ae412-d995-4865-885a-48302588de03";
const form = document.getElementById("cal-booking-place-routingFormPrerender-form");
const name = form.querySelector("input[name='name']").value;
const email = form.querySelector("input[name='email']").value;
const skills = form.querySelector("select[name='skills']").value;
if (name && email) {
document.getElementById("cal-booking-place-routingFormPrerender-submit").setAttribute("data-cal-link", `router?form=${seededFormAcmeId}&email=${email}&name=${name}&Location=London&Department=Engineering&Manager=John Doe&Rating=5&skills=${skills}&Email=${email}`);
}
requestAnimationFrame(updateSubmitButtonLink);
});
</script>
</div>
<div id="cal-booking-place-routingFormWithoutPrerender">
<a href="?only=ns:routingFormWithoutPrerender">Routing Form Without Prerender Demo</a>
<form id="cal-booking-place-routingFormWithoutPrerender-form">
<input type="text" name="name" placeholder="John Doe" />
<input type="email" name="email" placeholder="john@example.com" />
<select name="skills" placeholder="JavaScript, Node.js">
<option value="JavaScript">JavaScript</option>
<option value="Sales">Sales</option>
</select>
</form>
<button id="cal-booking-place-routingFormWithoutPrerender-submit" data-cal-namespace="routingFormWithoutPrerender" data-cal-config='{"cal.embed.pageType":"team.event.booking.slots", "guests":["guest1@example.com", "guest2@example.com"]}'>Submit</button>
<script>
requestAnimationFrame(function updateSubmitButtonLink() {
const seededFormAcmeId = "948ae412-d995-4865-885a-48302588de03";
const form = document.getElementById("cal-booking-place-routingFormWithoutPrerender-form");
const name = form.querySelector("input[name='name']").value;
const email = form.querySelector("input[name='email']").value;
const skills = form.querySelector("select[name='skills']").value;
if (name && email) {
document.getElementById("cal-booking-place-routingFormWithoutPrerender-submit").setAttribute("data-cal-link", `router?form=${seededFormAcmeId}&email=${email}&name=${name}&Location=London&Department=Engineering&Manager=John Doe&Rating=5&skills=${skills}&Email=${email}`);
}
requestAnimationFrame(updateSubmitButtonLink);
});
</script>
</div>
</div>
<script type="module" src="./playground.ts"></script>
</script>
</body>
</html>
@@ -165,12 +165,24 @@ export class EmbedElement extends HTMLElement {
this.boundPrefersDarkThemeChangedHandler = this.prefersDarkThemeChangedHandler.bind(this);
}
public isSkeletonLoaderVisible() {
const skeletonEl = this.getSkeletonElement();
// Comparing with "none" which is set by toggleLoader when skeleton is hidden
return skeletonEl.style.display !== "none";
}
public resizeHandler() {
const newLayout = getTrueLayout({ layout: this.layout ?? null });
if (newLayout === this.layout) {
return;
}
this.layout = newLayout;
// We can't accidentaly show skeleton if it isn't showing
if (!this.isSkeletonLoaderVisible()) {
return;
}
const { skeletonContent, skeletonContainerStyle, skeletonStyle } = this.getSkeletonData({
layout: this.getLayout(),
pageType: this.getPageType() ?? null,
@@ -22,7 +22,10 @@ export class Inline extends EmbedElement {
this.toggleLoader(false);
slotEl.style.visibility = "hidden";
errorEl.style.display = "block";
const errorString = getErrorString(this.dataset.errorCode);
const errorString = getErrorString({
errorCode: this.dataset.errorCode,
errorMessage: this.dataset.message,
});
errorEl.innerText = errorString;
}
}
@@ -12,6 +12,9 @@ export class ModalBox extends EmbedElement {
return ["state"];
}
/**
* Show the modal box, regardless of anything else - Kind of like forced open
*/
show(show: boolean) {
this.assertHasShadowRoot();
// We can't make it display none as that takes iframe width and height calculations to 0
@@ -21,7 +24,14 @@ export class ModalBox extends EmbedElement {
}
}
/**
* Open the modal box if it makes sense to do so.
*/
open() {
if (this.getAttribute("state") === "prerendering") {
// You can't show a modal thats prerendering or prerendered
return;
}
this.show(true);
const event = new Event("open");
this.dispatchEvent(event);
@@ -29,30 +39,71 @@ export class ModalBox extends EmbedElement {
private isLoaderRunning() {
const state = this.getAttribute("state");
return !state || state === "loading" || state === "reopening";
return !state || state === "loading";
}
/**
* Close the modal box - It is like a forced close
*/
private explicitClose() {
this.show(false);
const event = new Event("close");
this.dispatchEvent(event);
}
isShowingMessage() {
const currentState = this.getAttribute("state");
return currentState === "has-message" || currentState === "failed";
}
/**
* Close the modal box if it makes sense to do so.
*/
close() {
if (this.isLoaderRunning()) {
// We want to avoid accidentally closing the modal through Esc, Click outside, etc.
if (this.isLoaderRunning() || this.isShowingMessage()) {
return;
}
this.explicitClose();
}
hideIframe() {
/**
* Takes the iframe out of layout and hides it
*/
collapseIframe() {
const iframe = this.querySelector("iframe");
if (iframe) {
iframe.style.display = "none";
}
}
/**
* Put the iframe back in layout and make it visible
*/
uncollapseIframe() {
const iframe = this.querySelector("iframe");
if (iframe) {
// Resets the display to its default value
iframe.style.display = "";
}
}
/**
* Make the iframe invisible but stays in layout
*/
makeIframeInvisible() {
const iframe = this.querySelector("iframe");
if (iframe) {
iframe.style.visibility = "hidden";
}
}
showIframe() {
/**
* Make the iframe visible, doesn't affect layout
*
* Iframe on creation is invisible
*/
makeIframeVisible() {
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
@@ -60,40 +111,108 @@ export class ModalBox extends EmbedElement {
}
}
getErrorElement(): HTMLElement {
getMessageElement(): HTMLElement {
this.assertHasShadowRoot();
const element = this.shadowRoot.querySelector<HTMLElement>("#error");
const element = this.shadowRoot.querySelector<HTMLElement>("#message");
if (!element) {
throw new Error("No error element");
throw new Error("No message element");
}
return element;
}
getMessageContainerElement(): HTMLElement {
this.assertHasShadowRoot();
const element = this.shadowRoot.querySelector<HTMLElement>("#message-container");
if (!element) {
throw new Error("No message container element");
}
return element;
}
toggleMessageElement(show: boolean) {
const errorContainerElement = this.getMessageContainerElement();
if (show) {
errorContainerElement.style.display = "";
} else {
errorContainerElement.style.display = "none";
}
}
ensureIframeFullyVisible() {
// Iframe when added is invisible, so we ensure that it is visible
this.makeIframeVisible();
// Iframe is collapsed(doesn't take any space) when error is to be shown, so we ensure that it is uncollapsed
this.uncollapseIframe();
}
/**
* Called when the state is "loaded"
*/
onStateLoaded() {
// Hide Loader
this.toggleLoader(false);
// Message is shown either in "failed" or "has-message" state, so we hide it here
this.toggleMessageElement(false);
// Open Modal
this.open();
// Ensure Iframe is fully visible
this.ensureIframeFullyVisible();
}
/**
* Called when the state is "failed" or "has-message"
*/
onStateFailedOrMessage() {
this.toggleLoader(false);
this.toggleMessageElement(true);
this.collapseIframe();
const message = this.dataset.message;
const errorMessage = this.dataset.errorCode
? getErrorString({
errorCode: this.dataset.errorCode,
errorMessage: message,
})
: null;
const messageToShow = errorMessage || message;
if (messageToShow) {
this.getMessageElement().innerText = messageToShow;
}
}
attributeChangedCallback(name: string, oldValue: string, newValue: string) {
if (name !== "state") {
return;
}
if (newValue === "loading") {
this.open();
this.hideIframe();
this.toggleLoader(true);
} else if (newValue == "loaded" || newValue === "reopening") {
this.open();
this.showIframe();
this.toggleLoader(false);
// Ensure iframe takes its available space as it could be possible that we are moving from "failed" state to "loading" state or some other case.
this.uncollapseIframe();
this.toggleMessageElement(false);
// Keep iframe invisible as it will be made visible when the state changes to "loaded"
this.makeIframeInvisible();
} else if (newValue == "loaded") {
this.onStateLoaded();
} else if (newValue == "closed") {
this.explicitClose();
} else if (newValue === "failed") {
this.getLoaderElement().style.display = "none";
this.getSkeletonElement().style.display = "none";
this.getErrorElement().style.display = "inline-block";
const errorString = getErrorString(this.dataset.errorCode);
this.getErrorElement().innerText = errorString;
} else if (newValue === "failed" || newValue === "has-message") {
this.onStateFailedOrMessage();
} else if (newValue === "prerendering") {
// We do a close here because we don't want the loaders to show up when the modal is prerendering
// As per HTML, both skeleton/loader are configured to be shown by default, so we need to hide them and infact we don't want to show up anything unexpected so we completely hide the customElement itself
this.explicitClose();
} else if (newValue === "reopened") {
// Show in whatever state it is
this.open();
}
}
@@ -27,6 +27,11 @@ function getStyle() {
overflow: auto;
}
.message-container {
min-height: 200px;
width: 600px;
}
.header {
position: relative;
float:right;
@@ -58,12 +63,17 @@ const html = ({
layout,
pageType,
});
// Keep message-container outside modal-box as that restricts the content to be shown through its overflow:auto unnecessarily
return `
${getStyle()}
<div class="my-backdrop">
<div class="header">
<button type="button" class="close" aria-label="Close">&times;</button>
</div>
<div id="message-container" style="left: 50%; top: 50%; transform: translate(-50%, -50%);" class="message-container flex items-center p-24 justify-center dark:bg-muted rounded-md border-subtle border bg-default text-default absolute z-highest">
<div id="message"></div>
</div>
<div class="modal-box">
<div class="body" id="skeleton-container" style="${skeletonContainerStyle}">
<div id="wrapper" class="z-[999999999999] absolute flex w-full items-center">
@@ -74,10 +84,10 @@ ${getStyle()}
<div id="skeleton" style="${skeletonStyle}" class="absolute z-highest">
${skeletonContent}
</div>
<div id="error" class="hidden left-1/2 -translate-x-1/2 relative text-inverted"></div>
<slot></slot>
</div>
</div>
</div>`;
};
@@ -0,0 +1,244 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { embedStore, getEmbedBookerState } from "../embed-iframe";
// Test helper functions
type fakeCurrentDocumentUrlParams = {
origin?: string;
path?: string;
params?: Record<string, string>;
};
function fakeCurrentDocumentUrl({
origin = "https://example.com",
path = "",
params = {},
}: fakeCurrentDocumentUrlParams = {}) {
const url = new URL(path, origin);
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, value);
});
return mockDocumentUrl(url);
}
function mockDocumentUrl(url: URL | string) {
return vi.spyOn(document, "URL", "get").mockReturnValue(url.toString());
}
function createSearchParams(params: Record<string, string>) {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
searchParams.set(key, value);
});
return searchParams;
}
describe("embedStore.router.ensureQueryParamsInUrl", () => {
// Mock window.history and URL
const originalHistory = window.history;
const originalURL = window.URL;
function nextTick() {
vi.advanceTimersByTime(100);
}
beforeEach(() => {
vi.useFakeTimers();
// Mock requestAnimationFrame and cancelAnimationFrame
window.requestAnimationFrame = vi.fn((callback: FrameRequestCallback) => {
console.log("mockRequestAnimationFrame called");
const timeoutId = setTimeout(() => {
callback(performance.now());
}, 100) as unknown as number;
return timeoutId;
});
// Mock history.replaceState
window.history.replaceState = (...args) => {
const url = args[2];
if (!url) {
throw new Error("url is not provided");
}
vi.spyOn(document, "URL", "get").mockReturnValue(url.toString());
};
});
afterEach(() => {
vi.useRealTimers();
// Cleanup
window.history = originalHistory;
window.URL = originalURL;
vi.resetAllMocks();
});
it("should add missing parameters to URL", async () => {
// Setup
fakeCurrentDocumentUrl();
// Execute
const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({
toBeThereParams: {
theme: "dark",
layout: "month",
},
toRemoveParams: [],
});
// Assert
expect(document.URL).toContain("theme=dark");
expect(document.URL).toContain("layout=month");
// Cleanup
stopEnsuringQueryParamsInUrl();
});
it("should ensure that no existing value of param exists as is", () => {
fakeCurrentDocumentUrl({ params: { guest: "initial" } });
const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({
toBeThereParams: { guest: ["ax.com", "bx.com"] },
toRemoveParams: [],
});
expect(document.URL).toContain("guest=ax.com");
expect(document.URL).toContain("guest=bx.com");
expect(document.URL).not.toContain("guest=initial");
stopEnsuringQueryParamsInUrl();
});
it("should remove specified parameters from URL", async () => {
// Setup
fakeCurrentDocumentUrl({ params: { remove: "true", keep: "yes" } });
// Execute
const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({
toBeThereParams: {},
toRemoveParams: ["remove"],
});
// Assert
expect(document.URL).not.toContain("remove=true");
expect(document.URL).toContain("keep=yes");
// Cleanup
stopEnsuringQueryParamsInUrl();
});
it("should handle empty parameters", async () => {
fakeCurrentDocumentUrl();
const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({
toBeThereParams: {},
toRemoveParams: [],
});
nextTick();
stopEnsuringQueryParamsInUrl();
});
it("should restore parameters if they are changed before cleanup, otherwise not", async () => {
fakeCurrentDocumentUrl();
const initialTheme = "dark";
const { stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({
toBeThereParams: { theme: initialTheme },
toRemoveParams: [],
});
// First interval - should add the parameter
expect(document.URL).toContain(`theme=${initialTheme}`);
const changedThemeByReact = "light";
// Simulate React code changing the URL
fakeCurrentDocumentUrl({ params: { theme: changedThemeByReact } });
// Next interval - should restore our parameter
nextTick();
expect(document.URL).toContain(`theme=${initialTheme}`);
// After cleanup, changes should not be restored
stopEnsuringQueryParamsInUrl();
fakeCurrentDocumentUrl({ params: { theme: "light" } });
nextTick();
expect(document.URL).toContain("theme=light");
expect(document.URL).not.toContain(`theme=${initialTheme}`);
});
});
describe("getEmbedBookerState", () => {
it("should return 'initializing' when bookerState is 'loading'", () => {
const result = getEmbedBookerState({
bookerState: "loading",
slotsQuery: {
isLoading: false,
isPending: false,
isSuccess: false,
isError: false,
},
});
expect(result).toBe("initializing");
});
it("should return 'slotsLoading' when slotsQuery.isLoading is true", () => {
const result = getEmbedBookerState({
bookerState: "selecting_date",
slotsQuery: {
isLoading: true,
isPending: false,
isSuccess: false,
isError: false,
},
});
expect(result).toBe("slotsLoading");
});
it("should return 'slotsDone' when slotsQuery.isPending is true but not loading", () => {
const result = getEmbedBookerState({
bookerState: "selecting_date",
slotsQuery: {
isLoading: false,
isPending: true,
isSuccess: false,
isError: false,
},
});
expect(result).toBe("slotsDone");
});
it("should return 'slotsDone' when slotsQuery.isSuccess is true", () => {
const result = getEmbedBookerState({
bookerState: "selecting_date",
slotsQuery: {
isLoading: false,
isPending: false,
isSuccess: true,
isError: false,
},
});
expect(result).toBe("slotsDone");
});
it("should return 'slotsLoadingError' when slotsQuery.isError is true", () => {
const result = getEmbedBookerState({
bookerState: "selecting_date",
slotsQuery: {
isLoading: false,
isPending: false,
isSuccess: false,
isError: true,
},
});
expect(result).toBe("slotsLoadingError");
});
it("should return 'slotsPending' when no other conditions are met", () => {
const result = getEmbedBookerState({
bookerState: "selecting_date",
slotsQuery: {
isLoading: false,
isPending: false,
isSuccess: false,
isError: false,
},
});
expect(result).toBe("slotsPending");
});
});
@@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { generateDataAttributes } from "../utils";
import { generateDataAttributes, isSameBookingLink } from "../utils";
describe("generateDataAttributes", () => {
it("should handle PascalCase property names correctly", () => {
@@ -59,3 +59,41 @@ describe("generateDataAttributes", () => {
);
});
});
describe("isSameBookingLink", () => {
it("should return true for same booking link", () => {
expect(
isSameBookingLink({
bookingLinkPath1: "/team/event-booking-url",
bookingLinkPath2: "/team/event-booking-url",
})
).toBe(true);
expect(
isSameBookingLink({
bookingLinkPath1: "/team/team1/event-booking-url",
bookingLinkPath2: "/team/team1/event-booking-url",
})
).toBe(true);
});
it("should return false for different booking links", () => {
expect(
isSameBookingLink({
bookingLinkPath1: "/team/event-booking-url",
bookingLinkPath2: "/team/event-booking-url-2",
})
).toBe(false);
});
it("should return true for same booking links with /team prefix in them", () => {
expect(isSameBookingLink({ bookingLinkPath1: "/team/sales/demo", bookingLinkPath2: "/sales/demo" })).toBe(
true
);
expect(
isSameBookingLink({
bookingLinkPath1: "/team1/event-booking-url",
bookingLinkPath2: "/team/team1/event-booking-url",
})
).toBe(true);
});
});
@@ -0,0 +1,10 @@
// We can't keep it too high because that might cause stale content to be shown
// TODO: We should introduce a slotRefresh mechanism
export const EMBED_MODAL_IFRAME_SLOT_STALE_TIME = 10 * 1000; // 1 minute
export const EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS = 2 * 60 * 1000; // 2 minutes
if (EMBED_MODAL_IFRAME_SLOT_STALE_TIME > EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS) {
throw new Error(
"EMBED_MODAL_IFRAME_SLOT_STALE_TIME must be less than EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS"
);
}
+284 -76
View File
@@ -1,6 +1,5 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState, useCallback } from "react";
import type { Message } from "./embed";
@@ -11,9 +10,25 @@ import type {
EmbedNonStylesConfig,
BookerLayouts,
EmbedStyles,
KnownConfig,
EmbedBookerState,
SlotsQuery,
PrefillAndIframeAttrsConfig,
} from "./types";
import { useCompatSearchParams } from "./useCompatSearchParams";
import { isParamValuePresentInUrlSearchParams } from "./utils";
// We don't import it from Booker/types because the types from this module are published to npm and we can't import packages that aren't published
type BookerState = "loading" | "selecting_date" | "selecting_time" | "booking";
// Prerendering is a hidden process and we shouldn't really track any events from it unless absolutely necessary
const eventsAllowedInPrerendering = [
// so that Postmessage communication starts
"__iframeReady",
// so that iframe height is adjusted according to the content, and iframe is ready to be shown when needed
"__dimensionChanged",
// For other events, we should consider introducing prerender specific events and not reuse existing events
];
type SetStyles = React.Dispatch<React.SetStateAction<EmbedStyles>>;
type setNonStylesConfig = React.Dispatch<React.SetStateAction<EmbedNonStylesConfig>>;
@@ -22,16 +37,6 @@ const enum EMBED_IFRAME_STATE {
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;
};
} & KnownConfig;
declare global {
interface Window {
CalEmbed: {
@@ -40,32 +45,89 @@ declare global {
applyCssVars: (cssVarsPerTheme: UiConfig["cssVarsPerTheme"]) => void;
};
// Marks that Booker has moved to some non-"loading" state
_embedBookerState?: "initializing" | "done";
_embedBookerState?: EmbedBookerState;
}
}
/**
* This is in-memory persistence needed so that when user browses through the embed, the configurations from the instructions aren't lost.
*/
const embedStore = {
export const embedStore = {
connectVersion: 0 as number,
/**
* Tracks whether the prerender has been completed or not.
* NOTE: prerenderState would be "completed" even after the iframe was switched from isPrerendering=true to not Prerendering(which happens after connect)
*/
prerenderState: null as null | "inProgress" | "completed",
// 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;
/**
* When we do the history push, it is possible that
* - React might revert that change depending on in what state React is in while initializing
* - So, we use a declarative approach to ensure that our requirement is continuously met
*/
ensureQueryParamsInUrl({
toBeThereParams,
toRemoveParams,
}: {
toBeThereParams: Record<string, string | string[]>;
toRemoveParams: string[];
}) {
let stopUpdating = false;
function updateIfNeeded() {
if (stopUpdating) {
return { hasChanged: false };
}
const currentUrl = new URL(document.URL);
let hasChanged = false;
// 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);
// Ensuring toBeThereSearchParams
for (const [key, newValue] of Object.entries(toBeThereParams)) {
// It checks that the value must be present and if an array no other item should be there except those in newValue
hasChanged = !isParamValuePresentInUrlSearchParams({
param: key,
value: newValue,
container: currentUrl.searchParams,
});
if (hasChanged) {
setParamInUrl({ key, value: newValue, url: currentUrl });
}
}
removeParamsFromUrl({ keys: toRemoveParams, url: currentUrl });
hasChanged = hasChanged || toRemoveParams.length > 0;
if (hasChanged) {
// Avoid unnecessary history push
window.history.replaceState({}, "", currentUrl.toString());
}
requestAnimationFrame(updateIfNeeded);
return {
hasChanged,
};
}
const { hasChanged } = updateIfNeeded();
return {
stopEnsuringQueryParamsInUrl: () => {
stopUpdating = true;
},
hasChanged,
};
function removeParamsFromUrl({ keys, url }: { keys: string[]; url: URL }) {
for (const key of keys) {
url.searchParams.delete(key);
}
}
function setParamInUrl({ key, value, url }: { key: string; value: string | string[]; url: URL }) {
// Reset and then set the new value, to ensure nothing else remains in value
url.searchParams.delete(key);
const newValueArray = Array.isArray(value) ? value : [value];
newValueArray.forEach((val) => {
url.searchParams.append(key, val);
});
}
},
},
@@ -125,7 +187,7 @@ function log(...args: unknown[]) {
args.unshift("CAL:");
logQueue.push(args);
if (searchParams.get("debug")) {
console.log(...args);
console.log("Child:", ...args);
}
}
}
@@ -199,8 +261,6 @@ 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) {
@@ -309,8 +369,25 @@ function getEmbedType() {
}
}
/**
* It is important to be able to check realtime(instead of storing isLinkReady as a variable) if the link is ready, because there is a possibility that booker might have moved to non-ready state from ready state
*/
function isLinkReady() {
if (!embedStore.parentInformedAboutContentHeight) {
return false;
}
if (isBookerPage()) {
// Let's wait for Booker to be ready before showing the embed
// It means that booker has loaded all its data and is ready to show
// TODO: We could try to mark the embed as ready earlier in this case not relying on document.readyState
return isBookerReady();
}
return true;
}
function isBookerReady() {
return window._embedBookerState === "done";
return window._embedBookerState === "slotsDone";
}
function isBookerPage() {
@@ -340,16 +417,33 @@ export const useEmbedType = () => {
return state;
};
function unhideBody() {
function makeBodyVisible() {
if (document.body.style.visibility !== "visible") {
document.body.style.visibility = "visible";
}
// Ensure that it stays visible and not reverted by React
runAsap(() => {
unhideBody();
makeBodyVisible();
});
}
/**
* On an embed page, there are two changes done
* - Body is made invisible
* - Background is set to transparent
*
* This function reverses both of them
*/
function showPageAsNonEmbed() {
makeBodyVisible();
resetTransparentBackground();
function resetTransparentBackground() {
if (document.body.style.background === "transparent") {
document.body.style.background = "";
}
}
}
// It is a map of methods that can be called by parent using doInIframe({method: "methodName", arg: "argument"})
const methods = {
ui: function style(uiConfig: UiConfig) {
@@ -401,43 +495,62 @@ const methods = {
log("Method: `parentKnowsIframeReady` called");
runAsap(function tryInformingLinkReady() {
// TODO: Do it by attaching a listener for change in parentInformedAboutContentHeight
if (!embedStore.parentInformedAboutContentHeight) {
runAsap(tryInformingLinkReady);
return;
}
// Let's wait for Booker to be ready before showing the embed
// It means that booker has loaded all its data and is ready to show
// TODO: We could try to mark the embed as ready earlier in this case not relying on document.readyState
if (isBookerPage() && !isBookerReady()) {
if (!isLinkReady()) {
runAsap(tryInformingLinkReady);
return;
}
// No UI change should happen in sight. Let the parent height adjust and in next cycle show it.
unhideBody();
if (!isPrerendering()) {
sdkActionManager?.fire("linkReady", {});
// Embed background must still remain transparent
makeBodyVisible();
if (isPrerendering()) {
log("prerenderState is 'completed'");
embedStore.prerenderState = "completed";
}
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);
}
}
/**
* Connects new config to prerendered page
*/
connect: function connect({
config,
params,
}: {
config: PrefillAndIframeAttrsConfig;
// This is basically searchParams simplified as Record<string, string | string[]>
// So a=1&a=2&b=3 would be {a: ["1", "2"], b: "3"}
// We can't accept URLSearchParams as it isn't cloneable and thus postMessage doesn't support it
params: Record<string, string | string[]>;
}) {
log("Method: connect, requested with params", { config, params });
const { iframeAttrs: _1, ...queryParamsFromConfig } = config;
const connectVersion = (embedStore.connectVersion = embedStore.connectVersion + 1);
// We reset it to allow informing parent again through `__dimensionChanged` event about possibly updated dimensions with changes in config
embedStore.parentInformedAboutContentHeight = false;
connectPreloadedEmbed({ url: currentUrl });
// Config is just a typed and more declarative way to pass the query params from the parent(except iframeAttrs which is meant to be consumed by parent and not supposed to passed to child)
// So, query params can come directly by providing them to calLink or through config
const toBeThereParams = {
...params,
// Query params from config takes precedence over query params in url
...(queryParamsFromConfig as Record<string, string | string[]>),
"cal.embed.connectVersion": connectVersion.toString(),
};
(function tryToConnect() {
if (embedStore.prerenderState !== "completed") {
runAsap(tryToConnect);
return;
}
log("Method: connect, prerenderState is completed. Connecting");
connectPreloadedEmbed({
// We know after removing iframeAttrs, that it is of this type
toBeThereParams,
toRemoveParams: ["preload", "prerender", "cal.skipSlotsFetch"],
});
})();
},
};
@@ -457,6 +570,10 @@ const messageParent = (data: CustomEvent["detail"]) => {
);
};
/**
* This function is called once the iframe loads.
* It isn't called on "connect"
*/
function keepParentInformedAboutDimensionChanges() {
let knownIframeHeight: number | null = null;
let knownIframeWidth: number | null = null;
@@ -515,13 +632,14 @@ function keepParentInformedAboutDimensionChanges() {
const iframeHeight = isFirstTime ? documentScrollHeight : contentHeight;
const iframeWidth = isFirstTime ? documentScrollWidth : contentWidth;
embedStore.parentInformedAboutContentHeight = true;
if (!iframeHeight || !iframeWidth) {
runAsap(informAboutScroll);
return;
}
if (knownIframeHeight !== iframeHeight || knownIframeWidth !== iframeWidth) {
const isThereAChangeInDimensions = knownIframeHeight !== iframeHeight || knownIframeWidth !== iframeWidth;
if (isThereAChangeInDimensions || !embedStore.parentInformedAboutContentHeight) {
embedStore.parentInformedAboutContentHeight = true;
knownIframeHeight = iframeHeight;
knownIframeWidth = iframeWidth;
// FIXME: This event shouldn't be subscribable by the user. Only by the SDK.
@@ -556,7 +674,7 @@ function main() {
actOnColorScheme(embedStore.uiConfig.colorScheme);
// If embed link is opened in top, and not in iframe. Let the page be visible.
if (top === window) {
unhideBody();
showPageAsNonEmbed();
// We would want to avoid a situation where Cal.com embeds cal.com and then embed-iframe is in the top as well. In such case, we would want to avoid infinite loop of events being passed.
log("Embed SDK Skipped as we are in top");
return;
@@ -588,6 +706,9 @@ function main() {
});
sdkActionManager?.on("*", (e) => {
if (isPrerendering() && !eventsAllowedInPrerendering.includes(e.detail.type)) {
return;
}
const detail = e.detail;
log(detail);
messageParent(detail);
@@ -601,7 +722,13 @@ function main() {
}
function initializeAndSetupEmbed() {
sdkActionManager?.fire("__iframeReady", {});
sdkActionManager?.fire("__iframeReady", {
isPrerendering: isPrerendering(),
});
if (isPrerendering()) {
embedStore.prerenderState = "inProgress";
}
// Only NOT_INITIALIZED -> INITIALIZED transition is allowed
if (embedStore.state !== EMBED_IFRAME_STATE.NOT_INITIALIZED) {
@@ -642,19 +769,100 @@ function actOnColorScheme(colorScheme: string | null | undefined) {
* 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
function connectPreloadedEmbed({
toBeThereParams,
toRemoveParams,
}: {
toBeThereParams: Record<string, string | string[]>;
toRemoveParams: string[];
}) {
const { hasChanged, stopEnsuringQueryParamsInUrl } = embedStore.router.ensureQueryParamsInUrl({
toBeThereParams,
toRemoveParams,
});
let waitForFrames = 0;
if (isBookerReady() && hasChanged) {
// Give some time for react to update state that might lead booker to go to slotsLoading state
waitForFrames = 5;
}
// Booker might alreadyu be in slotsDone state. But we don't know if new getTeamSchedule request would intitiate or not. It would initiate when React updates the state but it might not go depending on if there is no actual state change in useSchedule components
// But we can know if cal.routedTeamMemberIds is changed. If it is changed, then we reset slotsDone -> slotsLoading.
// Firing this event would stop the loader and show the embed
// This causes loader to go away later.
runAsap(function tryToFireLinkReady() {
if (!isLinkReady() || waitForFrames > 0) {
waitForFrames--;
runAsap(tryToFireLinkReady);
return;
}
// link is ready now, so we could stop doing it.
// Also the page is visible to user now.
stopEnsuringQueryParamsInUrl();
sdkActionManager?.fire("linkReady", {});
}, MAX_TIME_TO_LET_REACT_APPLY_UI_CHANGES);
});
}
const isPrerendering = () => {
return new URL(document.URL).searchParams.get("prerender") === "true";
};
export function getEmbedBookerState({
bookerState,
slotsQuery,
}: {
bookerState: BookerState;
slotsQuery: SlotsQuery;
}): EmbedBookerState {
if (bookerState === "loading") {
return "initializing";
}
if (slotsQuery.isLoading) {
return "slotsLoading";
}
// Pending but not loading, it means that request is intentionally disabled via enabled:false in useQuery
if (slotsQuery.isPending) {
return "slotsDone";
}
if (slotsQuery.isSuccess) {
return "slotsDone";
}
if (slotsQuery.isError) {
return "slotsLoadingError";
}
return "slotsPending";
}
/**
* It is meant to sync BookerState to EmbedBookerState
* This function is meant to be called outside useEffect so that we don't wait for React to re-render before doing our work
*/
export function updateEmbedBookerState({
bookerState,
slotsQuery,
}: {
bookerState: BookerState;
slotsQuery: SlotsQuery;
}) {
// Ensure that only after the bookerState is reflected, we update the embedIsBookerReady
if (typeof window === "undefined") {
return;
}
const _window = window as Window & {
_embedBookerState?: EmbedBookerState;
};
const embedBookerState = getEmbedBookerState({ bookerState, slotsQuery });
_window._embedBookerState = embedBookerState;
}
main();
File diff suppressed because it is too large Load Diff
+433 -54
View File
@@ -3,20 +3,29 @@ import { FloatingButton } from "./FloatingButton/FloatingButton";
import { Inline } from "./Inline/inline";
import { ModalBox } from "./ModalBox/ModalBox";
import { addAppCssVars } from "./addAppCssVars";
import type { InterfaceWithParent, interfaceWithParent, PrefillAndIframeAttrsConfig } from "./embed-iframe";
import {
EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS,
EMBED_MODAL_IFRAME_SLOT_STALE_TIME,
} from "./constants";
import type { InterfaceWithParent, interfaceWithParent } from "./embed-iframe";
import css from "./embed.css";
import { SdkActionManager } from "./sdk-action-manager";
import type { EventData, EventDataMap } from "./sdk-action-manager";
import tailwindCss from "./tailwindCss";
import type { UiConfig } from "./types";
import type { UiConfig, EmbedPageType, PrefillAndIframeAttrsConfig } from "./types";
import { getMaxHeightForModal } from "./ui-utils";
import { fromEntriesWithDuplicateKeys, getConfigProp, generateDataAttributes } from "./utils";
export type { PrefillAndIframeAttrsConfig } from "./embed-iframe";
import {
fromEntriesWithDuplicateKeys,
isRouterPath,
submitResponseAndGetRoutingResult,
generateDataAttributes,
getConfigProp,
isSameBookingLink,
} from "./utils";
// Exporting for consumption by @calcom/embed-core user
export type { EmbedEvent } from "./sdk-action-manager";
export type { PrefillAndIframeAttrsConfig } from "./types";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Rest<T extends any[] | undefined> = T extends [any, ...infer U] ? U : never;
export type Message = {
@@ -36,13 +45,13 @@ customElements.define("cal-inline", Inline);
declare module "*.css";
type Namespace = string;
type InitConfig = {
type CalConfig = {
calOrigin: string;
debug?: boolean;
uiDebug?: boolean;
};
type InitArgConfig = Partial<InitConfig> & {
type InitArgConfig = Partial<CalConfig> & {
origin?: string;
};
@@ -193,7 +202,7 @@ type PrefillAndIframeAttrsConfigWithGuestAndColorScheme = PrefillAndIframeAttrsC
export class Cal {
iframe?: HTMLIFrameElement;
__config: InitConfig;
__config: CalConfig;
modalBox?: Element;
@@ -209,10 +218,14 @@ export class Cal {
api: CalApi;
isPerendering?: boolean;
isPrerendering?: boolean;
static actionsManagers: Record<Namespace, SdkActionManager>;
// Store calLink separately and not rely on deriving it from iframe.src, because we could load different URL in iframe(derived from calLink e.g. calLink=Router -> redirects to eventBookingUrl and then we load that URL in iframe)
calLink: string | null = null;
embedConfig: PrefillAndIframeAttrsConfig | null = null;
// Tracks the time when the embed was last rendered with some changes to iframe i.e. it identifies if the iframe is freshly updated and when
embedRenderStartTime: number | null = null;
static ensureGuestKey(config: PrefillAndIframeAttrsConfig) {
config = config || {};
return {
@@ -237,7 +250,7 @@ export class Cal {
const [method, ...args] = instruction;
if (!this.api[method]) {
// Instead of throwing error, log and move forward in the queue
log(`Instruction ${method} not FOUND`);
error(`Instruction ${method} not FOUND`);
}
try {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
@@ -245,7 +258,7 @@ export class Cal {
this.api[method](...args);
} catch (e) {
// Instead of throwing error, log and move forward in the queue
log(`Instruction couldn't be executed`, e);
error(`Instruction couldn't be executed`, e);
}
return instruction;
}
@@ -280,7 +293,25 @@ export class Cal {
iframe.className = "cal-embed";
iframe.name = `cal-embed=${this.namespace}`;
iframe.title = `Book a call`;
const embedConfig = this.getInitConfig();
this.loadInIframe({ calLink, config, calOrigin, iframe });
return iframe;
}
loadInIframe({
calLink,
config = {},
calOrigin,
iframe,
}: {
iframe: HTMLIFrameElement;
calLink: string;
config?: PrefillAndIframeAttrsConfig;
calOrigin: string | null;
}) {
log("Loading in iframe", calLink);
iframe.dataset.calLink = calLink;
const calConfig = this.getCalConfig();
const { iframeAttrs, ...queryParamsFromConfig } = config;
if (iframeAttrs && iframeAttrs.id) {
@@ -290,10 +321,11 @@ export class Cal {
const searchParams = this.buildFilteredQueryParams(queryParamsFromConfig);
// cal.com has rewrite issues on Safari that sometimes cause 404 for assets.
const originToUse = (calOrigin || embedConfig.calOrigin || "").replace(
const originToUse = (calOrigin || calConfig.calOrigin || "").replace(
"https://cal.com",
"https://app.cal.com"
);
const urlInstance = new URL(`${originToUse}/${calLink}`);
if (!urlInstance.pathname.endsWith("embed")) {
// TODO: Make a list of patterns that are embeddable. All except that should be allowed with a warning that "The page isn't optimized for embedding"
@@ -302,14 +334,14 @@ export class Cal {
urlInstance.searchParams.set("embed", this.namespace);
if (embedConfig.debug) {
urlInstance.searchParams.set("debug", `${embedConfig.debug}`);
if (calConfig.debug) {
urlInstance.searchParams.set("debug", `${calConfig.debug}`);
}
// Keep iframe invisible, till the embedded calLink sets its color-scheme. This is so that there is no flash of non-transparent(white/black) background
iframe.style.visibility = "hidden";
if (embedConfig.uiDebug) {
if (calConfig.uiDebug) {
iframe.style.border = "1px solid green";
}
@@ -319,11 +351,26 @@ export class Cal {
for (const [key, value] of searchParams) {
urlInstance.searchParams.append(key, value);
}
// Very Important:Reset iframe ready flag, as iframe might load a fresh URL and we need to check when it is ready.
this.iframeReady = false;
if (iframe.src === urlInstance.toString()) {
// Ensure reload occurs even if the url is same - Though browser normally does it, but would be better to ensure it
// This param has no other purpose except to ensure forced reload.
urlInstance.searchParams.append("__cal.reloadTs", Date.now().toString());
}
iframe.src = urlInstance.toString();
return iframe;
}
getInitConfig() {
/**
* Returns the config applicable to entire Cal namespace.
* Individual embeds can have their own embed config passed via `config` prop normally.
* Also, calOrigin could be passed individually as well at the moment
*/
getCalConfig() {
return this.__config;
}
@@ -380,15 +427,14 @@ export class Cal {
}
});
this.actionManager.on("__iframeReady", () => {
this.actionManager.on("__iframeReady", (e) => {
this.iframeReady = true;
if (this.iframe) {
if (this.iframe && !e.detail.data.isPrerendering) {
// It's a bit late to make the iframe visible here. We just needed to wait for the HTML tag of the embedded calLink to be rendered(which then informs the browser of the color-scheme)
// Right now it would wait for embed-iframe.js bundle to be loaded as well. We can speed that up by inlining the JS that informs about color-scheme being set in the HTML.
// TODO: Right now it would wait for embed-iframe.js bundle to be loaded as well. We can speed that up by inlining the JS that informs about color-scheme being set in the HTML.
// But it's okay to do it here for now because the embedded calLink also keeps itself hidden till it receives `parentKnowsIframeReady` message(It has it's own reasons for that)
// Once the embedded calLink starts not hiding the document, we should optimize this line to make the iframe visible earlier than this.
// Imp: Don't use visibility:visible as that would make the iframe show even if the host element(A paren tof the iframe) has visibility:hidden set. Just reset the visibility to default
// Imp: Don't use visibility:visible as that would make the iframe show even if the host element(A parent of the iframe) has visibility:hidden set. Just reset the visibility to default
this.iframe.style.visibility = "";
}
this.doInIframe({ method: "parentKnowsIframeReady" } as const);
@@ -411,10 +457,14 @@ 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
if (this.isPrerendering) {
// Ensure that we don't mark embed as loaded if it's prerendering otherwise prerendered embed could show-up without any user action
return;
}
this.iframe!.style.visibility = "";
// Removes the loader
// TODO: We should be using consistent approach of "state" attribute for modalBox and inlineEl.
this.modalBox?.setAttribute("state", "loaded");
this.inlineEl?.setAttribute("loading", "done");
});
@@ -424,6 +474,9 @@ export class Cal {
if (!iframe) {
return;
}
if (this.isPrerendering) {
return;
}
this.inlineEl?.setAttribute("data-error-code", e.detail.data.code);
this.modalBox?.setAttribute("data-error-code", e.detail.data.code);
this.inlineEl?.setAttribute("loading", "failed");
@@ -461,13 +514,247 @@ export class Cal {
return searchParams;
}
getNextActionForModal({
modal,
pathWithQueryToLoad,
stateData,
}: {
modal: { uid: string };
pathWithQueryToLoad: string;
stateData: {
embedConfig: PrefillAndIframeAttrsConfig;
previousEmbedConfig: PrefillAndIframeAttrsConfig | null;
isConnectionInitiated: boolean;
previousEmbedRenderStartTime: number | null;
embedRenderStartTime: number;
};
}) {
const {
embedConfig,
previousEmbedConfig,
isConnectionInitiated,
previousEmbedRenderStartTime,
embedRenderStartTime,
} = stateData;
const calConfig = this.getCalConfig();
const lastLoadedUrlInIframeObject = this.getLastLoadedLinkInframe();
const lastLoadedPathInIframe = lastLoadedUrlInIframeObject?.pathname ?? null;
const urlToLoadObject = new URL(pathWithQueryToLoad, calConfig.calOrigin as string);
const existingModalEl = document.querySelector(`cal-modal-box[uid="${modal.uid}"]`);
const urlToLoadPath = urlToLoadObject.pathname;
// We only check for path because query params are handled by connect flow
// Also origin we assume never changes without page reload of the embedding page
const isSameCalLink =
lastLoadedPathInIframe &&
isSameBookingLink({
bookingLinkPath1: lastLoadedPathInIframe,
bookingLinkPath2: urlToLoadPath,
});
const lastLoadedUrlInIframeObjectSearchParams = lastLoadedUrlInIframeObject?.searchParams.toString();
const urlToLoadObjectSearchParams = urlToLoadObject.searchParams.toString();
const areSameQueryParams = lastLoadedUrlInIframeObjectSearchParams === urlToLoadObjectSearchParams;
const isSameConfig =
previousEmbedConfig &&
isSameEmbedConfig({
embedConfig1: previousEmbedConfig,
embedConfig2: embedConfig,
});
const isInFailedState = existingModalEl && existingModalEl.getAttribute("state") === "failed";
const timeSinceLastRender = previousEmbedRenderStartTime
? embedRenderStartTime - previousEmbedRenderStartTime
: 0;
const crossedReloadThreshold = previousEmbedRenderStartTime
? timeSinceLastRender > EMBED_MODAL_IFRAME_FORCE_RELOAD_THRESHOLD_MS
: false;
const areSlotsStale = previousEmbedRenderStartTime
? timeSinceLastRender > EMBED_MODAL_IFRAME_SLOT_STALE_TIME
: false;
// Note that we don't worry about change in embed config because that is passed on as query params to the iframe and that is already supported by "connect" flow
const isResetNeeded = !isSameCalLink || isInFailedState || crossedReloadThreshold;
const actionToTake = isResetNeeded
? "fullReload"
: !isSameConfig || !areSameQueryParams || !isConnectionInitiated || areSlotsStale
? "connect"
: "noAction";
log("Next Modal Action:", actionToTake, {
path: {
isSame: isSameCalLink,
urlToLoadPath,
lastLoadedPathInIframe,
},
config: {
isSame: isSameConfig,
previousEmbedConfig,
embedConfig,
},
queryParams: {
isSame: areSameQueryParams,
lastLoadedUrlInIframeObjectSearchParams,
urlToLoadObjectSearchParams,
},
areSlotsStale,
crossedReloadThreshold,
isInFailedState,
isConnectionInitiated,
});
return actionToTake;
function isSameEmbedConfig({
embedConfig1,
embedConfig2,
}: {
embedConfig1: PrefillAndIframeAttrsConfig;
embedConfig2: PrefillAndIframeAttrsConfig;
}) {
if (Object.keys(embedConfig1).length !== Object.keys(embedConfig2).length) {
return false;
}
// Verify the two config have all props as same
return Object.keys(embedConfig1).every((key) => {
if (typeof embedConfig1[key] !== typeof embedConfig2[key]) {
return false;
}
// Now we know both have same type.
const embedConfig1Value = embedConfig1[key];
const embedConfig2Value = embedConfig2[key];
if (embedConfig1Value instanceof Array && embedConfig2Value instanceof Array) {
return (
embedConfig1Value.length === embedConfig2Value.length &&
embedConfig1Value.every((value: string) => embedConfig2Value.includes(value))
);
}
if (typeof embedConfig1Value === "string") {
return embedConfig1Value === embedConfig2Value;
}
return true;
});
}
}
/**
* Returns the last loaded URL in iframe.
* Removes /embed from the pathname and returns the origin.
*/
getLastLoadedLinkInframe() {
if (!this.iframe || !this.iframe.dataset.calLink) {
return null;
}
const calLink = this.iframe.dataset.calLink;
if (!calLink) {
return null;
}
const urlObject = new URL(calLink, new URL(this.iframe.src).origin);
return new URL(`${urlObject.pathname}${urlObject.search}`, urlObject.origin);
}
async submitThroughHeadlessRouterInModal({
modal,
calLinkUrlObject,
stateData,
}: {
modal: { uid: string; element: Element; calOrigin: string | null };
calLinkUrlObject: URL;
stateData: {
embedConfig: PrefillAndIframeAttrsConfigWithGuestAndColorScheme;
previousEmbedConfig: PrefillAndIframeAttrsConfigWithGuestAndColorScheme | null;
embedRenderStartTime: number;
previousEmbedRenderStartTime: number | null;
isConnectionInitiated: boolean;
};
}) {
const { uid: modalBoxUid, element: modalEl, calOrigin: _calOrigin } = modal;
const { embedConfig } = stateData;
const lastLoadedUrlInIframeObject = this.getLastLoadedLinkInframe();
const lastLoadedPathInIframe = lastLoadedUrlInIframeObject?.pathname ?? null;
const calConfig = this.getCalConfig();
const calOrigin = _calOrigin ?? calConfig.calOrigin;
const headlessRouterPageObject = calLinkUrlObject;
const result = await submitResponseAndGetRoutingResult({
headlessRouterPageUrl: headlessRouterPageObject.toString(),
});
log("Headless router result", result);
if ("redirect" in result) {
const routerRedirectUrl = new URL(result.redirect);
const paramsFromRedirect = fromEntriesWithDuplicateKeys(routerRedirectUrl.searchParams.entries());
const newEmbedConfig = (this.embedConfig = {
...embedConfig,
...paramsFromRedirect,
});
const actionToTake = this.getNextActionForModal({
modal: { uid: modalBoxUid },
pathWithQueryToLoad: `${routerRedirectUrl.pathname}${routerRedirectUrl.search}`,
stateData: {
...stateData,
embedConfig: newEmbedConfig,
},
});
if (actionToTake === "fullReload") {
if (lastLoadedPathInIframe) {
console.error("Preloaded iframe couldn't be used", {
preloadedPath: lastLoadedPathInIframe,
newPath: routerRedirectUrl.pathname,
});
}
const pathWithoutStartingSlash = routerRedirectUrl.pathname.replace(/^\//, "");
this.loadInIframe({
calLink: pathWithoutStartingSlash,
calOrigin,
config: newEmbedConfig,
iframe: this.iframe as HTMLIFrameElement,
});
} else {
log("Reusing the same iframe for headless router", {
routerRedirectUrl: routerRedirectUrl.toString(),
lastLoadedPathInIframe,
});
// Connection Initiated
this.doInIframe({
method: "connect",
arg: {
config: newEmbedConfig,
params: fromEntriesWithDuplicateKeys(routerRedirectUrl.searchParams.entries()),
},
});
}
} else if ("message" in result) {
log("Setting message in modal", {
message: result.message,
});
// TODO: We might need to sanitize the error message and error code before setting it
modalEl.setAttribute("data-message", result.message);
modalEl.setAttribute("state", "has-message");
} else if ("error" in result) {
log("Setting error in modal", {
error: result.error,
});
// We need to show this message in the modal
modalEl.setAttribute("data-error-code", "routerError");
// TODO: We might need to sanitize the error message and error code before setting it
modalEl.setAttribute("data-message", result.error);
modalEl.setAttribute("state", "failed");
}
}
}
class CalApi {
cal: Cal;
static initializedNamespaces = [] as string[];
modalUid?: string;
preloadedModalUid?: string;
prerenderedModalUid?: string;
constructor(cal: Cal) {
this.cal = cal;
}
@@ -479,7 +766,7 @@ class CalApi {
init(namespaceOrConfig?: string | InitArgConfig, config = {} as InitArgConfig) {
let initForNamespace = "";
if (typeof namespaceOrConfig !== "string") {
config = (namespaceOrConfig || {}) as InitConfig;
config = (namespaceOrConfig || {}) as CalConfig;
} else {
initForNamespace = namespaceOrConfig;
}
@@ -560,7 +847,7 @@ class CalApi {
}
config.embedType = "inline";
const calConfig = this.cal.getInitConfig();
const calConfig = this.cal.getCalConfig();
const iframe = this.cal.createIframe({
calLink,
@@ -654,7 +941,7 @@ class CalApi {
dataset["buttonTextColor"] = `${buttonTextColor}`;
}
modal({
async modal({
calLink,
config = {},
calOrigin,
@@ -665,16 +952,22 @@ class CalApi {
calOrigin?: string;
__prerender?: boolean;
}) {
const uid = this.modalUid || this.preloadedModalUid || String(Date.now()) || "0";
const isConnectingToPreloadedModal = this.preloadedModalUid && !this.modalUid;
// `this.modalUid` is set in non-preload case(Temporarily not being-set)
// `this.prerenderedModalUid` is set for a modal created through "prerender"
const uid = this.modalUid || this.prerenderedModalUid || String(Date.now()) || "0";
// Means whether there is already an attempt to use the prerendered modal
const isConnectionInitiated = !!(this.modalUid && this.prerenderedModalUid);
const containerEl = document.body;
this.cal.isPerendering = !!__prerender;
this.cal.isPrerendering = !!__prerender;
if (__prerender) {
// Add preload query param
// Add prerender query param
config.prerender = "true";
// When prerendering, we don't want to preload slots as they might be outdated anyway by the time they are used
// Also, when used with Headless Router attributes setup, we might endup fetching slots for a lot of people, which would be a waste and unnecessary load on Cal.com resources
config["cal.skipSlotsFetch"] = "true";
}
const configWithGuestKeyAndColorScheme = withColorScheme(
@@ -684,29 +977,91 @@ class CalApi {
}),
containerEl
);
const calConfig = this.cal.getCalConfig();
// calOrigin could have been passed as empty string by the user
calOrigin = calOrigin || calConfig.calOrigin;
const embedRenderStartTime = Date.now();
const previousEmbedConfig = this.cal.embedConfig;
const previousEmbedRenderStartTime = this.cal.embedRenderStartTime;
this.cal.embedConfig = configWithGuestKeyAndColorScheme;
const existingModalEl = document.querySelector(`cal-modal-box[uid="${uid}"]`);
if (existingModalEl) {
if (isConnectingToPreloadedModal) {
this.cal.doInIframe({
method: "connect",
arg: configWithGuestKeyAndColorScheme,
});
this.modalUid = uid;
// isConnectionPossible
if (!!existingModalEl && !!this.cal.iframe) {
const calLinkUrlObject = new URL(calLink, calOrigin);
const isHeadlessRouterPath = calLinkUrlObject ? isRouterPath(calLinkUrlObject.toString()) : false;
log(`Trying to reuse modal ${uid}`);
const stateData = {
embedConfig: configWithGuestKeyAndColorScheme,
previousEmbedConfig,
embedRenderStartTime,
previousEmbedRenderStartTime,
isConnectionInitiated,
};
if (isHeadlessRouterPath) {
// Immediately take it to loading state. Either through connect or through loadInIframe, it would later be updated
existingModalEl.setAttribute("state", "loading");
return;
// submitThroughHeadlessRouterInModal would further decide whether full page reload is needed or a connect would suffice
// actionToTake might be "fullReload" and still connect could work in case of headless router because there might be just query params change(which can be handled by connect) of calLink(i.e. ?form=formid&newParam=newValue)
await this.cal.submitThroughHeadlessRouterInModal({
modal: { uid, element: existingModalEl, calOrigin },
calLinkUrlObject,
stateData,
});
} else {
existingModalEl.setAttribute("state", "reopening");
return;
const actionToTake = this.cal.getNextActionForModal({
modal: { uid },
pathWithQueryToLoad: `${calLinkUrlObject.pathname}${calLinkUrlObject.search}`,
stateData,
});
if (actionToTake === "noAction") {
log(`Reopening modal without any other action needed ${uid}`);
// Reopen the modal, nothing else to do
existingModalEl.setAttribute("state", "reopened");
return;
}
log("Attempting to load/connect regular booking link");
// Immediately take it to loading state. Either through connect or through loadInIframe, it would later be updated
existingModalEl.setAttribute("state", "loading");
if (actionToTake === "fullReload") {
log("Initiating full page load");
this.cal.loadInIframe({
calLink,
calOrigin,
iframe: this.cal.iframe,
config: configWithGuestKeyAndColorScheme,
});
} else if (actionToTake === "connect") {
this.cal.doInIframe({
method: "connect",
arg: {
config: configWithGuestKeyAndColorScheme,
params: fromEntriesWithDuplicateKeys(calLinkUrlObject.searchParams.entries()),
},
});
}
}
// We reach here in case of connect or fullReload
this.modalUid = uid;
this.cal.embedRenderStartTime = embedRenderStartTime;
return;
}
log(`Creating new modal ${uid}`);
if (__prerender) {
this.preloadedModalUid = uid;
this.prerenderedModalUid = uid;
} else {
// Intentionally not setting it to have the behaviour of reusing the same modal. Because it causes outdated content that might not be valid based on
// 1. The time difference b/w reopening(availability getting changed in b/w)
// 2. User using different query params but they not being used because of the same modal being reused. Happens in case of headless router being opened in embed
// Intentionally not setting it to avoid the behaviour of reusing the same modal. It was disabled earlier but now can be enabled but we will enable it later.
// this.modalUid = uid;
}
@@ -720,7 +1075,7 @@ class CalApi {
iframe = this.cal.createIframe({
calLink,
config: configWithGuestKeyAndColorScheme,
calOrigin: calOrigin || null,
calOrigin,
});
}
@@ -742,6 +1097,9 @@ class CalApi {
</cal-modal-box>`;
this.cal.modalBox = template.content.children[0];
this.cal.modalBox.appendChild(iframe);
// Set state through setAttribute so that onAttributeChangedCallback is triggered
this.cal.modalBox.setAttribute("state", "loading");
if (__prerender) {
this.cal.modalBox.setAttribute("state", "prerendering");
}
@@ -800,12 +1158,14 @@ class CalApi {
calLink,
type,
options = {},
pageType,
}: {
calLink: string;
type?: "modal" | "floatingButton";
options?: {
prerenderIframe?: boolean;
};
pageType?: EmbedPageType;
}) {
// eslint-disable-next-line prefer-rest-params
validate(arguments[0], {
@@ -835,7 +1195,7 @@ class CalApi {
throw new Error(`Namespace ${namespace} isn't defined`);
}
const config = this.cal.getInitConfig();
const config = this.cal.getCalConfig();
let prerenderIframe = options.prerenderIframe;
if (type && prerenderIframe === undefined) {
prerenderIframe = true;
@@ -847,11 +1207,12 @@ class CalApi {
if (prerenderIframe) {
if (type === "modal" || type === "floatingButton") {
this.cal.isPerendering = true;
this.cal.isPrerendering = true;
this.modal({
calLink,
calOrigin: config.calOrigin,
__prerender: true,
...(pageType ? { config: { "cal.embed.pageType": pageType } } : {}),
});
} else {
console.warn("Ignoring - full preload for inline embed and instead preloading assets only");
@@ -862,10 +1223,19 @@ class CalApi {
}
}
prerender({ calLink, type }: { calLink: string; type: "modal" | "floatingButton" }) {
prerender({
calLink,
type,
pageType,
}: {
calLink: string;
type: "modal" | "floatingButton";
pageType?: EmbedPageType;
}) {
this.preload({
calLink,
type,
pageType,
});
}
@@ -1050,7 +1420,7 @@ function getEmbedApiFn(ns: string) {
return api;
}
function preloadAssetsForCalLink({ config, calLink }: { config: InitConfig; calLink: string }) {
function preloadAssetsForCalLink({ config, calLink }: { config: CalConfig; calLink: string }) {
const iframe = document.body.appendChild(document.createElement("iframe"));
const urlInstance = new URL(`${config.calOrigin}/${calLink}`);
@@ -1081,5 +1451,14 @@ function initializeGlobalCalProps() {
}
function log(...args: unknown[]) {
console.log(...args);
const searchString = location.search;
globalCal.__logQueue = globalCal.__logQueue || [];
globalCal.__logQueue.push(args);
if (searchString.includes("cal.embed.logging=1") || process.env.INTEGRATION_TEST_MODE === "true") {
console.log("Parent:", ...args);
}
}
function error(...args: unknown[]) {
console.error(...args);
}
@@ -101,7 +101,9 @@ export type EventDataMap = {
__routeChanged: Record<string, never>;
__windowLoadComplete: Record<string, never>;
__closeIframe: Record<string, never>;
__iframeReady: Record<string, never>;
__iframeReady: {
isPrerendering: boolean;
};
__dimensionChanged: {
iframeHeight: number;
iframeWidth: number;
+28 -1
View File
@@ -69,4 +69,31 @@ export type KnownConfig = {
"cal.embed.pageType"?: EmbedPageType;
};
export {};
export type EmbedBookerState =
| "initializing"
| "slotsPending"
| "slotsLoading"
/**
* When slots have loaded
* Even when slots aren't requested, due to cal.skipSlotFetch, then also this state is achieved
*/
| "slotsDone"
| "slotsLoadingError";
export type SlotsStatus = "loading" | "success" | "error";
export type SlotsQuery = {
isPending?: boolean;
isError?: boolean;
isSuccess?: boolean;
isLoading?: boolean;
};
/**
* 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;
};
} & KnownConfig;
+102 -4
View File
@@ -1,10 +1,22 @@
import type { KnownConfig } from "./types";
import type { KnownConfig, PrefillAndIframeAttrsConfig } from "./types";
export const getErrorString = (errorCode: string | undefined) => {
export const getErrorString = ({
errorCode,
errorMessage,
}: {
errorCode: string | undefined;
errorMessage: string | undefined;
}) => {
const defaultErrorMessage = "Something went wrong.";
if (errorCode === "404") {
return `Error Code: 404. Cal Link seems to be wrong.`;
errorMessage = errorMessage ?? "Cal Link seems to be wrong.";
return `Error Code: 404. ${errorMessage}`;
} else if (errorCode === "routerError") {
errorMessage = errorMessage ?? defaultErrorMessage;
return `Error Code: routerError. ${errorMessage}`;
} else {
return `Error Code: ${errorCode}. Something went wrong.`;
errorMessage = errorMessage ?? defaultErrorMessage;
return `Error Code: ${errorCode}. ${errorMessage}`;
}
};
@@ -38,6 +50,28 @@ export function fromEntriesWithDuplicateKeys(entries: IterableIterator<[string,
return result;
}
export function isParamValuePresentInUrlSearchParams({
param,
value,
container,
}: {
param: string;
value: string | string[];
container: URLSearchParams;
}) {
// Because UrlSearchParams could have multiple entries with same key like guest=1&guest=2
const containerEntries = fromEntriesWithDuplicateKeys(container.entries());
if (!container.has(param)) {
return false;
}
const containerValue = containerEntries[param];
const valueArray = Array.isArray(value) ? value : [value];
if (valueArray.length !== containerValue.length) {
return false;
}
return valueArray.every((valueItem) => containerValue.includes(valueItem));
}
function listKnownConfigProps() {
const knownConfigProps: (keyof KnownConfig)[] = [
"flag.coep",
@@ -98,3 +132,67 @@ export function generateDataAttributes(props: Record<string, string | null | und
.map(([key, value]) => `data-${hyphenate(key)}="${escapeHtmlAttribute(value)}"`)
.join(" ");
}
export function isRouterPath(path: string) {
// URL doesn't matter
const urlObject = new URL(path, "http://baseUrl.example");
return urlObject.pathname === "/router";
}
export async function submitResponseAndGetRoutingResult({
headlessRouterPageUrl,
}: {
headlessRouterPageUrl: string;
}) {
const headlessRouterUrlObject = new URL(headlessRouterPageUrl);
const searchParams = headlessRouterUrlObject.searchParams;
const headlessRouterApiUrl = `${headlessRouterUrlObject.origin}${headlessRouterUrlObject.pathname.replace(
/^\/?router/,
"/api/router"
)}`;
const response = await fetch(headlessRouterApiUrl, {
method: "POST",
body: JSON.stringify(fromEntriesWithDuplicateKeys(searchParams.entries())),
headers: {
"Content-Type": "application/json",
},
});
const result = await response.json();
if (result.status === "success") {
if (!result.data.redirect && !result.data.message) {
throw new Error("No `redirect` or `message` in response");
}
return result.data as { redirect: string } | { message: string };
} else {
if (!result.data.message) {
throw new Error("No `message` in response");
}
console.warn("Error submitting response and getting routing result", result);
return { error: result.data.message } as { error: string };
}
}
export function isSameBookingLink({
bookingLinkPath1,
bookingLinkPath2,
}: {
bookingLinkPath1: string;
bookingLinkPath2: string;
}) {
// Headless router redirects to /team/event-booking-url at the moment. In future it might fix it to /event-booking-url
// So, stripe /team from both the URLs if present so that they can be compared easily
return bookingLinkPath1.replace(/^\/team\//, "/") === bookingLinkPath2.replace(/^\/team\//, "/");
}
export function buildSearchParamsFromConfig(config: PrefillAndIframeAttrsConfig) {
const { iframeAttrs: _1, ...params } = config;
const searchParams = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
if (value instanceof Array) {
value.forEach((val) => searchParams.append(key, val));
} else if (typeof value === "string") {
searchParams.set(key, value);
}
}
return searchParams;
}
+9
View File
@@ -1,5 +1,14 @@
# @calcom/embed-react
## 1.5.3
### Patch Changes
- Added react-19 as peer dependency
- Updated dependencies
- @calcom/embed-core@1.5.3
- @calcom/embed-snippet@1.3.3
## 1.5.2
### Patch Changes
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@calcom/embed-react",
"sideEffects": false,
"version": "1.5.2",
"version": "1.5.3",
"description": "Embed Cal Link as a React Component",
"license": "SEE LICENSE IN LICENSE",
"repository": {
@@ -1,5 +1,13 @@
# @calcom/embed-snippet
## 1.3.3
### Patch Changes
- Added react-19 as peer dependency
- Updated dependencies
- @calcom/embed-core@1.5.3
## 1.3.2
### Patch Changes
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@calcom/embed-snippet",
"sideEffects": false,
"version": "1.3.2",
"version": "1.3.3",
"main": "./dist/snippet.umd.js",
"module": "./dist/snippet.es.js",
"description": "Vanilla JS embed snippet that is responsible to fetch @calcom/embed-core and thus show Cal Link as an embed on a page.",
+3 -10
View File
@@ -8,6 +8,7 @@ import BookingPageTagManager from "@calcom/app-store/BookingPageTagManager";
import { useIsPlatformBookerEmbed } from "@calcom/atoms/hooks/useIsPlatformBookerEmbed";
import dayjs from "@calcom/dayjs";
import PoweredBy from "@calcom/ee/components/PoweredBy";
import { updateEmbedBookerState } from "@calcom/embed-core/src/embed-iframe";
import TurnstileCaptcha from "@calcom/features/auth/Turnstile";
import useSkipConfirmStep from "@calcom/features/bookings/Booker/components/hooks/useSkipConfirmStep";
import { getQueryParam } from "@calcom/features/bookings/Booker/utils/query-param";
@@ -38,18 +39,10 @@ import { useIsQuickAvailabilityCheckFeatureEnabled } from "./components/hooks/us
import { fadeInLeft, getBookerSizeClassNames, useBookerResizeAnimation } from "./config";
import framerFeatures from "./framer-features";
import { useBookerStore } from "./store";
import type { BookerProps, WrappedBookerProps, BookerState } from "./types";
import type { BookerProps, WrappedBookerProps } from "./types";
import { isBookingDryRun } from "./utils/isBookingDryRun";
import { isTimeSlotAvailable } from "./utils/isTimeslotAvailable";
function updateEmbedBookerState({ bookerState }: { bookerState: BookerState }) {
// Ensure that only after the bookerState is reflected, we update the embedIsBookerReady
if (typeof window !== "undefined") {
(window as Window & { _embedBookerState?: "initializing" | "done" })._embedBookerState =
bookerState && bookerState !== "loading" ? "done" : "initializing";
}
}
const BookerComponent = ({
username,
eventSlug,
@@ -186,7 +179,7 @@ const BookerComponent = ({
(bookerState === "booking" || (bookerState === "selecting_time" && skipConfirmStep))
);
updateEmbedBookerState({ bookerState });
updateEmbedBookerState({ bookerState, slotsQuery: schedule });
useEffect(() => {
if (event.isPending) return setBookerState("loading");
@@ -164,8 +164,8 @@ type BookingDataSchemaGetter =
| typeof import("@calcom/features/bookings/lib/getBookingDataSchemaForApi").default;
type CreatedBooking = Booking & { appsStatus?: AppsStatus[]; paymentUid?: string; paymentId?: number };
const buildDryRunBooking = ({
type ReturnTypeCreateBooking = Awaited<ReturnType<typeof createBooking>>;
export const buildDryRunBooking = ({
eventTypeId,
organizerUser,
eventName,
@@ -193,8 +193,11 @@ const buildDryRunBooking = ({
isManagedEventType: boolean;
}) => {
const sanitizedOrganizerUser = {
...organizerUser,
credentials: undefined,
id: organizerUser.id,
name: organizerUser.name,
username: organizerUser.username,
email: organizerUser.email,
timeZone: organizerUser.timeZone,
};
const booking = {
id: -101,
@@ -210,8 +213,6 @@ const buildDryRunBooking = ({
createdAt: new Date(),
updatedAt: new Date(),
attendees: [],
references: [],
payment: [],
oneTimePassword: null,
smsReminderNumber: null,
metadata: {},
@@ -222,25 +223,18 @@ const buildDryRunBooking = ({
responses: null,
location: null,
paid: false,
destinationCalendar: null,
cancellationReason: null,
rejectionReason: null,
dynamicEventSlugRef: null,
dynamicGroupSlugRef: null,
rescheduledFrom: null,
fromReschedule: null,
recurringEventId: null,
seatsReferences: [],
workflowReminders: [],
scheduledJobs: [],
rescheduledTo: null,
rescheduledBy: null,
destinationCalendarId: null,
reassignReason: null,
reassignById: null,
rescheduled: false,
confirmed: false,
isRecurringEvent: false,
isRecorded: false,
iCalSequence: 0,
rating: null,
@@ -248,7 +242,9 @@ const buildDryRunBooking = ({
noShowHost: null,
cancelledBy: null,
creationSource: CreationSource.WEBAPP,
} as CreatedBooking;
references: [],
payment: [],
} satisfies ReturnTypeCreateBooking;
/**
* Troubleshooting data
@@ -0,0 +1,100 @@
import { describe, it, expect } from "vitest";
import { BookingStatus, CreationSource } from "@calcom/prisma/enums";
import { buildDryRunBooking } from "../../handleNewBooking";
describe("buildDryRunBooking", () => {
const baseOrganizerUser = {
id: 1,
name: "Test User",
username: "testuser",
email: "testuser@example.com",
timeZone: "America/New_York",
credentials: [
{
abc: "xyz",
},
],
};
const baseInputs = {
eventTypeId: 10,
organizerUser: baseOrganizerUser,
eventName: "Test Event",
startTime: "2024-01-01T10:00:00.000Z",
endTime: "2024-01-01T11:00:00.000Z",
contactOwnerFromReq: "owner@example.com",
contactOwnerEmail: "owner@example.com",
allHostUsers: [{ id: 1 }, { id: 2 }],
isManagedEventType: false,
};
it("should build a dry run booking with all provided details and nothing more than that", () => {
const { booking, troubleshooterData } = buildDryRunBooking(baseInputs);
const { user, ...bookingExceptUser } = booking;
expect(user).toEqual({
id: baseOrganizerUser.id,
name: baseOrganizerUser.name,
username: baseOrganizerUser.username,
email: baseOrganizerUser.email,
timeZone: baseOrganizerUser.timeZone,
});
expect(bookingExceptUser).toEqual({
id: -101,
uid: "DRY_RUN_UID",
iCalUID: "DRY_RUN_ICAL_UID",
status: BookingStatus.ACCEPTED,
eventTypeId: baseInputs.eventTypeId,
userId: baseOrganizerUser.id,
title: baseInputs.eventName,
startTime: new Date(baseInputs.startTime),
endTime: new Date(baseInputs.endTime),
createdAt: expect.any(Date),
updatedAt: expect.any(Date),
attendees: [],
oneTimePassword: null,
smsReminderNumber: null,
metadata: {},
idempotencyKey: null,
userPrimaryEmail: null,
description: null,
customInputs: null,
responses: null,
location: null,
paid: false,
cancellationReason: null,
rejectionReason: null,
dynamicEventSlugRef: null,
dynamicGroupSlugRef: null,
fromReschedule: null,
recurringEventId: null,
scheduledJobs: [],
rescheduledBy: null,
destinationCalendarId: null,
reassignReason: null,
reassignById: null,
rescheduled: false,
isRecorded: false,
iCalSequence: 0,
rating: null,
ratingFeedback: null,
noShowHost: null,
cancelledBy: null,
creationSource: CreationSource.WEBAPP,
references: [],
payment: [],
});
expect(troubleshooterData).toEqual({
organizerUserId: baseOrganizerUser.id,
eventTypeId: baseInputs.eventTypeId,
askedContactOwnerEmail: baseInputs.contactOwnerFromReq,
usedContactOwnerEmail: baseInputs.contactOwnerEmail,
allHostUsers: [1, 2],
isManagedEventType: baseInputs.isManagedEventType,
});
});
});
@@ -4,16 +4,23 @@ import type { SortingState, OnChangeFn, VisibilityState, ColumnSizingState } fro
// eslint-disable-next-line no-restricted-imports
import debounce from "lodash/debounce";
import { usePathname } from "next/navigation";
import { useQueryState, parseAsArrayOf, parseAsJson, parseAsInteger, parseAsString } from "nuqs";
import { useQueryState } from "nuqs";
import { createContext, useCallback, useEffect, useRef, useMemo } from "react";
import { useSegmentsNoop } from "./hooks/useSegmentsNoop";
import {
activeFiltersParser,
sortingParser,
columnVisibilityParser,
columnSizingParser,
segmentIdParser,
pageIndexParser,
pageSizeParser,
searchTermParser,
DEFAULT_PAGE_SIZE,
} from "./lib/parsers";
import {
type FilterValue,
ZSorting,
ZColumnVisibility,
ZActiveFilter,
ZColumnSizing,
type FilterSegmentOutput,
type ActiveFilters,
type UseSegments,
@@ -61,12 +68,6 @@ export type DataTableContextType = {
export const DataTableContext = createContext<DataTableContextType | null>(null);
const DEFAULT_ACTIVE_FILTERS: ActiveFilters = [];
const DEFAULT_SORTING: SortingState = [];
const DEFAULT_COLUMN_VISIBILITY: VisibilityState = {};
const DEFAULT_COLUMN_SIZING: ColumnSizingState = {};
const DEFAULT_PAGE_SIZE = 10;
interface DataTableProviderProps {
useSegments?: UseSegments;
tableIdentifier?: string;
@@ -83,26 +84,17 @@ export function DataTableProvider({
ctaContainerClassName = CTA_CONTAINER_CLASS_NAME,
}: DataTableProviderProps) {
const filterToOpen = useRef<string | undefined>(undefined);
const [activeFilters, setActiveFilters] = useQueryState(
"activeFilters",
parseAsArrayOf(parseAsJson(ZActiveFilter.parse)).withDefault(DEFAULT_ACTIVE_FILTERS)
);
const [sorting, setSorting] = useQueryState(
"sorting",
parseAsArrayOf(parseAsJson(ZSorting.parse)).withDefault(DEFAULT_SORTING)
);
const [activeFilters, setActiveFilters] = useQueryState("activeFilters", activeFiltersParser);
const [sorting, setSorting] = useQueryState("sorting", sortingParser);
const [columnVisibility, setColumnVisibility] = useQueryState<VisibilityState>(
"cols",
parseAsJson(ZColumnVisibility.parse).withDefault(DEFAULT_COLUMN_VISIBILITY)
columnVisibilityParser
);
const [columnSizing, setColumnSizing] = useQueryState<ColumnSizingState>(
"widths",
parseAsJson(ZColumnSizing.parse).withDefault(DEFAULT_COLUMN_SIZING)
);
const [segmentId, setSegmentId] = useQueryState("segment", parseAsInteger.withDefault(-1));
const [pageIndex, setPageIndex] = useQueryState("page", parseAsInteger.withDefault(0));
const [pageSize, setPageSize] = useQueryState("size", parseAsInteger.withDefault(defaultPageSize));
const [searchTerm, setSearchTerm] = useQueryState("q", parseAsString.withDefault(""));
const [columnSizing, setColumnSizing] = useQueryState<ColumnSizingState>("widths", columnSizingParser);
const [segmentId, setSegmentId] = useQueryState("segment", segmentIdParser);
const [pageIndex, setPageIndex] = useQueryState("page", pageIndexParser);
const [pageSize, setPageSize] = useQueryState("size", pageSizeParser);
const [searchTerm, setSearchTerm] = useQueryState("q", searchTermParser);
const setDebouncedSearchTerm = useMemo(
() => debounce((value: string | null) => setSearchTerm(value ? value.trim() : null), 500),
@@ -39,6 +39,7 @@ export type DataTablePropsFromWrapper<TData> = {
headerClassName?: string;
rowClassName?: string;
paginationMode?: "infinite" | "standard";
hasWrapperContext?: boolean;
};
export type DataTableProps<TData> = DataTablePropsFromWrapper<TData> & {
@@ -64,6 +65,7 @@ export function DataTable<TData>({
headerClassName,
rowClassName,
paginationMode = "infinite",
hasWrapperContext = false,
...rest
}: DataTableProps<TData> & React.ComponentPropsWithoutRef<"div">) {
const { rows } = table.getRowModel();
@@ -104,7 +106,10 @@ export function DataTable<TData>({
return (
<div
className={classNames("grid", className)}
className={classNames(
!hasWrapperContext ? "grid" : "bg-muted grid rounded-xl px-0.5 pb-0.5",
className
)}
style={{
gridTemplateRows: "auto 1fr auto",
gridTemplateAreas: "'header' 'body' 'footer'",
@@ -129,20 +134,23 @@ export function DataTable<TData>({
onScroll={onScroll}
className={classNames(
"relative overflow-auto",
"scrollbar-thin border-subtle relative rounded-md border",
"scrollbar-thin relative rounded-md ",
paginationMode === "infinite" && "h-[80dvh]", // Set a fixed height for the container
containerClassName
)}
style={{ gridArea: "body" }}>
<TableNew
className="data-table grid border-0"
className={classNames(
"data-table grid border-0",
!hasWrapperContext && "bg-muted rounded-xl px-0.5 pb-0.5"
)}
style={{
...columnSizingVars,
...(Boolean(enableColumnResizing) && { width: table.getTotalSize() }),
}}>
<TableHeader className={classNames("sticky top-0 z-10", headerClassName)}>
{table.getHeaderGroups().map((headerGroup: HeaderGroup<TData>) => (
<TableRow key={headerGroup.id} className="hover:bg-subtle flex w-full">
<TableRow key={headerGroup.id} className="hover:bg-subtle flex w-full border-none">
{headerGroup.headers.map((header: Header<TData, unknown>) => {
const { column } = header;
return (
@@ -155,7 +163,6 @@ export function DataTable<TData>({
}}
className={classNames(
"relative flex shrink-0 items-center",
"bg-subtle",
column.getIsPinned() && "top-0 z-20 sm:sticky"
)}>
<TableHeadLabel header={header} />
@@ -164,7 +171,7 @@ export function DataTable<TData>({
onMouseDown={header.getResizeHandler()}
onTouchStart={header.getResizeHandler()}
className={classNames(
"group absolute right-0 top-0 h-full w-[5px] cursor-col-resize touch-none select-none opacity-[0.1] hover:opacity-50",
"group absolute right-0 top-0 h-full w-[5px] cursor-col-resize touch-none select-none opacity-0 hover:opacity-50",
header.column.getIsResizing() && "!opacity-75"
)}>
<div className="bg-inverted mx-auto h-full w-[1px]" />
@@ -279,7 +286,10 @@ function DataTableBody<TData>({
}
return (
<TableBody className="relative grid" data-testid={testId} style={{ height: tableHeight }}>
<TableBody
className="border-subtle relative grid overflow-hidden rounded-xl border"
data-testid={testId}
style={{ height: tableHeight }}>
{rowsToRender.map(({ row, virtualItem }) => (
<TableRow
ref={virtualItem ? (node) => rowVirtualizer.measureElement(node) : undefined}
@@ -307,7 +317,7 @@ function DataTableBody<TData>({
width: `var(--col-${kebabCase(cell.column.id)}-size)`,
}}
className={classNames(
"flex shrink-0 items-center overflow-hidden",
"bg-default group-hover:!bg-muted group-data-[state=selected]:bg-subtle flex shrink-0 items-center overflow-hidden",
variant === "compact" && "p-0",
column.getIsPinned() &&
"bg-default group-hover:!bg-muted group-data-[state=selected]:bg-subtle sm:sticky"
@@ -351,7 +361,7 @@ const TableHeadLabel = ({ header }: { header: Header<any, any> }) => {
open && "bg-muted"
)}>
<div
className="truncate"
className="text-default truncate text-sm leading-none"
title={
typeof header.column.columnDef.header === "string" ? header.column.columnDef.header : undefined
}>
@@ -1,6 +1,6 @@
"use client";
import type { VisibilityState } from "@tanstack/react-table";
import type { Row, VisibilityState } from "@tanstack/react-table";
// eslint-disable-next-line no-restricted-imports
import { noop } from "lodash";
import { useEffect, useRef } from "react";
@@ -22,6 +22,7 @@ type BaseDataTableWrapperProps<TData> = Omit<
EmptyView?: React.ReactNode;
LoaderView?: React.ReactNode;
tableContainerRef?: React.RefObject<HTMLDivElement>;
onRowMouseclick?: (row: Row<TData>) => void;
};
type InfinitePaginationProps<TData> = BaseDataTableWrapperProps<TData> & {
@@ -61,6 +62,7 @@ export function DataTableWrapper<TData>({
children,
tableContainerRef: externalRef,
paginationMode,
onRowMouseclick,
}: DataTableWrapperProps<TData>) {
const internalRef = useRef<HTMLDivElement>(null);
const tableContainerRef = externalRef || internalRef;
@@ -130,13 +132,15 @@ export function DataTableWrapper<TData>({
headerClassName={headerClassName}
rowClassName={rowClassName}
paginationMode={paginationMode}
onRowMouseclick={onRowMouseclick}
hasWrapperContext={true}
onScroll={
paginationMode === "infinite"
? (e: Pick<React.UIEvent<HTMLDivElement, UIEvent>, "target">) =>
fetchMoreOnBottomReached(e.target as HTMLDivElement)
: undefined
}>
<div style={{ gridArea: "footer", marginTop: "1rem" }}>
<div style={{ gridArea: "footer" }} className="px-3 py-2">
<DataTablePagination<TData>
table={table}
totalRowCount={totalRowCount}
+1
View File
@@ -1,6 +1,7 @@
export * from "./components";
export * from "./lib/types";
export * from "./lib/utils";
export * from "./lib/serializers";
export * from "./DataTableProvider";
export * from "./hooks/useColumnResizing";
export * from "./hooks";
@@ -0,0 +1,24 @@
import type { SortingState, VisibilityState, ColumnSizingState } from "@tanstack/react-table";
import { parseAsArrayOf, parseAsJson, parseAsInteger, parseAsString } from "nuqs";
import { ZActiveFilter, ZSorting, ZColumnVisibility, ZColumnSizing } from "./types";
import type { ActiveFilters } from "./types";
const DEFAULT_ACTIVE_FILTERS: ActiveFilters = [] as ActiveFilters;
const DEFAULT_SORTING: SortingState = [] as SortingState;
const DEFAULT_COLUMN_VISIBILITY: VisibilityState = {};
const DEFAULT_COLUMN_SIZING: ColumnSizingState = {};
export const DEFAULT_PAGE_SIZE = 10;
export const activeFiltersParser = parseAsArrayOf(parseAsJson(ZActiveFilter.parse)).withDefault(
DEFAULT_ACTIVE_FILTERS
);
export const sortingParser = parseAsArrayOf(parseAsJson(ZSorting.parse)).withDefault(DEFAULT_SORTING);
export const columnVisibilityParser = parseAsJson(ZColumnVisibility.parse).withDefault(
DEFAULT_COLUMN_VISIBILITY
);
export const columnSizingParser = parseAsJson(ZColumnSizing.parse).withDefault(DEFAULT_COLUMN_SIZING);
export const segmentIdParser = parseAsInteger.withDefault(-1);
export const pageIndexParser = parseAsInteger.withDefault(0);
export const pageSizeParser = parseAsInteger.withDefault(DEFAULT_PAGE_SIZE);
export const searchTermParser = parseAsString.withDefault("");
@@ -0,0 +1,28 @@
import { createSerializer } from "nuqs";
import {
activeFiltersParser,
sortingParser,
columnVisibilityParser,
columnSizingParser,
segmentIdParser,
pageIndexParser,
pageSizeParser,
searchTermParser,
} from "./parsers";
/**
* Serializer for data table query parameters
* Used to generate URL query parameters for data tables
* Based on the parameters used in DataTableProvider
*/
export const dataTableQueryParamsSerializer = createSerializer({
activeFilters: activeFiltersParser,
sorting: sortingParser,
cols: columnVisibilityParser,
widths: columnSizingParser,
segment: segmentIdParser,
page: pageIndexParser,
size: pageSizeParser,
q: searchTermParser,
});
@@ -19,10 +19,10 @@ import type { Dispatch, SetStateAction } from "react";
import { checkAdminOrOwner } from "@calcom/features/auth/lib/checkAdminOrOwner";
import { Dialog } from "@calcom/features/components/controlled-dialog";
import {
DataTable,
DataTableProvider,
DataTableToolbar,
DataTableFilters,
DataTableWrapper,
DataTableSelectionBar,
useDataTable,
useFetchMoreOnBottomReached,
@@ -305,7 +305,6 @@ function MemberListContent(props: Props) {
checked={table.getIsAllPageRowsSelected()}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
className="translate-y-[2px]"
/>
),
cell: ({ row }) => (
@@ -660,18 +659,27 @@ function MemberListContent(props: Props) {
return (
<>
<DataTable
<DataTableWrapper
testId="team-member-list-container"
table={table}
tableContainerRef={tableContainerRef}
isPending={isPending}
enableColumnResizing={true}
onScroll={(e) => fetchMoreOnBottomReached(e.target as HTMLDivElement)}>
<DataTableToolbar.Root>
<div className="flex w-full gap-2">
paginationMode="infinite"
hasNextPage={hasNextPage}
fetchNextPage={fetchNextPage}
isFetching={isFetching}
totalRowCount={totalRowCount}
ToolbarLeft={
<>
<DataTableToolbar.SearchBar />
<DataTableFilters.AddFilterButton table={table} />
<DataTableFilters.ColumnVisibilityButton table={table} />
<DataTableFilters.FilterBar table={table} />
</>
}
ToolbarRight={
<>
<DataTableFilters.ClearFiltersButton />
{isAdminOrOwner && (
<DataTableToolbar.CTA
type="button"
@@ -682,12 +690,8 @@ function MemberListContent(props: Props) {
{t("add")}
</DataTableToolbar.CTA>
)}
</div>
<div className="flex gap-2 justify-self-start">
<DataTableFilters.ActiveFilters table={table} />
</div>
</DataTableToolbar.Root>
</>
}>
{numberOfSelectedRows >= 2 && dynamicLinkVisible && (
<DataTableSelectionBar.Root className="!bottom-[7.3rem] md:!bottom-32">
<DynamicLink table={table} domain={domain} />
@@ -715,7 +719,7 @@ function MemberListContent(props: Props) {
/>
</DataTableSelectionBar.Root>
)}
</DataTable>
</DataTableWrapper>
{state.deleteMember.showModal && (
<Dialog
open={true}

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