Compare commits
138
Commits
v3.4.0-rc1
...
v3.4.6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
51fd4102ae | ||
|
|
9d1ef0a649 | ||
|
|
f80dc0738a | ||
|
|
678ab3f453 | ||
|
|
199d3e4c3f | ||
|
|
79a6aef0e7 | ||
|
|
4d49fb0636 | ||
|
|
0be1387d0f | ||
|
|
58ab278813 | ||
|
|
4de142cb7c | ||
|
|
b4d27a9326 | ||
|
|
31f3d9778e | ||
|
|
0a59c95b93 | ||
|
|
9e3465eeb6 | ||
|
|
31fc4724e0 | ||
|
|
f81f0a26ec | ||
|
|
9a80bb6194 | ||
|
|
901fc36c97 | ||
|
|
2831fb2b57 | ||
|
|
426d31712e | ||
|
|
09ecd445bb | ||
|
|
08d65c85de | ||
|
|
b9cef10ef2 | ||
|
|
0dc41592f2 | ||
|
|
aabf3c54ea | ||
|
|
c2a57fd72b | ||
|
|
52386e08f2 | ||
|
|
b724d367fc | ||
|
|
07924751ad | ||
|
|
defa8df7ca | ||
|
|
bf8580fa88 | ||
|
|
c7b1e4dfa1 | ||
|
|
139a7c8249 | ||
|
|
158da51a5d | ||
|
|
f9ad99e572 | ||
|
|
1c65f5c150 | ||
|
|
0fb75b715d | ||
|
|
9364055283 | ||
|
|
1929b23ea8 | ||
|
|
efc7be0b6b | ||
|
|
327159c2ae | ||
|
|
af801df421 | ||
|
|
79c1aa60a2 | ||
|
|
a9535d3fd4 | ||
|
|
0ae6506bc1 | ||
|
|
a8c03262c2 | ||
|
|
687669ce17 | ||
|
|
bf6dd665f0 | ||
|
|
9250b91bb0 | ||
|
|
b934c74c30 | ||
|
|
96810b5ba1 | ||
|
|
ee08118ed3 | ||
|
|
051353e7f1 | ||
|
|
19f52429b0 | ||
|
|
4ed15d2755 | ||
|
|
154af1367a | ||
|
|
1de60bcfeb | ||
|
|
3679854c43 | ||
|
|
ce64c494f4 | ||
|
|
df4aa24913 | ||
|
|
aa54c013f8 | ||
|
|
0014ca6865 | ||
|
|
6c00c9b2b8 | ||
|
|
46fc67f70d | ||
|
|
e91fe12219 | ||
|
|
2a8f7412dd | ||
|
|
64d634e406 | ||
|
|
39cfe18ffe | ||
|
|
eac45c5e23 | ||
|
|
446c2b0f0e | ||
|
|
92e5aae901 | ||
|
|
bf3db721e2 | ||
|
|
8d4561c866 | ||
|
|
6627a211d7 | ||
|
|
0763a64b30 | ||
|
|
d333a31221 | ||
|
|
55a8a0d2d3 | ||
|
|
3093216534 | ||
|
|
99a1c36ffc | ||
|
|
f4e48f5fc1 | ||
|
|
c352dc647e | ||
|
|
34bb069b4a | ||
|
|
39ea9c112d | ||
|
|
e32d4648af | ||
|
|
20b7633ab5 | ||
|
|
6f017a7972 | ||
|
|
cbd0e2d287 | ||
|
|
ff739bf9be | ||
|
|
ff3541910b | ||
|
|
d043de7724 | ||
|
|
be1517facd | ||
|
|
19eced00f5 | ||
|
|
e2414b174a | ||
|
|
5e3c0cdea1 | ||
|
|
6b6d3d90e4 | ||
|
|
9b348adb6a | ||
|
|
feda420f0c | ||
|
|
4b818de0c8 | ||
|
|
614741d207 | ||
|
|
2550485c49 | ||
|
|
efc3e864bb | ||
|
|
1bf56fbe93 | ||
|
|
629629cb9e | ||
|
|
8c0751b186 | ||
|
|
0b46f61a23 | ||
|
|
0c92fbe11d | ||
|
|
59fa713549 | ||
|
|
63416d4f33 | ||
|
|
9e927af813 | ||
|
|
4b8bdeba74 | ||
|
|
4e4d67c8c0 | ||
|
|
ffda234b3c | ||
|
|
0bc44c36db | ||
|
|
f18bee5c3d | ||
|
|
73e16215bd | ||
|
|
75d7d2f172 | ||
|
|
269dca5b6d | ||
|
|
0fd6bed813 | ||
|
|
33bef6acd0 | ||
|
|
a6c4b31845 | ||
|
|
ea8ba8defc | ||
|
|
94cb78491a | ||
|
|
d12a5c5883 | ||
|
|
7a014761dc | ||
|
|
91ac952a72 | ||
|
|
225055fb0c | ||
|
|
d46e80c2ac | ||
|
|
fe364bd2da | ||
|
|
2756dff735 | ||
|
|
401f64b986 | ||
|
|
a02dcf485f | ||
|
|
f2ecd9818a | ||
|
|
bc81f659aa | ||
|
|
f8f038c5e9 | ||
|
|
fefb6acc57 | ||
|
|
461120ad84 | ||
|
|
bab72a5d2e | ||
|
|
e4011b4a23 |
+1
-1
@@ -87,7 +87,7 @@ CRON_ENABLE_APP_SYNC=false
|
||||
|
||||
# Application Key for symmetric encryption and decryption
|
||||
# must be 32 bytes for AES256 encryption algorithm
|
||||
# You can use: `openssl rand -base64 24` to generate one
|
||||
# You can use: `openssl rand -base64 32` to generate one
|
||||
CALENDSO_ENCRYPTION_KEY=
|
||||
|
||||
# Intercom Config
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
name: Auto Comment Merge Conflicts
|
||||
on: push
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
auto-comment-merge-conflicts:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: codytseng/auto-comment-merge-conflicts@v1
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
comment-body: "Hey there, there is a merge conflict, can you take a look?"
|
||||
wait-ms: 3000
|
||||
max-retries: 5
|
||||
label-name: "🚨 merge conflict"
|
||||
ignore-authors: dependabot,otherAuthor
|
||||
@@ -15,3 +15,5 @@ jobs:
|
||||
- uses: ./.github/actions/yarn-install
|
||||
# Should be an 8GB machine as per https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners
|
||||
- run: yarn test
|
||||
# We could add different timezones here that we need to run our tests in
|
||||
- run: TZ=America/Los_Angeles yarn test -- --timeZoneDependentTestsOnly
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
diff --git a/index.cjs b/index.cjs
|
||||
index b645707a3549fc298508726e404243499bbed499..f34b0891e99b275a9218e253f303f43d31ef3f73 100644
|
||||
--- a/index.cjs
|
||||
+++ b/index.cjs
|
||||
@@ -13,8 +13,8 @@ function withMetadataArgument(func, _arguments) {
|
||||
// https://github.com/babel/babel/issues/2212#issuecomment-131827986
|
||||
// An alternative approach:
|
||||
// https://www.npmjs.com/package/babel-plugin-add-module-exports
|
||||
-exports = module.exports = min.parsePhoneNumberFromString
|
||||
-exports['default'] = min.parsePhoneNumberFromString
|
||||
+// exports = module.exports = min.parsePhoneNumberFromString
|
||||
+// exports['default'] = min.parsePhoneNumberFromString
|
||||
|
||||
// `parsePhoneNumberFromString()` named export is now considered legacy:
|
||||
// it has been promoted to a default export due to being too verbose.
|
||||
@@ -0,0 +1,26 @@
|
||||
diff --git a/dist/commonjs/serverSideTranslations.js b/dist/commonjs/serverSideTranslations.js
|
||||
index bcad3d02fbdfab8dacb1d85efd79e98623a0c257..fff668f598154a13c4030d1b4a90d5d9c18214ad 100644
|
||||
--- a/dist/commonjs/serverSideTranslations.js
|
||||
+++ b/dist/commonjs/serverSideTranslations.js
|
||||
@@ -36,7 +36,6 @@ var _fs = _interopRequireDefault(require("fs"));
|
||||
var _path = _interopRequireDefault(require("path"));
|
||||
var _createConfig = require("./config/createConfig");
|
||||
var _node = _interopRequireDefault(require("./createClient/node"));
|
||||
-var _appWithTranslation = require("./appWithTranslation");
|
||||
var _utils = require("./utils");
|
||||
function ownKeys(object, enumerableOnly) { var keys = Object.keys(object); if (Object.getOwnPropertySymbols) { var symbols = Object.getOwnPropertySymbols(object); enumerableOnly && (symbols = symbols.filter(function (sym) { return Object.getOwnPropertyDescriptor(object, sym).enumerable; })), keys.push.apply(keys, symbols); } return keys; }
|
||||
function _objectSpread(target) { for (var i = 1; i < arguments.length; i++) { var source = null != arguments[i] ? arguments[i] : {}; i % 2 ? ownKeys(Object(source), !0).forEach(function (key) { (0, _defineProperty2["default"])(target, key, source[key]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(target, Object.getOwnPropertyDescriptors(source)) : ownKeys(Object(source)).forEach(function (key) { Object.defineProperty(target, key, Object.getOwnPropertyDescriptor(source, key)); }); } return target; }
|
||||
@@ -110,12 +109,8 @@ var serverSideTranslations = /*#__PURE__*/function () {
|
||||
lng: initialLocale
|
||||
}));
|
||||
localeExtension = config.localeExtension, localePath = config.localePath, fallbackLng = config.fallbackLng, reloadOnPrerender = config.reloadOnPrerender;
|
||||
- if (!reloadOnPrerender) {
|
||||
- _context.next = 18;
|
||||
- break;
|
||||
- }
|
||||
_context.next = 18;
|
||||
- return _appWithTranslation.globalI18n === null || _appWithTranslation.globalI18n === void 0 ? void 0 : _appWithTranslation.globalI18n.reloadResources();
|
||||
+ return void 0;
|
||||
case 18:
|
||||
_createClient = (0, _node["default"])(_objectSpread(_objectSpread({}, config), {}, {
|
||||
lng: initialLocale
|
||||
@@ -0,0 +1,4 @@
|
||||
# Checkly Tests
|
||||
|
||||
Run as `yarn checkly test`
|
||||
Deploy the tests as `yarn checkly deploy`
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Org", () => {
|
||||
// Because these pages involve next.config.js rewrites, it's better to test them on production
|
||||
test.describe("Embeds - i.cal.com", () => {
|
||||
test("Org Profile Page should be embeddable", async ({ page }) => {
|
||||
const response = await page.goto("https://i.cal.com/embed");
|
||||
expect(response?.status()).toBe(200);
|
||||
await page.screenshot({ path: "screenshot.jpg" });
|
||||
await expectPageToBeServerSideRendered(page);
|
||||
});
|
||||
|
||||
test("Org User(Peer) Page should be embeddable", async ({ page }) => {
|
||||
const response = await page.goto("https://i.cal.com/peer/embed");
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.locator("text=Peer Richelsen")).toBeVisible();
|
||||
await expectPageToBeServerSideRendered(page);
|
||||
});
|
||||
|
||||
test("Org User Event(peer/meet) Page should be embeddable", async ({ page }) => {
|
||||
const response = await page.goto("https://i.cal.com/peer/meet/embed");
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.locator('[data-testid="decrementMonth"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="incrementMonth"]')).toBeVisible();
|
||||
await expectPageToBeServerSideRendered(page);
|
||||
});
|
||||
|
||||
test("Org Team Profile(/sales) page should be embeddable", async ({ page }) => {
|
||||
const response = await page.goto("https://i.cal.com/sales/embed");
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.locator("text=Cal.com Sales")).toBeVisible();
|
||||
await expectPageToBeServerSideRendered(page);
|
||||
});
|
||||
|
||||
test("Org Team Event page(/sales/hippa) should be embeddable", async ({ page }) => {
|
||||
const response = await page.goto("https://i.cal.com/sales/hipaa/embed");
|
||||
expect(response?.status()).toBe(200);
|
||||
await expect(page.locator('[data-testid="decrementMonth"]')).toBeVisible();
|
||||
await expect(page.locator('[data-testid="incrementMonth"]')).toBeVisible();
|
||||
await expectPageToBeServerSideRendered(page);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// This ensures that the route is actually mapped to a page that is using withEmbedSsr
|
||||
async function expectPageToBeServerSideRendered(page: Page) {
|
||||
expect(
|
||||
await page.evaluate(() => {
|
||||
return window.__NEXT_DATA__.props.pageProps.isEmbed;
|
||||
})
|
||||
).toBe(true);
|
||||
}
|
||||
+5
-1
@@ -16,6 +16,10 @@ Emails are cleaned and routed in [receive/route.ts](/apps/ai/src/app/api/receive
|
||||
|
||||
Incoming emails are routed by email address. Addresses are verified by [DKIM record](https://support.google.com/a/answer/174124?hl=en), making them hard to spoof.
|
||||
|
||||
## Recognition
|
||||
|
||||
<a href="https://www.producthunt.com/posts/cal-ai?utm_source=badge-top-post-badge&utm_medium=badge&utm_souce=badge-cal-ai" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/top-post-badge.svg?post_id=419860&theme=light&period=daily" alt="Cal.ai - World's first open source AI scheduling assistant | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a> <a href="https://www.producthunt.com/posts/cal-ai?utm_source=badge-featured&utm_medium=badge&utm_souce=badge-cal-ai" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=419860&theme=light" alt="Cal.ai - World's first open source AI scheduling assistant | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Development
|
||||
@@ -44,7 +48,7 @@ Here is the full architecture:
|
||||
|
||||
### Email Router
|
||||
|
||||
To expose the AI app, run `ngrok http 3000` (or the AI app's port number) in a new terminal. You may need to install [nGrok](https://ngrok.com/).
|
||||
To expose the AI app, run `ngrok http 3005` (or the AI app's port number) in a new terminal. You may need to install [nGrok](https://ngrok.com/).
|
||||
|
||||
To forward incoming emails to the serverless function at `/agent`, we use [SendGrid's Inbound Parse](https://docs.sendgrid.com/for-developers/parsing-email/setting-up-the-inbound-parse-webhook).
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/ai",
|
||||
"version": "1.2.0",
|
||||
"version": "1.2.1",
|
||||
"private": true,
|
||||
"author": "Cal.com Inc.",
|
||||
"dependencies": {
|
||||
|
||||
@@ -40,6 +40,13 @@ export const POST = async (request: NextRequest) => {
|
||||
|
||||
return new NextResponse("ok");
|
||||
} catch (error) {
|
||||
await sendEmail({
|
||||
subject: `Re: ${subject}`,
|
||||
text: "Thanks for using Cal.ai! We're experiencing high demand and can't currently process your request. Please try again later.",
|
||||
to: user.email,
|
||||
from: agentEmail,
|
||||
});
|
||||
|
||||
return new NextResponse(
|
||||
(error as Error).message || "Something went wrong. Please try again or reach out for help.",
|
||||
{ status: 500 }
|
||||
|
||||
@@ -3,6 +3,7 @@ import { simpleParser } from "mailparser";
|
||||
import type { NextRequest } from "next/server";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { checkRateLimitAndThrowError } from "@calcom/lib/checkRateLimitAndThrowError";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
import { env } from "../../../env.mjs";
|
||||
@@ -31,18 +32,37 @@ export const POST = async (request: NextRequest) => {
|
||||
|
||||
const formData = await request.formData();
|
||||
const body = Object.fromEntries(formData);
|
||||
|
||||
// body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
|
||||
const signature = (body.dkim as string).includes(" : pass");
|
||||
|
||||
const envelope = JSON.parse(body.envelope as string);
|
||||
|
||||
const aiEmail = envelope.to[0];
|
||||
const subject = body.subject || "";
|
||||
|
||||
try {
|
||||
await checkRateLimitAndThrowError({
|
||||
identifier: `ai:email:${envelope.from}`,
|
||||
rateLimitingType: "ai",
|
||||
});
|
||||
} catch (error) {
|
||||
await sendEmail({
|
||||
subject: `Re: ${subject}`,
|
||||
text: "Thanks for using Cal.ai! You've reached your daily limit. Please try again tomorrow.",
|
||||
to: envelope.from,
|
||||
from: aiEmail,
|
||||
});
|
||||
|
||||
return new NextResponse("Exceeded rate limit", { status: 200 }); // Don't return 429 to avoid triggering retry logic in SendGrid
|
||||
}
|
||||
|
||||
// Parse email from mixed MIME type
|
||||
const parsed: ParsedMail = await simpleParser(body.email as Source);
|
||||
|
||||
if (!parsed.text && !parsed.subject) {
|
||||
await sendEmail({
|
||||
subject: `Re: ${subject}`,
|
||||
text: "Thanks for using Cal.ai! It looks like you forgot to include a message. Please try again.",
|
||||
to: envelope.from,
|
||||
from: aiEmail,
|
||||
});
|
||||
return new NextResponse("Email missing text and subject", { status: 400 });
|
||||
}
|
||||
|
||||
@@ -62,11 +82,14 @@ export const POST = async (request: NextRequest) => {
|
||||
where: { email: envelope.from },
|
||||
});
|
||||
|
||||
// body.dkim looks like {@domain-com.22222222.gappssmtp.com : pass}
|
||||
const signature = (body.dkim as string).includes(" : pass");
|
||||
|
||||
// User is not a cal.com user or is using an unverified email.
|
||||
if (!signature || !user) {
|
||||
await sendEmail({
|
||||
html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address.`,
|
||||
subject: `Re: ${body.subject}`,
|
||||
html: `Thanks for your interest in Cal.ai! To get started, Make sure you have a <a href="https://cal.com/signup" target="_blank">cal.com</a> account with this email address and then install Cal.ai here: <a href="https://go.cal.com/ai" target="_blank">go.cal.com/ai</a>.`,
|
||||
subject: `Re: ${subject}`,
|
||||
text: `Thanks for your interest in Cal.ai! To get started, Make sure you have a cal.com account with this email address. You can sign up for an account at: https://cal.com/signup`,
|
||||
to: envelope.from,
|
||||
from: aiEmail,
|
||||
@@ -83,7 +106,7 @@ export const POST = async (request: NextRequest) => {
|
||||
|
||||
await sendEmail({
|
||||
html: `Thanks for using Cal.ai! To get started, the app must be installed. <a href=${url} target="_blank">Click this link</a> to install it.`,
|
||||
subject: `Re: ${body.subject}`,
|
||||
subject: `Re: ${subject}`,
|
||||
text: `Thanks for using Cal.ai! To get started, the app must be installed. Click this link to install the Cal.ai app: ${url}`,
|
||||
to: envelope.from,
|
||||
from: aiEmail,
|
||||
@@ -110,7 +133,7 @@ export const POST = async (request: NextRequest) => {
|
||||
|
||||
if ("error" in availability) {
|
||||
await sendEmail({
|
||||
subject: `Re: ${body.subject}`,
|
||||
subject: `Re: ${subject}`,
|
||||
text: "Sorry, there was an error fetching your availability. Please try again.",
|
||||
to: user.email,
|
||||
from: aiEmail,
|
||||
@@ -121,7 +144,7 @@ export const POST = async (request: NextRequest) => {
|
||||
|
||||
if ("error" in eventTypes) {
|
||||
await sendEmail({
|
||||
subject: `Re: ${body.subject}`,
|
||||
subject: `Re: ${subject}`,
|
||||
text: "Sorry, there was an error fetching your event types. Please try again.",
|
||||
to: user.email,
|
||||
from: aiEmail,
|
||||
@@ -139,8 +162,8 @@ export const POST = async (request: NextRequest) => {
|
||||
body: JSON.stringify({
|
||||
apiKey,
|
||||
userId: user.id,
|
||||
message: parsed.text,
|
||||
subject: parsed.subject,
|
||||
message: parsed.text || "",
|
||||
subject: parsed.subject || "",
|
||||
replyTo: aiEmail,
|
||||
user: {
|
||||
email: user.email,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { hashAPIKey } from "@calcom/features/ee/api-keys/lib/apiKeys";
|
||||
import checkLicense from "@calcom/features/ee/common/server/checkLicense";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
|
||||
import { isAdminGuard } from "~/lib/utils/isAdmin";
|
||||
import { isAdminGuard } from "../utils/isAdmin";
|
||||
|
||||
// Used to check if the apiKey is not expired, could be extracted if reused. but not for now.
|
||||
export const dateNotInPast = function (date: Date) {
|
||||
|
||||
@@ -16,22 +16,25 @@ import { rateLimitApiKey } from "./rateLimitApiKey";
|
||||
import { verifyApiKey } from "./verifyApiKey";
|
||||
import { withPagination } from "./withPagination";
|
||||
|
||||
const withMiddleware = label(
|
||||
{
|
||||
HTTP_GET_OR_POST,
|
||||
HTTP_GET_DELETE_PATCH,
|
||||
HTTP_GET,
|
||||
HTTP_PATCH,
|
||||
HTTP_POST,
|
||||
HTTP_DELETE,
|
||||
addRequestId,
|
||||
verifyApiKey,
|
||||
rateLimitApiKey,
|
||||
customPrismaClient,
|
||||
extendRequest,
|
||||
pagination: withPagination,
|
||||
captureErrors,
|
||||
},
|
||||
const middleware = {
|
||||
HTTP_GET_OR_POST,
|
||||
HTTP_GET_DELETE_PATCH,
|
||||
HTTP_GET,
|
||||
HTTP_PATCH,
|
||||
HTTP_POST,
|
||||
HTTP_DELETE,
|
||||
addRequestId,
|
||||
verifyApiKey,
|
||||
rateLimitApiKey,
|
||||
customPrismaClient,
|
||||
extendRequest,
|
||||
pagination: withPagination,
|
||||
captureErrors,
|
||||
};
|
||||
|
||||
type Middleware = keyof typeof middleware;
|
||||
|
||||
const middlewareOrder =
|
||||
// The order here, determines the order of execution
|
||||
[
|
||||
"extendRequest",
|
||||
@@ -41,7 +44,8 @@ const withMiddleware = label(
|
||||
"verifyApiKey",
|
||||
"rateLimitApiKey",
|
||||
"addRequestId",
|
||||
] // <-- Provide a list of middleware to call automatically
|
||||
);
|
||||
] as Middleware[]; // <-- Provide a list of middleware to call automatically
|
||||
|
||||
export { withMiddleware };
|
||||
const withMiddleware = label(middleware, middlewareOrder);
|
||||
|
||||
export { withMiddleware, middleware, middlewareOrder };
|
||||
|
||||
@@ -75,6 +75,7 @@ export const schemaUserBaseBodyParams = User.pick({
|
||||
theme: true,
|
||||
defaultScheduleId: true,
|
||||
locale: true,
|
||||
hideBranding: true,
|
||||
timeFormat: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
@@ -95,6 +96,7 @@ const schemaUserEditParams = z.object({
|
||||
weekStart: z.nativeEnum(weekdays).optional(),
|
||||
brandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
||||
darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
||||
hideBranding: z.boolean().optional(),
|
||||
timeZone: timeZone.optional(),
|
||||
theme: z.nativeEnum(theme).optional().nullable(),
|
||||
timeFormat: z.nativeEnum(timeFormat).optional(),
|
||||
@@ -115,6 +117,7 @@ const schemaUserCreateParams = z.object({
|
||||
weekStart: z.nativeEnum(weekdays).optional(),
|
||||
brandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
||||
darkBrandColor: z.string().min(4).max(9).regex(/^#/).optional(),
|
||||
hideBranding: z.boolean().optional(),
|
||||
timeZone: timeZone.optional(),
|
||||
theme: z.nativeEnum(theme).optional().nullable(),
|
||||
timeFormat: z.nativeEnum(timeFormat).optional(),
|
||||
@@ -157,6 +160,7 @@ export const schemaUserReadPublic = User.pick({
|
||||
defaultScheduleId: true,
|
||||
locale: true,
|
||||
timeFormat: true,
|
||||
hideBranding: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
allowDynamicBooking: true,
|
||||
|
||||
@@ -20,6 +20,7 @@ export const schemaWebhookCreateParams = z
|
||||
payloadTemplate: z.string().optional().nullable(),
|
||||
eventTypeId: z.number().optional(),
|
||||
userId: z.number().optional(),
|
||||
secret: z.string().optional().nullable(),
|
||||
// API shouldn't mess with Apps webhooks yet (ie. Zapier)
|
||||
// appId: z.string().optional().nullable(),
|
||||
})
|
||||
@@ -31,6 +32,7 @@ export const schemaWebhookEditBodyParams = schemaWebhookBaseBodyParams
|
||||
.merge(
|
||||
z.object({
|
||||
eventTriggers: z.enum(WEBHOOK_TRIGGER_EVENTS).array().optional(),
|
||||
secret: z.string().optional().nullable(),
|
||||
})
|
||||
)
|
||||
.partial()
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import type { NextApiRequest } from "next";
|
||||
import type { z } from "zod";
|
||||
|
||||
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import type { PrismaClient } from "@calcom/prisma";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
|
||||
import {
|
||||
schemaDestinationCalendarEditBodyParams,
|
||||
@@ -56,16 +62,251 @@ import { schemaQueryIdParseInt } from "~/lib/validations/shared/queryIdTransform
|
||||
* 404:
|
||||
* description: Destination calendar not found
|
||||
*/
|
||||
type DestinationCalendarType = {
|
||||
userId?: number | null;
|
||||
eventTypeId?: number | null;
|
||||
credentialId: number | null;
|
||||
};
|
||||
|
||||
type UserCredentialType = {
|
||||
id: number;
|
||||
appId: string | null;
|
||||
type: string;
|
||||
userId: number | null;
|
||||
user: {
|
||||
email: string;
|
||||
} | null;
|
||||
teamId: number | null;
|
||||
key: Prisma.JsonValue;
|
||||
invalid: boolean | null;
|
||||
};
|
||||
|
||||
export async function patchHandler(req: NextApiRequest) {
|
||||
const { prisma, query, body } = req;
|
||||
const { userId, isAdmin, prisma, query, body } = req;
|
||||
const { id } = schemaQueryIdParseInt.parse(query);
|
||||
const parsedBody = schemaDestinationCalendarEditBodyParams.parse(body);
|
||||
const assignedUserId = isAdmin ? parsedBody.userId || userId : userId;
|
||||
|
||||
validateIntegrationInput(parsedBody);
|
||||
const destinationCalendarObject: DestinationCalendarType = await getDestinationCalendar(id, prisma);
|
||||
await validateRequestAndOwnership({ destinationCalendarObject, parsedBody, assignedUserId, prisma });
|
||||
|
||||
const userCredentials = await getUserCredentials({
|
||||
credentialId: destinationCalendarObject.credentialId,
|
||||
userId: assignedUserId,
|
||||
prisma,
|
||||
});
|
||||
const credentialId = await verifyCredentialsAndGetId({
|
||||
parsedBody,
|
||||
userCredentials,
|
||||
currentCredentialId: destinationCalendarObject.credentialId,
|
||||
});
|
||||
// If the user has passed eventTypeId, we need to remove userId from the update data to make sure we don't link it to user as well
|
||||
if (parsedBody.eventTypeId) parsedBody.userId = undefined;
|
||||
const destinationCalendar = await prisma.destinationCalendar.update({
|
||||
where: { id },
|
||||
data: parsedBody,
|
||||
data: { ...parsedBody, credentialId },
|
||||
});
|
||||
return { destinationCalendar: schemaDestinationCalendarReadPublic.parse(destinationCalendar) };
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves user credentials associated with a given credential ID and user ID and validates if the credentials belong to this user
|
||||
*
|
||||
* @param credentialId - The ID of the credential to fetch. If not provided, an error is thrown.
|
||||
* @param userId - The user ID against which the credentials need to be verified.
|
||||
* @param prisma - An instance of PrismaClient for database operations.
|
||||
*
|
||||
* @returns - An array containing the matching user credentials.
|
||||
*
|
||||
* @throws HttpError - If `credentialId` is not provided or no associated credentials are found in the database.
|
||||
*/
|
||||
async function getUserCredentials({
|
||||
credentialId,
|
||||
userId,
|
||||
prisma,
|
||||
}: {
|
||||
credentialId: number | null;
|
||||
userId: number;
|
||||
prisma: PrismaClient;
|
||||
}) {
|
||||
if (!credentialId) {
|
||||
throw new HttpError({
|
||||
statusCode: 404,
|
||||
message: `Destination calendar missing credential id`,
|
||||
});
|
||||
}
|
||||
const userCredentials = await prisma.credential.findMany({
|
||||
where: { id: credentialId, userId },
|
||||
select: credentialForCalendarServiceSelect,
|
||||
});
|
||||
|
||||
if (!userCredentials || userCredentials.length === 0) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: `Bad request, no associated credentials found`,
|
||||
});
|
||||
}
|
||||
return userCredentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies the provided credentials and retrieves the associated credential ID.
|
||||
*
|
||||
* This function checks if the `integration` and `externalId` properties from the parsed body are present.
|
||||
* If both properties exist, it fetches the connected calendar credentials using the provided user credentials
|
||||
* and checks for a matching external ID and integration from the list of connected calendars.
|
||||
*
|
||||
* If a match is found, it updates the `credentialId` with the one from the connected calendar.
|
||||
* Otherwise, it throws an HTTP error with a 400 status indicating an invalid credential ID.
|
||||
*
|
||||
* If the parsed body does not contain the necessary properties, the function
|
||||
* returns the `credentialId` from the destination calendar object.
|
||||
*
|
||||
* @param parsedBody - The parsed body from the incoming request, validated against a predefined schema.
|
||||
* Checked if it contain properties like `integration` and `externalId`.
|
||||
* @param userCredentials - An array of user credentials used to fetch the connected calendar credentials.
|
||||
* @param destinationCalendarObject - An object representing the destination calendar. Primarily used
|
||||
* to fetch the default `credentialId`.
|
||||
*
|
||||
* @returns - The verified `credentialId` either from the matched connected calendar in case of updating the destination calendar,
|
||||
* or the provided destination calendar object in other cases.
|
||||
*
|
||||
* @throws HttpError - If no matching connected calendar is found for the given `integration` and `externalId`.
|
||||
*/
|
||||
async function verifyCredentialsAndGetId({
|
||||
parsedBody,
|
||||
userCredentials,
|
||||
currentCredentialId,
|
||||
}: {
|
||||
parsedBody: z.infer<typeof schemaDestinationCalendarEditBodyParams>;
|
||||
userCredentials: UserCredentialType[];
|
||||
currentCredentialId: number | null;
|
||||
}) {
|
||||
if (parsedBody.integration && parsedBody.externalId) {
|
||||
const calendarCredentials = getCalendarCredentials(userCredentials);
|
||||
|
||||
const { connectedCalendars } = await getConnectedCalendars(
|
||||
calendarCredentials,
|
||||
[],
|
||||
parsedBody.externalId
|
||||
);
|
||||
const eligibleCalendars = connectedCalendars[0]?.calendars?.filter((calendar) => !calendar.readOnly);
|
||||
const calendar = eligibleCalendars?.find(
|
||||
(c) => c.externalId === parsedBody.externalId && c.integration === parsedBody.integration
|
||||
);
|
||||
|
||||
if (!calendar?.credentialId)
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Bad request, credential id invalid",
|
||||
});
|
||||
return calendar?.credentialId;
|
||||
}
|
||||
return currentCredentialId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the request for updating a destination calendar.
|
||||
*
|
||||
* This function checks the validity of the provided eventTypeId against the existing destination calendar object
|
||||
* in the sense that if the destination calendar is not linked to an event type, the eventTypeId can not be provided.
|
||||
*
|
||||
* It also ensures that the eventTypeId, if provided, belongs to the assigned user.
|
||||
*
|
||||
* @param destinationCalendarObject - An object representing the destination calendar.
|
||||
* @param parsedBody - The parsed body from the incoming request, validated against a predefined schema.
|
||||
* @param assignedUserId - The user ID assigned for the operation, which might be an admin or a regular user.
|
||||
* @param prisma - An instance of PrismaClient for database operations.
|
||||
*
|
||||
* @throws HttpError - If the validation fails or inconsistencies are detected in the request data.
|
||||
*/
|
||||
async function validateRequestAndOwnership({
|
||||
destinationCalendarObject,
|
||||
parsedBody,
|
||||
assignedUserId,
|
||||
prisma,
|
||||
}: {
|
||||
destinationCalendarObject: DestinationCalendarType;
|
||||
parsedBody: z.infer<typeof schemaDestinationCalendarEditBodyParams>;
|
||||
assignedUserId: number;
|
||||
prisma: PrismaClient;
|
||||
}) {
|
||||
if (parsedBody.eventTypeId) {
|
||||
if (!destinationCalendarObject.eventTypeId) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: `The provided destination calendar can not be linked to an event type`,
|
||||
});
|
||||
}
|
||||
|
||||
const userEventType = await prisma.eventType.findFirst({
|
||||
where: { id: parsedBody.eventTypeId },
|
||||
select: { userId: true },
|
||||
});
|
||||
|
||||
if (!userEventType || userEventType.userId !== assignedUserId) {
|
||||
throw new HttpError({
|
||||
statusCode: 404,
|
||||
message: `Event type with ID ${parsedBody.eventTypeId} not found`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (!parsedBody.eventTypeId) {
|
||||
if (destinationCalendarObject.eventTypeId) {
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: `The provided destination calendar can only be linked to an event type`,
|
||||
});
|
||||
}
|
||||
if (destinationCalendarObject.userId !== assignedUserId) {
|
||||
throw new HttpError({
|
||||
statusCode: 403,
|
||||
message: `Forbidden`,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the destination calendar based on the provided ID as the path parameter, specifically `credentialId` and `eventTypeId`.
|
||||
*
|
||||
* If no matching destination calendar is found for the provided ID, an HTTP error with a 404 status
|
||||
* indicating that the desired destination calendar was not found is thrown.
|
||||
*
|
||||
* @param id - The ID of the destination calendar to be retrieved.
|
||||
* @param prisma - An instance of PrismaClient for database operations.
|
||||
*
|
||||
* @returns - An object containing details of the matching destination calendar, specifically `credentialId` and `eventTypeId`.
|
||||
*
|
||||
* @throws HttpError - If no destination calendar matches the provided ID.
|
||||
*/
|
||||
async function getDestinationCalendar(id: number, prisma: PrismaClient) {
|
||||
const destinationCalendarObject = await prisma.destinationCalendar.findFirst({
|
||||
where: {
|
||||
id,
|
||||
},
|
||||
select: { userId: true, eventTypeId: true, credentialId: true },
|
||||
});
|
||||
|
||||
if (!destinationCalendarObject) {
|
||||
throw new HttpError({
|
||||
statusCode: 404,
|
||||
message: `Destination calendar with ID ${id} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
return destinationCalendarObject;
|
||||
}
|
||||
|
||||
function validateIntegrationInput(parsedBody: z.infer<typeof schemaDestinationCalendarEditBodyParams>) {
|
||||
if (parsedBody.integration && !parsedBody.externalId) {
|
||||
throw new HttpError({ statusCode: 400, message: "External Id is required with integration value" });
|
||||
}
|
||||
if (!parsedBody.integration && parsedBody.externalId) {
|
||||
throw new HttpError({ statusCode: 400, message: "Integration value is required with external ID" });
|
||||
}
|
||||
}
|
||||
|
||||
export default defaultResponder(patchHandler);
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import type { NextApiRequest } from "next";
|
||||
|
||||
import { getCalendarCredentials, getConnectedCalendars } from "@calcom/core/CalendarManager";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import { credentialForCalendarServiceSelect } from "@calcom/prisma/selects/credential";
|
||||
|
||||
import {
|
||||
schemaDestinationCalendarReadPublic,
|
||||
@@ -30,6 +32,7 @@ import {
|
||||
* required:
|
||||
* - integration
|
||||
* - externalId
|
||||
* - credentialId
|
||||
* properties:
|
||||
* integration:
|
||||
* type: string
|
||||
@@ -43,6 +46,9 @@ import {
|
||||
* bookingId:
|
||||
* type: integer
|
||||
* description: 'The booking ID it is associated with'
|
||||
* userId:
|
||||
* type: integer
|
||||
* description: 'The user it is associated with'
|
||||
* tags:
|
||||
* - destination-calendars
|
||||
* responses:
|
||||
@@ -55,20 +61,56 @@ import {
|
||||
*/
|
||||
async function postHandler(req: NextApiRequest) {
|
||||
const { userId, isAdmin, prisma, body } = req;
|
||||
|
||||
const parsedBody = schemaDestinationCalendarCreateBodyParams.parse(body);
|
||||
|
||||
await checkPermissions(req, userId);
|
||||
|
||||
if (!parsedBody.eventTypeId) {
|
||||
parsedBody.userId = userId;
|
||||
const assignedUserId = isAdmin && parsedBody.userId ? parsedBody.userId : userId;
|
||||
|
||||
/* Check if credentialId data matches the ownership and integration passed in */
|
||||
const userCredentials = await prisma.credential.findMany({
|
||||
where: {
|
||||
type: parsedBody.integration,
|
||||
userId: assignedUserId,
|
||||
},
|
||||
select: credentialForCalendarServiceSelect,
|
||||
});
|
||||
|
||||
if (userCredentials.length === 0)
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Bad request, credential id invalid",
|
||||
});
|
||||
|
||||
const calendarCredentials = getCalendarCredentials(userCredentials);
|
||||
|
||||
const { connectedCalendars } = await getConnectedCalendars(calendarCredentials, [], parsedBody.externalId);
|
||||
|
||||
const eligibleCalendars = connectedCalendars[0]?.calendars?.filter((calendar) => !calendar.readOnly);
|
||||
const calendar = eligibleCalendars?.find(
|
||||
(c) => c.externalId === parsedBody.externalId && c.integration === parsedBody.integration
|
||||
);
|
||||
if (!calendar?.credentialId)
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Bad request, credential id invalid",
|
||||
});
|
||||
const credentialId = calendar.credentialId;
|
||||
|
||||
if (parsedBody.eventTypeId) {
|
||||
const eventType = await prisma.eventType.findFirst({
|
||||
where: { id: parsedBody.eventTypeId, userId: parsedBody.userId },
|
||||
});
|
||||
if (!eventType)
|
||||
throw new HttpError({
|
||||
statusCode: 400,
|
||||
message: "Bad request, eventTypeId invalid",
|
||||
});
|
||||
parsedBody.userId = undefined;
|
||||
}
|
||||
|
||||
if (isAdmin) {
|
||||
parsedBody.userId = parsedBody.userId || userId;
|
||||
}
|
||||
|
||||
const destination_calendar = await prisma.destinationCalendar.create({ data: { ...parsedBody } });
|
||||
const destination_calendar = await prisma.destinationCalendar.create({
|
||||
data: { ...parsedBody, credentialId },
|
||||
});
|
||||
|
||||
return {
|
||||
destinationCalendar: schemaDestinationCalendarReadPublic.parse(destination_calendar),
|
||||
|
||||
@@ -3,18 +3,17 @@ import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { defaultResponder } from "@calcom/lib/server";
|
||||
import { createContext } from "@calcom/trpc/server/createContext";
|
||||
import { slotsRouter } from "@calcom/trpc/server/routers/viewer/slots/_router";
|
||||
import { getScheduleSchema } from "@calcom/trpc/server/routers/viewer/slots/types";
|
||||
import { getAvailableSlots } from "@calcom/trpc/server/routers/viewer/slots/util";
|
||||
|
||||
import { TRPCError } from "@trpc/server";
|
||||
import { getHTTPStatusCodeFromError } from "@trpc/server/http";
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
/** @see https://trpc.io/docs/server-side-calls */
|
||||
const ctx = await createContext({ req, res });
|
||||
const caller = slotsRouter.createCaller(ctx);
|
||||
try {
|
||||
const input = getScheduleSchema.parse(req.query);
|
||||
return await getAvailableSlots({ ctx: await createContext({ req, res }), input });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return await caller.getSchedule(req.query as any /* Let tRPC handle this */);
|
||||
} catch (cause) {
|
||||
if (cause instanceof TRPCError) {
|
||||
const statusCode = getHTTPStatusCodeFromError(cause);
|
||||
|
||||
@@ -53,6 +53,9 @@ import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validation
|
||||
* timeZone:
|
||||
* description: The user's time zone
|
||||
* type: string
|
||||
* hideBranding:
|
||||
* description: Remove branding from the user's calendar page
|
||||
* type: boolean
|
||||
* theme:
|
||||
* description: Default theme for the user. Acceptable values are one of [DARK, LIGHT]
|
||||
* type: string
|
||||
@@ -79,7 +82,7 @@ import { schemaUserEditBodyParams, schemaUserReadPublic } from "~/lib/validation
|
||||
* - users
|
||||
* responses:
|
||||
* 200:
|
||||
* description: OK, user edited successfuly
|
||||
* description: OK, user edited successfully
|
||||
* 400:
|
||||
* description: Bad request. User body is invalid.
|
||||
* 401:
|
||||
@@ -94,9 +97,10 @@ export async function patchHandler(req: NextApiRequest) {
|
||||
if (!isAdmin && query.userId !== req.userId) throw new HttpError({ statusCode: 403, message: "Forbidden" });
|
||||
|
||||
const body = await schemaUserEditBodyParams.parseAsync(req.body);
|
||||
// disable role changes unless admin.
|
||||
if (!isAdmin && body.role) {
|
||||
body.role = undefined;
|
||||
// disable role or branding changes unless admin.
|
||||
if (!isAdmin) {
|
||||
if (body.role) body.role = undefined;
|
||||
if (body.hideBranding) body.hideBranding = undefined;
|
||||
}
|
||||
|
||||
const userSchedules = await prisma.schedule.findMany({
|
||||
|
||||
@@ -42,6 +42,9 @@ import { schemaUserCreateBodyParams } from "~/lib/validations/user";
|
||||
* darkBrandColor:
|
||||
* description: The new user's brand color for dark mode
|
||||
* type: string
|
||||
* hideBranding:
|
||||
* description: Remove branding from the user's calendar page
|
||||
* type: boolean
|
||||
* weekStart:
|
||||
* description: Start of the week. Acceptable values are one of [SUNDAY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY]
|
||||
* type: string
|
||||
|
||||
@@ -51,6 +51,9 @@ import { schemaWebhookEditBodyParams, schemaWebhookReadPublic } from "~/lib/vali
|
||||
* eventTypeId:
|
||||
* type: number
|
||||
* description: The event type ID if this webhook should be associated with only that event type
|
||||
* secret:
|
||||
* type: string
|
||||
* description: The secret to verify the authenticity of the received payload
|
||||
* tags:
|
||||
* - webhooks
|
||||
* externalDocs:
|
||||
|
||||
@@ -49,6 +49,9 @@ import { schemaWebhookCreateBodyParams, schemaWebhookReadPublic } from "~/lib/va
|
||||
* eventTypeId:
|
||||
* type: number
|
||||
* description: The event type ID if this webhook should be associated with only that event type
|
||||
* secret:
|
||||
* type: string
|
||||
* description: The secret to verify the authenticity of the received payload
|
||||
* tags:
|
||||
* - webhooks
|
||||
* externalDocs:
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { describe, vi, it, expect, afterEach } from "vitest";
|
||||
|
||||
import { middlewareOrder } from "../../../lib/helpers/withMiddleware";
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetAllMocks();
|
||||
});
|
||||
|
||||
// Not sure if there is much point testing this order is actually applied via an integration test:
|
||||
// It is tested internally https://github.com/htunnicliff/next-api-middleware/blob/368b12aa30e79f4bd7cfe7aacc18da263cc3de2f/lib/label.spec.ts#L62
|
||||
describe("API - withMiddleware test", () => {
|
||||
it("Custom prisma should be before verifyApiKey", async () => {
|
||||
const customPrismaClientIndex = middlewareOrder.indexOf("customPrismaClient");
|
||||
const verifyApiKeyIndex = middlewareOrder.indexOf("verifyApiKey");
|
||||
expect(customPrismaClientIndex).toBeLessThan(verifyApiKeyIndex);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
Hello World
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Metadata } from "next";
|
||||
import { headers as nextHeaders, cookies as nextCookies } from "next/headers";
|
||||
import Script from "next/script";
|
||||
import React from "react";
|
||||
|
||||
import { getLocale } from "@calcom/features/auth/lib/getLocale";
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
|
||||
import "../styles/globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
icons: {
|
||||
icon: [
|
||||
{
|
||||
sizes: "32x32",
|
||||
url: "/api/logo?type=favicon-32",
|
||||
},
|
||||
{
|
||||
sizes: "16x16",
|
||||
url: "/api/logo?type=favicon-16",
|
||||
},
|
||||
],
|
||||
apple: {
|
||||
sizes: "180x180",
|
||||
url: "/api/logo?type=apple-touch-icon",
|
||||
},
|
||||
other: [
|
||||
{
|
||||
url: "/safari-pinned-tab.svg",
|
||||
rel: "mask-icon",
|
||||
},
|
||||
],
|
||||
},
|
||||
manifest: "/site.webmanifest",
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#f9fafb" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#1C1C1C" },
|
||||
],
|
||||
other: {
|
||||
"msapplication-TileColor": "#000000",
|
||||
},
|
||||
};
|
||||
|
||||
const getInitialProps = async (
|
||||
url: string,
|
||||
headers: ReturnType<typeof nextHeaders>,
|
||||
cookies: ReturnType<typeof nextCookies>
|
||||
) => {
|
||||
const { pathname, searchParams } = new URL(url);
|
||||
|
||||
const isEmbed = pathname.endsWith("/embed") || (searchParams?.get("embedType") ?? null) !== null;
|
||||
const embedColorScheme = searchParams?.get("ui.color-scheme");
|
||||
|
||||
// @ts-expect-error we cannot access ctx.req in app dir, however headers and cookies are only properties needed to extract the locale
|
||||
const newLocale = await getLocale({ headers, cookies });
|
||||
let direction = "ltr";
|
||||
|
||||
try {
|
||||
const intlLocale = new Intl.Locale(newLocale);
|
||||
// @ts-expect-error INFO: Typescript does not know about the Intl.Locale textInfo attribute
|
||||
direction = intlLocale.textInfo?.direction;
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
|
||||
return { isEmbed, embedColorScheme, locale: newLocale, direction };
|
||||
};
|
||||
|
||||
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
const headers = nextHeaders();
|
||||
const cookies = nextCookies();
|
||||
|
||||
const fullUrl = headers.get("x-url") ?? "";
|
||||
const nonce = headers.get("x-csp") ?? "";
|
||||
|
||||
const { locale, direction, isEmbed, embedColorScheme } = await getInitialProps(fullUrl, headers, cookies);
|
||||
return (
|
||||
<html
|
||||
lang={locale}
|
||||
dir={direction}
|
||||
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
|
||||
<head nonce={nonce}>
|
||||
{!IS_PRODUCTION && process.env.VERCEL_ENV === "preview" && (
|
||||
// eslint-disable-next-line @next/next/no-sync-scripts
|
||||
<Script
|
||||
data-project-id="KjpMrKTnXquJVKfeqmjdTffVPf1a6Unw2LZ58iE4"
|
||||
src="https://snippet.meticulous.ai/v1/stagingMeticulousSnippet.js"
|
||||
/>
|
||||
)}
|
||||
</head>
|
||||
<body
|
||||
className="dark:bg-darkgray-50 desktop-transparent bg-subtle antialiased"
|
||||
style={
|
||||
isEmbed
|
||||
? {
|
||||
background: "transparent",
|
||||
// Keep the embed hidden till parent initializes and
|
||||
// - gives it the appropriate styles if UI instruction is there.
|
||||
// - gives iframe the appropriate height(equal to document height) which can only be known after loading the page once in browser.
|
||||
// - Tells iframe which mode it should be in (dark/light) - if there is a a UI instruction for that
|
||||
visibility: "hidden",
|
||||
}
|
||||
: {}
|
||||
}>
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -60,14 +60,18 @@ export default function AppListCard(props: AppListCardProps) {
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldHighlight && highlight) {
|
||||
const timer = setTimeout(() => {
|
||||
setHighlight(false);
|
||||
if (shouldHighlight && highlight && searchParams !== null && pathname !== null) {
|
||||
timeoutRef.current = setTimeout(() => {
|
||||
const _searchParams = new URLSearchParams(searchParams);
|
||||
_searchParams.delete("hl");
|
||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||
_searchParams.delete("category"); // this comes from params, not from search params
|
||||
|
||||
setHighlight(false);
|
||||
|
||||
const stringifiedSearchParams = _searchParams.toString();
|
||||
|
||||
router.replace(`${pathname}${stringifiedSearchParams !== "" ? `?${stringifiedSearchParams}` : ""}`);
|
||||
}, 3000);
|
||||
timeoutRef.current = timer;
|
||||
}
|
||||
return () => {
|
||||
if (timeoutRef.current) {
|
||||
@@ -75,8 +79,7 @@ export default function AppListCard(props: AppListCardProps) {
|
||||
timeoutRef.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [highlight, pathname, router, searchParams, shouldHighlight]);
|
||||
|
||||
return (
|
||||
<div className={classNames(highlight && "dark:bg-muted bg-yellow-100")}>
|
||||
|
||||
@@ -58,7 +58,7 @@ function PageWrapper(props: AppProps) {
|
||||
<Head>
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0"
|
||||
content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0, viewport-fit=cover"
|
||||
/>
|
||||
</Head>
|
||||
<DefaultSeo
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
"use client";
|
||||
|
||||
import type { SSRConfig } from "next-i18next";
|
||||
import { Inter } from "next/font/google";
|
||||
import localFont from "next/font/local";
|
||||
// import I18nLanguageHandler from "@components/I18nLanguageHandler";
|
||||
import { usePathname } from "next/navigation";
|
||||
import Script from "next/script";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
import "@calcom/embed-core/src/embed-iframe";
|
||||
import LicenseRequired from "@calcom/features/ee/common/components/LicenseRequired";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
import type { AppProps } from "@lib/app-providers-app-dir";
|
||||
import AppProviders from "@lib/app-providers-app-dir";
|
||||
|
||||
export interface CalPageWrapper {
|
||||
(props?: AppProps): JSX.Element;
|
||||
PageWrapper?: AppProps["Component"]["PageWrapper"];
|
||||
}
|
||||
|
||||
const interFont = Inter({ subsets: ["latin"], variable: "--font-inter", preload: true, display: "swap" });
|
||||
const calFont = localFont({
|
||||
src: "../fonts/CalSans-SemiBold.woff2",
|
||||
variable: "--font-cal",
|
||||
preload: true,
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export type PageWrapperProps = Readonly<{
|
||||
getLayout: (page: React.ReactElement) => ReactNode;
|
||||
children: React.ReactElement;
|
||||
requiresLicense: boolean;
|
||||
isThemeSupported: boolean;
|
||||
isBookingPage: boolean;
|
||||
nonce: string | undefined;
|
||||
themeBasis: string | null;
|
||||
i18n?: SSRConfig;
|
||||
}>;
|
||||
|
||||
function PageWrapper(props: PageWrapperProps) {
|
||||
const pathname = usePathname();
|
||||
let pageStatus = "200";
|
||||
|
||||
if (pathname === "/404") {
|
||||
pageStatus = "404";
|
||||
} else if (pathname === "/500") {
|
||||
pageStatus = "500";
|
||||
}
|
||||
|
||||
// On client side don't let nonce creep into DOM
|
||||
// It also avoids hydration warning that says that Client has the nonce value but server has "" because browser removes nonce attributes before DOM is built
|
||||
// See https://github.com/kentcdodds/nonce-hydration-issues
|
||||
// Set "" only if server had it set otherwise keep it undefined because server has to match with client to avoid hydration error
|
||||
const nonce = typeof window !== "undefined" ? (props.nonce ? "" : undefined) : props.nonce;
|
||||
const providerProps: PageWrapperProps = {
|
||||
...props,
|
||||
nonce,
|
||||
};
|
||||
|
||||
const getLayout: (page: React.ReactElement) => ReactNode = props.getLayout ?? ((page) => page);
|
||||
|
||||
return (
|
||||
<AppProviders {...providerProps}>
|
||||
{/* <I18nLanguageHandler locales={props.router.locales || []} /> */}
|
||||
<>
|
||||
<Script
|
||||
nonce={nonce}
|
||||
id="page-status"
|
||||
dangerouslySetInnerHTML={{ __html: `window.CalComPageStatus = '${pageStatus}'` }}
|
||||
/>
|
||||
<style jsx global>{`
|
||||
:root {
|
||||
--font-inter: ${interFont.style.fontFamily};
|
||||
--font-cal: ${calFont.style.fontFamily};
|
||||
}
|
||||
`}</style>
|
||||
|
||||
{getLayout(
|
||||
props.requiresLicense ? <LicenseRequired>{props.children}</LicenseRequired> : props.children
|
||||
)}
|
||||
</>
|
||||
</AppProviders>
|
||||
);
|
||||
}
|
||||
|
||||
export default trpc.withTRPC(PageWrapper);
|
||||
@@ -141,17 +141,6 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
: []),
|
||||
];
|
||||
|
||||
const showRecordingActions: ActionType[] = [
|
||||
{
|
||||
id: "view_recordings",
|
||||
label: t("view_recordings"),
|
||||
onClick: () => {
|
||||
setViewRecordingsDialogIsOpen(true);
|
||||
},
|
||||
disabled: mutation.isLoading,
|
||||
},
|
||||
];
|
||||
|
||||
let bookedActions: ActionType[] = [
|
||||
{
|
||||
id: "cancel",
|
||||
@@ -226,6 +215,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
};
|
||||
|
||||
const startTime = dayjs(booking.startTime)
|
||||
.tz(user?.timeZone)
|
||||
.locale(language)
|
||||
.format(isUpcoming ? "ddd, D MMM" : "D MMMM YYYY");
|
||||
const [isOpenRescheduleDialog, setIsOpenRescheduleDialog] = useState(false);
|
||||
@@ -269,11 +259,21 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
const bookingLink = buildBookingLink();
|
||||
|
||||
const title = booking.title;
|
||||
// To be used after we run query on legacy bookings
|
||||
// const showRecordingsButtons = booking.isRecorded && isPast && isConfirmed;
|
||||
|
||||
const showRecordingsButtons =
|
||||
(booking.location === "integrations:daily" || booking?.location?.trim() === "") && isPast && isConfirmed;
|
||||
const showRecordingsButtons = !!(booking.isRecorded && isPast && isConfirmed);
|
||||
const checkForRecordingsButton =
|
||||
!showRecordingsButtons && (booking.location === "integrations:daily" || booking?.location?.trim() === "");
|
||||
|
||||
const showRecordingActions: ActionType[] = [
|
||||
{
|
||||
id: checkForRecordingsButton ? "check_for_recordings" : "view_recordings",
|
||||
label: checkForRecordingsButton ? t("check_for_recordings") : t("view_recordings"),
|
||||
onClick: () => {
|
||||
setViewRecordingsDialogIsOpen(true);
|
||||
},
|
||||
disabled: mutation.isLoading,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -298,7 +298,7 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
paymentCurrency={booking.payment[0].currency}
|
||||
/>
|
||||
)}
|
||||
{showRecordingsButtons && (
|
||||
{(showRecordingsButtons || checkForRecordingsButton) && (
|
||||
<ViewRecordingsDialog
|
||||
booking={booking}
|
||||
isOpenDialog={viewRecordingsDialogIsOpen}
|
||||
@@ -468,7 +468,9 @@ function BookingListItem(booking: BookingItemProps) {
|
||||
</>
|
||||
) : null}
|
||||
{isPast && isPending && !isConfirmed ? <TableActions actions={bookedActions} /> : null}
|
||||
{showRecordingsButtons && <TableActions actions={showRecordingActions} />}
|
||||
{(showRecordingsButtons || checkForRecordingsButton) && (
|
||||
<TableActions actions={showRecordingActions} />
|
||||
)}
|
||||
{isCancelled && booking.rescheduled && (
|
||||
<div className="hidden h-full items-center md:flex">
|
||||
<RequestSentMessage />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useCallback, useState } from "react";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -26,9 +26,6 @@ type Props = {
|
||||
};
|
||||
|
||||
export default function CancelBooking(props: Props) {
|
||||
const pathname = usePathname();
|
||||
const searchParams = useSearchParams();
|
||||
const asPath = `${pathname}?${searchParams.toString()}`;
|
||||
const [cancellationReason, setCancellationReason] = useState<string>("");
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
@@ -44,6 +41,7 @@ export default function CancelBooking(props: Props) {
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
{error && (
|
||||
@@ -100,7 +98,8 @@ export default function CancelBooking(props: Props) {
|
||||
});
|
||||
|
||||
if (res.status >= 200 && res.status < 300) {
|
||||
router.replace(asPath);
|
||||
// tested by apps/web/playwright/booking-pages.e2e.ts
|
||||
router.refresh();
|
||||
} else {
|
||||
setLoading(false);
|
||||
setError(
|
||||
|
||||
@@ -433,6 +433,23 @@ export const EventAdvancedTab = ({ eventType, team }: Pick<EventTypeSetupProps,
|
||||
</>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name="lockTimeZoneToggleOnBookingPage"
|
||||
control={formMethods.control}
|
||||
defaultValue={eventType.lockTimeZoneToggleOnBookingPage}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<SettingsToggle
|
||||
labelClassName="text-sm"
|
||||
toggleSwitchAtTheEnd={true}
|
||||
switchContainerClassName="border-subtle rounded-lg border py-6 px-4 sm:px-6"
|
||||
title={t("lock_timezone_toggle_on_booking_page")}
|
||||
{...shouldLockDisableProps("lockTimeZoneToggleOnBookingPage")}
|
||||
description={t("description_lock_timezone_toggle_on_booking_page")}
|
||||
checked={value}
|
||||
onCheckedChange={(e) => onChange(e)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{allowDisablingAttendeeConfirmationEmails(workflows) && (
|
||||
<>
|
||||
<Controller
|
||||
|
||||
@@ -7,7 +7,6 @@ import type { UseFormRegisterReturn } from "react-hook-form";
|
||||
import { Controller, useFormContext, useWatch } from "react-hook-form";
|
||||
import type { SingleValue } from "react-select";
|
||||
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import type { DurationType } from "@calcom/lib/convertToNewDurationType";
|
||||
import convertToNewDurationType from "@calcom/lib/convertToNewDurationType";
|
||||
@@ -141,17 +140,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
defaultValue: periodType?.type,
|
||||
});
|
||||
|
||||
const { shouldLockIndicator, shouldLockDisableProps } = useLockedFieldsManager(
|
||||
eventType,
|
||||
t("locked_fields_admin_description"),
|
||||
t("locked_fields_member_description")
|
||||
);
|
||||
|
||||
const bookingLimitsLocked = shouldLockDisableProps("bookingLimits");
|
||||
const durationLimitsLocked = shouldLockDisableProps("durationLimits");
|
||||
const periodTypeLocked = shouldLockDisableProps("periodType");
|
||||
const offsetStartLockedProps = shouldLockDisableProps("offsetStart");
|
||||
|
||||
const optionsPeriod = [
|
||||
{ value: 1, label: t("calendar_days") },
|
||||
{ value: 0, label: t("business_days") },
|
||||
@@ -174,10 +162,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
<div className="border-subtle space-y-6 rounded-lg border p-6">
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
|
||||
<div className="w-full">
|
||||
<Label htmlFor="beforeBufferTime">
|
||||
{t("before_event")}
|
||||
{shouldLockIndicator("bookingLimits")}
|
||||
</Label>
|
||||
<Label htmlFor="beforeBufferTime">{t("before_event")}</Label>
|
||||
<Controller
|
||||
name="beforeBufferTime"
|
||||
control={formMethods.control}
|
||||
@@ -196,7 +181,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
return (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
isDisabled={shouldLockDisableProps("bookingLimits").disabled}
|
||||
onChange={(val) => {
|
||||
if (val) onChange(val.value);
|
||||
}}
|
||||
@@ -210,10 +194,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Label htmlFor="afterBufferTime">
|
||||
{t("after_event")}
|
||||
{shouldLockIndicator("bookingLimits")}
|
||||
</Label>
|
||||
<Label htmlFor="afterBufferTime">{t("after_event")}</Label>
|
||||
<Controller
|
||||
name="afterBufferTime"
|
||||
control={formMethods.control}
|
||||
@@ -232,7 +213,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
return (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
isDisabled={shouldLockDisableProps("bookingLimits").disabled}
|
||||
onChange={(val) => {
|
||||
if (val) onChange(val.value);
|
||||
}}
|
||||
@@ -248,20 +228,11 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
</div>
|
||||
<div className="flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0">
|
||||
<div className="w-full">
|
||||
<Label htmlFor="minimumBookingNotice">
|
||||
{t("minimum_booking_notice")}
|
||||
{shouldLockIndicator("minimumBookingNotice")}
|
||||
</Label>
|
||||
<MinimumBookingNoticeInput
|
||||
disabled={shouldLockDisableProps("minimumBookingNotice").disabled}
|
||||
{...formMethods.register("minimumBookingNotice")}
|
||||
/>
|
||||
<Label htmlFor="minimumBookingNotice">{t("minimum_booking_notice")}</Label>
|
||||
<MinimumBookingNoticeInput {...formMethods.register("minimumBookingNotice")} />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<Label htmlFor="slotInterval">
|
||||
{t("slot_interval")}
|
||||
{shouldLockIndicator("slotInterval")}
|
||||
</Label>
|
||||
<Label htmlFor="slotInterval">{t("slot_interval")}</Label>
|
||||
<Controller
|
||||
name="slotInterval"
|
||||
control={formMethods.control}
|
||||
@@ -279,7 +250,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
return (
|
||||
<Select
|
||||
isSearchable={false}
|
||||
isDisabled={shouldLockDisableProps("slotInterval").disabled}
|
||||
onChange={(val) => {
|
||||
formMethods.setValue("slotInterval", val && (val.value || 0) > 0 ? val.value : null);
|
||||
}}
|
||||
@@ -305,7 +275,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
toggleSwitchAtTheEnd={true}
|
||||
labelClassName="text-sm"
|
||||
title={t("limit_booking_frequency")}
|
||||
{...bookingLimitsLocked}
|
||||
description={t("limit_booking_frequency_description")}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(active) => {
|
||||
@@ -323,12 +292,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
)}
|
||||
childrenClassName="lg:ml-0">
|
||||
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||
<IntervalLimitsManager
|
||||
disabled={bookingLimitsLocked.disabled}
|
||||
propertyName="bookingLimits"
|
||||
defaultLimit={1}
|
||||
step={1}
|
||||
/>
|
||||
<IntervalLimitsManager propertyName="bookingLimits" defaultLimit={1} step={1} />
|
||||
</div>
|
||||
</SettingsToggle>
|
||||
);
|
||||
@@ -350,7 +314,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("limit_total_booking_duration")}
|
||||
description={t("limit_total_booking_duration_description")}
|
||||
{...durationLimitsLocked}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(active) => {
|
||||
if (active) {
|
||||
@@ -365,7 +328,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
<IntervalLimitsManager
|
||||
propertyName="durationLimits"
|
||||
defaultLimit={60}
|
||||
disabled={durationLimitsLocked.disabled}
|
||||
step={15}
|
||||
textFieldSuffix={t("minutes")}
|
||||
/>
|
||||
@@ -391,7 +353,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("limit_future_bookings")}
|
||||
description={t("limit_future_bookings_description")}
|
||||
{...periodTypeLocked}
|
||||
checked={isChecked}
|
||||
onCheckedChange={(bool) => formMethods.setValue("periodType", bool ? "ROLLING" : "UNLIMITED")}>
|
||||
<div className="border-subtle rounded-b-lg border border-t-0 p-6">
|
||||
@@ -399,9 +360,7 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
defaultValue={watchPeriodType}
|
||||
value={watchPeriodType}
|
||||
onValueChange={(val) => formMethods.setValue("periodType", val as PeriodType)}>
|
||||
{PERIOD_TYPES.filter((opt) =>
|
||||
periodTypeLocked.disabled ? watchPeriodType === opt.type : true
|
||||
).map((period) => {
|
||||
{PERIOD_TYPES.map((period) => {
|
||||
if (period.type === "UNLIMITED") return null;
|
||||
return (
|
||||
<div
|
||||
@@ -410,14 +369,13 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
watchPeriodType === "UNLIMITED" && "pointer-events-none opacity-30"
|
||||
)}
|
||||
key={period.type}>
|
||||
{!periodTypeLocked.disabled && (
|
||||
<RadioGroup.Item
|
||||
id={period.type}
|
||||
value={period.type}
|
||||
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
|
||||
</RadioGroup.Item>
|
||||
)}
|
||||
<RadioGroup.Item
|
||||
id={period.type}
|
||||
value={period.type}
|
||||
className="min-w-4 bg-default border-default flex h-4 w-4 cursor-pointer items-center rounded-full border focus:border-2 focus:outline-none ltr:mr-2 rtl:ml-2">
|
||||
<RadioGroup.Indicator className="after:bg-inverted relative flex h-4 w-4 items-center justify-center after:block after:h-2 after:w-2 after:rounded-full" />
|
||||
</RadioGroup.Item>
|
||||
|
||||
{period.prefix ? <span>{period.prefix} </span> : null}
|
||||
{period.type === "ROLLING" && (
|
||||
<div className="flex items-center">
|
||||
@@ -426,14 +384,12 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
type="number"
|
||||
className="border-default my-0 block w-16 text-sm [appearance:textfield] ltr:mr-2 rtl:ml-2"
|
||||
placeholder="30"
|
||||
disabled={periodTypeLocked.disabled}
|
||||
{...formMethods.register("periodDays", { valueAsNumber: true })}
|
||||
defaultValue={eventType.periodDays || 30}
|
||||
/>
|
||||
<Select
|
||||
options={optionsPeriod}
|
||||
isSearchable={false}
|
||||
isDisabled={periodTypeLocked.disabled}
|
||||
onChange={(opt) => {
|
||||
formMethods.setValue(
|
||||
"periodCountCalendarDays",
|
||||
@@ -458,7 +414,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
<DateRangePicker
|
||||
startDate={formMethods.getValues("periodDates").startDate}
|
||||
endDate={formMethods.getValues("periodDates").endDate}
|
||||
disabled={periodTypeLocked.disabled}
|
||||
onDatesChange={({ startDate, endDate }) => {
|
||||
formMethods.setValue("periodDates", {
|
||||
startDate,
|
||||
@@ -490,7 +445,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
childrenClassName="lg:ml-0"
|
||||
title={t("offset_toggle")}
|
||||
description={t("offset_toggle_description")}
|
||||
{...offsetStartLockedProps}
|
||||
checked={offsetToggle}
|
||||
onCheckedChange={(active) => {
|
||||
setOffsetToggle(active);
|
||||
@@ -503,7 +457,6 @@ export const EventLimitsTab = ({ eventType }: Pick<EventTypeSetupProps, "eventTy
|
||||
required
|
||||
type="number"
|
||||
containerClassName="max-w-80"
|
||||
{...offsetStartLockedProps}
|
||||
label={t("offset_start")}
|
||||
{...formMethods.register("offsetStart")}
|
||||
addOnSuffix={<>{t("minutes")}</>}
|
||||
|
||||
@@ -1,27 +1,22 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import { ErrorMessage } from "@hookform/error-message";
|
||||
import { Trans } from "next-i18next";
|
||||
import Link from "next/link";
|
||||
import type { EventTypeSetupProps, FormValues } from "pages/event-types/[type]";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Controller, useForm, useFormContext } from "react-hook-form";
|
||||
import { Controller, useFormContext, useFieldArray } from "react-hook-form";
|
||||
import type { MultiValue } from "react-select";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { EventLocationType } from "@calcom/app-store/locations";
|
||||
import { getEventLocationType, MeetLocationType, LocationType } from "@calcom/app-store/locations";
|
||||
import { getEventLocationType, LocationType, MeetLocationType } from "@calcom/app-store/locations";
|
||||
import useLockedFieldsManager from "@calcom/features/ee/managed-event-types/hooks/useLockedFieldsManager";
|
||||
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
|
||||
import { classNames } from "@calcom/lib";
|
||||
import { CAL_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import invertLogoOnDark from "@calcom/lib/invertLogoOnDark";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { slugify } from "@calcom/lib/slugify";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import {
|
||||
Button,
|
||||
Label,
|
||||
Select,
|
||||
SettingsToggle,
|
||||
@@ -30,11 +25,16 @@ import {
|
||||
Editor,
|
||||
SkeletonContainer,
|
||||
SkeletonText,
|
||||
Input,
|
||||
PhoneInput,
|
||||
Button,
|
||||
showToast,
|
||||
} from "@calcom/ui";
|
||||
import { Edit2, Check, X, Plus } from "@calcom/ui/components/icon";
|
||||
import { Plus, X, Check } from "@calcom/ui/components/icon";
|
||||
import { CornerDownRight } from "@calcom/ui/components/icon";
|
||||
|
||||
import { EditLocationDialog } from "@components/dialog/EditLocationDialog";
|
||||
import type { SingleValueLocationOption, LocationOption } from "@components/ui/form/LocationSelect";
|
||||
import CheckboxField from "@components/ui/form/CheckboxField";
|
||||
import type { SingleValueLocationOption } from "@components/ui/form/LocationSelect";
|
||||
import LocationSelect from "@components/ui/form/LocationSelect";
|
||||
|
||||
const getLocationFromType = (
|
||||
@@ -114,9 +114,6 @@ export const EventSetupTab = (
|
||||
const { t } = useLocale();
|
||||
const formMethods = useFormContext<FormValues>();
|
||||
const { eventType, team, destinationCalendar } = props;
|
||||
const [showLocationModal, setShowLocationModal] = useState(false);
|
||||
const [editingLocationType, setEditingLocationType] = useState<string>("");
|
||||
const [selectedLocation, setSelectedLocation] = useState<LocationOption | undefined>(undefined);
|
||||
const [multipleDuration, setMultipleDuration] = useState(eventType.metadata?.multipleDuration);
|
||||
const orgBranding = useOrgBranding();
|
||||
const seatsEnabled = formMethods.watch("seatsPerTimeSlotEnabled");
|
||||
@@ -150,83 +147,6 @@ export const EventSetupTab = (
|
||||
selectedMultipleDuration.find((opt) => opt.value === eventType.length) ?? null
|
||||
);
|
||||
|
||||
const openLocationModal = (type: EventLocationType["type"], address = "") => {
|
||||
const option = getLocationFromType(type, locationOptions);
|
||||
if (option && option.value === LocationType.InPerson) {
|
||||
const inPersonOption = {
|
||||
...option,
|
||||
address,
|
||||
};
|
||||
setSelectedLocation(inPersonOption);
|
||||
} else {
|
||||
setSelectedLocation(option);
|
||||
}
|
||||
setShowLocationModal(true);
|
||||
};
|
||||
|
||||
const removeLocation = (selectedLocation: (typeof eventType.locations)[number]) => {
|
||||
formMethods.setValue(
|
||||
"locations",
|
||||
formMethods.getValues("locations").filter((location) => {
|
||||
if (location.type === LocationType.InPerson) {
|
||||
return location.address !== selectedLocation.address;
|
||||
}
|
||||
return location.type !== selectedLocation.type;
|
||||
}),
|
||||
{ shouldValidate: true }
|
||||
);
|
||||
};
|
||||
|
||||
const saveLocation = (newLocationType: EventLocationType["type"], details = {}) => {
|
||||
const locationType = editingLocationType !== "" ? editingLocationType : newLocationType;
|
||||
const existingIdx = formMethods.getValues("locations").findIndex((loc) => locationType === loc.type);
|
||||
if (existingIdx !== -1) {
|
||||
const copy = formMethods.getValues("locations");
|
||||
if (editingLocationType !== "") {
|
||||
copy[existingIdx] = {
|
||||
...details,
|
||||
type: newLocationType,
|
||||
};
|
||||
}
|
||||
|
||||
formMethods.setValue("locations", [
|
||||
...copy,
|
||||
...(newLocationType === LocationType.InPerson && editingLocationType === ""
|
||||
? [{ ...details, type: newLocationType }]
|
||||
: []),
|
||||
]);
|
||||
} else {
|
||||
formMethods.setValue(
|
||||
"locations",
|
||||
formMethods.getValues("locations").concat({ type: newLocationType, ...details })
|
||||
);
|
||||
}
|
||||
|
||||
setEditingLocationType("");
|
||||
setShowLocationModal(false);
|
||||
};
|
||||
|
||||
const locationFormSchema = z.object({
|
||||
locationType: z.string(),
|
||||
locationAddress: z.string().optional(),
|
||||
displayLocationPublicly: z.boolean().optional(),
|
||||
locationPhoneNumber: z
|
||||
.string()
|
||||
.refine((val) => isValidPhoneNumber(val))
|
||||
.optional(),
|
||||
locationLink: z.string().url().optional(), // URL validates as new URL() - which requires HTTPS:// In the input field
|
||||
});
|
||||
|
||||
const locationFormMethods = useForm<{
|
||||
locationType: EventLocationType["type"];
|
||||
locationPhoneNumber?: string;
|
||||
locationAddress?: string; // TODO: We should validate address or fetch the address from googles api to see if its valid?
|
||||
locationLink?: string; // Currently this only accepts links that are HTTPS://
|
||||
displayLocationPublicly?: boolean;
|
||||
}>({
|
||||
resolver: zodResolver(locationFormSchema),
|
||||
});
|
||||
|
||||
const { isChildrenManagedEventType, isManagedEventType, shouldLockIndicator, shouldLockDisableProps } =
|
||||
useLockedFieldsManager(
|
||||
eventType,
|
||||
@@ -236,6 +156,15 @@ export const EventSetupTab = (
|
||||
|
||||
const Locations = () => {
|
||||
const { t } = useLocale();
|
||||
const {
|
||||
fields: locationFields,
|
||||
append,
|
||||
remove,
|
||||
update: updateLocationField,
|
||||
} = useFieldArray({
|
||||
control: formMethods.control,
|
||||
name: "locations",
|
||||
});
|
||||
|
||||
const [animationRef] = useAutoAnimate<HTMLUListElement>();
|
||||
|
||||
@@ -254,131 +183,266 @@ export const EventSetupTab = (
|
||||
|
||||
const { locationDetails, locationAvailable } = getLocationInfo(props);
|
||||
|
||||
const LocationInput = (props: {
|
||||
eventLocationType: EventLocationType;
|
||||
defaultValue?: string;
|
||||
index: number;
|
||||
}) => {
|
||||
const { eventLocationType, index, ...remainingProps } = props;
|
||||
|
||||
if (eventLocationType?.organizerInputType === "text") {
|
||||
const { defaultValue, ...rest } = remainingProps;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
|
||||
control={formMethods.control}
|
||||
defaultValue={defaultValue}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<>
|
||||
<Input
|
||||
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
|
||||
type="text"
|
||||
required
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
className="my-0"
|
||||
{...rest}
|
||||
/>
|
||||
<ErrorMessage
|
||||
errors={formMethods.formState.errors.locations?.[index]}
|
||||
name={eventLocationType.defaultValueVariable}
|
||||
className="text-error my-1 text-sm"
|
||||
as="div"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
} else if (eventLocationType?.organizerInputType === "phone") {
|
||||
const { defaultValue, ...rest } = remainingProps;
|
||||
|
||||
return (
|
||||
<Controller
|
||||
name={`locations.${index}.${eventLocationType.defaultValueVariable}`}
|
||||
control={formMethods.control}
|
||||
defaultValue={defaultValue}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<>
|
||||
<PhoneInput
|
||||
required
|
||||
name={`locations[${index}].${eventLocationType.defaultValueVariable}`}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
{...rest}
|
||||
/>
|
||||
<ErrorMessage
|
||||
errors={formMethods.formState.errors.locations?.[index]}
|
||||
name={eventLocationType.defaultValueVariable}
|
||||
className="text-error my-1 text-sm"
|
||||
as="div"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const [showEmptyLocationSelect, setShowEmptyLocationSelect] = useState(false);
|
||||
const [selectedNewOption, setSelectedNewOption] = useState<SingleValueLocationOption | null>(null);
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{validLocations.length === 0 && (
|
||||
<div className="flex">
|
||||
<LocationSelect
|
||||
placeholder={t("select")}
|
||||
options={locationOptions}
|
||||
isDisabled={shouldLockDisableProps("locations").disabled}
|
||||
defaultValue={defaultValue}
|
||||
isSearchable={false}
|
||||
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||
menuPlacement="auto"
|
||||
onChange={(e: SingleValueLocationOption) => {
|
||||
if (e?.value) {
|
||||
const newLocationType = e.value;
|
||||
const eventLocationType = getEventLocationType(newLocationType);
|
||||
if (!eventLocationType) {
|
||||
return;
|
||||
}
|
||||
locationFormMethods.setValue("locationType", newLocationType);
|
||||
if (eventLocationType.organizerInputType) {
|
||||
openLocationModal(newLocationType);
|
||||
} else {
|
||||
saveLocation(newLocationType);
|
||||
}
|
||||
<ul ref={animationRef} className="space-y-2">
|
||||
{locationFields.map((field, index) => {
|
||||
const eventLocationType = getEventLocationType(field.type);
|
||||
const defaultLocation = formMethods
|
||||
.getValues("locations")
|
||||
?.find((location: { type: EventLocationType["type"]; address?: string }) => {
|
||||
if (location.type === LocationType.InPerson) {
|
||||
return location.type === eventLocationType?.type && location.address === field?.address;
|
||||
} else {
|
||||
return location.type === eventLocationType?.type;
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{validLocations.length > 0 && (
|
||||
<ul ref={animationRef}>
|
||||
{validLocations.map((location, index) => {
|
||||
const eventLocationType = getEventLocationType(location.type);
|
||||
if (!eventLocationType) {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const eventLabel =
|
||||
location[eventLocationType.defaultValueVariable] || t(eventLocationType.label);
|
||||
return (
|
||||
<li
|
||||
key={`${location.type}${index}`}
|
||||
className="border-default text-default mb-2 h-9 rounded-md border px-2 py-1.5 hover:cursor-pointer">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<img
|
||||
src={eventLocationType.iconUrl}
|
||||
className={classNames(
|
||||
"h-4 w-4",
|
||||
classNames(invertLogoOnDark(eventLocationType.iconUrl))
|
||||
)}
|
||||
alt={`${eventLocationType.label} logo`}
|
||||
/>
|
||||
<span className="ms-1 line-clamp-1 text-sm">{`${eventLabel} ${
|
||||
location.teamName ? `(${location.teamName})` : ""
|
||||
}`}</span>
|
||||
const option = getLocationFromType(field.type, locationOptions);
|
||||
|
||||
return (
|
||||
<li key={field.id}>
|
||||
<div className="flex w-full items-center">
|
||||
<LocationSelect
|
||||
name={`locations[${index}].type`}
|
||||
placeholder={t("select")}
|
||||
options={locationOptions}
|
||||
isDisabled={shouldLockDisableProps("locations").disabled}
|
||||
defaultValue={option}
|
||||
isSearchable={false}
|
||||
className="block min-w-0 flex-1 rounded-sm text-sm"
|
||||
menuPlacement="auto"
|
||||
onChange={(e: SingleValueLocationOption) => {
|
||||
if (e?.value) {
|
||||
const newLocationType = e.value;
|
||||
const eventLocationType = getEventLocationType(newLocationType);
|
||||
if (!eventLocationType) {
|
||||
return;
|
||||
}
|
||||
const canAddLocation =
|
||||
eventLocationType.organizerInputType ||
|
||||
!validLocations.find((location) => location.type === newLocationType);
|
||||
|
||||
if (canAddLocation) {
|
||||
updateLocationField(index, { type: newLocationType });
|
||||
} else {
|
||||
updateLocationField(index, { type: field.type });
|
||||
showToast(t("location_already_exists"), "warning");
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
data-testid={`delete-locations.${index}.type`}
|
||||
className="min-h-9 block h-9 px-2"
|
||||
type="button"
|
||||
onClick={() => remove(index)}
|
||||
aria-label={t("remove")}>
|
||||
<div className="h-4 w-4">
|
||||
<X className="border-l-1 hover:text-emphasis text-subtle h-4 w-4" />
|
||||
</div>
|
||||
<div className="flex">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
locationFormMethods.setValue("locationType", location.type);
|
||||
locationFormMethods.unregister("locationLink");
|
||||
if (location.type === LocationType.InPerson) {
|
||||
locationFormMethods.setValue("locationAddress", location.address);
|
||||
} else {
|
||||
locationFormMethods.unregister("locationAddress");
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{eventLocationType?.organizerInputType && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="flex gap-2">
|
||||
<div className="flex items-center justify-center">
|
||||
<CornerDownRight className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="w-full">
|
||||
<LocationInput
|
||||
defaultValue={
|
||||
defaultLocation
|
||||
? defaultLocation[eventLocationType.defaultValueVariable]
|
||||
: undefined
|
||||
}
|
||||
locationFormMethods.unregister("locationPhoneNumber");
|
||||
setEditingLocationType(location.type);
|
||||
openLocationModal(location.type, location.address);
|
||||
eventLocationType={eventLocationType}
|
||||
index={index}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-6">
|
||||
<CheckboxField
|
||||
data-testid="display-location"
|
||||
defaultChecked={defaultLocation?.displayLocationPublicly}
|
||||
description={t("display_location_label")}
|
||||
onChange={(e) => {
|
||||
const fieldValues = formMethods.getValues().locations[index];
|
||||
updateLocationField(index, {
|
||||
...fieldValues,
|
||||
displayLocationPublicly: e.target.checked,
|
||||
});
|
||||
}}
|
||||
aria-label={t("edit")}
|
||||
className="hover:text-emphasis text-subtle mr-1 p-1">
|
||||
<Edit2 className="h-4 w-4" />
|
||||
</button>
|
||||
<button type="button" onClick={() => removeLocation(location)} aria-label={t("remove")}>
|
||||
<X className="border-l-1 hover:text-emphasis text-subtle h-6 w-6 pl-1 " />
|
||||
</button>
|
||||
informationIconText={t("display_location_info_badge")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
{validLocations.some(
|
||||
(location) =>
|
||||
location.type === MeetLocationType && destinationCalendar?.integration !== "google_calendar"
|
||||
) && (
|
||||
<div className="text-default flex text-sm">
|
||||
<Check className="mr-1.5 mt-0.5 h-2 w-2.5" />
|
||||
<Trans i18nKey="event_type_requres_google_cal">
|
||||
<p>
|
||||
The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work.
|
||||
Change it{" "}
|
||||
<Link
|
||||
href={`${CAL_URL}/event-types/${eventType.id}?tabName=advanced`}
|
||||
className="underline">
|
||||
here.
|
||||
</Link>{" "}
|
||||
</p>
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
{isChildrenManagedEventType && !locationAvailable && locationDetails && (
|
||||
<p className="pl-1 text-sm leading-none text-red-600">
|
||||
{t("app_not_connected", { appName: locationDetails.name })}{" "}
|
||||
<a className="underline" href={`${CAL_URL}/apps/${locationDetails.slug}`}>
|
||||
{t("connect_now")}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{validLocations.length > 0 && !isManagedEventType && !isChildrenManagedEventType && (
|
||||
<li>
|
||||
<Button
|
||||
data-testid="add-location"
|
||||
StartIcon={Plus}
|
||||
color="minimal"
|
||||
onClick={() => setShowLocationModal(true)}>
|
||||
{t("add_location")}
|
||||
</Button>
|
||||
)}
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
)}
|
||||
);
|
||||
})}
|
||||
{(validLocations.length === 0 || showEmptyLocationSelect) && (
|
||||
<div className="flex">
|
||||
<LocationSelect
|
||||
defaultMenuIsOpen={showEmptyLocationSelect}
|
||||
autoFocus
|
||||
placeholder={t("select")}
|
||||
options={locationOptions}
|
||||
value={selectedNewOption}
|
||||
isDisabled={shouldLockDisableProps("locations").disabled}
|
||||
defaultValue={defaultValue}
|
||||
isSearchable={false}
|
||||
className="block w-full min-w-0 flex-1 rounded-sm text-sm"
|
||||
menuPlacement="auto"
|
||||
onChange={(e: SingleValueLocationOption) => {
|
||||
if (e?.value) {
|
||||
const newLocationType = e.value;
|
||||
const eventLocationType = getEventLocationType(newLocationType);
|
||||
if (!eventLocationType) {
|
||||
return;
|
||||
}
|
||||
|
||||
const canAppendLocation =
|
||||
eventLocationType.organizerInputType ||
|
||||
!validLocations.find((location) => location.type === newLocationType);
|
||||
|
||||
if (canAppendLocation) {
|
||||
append({ type: newLocationType });
|
||||
setSelectedNewOption(e);
|
||||
} else {
|
||||
showToast(t("location_already_exists"), "warning");
|
||||
setSelectedNewOption(null);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{validLocations.some(
|
||||
(location) =>
|
||||
location.type === MeetLocationType && destinationCalendar?.integration !== "google_calendar"
|
||||
) && (
|
||||
<div className="text-default flex items-center text-sm">
|
||||
<div className="mr-1.5 h-3 w-3">
|
||||
<Check className="h-3 w-3" />
|
||||
</div>
|
||||
<Trans i18nKey="event_type_requres_google_cal">
|
||||
<p>
|
||||
The “Add to calendar” for this event type needs to be a Google Calendar for Meet to work.
|
||||
Change it{" "}
|
||||
<Link
|
||||
href={`${CAL_URL}/event-types/${eventType.id}?tabName=advanced`}
|
||||
className="underline">
|
||||
here.
|
||||
</Link>{" "}
|
||||
</p>
|
||||
</Trans>
|
||||
</div>
|
||||
)}
|
||||
{isChildrenManagedEventType && !locationAvailable && locationDetails && (
|
||||
<p className="pl-1 text-sm leading-none text-red-600">
|
||||
{t("app_not_connected", { appName: locationDetails.name })}{" "}
|
||||
<a className="underline" href={`${CAL_URL}/apps/${locationDetails.slug}`}>
|
||||
{t("connect_now")}
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
{validLocations.length > 0 && !isManagedEventType && !isChildrenManagedEventType && (
|
||||
<li>
|
||||
<Button
|
||||
data-testid="add-location"
|
||||
StartIcon={Plus}
|
||||
color="minimal"
|
||||
onClick={() => setShowEmptyLocationSelect(true)}>
|
||||
{t("add_location")}
|
||||
</Button>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
<p className="text-default mt-2 text-sm">
|
||||
<Trans i18nKey="cant_find_the_right_video_app_visit_our_app_store">
|
||||
Can't find the right video app? Visit our
|
||||
<Link className="cursor-pointer text-blue-500 underline" href="/apps/categories/video">
|
||||
App Store
|
||||
</Link>
|
||||
.
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -542,33 +606,6 @@ export const EventSetupTab = (
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* We portal this modal so we can submit the form inside. Otherwise we get issues submitting two forms at once */}
|
||||
<EditLocationDialog
|
||||
isOpenDialog={showLocationModal}
|
||||
setShowLocationModal={setShowLocationModal}
|
||||
saveLocation={saveLocation}
|
||||
defaultValues={formMethods.getValues("locations")}
|
||||
selection={
|
||||
selectedLocation
|
||||
? selectedLocation.address
|
||||
? {
|
||||
value: selectedLocation.value,
|
||||
label: t(selectedLocation.label),
|
||||
icon: selectedLocation.icon,
|
||||
address: selectedLocation.address,
|
||||
}
|
||||
: {
|
||||
value: selectedLocation.value,
|
||||
label: t(selectedLocation.label),
|
||||
icon: selectedLocation.icon,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
setSelectedLocation={setSelectedLocation}
|
||||
setEditingLocationType={setEditingLocationType}
|
||||
teamId={eventType.team?.id}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -425,7 +425,7 @@ function EventTypeSingleLayout({
|
||||
<div className="p-2 md:mx-0 md:p-0 xl:hidden">
|
||||
<HorizontalTabs tabs={EventTypeTabs} linkShallow />
|
||||
</div>
|
||||
<div className="w-full overflow-hidden ltr:mr-2 rtl:ml-2">
|
||||
<div className="w-full ltr:mr-2 rtl:ml-2">
|
||||
<div
|
||||
className={classNames(
|
||||
"bg-default border-subtle mt-4 rounded-md sm:mx-0 xl:mt-0",
|
||||
|
||||
@@ -3,12 +3,13 @@ import type { FormEvent } from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
|
||||
import OrganizationMemberAvatar from "@calcom/features/ee/organizations/components/OrganizationMemberAvatar";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import turndown from "@calcom/lib/turndownService";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { Ensure } from "@calcom/types/utils";
|
||||
import { Button, Editor, ImageUploader, Label, showToast } from "@calcom/ui";
|
||||
import { ArrowRight } from "@calcom/ui/components/icon";
|
||||
|
||||
@@ -96,16 +97,19 @@ const UserProfile = () => {
|
||||
},
|
||||
];
|
||||
|
||||
const organization =
|
||||
user.organization && user.organization.id
|
||||
? {
|
||||
...(user.organization as Ensure<typeof user.organization, "id">),
|
||||
slug: user.organization.slug || null,
|
||||
requestedSlug: user.organization.metadata?.requestedSlug || null,
|
||||
}
|
||||
: null;
|
||||
return (
|
||||
<form onSubmit={onSubmit}>
|
||||
<div className="flex flex-row items-center justify-start rtl:justify-end">
|
||||
{user && (
|
||||
<OrganizationAvatar
|
||||
alt={user.username || "user avatar"}
|
||||
size="lg"
|
||||
imageSrc={imageSrc}
|
||||
organizationSlug={user.organization?.slug}
|
||||
/>
|
||||
<OrganizationMemberAvatar size="lg" user={user} previewSrc={imageSrc} organization={organization} />
|
||||
)}
|
||||
<input
|
||||
ref={avatarRef}
|
||||
|
||||
@@ -5,11 +5,15 @@ import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import { md } from "@calcom/lib/markdownIt";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import type { TeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import { Avatar } from "@calcom/ui";
|
||||
|
||||
import { UserAvatar } from "@components/ui/avatar/UserAvatar";
|
||||
|
||||
type TeamType = Omit<NonNullable<TeamWithMembers>, "inviteToken">;
|
||||
type MembersType = TeamType["members"];
|
||||
type MemberType = Pick<MembersType[number], "id" | "name" | "bio" | "username"> & { safeBio: string | null };
|
||||
type MemberType = Pick<MembersType[number], "id" | "name" | "bio" | "username" | "organizationId"> & {
|
||||
safeBio: string | null;
|
||||
orgOrigin: string;
|
||||
};
|
||||
|
||||
const Member = ({ member, teamName }: { member: MemberType; teamName: string | null }) => {
|
||||
const routerQuery = useRouterQuery();
|
||||
@@ -20,9 +24,11 @@ const Member = ({ member, teamName }: { member: MemberType; teamName: string | n
|
||||
const { slug: _slug, orgSlug: _orgSlug, user: _user, ...queryParamsToForward } = routerQuery;
|
||||
|
||||
return (
|
||||
<Link key={member.id} href={{ pathname: `/${member.username}`, query: queryParamsToForward }}>
|
||||
<Link
|
||||
key={member.id}
|
||||
href={{ pathname: `${member.orgOrigin}/${member.username}`, query: queryParamsToForward }}>
|
||||
<div className="sm:min-w-80 sm:max-w-80 bg-default hover:bg-muted border-subtle group flex min-h-full flex-col space-y-2 rounded-md border p-4 hover:cursor-pointer">
|
||||
<Avatar size="md" alt={member.name || ""} imageSrc={`/${member.username}/avatar.png`} />
|
||||
<UserAvatar size="md" user={member} />
|
||||
<section className="mt-2 line-clamp-4 w-full space-y-1">
|
||||
<p className="text-default font-medium">{member.name}</p>
|
||||
<div className="text-subtle line-clamp-3 overflow-ellipsis text-sm font-normal">
|
||||
|
||||
@@ -222,9 +222,9 @@ const PremiumTextfield = (props: ICustomUsernameProps) => {
|
||||
onChange={(event) => {
|
||||
event.preventDefault();
|
||||
// Reset payment status
|
||||
const _searchParams = new URLSearchParams(searchParams);
|
||||
const _searchParams = new URLSearchParams(searchParams ?? undefined);
|
||||
_searchParams.delete("paymentStatus");
|
||||
if (searchParams.toString() !== _searchParams.toString()) {
|
||||
if (searchParams?.toString() !== _searchParams.toString()) {
|
||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||
}
|
||||
setInputUsernameValue(event.target.value);
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
import { Avatar } from "@calcom/ui";
|
||||
|
||||
type UserAvatarProps = Omit<React.ComponentProps<typeof Avatar>, "alt" | "imageSrc"> & {
|
||||
user: Pick<User, "organizationId" | "name" | "username">;
|
||||
/**
|
||||
* Useful when allowing the user to upload their own avatar and showing the avatar before it's uploaded
|
||||
*/
|
||||
previewSrc?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* It is aware of the user's organization to correctly show the avatar from the correct URL
|
||||
*/
|
||||
export function UserAvatar(props: UserAvatarProps) {
|
||||
const { user, previewSrc, ...rest } = props;
|
||||
return <Avatar {...rest} alt={user.name || ""} imageSrc={previewSrc ?? getUserAvatarUrl(user)} />;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import type { User } from "@calcom/prisma/client";
|
||||
import { AvatarGroup } from "@calcom/ui";
|
||||
|
||||
type UserAvatarProps = Omit<React.ComponentProps<typeof AvatarGroup>, "items"> & {
|
||||
users: Pick<User, "organizationId" | "name" | "username">[];
|
||||
};
|
||||
export function UserAvatarGroup(props: UserAvatarProps) {
|
||||
const { users, ...rest } = props;
|
||||
return (
|
||||
<AvatarGroup
|
||||
{...rest}
|
||||
items={users.map((user) => ({
|
||||
alt: user.name || "",
|
||||
title: user.name || "",
|
||||
image: getUserAvatarUrl(user),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { getUserAvatarUrl } from "@calcom/lib/getAvatarUrl";
|
||||
import type { Team, User } from "@calcom/prisma/client";
|
||||
import { AvatarGroup } from "@calcom/ui";
|
||||
|
||||
type UserAvatarProps = Omit<React.ComponentProps<typeof AvatarGroup>, "items"> & {
|
||||
users: Pick<User, "organizationId" | "name" | "username">[];
|
||||
organization: Pick<Team, "slug" | "name">;
|
||||
};
|
||||
|
||||
export function UserAvatarGroupWithOrg(props: UserAvatarProps) {
|
||||
const { users, organization, ...rest } = props;
|
||||
const items = [
|
||||
{
|
||||
image: `${WEBAPP_URL}/team/${organization.slug}/avatar.png`,
|
||||
alt: organization.name || undefined,
|
||||
title: organization.name,
|
||||
},
|
||||
].concat(
|
||||
users.map((user) => {
|
||||
return {
|
||||
image: getUserAvatarUrl(user),
|
||||
alt: user.name || undefined,
|
||||
title: user.name || user.username || "",
|
||||
};
|
||||
})
|
||||
);
|
||||
users.unshift();
|
||||
return <AvatarGroup {...rest} items={items} />;
|
||||
}
|
||||
@@ -52,7 +52,7 @@ const CheckboxField = forwardRef<HTMLInputElement, Props>(
|
||||
className="text-primary-600 focus:ring-primary-500 border-default bg-default h-4 w-4 rounded"
|
||||
/>
|
||||
</div>
|
||||
<span className="ms-3 text-sm">{description}</span>
|
||||
<span className="ms-2 text-sm">{description}</span>
|
||||
</>
|
||||
)}
|
||||
{informationIconText && <InfoBadge content={informationIconText} />}
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||
import { dir } from "i18next";
|
||||
import type { Session } from "next-auth";
|
||||
import { SessionProvider, useSession } from "next-auth/react";
|
||||
import { EventCollectionProvider } from "next-collect/client";
|
||||
import { appWithTranslation, type SSRConfig } from "next-i18next";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import type { AppProps as NextAppProps } from "next/app";
|
||||
import type { ReadonlyURLSearchParams } from "next/navigation";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useEffect, type ReactNode } from "react";
|
||||
|
||||
import { OrgBrandingProvider } from "@calcom/features/ee/organizations/context/provider";
|
||||
import DynamicHelpscoutProvider from "@calcom/features/ee/support/lib/helpscout/providerDynamic";
|
||||
import DynamicIntercomProvider from "@calcom/features/ee/support/lib/intercom/providerDynamic";
|
||||
import { FeatureProvider } from "@calcom/features/flags/context/provider";
|
||||
import { useFlags } from "@calcom/features/flags/hooks";
|
||||
import { MetaProvider } from "@calcom/ui";
|
||||
|
||||
import useIsBookingPage from "@lib/hooks/useIsBookingPage";
|
||||
import type { WithNonceProps } from "@lib/withNonce";
|
||||
|
||||
import { useViewerI18n } from "@components/I18nLanguageHandler";
|
||||
import type { PageWrapperProps } from "@components/PageWrapperAppDir";
|
||||
|
||||
// Workaround for https://github.com/vercel/next.js/issues/8592
|
||||
export type AppProps = Omit<
|
||||
NextAppProps<
|
||||
WithNonceProps<{
|
||||
themeBasis?: string;
|
||||
session: Session;
|
||||
}>
|
||||
>,
|
||||
"Component"
|
||||
> & {
|
||||
Component: NextAppProps["Component"] & {
|
||||
requiresLicense?: boolean;
|
||||
isThemeSupported?: boolean;
|
||||
isBookingPage?: boolean | ((arg: { router: NextAppProps["router"] }) => boolean);
|
||||
getLayout?: (page: React.ReactElement) => ReactNode;
|
||||
PageWrapper?: (props: AppProps) => JSX.Element;
|
||||
};
|
||||
|
||||
/** Will be defined only is there was an error */
|
||||
err?: Error;
|
||||
};
|
||||
|
||||
const getEmbedNamespace = (searchParams: ReadonlyURLSearchParams) => {
|
||||
// Mostly embed query param should be available on server. Use that there.
|
||||
// Use the most reliable detection on client
|
||||
return typeof window !== "undefined" ? window.getEmbedNamespace() : searchParams.get("embed") ?? null;
|
||||
};
|
||||
|
||||
// @ts-expect-error appWithTranslation expects AppProps
|
||||
const AppWithTranslationHoc = appWithTranslation(({ children }) => <>{children}</>);
|
||||
|
||||
const CustomI18nextProvider = (props: { children: React.ReactElement; i18n?: SSRConfig }) => {
|
||||
/**
|
||||
* i18n should never be clubbed with other queries, so that it's caching can be managed independently.
|
||||
**/
|
||||
// @TODO
|
||||
|
||||
const session = useSession();
|
||||
const locale =
|
||||
session?.data?.user.locale ?? typeof window !== "undefined" ? window.document.documentElement.lang : "en";
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
// @ts-expect-error TS2790: The operand of a 'delete' operator must be optional.
|
||||
delete window.document.documentElement["lang"];
|
||||
|
||||
window.document.documentElement.lang = locale;
|
||||
|
||||
// Next.js writes the locale to the same attribute
|
||||
// https://github.com/vercel/next.js/blob/1609da2d9552fed48ab45969bdc5631230c6d356/packages/next/src/shared/lib/router/router.ts#L1786
|
||||
// which can result in a race condition
|
||||
// this property descriptor ensures this never happens
|
||||
Object.defineProperty(window.document.documentElement, "lang", {
|
||||
configurable: true,
|
||||
// value: locale,
|
||||
set: function (this) {
|
||||
// empty setter on purpose
|
||||
},
|
||||
get: function () {
|
||||
return locale;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
window.document.documentElement.lang = locale;
|
||||
}
|
||||
window.document.dir = dir(locale);
|
||||
}, [locale]);
|
||||
|
||||
const clientViewerI18n = useViewerI18n(locale);
|
||||
const i18n = clientViewerI18n.data?.i18n ?? props.i18n;
|
||||
|
||||
if (!i18n || !i18n._nextI18Next) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
// @ts-expect-error AppWithTranslationHoc expects AppProps
|
||||
<AppWithTranslationHoc pageProps={{ _nextI18Next: i18n._nextI18Next }}>
|
||||
{props.children}
|
||||
</AppWithTranslationHoc>
|
||||
);
|
||||
};
|
||||
|
||||
const enum ThemeSupport {
|
||||
// e.g. Login Page
|
||||
None = "none",
|
||||
// Entire App except Booking Pages
|
||||
App = "systemOnly",
|
||||
// Booking Pages(including Routing Forms)
|
||||
Booking = "userConfigured",
|
||||
}
|
||||
|
||||
type CalcomThemeProps = Readonly<{
|
||||
isBookingPage: boolean;
|
||||
themeBasis: string | null;
|
||||
nonce: string | undefined;
|
||||
isThemeSupported: boolean;
|
||||
children: React.ReactNode;
|
||||
}>;
|
||||
|
||||
const CalcomThemeProvider = (props: CalcomThemeProps) => {
|
||||
// Use namespace of embed to ensure same namespaced embed are displayed with same theme. This allows different embeds on the same website to be themed differently
|
||||
// One such example is our Embeds Demo and Testing page at http://localhost:3100
|
||||
// Having `getEmbedNamespace` defined on window before react initializes the app, ensures that embedNamespace is available on the first mount and can be used as part of storageKey
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const embedNamespace = searchParams ? getEmbedNamespace(searchParams) : null;
|
||||
const isEmbedMode = typeof embedNamespace === "string";
|
||||
|
||||
return (
|
||||
<ThemeProvider {...getThemeProviderProps({ ...props, isEmbedMode, embedNamespace })}>
|
||||
{/* Embed Mode can be detected reliably only on client side here as there can be static generated pages as well which can't determine if it's embed mode at backend */}
|
||||
{/* color-scheme makes background:transparent not work in iframe which is required by embed. */}
|
||||
{typeof window !== "undefined" && !isEmbedMode && (
|
||||
<style jsx global>
|
||||
{`
|
||||
.dark {
|
||||
color-scheme: dark;
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
)}
|
||||
{props.children}
|
||||
</ThemeProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* The most important job for this fn is to generate correct storageKey for theme persistenc.
|
||||
* `storageKey` is important because that key is listened for changes(using [`storage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event) event) and any pages opened will change it's theme based on that(as part of next-themes implementation).
|
||||
* Choosing the right storageKey avoids theme flickering caused by another page using different theme
|
||||
* So, we handle all the cases here namely,
|
||||
* - Both Booking Pages, /free/30min and /pro/30min but configured with different themes but being operated together.
|
||||
* - Embeds using different namespace. They can be completely themed different on the same page.
|
||||
* - Embeds using the same namespace but showing different cal.com links with different themes
|
||||
* - Embeds using the same namespace and showing same cal.com links with different themes(Different theme is possible for same cal.com link in case of embed because of theme config available in embed)
|
||||
* - App has different theme then Booking Pages.
|
||||
*
|
||||
* All the above cases have one thing in common, which is the origin and thus localStorage is shared and thus `storageKey` is critical to avoid theme flickering.
|
||||
*
|
||||
* Some things to note:
|
||||
* - There is a side effect of so many factors in `storageKey` that many localStorage keys will be created if a user goes through all these scenarios(e.g like booking a lot of different users)
|
||||
* - Some might recommend disabling localStorage persistence but that doesn't give good UX as then we would default to light theme always for a few seconds before switching to dark theme(if that's the user's preference).
|
||||
* - We can't disable [`storage`](https://developer.mozilla.org/en-US/docs/Web/API/Window/storage_event) event handling as well because changing theme in one tab won't change the theme without refresh in other tabs. That's again a bad UX
|
||||
* - Theme flickering becomes infinitely ongoing in case of embeds because of the browser's delay in processing `storage` event within iframes. Consider two embeds simulatenously opened with pages A and B. Note the timeline and keep in mind that it happened
|
||||
* because 'setItem(A)' and 'Receives storageEvent(A)' allowed executing setItem(B) in b/w because of the delay.
|
||||
* - t1 -> setItem(A) & Fires storageEvent(A) - On Page A) - Current State(A)
|
||||
* - t2 -> setItem(B) & Fires storageEvent(B) - On Page B) - Current State(B)
|
||||
* - t3 -> Receives storageEvent(A) & thus setItem(A) & thus fires storageEvent(A) (On Page B) - Current State(A)
|
||||
* - t4 -> Receives storageEvent(B) & thus setItem(B) & thus fires storageEvent(B) (On Page A) - Current State(B)
|
||||
* - ... and so on ...
|
||||
*/
|
||||
function getThemeProviderProps(props: {
|
||||
isBookingPage: boolean;
|
||||
themeBasis: string | null;
|
||||
nonce: string | undefined;
|
||||
isEmbedMode: boolean;
|
||||
embedNamespace: string | null;
|
||||
isThemeSupported: boolean;
|
||||
}) {
|
||||
const themeSupport = props.isBookingPage
|
||||
? ThemeSupport.Booking
|
||||
: // if isThemeSupported is explicitly false, we don't use theme there
|
||||
props.isThemeSupported === false
|
||||
? ThemeSupport.None
|
||||
: ThemeSupport.App;
|
||||
|
||||
const isBookingPageThemeSupportRequired = themeSupport === ThemeSupport.Booking;
|
||||
|
||||
if ((isBookingPageThemeSupportRequired || props.isEmbedMode) && !props.themeBasis) {
|
||||
console.warn(
|
||||
"`themeBasis` is required for booking page theme support. Not providing it will cause theme flicker."
|
||||
);
|
||||
}
|
||||
|
||||
const appearanceIdSuffix = props.themeBasis ? `:${props.themeBasis}` : "";
|
||||
const forcedTheme = themeSupport === ThemeSupport.None ? "light" : undefined;
|
||||
let embedExplicitlySetThemeSuffix = "";
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
const embedTheme = window.getEmbedTheme();
|
||||
if (embedTheme) {
|
||||
embedExplicitlySetThemeSuffix = `:${embedTheme}`;
|
||||
}
|
||||
}
|
||||
|
||||
const storageKey = props.isEmbedMode
|
||||
? // Same Namespace, Same Organizer but different themes would still work seamless and not cause theme flicker
|
||||
// Even though it's recommended to use different namespaces when you want to theme differently on the same page but if the embeds are on different pages, the problem can still arise
|
||||
`embed-theme-${props.embedNamespace}${appearanceIdSuffix}${embedExplicitlySetThemeSuffix}`
|
||||
: themeSupport === ThemeSupport.App
|
||||
? "app-theme"
|
||||
: isBookingPageThemeSupportRequired
|
||||
? `booking-theme${appearanceIdSuffix}`
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
storageKey,
|
||||
forcedTheme,
|
||||
themeSupport,
|
||||
nonce: props.nonce,
|
||||
enableColorScheme: false,
|
||||
enableSystem: themeSupport !== ThemeSupport.None,
|
||||
// next-themes doesn't listen to changes on storageKey. So we need to force a re-render when storageKey changes
|
||||
// This is how login to dashboard soft navigation changes theme from light to dark
|
||||
key: storageKey,
|
||||
attribute: "class",
|
||||
};
|
||||
}
|
||||
|
||||
function FeatureFlagsProvider({ children }: { children: React.ReactNode }) {
|
||||
const flags = useFlags();
|
||||
return <FeatureProvider value={flags}>{children}</FeatureProvider>;
|
||||
}
|
||||
|
||||
function useOrgBrandingValues() {
|
||||
const session = useSession();
|
||||
return session?.data?.user.org;
|
||||
}
|
||||
|
||||
function OrgBrandProvider({ children }: { children: React.ReactNode }) {
|
||||
const orgBrand = useOrgBrandingValues();
|
||||
return <OrgBrandingProvider value={{ orgBrand }}>{children}</OrgBrandingProvider>;
|
||||
}
|
||||
|
||||
const AppProviders = (props: PageWrapperProps) => {
|
||||
// No need to have intercom on public pages - Good for Page Performance
|
||||
const isBookingPage = useIsBookingPage();
|
||||
|
||||
const RemainingProviders = (
|
||||
<EventCollectionProvider options={{ apiPath: "/api/collect-events" }}>
|
||||
<SessionProvider>
|
||||
<CustomI18nextProvider i18n={props.i18n}>
|
||||
<TooltipProvider>
|
||||
{/* color-scheme makes background:transparent not work which is required by embed. We need to ensure next-theme adds color-scheme to `body` instead of `html`(https://github.com/pacocoursey/next-themes/blob/main/src/index.tsx#L74). Once that's done we can enable color-scheme support */}
|
||||
<CalcomThemeProvider
|
||||
themeBasis={props.themeBasis}
|
||||
nonce={props.nonce}
|
||||
isThemeSupported={props.isThemeSupported}
|
||||
isBookingPage={props.isBookingPage || isBookingPage}>
|
||||
<FeatureFlagsProvider>
|
||||
<OrgBrandProvider>
|
||||
<MetaProvider>{props.children}</MetaProvider>
|
||||
</OrgBrandProvider>
|
||||
</FeatureFlagsProvider>
|
||||
</CalcomThemeProvider>
|
||||
</TooltipProvider>
|
||||
</CustomI18nextProvider>
|
||||
</SessionProvider>
|
||||
</EventCollectionProvider>
|
||||
);
|
||||
|
||||
if (isBookingPage) {
|
||||
return RemainingProviders;
|
||||
}
|
||||
|
||||
return (
|
||||
<DynamicHelpscoutProvider>
|
||||
<DynamicIntercomProvider>{RemainingProviders}</DynamicIntercomProvider>
|
||||
</DynamicHelpscoutProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppProviders;
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TooltipProvider } from "@radix-ui/react-tooltip";
|
||||
import { dir } from "i18next";
|
||||
import type { Session } from "next-auth";
|
||||
import { SessionProvider, useSession } from "next-auth/react";
|
||||
import { EventCollectionProvider } from "next-collect/client";
|
||||
@@ -77,19 +78,33 @@ const CustomI18nextProvider = (props: AppPropsWithoutNonce) => {
|
||||
const locale = session?.data?.user.locale ?? props.pageProps.newLocale;
|
||||
|
||||
useEffect(() => {
|
||||
window.document.documentElement.lang = locale;
|
||||
|
||||
let direction = window.document.dir || "ltr";
|
||||
|
||||
try {
|
||||
const intlLocale = new Intl.Locale(locale);
|
||||
// @ts-expect-error INFO: Typescript does not know about the Intl.Locale textInfo attribute
|
||||
direction = intlLocale.textInfo?.direction;
|
||||
// @ts-expect-error TS2790: The operand of a 'delete' operator must be optional.
|
||||
delete window.document.documentElement["lang"];
|
||||
|
||||
window.document.documentElement.lang = locale;
|
||||
|
||||
// Next.js writes the locale to the same attribute
|
||||
// https://github.com/vercel/next.js/blob/1609da2d9552fed48ab45969bdc5631230c6d356/packages/next/src/shared/lib/router/router.ts#L1786
|
||||
// which can result in a race condition
|
||||
// this property descriptor ensures this never happens
|
||||
Object.defineProperty(window.document.documentElement, "lang", {
|
||||
configurable: true,
|
||||
// value: locale,
|
||||
set: function (this) {
|
||||
// empty setter on purpose
|
||||
},
|
||||
get: function () {
|
||||
return locale;
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
window.document.documentElement.lang = locale;
|
||||
}
|
||||
|
||||
window.document.dir = direction;
|
||||
window.document.dir = dir(locale);
|
||||
}, [locale]);
|
||||
|
||||
const clientViewerI18n = useViewerI18n(locale);
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { buildNonce } from "./buildNonce";
|
||||
|
||||
describe("buildNonce", () => {
|
||||
it("should return an empty string for an empty array", () => {
|
||||
const nonce = buildNonce(new Uint8Array());
|
||||
|
||||
expect(nonce).toEqual("");
|
||||
expect(atob(nonce).length).toEqual(0);
|
||||
});
|
||||
|
||||
it("should return a base64 string for values from 0 to 63", () => {
|
||||
const array = Array(22)
|
||||
.fill(0)
|
||||
.map((_, i) => i);
|
||||
const nonce = buildNonce(new Uint8Array(array));
|
||||
|
||||
expect(nonce.length).toEqual(24);
|
||||
expect(nonce).toEqual("ABCDEFGHIJKLMNOPQRSTQQ==");
|
||||
|
||||
expect(atob(nonce).length).toEqual(16);
|
||||
});
|
||||
|
||||
it("should return a base64 string for values from 64 to 127", () => {
|
||||
const array = Array(22)
|
||||
.fill(0)
|
||||
.map((_, i) => i + 64);
|
||||
const nonce = buildNonce(new Uint8Array(array));
|
||||
|
||||
expect(nonce.length).toEqual(24);
|
||||
expect(nonce).toEqual("ABCDEFGHIJKLMNOPQRSTQQ==");
|
||||
|
||||
expect(atob(nonce).length).toEqual(16);
|
||||
});
|
||||
|
||||
it("should return a base64 string for values from 128 to 191", () => {
|
||||
const array = Array(22)
|
||||
.fill(0)
|
||||
.map((_, i) => i + 128);
|
||||
const nonce = buildNonce(new Uint8Array(array));
|
||||
|
||||
expect(nonce.length).toEqual(24);
|
||||
expect(nonce).toEqual("ABCDEFGHIJKLMNOPQRSTQQ==");
|
||||
|
||||
expect(atob(nonce).length).toEqual(16);
|
||||
});
|
||||
|
||||
it("should return a base64 string for values from 192 to 255", () => {
|
||||
const array = Array(22)
|
||||
.fill(0)
|
||||
.map((_, i) => i + 192);
|
||||
const nonce = buildNonce(new Uint8Array(array));
|
||||
|
||||
expect(nonce.length).toEqual(24);
|
||||
expect(nonce).toEqual("ABCDEFGHIJKLMNOPQRSTQQ==");
|
||||
|
||||
expect(atob(nonce).length).toEqual(16);
|
||||
});
|
||||
|
||||
it("should return a base64 string for values from 0 to 42", () => {
|
||||
const array = Array(22)
|
||||
.fill(0)
|
||||
.map((_, i) => 2 * i);
|
||||
const nonce = buildNonce(new Uint8Array(array));
|
||||
|
||||
expect(nonce.length).toEqual(24);
|
||||
expect(nonce).toEqual("ACEGIKMOQSUWYacegikmgg==");
|
||||
|
||||
expect(atob(nonce).length).toEqual(16);
|
||||
});
|
||||
|
||||
it("should return a base64 string for 0 values", () => {
|
||||
const array = Array(22)
|
||||
.fill(0)
|
||||
.map(() => 0);
|
||||
const nonce = buildNonce(new Uint8Array(array));
|
||||
|
||||
expect(nonce.length).toEqual(24);
|
||||
expect(nonce).toEqual("AAAAAAAAAAAAAAAAAAAAAA==");
|
||||
|
||||
expect(atob(nonce).length).toEqual(16);
|
||||
});
|
||||
|
||||
it("should return a base64 string for 0xFF values", () => {
|
||||
const array = Array(22)
|
||||
.fill(0)
|
||||
.map(() => 0xff);
|
||||
const nonce = buildNonce(new Uint8Array(array));
|
||||
|
||||
expect(nonce.length).toEqual(24);
|
||||
expect(nonce).toEqual("////////////////////ww==");
|
||||
|
||||
expect(atob(nonce).length).toEqual(16);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
/*
|
||||
The buildNonce array allows a randomly generated 22-unsigned-byte array
|
||||
and returns a 24-ASCII character string that mimics a base64-string.
|
||||
*/
|
||||
|
||||
export const buildNonce = (uint8array: Uint8Array): string => {
|
||||
// the random uint8array should contain 22 bytes
|
||||
// 22 bytes mimic the base64-encoded 16 bytes
|
||||
// base64 encodes 6 bits (log2(64)) with 8 bits (64 allowed characters)
|
||||
// thus ceil(16*8/6) gives us 22 bytes
|
||||
if (uint8array.length != 22) {
|
||||
return "";
|
||||
}
|
||||
|
||||
// for each random byte, we take:
|
||||
// a) only the last 6 bits (so we map them to the base64 alphabet)
|
||||
// b) for the last byte, we are interested in two bits
|
||||
// explaination:
|
||||
// 16*8 bits = 128 bits of information (order: left->right)
|
||||
// 22*6 bits = 132 bits (order: left->right)
|
||||
// thus the last byte has 4 redundant (least-significant, right-most) bits
|
||||
// it leaves the last byte with 2 bits of information before the redundant bits
|
||||
// so the bitmask is 0x110000 (2 bits of information, 4 redundant bits)
|
||||
const bytes = uint8array.map((value, i) => {
|
||||
if (i < 20) {
|
||||
return value & 0b111111;
|
||||
}
|
||||
|
||||
return value & 0b110000;
|
||||
});
|
||||
|
||||
const nonceCharacters: string[] = [];
|
||||
|
||||
bytes.forEach((value) => {
|
||||
nonceCharacters.push(BASE64_ALPHABET.charAt(value));
|
||||
});
|
||||
|
||||
// base64-encoded strings can be padded with 1 or 2 `=`
|
||||
// since 22 % 4 = 2, we pad with two `=`
|
||||
nonceCharacters.push("==");
|
||||
|
||||
// the end result has 22 information and 2 padding ASCII characters = 24 ASCII characters
|
||||
return nonceCharacters.join("");
|
||||
};
|
||||
+3
-2
@@ -1,10 +1,11 @@
|
||||
import crypto from "crypto";
|
||||
import type { IncomingMessage, OutgoingMessage } from "http";
|
||||
import { z } from "zod";
|
||||
|
||||
import { IS_PRODUCTION } from "@calcom/lib/constants";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
import { buildNonce } from "@lib/buildNonce";
|
||||
|
||||
function getCspPolicy(nonce: string) {
|
||||
//TODO: Do we need to explicitly define it in turbo.json
|
||||
const CSP_POLICY = process.env.CSP_POLICY;
|
||||
@@ -59,7 +60,7 @@ export function csp(req: IncomingMessage | null, res: OutgoingMessage | null) {
|
||||
}
|
||||
const CSP_POLICY = process.env.CSP_POLICY;
|
||||
const cspEnabledForInstance = CSP_POLICY;
|
||||
const nonce = crypto.randomBytes(16).toString("base64");
|
||||
const nonce = buildNonce(crypto.getRandomValues(new Uint8Array(22)));
|
||||
|
||||
const parsedUrl = new URL(req.url, "http://base_url");
|
||||
const cspEnabledForPage = cspEnabledForInstance && isPagePathRequest(parsedUrl);
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import prismaMock from "../../../tests/libs/__mocks__/prismaMock";
|
||||
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "./getTemporaryOrgRedirect";
|
||||
|
||||
function mockARedirectInDB({
|
||||
toUrl,
|
||||
slug,
|
||||
redirectType,
|
||||
}: {
|
||||
toUrl: string;
|
||||
slug: string;
|
||||
redirectType: RedirectType;
|
||||
}) {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
//@ts-ignore
|
||||
prismaMock.tempOrgRedirect.findUnique.mockImplementation(({ where }) => {
|
||||
return new Promise((resolve) => {
|
||||
if (
|
||||
where.from_type_fromOrgId.type === redirectType &&
|
||||
where.from_type_fromOrgId.from === slug &&
|
||||
where.from_type_fromOrgId.fromOrgId === 0
|
||||
) {
|
||||
resolve({ toUrl });
|
||||
} else {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
describe("getTemporaryOrgRedirect", () => {
|
||||
it("should generate event-type URL without existing query params", async () => {
|
||||
mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User });
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slug: "slug",
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: "30min",
|
||||
currentQuery: {},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/30min",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate event-type URL with existing query params", async () => {
|
||||
mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User });
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slug: "slug",
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: "30min",
|
||||
currentQuery: {
|
||||
abc: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/30min?abc=1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate User URL with existing query params", async () => {
|
||||
mockARedirectInDB({ slug: "slug", toUrl: "https://calcom.cal.com", redirectType: RedirectType.User });
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slug: "slug",
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: {
|
||||
abc: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com?abc=1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Team Profile URL with existing query params", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "seeded-team",
|
||||
toUrl: "https://calcom.cal.com",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slug: "seeded-team",
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: {
|
||||
abc: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com?abc=1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Team Event URL with existing query params", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "seeded-team",
|
||||
toUrl: "https://calcom.cal.com",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slug: "seeded-team",
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: "30min",
|
||||
currentQuery: {
|
||||
abc: "1",
|
||||
},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/30min?abc=1",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should generate Team Event URL without query params", async () => {
|
||||
mockARedirectInDB({
|
||||
slug: "seeded-team",
|
||||
toUrl: "https://calcom.cal.com",
|
||||
redirectType: RedirectType.Team,
|
||||
});
|
||||
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slug: "seeded-team",
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: "30min",
|
||||
currentQuery: {},
|
||||
});
|
||||
|
||||
expect(redirect).toEqual({
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: "https://calcom.cal.com/30min",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { ParsedUrlQuery } from "querystring";
|
||||
import { stringify } from "querystring";
|
||||
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { safeStringify } from "@calcom/lib/safeStringify";
|
||||
import type { RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["lib", "getTemporaryOrgRedirect"] });
|
||||
export const getTemporaryOrgRedirect = async ({
|
||||
slug,
|
||||
redirectType,
|
||||
eventTypeSlug,
|
||||
currentQuery,
|
||||
}: {
|
||||
slug: string;
|
||||
redirectType: RedirectType;
|
||||
eventTypeSlug: string | null;
|
||||
currentQuery: ParsedUrlQuery;
|
||||
}) => {
|
||||
const prisma = (await import("@calcom/prisma")).default;
|
||||
log.debug(
|
||||
`Looking for redirect for`,
|
||||
safeStringify({
|
||||
slug,
|
||||
redirectType,
|
||||
eventTypeSlug,
|
||||
})
|
||||
);
|
||||
const redirect = await prisma.tempOrgRedirect.findUnique({
|
||||
where: {
|
||||
from_type_fromOrgId: {
|
||||
type: redirectType,
|
||||
from: slug,
|
||||
fromOrgId: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
log.debug(`Redirecting ${slug} to ${redirect.toUrl}`);
|
||||
const newDestinationWithoutQuery = eventTypeSlug ? `${redirect.toUrl}/${eventTypeSlug}` : redirect.toUrl;
|
||||
const currentQueryString = stringify(currentQuery);
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: `${newDestinationWithoutQuery}${currentQueryString ? `?${currentQueryString}` : ""}`,
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -1,12 +1,12 @@
|
||||
import { usePathname, useSearchParams } from "next/navigation";
|
||||
|
||||
export default function useIsBookingPage() {
|
||||
export default function useIsBookingPage(): boolean {
|
||||
const pathname = usePathname();
|
||||
const isBookingPage = ["/booking/", "/cancel", "/reschedule"].some((route) => pathname?.startsWith(route));
|
||||
|
||||
const searchParams = useSearchParams();
|
||||
const userParam = searchParams.get("user");
|
||||
const teamParam = searchParams.get("team");
|
||||
const userParam = Boolean(searchParams?.get("user"));
|
||||
const teamParam = Boolean(searchParams?.get("team"));
|
||||
|
||||
return !!(isBookingPage || userParam || teamParam);
|
||||
return isBookingPage || userParam || teamParam;
|
||||
}
|
||||
|
||||
@@ -1,17 +1,21 @@
|
||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||
import { useCallback } from "react";
|
||||
|
||||
export default function useRouterQuery<T extends string>(name: T) {
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
|
||||
const setQuery = (newValue: string | number | null | undefined) => {
|
||||
const _searchParams = new URLSearchParams(searchParams);
|
||||
_searchParams.set(name, newValue as string);
|
||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||
};
|
||||
const setQuery = useCallback(
|
||||
(newValue: string | number | null | undefined) => {
|
||||
const _searchParams = new URLSearchParams(searchParams ?? undefined);
|
||||
_searchParams.set(name, newValue as string);
|
||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||
},
|
||||
[name, pathname, router, searchParams]
|
||||
);
|
||||
|
||||
return { [name]: searchParams.get(name), setQuery } as {
|
||||
return { [name]: searchParams?.get(name), setQuery } as {
|
||||
[K in T]: string | undefined;
|
||||
} & { setQuery: typeof setQuery };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import type { Request, Response } from "express";
|
||||
import type { Redirect } from "next";
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { createMocks } from "node-mocks-http";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import withEmbedSsr from "./withEmbedSsr";
|
||||
|
||||
export type CustomNextApiRequest = NextApiRequest & Request;
|
||||
|
||||
export type CustomNextApiResponse = NextApiResponse & Response;
|
||||
export function createMockNextJsRequest(...args: Parameters<typeof createMocks>) {
|
||||
return createMocks<CustomNextApiRequest, CustomNextApiResponse>(...args);
|
||||
}
|
||||
|
||||
function getServerSidePropsFnGenerator(
|
||||
config:
|
||||
| { redirectUrl: string }
|
||||
| { props: Record<string, unknown> }
|
||||
| {
|
||||
notFound: true;
|
||||
}
|
||||
) {
|
||||
if ("redirectUrl" in config)
|
||||
return async () => {
|
||||
return {
|
||||
redirect: {
|
||||
permanent: false,
|
||||
destination: config.redirectUrl,
|
||||
} satisfies Redirect,
|
||||
};
|
||||
};
|
||||
|
||||
if ("props" in config)
|
||||
return async () => {
|
||||
return {
|
||||
props: config.props,
|
||||
};
|
||||
};
|
||||
|
||||
if ("notFound" in config)
|
||||
return async () => {
|
||||
return {
|
||||
notFound: true as const,
|
||||
};
|
||||
};
|
||||
|
||||
throw new Error("Invalid config");
|
||||
}
|
||||
|
||||
function getServerSidePropsContextArg({
|
||||
embedRelatedParams,
|
||||
}: {
|
||||
embedRelatedParams?: Record<string, string>;
|
||||
}) {
|
||||
return {
|
||||
...createMockNextJsRequest(),
|
||||
query: {
|
||||
...embedRelatedParams,
|
||||
},
|
||||
resolvedUrl: "/MOCKED_RESOLVED_URL",
|
||||
};
|
||||
}
|
||||
|
||||
describe("withEmbedSsr", () => {
|
||||
describe("when gSSP returns redirect", () => {
|
||||
describe("when redirect destination is relative, should add /embed to end of the path", () => {
|
||||
it("should add layout and embed params from the current query", async () => {
|
||||
const withEmbedGetSsr = withEmbedSsr(
|
||||
getServerSidePropsFnGenerator({
|
||||
redirectUrl: "/reschedule",
|
||||
})
|
||||
);
|
||||
|
||||
const ret = await withEmbedGetSsr(
|
||||
getServerSidePropsContextArg({
|
||||
embedRelatedParams: {
|
||||
layout: "week_view",
|
||||
embed: "namespace1",
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(ret).toEqual({
|
||||
redirect: {
|
||||
destination: "/reschedule/embed?layout=week_view&embed=namespace1",
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should add layout and embed params without losing query params that were in redirect", async () => {
|
||||
const withEmbedGetSsr = withEmbedSsr(
|
||||
getServerSidePropsFnGenerator({
|
||||
redirectUrl: "/reschedule?redirectParam=1",
|
||||
})
|
||||
);
|
||||
|
||||
const ret = await withEmbedGetSsr(
|
||||
getServerSidePropsContextArg({
|
||||
embedRelatedParams: {
|
||||
layout: "week_view",
|
||||
embed: "namespace1",
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(ret).toEqual({
|
||||
redirect: {
|
||||
destination: "/reschedule/embed?redirectParam=1&layout=week_view&embed=namespace1",
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("should add embed param even when it was empty(i.e. default namespace of embed)", async () => {
|
||||
const withEmbedGetSsr = withEmbedSsr(
|
||||
getServerSidePropsFnGenerator({
|
||||
redirectUrl: "/reschedule?redirectParam=1",
|
||||
})
|
||||
);
|
||||
|
||||
const ret = await withEmbedGetSsr(
|
||||
getServerSidePropsContextArg({
|
||||
embedRelatedParams: {
|
||||
layout: "week_view",
|
||||
embed: "",
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(ret).toEqual({
|
||||
redirect: {
|
||||
destination: "/reschedule/embed?redirectParam=1&layout=week_view&embed=",
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when redirect destination is absolute, should add /embed to end of the path", () => {
|
||||
it("should add layout and embed params from the current query when destination URL is HTTPS", async () => {
|
||||
const withEmbedGetSsr = withEmbedSsr(
|
||||
getServerSidePropsFnGenerator({
|
||||
redirectUrl: "https://calcom.cal.local/owner",
|
||||
})
|
||||
);
|
||||
|
||||
const ret = await withEmbedGetSsr(
|
||||
getServerSidePropsContextArg({
|
||||
embedRelatedParams: {
|
||||
layout: "week_view",
|
||||
embed: "namespace1",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(ret).toEqual({
|
||||
redirect: {
|
||||
destination: "https://calcom.cal.local/owner/embed?layout=week_view&embed=namespace1",
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
it("should add layout and embed params from the current query when destination URL is HTTP", async () => {
|
||||
const withEmbedGetSsr = withEmbedSsr(
|
||||
getServerSidePropsFnGenerator({
|
||||
redirectUrl: "http://calcom.cal.local/owner",
|
||||
})
|
||||
);
|
||||
|
||||
const ret = await withEmbedGetSsr(
|
||||
getServerSidePropsContextArg({
|
||||
embedRelatedParams: {
|
||||
layout: "week_view",
|
||||
embed: "namespace1",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(ret).toEqual({
|
||||
redirect: {
|
||||
destination: "http://calcom.cal.local/owner/embed?layout=week_view&embed=namespace1",
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
it("should correctly identify a URL as non absolute URL if protocol is missing", async () => {
|
||||
const withEmbedGetSsr = withEmbedSsr(
|
||||
getServerSidePropsFnGenerator({
|
||||
redirectUrl: "httpcalcom.cal.local/owner",
|
||||
})
|
||||
);
|
||||
|
||||
const ret = await withEmbedGetSsr(
|
||||
getServerSidePropsContextArg({
|
||||
embedRelatedParams: {
|
||||
layout: "week_view",
|
||||
embed: "namespace1",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(ret).toEqual({
|
||||
redirect: {
|
||||
// FIXME: Note that it is adding a / in the beginning of the path, which might be fine for now, but could be an issue
|
||||
destination: "/httpcalcom.cal.local/owner/embed?layout=week_view&embed=namespace1",
|
||||
permanent: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when gSSP returns props", () => {
|
||||
it("should add isEmbed=true prop", async () => {
|
||||
const withEmbedGetSsr = withEmbedSsr(
|
||||
getServerSidePropsFnGenerator({
|
||||
props: {
|
||||
prop1: "value1",
|
||||
},
|
||||
})
|
||||
);
|
||||
const ret = await withEmbedGetSsr(
|
||||
getServerSidePropsContextArg({
|
||||
embedRelatedParams: {
|
||||
layout: "week_view",
|
||||
embed: "",
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(ret).toEqual({
|
||||
props: {
|
||||
prop1: "value1",
|
||||
isEmbed: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("when gSSP doesn't have props or redirect ", () => {
|
||||
it("should return the result from gSSP as is", async () => {
|
||||
const withEmbedGetSsr = withEmbedSsr(
|
||||
getServerSidePropsFnGenerator({
|
||||
notFound: true,
|
||||
})
|
||||
);
|
||||
|
||||
const ret = await withEmbedGetSsr(
|
||||
getServerSidePropsContextArg({
|
||||
embedRelatedParams: {
|
||||
layout: "week_view",
|
||||
embed: "",
|
||||
},
|
||||
})
|
||||
);
|
||||
|
||||
expect(ret).toEqual({ notFound: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { GetServerSideProps, GetServerSidePropsContext, GetServerSidePropsResult } from "next";
|
||||
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
|
||||
export type EmbedProps = {
|
||||
isEmbed?: boolean;
|
||||
};
|
||||
@@ -11,14 +13,25 @@ export default function withEmbedSsr(getServerSideProps: GetServerSideProps) {
|
||||
const layout = context.query.layout;
|
||||
|
||||
if ("redirect" in ssrResponse) {
|
||||
// Use a dummy URL https://base as the fallback base URL so that URL parsing works for relative URLs as well.
|
||||
const destinationUrlObj = new URL(ssrResponse.redirect.destination, "https://base");
|
||||
const destinationUrl = ssrResponse.redirect.destination;
|
||||
let urlPrefix = "";
|
||||
|
||||
// Get the URL parsed from URL so that we can reliably read pathname and searchParams from it.
|
||||
const destinationUrlObj = new URL(ssrResponse.redirect.destination, WEBAPP_URL);
|
||||
|
||||
// If it's a complete URL, use the origin as the prefix to ensure we redirect to the same domain.
|
||||
if (destinationUrl.search(/^(http:|https:).*/) !== -1) {
|
||||
urlPrefix = destinationUrlObj.origin;
|
||||
} else {
|
||||
// Don't use any prefix for relative URLs to ensure we stay on the same domain
|
||||
urlPrefix = "";
|
||||
}
|
||||
|
||||
const destinationQueryStr = destinationUrlObj.searchParams.toString();
|
||||
// Make sure that redirect happens to /embed page and pass on embed query param as is for preserving Cal JS API namespace
|
||||
const newDestinationUrl = `${
|
||||
destinationUrlObj.pathname
|
||||
}/embed?${destinationUrlObj.searchParams.toString()}&layout=${layout}&embed=${embed}`;
|
||||
|
||||
const newDestinationUrl = `${urlPrefix}${destinationUrlObj.pathname}/embed?${
|
||||
destinationQueryStr ? `${destinationQueryStr}&` : ""
|
||||
}layout=${layout}&embed=${embed}`;
|
||||
return {
|
||||
...ssrResponse,
|
||||
redirect: {
|
||||
|
||||
+23
-1
@@ -102,6 +102,16 @@ const matcherConfigRootPath = {
|
||||
source: "/",
|
||||
};
|
||||
|
||||
const matcherConfigRootPathEmbed = {
|
||||
has: [
|
||||
{
|
||||
type: "host",
|
||||
value: orgHostPath,
|
||||
},
|
||||
],
|
||||
source: "/embed",
|
||||
};
|
||||
|
||||
const matcherConfigUserRoute = {
|
||||
has: [
|
||||
{
|
||||
@@ -226,6 +236,14 @@ const nextConfig = {
|
||||
},
|
||||
async rewrites() {
|
||||
const beforeFiles = [
|
||||
{
|
||||
/**
|
||||
* Needed due to the introduction of dotted usernames
|
||||
* @see https://github.com/calcom/cal.com/pull/11706
|
||||
*/
|
||||
source: "/embed.js",
|
||||
destination: "/embed/embed.js",
|
||||
},
|
||||
{
|
||||
source: "/login",
|
||||
destination: "/auth/login",
|
||||
@@ -235,7 +253,11 @@ const nextConfig = {
|
||||
? [
|
||||
{
|
||||
...matcherConfigRootPath,
|
||||
destination: "/team/:orgSlug",
|
||||
destination: "/team/:orgSlug?isOrgProfile=1",
|
||||
},
|
||||
{
|
||||
...matcherConfigRootPathEmbed,
|
||||
destination: "/team/:orgSlug/embed?isOrgProfile=1",
|
||||
},
|
||||
{
|
||||
...matcherConfigUserRoute,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@calcom/web",
|
||||
"version": "3.4.0",
|
||||
"version": "3.4.6",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"analyze": "ANALYZE=true next build",
|
||||
@@ -166,6 +166,7 @@
|
||||
"env-cmd": "^10.1.0",
|
||||
"module-alias": "^2.2.2",
|
||||
"msw": "^0.42.3",
|
||||
"node-html-parser": "^6.1.10",
|
||||
"postcss": "^8.4.18",
|
||||
"tailwindcss": "^3.3.1",
|
||||
"tailwindcss-animate": "^1.0.6",
|
||||
|
||||
+14
-9
@@ -3,7 +3,10 @@ import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { orgDomainConfig, subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import {
|
||||
getOrgDomainConfigFromHostname,
|
||||
subdomainSuffix,
|
||||
} from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { DOCS_URL, IS_CALCOM, JOIN_DISCORD, WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { HeadSeo } from "@calcom/ui";
|
||||
@@ -50,9 +53,12 @@ export default function Custom404() {
|
||||
|
||||
const [url, setUrl] = useState(`${WEBSITE_URL}/signup`);
|
||||
useEffect(() => {
|
||||
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(window.location.host);
|
||||
const [routerUsername] = pathname?.replace("%20", "-").split(/[?#]/);
|
||||
if (!isValidOrgDomain || !currentOrgDomain) {
|
||||
const { isValidOrgDomain, currentOrgDomain } = getOrgDomainConfigFromHostname({
|
||||
hostname: window.location.host,
|
||||
});
|
||||
|
||||
const [routerUsername] = pathname?.replace("%20", "-").split(/[?#]/) ?? [];
|
||||
if (routerUsername && (!isValidOrgDomain || !currentOrgDomain)) {
|
||||
const splitPath = routerUsername.split("/");
|
||||
if (splitPath[1] === "team" && splitPath.length === 3) {
|
||||
// Accessing a non-existent team
|
||||
@@ -66,13 +72,12 @@ export default function Custom404() {
|
||||
setUrl(`${WEBSITE_URL}/signup?username=${routerUsername.replace("/", "")}`);
|
||||
}
|
||||
} else {
|
||||
setUsername(currentOrgDomain);
|
||||
setUsername(currentOrgDomain ?? "");
|
||||
setCurrentPageType(pageType.ORG);
|
||||
setUrl(
|
||||
`${WEBSITE_URL}/signup?callbackUrl=settings/organizations/new%3Fslug%3D${currentOrgDomain.replace(
|
||||
"/",
|
||||
""
|
||||
)}`
|
||||
`${WEBSITE_URL}/signup?callbackUrl=settings/organizations/new%3Fslug%3D${
|
||||
currentOrgDomain?.replace("/", "") ?? ""
|
||||
}`
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
|
||||
+55
-14
@@ -11,7 +11,7 @@ import {
|
||||
useEmbedStyles,
|
||||
useIsEmbed,
|
||||
} from "@calcom/embed-core/embed-iframe";
|
||||
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
|
||||
import OrganizationMemberAvatar from "@calcom/features/ee/organizations/components/OrganizationMemberAvatar";
|
||||
import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { EventTypeDescriptionLazy as EventTypeDescription } from "@calcom/features/eventtypes/components";
|
||||
@@ -23,9 +23,9 @@ import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
|
||||
import prisma from "@calcom/prisma";
|
||||
import type { EventType, User } from "@calcom/prisma/client";
|
||||
import { RedirectType, type EventType, type User } from "@calcom/prisma/client";
|
||||
import { baseEventTypeSelect } from "@calcom/prisma/selects";
|
||||
import { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { EventTypeMetaDataSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { HeadSeo, UnpublishedEntity } from "@calcom/ui";
|
||||
import { Verified, ArrowRight } from "@calcom/ui/components/icon";
|
||||
|
||||
@@ -35,6 +35,8 @@ import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "../lib/getTemporaryOrgRedirect";
|
||||
|
||||
export function UserPage(props: InferGetServerSidePropsType<typeof getServerSideProps>) {
|
||||
const { users, profile, eventTypes, markdownStrippedBio, entity } = props;
|
||||
|
||||
@@ -97,11 +99,22 @@ export function UserPage(props: InferGetServerSidePropsType<typeof getServerSide
|
||||
"max-w-3xl px-4 py-24"
|
||||
)}>
|
||||
<div className="mb-8 text-center">
|
||||
<OrganizationAvatar
|
||||
imageSrc={profile.image}
|
||||
<OrganizationMemberAvatar
|
||||
size="xl"
|
||||
alt={profile.name}
|
||||
organizationSlug={profile.organizationSlug}
|
||||
user={{
|
||||
organizationId: profile.organization?.id,
|
||||
name: profile.name,
|
||||
username: profile.username,
|
||||
}}
|
||||
organization={
|
||||
profile.organization?.id
|
||||
? {
|
||||
id: profile.organization.id,
|
||||
slug: profile.organization.slug,
|
||||
requestedSlug: null,
|
||||
}
|
||||
: null
|
||||
}
|
||||
/>
|
||||
<h1 className="font-cal text-emphasis mb-1 text-3xl" data-testid="name-title">
|
||||
{profile.name}
|
||||
@@ -224,8 +237,13 @@ export type UserPageProps = {
|
||||
theme: string | null;
|
||||
brandColor: string;
|
||||
darkBrandColor: string;
|
||||
organizationSlug: string | null;
|
||||
organization: {
|
||||
requestedSlug: string | null;
|
||||
slug: string | null;
|
||||
id: number | null;
|
||||
};
|
||||
allowSEOIndexing: boolean;
|
||||
username: string | null;
|
||||
};
|
||||
users: Pick<User, "away" | "name" | "username" | "bio" | "verified">[];
|
||||
themeBasis: string | null;
|
||||
@@ -246,6 +264,7 @@ export type UserPageProps = {
|
||||
| "slug"
|
||||
| "length"
|
||||
| "hidden"
|
||||
| "lockTimeZoneToggleOnBookingPage"
|
||||
| "requiresConfirmation"
|
||||
| "requiresBookerEmailVerification"
|
||||
| "price"
|
||||
@@ -256,18 +275,16 @@ export type UserPageProps = {
|
||||
|
||||
export const getServerSideProps: GetServerSideProps<UserPageProps> = async (context) => {
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
const usernameList = getUsernameList(context.query.user as string);
|
||||
const isOrgContext = isValidOrgDomain && currentOrgDomain;
|
||||
const dataFetchStart = Date.now();
|
||||
const usersWithoutAvatar = await prisma.user.findMany({
|
||||
where: {
|
||||
username: {
|
||||
in: usernameList,
|
||||
},
|
||||
organization: isValidOrgDomain && currentOrgDomain ? getSlugOrRequestedSlug(currentOrgDomain) : null,
|
||||
organization: isOrgContext ? getSlugOrRequestedSlug(currentOrgDomain) : null,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -275,6 +292,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
||||
email: true,
|
||||
name: true,
|
||||
bio: true,
|
||||
metadata: true,
|
||||
brandColor: true,
|
||||
darkBrandColor: true,
|
||||
organizationId: true,
|
||||
@@ -282,6 +300,7 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
||||
select: {
|
||||
slug: true,
|
||||
name: true,
|
||||
metadata: true,
|
||||
},
|
||||
},
|
||||
theme: true,
|
||||
@@ -309,9 +328,26 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
||||
|
||||
const users = usersWithoutAvatar.map((user) => ({
|
||||
...user,
|
||||
organization: {
|
||||
...user.organization,
|
||||
metadata: user.organization?.metadata ? teamMetadataSchema.parse(user.organization.metadata) : null,
|
||||
},
|
||||
avatar: `/${user.username}/avatar.png`,
|
||||
}));
|
||||
|
||||
if (!isOrgContext) {
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slug: usernameList[0],
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
}
|
||||
|
||||
if (!users.length || (!isValidOrgDomain && !users.some((user) => user.organizationId === null))) {
|
||||
return {
|
||||
notFound: true,
|
||||
@@ -328,8 +364,13 @@ export const getServerSideProps: GetServerSideProps<UserPageProps> = async (cont
|
||||
theme: user.theme,
|
||||
brandColor: user.brandColor,
|
||||
darkBrandColor: user.darkBrandColor,
|
||||
organizationSlug: user.organization?.slug ?? null,
|
||||
allowSEOIndexing: user.allowSEOIndexing ?? true,
|
||||
username: user.username,
|
||||
organization: {
|
||||
id: user.organizationId,
|
||||
slug: user.organization?.slug ?? null,
|
||||
requestedSlug: user.organization?.metadata?.requestedSlug ?? null,
|
||||
},
|
||||
};
|
||||
|
||||
const eventTypesWithHidden = await getEventTypesWithHiddenFromDB(user.id);
|
||||
|
||||
@@ -15,12 +15,15 @@ import { orgDomainConfig, userOrgQuery } from "@calcom/features/ee/organizations
|
||||
import { getUsernameList } from "@calcom/lib/defaultEvents";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
import type { EmbedProps } from "@lib/withEmbedSsr";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "../../lib/getTemporaryOrgRedirect";
|
||||
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
||||
|
||||
export default function Type({
|
||||
@@ -69,10 +72,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
@@ -93,7 +93,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
if (!users.length) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
} as const;
|
||||
}
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
|
||||
@@ -115,7 +115,7 @@ async function getDynamicGroupPageProps(context: GetServerSidePropsContext) {
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -145,10 +145,22 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
const { user: usernames, type: slug } = paramsSchema.parse(context.params);
|
||||
const username = usernames[0];
|
||||
const { rescheduleUid, bookingUid, duration: queryDuration } = context.query;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
|
||||
if (!isOrgContext) {
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slug: usernames[0],
|
||||
redirectType: RedirectType.User,
|
||||
eventTypeSlug: slug,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
}
|
||||
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
@@ -167,7 +179,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
if (!user) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
} as const;
|
||||
}
|
||||
|
||||
let booking: GetBookingType | null = null;
|
||||
@@ -189,7 +201,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
if (!eventData) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
} as const;
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { IncomingMessage } from "http";
|
||||
import { dir } from "i18next";
|
||||
import type { NextPageContext } from "next";
|
||||
import type { DocumentContext, DocumentProps } from "next/document";
|
||||
import Document, { Head, Html, Main, NextScript } from "next/document";
|
||||
@@ -50,21 +51,15 @@ class MyDocument extends Document<Props> {
|
||||
render() {
|
||||
const { isEmbed, embedColorScheme } = this.props;
|
||||
const newLocale = this.props.newLocale || "en";
|
||||
const newDir = dir(newLocale);
|
||||
|
||||
const nonceParsed = z.string().safeParse(this.props.nonce);
|
||||
const nonce = nonceParsed.success ? nonceParsed.data : "";
|
||||
|
||||
const intlLocale = new Intl.Locale(newLocale);
|
||||
// @ts-expect-error INFO: Typescript does not know about the Intl.Locale textInfo attribute
|
||||
const direction = intlLocale.textInfo?.direction;
|
||||
if (!direction) {
|
||||
throw new Error("NodeJS major breaking change detected, use getTextInfo() instead.");
|
||||
}
|
||||
|
||||
return (
|
||||
<Html
|
||||
lang={newLocale}
|
||||
dir={direction}
|
||||
dir={newDir}
|
||||
style={embedColorScheme ? { colorScheme: embedColorScheme as string } : undefined}>
|
||||
<Head nonce={nonce}>
|
||||
<script
|
||||
|
||||
@@ -26,7 +26,7 @@ type AugmentedNextPageContext = Omit<NextPageContext, "err"> & {
|
||||
err: AugmentedError;
|
||||
};
|
||||
|
||||
const log = logger.getChildLogger({ prefix: ["[error]"] });
|
||||
const log = logger.getSubLogger({ prefix: ["[error]"] });
|
||||
|
||||
const CustomError: NextPage<CustomErrorProps> = (props) => {
|
||||
const { statusCode, err, message, hasGetInitialPropsRun } = props;
|
||||
|
||||
@@ -6,7 +6,7 @@ import { prisma } from "@calcom/prisma";
|
||||
import type { AppCategories, Prisma } from "@calcom/prisma/client";
|
||||
|
||||
const isDryRun = process.env.CRON_ENABLE_APP_SYNC !== "true";
|
||||
const log = logger.getChildLogger({
|
||||
const log = logger.getSubLogger({
|
||||
prefix: ["[api/cron/syncAppMeta]", ...(isDryRun ? ["(dry-run)"] : [])],
|
||||
});
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
} from "@calcom/lib/constants";
|
||||
import logger from "@calcom/lib/logger";
|
||||
|
||||
const log = logger.getChildLogger({ prefix: ["[api/logo]"] });
|
||||
const log = logger.getSubLogger({ prefix: ["[api/logo]"] });
|
||||
|
||||
function removePort(url: string) {
|
||||
return url.replace(/:\d+$/, "");
|
||||
@@ -154,7 +154,7 @@ async function getTeamLogos(subdomain: string, isValidOrgDomain: boolean) {
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
const { query } = req;
|
||||
const parsedQuery = logoApiSchema.parse(query);
|
||||
const { isValidOrgDomain } = orgDomainConfig(req.headers.host ?? "");
|
||||
const { isValidOrgDomain } = orgDomainConfig(req);
|
||||
|
||||
const hostname = req?.headers["host"];
|
||||
if (!hostname) throw new Error("No hostname");
|
||||
|
||||
@@ -62,6 +62,46 @@ const triggerWebhook = async ({
|
||||
await Promise.all(promises);
|
||||
};
|
||||
|
||||
const checkIfUserIsPartOfTheSameTeam = async (
|
||||
teamId: number | undefined | null,
|
||||
userId: number,
|
||||
userEmail: string | undefined | null
|
||||
) => {
|
||||
if (!teamId) return false;
|
||||
|
||||
const getUserQuery = () => {
|
||||
if (!!userEmail) {
|
||||
return {
|
||||
OR: [
|
||||
{
|
||||
id: userId,
|
||||
},
|
||||
{
|
||||
email: userEmail,
|
||||
},
|
||||
],
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
id: userId,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
id: teamId,
|
||||
members: {
|
||||
some: {
|
||||
user: getUserQuery(),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return !!team;
|
||||
};
|
||||
|
||||
async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
if (!process.env.SENDGRID_API_KEY || !process.env.SENDGRID_EMAIL) {
|
||||
return res.status(405).json({ message: "No SendGrid API key or email" });
|
||||
@@ -137,12 +177,22 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
const isUserAttendeeOrOrganiser =
|
||||
booking?.user?.id === session.user.id ||
|
||||
attendeesList.find((attendee) => attendee.id === session.user.id);
|
||||
attendeesList.find(
|
||||
(attendee) => attendee.id === session.user.id || attendee.email === session.user.email
|
||||
);
|
||||
|
||||
if (!isUserAttendeeOrOrganiser) {
|
||||
return res.status(403).send({
|
||||
message: "Unauthorised",
|
||||
});
|
||||
const isUserMemberOfTheTeam = checkIfUserIsPartOfTheSameTeam(
|
||||
booking?.eventType?.teamId,
|
||||
session.user.id,
|
||||
session.user.email
|
||||
);
|
||||
|
||||
if (!isUserMemberOfTheTeam) {
|
||||
return res.status(403).send({
|
||||
message: "Unauthorised",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await prisma.booking.update({
|
||||
@@ -202,7 +252,7 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||
|
||||
return res.status(403).json({ message: "User does not have team plan to send out emails" });
|
||||
} catch (err) {
|
||||
console.warn("something_went_wrong", err);
|
||||
console.warn("Error in /recorded-daily-video", err);
|
||||
return res.status(500).json({ message: "something went wrong" });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import type { NextApiRequest, NextApiResponse } from "next";
|
||||
import { z } from "zod";
|
||||
|
||||
import { getSlugOrRequestedSlug, orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import {
|
||||
orgDomainConfig,
|
||||
whereClauseForOrgWithSlugOrRequestedSlug,
|
||||
} from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { AVATAR_FALLBACK } from "@calcom/lib/constants";
|
||||
import { getPlaceholderAvatar } from "@calcom/lib/defaultAvatarImage";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import prisma from "@calcom/prisma";
|
||||
|
||||
const log = logger.getSubLogger({ prefix: ["team/[slug]"] });
|
||||
const querySchema = z
|
||||
.object({
|
||||
username: z.string(),
|
||||
teamname: z.string(),
|
||||
/**
|
||||
* Passed when we want to fetch avatar of a particular organization
|
||||
*/
|
||||
orgSlug: z.string(),
|
||||
/**
|
||||
* Allow fetching avatar of a particular organization
|
||||
@@ -21,7 +29,7 @@ const querySchema = z
|
||||
|
||||
async function getIdentityData(req: NextApiRequest) {
|
||||
const { username, teamname, orgId, orgSlug } = querySchema.parse(req.query);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req.headers.host ?? "");
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(req);
|
||||
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
|
||||
@@ -30,7 +38,7 @@ async function getIdentityData(req: NextApiRequest) {
|
||||
id: orgId,
|
||||
}
|
||||
: org
|
||||
? getSlugOrRequestedSlug(org)
|
||||
? whereClauseForOrgWithSlugOrRequestedSlug(org)
|
||||
: null;
|
||||
|
||||
if (username) {
|
||||
@@ -41,6 +49,7 @@ async function getIdentityData(req: NextApiRequest) {
|
||||
},
|
||||
select: { avatar: true, email: true },
|
||||
});
|
||||
|
||||
return {
|
||||
name: username,
|
||||
email: user?.email,
|
||||
@@ -48,6 +57,7 @@ async function getIdentityData(req: NextApiRequest) {
|
||||
org,
|
||||
};
|
||||
}
|
||||
|
||||
if (teamname) {
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
@@ -56,6 +66,7 @@ async function getIdentityData(req: NextApiRequest) {
|
||||
},
|
||||
select: { logo: true },
|
||||
});
|
||||
|
||||
return {
|
||||
org,
|
||||
name: teamname,
|
||||
@@ -63,15 +74,25 @@ async function getIdentityData(req: NextApiRequest) {
|
||||
avatar: getPlaceholderAvatar(team?.logo, teamname),
|
||||
};
|
||||
}
|
||||
|
||||
if (orgSlug) {
|
||||
const org = await prisma.team.findFirst({
|
||||
where: getSlugOrRequestedSlug(orgSlug),
|
||||
const orgs = await prisma.team.findMany({
|
||||
where: {
|
||||
...whereClauseForOrgWithSlugOrRequestedSlug(orgSlug),
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
logo: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (orgs.length > 1) {
|
||||
// This should never happen, but instead of throwing error, we are just logging to be able to observe when it happens.
|
||||
log.error("More than one organization found for slug", orgSlug);
|
||||
}
|
||||
|
||||
const org = orgs[0];
|
||||
return {
|
||||
org: org?.slug,
|
||||
name: org?.name,
|
||||
|
||||
@@ -9,7 +9,7 @@ type Response = {
|
||||
};
|
||||
|
||||
export default async function handler(req: NextApiRequest, res: NextApiResponse<Response>): Promise<void> {
|
||||
const { currentOrgDomain } = orgDomainConfig(req.headers.host ?? "");
|
||||
const { currentOrgDomain } = orgDomainConfig(req);
|
||||
const result = await checkUsername(req.body.username, currentOrgDomain);
|
||||
return res.status(200).json(result);
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ function AppsSearch({
|
||||
onChange: ChangeEventHandler<HTMLInputElement>;
|
||||
className?: string;
|
||||
}) {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
<TextField
|
||||
className="bg-subtle !border-muted !pl-0 focus:!ring-offset-0"
|
||||
@@ -54,6 +55,7 @@ function AppsSearch({
|
||||
type="search"
|
||||
autoComplete="false"
|
||||
onChange={onChange}
|
||||
placeholder={t("search")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ inferSSRProps<typeof _getServerSideProps> & WithNonceProps<{}>) {
|
||||
|
||||
const telemetry = useTelemetry();
|
||||
|
||||
let callbackUrl = searchParams.get("callbackUrl") || "";
|
||||
let callbackUrl = searchParams?.get("callbackUrl") || "";
|
||||
|
||||
if (/"\//.test(callbackUrl)) callbackUrl = callbackUrl.substring(1);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import { signOut, useSession } from "next-auth/react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { WEBSITE_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
@@ -18,6 +18,7 @@ import { ssrInit } from "@server/lib/ssr";
|
||||
type Props = inferSSRProps<typeof getServerSideProps>;
|
||||
|
||||
export function Logout(props: Props) {
|
||||
const [btnLoading, setBtnLoading] = useState<boolean>(false);
|
||||
const { status } = useSession();
|
||||
if (status === "authenticated") signOut({ redirect: false });
|
||||
const router = useRouter();
|
||||
@@ -35,6 +36,11 @@ export function Logout(props: Props) {
|
||||
return "hope_to_see_you_soon";
|
||||
};
|
||||
|
||||
const navigateToLogin = () => {
|
||||
setBtnLoading(true);
|
||||
router.push("/auth/login");
|
||||
};
|
||||
|
||||
return (
|
||||
<AuthContainer title={t("logged_out")} description={t("youve_been_logged_out")} showLogo>
|
||||
<div className="mb-4">
|
||||
@@ -50,7 +56,11 @@ export function Logout(props: Props) {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Button href="/auth/login" className="flex w-full justify-center">
|
||||
<Button
|
||||
data-testid="logout-btn"
|
||||
onClick={navigateToLogin}
|
||||
className="flex w-full justify-center"
|
||||
loading={btnLoading}>
|
||||
{t("go_back_login")}
|
||||
</Button>
|
||||
</AuthContainer>
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function Authorize() {
|
||||
const state = searchParams?.get("state") as string;
|
||||
const scope = searchParams?.get("scope") as string;
|
||||
|
||||
const queryString = searchParams.toString();
|
||||
const queryString = searchParams?.toString();
|
||||
|
||||
const [selectedAccount, setSelectedAccount] = useState<{ value: string; label: string } | null>();
|
||||
const scopes = scope ? scope.toString().split(",") : [];
|
||||
|
||||
@@ -24,7 +24,7 @@ function useSetStep() {
|
||||
const searchParams = useSearchParams();
|
||||
const pathname = usePathname();
|
||||
const setStep = (newStep = 1) => {
|
||||
const _searchParams = new URLSearchParams(searchParams);
|
||||
const _searchParams = new URLSearchParams(searchParams ?? undefined);
|
||||
_searchParams.set("step", newStep.toString());
|
||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||
};
|
||||
|
||||
@@ -65,7 +65,7 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
|
||||
const session = await getServerSession({ req, res });
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
const { currentOrgDomain } = orgDomainConfig(context.req);
|
||||
|
||||
if (session) {
|
||||
// Validating if username is Premium, while this is true an email its required for stripe user confirmation
|
||||
|
||||
@@ -164,7 +164,7 @@ export default function Verify() {
|
||||
e.preventDefault();
|
||||
setSecondsLeft(30);
|
||||
// Update query params with t:timestamp, shallow: true doesn't re-render the page
|
||||
const _searchParams = new URLSearchParams(searchParams.toString());
|
||||
const _searchParams = new URLSearchParams(searchParams?.toString());
|
||||
_searchParams.set("t", `${Date.now()}`);
|
||||
router.replace(`${pathname}?${_searchParams.toString()}`);
|
||||
return await sendVerificationLogin(customer.email, customer.username);
|
||||
|
||||
@@ -115,6 +115,13 @@ export default function Success(props: SuccessProps) {
|
||||
const tz = props.tz ? props.tz : isSuccessBookingPage && attendeeTimeZone ? attendeeTimeZone : timeZone();
|
||||
|
||||
const location = props.bookingInfo.location as ReturnType<typeof getEventLocationValue>;
|
||||
let rescheduleLocation: string | undefined;
|
||||
if (
|
||||
typeof props.bookingInfo.responses.location === "object" &&
|
||||
"optionValue" in props.bookingInfo.responses.location
|
||||
) {
|
||||
rescheduleLocation = props.bookingInfo.responses.location.optionValue;
|
||||
}
|
||||
|
||||
const locationVideoCallUrl: string | undefined = bookingMetadataSchema.parse(
|
||||
props?.bookingInfo?.metadata || {}
|
||||
@@ -148,7 +155,7 @@ export default function Success(props: SuccessProps) {
|
||||
const [calculatedDuration, setCalculatedDuration] = useState<number | undefined>(undefined);
|
||||
const { requiresLoginToUpdate } = props;
|
||||
function setIsCancellationMode(value: boolean) {
|
||||
const _searchParams = new URLSearchParams(searchParams);
|
||||
const _searchParams = new URLSearchParams(searchParams ?? undefined);
|
||||
|
||||
if (value) {
|
||||
_searchParams.set("cancel", "true");
|
||||
@@ -295,7 +302,14 @@ export default function Success(props: SuccessProps) {
|
||||
bookingInfo.status
|
||||
);
|
||||
|
||||
const rescheduleLocationToDisplay = getSuccessPageLocationMessage(
|
||||
rescheduleLocation ?? "",
|
||||
t,
|
||||
bookingInfo.status
|
||||
);
|
||||
|
||||
const providerName = guessEventLocationType(location)?.label;
|
||||
const rescheduleProviderName = guessEventLocationType(rescheduleLocation)?.label;
|
||||
|
||||
return (
|
||||
<div className={isEmbed ? "" : "h-screen"} data-testid="success-page">
|
||||
@@ -328,14 +342,17 @@ export default function Success(props: SuccessProps) {
|
||||
<div
|
||||
className={classNames(
|
||||
shouldAlignCentrally ? "text-center" : "",
|
||||
"flex items-end justify-center px-4 pb-20 pt-4 sm:block sm:p-0"
|
||||
"flex items-end justify-center px-4 pb-20 pt-4 sm:flex sm:p-0"
|
||||
)}>
|
||||
<div
|
||||
className={classNames("my-4 transition-opacity sm:my-0", isEmbed ? "" : " inset-0")}
|
||||
className={classNames(
|
||||
"main my-4 flex flex-col transition-opacity sm:my-0 ",
|
||||
isEmbed ? "" : " inset-0"
|
||||
)}
|
||||
aria-hidden="true">
|
||||
<div
|
||||
className={classNames(
|
||||
"main inline-block transform overflow-hidden rounded-lg border sm:my-8 sm:max-w-xl",
|
||||
"inline-block transform overflow-hidden rounded-lg border sm:my-8 sm:max-w-xl",
|
||||
!isBackgroundTransparent && " bg-default dark:bg-muted border-booker border-booker-width",
|
||||
"px-8 pb-4 pt-5 text-left align-bottom transition-all sm:w-full sm:py-8 sm:align-middle"
|
||||
)}
|
||||
@@ -467,18 +484,50 @@ export default function Success(props: SuccessProps) {
|
||||
<>
|
||||
<div className="mt-3 font-medium">{t("where")}</div>
|
||||
<div className="col-span-2 mt-3" data-testid="where">
|
||||
{locationToDisplay.startsWith("http") ? (
|
||||
<a
|
||||
href={locationToDisplay}
|
||||
target="_blank"
|
||||
title={locationToDisplay}
|
||||
className="text-default flex items-center gap-2 underline"
|
||||
rel="noreferrer">
|
||||
{providerName || "Link"}
|
||||
<ExternalLink className="text-default inline h-4 w-4" />
|
||||
</a>
|
||||
{!rescheduleLocation || locationToDisplay === rescheduleLocationToDisplay ? (
|
||||
locationToDisplay.startsWith("http") ? (
|
||||
<a
|
||||
href={locationToDisplay}
|
||||
target="_blank"
|
||||
title={locationToDisplay}
|
||||
className="text-default flex items-center gap-2"
|
||||
rel="noreferrer">
|
||||
{providerName || "Link"}
|
||||
<ExternalLink className="text-default inline h-4 w-4" />
|
||||
</a>
|
||||
) : (
|
||||
locationToDisplay
|
||||
)
|
||||
) : (
|
||||
locationToDisplay
|
||||
<>
|
||||
{!!formerTime &&
|
||||
(locationToDisplay.startsWith("http") ? (
|
||||
<a
|
||||
href={locationToDisplay}
|
||||
target="_blank"
|
||||
title={locationToDisplay}
|
||||
className="text-default flex items-center gap-2 line-through"
|
||||
rel="noreferrer">
|
||||
{providerName || "Link"}
|
||||
<ExternalLink className="text-default inline h-4 w-4" />
|
||||
</a>
|
||||
) : (
|
||||
<p className="line-through">{locationToDisplay}</p>
|
||||
))}
|
||||
{rescheduleLocationToDisplay.startsWith("http") ? (
|
||||
<a
|
||||
href={rescheduleLocationToDisplay}
|
||||
target="_blank"
|
||||
title={rescheduleLocationToDisplay}
|
||||
className="text-default flex items-center gap-2"
|
||||
rel="noreferrer">
|
||||
{rescheduleProviderName || "Link"}
|
||||
<ExternalLink className="text-default inline h-4 w-4" />
|
||||
</a>
|
||||
) : (
|
||||
rescheduleLocationToDisplay
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
@@ -1042,7 +1091,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
|
||||
const parsedQuery = querySchema.safeParse(context.query);
|
||||
|
||||
if (!parsedQuery.success) return { notFound: true };
|
||||
if (!parsedQuery.success) return { notFound: true } as const;
|
||||
const { uid, eventTypeSlug, seatReferenceUid } = parsedQuery.data;
|
||||
|
||||
const { uid: maybeUid } = await maybeGetBookingUidFromSeat(prisma, uid);
|
||||
@@ -1100,7 +1149,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
if (!bookingInfoRaw) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
} as const;
|
||||
}
|
||||
|
||||
const eventTypeRaw = !bookingInfoRaw.eventTypeId
|
||||
@@ -1109,7 +1158,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
if (!eventTypeRaw) {
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
} as const;
|
||||
}
|
||||
|
||||
if (eventTypeRaw.seatsPerTimeSlot && !seatReferenceUid && !session) {
|
||||
@@ -1130,7 +1179,7 @@ export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||
if (!eventTypeRaw.owner)
|
||||
return {
|
||||
notFound: true,
|
||||
};
|
||||
} as const;
|
||||
eventTypeRaw.users.push({
|
||||
...eventTypeRaw.owner,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import withEmbedSsr from "@lib/withEmbedSsr";
|
||||
|
||||
import { getServerSideProps as _getServerSideProps } from "../[uid]";
|
||||
|
||||
export { default } from "../[uid]";
|
||||
|
||||
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
|
||||
@@ -61,7 +61,7 @@ async function getUserPageProps(context: GetServerSidePropsContext) {
|
||||
const session = await getServerSession(context);
|
||||
const { link, slug } = paramsSchema.parse(context.params);
|
||||
const { rescheduleUid, duration: queryDuration } = context.query;
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req.headers.host ?? "");
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req);
|
||||
const org = isValidOrgDomain ? currentOrgDomain : null;
|
||||
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable @typescript-eslint/no-empty-function */
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { isValidPhoneNumber } from "libphonenumber-js";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
@@ -86,6 +87,7 @@ export type FormValues = {
|
||||
offsetStart: number;
|
||||
description: string;
|
||||
disableGuests: boolean;
|
||||
lockTimeZoneToggleOnBookingPage: boolean;
|
||||
requiresConfirmation: boolean;
|
||||
requiresBookerEmailVerification: boolean;
|
||||
recurringEvent: RecurringEvent | null;
|
||||
@@ -299,6 +301,28 @@ const EventTypePage = (props: EventTypeSetupProps) => {
|
||||
length: z.union([z.string().transform((val) => +val), z.number()]).optional(),
|
||||
offsetStart: z.union([z.string().transform((val) => +val), z.number()]).optional(),
|
||||
bookingFields: eventTypeBookingFields,
|
||||
locations: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
type: z.string(),
|
||||
address: z.string().optional(),
|
||||
link: z.string().url().optional(),
|
||||
phone: z
|
||||
.string()
|
||||
.refine((val) => isValidPhoneNumber(val))
|
||||
.optional(),
|
||||
hostPhoneNumber: z
|
||||
.string()
|
||||
.refine((val) => isValidPhoneNumber(val))
|
||||
.optional(),
|
||||
displayLocationPublicly: z.boolean().optional(),
|
||||
credentialId: z.number().optional(),
|
||||
teamName: z.string().optional(),
|
||||
})
|
||||
.passthrough()
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
// TODO: Add schema for other fields later.
|
||||
.passthrough()
|
||||
|
||||
@@ -65,7 +65,6 @@ import {
|
||||
MoreHorizontal,
|
||||
Trash,
|
||||
Upload,
|
||||
User as UserIcon,
|
||||
Users,
|
||||
} from "@calcom/ui/components/icon";
|
||||
|
||||
@@ -73,6 +72,7 @@ import useMeQuery from "@lib/hooks/useMeQuery";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import SkeletonLoader from "@components/eventtype/SkeletonLoader";
|
||||
import { UserAvatarGroup } from "@components/ui/avatar/UserAvatarGroup";
|
||||
|
||||
type EventTypeGroups = RouterOutputs["viewer"]["eventTypes"]["getByViewer"]["eventTypeGroups"];
|
||||
type EventTypeGroupProfile = EventTypeGroups[number]["profile"];
|
||||
@@ -299,7 +299,7 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
|
||||
// inject selection data into url for correct router history
|
||||
const openDuplicateModal = (eventType: EventType, group: EventTypeGroup) => {
|
||||
const newSearchParams = new URLSearchParams(searchParams);
|
||||
const newSearchParams = new URLSearchParams(searchParams ?? undefined);
|
||||
function setParamsIfDefined(key: string, value: string | number | boolean | null | undefined) {
|
||||
if (value) newSearchParams.set(key, value.toString());
|
||||
if (value === null) newSearchParams.delete(key);
|
||||
@@ -399,23 +399,11 @@ export const EventTypeList = ({ group, groupIndex, readOnly, types }: EventTypeL
|
||||
<div className="mt-4 hidden sm:mt-0 sm:flex">
|
||||
<div className="flex justify-between space-x-2 rtl:space-x-reverse">
|
||||
{type.team && !isManagedEventType && (
|
||||
<AvatarGroup
|
||||
<UserAvatarGroup
|
||||
className="relative right-3 top-1"
|
||||
size="sm"
|
||||
truncateAfter={4}
|
||||
items={
|
||||
type?.users
|
||||
? type.users.map(
|
||||
(organizer: { name: string | null; username: string | null }) => ({
|
||||
alt: organizer.name || "",
|
||||
image: `${orgBranding?.fullDomain ?? WEBAPP_URL}/${
|
||||
organizer.username
|
||||
}/avatar.png`,
|
||||
title: organizer.name || "",
|
||||
})
|
||||
)
|
||||
: []
|
||||
}
|
||||
users={type?.users ?? []}
|
||||
/>
|
||||
)}
|
||||
{isManagedEventType && type?.children && type.children?.length > 0 && (
|
||||
@@ -821,34 +809,6 @@ const Actions = () => {
|
||||
);
|
||||
};
|
||||
|
||||
const SetupProfileBanner = ({ closeAction }: { closeAction: () => void }) => {
|
||||
const { t } = useLocale();
|
||||
const orgBranding = useOrgBranding();
|
||||
|
||||
return (
|
||||
<Alert
|
||||
className="my-4"
|
||||
severity="info"
|
||||
title={t("set_up_your_profile")}
|
||||
message={t("set_up_your_profile_description", { orgName: orgBranding?.name })}
|
||||
CustomIcon={UserIcon}
|
||||
actions={
|
||||
<div className="flex gap-1">
|
||||
<Button color="minimal" className="text-sky-700 hover:bg-sky-100" onClick={closeAction}>
|
||||
{t("dismiss")}
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
className="border-sky-700 bg-sky-50 text-sky-700 hover:border-sky-900 hover:bg-sky-200"
|
||||
href="/getting-started">
|
||||
{t("set_up")}
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const EmptyEventTypeList = ({ group }: { group: EventTypeGroup }) => {
|
||||
const { t } = useLocale();
|
||||
return (
|
||||
@@ -984,7 +944,6 @@ const EventTypesPage = () => {
|
||||
heading={t("event_types_page_title")}
|
||||
hideHeadingOnMobile
|
||||
subtitle={t("event_types_page_subtitle")}
|
||||
afterHeading={showProfileBanner && <SetupProfileBanner closeAction={closeBanner} />}
|
||||
beforeCTAactions={<Actions />}
|
||||
CTA={<CTA data={data} />}>
|
||||
<HeadSeo
|
||||
|
||||
@@ -1,19 +1,7 @@
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import withEmbedSsr from "@lib/withEmbedSsr";
|
||||
|
||||
import { getServerSideProps as _getServerSideProps } from "../[type]";
|
||||
|
||||
export { default } from "../[type]";
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const ssrResponse = await _getServerSideProps(context);
|
||||
if (ssrResponse.notFound) {
|
||||
return ssrResponse;
|
||||
}
|
||||
return {
|
||||
...ssrResponse,
|
||||
props: {
|
||||
...ssrResponse.props,
|
||||
isEmbed: true,
|
||||
},
|
||||
};
|
||||
};
|
||||
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import withEmbedSsr from "@lib/withEmbedSsr";
|
||||
|
||||
import { getServerSideProps as _getServerSideProps } from "../[user]";
|
||||
|
||||
export { default } from "../[user]";
|
||||
|
||||
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
|
||||
@@ -0,0 +1,7 @@
|
||||
import withEmbedSsr from "@lib/withEmbedSsr";
|
||||
|
||||
import { getServerSideProps as _getServerSideProps } from "./index";
|
||||
|
||||
export { default } from "./index";
|
||||
|
||||
export const getServerSideProps = withEmbedSsr(_getServerSideProps);
|
||||
@@ -6,7 +6,7 @@ import { Controller, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
|
||||
import OrganizationAvatar from "@calcom/features/ee/organizations/components/OrganizationAvatar";
|
||||
import OrganizationMemberAvatar from "@calcom/features/ee/organizations/components/OrganizationMemberAvatar";
|
||||
import SectionBottomActions from "@calcom/features/settings/SectionBottomActions";
|
||||
import { getLayout } from "@calcom/features/settings/layouts/SettingsLayout";
|
||||
import { APP_NAME, FULL_NAME_LENGTH_MAX_LIMIT } from "@calcom/lib/constants";
|
||||
@@ -19,6 +19,7 @@ import type { TRPCClientErrorLike } from "@calcom/trpc/client";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import type { AppRouter } from "@calcom/trpc/server/routers/_app";
|
||||
import type { Ensure } from "@calcom/types/utils";
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
@@ -77,8 +78,8 @@ type FormValues = {
|
||||
bio: string;
|
||||
};
|
||||
|
||||
const checkIfItFallbackImage = (fetchedImgSrc: string) => {
|
||||
return fetchedImgSrc.endsWith(AVATAR_FALLBACK);
|
||||
const checkIfItFallbackImage = (fetchedImgSrc?: string) => {
|
||||
return !fetchedImgSrc || fetchedImgSrc.endsWith(AVATAR_FALLBACK);
|
||||
};
|
||||
|
||||
const ProfileView = () => {
|
||||
@@ -225,10 +226,11 @@ const ProfileView = () => {
|
||||
[ErrorCode.ThirdPartyIdentityProviderEnabled]: t("account_created_with_identity_provider"),
|
||||
};
|
||||
|
||||
if (isLoading || !user || fetchedImgSrc === undefined)
|
||||
if (isLoading || !user) {
|
||||
return (
|
||||
<SkeletonLoader title={t("profile")} description={t("profile_description", { appName: APP_NAME })} />
|
||||
);
|
||||
}
|
||||
|
||||
const defaultValues = {
|
||||
username: user.username || "",
|
||||
@@ -251,6 +253,7 @@ const ProfileView = () => {
|
||||
isLoading={updateProfileMutation.isLoading}
|
||||
isFallbackImg={checkIfItFallbackImage(fetchedImgSrc)}
|
||||
userAvatar={user.avatar}
|
||||
user={user}
|
||||
userOrganization={user.organization}
|
||||
onSubmit={(values) => {
|
||||
if (values.email !== user.email && isCALIdentityProvider) {
|
||||
@@ -280,8 +283,8 @@ const ProfileView = () => {
|
||||
/>
|
||||
|
||||
<div className="border-subtle mt-6 rounded-lg rounded-b-none border border-b-0 p-6">
|
||||
<Label className="text-base font-semibold text-red-700">{t("danger_zone")}</Label>
|
||||
<p className="text-subtle">{t("account_deletion_cannot_be_undone")}</p>
|
||||
<Label className="mb-0 text-base font-semibold text-red-700">{t("danger_zone")}</Label>
|
||||
<p className="text-subtle text-sm">{t("account_deletion_cannot_be_undone")}</p>
|
||||
</div>
|
||||
{/* Delete account Dialog */}
|
||||
<Dialog open={deleteAccountOpen} onOpenChange={setDeleteAccountOpen}>
|
||||
@@ -396,6 +399,7 @@ const ProfileForm = ({
|
||||
isLoading = false,
|
||||
isFallbackImg,
|
||||
userAvatar,
|
||||
user,
|
||||
userOrganization,
|
||||
}: {
|
||||
defaultValues: FormValues;
|
||||
@@ -404,6 +408,7 @@ const ProfileForm = ({
|
||||
isLoading: boolean;
|
||||
isFallbackImg: boolean;
|
||||
userAvatar: string;
|
||||
user: RouterOutputs["viewer"]["me"];
|
||||
userOrganization: RouterOutputs["viewer"]["me"]["organization"];
|
||||
}) => {
|
||||
const { t } = useLocale();
|
||||
@@ -443,13 +448,21 @@ const ProfileForm = ({
|
||||
name="avatar"
|
||||
render={({ field: { value } }) => {
|
||||
const showRemoveAvatarButton = !isFallbackImg || (value && userAvatar !== value);
|
||||
const organization =
|
||||
userOrganization && userOrganization.id
|
||||
? {
|
||||
...(userOrganization as Ensure<typeof user.organization, "id">),
|
||||
slug: userOrganization.slug || null,
|
||||
requestedSlug: userOrganization.metadata?.requestedSlug || null,
|
||||
}
|
||||
: null;
|
||||
return (
|
||||
<>
|
||||
<OrganizationAvatar
|
||||
alt={formMethods.getValues("username")}
|
||||
imageSrc={value}
|
||||
<OrganizationMemberAvatar
|
||||
previewSrc={value}
|
||||
size="lg"
|
||||
organizationSlug={userOrganization.slug}
|
||||
user={user}
|
||||
organization={organization}
|
||||
/>
|
||||
<div className="ms-4">
|
||||
<h2 className="mb-2 text-sm font-medium">{t("profile_picture")}</h2>
|
||||
|
||||
@@ -164,14 +164,13 @@ const PasswordView = ({ user }: PasswordViewProps) => {
|
||||
<>
|
||||
<Meta title={t("password")} description={t("password_description")} borderInShellHeader={true} />
|
||||
{user && user.identityProvider !== IdentityProvider.CAL ? (
|
||||
<div>
|
||||
<div className="mt-6">
|
||||
<h2 className="font-cal text-emphasis text-lg font-medium leading-6">
|
||||
{t("account_managed_by_identity_provider", {
|
||||
provider: identityProviderNameMap[user.identityProvider],
|
||||
})}
|
||||
</h2>
|
||||
</div>
|
||||
<div className="border-subtle rounded-b-xl border border-t-0 px-4 py-6 sm:px-6">
|
||||
<h2 className="font-cal text-emphasis text-lg font-medium leading-6">
|
||||
{t("account_managed_by_identity_provider", {
|
||||
provider: identityProviderNameMap[user.identityProvider],
|
||||
})}
|
||||
</h2>
|
||||
|
||||
<p className="text-subtle mt-1 text-sm">
|
||||
{t("account_managed_by_identity_provider_description", {
|
||||
provider: identityProviderNameMap[user.identityProvider],
|
||||
@@ -180,7 +179,7 @@ const PasswordView = ({ user }: PasswordViewProps) => {
|
||||
</div>
|
||||
) : (
|
||||
<Form form={formMethods} handleSubmit={handleSubmit}>
|
||||
<div className="border-x px-4 py-6 sm:px-6">
|
||||
<div className="border-subtle border-x px-4 py-6 sm:px-6">
|
||||
{formMethods.formState.errors.apiError && (
|
||||
<div className="pb-6">
|
||||
<Alert severity="error" message={formMethods.formState.errors.apiError?.message} />
|
||||
|
||||
@@ -8,7 +8,7 @@ import { FormProvider, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { checkPremiumUsername } from "@calcom/features/ee/common/lib/checkPremiumUsername";
|
||||
import { getOrgFullDomain } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { isSAMLLoginEnabled } from "@calcom/features/ee/sso/lib/saml";
|
||||
import { useFlagMap } from "@calcom/features/flags/context/provider";
|
||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||
@@ -159,7 +159,7 @@ export default function Signup({ prepopulateFormValues, token, orgSlug, orgAutoA
|
||||
<TextField
|
||||
addOnLeading={
|
||||
orgSlug
|
||||
? `${getOrgFullDomain(orgSlug, { protocol: true })}/`
|
||||
? `${getOrgFullOrigin(orgSlug, { protocol: true })}/`
|
||||
: `${process.env.NEXT_PUBLIC_WEBSITE_URL}/`
|
||||
}
|
||||
{...register("username")}
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
// This route is reachable by
|
||||
// 1. /team/[slug]
|
||||
// 2. / (when on org domain e.g. http://calcom.cal.com/. This is through a rewrite from next.config.js)
|
||||
// Also the getServerSideProps and default export are reused by
|
||||
// 1. org/[orgSlug]/team/[slug]
|
||||
// 2. org/[orgSlug]/[user]/[type]
|
||||
import classNames from "classnames";
|
||||
import type { GetServerSidePropsContext } from "next";
|
||||
import Link from "next/link";
|
||||
@@ -5,21 +11,23 @@ import { usePathname } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { sdkActionManager, useIsEmbed } from "@calcom/embed-core/embed-iframe";
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { orgDomainConfig, getOrgFullOrigin } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import EventTypeDescription from "@calcom/features/eventtypes/components/EventTypeDescription";
|
||||
import { getFeatureFlagMap } from "@calcom/features/flags/server/utils";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { useRouterQuery } from "@calcom/lib/hooks/useRouterQuery";
|
||||
import useTheme from "@calcom/lib/hooks/useTheme";
|
||||
import logger from "@calcom/lib/logger";
|
||||
import { markdownToSafeHTML } from "@calcom/lib/markdownToSafeHTML";
|
||||
import { getTeamWithMembers } from "@calcom/lib/server/queries/teams";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { stripMarkdown } from "@calcom/lib/stripMarkdown";
|
||||
import { collectPageParameters, telemetryEventTypes, useTelemetry } from "@calcom/lib/telemetry";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { RedirectType } from "@calcom/prisma/client";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
import { Avatar, AvatarGroup, Button, HeadSeo, UnpublishedEntity } from "@calcom/ui";
|
||||
import { Avatar, Button, HeadSeo, UnpublishedEntity } from "@calcom/ui";
|
||||
import { ArrowRight } from "@calcom/ui/components/icon";
|
||||
|
||||
import { useToggleQuery } from "@lib/hooks/useToggleQuery";
|
||||
@@ -27,11 +35,14 @@ import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
import Team from "@components/team/screens/Team";
|
||||
import { UserAvatarGroup } from "@components/ui/avatar/UserAvatarGroup";
|
||||
|
||||
import { ssrInit } from "@server/lib/ssr";
|
||||
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
import { getTemporaryOrgRedirect } from "../../lib/getTemporaryOrgRedirect";
|
||||
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps>;
|
||||
const log = logger.getSubLogger({ prefix: ["team/[slug]"] });
|
||||
function TeamPage({
|
||||
team,
|
||||
isUnpublished,
|
||||
@@ -101,15 +112,11 @@ function TeamPage({
|
||||
<EventTypeDescription className="text-sm" eventType={type} />
|
||||
</div>
|
||||
<div className="mt-1 self-center">
|
||||
<AvatarGroup
|
||||
<UserAvatarGroup
|
||||
truncateAfter={4}
|
||||
className="flex flex-shrink-0"
|
||||
size="sm"
|
||||
items={type.users.map((user) => ({
|
||||
alt: user.name || "",
|
||||
title: user.name || "",
|
||||
image: `/${user.username}/avatar.png` || "",
|
||||
}))}
|
||||
users={type.users}
|
||||
/>
|
||||
</div>
|
||||
</Link>
|
||||
@@ -139,17 +146,11 @@ function TeamPage({
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<AvatarGroup
|
||||
<UserAvatarGroup
|
||||
className="mr-6"
|
||||
size="sm"
|
||||
truncateAfter={4}
|
||||
items={team.members
|
||||
.filter((mem) => mem.subteams?.includes(ch.slug) && mem.accepted)
|
||||
.map((member) => ({
|
||||
alt: member.name || "",
|
||||
image: `/${member.username}/avatar.png`,
|
||||
title: member.name || "",
|
||||
}))}
|
||||
users={team.members.filter((mem) => mem.subteams?.includes(ch.slug) && mem.accepted)}
|
||||
/>
|
||||
</Link>
|
||||
</li>
|
||||
@@ -268,30 +269,49 @@ function TeamPage({
|
||||
|
||||
export const getServerSideProps = async (context: GetServerSidePropsContext) => {
|
||||
const slug = Array.isArray(context.query?.slug) ? context.query.slug.pop() : context.query.slug;
|
||||
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
const { isValidOrgDomain, currentOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
const isOrgContext = isValidOrgDomain && currentOrgDomain;
|
||||
|
||||
// Provided by Rewrite from next.config.js
|
||||
const isOrgProfile = context.query?.isOrgProfile === "1";
|
||||
const flags = await getFeatureFlagMap(prisma);
|
||||
const isOrganizationFeatureEnabled = flags["organizations"];
|
||||
|
||||
log.debug("getServerSideProps", {
|
||||
isOrgProfile,
|
||||
isOrganizationFeatureEnabled,
|
||||
isValidOrgDomain,
|
||||
currentOrgDomain,
|
||||
});
|
||||
|
||||
const team = await getTeamWithMembers({
|
||||
slug: slugify(slug ?? ""),
|
||||
orgSlug: currentOrgDomain,
|
||||
isTeamView: true,
|
||||
isOrgView: isValidOrgDomain && context.resolvedUrl === "/",
|
||||
isOrgView: isValidOrgDomain && isOrgProfile,
|
||||
});
|
||||
|
||||
if (!isOrgContext && slug) {
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slug: slug,
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: null,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
}
|
||||
|
||||
const ssr = await ssrInit(context);
|
||||
const metadata = teamMetadataSchema.parse(team?.metadata ?? {});
|
||||
console.info("gSSP, team/[slug] - ", {
|
||||
isValidOrgDomain,
|
||||
currentOrgDomain,
|
||||
ALLOWED_HOSTNAMES: process.env.ALLOWED_HOSTNAMES,
|
||||
flags: JSON.stringify(flags),
|
||||
});
|
||||
|
||||
// Taking care of sub-teams and orgs
|
||||
if (
|
||||
(!isValidOrgDomain && team?.parent) ||
|
||||
(!isValidOrgDomain && !!metadata?.isOrganization) ||
|
||||
flags["organizations"] !== true
|
||||
!isOrganizationFeatureEnabled
|
||||
) {
|
||||
return { notFound: true } as const;
|
||||
}
|
||||
@@ -342,7 +362,9 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
subteams: member.subteams,
|
||||
username: member.username,
|
||||
accepted: member.accepted,
|
||||
organizationId: member.organizationId,
|
||||
safeBio: markdownToSafeHTML(member.bio || ""),
|
||||
orgOrigin: getOrgFullOrigin(member.organization?.slug || ""),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
@@ -11,12 +11,15 @@ import { getSlugOrRequestedSlug } from "@calcom/features/ee/organizations/lib/or
|
||||
import { orgDomainConfig } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import prisma from "@calcom/prisma";
|
||||
import { RedirectType } from "@calcom/prisma/client";
|
||||
|
||||
import type { inferSSRProps } from "@lib/types/inferSSRProps";
|
||||
import type { EmbedProps } from "@lib/withEmbedSsr";
|
||||
|
||||
import PageWrapper from "@components/PageWrapper";
|
||||
|
||||
import { getTemporaryOrgRedirect } from "../../../lib/getTemporaryOrgRedirect";
|
||||
|
||||
export type PageProps = inferSSRProps<typeof getServerSideProps> & EmbedProps;
|
||||
|
||||
export default function Type({
|
||||
@@ -71,10 +74,21 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) =>
|
||||
const { rescheduleUid, duration: queryDuration } = context.query;
|
||||
const { ssrInit } = await import("@server/lib/ssr");
|
||||
const ssr = await ssrInit(context);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(
|
||||
context.req.headers.host ?? "",
|
||||
context.params?.orgSlug
|
||||
);
|
||||
const { currentOrgDomain, isValidOrgDomain } = orgDomainConfig(context.req, context.params?.orgSlug);
|
||||
const isOrgContext = currentOrgDomain && isValidOrgDomain;
|
||||
|
||||
if (!isOrgContext) {
|
||||
const redirect = await getTemporaryOrgRedirect({
|
||||
slug: teamSlug,
|
||||
redirectType: RedirectType.Team,
|
||||
eventTypeSlug: meetingSlug,
|
||||
currentQuery: context.query,
|
||||
});
|
||||
|
||||
if (redirect) {
|
||||
return redirect;
|
||||
}
|
||||
}
|
||||
|
||||
const team = await prisma.team.findFirst({
|
||||
where: {
|
||||
|
||||
@@ -26,6 +26,7 @@ function Teams() {
|
||||
CTA={
|
||||
(!user.organizationId || user.organization.isOrgAdmin) && (
|
||||
<Button
|
||||
data-testid="new-team-btn"
|
||||
variant="fab"
|
||||
StartIcon={Plus}
|
||||
type="button"
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe("AppListCard", async () => {
|
||||
test("should remove the highlight from the URL", async ({ page, users }) => {
|
||||
const user = await users.create({});
|
||||
await user.apiLogin();
|
||||
|
||||
await page.goto("/apps/installed/conferencing?hl=daily-video");
|
||||
|
||||
await page.waitForLoadState();
|
||||
|
||||
await page.waitForURL("/apps/installed/conferencing");
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,7 @@ test.describe("free user", () => {
|
||||
// book same time spot again
|
||||
await bookTimeSlot(page);
|
||||
|
||||
await expect(page.locator("[data-testid=booking-fail]")).toBeVisible({ timeout: 1000 });
|
||||
await page.locator("[data-testid=booking-fail]").waitFor({ state: "visible" });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -435,6 +435,8 @@ test.describe("Reschedule for booking with seats", () => {
|
||||
|
||||
await page.locator('[data-testid="confirm_cancel"]').click();
|
||||
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const oldBooking = await prisma.booking.findFirst({
|
||||
where: { uid: booking.uid },
|
||||
select: {
|
||||
|
||||
@@ -0,0 +1,483 @@
|
||||
import { loginUser } from "../../fixtures/regularBookings";
|
||||
import { test } from "../../lib/fixtures";
|
||||
|
||||
test.describe("Booking With Address Question and Each Other Question", () => {
|
||||
const bookingOptions = { hasPlaceholder: true, isRequired: true };
|
||||
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
await loginUser(users);
|
||||
await page.goto("/event-types");
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and Checkbox Group Question", () => {
|
||||
test("Address required and checkbox group required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Checkbox Group question (both required)",
|
||||
secondQuestion: "checkbox",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and checkbox group not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Checkbox Group question (only address required)",
|
||||
secondQuestion: "checkbox",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and Checkbox Question", () => {
|
||||
test("Address required and checkbox required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Checkbox question (both required)",
|
||||
secondQuestion: "boolean",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Addres and checkbox not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Checkbox question (only address required)",
|
||||
secondQuestion: "boolean",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and Long text Question", () => {
|
||||
test("Addres required and Long Text required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Long Text question (both required)",
|
||||
secondQuestion: "textarea",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and Long Text not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", false, "textarea test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Long Text question (only address required)",
|
||||
secondQuestion: "textarea",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and Multi email Question", () => {
|
||||
test("Address required and Multi email required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion(
|
||||
"multiemail",
|
||||
"multiemail-test",
|
||||
"multiemail test",
|
||||
true,
|
||||
"multiemail test"
|
||||
);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Multiemail question (both required)",
|
||||
secondQuestion: "multiemail",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and Multi email not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion(
|
||||
"multiemail",
|
||||
"multiemail-test",
|
||||
"multiemail test",
|
||||
false,
|
||||
"multiemail test"
|
||||
);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Multiemail question (only address required)",
|
||||
secondQuestion: "multiemail",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and multiselect Question", () => {
|
||||
test("Address required and multiselect text required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Multi Select question (both required)",
|
||||
secondQuestion: "multiselect",
|
||||
options: { ...bookingOptions, isMultiSelect: true },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and multiselect text not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Multi Select question (only address required)",
|
||||
secondQuestion: "multiselect",
|
||||
options: { ...bookingOptions, isMultiSelect: true, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and Number Question", () => {
|
||||
test("Address required and Number required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("number", "number-test", "number test", true, "number test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Number question (both required)",
|
||||
secondQuestion: "number",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and Number not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("number", "number-test", "number test", false, "number test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Number question (only address required)",
|
||||
secondQuestion: "number",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and Phone Question", () => {
|
||||
test("Address required and Phone required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone-test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Multi Select question (both required)",
|
||||
secondQuestion: "phone",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and Phone not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", false, "phone-test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Multi Select question (only address required)",
|
||||
secondQuestion: "phone",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and Radio group Question", () => {
|
||||
test("Address required and Radio group required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("radio", "radio-test", "radio test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Radio question (both required)",
|
||||
secondQuestion: "radio",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and Radio group not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("radio", "radio-test", "radio test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Radio question (only address required)",
|
||||
secondQuestion: "radio",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and select Question", () => {
|
||||
test("Address required and select required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Select question (both required)",
|
||||
secondQuestion: "select",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and select not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", false, "select test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Select question (both required)",
|
||||
secondQuestion: "select",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Address Question and Short text question", () => {
|
||||
test("Address required and Short text required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("text", "text-test", "text test", true, "text test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Multi Select question (both required)",
|
||||
secondQuestion: "text",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Address and Short text not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.addQuestion("text", "text-test", "text test", false, "text test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "address",
|
||||
fillText: "Test Address question and Multi Select question (only address required)",
|
||||
secondQuestion: "text",
|
||||
options: { ...bookingOptions, isRequired: true },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,483 @@
|
||||
import { loginUser } from "../fixtures/regularBookings";
|
||||
import { test } from "../lib/fixtures";
|
||||
|
||||
test.describe("Booking With Long Text Question and Each Other Question", () => {
|
||||
const bookingOptions = { hasPlaceholder: true, isRequired: true };
|
||||
|
||||
test.beforeEach(async ({ page, users }) => {
|
||||
await loginUser(users);
|
||||
await page.goto("/event-types");
|
||||
});
|
||||
|
||||
test("Long Text and Address required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "textarea",
|
||||
fillText: "Test Long Text question and Address question (both required)",
|
||||
secondQuestion: "address",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Long Text required and Address not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", false, "address test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "textarea",
|
||||
fillText: "Test Long Text question and Address question (only Long Text required)",
|
||||
secondQuestion: "address",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test.describe("Booking With Long Text Question and Checkbox Group Question", () => {
|
||||
test("Long Text and Checkbox Group required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "textarea",
|
||||
fillText: "Test Long Text question and Checkbox Group question (both required)",
|
||||
secondQuestion: "checkbox",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Long Text required and Checkbox Group not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "textarea",
|
||||
fillText: "Test Long Text question and Checkbox Group question (only Long Text required)",
|
||||
secondQuestion: "checkbox",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Long Text Question and checkbox Question", () => {
|
||||
test("Long Text and checkbox required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "textarea",
|
||||
fillText: "Test Long Text question and Checkbox question (only Long Text required)",
|
||||
secondQuestion: "boolean",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Long Text required and checkbox not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "textarea",
|
||||
fillText: "Test Long Text question and Checkbox question (only Long Text required)",
|
||||
secondQuestion: "boolean",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Long Text Question and Multiple email Question", () => {
|
||||
const bookingOptions = { hasPlaceholder: true, isRequired: true };
|
||||
test("Long Text and Multiple email required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.addQuestion(
|
||||
"multiemail",
|
||||
"multiemail-test",
|
||||
"multiemail test",
|
||||
true,
|
||||
"multiemail test"
|
||||
);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "textarea",
|
||||
fillText: "Test Long Text question and Multiple email question (both required)",
|
||||
secondQuestion: "multiemail",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Long Text required and Multiple email not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.addQuestion(
|
||||
"multiemail",
|
||||
"multiemail-test",
|
||||
"multiemail test",
|
||||
false,
|
||||
"multiemail test"
|
||||
);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "textarea",
|
||||
fillText: "Test Long Text question and Multiple email question (only Long Text required)",
|
||||
secondQuestion: "multiemail",
|
||||
options: { hasPlaceholder: true, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Long Text Question and multiselect Question", () => {
|
||||
test("Long Text and multiselect text required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "textarea",
|
||||
fillText: "Test Long Text question and multiselect question (both required)",
|
||||
secondQuestion: "multiselect",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Long Text required and multiselect text not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "textarea",
|
||||
fillText: "Test Long Text question and multiselect question (only long text required)",
|
||||
secondQuestion: "multiselect",
|
||||
options: { hasPlaceholder: false, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Long Text Question and Number Question", () => {
|
||||
test("Long Text and Number required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "textarea",
|
||||
fillText: "Test Long Text question and Number question (both required)",
|
||||
secondQuestion: "multiselect",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test("Long Text required and Number not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "textarea",
|
||||
fillText: "Test Long Text question and Number question (only Long Textß required)",
|
||||
secondQuestion: "multiselect",
|
||||
options: { hasPlaceholder: false, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test.describe("Booking With Long Text Question and Phone Question", () => {
|
||||
test("Long Text and Phone required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "textarea",
|
||||
fillText: "Test Long Text question and Phone question (both required)",
|
||||
secondQuestion: "phone",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Long Text required and Phone not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", false, "phone test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "textarea",
|
||||
fillText: "Test Long Text question and Phone question (only Long Text required)",
|
||||
secondQuestion: "phone",
|
||||
options: { hasPlaceholder: false, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Long Text Question and Radio group Question", () => {
|
||||
test("Long Text and Radio group required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.addQuestion("radio", "radio-test", "radio test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "textarea",
|
||||
fillText: "Test Long Text question and Radio Group question (both required)",
|
||||
secondQuestion: "radio",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Long Text required and Radio group not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.addQuestion("radio", "radio-test", "radio test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "textarea",
|
||||
fillText: "Test Long Text question and Radio Group question (only Long Text required)",
|
||||
secondQuestion: "radio",
|
||||
options: { hasPlaceholder: false, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Long Text Question and select Question", () => {
|
||||
test("Long Text and select required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "textarea",
|
||||
fillText: "Test Long Text question and Select question (both required)",
|
||||
secondQuestion: "select",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Long Text required and select not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "textarea",
|
||||
fillText: "Test Long Text question and Select question (only Long Text required)",
|
||||
secondQuestion: "select",
|
||||
options: { hasPlaceholder: false, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Long Text Question and Short text question", () => {
|
||||
const bookingOptions = { hasPlaceholder: true, isRequired: true };
|
||||
test("Long Text and Short text required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.addQuestion("text", "text-test", "text test", true, "text test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "textarea",
|
||||
fillText: "Test Long Text question and Text question (both required)",
|
||||
secondQuestion: "text",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Long Text required and Short text not required", async ({ bookingPage }) => {
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.addQuestion("text", "text-test", "text test", false, "text test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "textarea",
|
||||
fillText: "Test Long Text question and Text question (only Long Text required)",
|
||||
secondQuestion: "text",
|
||||
options: { hasPlaceholder: false, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,447 @@
|
||||
import { loginUser } from "../fixtures/regularBookings";
|
||||
import { test } from "../lib/fixtures";
|
||||
|
||||
test.describe("Booking With Phone Question and Each Other Question", () => {
|
||||
const bookingOptions = { hasPlaceholder: true, isRequired: true };
|
||||
|
||||
test.beforeEach(async ({ page, users, bookingPage }) => {
|
||||
await loginUser(users);
|
||||
await page.goto("/event-types");
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
});
|
||||
|
||||
test.describe("Booking With Phone Question and Address Question", () => {
|
||||
test("Phone and Address required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "phone",
|
||||
fillText: "Test Phone question and Address question (both required)",
|
||||
secondQuestion: "address",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Phone required and Address not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", false, "address test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "phone",
|
||||
fillText: "Test Phone question and Address question (only phone required)",
|
||||
secondQuestion: "address",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test.describe("Booking With Phone Question and checkbox group Question", () => {
|
||||
const bookingOptions = { hasPlaceholder: false, isRequired: true };
|
||||
test("Phone and checkbox group required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "phone",
|
||||
fillText: "Test Phone question and checkbox group question (both required)",
|
||||
secondQuestion: "checkbox",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Phone required and checkbox group not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "phone",
|
||||
fillText: "Test Phone question and checkbox group question (only phone required)",
|
||||
secondQuestion: "checkbox",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Phone Question and checkbox Question", () => {
|
||||
test("Phone and checkbox required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "phone",
|
||||
fillText: "Test Phone question and checkbox question (both required)",
|
||||
secondQuestion: "boolean",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
test("Phone required and checkbox not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "phone",
|
||||
fillText: "Test Phone question and checkbox (only phone required)",
|
||||
secondQuestion: "boolean",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Phone Question and Long text Question", () => {
|
||||
test("Phone and Long text required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "phone",
|
||||
fillText: "Test Phone question and Long Text question (both required)",
|
||||
secondQuestion: "textarea",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Phone required and Long text not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", false, "textarea test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "phone",
|
||||
fillText: "Test Phone question and Long Text question (only phone required)",
|
||||
secondQuestion: "textarea",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Phone Question and Multi email Question", () => {
|
||||
const bookingOptions = { hasPlaceholder: true, isRequired: true };
|
||||
test("Phone and Multi email required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||
await bookingPage.addQuestion(
|
||||
"multiemail",
|
||||
"multiemail-test",
|
||||
"multiemail test",
|
||||
true,
|
||||
"multiemail test"
|
||||
);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "phone",
|
||||
fillText: "Test Phone question and Multi Email question (both required)",
|
||||
secondQuestion: "multiemail",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Phone required and Multi email not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||
await bookingPage.addQuestion(
|
||||
"multiemail",
|
||||
"multiemail-test",
|
||||
"multiemail test",
|
||||
false,
|
||||
"multiemail test"
|
||||
);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "phone",
|
||||
fillText: "Test Phone question and Multi Email question (only phone required)",
|
||||
secondQuestion: "multiemail",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Phone Question and multiselect Question", () => {
|
||||
test("Phone and multiselect text required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "phone",
|
||||
fillText: "Test Phone question and Multi Select question (both required)",
|
||||
secondQuestion: "multiselect",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Phone required and multiselect text not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "phone",
|
||||
fillText: "Test Phone question and Multi Select question (only phone required)",
|
||||
secondQuestion: "multiselect",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Phone Question and Number Question", () => {
|
||||
test("Phone and Number required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||
await bookingPage.addQuestion("number", "number-test", "number test", true, "number test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "phone",
|
||||
fillText: "Test Phone question and Number question (both required)",
|
||||
secondQuestion: "number",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Phone required and Number not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||
await bookingPage.addQuestion("number", "number-test", "number test", false, "number test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "phone",
|
||||
fillText: "Test Phone question and Number question (only phone required)",
|
||||
secondQuestion: "number",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Phone Question and Radio group Question", () => {
|
||||
test("Phone and Radio group required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||
await bookingPage.addQuestion("radio", "radio-test", "radio test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "phone",
|
||||
fillText: "Test Phone question and Radio question (both required)",
|
||||
secondQuestion: "radio",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Phone required and Radio group not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||
await bookingPage.addQuestion("radio", "radio-test", "radio test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "phone",
|
||||
fillText: "Test Phone question and Radio question (only phone required)",
|
||||
secondQuestion: "radio",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Phone Question and select Question", () => {
|
||||
test("Phone and select required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "phone",
|
||||
fillText: "Test Phone question and Select question (both required)",
|
||||
secondQuestion: "select",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Phone required and select not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", false, "select test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "phone",
|
||||
fillText: "Test Phone question and Select question (only phone required)",
|
||||
secondQuestion: "select",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Phone Question and Short text question", () => {
|
||||
const bookingOptions = { hasPlaceholder: true, isRequired: true };
|
||||
test("Phone and Short text required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||
await bookingPage.addQuestion("text", "text-test", "text test", true, "text test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "phone",
|
||||
fillText: "Test Phone question and Text question (both required)",
|
||||
secondQuestion: "text",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Phone required and Short text not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||
await bookingPage.addQuestion("text", "text-test", "text test", false, "text test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "phone",
|
||||
fillText: "Test Phone question and Text question (only phone required)",
|
||||
secondQuestion: "text",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,441 @@
|
||||
import { loginUser } from "../fixtures/regularBookings";
|
||||
import { test } from "../lib/fixtures";
|
||||
|
||||
test.describe("Booking With Phone Question and Each Other Question", () => {
|
||||
const bookingOptions = { hasPlaceholder: true, isRequired: true };
|
||||
|
||||
test.beforeEach(async ({ page, users, bookingPage }) => {
|
||||
await loginUser(users);
|
||||
await page.goto("/event-types");
|
||||
await bookingPage.goToEventType("30 min");
|
||||
await bookingPage.goToTab("event_advanced_tab_title");
|
||||
});
|
||||
|
||||
test.describe("Booking With Select Question and Address Question", () => {
|
||||
test("Select required and Address required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", true, "address test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and Address question (both required)",
|
||||
secondQuestion: "address",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Select and Address not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("address", "address-test", "address test", false, "address test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and Address question (only select required)",
|
||||
secondQuestion: "address",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Select Question and checkbox group Question", () => {
|
||||
test("Select required and checkbox group required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and Checkbox question (both required)",
|
||||
secondQuestion: "checkbox",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Select and checkbox group not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("checkbox", "checkbox-test", "checkbox test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and Checkbox question (only Select required)",
|
||||
secondQuestion: "checkbox",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Select Question and checkbox Question", () => {
|
||||
test("Select required and checkbox required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and boolean question (both required)",
|
||||
secondQuestion: "boolean",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Select and checkbox not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("boolean", "boolean-test", "boolean test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and boolean question (only select required)",
|
||||
secondQuestion: "boolean",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Select Question and Long text Question", () => {
|
||||
test("Select required and Long text required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", true, "textarea test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and textarea question (both required)",
|
||||
secondQuestion: "textarea",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Select and Long text not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("textarea", "textarea-test", "textarea test", false, "textarea test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and textarea question (only select required)",
|
||||
secondQuestion: "textarea",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Select Question and Multi email Question", () => {
|
||||
test("Select required and Multi email required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion(
|
||||
"multiemail",
|
||||
"multiemail-test",
|
||||
"multiemail test",
|
||||
true,
|
||||
"multiemail test"
|
||||
);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and multiemail question (both required)",
|
||||
secondQuestion: "multiemail",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Select and Multi email not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion(
|
||||
"multiemail",
|
||||
"multiemail-test",
|
||||
"multiemail test",
|
||||
false,
|
||||
"multiemail test"
|
||||
);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and multiemail question (only select required)",
|
||||
secondQuestion: "multiemail",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Select Question and multiselect Question", () => {
|
||||
test("Select required and multiselect text required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and multiselect question (both required)",
|
||||
secondQuestion: "multiselect",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Select and multiselect text not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("multiselect", "multiselect-test", "multiselect test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and multiselect question (only select required)",
|
||||
secondQuestion: "multiselect",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Select Question and Number Question", () => {
|
||||
test("Select required and Number required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("number", "number-test", "number test", true, "number test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and number question (both required)",
|
||||
secondQuestion: "number",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Select and Number not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("number", "number-test", "number test", false, "number test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and number question (only select required)",
|
||||
secondQuestion: "number",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Select Question and Phone Question", () => {
|
||||
test("Select required and select required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", true, "phone test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and phone question (both required)",
|
||||
secondQuestion: "phone",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Select and Phone not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("phone", "phone-test", "phone test", false, "phone test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and phone question (only select required)",
|
||||
secondQuestion: "phone",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Select Question and Radio group Question", () => {
|
||||
test("Select required and Radio group required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("radio", "radio-test", "radio test", true);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and radio question (both required)",
|
||||
secondQuestion: "radio",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Select and Radio group not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("radio", "radio-test", "radio test", false);
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and radio question (only select required)",
|
||||
secondQuestion: "radio",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Booking With Select Question and Short text question", () => {
|
||||
test("Select required and Short text required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("text", "text-test", "text test", true, "text test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and text question (both required)",
|
||||
secondQuestion: "text",
|
||||
options: bookingOptions,
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
|
||||
test("Select and Short text not required", async ({ bookingPage }) => {
|
||||
await bookingPage.addQuestion("select", "select-test", "select test", true, "select test");
|
||||
await bookingPage.addQuestion("text", "text-test", "text test", false, "text test");
|
||||
await bookingPage.updateEventType();
|
||||
const eventTypePage = await bookingPage.previewEventType();
|
||||
await bookingPage.selectTimeSlot(eventTypePage);
|
||||
await bookingPage.fillAndConfirmBooking({
|
||||
eventTypePage,
|
||||
placeholderText: "Please share anything that will help prepare for our meeting.",
|
||||
question: "select",
|
||||
fillText: "Test Select question and text question (only select required)",
|
||||
secondQuestion: "text",
|
||||
options: { ...bookingOptions, isRequired: false },
|
||||
});
|
||||
await bookingPage.rescheduleBooking(eventTypePage);
|
||||
await bookingPage.assertBookingRescheduled(eventTypePage);
|
||||
await bookingPage.cancelBooking(eventTypePage);
|
||||
await bookingPage.assertBookingCanceled(eventTypePage);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -43,9 +43,40 @@ test.describe("Change username on settings", () => {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(newUpdatedUser.username).toBe("demousernamex");
|
||||
});
|
||||
|
||||
test("User can change username to include periods(or dots)", async ({ page, users, prisma }) => {
|
||||
const user = await users.create();
|
||||
|
||||
await user.apiLogin();
|
||||
// Try to go homepage
|
||||
await page.goto("/settings/my-account/profile");
|
||||
// Change username from normal to normal
|
||||
const usernameInput = page.locator("[data-testid=username-input]");
|
||||
// User can change username to include dots(or periods)
|
||||
await usernameInput.fill("demo.username");
|
||||
await page.click("[data-testid=update-username-btn]");
|
||||
await Promise.all([
|
||||
page.click("[data-testid=save-username]"),
|
||||
page.getByTestId("toast-success").waitFor(),
|
||||
]);
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const updatedUser = await prisma.user.findUniqueOrThrow({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
expect(updatedUser.username).toBe("demo.username");
|
||||
|
||||
// Check if user avatar can be accessed and response headers contain 'image/' in the content type
|
||||
const response = await page.goto("/demo.username/avatar.png");
|
||||
expect(response?.headers()?.["content-type"]).toContain("image/");
|
||||
});
|
||||
|
||||
test("User can update to PREMIUM username", async ({ page, users }, testInfo) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!IS_STRIPE_ENABLED, "It should only run if Stripe is installed");
|
||||
|
||||
@@ -13,7 +13,7 @@ test("dynamic booking", async ({ page, users }) => {
|
||||
const pro = await users.create();
|
||||
await pro.apiLogin();
|
||||
|
||||
const free = await users.create({ username: "free" });
|
||||
const free = await users.create({ username: "free.example" });
|
||||
await page.goto(`/${pro.username}+${free.username}`);
|
||||
|
||||
await test.step("book an event first day in next month", async () => {
|
||||
|
||||
@@ -115,23 +115,13 @@ test.describe("Event Types tests", () => {
|
||||
|
||||
const locationData = ["location 1", "location 2", "location 3"];
|
||||
|
||||
const fillLocation = async (inputText: string) => {
|
||||
await page.locator("#location-select").click();
|
||||
await page.locator("text=In Person (Organizer Address)").click();
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(1000);
|
||||
await page.locator('input[name="locationAddress"]').fill(inputText);
|
||||
await page.locator("[data-testid=display-location]").check();
|
||||
await page.locator("[data-testid=update-location]").click();
|
||||
};
|
||||
|
||||
await fillLocation(locationData[0]);
|
||||
await fillLocation(page, locationData[0], 0);
|
||||
|
||||
await page.locator("[data-testid=add-location]").click();
|
||||
await fillLocation(locationData[1]);
|
||||
await fillLocation(page, locationData[1], 1);
|
||||
|
||||
await page.locator("[data-testid=add-location]").click();
|
||||
await fillLocation(locationData[2]);
|
||||
await fillLocation(page, locationData[2], 2);
|
||||
|
||||
await page.locator("[data-testid=update-eventtype]").click();
|
||||
|
||||
@@ -177,6 +167,93 @@ test.describe("Event Types tests", () => {
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
await expect(page.locator("text=+19199999999")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Can add Organzer Phone Number location and book with it", async ({ page }) => {
|
||||
await gotoFirstEventType(page);
|
||||
|
||||
await page.locator("#location-select").click();
|
||||
await page.locator(`text="Organizer Phone Number"`).click();
|
||||
const locationInputName = "locations[0].hostPhoneNumber";
|
||||
await page.locator(`input[name="${locationInputName}"]`).waitFor();
|
||||
await page.locator(`input[name="${locationInputName}"]`).fill("9199999999");
|
||||
|
||||
await saveEventType(page);
|
||||
await gotoBookingPage(page);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await bookTimeSlot(page);
|
||||
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
await expect(page.locator("text=+19199999999")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Can add Cal video location and book with it", async ({ page }) => {
|
||||
await gotoFirstEventType(page);
|
||||
|
||||
await page.locator("#location-select").click();
|
||||
await page.locator(`text="Cal Video (Global)"`).click();
|
||||
|
||||
await saveEventType(page);
|
||||
await page.getByTestId("toast-success").waitFor();
|
||||
await gotoBookingPage(page);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await bookTimeSlot(page);
|
||||
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
await expect(page.locator("[data-testid=where] ")).toContainText("Cal Video");
|
||||
});
|
||||
|
||||
test("Can add Link Meeting as location and book with it", async ({ page }) => {
|
||||
await gotoFirstEventType(page);
|
||||
|
||||
await page.locator("#location-select").click();
|
||||
await page.locator(`text="Link meeting"`).click();
|
||||
|
||||
const locationInputName = `locations[0].link`;
|
||||
|
||||
const testUrl = "https://cal.ai/";
|
||||
await page.locator(`input[name="${locationInputName}"]`).fill(testUrl);
|
||||
|
||||
await saveEventType(page);
|
||||
await page.getByTestId("toast-success").waitFor();
|
||||
await gotoBookingPage(page);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await bookTimeSlot(page);
|
||||
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
const linkElement = await page.locator("[data-testid=where] > a");
|
||||
expect(await linkElement.getAttribute("href")).toBe(testUrl);
|
||||
});
|
||||
|
||||
test("Can remove location from multiple locations that are saved", async ({ page }) => {
|
||||
await gotoFirstEventType(page);
|
||||
|
||||
// Add Attendee Phone Number location
|
||||
await selectAttendeePhoneNumber(page);
|
||||
|
||||
// Add Cal Video location
|
||||
await addAnotherLocation(page, "Cal Video (Global)");
|
||||
|
||||
await saveEventType(page);
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
// Remove Attendee Phone Number Location
|
||||
const removeButtomId = "delete-locations.0.type";
|
||||
await page.getByTestId(removeButtomId).click();
|
||||
|
||||
await saveEventType(page);
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
await gotoBookingPage(page);
|
||||
await selectFirstAvailableTimeSlotNextMonth(page);
|
||||
|
||||
await bookTimeSlot(page);
|
||||
|
||||
await expect(page.locator("[data-testid=success-page]")).toBeVisible();
|
||||
await expect(page.locator("[data-testid=where]")).toHaveText(/Cal Video/);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -205,3 +282,26 @@ async function gotoBookingPage(page: Page) {
|
||||
|
||||
await page.goto(previewLink ?? "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds n+1 location to the event type
|
||||
*/
|
||||
async function addAnotherLocation(page: Page, locationOptionText: string) {
|
||||
await page.locator("[data-testid=add-location]").click();
|
||||
// When adding another location, the dropdown opens automatically. So, we don't need to open it here.
|
||||
//
|
||||
await page.locator(`text="${locationOptionText}"`).click();
|
||||
}
|
||||
|
||||
const fillLocation = async (page: Page, inputText: string, index: number) => {
|
||||
// Except the first location, dropdown automatically opens when adding another location
|
||||
if (index == 0) {
|
||||
await page.locator("#location-select").last().click();
|
||||
}
|
||||
await page.locator("text=In Person (Organizer Address)").last().click();
|
||||
|
||||
const locationInputName = `locations[${index}].address`;
|
||||
await page.locator(`input[name="${locationInputName}"]`).waitFor();
|
||||
await page.locator(`input[name="locations[${index}].address"]`).fill(inputText);
|
||||
await page.locator("[data-testid=display-location]").last().check();
|
||||
};
|
||||
|
||||
@@ -3,13 +3,16 @@ import type { Booking, Prisma } from "@prisma/client";
|
||||
import short from "short-uuid";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import _dayjs from "@calcom/dayjs";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
const translator = short();
|
||||
|
||||
type BookingFixture = ReturnType<typeof createBookingFixture>;
|
||||
|
||||
// We default all dayjs calls to use Europe/London timezone
|
||||
const dayjs = (...args: Parameters<typeof _dayjs>) => _dayjs(...args).tz("Europe/London");
|
||||
|
||||
// creates a user fixture instance and stores the collection
|
||||
export const createBookingsFixture = (page: Page) => {
|
||||
const store = { bookings: [], page } as { bookings: BookingFixture[]; page: typeof page };
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import type { Team } from "@prisma/client";
|
||||
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
const getRandomSlug = () => `org-${Math.random().toString(36).substring(7)}`;
|
||||
|
||||
// creates a user fixture instance and stores the collection
|
||||
export const createOrgsFixture = (page: Page) => {
|
||||
const store = { orgs: [], page } as { orgs: Team[]; page: typeof page };
|
||||
return {
|
||||
create: async (opts: { name: string; slug?: string; requestedSlug?: string }) => {
|
||||
const org = await createOrgInDb({
|
||||
name: opts.name,
|
||||
slug: opts.slug || getRandomSlug(),
|
||||
requestedSlug: opts.requestedSlug,
|
||||
});
|
||||
store.orgs.push(org);
|
||||
return org;
|
||||
},
|
||||
get: () => store.orgs,
|
||||
deleteAll: async () => {
|
||||
await prisma.team.deleteMany({ where: { id: { in: store.orgs.map((org) => org.id) } } });
|
||||
store.orgs = [];
|
||||
},
|
||||
delete: async (id: number) => {
|
||||
await prisma.team.delete({ where: { id } });
|
||||
store.orgs = store.orgs.filter((b) => b.id !== id);
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
async function createOrgInDb({
|
||||
name,
|
||||
slug,
|
||||
requestedSlug,
|
||||
}: {
|
||||
name: string;
|
||||
slug: string | null;
|
||||
requestedSlug?: string;
|
||||
}) {
|
||||
return await prisma.team.create({
|
||||
data: {
|
||||
name: name,
|
||||
slug: slug,
|
||||
metadata: {
|
||||
isOrganization: true,
|
||||
...(requestedSlug
|
||||
? {
|
||||
requestedSlug,
|
||||
}
|
||||
: null),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
|
||||
import type { createUsersFixture } from "./users";
|
||||
|
||||
const reschedulePlaceholderText = "Let others know why you need to reschedule";
|
||||
export const scheduleSuccessfullyText = "This meeting is scheduled";
|
||||
|
||||
const EMAIL = "test@test.com";
|
||||
const EMAIL2 = "test2@test.com";
|
||||
const PHONE = "+55 (32) 983289947";
|
||||
|
||||
type BookingOptions = {
|
||||
hasPlaceholder?: boolean;
|
||||
isReschedule?: boolean;
|
||||
isRequired?: boolean;
|
||||
isMultiSelect?: boolean;
|
||||
};
|
||||
|
||||
interface QuestionActions {
|
||||
[key: string]: () => Promise<void>;
|
||||
}
|
||||
|
||||
type customLocators = {
|
||||
shouldChangeSelectLocator: boolean;
|
||||
shouldUseLastRadioGroupLocator: boolean;
|
||||
shouldUseFirstRadioGroupLocator: boolean;
|
||||
shouldChangeMultiSelectLocator: boolean;
|
||||
};
|
||||
|
||||
type fillAndConfirmBookingParams = {
|
||||
eventTypePage: Page;
|
||||
placeholderText: string;
|
||||
question: string;
|
||||
fillText: string;
|
||||
secondQuestion: string;
|
||||
options: BookingOptions;
|
||||
};
|
||||
|
||||
type UserFixture = ReturnType<typeof createUsersFixture>;
|
||||
|
||||
function isLastDayOfMonth(): boolean {
|
||||
const today = dayjs();
|
||||
const endOfMonth = today.endOf("month");
|
||||
return today.isSame(endOfMonth, "day");
|
||||
}
|
||||
|
||||
const fillQuestion = async (eventTypePage: Page, questionType: string, customLocators: customLocators) => {
|
||||
const questionActions: QuestionActions = {
|
||||
phone: async () => {
|
||||
await eventTypePage.locator('input[name="phone-test"]').clear();
|
||||
await eventTypePage.locator('input[name="phone-test"]').fill(PHONE);
|
||||
},
|
||||
multiemail: async () => {
|
||||
await eventTypePage.getByRole("button", { name: `${questionType} test` }).click();
|
||||
await eventTypePage.getByPlaceholder(`${questionType} test`).fill(EMAIL);
|
||||
await eventTypePage.getByTestId("add-another-guest").last().click();
|
||||
await eventTypePage.getByPlaceholder(`${questionType} test`).last().fill(EMAIL2);
|
||||
},
|
||||
checkbox: async () => {
|
||||
if (customLocators.shouldUseLastRadioGroupLocator || customLocators.shouldChangeMultiSelectLocator) {
|
||||
await eventTypePage.getByLabel("Option 1").last().click();
|
||||
await eventTypePage.getByLabel("Option 2").last().click();
|
||||
} else if (customLocators.shouldUseFirstRadioGroupLocator) {
|
||||
await eventTypePage.getByLabel("Option 1").first().click();
|
||||
await eventTypePage.getByLabel("Option 2").first().click();
|
||||
} else {
|
||||
await eventTypePage.getByLabel("Option 1").click();
|
||||
await eventTypePage.getByLabel("Option 2").click();
|
||||
}
|
||||
},
|
||||
multiselect: async () => {
|
||||
if (customLocators.shouldChangeMultiSelectLocator) {
|
||||
await eventTypePage.locator("form svg").nth(1).click();
|
||||
await eventTypePage.getByTestId("select-option-Option 1").click();
|
||||
} else {
|
||||
await eventTypePage.locator("form svg").last().click();
|
||||
await eventTypePage.getByTestId("select-option-Option 1").click();
|
||||
}
|
||||
},
|
||||
boolean: async () => {
|
||||
await eventTypePage.getByLabel(`${questionType} test`).check();
|
||||
},
|
||||
radio: async () => {
|
||||
await eventTypePage.locator('[id="radio-test\\.option\\.0\\.radio"]').click();
|
||||
},
|
||||
select: async () => {
|
||||
if (customLocators.shouldChangeSelectLocator) {
|
||||
await eventTypePage.locator("form svg").nth(1).click();
|
||||
await eventTypePage.getByTestId("select-option-Option 1").click();
|
||||
} else {
|
||||
await eventTypePage.locator("form svg").last().click();
|
||||
await eventTypePage.getByTestId("select-option-Option 1").click();
|
||||
}
|
||||
},
|
||||
number: async () => {
|
||||
await eventTypePage.getByPlaceholder(`${questionType} test`).click();
|
||||
await eventTypePage.getByPlaceholder(`${questionType} test`).fill("123");
|
||||
},
|
||||
address: async () => {
|
||||
await eventTypePage.getByPlaceholder(`${questionType} test`).click();
|
||||
await eventTypePage.getByPlaceholder(`${questionType} test`).fill("address test");
|
||||
},
|
||||
textarea: async () => {
|
||||
await eventTypePage.getByPlaceholder(`${questionType} test`).click();
|
||||
await eventTypePage.getByPlaceholder(`${questionType} test`).fill("textarea test");
|
||||
},
|
||||
text: async () => {
|
||||
await eventTypePage.getByPlaceholder(`${questionType} test`).click();
|
||||
await eventTypePage.getByPlaceholder(`${questionType} test`).fill("text test");
|
||||
},
|
||||
};
|
||||
if (questionActions[questionType]) {
|
||||
await questionActions[questionType]();
|
||||
}
|
||||
};
|
||||
|
||||
export async function loginUser(users: UserFixture) {
|
||||
const pro = await users.create({ name: "testuser" });
|
||||
await pro.apiLogin();
|
||||
}
|
||||
|
||||
const goToNextMonthIfNoAvailabilities = async (eventTypePage: Page) => {
|
||||
try {
|
||||
if (isLastDayOfMonth()) {
|
||||
await eventTypePage.getByTestId("view_next_month").waitFor({ timeout: 6000 });
|
||||
await eventTypePage.getByTestId("view_next_month").click();
|
||||
}
|
||||
} catch (err) {
|
||||
console.info("No need to click on view next month button");
|
||||
}
|
||||
};
|
||||
|
||||
export function createBookingPageFixture(page: Page) {
|
||||
return {
|
||||
goToEventType: async (eventType: string) => {
|
||||
await page.getByRole("link", { name: eventType }).click();
|
||||
},
|
||||
goToTab: async (tabName: string) => {
|
||||
await page.getByTestId(`vertical-tab-${tabName}`).click();
|
||||
},
|
||||
addQuestion: async (
|
||||
questionType: string,
|
||||
identifier: string,
|
||||
label: string,
|
||||
isRequired: boolean,
|
||||
placeholder?: string
|
||||
) => {
|
||||
await page.getByTestId("add-field").click();
|
||||
await page.locator("#test-field-type > .bg-default > div > div:nth-child(2)").first().click();
|
||||
await page.getByTestId(`select-option-${questionType}`).click();
|
||||
await page.getByLabel("Identifier").dblclick();
|
||||
await page.getByLabel("Identifier").fill(identifier);
|
||||
await page.getByLabel("Label").click();
|
||||
await page.getByLabel("Label").fill(label);
|
||||
if (placeholder) {
|
||||
await page.getByLabel("Placeholder").click();
|
||||
await page.getByLabel("Placeholder").fill(placeholder);
|
||||
}
|
||||
if (!isRequired) {
|
||||
await page.getByRole("radio", { name: "No" }).click();
|
||||
}
|
||||
await page.getByTestId("field-add-save").click();
|
||||
},
|
||||
updateEventType: async () => {
|
||||
await page.getByTestId("update-eventtype").click();
|
||||
},
|
||||
previewEventType: async () => {
|
||||
const eventtypePromise = page.waitForEvent("popup");
|
||||
await page.getByTestId("preview-button").click();
|
||||
return eventtypePromise;
|
||||
},
|
||||
selectTimeSlot: async (eventTypePage: Page) => {
|
||||
await goToNextMonthIfNoAvailabilities(eventTypePage);
|
||||
await eventTypePage.getByTestId("time").first().click();
|
||||
},
|
||||
clickReschedule: async () => {
|
||||
await page.getByText("Reschedule").click();
|
||||
},
|
||||
|
||||
selectFirstAvailableTime: async () => {
|
||||
await page.getByTestId("time").first().click();
|
||||
},
|
||||
fillRescheduleReasonAndConfirm: async () => {
|
||||
await page.getByPlaceholder(reschedulePlaceholderText).click();
|
||||
await page.getByPlaceholder(reschedulePlaceholderText).fill("Test reschedule");
|
||||
await page.getByTestId("confirm-reschedule-button").click();
|
||||
},
|
||||
|
||||
cancelBookingWithReason: async (page: Page) => {
|
||||
await page.getByTestId("cancel").click();
|
||||
await page.getByTestId("cancel_reason").fill("Test cancel");
|
||||
await page.getByTestId("confirm_cancel").click();
|
||||
},
|
||||
assertBookingCanceled: async (page: Page) => {
|
||||
await expect(page.getByTestId("cancelled-headline")).toBeVisible();
|
||||
},
|
||||
|
||||
rescheduleBooking: async (eventTypePage: Page) => {
|
||||
await goToNextMonthIfNoAvailabilities(eventTypePage);
|
||||
await eventTypePage.getByText("Reschedule").click();
|
||||
while (await eventTypePage.getByRole("button", { name: "View next" }).isVisible()) {
|
||||
await eventTypePage.getByRole("button", { name: "View next" }).click();
|
||||
}
|
||||
await eventTypePage.getByTestId("time").first().click();
|
||||
await eventTypePage.getByPlaceholder(reschedulePlaceholderText).click();
|
||||
await eventTypePage.getByPlaceholder(reschedulePlaceholderText).fill("Test reschedule");
|
||||
await eventTypePage.getByTestId("confirm-reschedule-button").click();
|
||||
},
|
||||
|
||||
assertBookingRescheduled: async (page: Page) => {
|
||||
await expect(page.getByText(scheduleSuccessfullyText)).toBeVisible();
|
||||
},
|
||||
|
||||
cancelBooking: async (eventTypePage: Page) => {
|
||||
await eventTypePage.getByTestId("cancel").click();
|
||||
await eventTypePage.getByTestId("cancel_reason").fill("Test cancel");
|
||||
await eventTypePage.getByTestId("confirm_cancel").click();
|
||||
await expect(eventTypePage.getByTestId("cancelled-headline")).toBeVisible();
|
||||
},
|
||||
|
||||
fillAndConfirmBooking: async ({
|
||||
eventTypePage,
|
||||
placeholderText,
|
||||
question,
|
||||
fillText,
|
||||
secondQuestion,
|
||||
options,
|
||||
}: fillAndConfirmBookingParams) => {
|
||||
const confirmButton = options.isReschedule ? "confirm-reschedule-button" : "confirm-book-button";
|
||||
|
||||
await expect(eventTypePage.getByText(`${secondQuestion} test`).first()).toBeVisible();
|
||||
await eventTypePage.getByPlaceholder(placeholderText).fill(fillText);
|
||||
|
||||
// Change the selector for specifics cases related to select question
|
||||
const shouldChangeSelectLocator = (question: string, secondQuestion: string): boolean =>
|
||||
question === "select" && ["multiemail", "multiselect", "address"].includes(secondQuestion);
|
||||
|
||||
const shouldUseLastRadioGroupLocator = (question: string, secondQuestion: string): boolean =>
|
||||
question === "radio" && secondQuestion === "checkbox";
|
||||
|
||||
const shouldUseFirstRadioGroupLocator = (question: string, secondQuestion: string): boolean =>
|
||||
question === "checkbox" && secondQuestion === "radio";
|
||||
|
||||
const shouldChangeMultiSelectLocator = (question: string, secondQuestion: string): boolean =>
|
||||
question === "multiselect" &&
|
||||
["address", "checkbox", "multiemail", "select"].includes(secondQuestion);
|
||||
|
||||
const customLocators = {
|
||||
shouldChangeSelectLocator: shouldChangeSelectLocator(question, secondQuestion),
|
||||
shouldUseLastRadioGroupLocator: shouldUseLastRadioGroupLocator(question, secondQuestion),
|
||||
shouldUseFirstRadioGroupLocator: shouldUseFirstRadioGroupLocator(question, secondQuestion),
|
||||
shouldChangeMultiSelectLocator: shouldChangeMultiSelectLocator(question, secondQuestion),
|
||||
};
|
||||
|
||||
// Fill the first question
|
||||
await fillQuestion(eventTypePage, question, customLocators);
|
||||
|
||||
// Fill the second question if is required
|
||||
options.isRequired && (await fillQuestion(eventTypePage, secondQuestion, customLocators));
|
||||
|
||||
await eventTypePage.getByTestId(confirmButton).click();
|
||||
const scheduleSuccessfullyPage = eventTypePage.getByText(scheduleSuccessfullyText);
|
||||
await scheduleSuccessfullyPage.waitFor({ state: "visible" });
|
||||
await expect(scheduleSuccessfullyPage).toBeVisible();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -4,14 +4,15 @@ import { Prisma as PrismaType } from "@prisma/client";
|
||||
import { hashSync as hash } from "bcryptjs";
|
||||
import type { API } from "mailhog";
|
||||
|
||||
import dayjs from "@calcom/dayjs";
|
||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { MembershipRole, SchedulingType } from "@calcom/prisma/enums";
|
||||
import { teamMetadataSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
import { selectFirstAvailableTimeSlotNextMonth, teamEventSlug, teamEventTitle } from "../lib/testUtils";
|
||||
import type { TimeZoneEnum } from "./types";
|
||||
import { TimeZoneEnum } from "./types";
|
||||
|
||||
// Don't import hashPassword from app as that ends up importing next-auth and initializing it before NEXTAUTH_URL can be updated during tests.
|
||||
export function hashPassword(password: string) {
|
||||
@@ -78,11 +79,13 @@ const createTeamAndAddUser = async (
|
||||
isUnpublished,
|
||||
isOrg,
|
||||
hasSubteam,
|
||||
organizationId,
|
||||
}: {
|
||||
user: { id: number; username: string | null; role?: MembershipRole };
|
||||
isUnpublished?: boolean;
|
||||
isOrg?: boolean;
|
||||
hasSubteam?: true;
|
||||
organizationId?: number | null;
|
||||
},
|
||||
workerInfo: WorkerInfo
|
||||
) => {
|
||||
@@ -101,6 +104,7 @@ const createTeamAndAddUser = async (
|
||||
data.children = { connect: [{ id: team.id }] };
|
||||
}
|
||||
data.orgUsers = isOrg ? { connect: [{ id: user.id }] } : undefined;
|
||||
data.parent = organizationId ? { connect: { id: organizationId } } : undefined;
|
||||
const team = await prisma.team.create({
|
||||
data,
|
||||
});
|
||||
@@ -114,6 +118,7 @@ const createTeamAndAddUser = async (
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
return team;
|
||||
};
|
||||
|
||||
@@ -282,12 +287,14 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
|
||||
isUnpublished: scenario.isUnpublished,
|
||||
isOrg: scenario.isOrg,
|
||||
hasSubteam: scenario.hasSubteam,
|
||||
organizationId: opts?.organizationId,
|
||||
},
|
||||
workerInfo
|
||||
);
|
||||
const teamEvent = await createTeamEventType(user, team, scenario);
|
||||
if (scenario.teammates) {
|
||||
// Create Teammate users
|
||||
const teamMatesIds = [];
|
||||
for (const teammateObj of scenario.teammates) {
|
||||
const teamUser = await prisma.user.create({
|
||||
data: createUser(workerInfo, teammateObj),
|
||||
@@ -319,8 +326,22 @@ export const createUsersFixture = (page: Page, emails: API | undefined, workerIn
|
||||
}),
|
||||
store.page
|
||||
);
|
||||
teamMatesIds.push(teamUser.id);
|
||||
store.users.push(teammateFixture);
|
||||
}
|
||||
// Add Teammates to OrgUsers
|
||||
if (scenario.isOrg) {
|
||||
await prisma.team.update({
|
||||
where: {
|
||||
id: team.id,
|
||||
},
|
||||
data: {
|
||||
orgUsers: {
|
||||
connect: teamMatesIds.map((userId) => ({ id: userId })).concat([{ id: user.id }]),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
const userFixture = createUserFixture(user, store.page);
|
||||
@@ -384,11 +405,27 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
||||
logout: async () => {
|
||||
await page.goto("/auth/logout");
|
||||
},
|
||||
getTeam: async () => {
|
||||
return prisma.membership.findFirstOrThrow({
|
||||
getFirstTeam: async () => {
|
||||
const memberships = await prisma.membership.findMany({
|
||||
where: { userId: user.id },
|
||||
include: { team: true },
|
||||
});
|
||||
|
||||
const membership = memberships
|
||||
.map((membership) => {
|
||||
return {
|
||||
...membership,
|
||||
team: {
|
||||
...membership.team,
|
||||
metadata: teamMetadataSchema.parse(membership.team.metadata),
|
||||
},
|
||||
};
|
||||
})
|
||||
.find((membership) => !membership.team?.metadata?.isOrganization);
|
||||
if (!membership) {
|
||||
throw new Error("No team found for user");
|
||||
}
|
||||
return membership;
|
||||
},
|
||||
getOrg: async () => {
|
||||
return prisma.membership.findFirstOrThrow({
|
||||
@@ -438,16 +475,27 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => {
|
||||
type SupportedTestEventTypes = PrismaType.EventTypeCreateInput & {
|
||||
_bookings?: PrismaType.BookingCreateInput[];
|
||||
};
|
||||
type CustomUserOptsKeys = "username" | "password" | "completedOnboarding" | "locale" | "name" | "email";
|
||||
type CustomUserOptsKeys =
|
||||
| "username"
|
||||
| "password"
|
||||
| "completedOnboarding"
|
||||
| "locale"
|
||||
| "name"
|
||||
| "email"
|
||||
| "organizationId";
|
||||
type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
|
||||
timeZone?: TimeZoneEnum;
|
||||
eventTypes?: SupportedTestEventTypes[];
|
||||
// ignores adding the worker-index after username
|
||||
useExactUsername?: boolean;
|
||||
roleInOrganization?: MembershipRole;
|
||||
};
|
||||
|
||||
// creates the actual user in the db.
|
||||
const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): PrismaType.UserCreateInput => {
|
||||
const createUser = (
|
||||
workerInfo: WorkerInfo,
|
||||
opts?: CustomUserOpts | null
|
||||
): PrismaType.UserUncheckedCreateInput => {
|
||||
// build a unique name for our user
|
||||
const uname =
|
||||
opts?.useExactUsername && opts?.username
|
||||
@@ -461,13 +509,15 @@ const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): Prism
|
||||
password: hashPassword(uname),
|
||||
emailVerified: new Date(),
|
||||
completedOnboarding: opts?.completedOnboarding ?? true,
|
||||
timeZone: opts?.timeZone ?? dayjs.tz.guess(),
|
||||
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
|
||||
locale: opts?.locale ?? "en",
|
||||
...getOrganizationRelatedProps({ organizationId: opts?.organizationId, role: opts?.roleInOrganization }),
|
||||
schedules:
|
||||
opts?.completedOnboarding ?? true
|
||||
? {
|
||||
create: {
|
||||
name: "Working Hours",
|
||||
timeZone: opts?.timeZone ?? TimeZoneEnum.UK,
|
||||
availability: {
|
||||
createMany: {
|
||||
data: getAvailabilityFromSchedule(DEFAULT_SCHEDULE),
|
||||
@@ -477,6 +527,42 @@ const createUser = (workerInfo: WorkerInfo, opts?: CustomUserOpts | null): Prism
|
||||
}
|
||||
: undefined,
|
||||
};
|
||||
|
||||
function getOrganizationRelatedProps({
|
||||
organizationId,
|
||||
role,
|
||||
}: {
|
||||
organizationId: number | null | undefined;
|
||||
role: MembershipRole | undefined;
|
||||
}) {
|
||||
if (!organizationId) {
|
||||
return null;
|
||||
}
|
||||
if (!role) {
|
||||
throw new Error("Missing role for user in organization");
|
||||
}
|
||||
return {
|
||||
organizationId: organizationId || null,
|
||||
...(organizationId
|
||||
? {
|
||||
teams: {
|
||||
// Create membership
|
||||
create: [
|
||||
{
|
||||
team: {
|
||||
connect: {
|
||||
id: organizationId,
|
||||
},
|
||||
},
|
||||
accepted: true,
|
||||
role: MembershipRole.ADMIN,
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
: null),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
async function confirmPendingPayment(page: Page) {
|
||||
@@ -543,7 +629,7 @@ export async function apiLogin(
|
||||
const data = {
|
||||
email: user.email ?? `${user.username}@example.com`,
|
||||
password: user.password ?? user.username,
|
||||
callbackURL: "http://localhost:3000/",
|
||||
callbackURL: WEBAPP_URL,
|
||||
redirect: "false",
|
||||
json: "true",
|
||||
csrfToken,
|
||||
|
||||
@@ -236,4 +236,34 @@ test.describe("Insights", async () => {
|
||||
// expect for "Team: test-insight" text in page
|
||||
expect(await page.locator("text=Team: test-insights").isVisible()).toBeTruthy();
|
||||
});
|
||||
|
||||
test("should test download button", async ({ page, users }) => {
|
||||
const owner = await users.create();
|
||||
const member = await users.create();
|
||||
|
||||
await createTeamsAndMembership(owner.id, member.id);
|
||||
|
||||
await owner.apiLogin();
|
||||
|
||||
await page.goto("/insights");
|
||||
await page.waitForLoadState("networkidle");
|
||||
|
||||
const downloadPromise = page.waitForEvent("download");
|
||||
|
||||
// Expect download button to be visible
|
||||
expect(await page.locator("text=Download").isVisible()).toBeTruthy();
|
||||
|
||||
// Click on Download button
|
||||
await page.getByText("Download").click();
|
||||
|
||||
// Expect as csv option to be visible
|
||||
expect(await page.locator("text=as CSV").isVisible()).toBeTruthy();
|
||||
|
||||
// Start waiting for download before clicking. Note no await.
|
||||
await page.getByText("as CSV").click();
|
||||
const download = await downloadPromise;
|
||||
|
||||
// Wait for the download process to complete and save the downloaded file somewhere.
|
||||
await download.saveAs("./" + "test-insights.csv");
|
||||
});
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user