diff --git a/apps/api/lib/validations/user.ts b/apps/api/lib/validations/user.ts index 107db36ba6..de9e22a976 100644 --- a/apps/api/lib/validations/user.ts +++ b/apps/api/lib/validations/user.ts @@ -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, diff --git a/apps/api/pages/api/users/[userId]/_patch.ts b/apps/api/pages/api/users/[userId]/_patch.ts index 59d8b76f94..84f6ffb45b 100644 --- a/apps/api/pages/api/users/[userId]/_patch.ts +++ b/apps/api/pages/api/users/[userId]/_patch.ts @@ -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({ diff --git a/apps/api/pages/api/users/_post.ts b/apps/api/pages/api/users/_post.ts index 7c945399d0..15c68aa31d 100644 --- a/apps/api/pages/api/users/_post.ts +++ b/apps/api/pages/api/users/_post.ts @@ -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 diff --git a/apps/web/next.config.js b/apps/web/next.config.js index 262e1f6e5a..22da1946e5 100644 --- a/apps/web/next.config.js +++ b/apps/web/next.config.js @@ -235,7 +235,7 @@ const nextConfig = { ? [ { ...matcherConfigRootPath, - destination: "/team/:orgSlug", + destination: "/team/:orgSlug?isOrgProfile=1", }, { ...matcherConfigUserRoute, diff --git a/apps/web/package.json b/apps/web/package.json index 6ba03c5b9b..245026e66e 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "@calcom/web", - "version": "3.4.1", + "version": "3.4.2", "private": true, "scripts": { "analyze": "ANALYZE=true next build", diff --git a/apps/web/pages/auth/logout.tsx b/apps/web/pages/auth/logout.tsx index 43990d6eff..b0f5d87d20 100644 --- a/apps/web/pages/auth/logout.tsx +++ b/apps/web/pages/auth/logout.tsx @@ -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; export function Logout(props: Props) { + const [btnLoading, setBtnLoading] = useState(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 (
@@ -50,7 +56,11 @@ export function Logout(props: Props) {
-
diff --git a/apps/web/pages/team/[slug].tsx b/apps/web/pages/team/[slug].tsx index 3d6c4bc7bb..73c69acd2f 100644 --- a/apps/web/pages/team/[slug].tsx +++ b/apps/web/pages/team/[slug].tsx @@ -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"; @@ -12,6 +18,7 @@ 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"; @@ -34,7 +41,7 @@ import { ssrInit } from "@server/lib/ssr"; import { getTemporaryOrgRedirect } from "../../lib/getTemporaryOrgRedirect"; export type PageProps = inferSSRProps; - +const log = logger.getSubLogger({ prefix: ["team/[slug]"] }); function TeamPage({ team, isUnpublished, @@ -277,12 +284,23 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => ); 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) { @@ -299,17 +317,12 @@ export const getServerSideProps = async (context: GetServerSidePropsContext) => 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; } diff --git a/apps/web/playwright/booking/phoneQuestion.e2e.ts b/apps/web/playwright/booking/phoneQuestion.e2e.ts new file mode 100644 index 0000000000..f8236c34ff --- /dev/null +++ b/apps/web/playwright/booking/phoneQuestion.e2e.ts @@ -0,0 +1,387 @@ +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.cancelAndRescheduleBooking(eventTypePage); + }); + + test("Phone 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.cancelAndRescheduleBooking(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.cancelAndRescheduleBooking(eventTypePage); + }); + + test("Phone 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.cancelAndRescheduleBooking(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.cancelAndRescheduleBooking(eventTypePage); + }); + test("Phone 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.cancelAndRescheduleBooking(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.cancelAndRescheduleBooking(eventTypePage); + }); + + test("Phone 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.cancelAndRescheduleBooking(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.cancelAndRescheduleBooking(eventTypePage); + }); + + test("Phone 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.cancelAndRescheduleBooking(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.cancelAndRescheduleBooking(eventTypePage); + }); + + test("Phone 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.cancelAndRescheduleBooking(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.cancelAndRescheduleBooking(eventTypePage); + }); + + test("Phone 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.cancelAndRescheduleBooking(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.cancelAndRescheduleBooking(eventTypePage); + }); + + test("Phone 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.cancelAndRescheduleBooking(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.cancelAndRescheduleBooking(eventTypePage); + }); + + test("Phone 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.cancelAndRescheduleBooking(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.cancelAndRescheduleBooking(eventTypePage); + }); + + test("Phone 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.cancelAndRescheduleBooking(eventTypePage); + }); + }); + }); +}); diff --git a/apps/web/playwright/fixtures/regularBookings.ts b/apps/web/playwright/fixtures/regularBookings.ts new file mode 100644 index 0000000000..447debd83a --- /dev/null +++ b/apps/web/playwright/fixtures/regularBookings.ts @@ -0,0 +1,250 @@ +import { expect, type Page } from "@playwright/test"; + +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; +}; + +interface QuestionActions { + [key: string]: () => Promise; +} + +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; + +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(); +} + +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) => { + while (await eventTypePage.getByRole("button", { name: "View next" }).isVisible()) { + await eventTypePage.getByRole("button", { name: "View next" }).click(); + } + await eventTypePage.getByTestId("time").first().click(); + }, + clickReschedule: async () => { + await page.getByText("Reschedule").click(); + }, + navigateToAvailableTimeSlot: async () => { + while (await page.getByRole("button", { name: "View next" }).isVisible()) { + await page.getByRole("button", { name: "View next" }).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(); + }, + verifyReschedulingSuccess: async () => { + await expect(page.getByText(scheduleSuccessfullyText)).toBeVisible(); + }, + cancelBookingWithReason: async () => { + await page.getByTestId("cancel").click(); + await page.getByTestId("cancel_reason").fill("Test cancel"); + await page.getByTestId("confirm_cancel").click(); + }, + verifyBookingCancellation: async () => { + await expect(page.getByTestId("cancelled-headline")).toBeVisible(); + }, + cancelAndRescheduleBooking: async (eventTypePage: Page) => { + 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(); + await expect(eventTypePage.getByText(scheduleSuccessfullyText)).toBeVisible(); + 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"].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(); + }, + }; +} diff --git a/apps/web/playwright/lib/fixtures.ts b/apps/web/playwright/lib/fixtures.ts index 2c9cb71216..61d315a754 100644 --- a/apps/web/playwright/lib/fixtures.ts +++ b/apps/web/playwright/lib/fixtures.ts @@ -10,6 +10,7 @@ import type { ExpectedUrlDetails } from "../../../../playwright.config"; import { createBookingsFixture } from "../fixtures/bookings"; import { createEmbedsFixture } from "../fixtures/embeds"; import { createPaymentsFixture } from "../fixtures/payments"; +import { createBookingPageFixture } from "../fixtures/regularBookings"; import { createRoutingFormsFixture } from "../fixtures/routingForms"; import { createServersFixture } from "../fixtures/servers"; import { createUsersFixture } from "../fixtures/users"; @@ -24,6 +25,7 @@ export interface Fixtures { prisma: typeof prisma; emails?: API; routingForms: ReturnType; + bookingPage: ReturnType; } declare global { @@ -80,4 +82,8 @@ export const test = base.extend({ await use(undefined); } }, + bookingPage: async ({ page }, use) => { + const bookingPage = createBookingPageFixture(page); + await use(bookingPage); + }, }); diff --git a/apps/web/playwright/login.e2e.ts b/apps/web/playwright/login.e2e.ts index 13f0cdd049..94569128f8 100644 --- a/apps/web/playwright/login.e2e.ts +++ b/apps/web/playwright/login.e2e.ts @@ -35,7 +35,7 @@ test.describe("user can login & logout succesfully", async () => { const signOutBtn = await page.locator(`text=${signOutLabel}`); await signOutBtn.click(); - await page.locator('a[href="/auth/login"]').click(); + await page.locator("[data-testid=logout-btn]").click(); // Reroute to the home page to check if the login form shows up await expect(page.locator(`[data-testid=login-form]`)).toBeVisible(); diff --git a/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarSettingsModal.tsx b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarSettingsModal.tsx index 24ccc80a73..118265be69 100644 --- a/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarSettingsModal.tsx +++ b/packages/features/bookings/Booker/components/OverlayCalendar/OverlayCalendarSettingsModal.tsx @@ -1,6 +1,6 @@ import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { Fragment } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Fragment, useEffect, useState } from "react"; import { classNames } from "@calcom/lib"; import { useLocale } from "@calcom/lib/hooks/useLocale"; @@ -43,17 +43,35 @@ const SkeletonLoader = () => { export function OverlayCalendarSettingsModal(props: IOverlayCalendarContinueModalProps) { const utils = trpc.useContext(); + const [initalised, setInitalised] = useState(false); + const searchParams = useSearchParams(); const setOverlayBusyDates = useOverlayCalendarStore((state) => state.setOverlayBusyDates); const { data, isLoading } = trpc.viewer.connectedCalendars.useQuery(undefined, { - enabled: !!props.open, + enabled: !!props.open || !!searchParams.get("overlayCalendar"), }); - const { toggleValue, hasItem } = useLocalSet<{ + const { toggleValue, hasItem, set } = useLocalSet<{ credentialId: number; externalId: string; }>("toggledConnectedCalendars", []); const router = useRouter(); const { t } = useLocale(); + + useEffect(() => { + if (data?.connectedCalendars && set.size === 0 && !initalised) { + data?.connectedCalendars.forEach((item) => { + item.calendars?.forEach((cal) => { + const id = { credentialId: item.credentialId, externalId: cal.externalId }; + if (cal.primary) { + toggleValue(id); + } + }); + }); + setInitalised(true); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [data, hasItem, set, initalised]); + return ( <> diff --git a/packages/features/ee/organizations/lib/orgDomains.ts b/packages/features/ee/organizations/lib/orgDomains.ts index 0d8051ffb7..68f6425fad 100644 --- a/packages/features/ee/organizations/lib/orgDomains.ts +++ b/packages/features/ee/organizations/lib/orgDomains.ts @@ -1,6 +1,7 @@ import type { Prisma } from "@prisma/client"; import { ALLOWED_HOSTNAMES, RESERVED_SUBDOMAINS, WEBAPP_URL } from "@calcom/lib/constants"; +import slugify from "@calcom/lib/slugify"; /** * return the org slug @@ -52,13 +53,14 @@ export function getOrgFullDomain(slug: string, options: { protocol: boolean } = } export function getSlugOrRequestedSlug(slug: string) { + const slugifiedValue = slugify(slug); return { OR: [ - { slug }, + { slug: slugifiedValue }, { metadata: { path: ["requestedSlug"], - equals: slug, + equals: slugifiedValue, }, }, ], diff --git a/packages/lib/server/queries/teams/index.ts b/packages/lib/server/queries/teams/index.ts index 6cf5e6f0d7..2d1fe4189b 100644 --- a/packages/lib/server/queries/teams/index.ts +++ b/packages/lib/server/queries/teams/index.ts @@ -7,6 +7,7 @@ import { SchedulingType } from "@calcom/prisma/enums"; import { EventTypeMetaDataSchema, teamMetadataSchema } from "@calcom/prisma/zod-utils"; import { WEBAPP_URL } from "../../../constants"; +import logger from "../../../logger"; export type TeamWithMembers = Awaited>; @@ -17,6 +18,9 @@ export async function getTeamWithMembers(args: { orgSlug?: string | null; includeTeamLogo?: boolean; isTeamView?: boolean; + /** + * If true, means that you are fetching an organization and not a team + */ isOrgView?: boolean; }) { const { id, slug, userId, orgSlug, isTeamView, isOrgView, includeTeamLogo } = args; @@ -120,12 +124,30 @@ export async function getTeamWithMembers(args: { } if (id) where.id = id; if (slug) where.slug = slug; + if (isOrgView) { + // We must fetch only the organization here. + // Note that an organization and a team that doesn't belong to an organization, both have parentId null + // If the organization has null slug(but requestedSlug is 'test') and the team also has slug 'test', we can't distinguish them without explicitly checking the metadata.isOrganization + // Note that, this isn't possible now to have same requestedSlug as the slug of a team not part of an organization. This is legacy teams handling mostly. But it is still safer to be sure that you are fetching an Organization only in case of isOrgView + where.metadata = { + path: ["isOrganization"], + equals: true, + }; + } - const team = await prisma.team.findFirst({ + const teams = await prisma.team.findMany({ where, select: teamSelect, }); + if (teams.length > 1) { + logger.error("Found more than one team/Org. We should be doing something wrong.", { + where, + teams: teams.map((team) => ({ id: team.id, slug: team.slug })), + }); + } + + const team = teams[0]; if (!team) return null; // This should improve performance saving already app data found. diff --git a/packages/trpc/server/routers/viewer/organizations/create.handler.ts b/packages/trpc/server/routers/viewer/organizations/create.handler.ts index 743805fd11..1e0f9a2e03 100644 --- a/packages/trpc/server/routers/viewer/organizations/create.handler.ts +++ b/packages/trpc/server/routers/viewer/organizations/create.handler.ts @@ -72,17 +72,17 @@ export const createHandler = async ({ input, ctx }: CreateOptions) => { }, }); - const slugCollisions = await prisma.team.findFirst({ + // An org doesn't have a parentId. A team that isn't part of an org also doesn't have a parentId. + // So, an org can't have the same slug as a non-org team. + // There is a unique index on [slug, parentId] in Team because we don't add the slug to the team always. We only add metadata.requestedSlug in some cases. So, DB won't prevent creation of such an organization. + const hasANonOrgTeamOrOrgWithSameSlug = await prisma.team.findFirst({ where: { slug: slug, - metadata: { - path: ["isOrganization"], - equals: true, - }, + parentId: null, }, }); - if (slugCollisions || RESERVED_SUBDOMAINS.includes(slug)) + if (hasANonOrgTeamOrOrgWithSameSlug || RESERVED_SUBDOMAINS.includes(slug)) throw new TRPCError({ code: "BAD_REQUEST", message: "organization_url_taken" }); if (userCollisions) throw new TRPCError({ code: "BAD_REQUEST", message: "admin_email_taken" });