diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index 42d5c75c9b..b033777214 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -7,6 +7,7 @@ import { hashSync as hash } from "bcryptjs"; import { uuid } from "short-uuid"; import { v4 } from "uuid"; +import updateChildrenEventTypes from "@calcom/features/ee/managed-event-types/lib/handleChildrenEventTypes"; import stripe from "@calcom/features/ee/payments/server/stripe"; import { DEFAULT_SCHEDULE, getAvailabilityFromSchedule } from "@calcom/lib/availability"; import { WEBAPP_URL } from "@calcom/lib/constants"; @@ -92,6 +93,7 @@ const createTeamEventType = async ( teamEventSlug?: string; teamEventLength?: number; seatsPerTimeSlot?: number; + managedEventUnlockedFields?: Record; } ) => { return await prisma.eventType.create({ @@ -122,6 +124,20 @@ const createTeamEventType = async ( slug: scenario?.teamEventSlug ?? `${teamEventSlug}-team-id-${team.id}`, length: scenario?.teamEventLength ?? 30, seatsPerTimeSlot: scenario?.seatsPerTimeSlot, + locations: [{ type: "integrations:daily" }], + metadata: + scenario?.schedulingType === SchedulingType.MANAGED + ? { + managedEventConfig: { + unlockedFields: { + locations: true, + scheduleId: true, + destinationCalendar: true, + ...scenario?.managedEventUnlockedFields, + }, + }, + } + : undefined, }, }); }; @@ -259,6 +275,8 @@ export const createUsersFixture = ( hasSubteam?: true; isUnpublished?: true; seatsPerTimeSlot?: number; + addManagedEventToTeamMates?: boolean; + managedEventUnlockedFields?: Record; orgRequestedSlug?: string; } = {} ) => { @@ -521,6 +539,31 @@ export const createUsersFixture = ( teamMates.push(teamUser); store.users.push(teammateFixture); } + // If the teamEvent is a managed one, we add the team mates to it. + if (scenario.schedulingType === SchedulingType.MANAGED && scenario.addManagedEventToTeamMates) { + await updateChildrenEventTypes({ + eventTypeId: teamEvent.id, + currentUserId: user.id, + hashedLink: "", + connectedLink: null, + oldEventType: { + team: null, + }, + updatedEventType: teamEvent, + children: teamMates.map((tm) => ({ + hidden: false, + owner: { + id: tm.id, + name: tm.name || tm.username || "Nameless", + email: tm.email, + eventTypeSlugs: [], + }, + })), + profileId: null, + prisma, + updatedValues: {}, + }); + } // Add Teammates to OrgUsers if (scenario.isOrg) { const orgProfilesCreate = teamMates @@ -730,10 +773,11 @@ const createUserFixture = (user: UserWithIncludes, page: Page) => { userId: user.id, }, }), - getFirstTeamEvent: async (teamId: number) => { + getFirstTeamEvent: async (teamId: number, schedulingType?: SchedulingType) => { return prisma.eventType.findFirstOrThrow({ where: { teamId, + schedulingType, }, }); }, diff --git a/apps/web/playwright/managed-event-types.e2e.ts b/apps/web/playwright/managed-event-types.e2e.ts index 28a567b620..90eae36af2 100644 --- a/apps/web/playwright/managed-event-types.e2e.ts +++ b/apps/web/playwright/managed-event-types.e2e.ts @@ -1,212 +1,247 @@ -import type { Page } from "@playwright/test"; +import type { Locator, Page } from "@playwright/test"; import { expect } from "@playwright/test"; +import { apiLogin } from "playwright/fixtures/users"; -import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants"; +import { SchedulingType } from "@calcom/prisma/enums"; -import { test } from "./lib/fixtures"; -import { - bookTimeSlot, - fillStripeTestCheckout, - localize, - selectFirstAvailableTimeSlotNextMonth, -} from "./lib/testUtils"; +import { test, type Fixtures } from "./lib/fixtures"; +import { bookTimeSlot, localize, selectFirstAvailableTimeSlotNextMonth } from "./lib/testUtils"; -test.afterEach(({ users }) => users.deleteAll()); +test.afterEach(async ({ users }) => { + await users.deleteAll(); +}); + +/** So we can test different areas in parallel and avoiding the creation flow each time */ +async function setupManagedEvent({ + users, + unlockedFields, +}: { + users: Fixtures["users"]; + unlockedFields?: Record; +}) { + const teamMateName = "teammate-1"; + const teamEventTitle = "Managed"; + const adminUser = await users.create(null, { + hasTeam: true, + teammates: [{ name: teamMateName }], + teamEventTitle, + teamEventSlug: "managed", + schedulingType: "MANAGED", + addManagedEventToTeamMates: true, + managedEventUnlockedFields: unlockedFields, + }); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const memberUser = users.get().find((u) => u.name === teamMateName)!; + const { team } = await adminUser.getFirstTeamMembership(); + const managedEvent = await adminUser.getFirstTeamEvent(team.id, SchedulingType.MANAGED); + return { adminUser, memberUser, managedEvent, teamMateName, teamEventTitle }; +} + +/** Short hand to get elements by translation key */ +const getByKey = async (page: Page, key: string) => page.getByText((await localize("en"))(key)); test.describe("Managed Event Types", () => { + /** We don't use setupManagedEvent here to test the actual creation flow */ test("Can create managed event type", async ({ page, users }) => { // Creating the owner user of the team - const adminUser = await users.create(); + const adminUser = await users.create(null, { + hasTeam: true, + teammates: [{ name: "teammate-1" }], + }); // Creating the member user of the team - const memberUser = await users.create(); // First we work with owner user, logging in await adminUser.apiLogin(); - // Let's create a team - await page.goto("/settings/teams/new"); + // Going to create an event type + await page.goto("/event-types"); + await page.getByTestId("new-event-type").click(); + await page.getByTestId("option-team-1").click(); + // Expecting we can add a managed event type as team owner + await expect(page.locator('button[value="MANAGED"]')).toBeVisible(); - await test.step("Managed event option exists for team admin", async () => { - // Filling team creation form wizard - await page.locator('input[name="name"]').fill(`${adminUser.username}'s Team`); - await page.click("[type=submit]"); - // TODO: Figure out a way to make this more reliable - // eslint-disable-next-line playwright/no-conditional-in-test - if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page); - await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i); - await page.getByTestId("new-member-button").click(); - await page.locator('[placeholder="email\\@example\\.com"]').fill(`${memberUser.username}@example.com`); - await page.getByTestId("invite-new-member-button").click(); - // wait for the second member to be added to the pending-member-list. - await page.getByTestId("pending-member-list").locator("li:nth-child(2)").waitFor(); - await page.locator("[data-testid=publish-button]").click(); - await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/event-type$/i); - // create a round-robin event type and finish - await expect(page.locator('button[value="ROUND_ROBIN"]')).toBeVisible(); - await page.click('button[value="ROUND_ROBIN"]'); - await page.fill("[name=title]", "roundRobin"); - await page.getByTestId("finish-button").click(); - await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i); - // Going to create an managed event type - await page.goto("/event-types"); - await page.getByTestId("new-event-type").click(); - await page.getByTestId("option-team-1").click(); - // Expecting we can add a managed event type as team owner - await expect(page.locator('button[value="MANAGED"]')).toBeVisible(); + // Actually creating a managed event type to test things further + await page.click('button[value="MANAGED"]'); + await page.fill("[name=title]", "managed"); + await page.click("[type=submit]"); - // Actually creating a managed event type to test things further - await page.click('button[value="MANAGED"]'); - await page.fill("[name=title]", "managed"); - await page.click("[type=submit]"); + await page.waitForURL("event-types/**"); + expect(page.url()).toContain("?tabName=team"); + }); - await page.waitForURL("event-types/**"); + /** From here we use setupManagedEvent to avoid repeating the previous flow */ + test("Has unlocked fields for admin", async ({ page, users }) => { + const { adminUser, managedEvent } = await setupManagedEvent({ users }); + await adminUser.apiLogin(); + await page.goto(`/event-types/${managedEvent.id}?tabName=setup`); + await page.getByTestId("update-eventtype").waitFor(); + await expect(page.locator('input[name="title"]')).toBeEditable(); + await expect(page.locator('input[name="slug"]')).toBeEditable(); + await expect(page.locator('input[name="length"]')).toBeEditable(); + }); + + test("Exists for added member", async ({ page, users }) => { + const { memberUser, teamEventTitle } = await setupManagedEvent({ + users, }); + await memberUser.apiLogin(); + await page.goto("/event-types"); + await expect( + page.getByTestId("event-types").locator("div").filter({ hasText: teamEventTitle }).nth(1) + ).toBeVisible(); + }); - await test.step("Managed event type has unlocked fields for admin", async () => { - await page.getByTestId("vertical-tab-event_setup_tab_title").click(); - await page.getByTestId("update-eventtype").waitFor(); - await expect(page.locator('input[name="title"]')).toBeEditable(); - await expect(page.locator('input[name="slug"]')).toBeEditable(); - await expect(page.locator('input[name="length"]')).toBeEditable(); + test("Can use Organizer's default app as location", async ({ page, users }) => { + const { adminUser, managedEvent } = await setupManagedEvent({ users }); + await adminUser.apiLogin(); + await page.goto(`/event-types/${managedEvent.id}?tabName=setup`); + await page.locator("#location-select").click(); + const optionText = await getByKey(page, "organizer_default_conferencing_app"); + await expect(optionText).toBeVisible(); + await optionText.click(); + await submitAndWaitForResponse(page); + + await page.getByTestId("vertical-tab-assignment").click(); + await gotoBookingPage(page); + await selectFirstAvailableTimeSlotNextMonth(page); + await bookTimeSlot(page); + + await expect(page.getByTestId("success-page")).toBeVisible(); + }); + + test("Has locked fields for added member", async ({ page, users }) => { + const { memberUser } = await setupManagedEvent({ + users, + }); + await memberUser.apiLogin(); + const managedEvent = await memberUser.getFirstEventAsOwner(); + await page.goto(`/event-types/${managedEvent.id}?tabName=setup`); + await page.waitForURL("event-types/**"); + + await expect(page.locator('input[name="title"]')).not.toBeEditable(); + await expect(page.locator('input[name="slug"]')).not.toBeEditable(); + await expect(page.locator('input[name="length"]')).not.toBeEditable(); + }); + + test("Provides discrete field lock/unlock state for admin", async ({ page, users }) => { + const { adminUser, teamEventTitle } = await setupManagedEvent({ users }); + await adminUser.apiLogin(); + await page.goto("/event-types"); + + await page.getByTestId("event-types").locator(`a[title="${teamEventTitle}"]`).click(); + await page.waitForURL("event-types/**"); + + // Locked by default + const titleLockIndicator = page.getByTestId("locked-indicator-title"); + await expect(titleLockIndicator).toBeVisible(); + await expect(titleLockIndicator.locator("[data-state='checked']")).toHaveCount(1); + + // Proceed to unlock and check that it got unlocked + titleLockIndicator.click(); + await expect(titleLockIndicator.locator("[data-state='checked']")).toHaveCount(0); + await expect(titleLockIndicator.locator("[data-state='unchecked']")).toHaveCount(1); + + // Save changes + await page.locator('[type="submit"]').click(); + await expect(titleLockIndicator.locator("[data-state='unchecked']")).toHaveCount(1); + }); + + test("Shows discretionally unlocked field to member", async ({ page, users }) => { + const { memberUser, teamEventTitle } = await setupManagedEvent({ + users, + unlockedFields: { + title: true, + }, + }); + await memberUser.apiLogin(); + await page.goto("/event-types"); + await page.getByTestId("event-types").locator(`a[title="${teamEventTitle}"]`).click(); + await page.waitForURL("event-types/**"); + + await expect(page.locator('input[name="title"]')).toBeEditable(); + }); + + test("Should only update the unlocked fields modified by Admin", async ({ + page: memberPage, + users, + browser, + }) => { + const { adminUser, memberUser, teamEventTitle } = await setupManagedEvent({ + users, + unlockedFields: { + title: true, + }, + }); + await memberUser.apiLogin(); + await memberPage.goto("/event-types"); + await memberPage.getByTestId("event-types").locator(`a[title="${teamEventTitle}"]`).click(); + await memberPage.waitForURL("event-types/**"); + await expect(memberPage.locator('input[name="title"]')).toBeEditable(); + await memberPage.locator('input[name="title"]').fill(`Managed Event Title`); + await submitAndWaitForResponse(memberPage); + + // We edit the managed event as original owner + const adminContext = await browser.newContext(); + const adminPage = await adminContext.newPage(); + const adminUserSnapshot = await adminUser.self(); + await apiLogin({ ...adminUserSnapshot, password: adminUserSnapshot.username }, adminPage); + await adminPage.goto("/event-types"); + await adminPage.getByTestId("event-types").locator(`a[title="${teamEventTitle}"]`).click(); + await adminPage.waitForURL("event-types/**"); + await adminPage.locator('input[name="length"]').fill(`45`); + await submitAndWaitForResponse(adminPage); + await adminContext.close(); + + await memberPage.goto("/event-types"); + await memberPage.getByTestId("event-types").locator('a[title="Managed Event Title"]').click(); + await memberPage.waitForURL("event-types/**"); + //match length + expect(await memberPage.locator("[data-testid=duration]").getAttribute("value")).toBe("45"); + //ensure description didn't update + expect(await memberPage.locator(`input[name="title"]`).getAttribute("value")).toBe(`Managed Event Title`); + await memberPage.locator('input[name="title"]').fill(`managed`); + // Save changes + await submitAndWaitForResponse(memberPage); + }); + + const MANAGED_EVENT_TABS: { slug: string; locator: (page: Page) => Locator | Promise }[] = [ + { slug: "setup", locator: (page) => getByKey(page, "title") }, + { + slug: "team", + locator: (page) => getByKey(page, "automatically_add_all_team_members"), + }, + { + slug: "availability", + locator: (page) => getByKey(page, "members_default_schedule_description"), + }, + { + slug: "limits", + locator: (page) => getByKey(page, "before_event"), + }, + { + slug: "advanced", + locator: (page) => getByKey(page, "event_name_in_calendar"), + }, + { + slug: "apps", + locator: (page) => page.getByRole("heading", { name: "No apps installed" }), + }, + { + slug: "workflows", + locator: (page) => page.getByTestId("empty-screen").getByRole("heading", { name: "Workflows" }), + }, + { + slug: "ai", + locator: (page) => page.getByTestId("empty-screen").getByRole("heading", { name: "Cal.ai" }), + }, + ]; + + MANAGED_EVENT_TABS.forEach((tab) => { + test(`Can render "${tab.slug}" tab`, async ({ page, users }) => { + const { adminUser, managedEvent } = await setupManagedEvent({ users }); + // First we work with owner user, logging in await adminUser.apiLogin(); - }); - - await test.step("Managed event type exists for added member", async () => { - // Now we need to accept the invitation as member and come back in as admin to - // assign the member in the managed event type - await memberUser.apiLogin(); - - await page.goto("/teams"); - await page.locator('button[data-testid^="accept-invitation"]').click(); - await page.getByText("Member").waitFor(); - - await page.goto("/auth/logout"); - - // Coming back as team owner to assign member user to managed event - await adminUser.apiLogin(); - await page.goto("/event-types"); - await page.getByTestId("event-types").locator('a[title="managed"]').click(); - await page.getByTestId("vertical-tab-assignment").click(); - await page.getByTestId("assignment-dropdown").click(); - - await page.getByTestId(`select-option-${memberUser.id}`).click(); - await page.locator('[type="submit"]').click(); - await page.getByTestId("toast-success").waitFor(); - }); - - await test.step("Managed event type can use Organizer's default app as location", async () => { - await page.getByTestId("vertical-tab-event_setup_tab_title").click(); - - await page.locator("#location-select").click(); - const optionText = (await localize("en"))("organizer_default_conferencing_app"); - await page.locator(`text=${optionText}`).click(); - await page.locator("[data-testid=update-eventtype]").click(); - await page.getByTestId("toast-success").waitFor(); - await page.waitForLoadState("networkidle"); - - await page.getByTestId("vertical-tab-assignment").click(); - await gotoBookingPage(page); - await selectFirstAvailableTimeSlotNextMonth(page); - await bookTimeSlot(page); - - await expect(page.getByTestId("success-page")).toBeVisible(); - }); - - await test.step("Managed event type has locked fields for added member", async () => { - await adminUser.logout(); - // Coming back as member user to see if there is a managed event present after assignment - await memberUser.apiLogin(); - await page.goto("/event-types"); - - await page.getByTestId("event-types").locator('a[title="managed"]').click(); - await page.waitForURL("event-types/**"); - - await expect(page.locator('input[name="title"]')).not.toBeEditable(); - await expect(page.locator('input[name="slug"]')).not.toBeEditable(); - await expect(page.locator('input[name="length"]')).not.toBeEditable(); - - await page.goto("/auth/logout"); - }); - - await test.step("Managed event type provides discrete field lock/unlock state for admin", async () => { - await adminUser.apiLogin(); - await page.goto("/event-types"); - - await page.getByTestId("event-types").locator('a[title="managed"]').click(); - await page.waitForURL("event-types/**"); - - // Locked by default - const titleLockIndicator = page.getByTestId("locked-indicator-title"); - await expect(titleLockIndicator).toBeVisible(); - await expect(titleLockIndicator.locator("[data-state='checked']")).toHaveCount(1); - - // Proceed to unlock and check that it got unlocked - titleLockIndicator.click(); - await expect(titleLockIndicator.locator("[data-state='checked']")).toHaveCount(0); - await expect(titleLockIndicator.locator("[data-state='unchecked']")).toHaveCount(1); - - // Save changes - await page.locator('[type="submit"]').click(); - await page.waitForLoadState("networkidle"); - - await page.goto("/auth/logout"); - }); - - await test.step("Managed event type shows discretionally unlocked field to member", async () => { - await memberUser.apiLogin(); - await page.goto("/event-types"); - - await page.getByTestId("event-types").locator('a[title="managed"]').click(); - await page.waitForURL("event-types/**"); - - await expect(page.locator('input[name="title"]')).toBeEditable(); - await page.waitForLoadState("networkidle"); - - await page.goto("/auth/logout"); - }); - - await test.step("Managed event type should only update the unlocked fields modified by Admin", async () => { - await memberUser.apiLogin(); - await page.goto("/event-types"); - - await page.getByTestId("event-types").locator('a[title="managed"]').click(); - await page.waitForURL("event-types/**"); - - await expect(page.locator('input[name="title"]')).toBeEditable(); - await page.locator('input[name="title"]').fill(`Managed Event Title`); - // Save changes - await page.locator('[type="submit"]').click(); - await page.getByTestId("toast-success").waitFor(); - await page.waitForLoadState("networkidle"); - - await page.goto("/auth/logout"); - - await adminUser.apiLogin(); - await page.goto("/event-types"); - await page.getByTestId("event-types").locator('a[title="managed"]').click(); - await page.waitForURL("event-types/**"); - - await page.locator('input[name="length"]').fill(`45`); - // Save changes - await page.locator('[type="submit"]').click(); - await page.getByTestId("toast-success").waitFor(); - await page.waitForLoadState("networkidle"); - - await page.goto("/auth/logout"); - - await memberUser.apiLogin(); - await page.goto("/event-types"); - - await page.getByTestId("event-types").locator('a[title="Managed Event Title"]').click(); - await page.waitForURL("event-types/**"); - - //match length - expect(await page.locator("[data-testid=duration]").getAttribute("value")).toBe("45"); - //ensure description didn't update - expect(await page.locator(`input[name="title"]`).getAttribute("value")).toBe(`Managed Event Title`); - await page.locator('input[name="title"]').fill(`managed`); - // Save changes - await page.locator('[type="submit"]').click(); - await page.getByTestId("toast-success").waitFor(); + await page.goto(`/event-types/${managedEvent.id}?tabName=${tab.slug}`); + await expect(await tab.locator(page)).toBeVisible(); }); }); }); @@ -216,3 +251,10 @@ async function gotoBookingPage(page: Page) { await page.goto(previewLink ?? ""); } + +async function submitAndWaitForResponse(page: Page) { + const submitPromise = page.waitForResponse("/api/trpc/eventTypes/update?batch=1"); + await page.locator('[type="submit"]').click(); + const response = await submitPromise; + expect(response.status()).toBe(200); +} diff --git a/apps/web/playwright/managedBooking/advancedOptions.e2e.ts b/apps/web/playwright/managedBooking/advancedOptions.e2e.ts deleted file mode 100644 index d7378defe9..0000000000 --- a/apps/web/playwright/managedBooking/advancedOptions.e2e.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { test } from "../lib/fixtures"; - -test.beforeEach(async ({ page, users, bookingPage }) => { - const teamEventTitle = "Test Managed Event Type"; - const userFixture = await users.create( - { name: "testuser" }, - { hasTeam: true, schedulingType: "MANAGED", teamEventTitle } - ); - await userFixture.apiLogin(); - - await page.goto("/event-types"); - await bookingPage.goToEventType(teamEventTitle); - await page.getByTestId("location-select").click(); - await page.locator(`text="Cal Video (Global)"`).click(); - await bookingPage.goToTab("event_advanced_tab_title"); -}); - -test.describe("Check advanced options in a managed team event type", () => { - test("Check advanced options in a managed team event type without offer seats", async ({ bookingPage }) => { - await bookingPage.checkRequiresConfirmation(); - await bookingPage.checkRequiresBookerEmailVerification(); - await bookingPage.checkHideNotes(); - await bookingPage.checkRedirectOnBooking(); - await bookingPage.checkLockTimezone(); - await bookingPage.updateEventType(); - await bookingPage.goToEventTypesPage(); - - await bookingPage.checkEventType(); - }); - - test("Check advanced options in a managed team event type with offer seats", async ({ bookingPage }) => { - await bookingPage.checkRequiresConfirmation(); - await bookingPage.checkRequiresBookerEmailVerification(); - await bookingPage.checkHideNotes(); - await bookingPage.checkRedirectOnBooking(); - await bookingPage.toggleOfferSeats(); - await bookingPage.checkLockTimezone(); - await bookingPage.updateEventType(); - await bookingPage.goToEventTypesPage(); - - await bookingPage.checkEventType(); - }); -});