chore: organization onboarding refactor (#24381)
* feat: redirect to new onboarding flow * Getting started * Brand details * Preview organization brands * Orgs team pages * Invite team steps * Move to global zustand store * Few darkmdoe fixes * Wip onboarding + stripe flow * Default plan state Server Action for gettting slug satus of org * Remove onboardingId * Confirmation prompt * Update old onboarding flow handlers to handle new fields * update onboarding hook * Filter out organization section for none -company emails * Match placeholders to users domain * Drop migration * Wip new onboarding intent * WIP flow for self-hosted. Same service call just split logic * WIP * Add TODO * Use onboarding user type instead of trpc session * WIP * WIP * pass role and team name from onboarding to save in schema * Add test to ensure role + name + team are persisted into onboarding table * migrate roles to enum values * Update ENUM * Fix type error * Redirect if flag is disabled * Remove web * WIP * WIP * Fix migration * Fix calls * User onboarding User types instead of trpc session * Fix factory tests * Fix flow for self hoste * Type error * More type fixes * Fix handler tests * Fix enum return type being different * Use consistant types across the oganization stuff * Fix * Use TEAM_BILLING for e2e test * Refactor is not company email and add tests * Fix * Fix * Refactor flow to submit after form complete * Fix flow with billing disabled * Fix tests * Apply suggestion from @coderabbitai[bot] Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * Rename and move test files * WIP * Fix types * Update repo paths + tests * Move to service folder * Fix tests * Fix types * Remove old test files * Restore lock * Fix path * Fix tests with new paths and factory logic * Fix updaetdAt * WIP onboardingID isolation * Fix e2e test * verify test * Code rabbit * Rename SelfHostedOnboardongService -> SelfHostedOrganizationOnboardingService * Fix stores * Fix type error * Fix types * remove tsignore * Apply suggestion from @coderabbitai[bot] Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> * NITS * Add the logic to auto complete admin org when billing enabled * Fix store being weird * We need to return the parsed value * fixes * sync from db always * Add onboardingSgtore tests * fix test * remove step and status --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: Hariom Balhara <hariombalhara@gmail.com>
This commit is contained in:
48 files changed
+4959
-1813
No files matched your search
@@ -0,0 +1,22 @@
|
||||
import { _generateMetadata } from "app/_utils";
|
||||
|
||||
import ResumeOnboardingPage, { LayoutWrapper } from "~/settings/organizations/new/resume-view";
|
||||
|
||||
export const generateMetadata = async () =>
|
||||
await _generateMetadata(
|
||||
(t) => t("resume_onboarding"),
|
||||
(t) => t("resume_onboarding_description"),
|
||||
undefined,
|
||||
undefined,
|
||||
"/settings/organizations/new/resume"
|
||||
);
|
||||
|
||||
const ServerPage = async () => {
|
||||
return (
|
||||
<LayoutWrapper>
|
||||
<ResumeOnboardingPage />
|
||||
</LayoutWrapper>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerPage;
|
||||
@@ -205,6 +205,7 @@ const AddNewTeamsFormChild = ({ teams }: { teams: { id: number; name: string; sl
|
||||
<TextField
|
||||
key={field.id}
|
||||
{...register(`teams.${index}.name`)}
|
||||
data-testid={`team.${index}.name`}
|
||||
label=""
|
||||
addOnClassname="bg-transparent p-0 border-l-0"
|
||||
className={index > 0 ? "mb-2" : ""}
|
||||
|
||||
@@ -34,28 +34,28 @@ const useOrgCreation = () => {
|
||||
const session = useSession();
|
||||
const utils = trpc.useUtils();
|
||||
const [serverErrorMessage, setServerErrorMessage] = useState("");
|
||||
const { useOnboardingStore, isBillingEnabled } = useOnboarding();
|
||||
const { useOnboardingStore } = useOnboarding();
|
||||
const { reset } = useOnboardingStore();
|
||||
|
||||
const checkoutMutation = trpc.viewer.organizations.createWithPaymentIntent.useMutation({
|
||||
onSuccess: (data) => {
|
||||
if (data.checkoutUrl) {
|
||||
window.location.href = data.checkoutUrl;
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
setServerErrorMessage(t(error.message));
|
||||
},
|
||||
});
|
||||
|
||||
const createOrgMutation = trpc.viewer.organizations.createSelfHosted.useMutation({
|
||||
// Single mutation for all flows (billing, self-hosted, admin)
|
||||
const intentToCreateOrgMutation = trpc.viewer.organizations.intentToCreateOrg.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
if (data.organization) {
|
||||
// Invalidate the organizations query to ensure fresh data on the next page
|
||||
reset({
|
||||
onboardingId: data.organizationOnboardingId,
|
||||
});
|
||||
|
||||
if (data.checkoutUrl) {
|
||||
// Billing enabled - redirect to Stripe
|
||||
window.location.href = data.checkoutUrl;
|
||||
} else if (data.organizationId) {
|
||||
// Self-hosted - org already created, redirect to organizations
|
||||
await utils.viewer.organizations.listCurrent.invalidate();
|
||||
await session.update();
|
||||
reset();
|
||||
window.location.href = `${window.location.origin}/settings/organizations/profile`;
|
||||
} else {
|
||||
// Unexpected state
|
||||
setServerErrorMessage("Unexpected response from server");
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
@@ -63,12 +63,10 @@ const useOrgCreation = () => {
|
||||
},
|
||||
});
|
||||
|
||||
const mutationToUse = isBillingEnabled ? checkoutMutation : createOrgMutation;
|
||||
|
||||
return {
|
||||
mutation: mutationToUse,
|
||||
mutate: mutationToUse.mutate,
|
||||
isPending: mutationToUse.isPending,
|
||||
mutation: intentToCreateOrgMutation,
|
||||
mutate: intentToCreateOrgMutation.mutate,
|
||||
isPending: intentToCreateOrgMutation.isPending,
|
||||
errorMessage: serverErrorMessage,
|
||||
};
|
||||
};
|
||||
@@ -84,7 +82,13 @@ export const AddNewTeamMembersForm = () => {
|
||||
invitedMembers,
|
||||
logo,
|
||||
bio,
|
||||
onboardingId,
|
||||
name,
|
||||
slug,
|
||||
billingPeriod,
|
||||
seats,
|
||||
pricePerSeat,
|
||||
brandColor,
|
||||
bannerUrl,
|
||||
} = useOnboardingStore();
|
||||
const orgCreation = useOrgCreation();
|
||||
|
||||
@@ -155,7 +159,7 @@ export const AddNewTeamMembersForm = () => {
|
||||
placeholder="colleague@company.com"
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" StartIcon="plus" color="secondary">
|
||||
<Button type="submit" StartIcon="plus" color="secondary" data-testid="invite-new-member-button">
|
||||
{t("add")}
|
||||
</Button>
|
||||
</form>
|
||||
@@ -203,22 +207,31 @@ export const AddNewTeamMembersForm = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 mt-6 flex items-center justify-end">
|
||||
<div className="mt-3 flex items-center justify-end">
|
||||
<Button
|
||||
data-testid="publish-button"
|
||||
onClick={() => {
|
||||
if (!onboardingId) {
|
||||
console.error("Org owner email and onboardingId are required", {
|
||||
orgOwnerEmail,
|
||||
onboardingId,
|
||||
});
|
||||
// Submit ALL data to intentToCreateOrg
|
||||
if (!name || !slug || !orgOwnerEmail) {
|
||||
console.error("Required fields missing", { name, slug, orgOwnerEmail });
|
||||
showToast(t("required_fields_missing"), "error");
|
||||
return;
|
||||
}
|
||||
orgCreation.mutation.mutate({
|
||||
|
||||
orgCreation.mutate({
|
||||
name,
|
||||
slug,
|
||||
orgOwnerEmail,
|
||||
seats,
|
||||
pricePerSeat,
|
||||
billingPeriod,
|
||||
creationSource: "WEBAPP" as const,
|
||||
logo,
|
||||
bio,
|
||||
brandColor,
|
||||
bannerUrl,
|
||||
teams,
|
||||
invitedMembers,
|
||||
onboardingId,
|
||||
});
|
||||
}}
|
||||
loading={orgCreation.isPending}>
|
||||
|
||||
@@ -6,8 +6,8 @@ import { useEffect, useState } from "react";
|
||||
import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingStore";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
const PaymentStatusView = () => {
|
||||
const { t } = useLocale();
|
||||
@@ -15,9 +15,7 @@ const PaymentStatusView = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const paymentStatus = searchParams?.get("paymentStatus");
|
||||
const paymentError = searchParams?.get("error");
|
||||
const { useOnboardingStore } = useOnboarding({
|
||||
step: "status",
|
||||
});
|
||||
const { useOnboardingStore } = useOnboarding();
|
||||
const [organizationCreated, setOrganizationCreated] = useState<boolean>(false);
|
||||
const { name } = useOnboardingStore();
|
||||
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
|
||||
import { useOnboarding } from "@calcom/features/ee/organizations/lib/onboardingStore";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Alert } from "@calcom/ui/components/alert";
|
||||
import { WizardLayout } from "@calcom/ui/components/layout";
|
||||
import { SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
|
||||
export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
|
||||
return (
|
||||
<WizardLayout currentStep={1} maxSteps={5}>
|
||||
{children}
|
||||
</WizardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
const ResumeOnboardingView = () => {
|
||||
const { t } = useLocale();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const onboardingIdParam = searchParams?.get("onboardingId");
|
||||
|
||||
const { dbOnboarding, isLoadingOrgOnboarding, useOnboardingStore } = useOnboarding();
|
||||
const { reset } = useOnboardingStore();
|
||||
|
||||
useEffect(() => {
|
||||
if (isLoadingOrgOnboarding) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!onboardingIdParam) {
|
||||
router.push("/settings/organizations/new");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!dbOnboarding) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (dbOnboarding.isComplete) {
|
||||
router.push("/settings/organizations");
|
||||
return;
|
||||
}
|
||||
|
||||
// Load onboarding data into store
|
||||
reset({
|
||||
onboardingId: dbOnboarding.id,
|
||||
name: dbOnboarding.name,
|
||||
slug: dbOnboarding.slug,
|
||||
orgOwnerEmail: dbOnboarding.orgOwnerEmail,
|
||||
billingPeriod: dbOnboarding.billingPeriod,
|
||||
seats: dbOnboarding.seats,
|
||||
pricePerSeat: dbOnboarding.pricePerSeat,
|
||||
logo: dbOnboarding.logo,
|
||||
bio: dbOnboarding.bio,
|
||||
brandColor: dbOnboarding.brandColor,
|
||||
bannerUrl: dbOnboarding.bannerUrl,
|
||||
});
|
||||
|
||||
// Redirect to next step (About page)
|
||||
router.push("/settings/organizations/new/about");
|
||||
}, [dbOnboarding, isLoadingOrgOnboarding, onboardingIdParam, reset, router]);
|
||||
|
||||
if (isLoadingOrgOnboarding) {
|
||||
return (
|
||||
<SkeletonContainer className="space-y-4">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-4 w-3/4" />
|
||||
<SkeletonText className="h-4 w-1/2" />
|
||||
</SkeletonContainer>
|
||||
);
|
||||
}
|
||||
|
||||
if (!onboardingIdParam) {
|
||||
return (
|
||||
<Alert
|
||||
data-testid="error"
|
||||
severity="error"
|
||||
title={t("error")}
|
||||
message={t("no_onboarding_id_provided")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!dbOnboarding) {
|
||||
return (
|
||||
<Alert data-testid="error" severity="error" title={t("error")} message={t("onboarding_not_found")} />
|
||||
);
|
||||
}
|
||||
|
||||
if (dbOnboarding.isComplete) {
|
||||
return (
|
||||
<Alert
|
||||
data-testid="error"
|
||||
severity="info"
|
||||
title={t("onboarding_already_complete")}
|
||||
message={t("onboarding_already_complete_description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading state while redirecting
|
||||
return (
|
||||
<SkeletonContainer className="space-y-4">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-4 w-3/4" />
|
||||
</SkeletonContainer>
|
||||
);
|
||||
};
|
||||
|
||||
export default ResumeOnboardingView;
|
||||
@@ -0,0 +1,451 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { uuid } from "short-uuid";
|
||||
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
|
||||
import { test } from "../lib/fixtures";
|
||||
|
||||
/**
|
||||
* Utility function to complete the organization onboarding wizard steps
|
||||
* (about, add-teams, onboard-members)
|
||||
*/
|
||||
async function completeOnboardingWizard(
|
||||
page: Page,
|
||||
options: {
|
||||
aboutText?: string;
|
||||
teams?: string[];
|
||||
members?: string[];
|
||||
} = {}
|
||||
) {
|
||||
const { aboutText = "Test organization description", teams = ["Engineering"], members = [] } = options;
|
||||
|
||||
// Step 1: Complete "About" step
|
||||
await page.waitForURL("**/settings/organizations/new/about");
|
||||
await page.locator('textarea[name="about"]').fill(aboutText);
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Step 2: Complete "Add Teams" step
|
||||
await page.waitForURL("**/settings/organizations/new/add-teams");
|
||||
for (let i = 0; i < teams.length; i++) {
|
||||
await page.getByTestId(`team.${i}.name`).fill(teams[i]);
|
||||
if (i < teams.length - 1) {
|
||||
await page.getByTestId("add_a_team").click();
|
||||
}
|
||||
}
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Step 3: Complete "Onboard Members" step
|
||||
await page.waitForURL("**/settings/organizations/new/onboard-members");
|
||||
|
||||
// Add members if provided
|
||||
for (const memberEmail of members) {
|
||||
await page.locator('[placeholder="colleague\\@company\\.com"]').fill(memberEmail);
|
||||
await page.getByTestId("invite-new-member-button").click();
|
||||
await expect(page.getByTestId("pending-member-list")).toContainText(memberEmail);
|
||||
}
|
||||
|
||||
// Click publish and wait for response
|
||||
const responsePromise = page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**");
|
||||
await page.getByTestId("publish-button").click();
|
||||
|
||||
const response = await responsePromise;
|
||||
const responseData = await response.json();
|
||||
|
||||
// TRPC batch response is an array with one element
|
||||
const result = responseData[0].result.data.json;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
test.describe("Organization Creation Flows - Comprehensive Suite", () => {
|
||||
test.afterEach(({ users, orgs }) => {
|
||||
users.deleteAll();
|
||||
orgs.deleteAll();
|
||||
});
|
||||
|
||||
test.describe("Admin Handover Flow (Billing Disabled)", () => {
|
||||
test("Admin creates org for existing user - handover and complete", async ({ page, users }) => {
|
||||
// Setup: Create admin and future owner
|
||||
const admin = await users.create({ role: "ADMIN" });
|
||||
await admin.apiLogin();
|
||||
|
||||
const ownerEmail = users.trackEmail({
|
||||
username: "orgowner",
|
||||
domain: "example.com",
|
||||
});
|
||||
const ownerUser = await users.create({
|
||||
username: "orgowner",
|
||||
email: ownerEmail,
|
||||
});
|
||||
|
||||
const orgName = "Test Organization";
|
||||
const orgSlug = `test-org-${uuid()}`.toLowerCase();
|
||||
|
||||
// Step 1: Admin fills form
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
await page.locator("input[name=orgOwnerEmail]").fill(ownerEmail);
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgSlug);
|
||||
|
||||
// If billing enabled, admin sees billing fields but we're testing billing disabled scenario
|
||||
if (IS_TEAM_BILLING_ENABLED) {
|
||||
await page.locator("input[name=seats]").fill("10");
|
||||
await page.locator("input[name=pricePerSeat]").fill("15");
|
||||
}
|
||||
|
||||
// Submit and wait for intentToCreateOrg
|
||||
await Promise.all([
|
||||
page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**"),
|
||||
page.locator("button[type=submit]").click(),
|
||||
]);
|
||||
|
||||
// Step 2: Verify admin is on handover page
|
||||
await page.waitForURL("**/settings/organizations/new/handover");
|
||||
|
||||
const onboardingUrlElement = page.getByTestId("onboarding-url");
|
||||
await expect(onboardingUrlElement).toBeVisible();
|
||||
|
||||
const onboardingUrl = await onboardingUrlElement.textContent();
|
||||
expect(onboardingUrl).toContain("/settings/organizations/new/resume?onboardingId=");
|
||||
|
||||
// Extract onboardingId from URL
|
||||
const onboardingIdMatch = onboardingUrl?.match(/onboardingId=([\w-]+)/);
|
||||
expect(onboardingIdMatch).toBeTruthy();
|
||||
const onboardingId = onboardingIdMatch?.[1];
|
||||
|
||||
// Step 3: Switch to owner user
|
||||
await page.context().clearCookies();
|
||||
await ownerUser.apiLogin();
|
||||
|
||||
// Step 4: Owner opens resume URL and completes the onboarding wizard
|
||||
await page.goto(`/settings/organizations/new/resume?onboardingId=${onboardingId}`);
|
||||
|
||||
const memberEmail = users.trackEmail({ username: "member1", domain: "example.com" });
|
||||
const result = await completeOnboardingWizard(page, {
|
||||
aboutText: "This is our test organization",
|
||||
teams: ["Engineering", "Marketing"],
|
||||
members: [memberEmail],
|
||||
});
|
||||
|
||||
// Verify the organization was created successfully in the API response
|
||||
expect(result.organizationId).toBeTruthy();
|
||||
expect(result.name).toBe(orgName);
|
||||
expect(result.slug).toBe(orgSlug);
|
||||
});
|
||||
|
||||
test("Admin creates org for self - immediate creation", async ({ page, users }) => {
|
||||
const admin = await users.create({ role: "ADMIN" });
|
||||
await admin.apiLogin();
|
||||
|
||||
const orgName = "Admin Org";
|
||||
const orgSlug = `admin-org-${uuid()}`.toLowerCase();
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
// Fill form with admin's own email
|
||||
await page.locator("input[name=orgOwnerEmail]").fill(admin.email);
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgSlug);
|
||||
|
||||
if (IS_TEAM_BILLING_ENABLED) {
|
||||
await page.locator("input[name=seats]").fill("5");
|
||||
await page.locator("input[name=pricePerSeat]").fill("20");
|
||||
}
|
||||
|
||||
// Submit - admin creating for self goes through wizard
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Admin creating for self goes through the onboarding wizard
|
||||
const result = await completeOnboardingWizard(page, {
|
||||
aboutText: "Admin's organization",
|
||||
teams: ["Operations"],
|
||||
members: [],
|
||||
});
|
||||
|
||||
// Verify the organization was created successfully
|
||||
expect(result.organizationId).toBeTruthy();
|
||||
expect(result.name).toBe(orgName);
|
||||
expect(result.slug).toBe(orgSlug);
|
||||
});
|
||||
|
||||
test("Admin handover URL structure is correct", async ({ page, users }) => {
|
||||
const admin = await users.create({ role: "ADMIN" });
|
||||
await admin.apiLogin();
|
||||
|
||||
const ownerEmail = users.trackEmail({
|
||||
username: "testowner",
|
||||
domain: "example.com",
|
||||
});
|
||||
await users.create({
|
||||
username: "testowner",
|
||||
email: ownerEmail,
|
||||
});
|
||||
|
||||
const orgName = "Handover Test";
|
||||
const orgSlug = `handover-${uuid()}`.toLowerCase();
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
await page.locator("input[name=orgOwnerEmail]").fill(ownerEmail);
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgSlug);
|
||||
|
||||
if (IS_TEAM_BILLING_ENABLED) {
|
||||
await page.locator("input[name=seats]").fill("10");
|
||||
await page.locator("input[name=pricePerSeat]").fill("15");
|
||||
}
|
||||
|
||||
const responsePromise = page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Wait for the response
|
||||
await responsePromise;
|
||||
|
||||
// Wait for redirect to handover page
|
||||
await page.waitForURL("**/settings/organizations/new/handover");
|
||||
|
||||
// Wait for handover page elements to be visible (with longer timeout for store to populate)
|
||||
await expect(page.getByTestId("onboarding-url")).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByTestId("copy-onboarding-url")).toBeVisible();
|
||||
|
||||
const onboardingUrl = await page.getByTestId("onboarding-url").textContent();
|
||||
|
||||
// Verify URL format
|
||||
expect(onboardingUrl).toMatch(/\/settings\/organizations\/new\/resume\?onboardingId=[\w-]+/);
|
||||
|
||||
// Verify copy button is clickable (toast message may vary by locale)
|
||||
await page.getByTestId("copy-onboarding-url").click();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Non-Admin User Flow", () => {
|
||||
test("Regular user creates org through full wizard", async ({ page, users }) => {
|
||||
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
|
||||
|
||||
const user = await users.create({
|
||||
username: "regularuser",
|
||||
email: users.trackEmail({ username: "regularuser", domain: "example.com" }),
|
||||
});
|
||||
await user.apiLogin();
|
||||
|
||||
const orgName = "User Organization";
|
||||
const orgSlug = `user-org-${uuid()}`.toLowerCase();
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
// Verify user email is pre-filled and disabled
|
||||
const emailInput = page.locator("input[name=orgOwnerEmail]");
|
||||
await expect(emailInput).toBeDisabled();
|
||||
await expect(emailInput).toHaveValue(user.email);
|
||||
|
||||
// Verify billing fields are NOT visible to regular users
|
||||
if (IS_TEAM_BILLING_ENABLED) {
|
||||
await expect(page.locator("input[name=seats]")).not.toBeVisible();
|
||||
await expect(page.locator("input[name=pricePerSeat]")).not.toBeVisible();
|
||||
|
||||
// Verify "Upgrade to Organizations" UI is shown
|
||||
await expect(page.getByText(/upgrade to organizations/i)).toBeVisible();
|
||||
}
|
||||
|
||||
// Fill form
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgSlug);
|
||||
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Verify navigated to /about step
|
||||
await page.waitForURL("**/settings/organizations/new/about");
|
||||
|
||||
// Complete about step
|
||||
await page.locator('textarea[name="about"]').fill("User's organization description");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Complete add-teams step
|
||||
await page.waitForURL("**/settings/organizations/new/add-teams");
|
||||
await page.getByTestId("team.0.name").fill("Team Alpha");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Complete onboard-members step
|
||||
await page.waitForURL("**/settings/organizations/new/onboard-members");
|
||||
await Promise.all([
|
||||
page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**"),
|
||||
page.getByTestId("publish-button").click(),
|
||||
]);
|
||||
|
||||
// In billing enabled mode, should redirect to Stripe or payment
|
||||
// In billing disabled mode, org created immediately
|
||||
if (IS_TEAM_BILLING_ENABLED) {
|
||||
// Would redirect to Stripe checkout
|
||||
await page.waitForURL(/checkout\.stripe\.com|\/settings\/organizations/);
|
||||
} else {
|
||||
// After org creation, session update causes redirect to profile page
|
||||
// Wait for it to load, then navigate to organizations
|
||||
await page.waitForURL("**/settings/my-account/profile");
|
||||
await page.goto("/settings/organizations");
|
||||
await page.waitForURL(/\/settings\/organizations/);
|
||||
}
|
||||
});
|
||||
|
||||
test("Regular user - email field is disabled", async ({ page, users }) => {
|
||||
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
|
||||
|
||||
const user = await users.create({
|
||||
username: "testuser",
|
||||
email: users.trackEmail({ username: "testuser", domain: "company.com" }),
|
||||
});
|
||||
await user.apiLogin();
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
// Verify email field state
|
||||
const emailInput = page.locator("input[name=orgOwnerEmail]");
|
||||
await expect(emailInput).toBeVisible();
|
||||
await expect(emailInput).toBeDisabled();
|
||||
await expect(emailInput).toHaveValue(user.email);
|
||||
|
||||
// Verify name and slug are derived from email domain
|
||||
const nameInput = page.locator("input[name=name]");
|
||||
const slugInput = page.locator("input[name=slug]");
|
||||
|
||||
// Name should be capitalized version of domain
|
||||
const nameValue = await nameInput.inputValue();
|
||||
expect(nameValue).toBeTruthy();
|
||||
expect(nameValue).toMatch(/company/i);
|
||||
|
||||
// Slug should be domain-based
|
||||
const slugValue = await slugInput.inputValue();
|
||||
expect(slugValue).toBeTruthy();
|
||||
expect(slugValue).toContain("company");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Form Validation", () => {
|
||||
test("Admin form - required fields validation", async ({ page, users }) => {
|
||||
const admin = await users.create({ role: "ADMIN" });
|
||||
await admin.apiLogin();
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
// Submit empty form
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Should show validation errors
|
||||
await expect(page.getByTestId("field-error")).toHaveCount(2); // name and slug required
|
||||
});
|
||||
|
||||
test("Slug already taken shows error", async ({ page, users, orgs }) => {
|
||||
const admin = await users.create({ role: "ADMIN" });
|
||||
await admin.apiLogin();
|
||||
|
||||
// Create an existing org with a specific slug
|
||||
const existingSlug = `existing-${uuid()}`.toLowerCase();
|
||||
await orgs.create({
|
||||
name: "Existing Org",
|
||||
slug: existingSlug,
|
||||
});
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
const ownerEmail = users.trackEmail({ username: "owner", domain: "example.com" });
|
||||
await users.create({ username: "owner", email: ownerEmail });
|
||||
|
||||
await page.locator("input[name=orgOwnerEmail]").fill(ownerEmail);
|
||||
await page.locator("input[name=name]").fill("New Org");
|
||||
await page.locator("input[name=slug]").fill(existingSlug);
|
||||
|
||||
if (IS_TEAM_BILLING_ENABLED) {
|
||||
await page.locator("input[name=seats]").fill("5");
|
||||
await page.locator("input[name=pricePerSeat]").fill("10");
|
||||
}
|
||||
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Should show slug taken error
|
||||
await expect(page.getByText(/url.*taken|already.*exists/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Navigation Between Steps", () => {
|
||||
test("User can navigate through wizard steps", async ({ page, users }) => {
|
||||
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
|
||||
|
||||
const user = await users.create({
|
||||
username: "wizarduser",
|
||||
email: users.trackEmail({ username: "wizarduser", domain: "example.com" }),
|
||||
});
|
||||
await user.apiLogin();
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
const orgName = "Wizard Test";
|
||||
const orgSlug = `wizard-${uuid()}`.toLowerCase();
|
||||
|
||||
// Step 1
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgSlug);
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Step 2: About
|
||||
await page.waitForURL("**/settings/organizations/new/about");
|
||||
await page.locator('textarea[name="about"]').fill("Wizard test org");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Step 3: Add teams
|
||||
await page.waitForURL("**/settings/organizations/new/add-teams");
|
||||
await page.getByTestId("team.0.name").fill("Team 1");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Step 4: Onboard members
|
||||
await page.waitForURL("**/settings/organizations/new/onboard-members");
|
||||
|
||||
// Verify wizard shows correct step numbers
|
||||
// The WizardLayout component shows currentStep/maxSteps
|
||||
// Just verify we reached the last step
|
||||
await expect(page.getByTestId("publish-button")).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Billing Field Visibility", () => {
|
||||
test("Admin sees billing fields when billing enabled", async ({ page, users }) => {
|
||||
test.skip(!IS_TEAM_BILLING_ENABLED, "Billing is not enabled");
|
||||
|
||||
const admin = await users.create({ role: "ADMIN" });
|
||||
await admin.apiLogin();
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
// Verify billing fields are visible for admin
|
||||
await expect(page.locator("input[name=seats]")).toBeVisible();
|
||||
await expect(page.locator("input[name=pricePerSeat]")).toBeVisible();
|
||||
await expect(page.locator("#billingPeriod")).toBeVisible();
|
||||
|
||||
// Verify toggle between monthly/annual
|
||||
await expect(page.getByText("Monthly")).toBeVisible();
|
||||
await expect(page.getByText("Annually")).toBeVisible();
|
||||
});
|
||||
|
||||
test("Non-admin sees pricing UI but not billing fields", async ({ page, users }) => {
|
||||
test.skip(!IS_TEAM_BILLING_ENABLED, "Billing is not enabled");
|
||||
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
|
||||
|
||||
const user = await users.create({
|
||||
username: "pricinguser",
|
||||
email: users.trackEmail({ username: "pricinguser", domain: "example.com" }),
|
||||
});
|
||||
await user.apiLogin();
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
// Should NOT see admin billing fields
|
||||
await expect(page.locator("input[name=seats]")).not.toBeVisible();
|
||||
await expect(page.locator("input[name=pricePerSeat]")).not.toBeVisible();
|
||||
await expect(page.locator("#billingPeriod")).not.toBeVisible();
|
||||
|
||||
// Should see upgrade/pricing UI
|
||||
await expect(page.getByText(/upgrade to organizations/i)).toBeVisible();
|
||||
await expect(page.getByText(/organization/i)).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,475 +0,0 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { JSDOM } from "jsdom";
|
||||
import type { Messages } from "mailhog";
|
||||
import path from "path";
|
||||
import { uuid } from "short-uuid";
|
||||
|
||||
import { IS_TEAM_BILLING_ENABLED } from "@calcom/lib/constants";
|
||||
|
||||
import type { createEmailsFixture } from "../fixtures/emails";
|
||||
import { test } from "../lib/fixtures";
|
||||
import { fillStripeTestCheckout } from "../lib/testUtils";
|
||||
import { getEmailsReceivedByUser } from "../lib/testUtils";
|
||||
|
||||
async function expectEmailWithSubject(
|
||||
page: Page,
|
||||
emails: ReturnType<typeof createEmailsFixture>,
|
||||
userEmail: string,
|
||||
subject: string
|
||||
) {
|
||||
if (!emails) return null;
|
||||
|
||||
// eslint-disable-next-line playwright/no-wait-for-timeout
|
||||
await page.waitForTimeout(2000);
|
||||
const receivedEmails = await getEmailsReceivedByUser({ emails, userEmail });
|
||||
|
||||
const allEmails = (receivedEmails as Messages).items;
|
||||
const email = allEmails.find((email) => email.subject === subject);
|
||||
if (!email) {
|
||||
throw new Error(`Email with subject ${subject} not found`);
|
||||
}
|
||||
const dom = new JSDOM(email.html);
|
||||
return dom;
|
||||
}
|
||||
|
||||
export async function expectOrganizationCreationEmailToBeSent({
|
||||
page,
|
||||
emails,
|
||||
userEmail,
|
||||
orgSlug,
|
||||
}: {
|
||||
page: Page;
|
||||
emails: ReturnType<typeof createEmailsFixture>;
|
||||
userEmail: string;
|
||||
orgSlug: string;
|
||||
}) {
|
||||
const dom = await expectEmailWithSubject(page, emails, userEmail, "Your organization has been created");
|
||||
const document = dom?.window?.document;
|
||||
expect(document?.querySelector(`[href*=${orgSlug}]`)).toBeTruthy();
|
||||
return dom;
|
||||
}
|
||||
|
||||
async function expectOrganizationCreationEmailToBeSentWithLinks({
|
||||
page,
|
||||
emails,
|
||||
userEmail,
|
||||
oldUsername,
|
||||
newUsername,
|
||||
orgSlug,
|
||||
}: {
|
||||
page: Page;
|
||||
emails: ReturnType<typeof createEmailsFixture>;
|
||||
userEmail: string;
|
||||
oldUsername: string;
|
||||
newUsername: string;
|
||||
orgSlug: string;
|
||||
}) {
|
||||
const dom = await expectOrganizationCreationEmailToBeSent({
|
||||
page,
|
||||
emails,
|
||||
userEmail,
|
||||
orgSlug,
|
||||
});
|
||||
const document = dom?.window.document;
|
||||
const links = document?.querySelectorAll(`[data-testid="organization-link-info"] [href]`);
|
||||
if (!links) {
|
||||
throw new Error(`data-testid="organization-link-info doesn't have links`);
|
||||
}
|
||||
expect((links[0] as unknown as HTMLAnchorElement).href).toContain(oldUsername);
|
||||
expect((links[1] as unknown as HTMLAnchorElement).href).toContain(newUsername);
|
||||
}
|
||||
|
||||
export async function expectEmailVerificationEmailToBeSent(
|
||||
page: Page,
|
||||
emails: ReturnType<typeof createEmailsFixture>,
|
||||
userEmail: string
|
||||
) {
|
||||
const subject = "Cal.com: Verify your account";
|
||||
return expectEmailWithSubject(page, emails, userEmail, subject);
|
||||
}
|
||||
|
||||
test.afterEach(({ users, orgs }) => {
|
||||
users.deleteAll();
|
||||
orgs.deleteAll();
|
||||
});
|
||||
|
||||
function capitalize(text: string) {
|
||||
if (!text) {
|
||||
return text;
|
||||
}
|
||||
return text.charAt(0).toUpperCase() + text.slice(1);
|
||||
}
|
||||
|
||||
test.describe("Organization", () => {
|
||||
test("Admin should be able to do onboarding handover an org where an existing user is made an owner", async ({
|
||||
page,
|
||||
users,
|
||||
emails,
|
||||
}) => {
|
||||
const appLevelAdmin = await users.create({
|
||||
role: "ADMIN",
|
||||
});
|
||||
await appLevelAdmin.apiLogin();
|
||||
|
||||
const orgOwnerUsernamePrefix = "owner";
|
||||
|
||||
const orgOwnerEmail = users.trackEmail({
|
||||
username: orgOwnerUsernamePrefix,
|
||||
domain: `example.com`,
|
||||
});
|
||||
|
||||
const orgOwnerUser = await users.create({
|
||||
username: orgOwnerUsernamePrefix,
|
||||
email: orgOwnerEmail,
|
||||
role: "ADMIN",
|
||||
});
|
||||
|
||||
const orgOwnerUsernameOutsideOrg = orgOwnerUser.username;
|
||||
const orgOwnerUsernameInOrg = orgOwnerEmail.split("@")[0];
|
||||
const orgName = capitalize(`${orgOwnerUser.username}`);
|
||||
const orgSlug = `myOrg-${uuid()}`.toLowerCase();
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
await test.step("Basic info", async () => {
|
||||
// Check required fields
|
||||
await page.locator("button[type=submit]").click();
|
||||
await expect(page.getByTestId("field-error")).toHaveCount(2);
|
||||
|
||||
// Happy path
|
||||
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
|
||||
});
|
||||
|
||||
await test.step("Handover", async () => {
|
||||
const onboardingUrl = await page.getByTestId("onboarding-url").textContent();
|
||||
expect(onboardingUrl).toContain("settings/organizations/new");
|
||||
});
|
||||
});
|
||||
|
||||
test.skip("Admin should be able to create an org where the owner doesn't exist yet", async ({
|
||||
page,
|
||||
users,
|
||||
emails,
|
||||
}) => {
|
||||
const appLevelAdmin = await users.create({
|
||||
role: "ADMIN",
|
||||
});
|
||||
await appLevelAdmin.apiLogin();
|
||||
const orgOwnerUsername = `owner`;
|
||||
const orgName = capitalize(`${orgOwnerUsername}`);
|
||||
const orgSlug = `myOrg-${uuid()}`.toLowerCase();
|
||||
const orgOwnerEmail = users.trackEmail({
|
||||
username: orgOwnerUsername,
|
||||
domain: `example.com`,
|
||||
});
|
||||
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
await test.step("Basic info", async () => {
|
||||
// Check required fields
|
||||
await page.locator("button[type=submit]").click();
|
||||
await expect(page.getByTestId("field-error")).toHaveCount(2);
|
||||
|
||||
// Happy path
|
||||
await fillAndSubmitFirstStepAsAdmin(page, orgOwnerEmail, orgName, orgSlug);
|
||||
});
|
||||
|
||||
const dom = await expectOrganizationCreationEmailToBeSent({
|
||||
page,
|
||||
emails,
|
||||
userEmail: orgOwnerEmail,
|
||||
orgSlug,
|
||||
});
|
||||
expect(dom?.window.document.querySelector(`[href*=${orgSlug}]`)).toBeTruthy();
|
||||
await expectEmailVerificationEmailToBeSent(page, emails, orgOwnerEmail);
|
||||
// Rest of the steps remain same as org creation with existing user as owner. So skipping them
|
||||
});
|
||||
|
||||
test("User can create and upgrade a org", async ({ page, users, emails }) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
|
||||
const stringUUID = uuid();
|
||||
|
||||
const orgOwnerUsername = `owner-${stringUUID}`;
|
||||
|
||||
const targetOrgEmail = users.trackEmail({
|
||||
username: orgOwnerUsername,
|
||||
domain: `example.com`,
|
||||
});
|
||||
const orgOwnerUser = await users.create({
|
||||
username: orgOwnerUsername,
|
||||
email: targetOrgEmail,
|
||||
});
|
||||
|
||||
await orgOwnerUser.apiLogin();
|
||||
const orgName = capitalize(`${orgOwnerUsername}`);
|
||||
await page.goto("/settings/organizations/new");
|
||||
|
||||
await test.step("Basic info", async () => {
|
||||
// These values are inferred due to an existing user being signed
|
||||
expect(await page.locator("input[name=name]").inputValue()).toBe("Example");
|
||||
expect(await page.locator("input[name=slug]").inputValue()).toBe("example");
|
||||
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgOwnerUsername);
|
||||
|
||||
await page.locator("button[type=submit]").click();
|
||||
});
|
||||
|
||||
await test.step("About the organization", async () => {
|
||||
// Choosing an avatar
|
||||
await page.getByTestId("open-upload-avatar-dialog").click();
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByText("Choose a file...").click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(path.join(__dirname, "../../public/apple-touch-icon.png"));
|
||||
await page.getByTestId("upload-avatar").click();
|
||||
|
||||
// About text
|
||||
await page.locator('textarea[name="about"]').fill("This is a testing org");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Waiting to be in next step URL
|
||||
await page.waitForURL("/settings/organizations/*/onboard-members");
|
||||
});
|
||||
|
||||
await test.step("On-board administrators", async () => {
|
||||
await page.waitForSelector('[data-testid="pending-member-list"]');
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
|
||||
|
||||
const adminEmail = users.trackEmail({ username: "rick", domain: `example.com` });
|
||||
|
||||
//can add members
|
||||
await page.getByTestId("new-member-button").click();
|
||||
await page.locator('[placeholder="email\\@example\\.com"]').fill(adminEmail);
|
||||
await page.getByTestId("invite-new-member-button").click();
|
||||
await expect(page.getByTestId("pending-member-item").filter({ hasText: adminEmail })).toBeVisible();
|
||||
// TODO: Check if invited admin received the invitation email
|
||||
// await expectInvitationEmailToBeReceived(
|
||||
// page,
|
||||
// emails,
|
||||
// adminEmail,
|
||||
// `${orgName}'s admin invited you to join the organization ${orgName} on Cal.com`
|
||||
// );
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
|
||||
|
||||
// can remove members
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
|
||||
const lastRemoveMemberButton = page.getByTestId("remove-member-button").last();
|
||||
await lastRemoveMemberButton.click();
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
|
||||
await page.getByTestId("publish-button").click();
|
||||
// Waiting to be in next step URL
|
||||
await page.waitForURL("/settings/organizations/*/add-teams");
|
||||
});
|
||||
|
||||
await test.step("Create teams", async () => {
|
||||
// Filling one team
|
||||
await page.locator('input[name="teams.0.name"]').fill("Marketing");
|
||||
|
||||
// Adding another team
|
||||
await page.getByTestId("add_a_team").click();
|
||||
await page.locator('input[name="teams.1.name"]').fill("Sales");
|
||||
|
||||
// Finishing the creation wizard
|
||||
await page.getByTestId("continue_or_checkout").click();
|
||||
});
|
||||
|
||||
await test.step("Login as org owner and pay", async () => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!IS_TEAM_BILLING_ENABLED, "Skipping paying for org as stripe is disabled");
|
||||
await orgOwnerUser.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
const upgradeButton = await page.getByTestId("upgrade_org_banner_button");
|
||||
|
||||
await expect(upgradeButton).toBeVisible();
|
||||
await upgradeButton.click();
|
||||
// Check that stripe checkout is present
|
||||
const expectedUrl = "https://checkout.stripe.com";
|
||||
|
||||
await page.waitForURL((url) => url.href.startsWith(expectedUrl));
|
||||
const url = page.url();
|
||||
|
||||
// Check that the URL matches the expected URL
|
||||
expect(url).toContain(expectedUrl);
|
||||
|
||||
await fillStripeTestCheckout(page);
|
||||
|
||||
const upgradeButtonHidden = await page.getByTestId("upgrade_org_banner_button");
|
||||
|
||||
await expect(upgradeButtonHidden).toBeHidden();
|
||||
});
|
||||
});
|
||||
|
||||
test("User gets prompted with >=3 teams to upgrade & can transfer existing teams to org", async ({
|
||||
page,
|
||||
users,
|
||||
}) => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(process.env.NEXT_PUBLIC_ORG_SELF_SERVE_ENABLED !== "1", "Org self serve is not enabled");
|
||||
const numberOfTeams = 3;
|
||||
const stringUUID = uuid();
|
||||
|
||||
const orgOwnerUsername = `owner-${stringUUID}`;
|
||||
|
||||
const targetOrgEmail = users.trackEmail({
|
||||
username: orgOwnerUsername,
|
||||
domain: `example.com`,
|
||||
});
|
||||
const orgOwnerUser = await users.create(
|
||||
{
|
||||
username: orgOwnerUsername,
|
||||
email: targetOrgEmail,
|
||||
},
|
||||
{ hasTeam: true, numberOfTeams }
|
||||
);
|
||||
|
||||
await orgOwnerUser.apiLogin();
|
||||
|
||||
await page.goto("/teams");
|
||||
|
||||
await test.step("Has org self serve banner", async () => {
|
||||
// These values are inferred due to an existing user being signed
|
||||
const selfServeButtonLocator = await page.getByTestId("setup_your_org_action_button");
|
||||
await expect(selfServeButtonLocator).toBeVisible();
|
||||
|
||||
await selfServeButtonLocator.click();
|
||||
await page.waitForURL("/settings/organizations/new");
|
||||
});
|
||||
|
||||
await test.step("Basic info", async () => {
|
||||
// These values are inferred due to an existing user being signed
|
||||
const slugLocator = await page.locator("input[name=slug]");
|
||||
expect(await page.locator("input[name=name]").inputValue()).toBe("Example");
|
||||
expect(await slugLocator.inputValue()).toBe("example");
|
||||
|
||||
await slugLocator.fill(`example-${stringUUID}`);
|
||||
|
||||
await page.locator("button[type=submit]").click();
|
||||
});
|
||||
|
||||
await test.step("About the organization", async () => {
|
||||
// Choosing an avatar
|
||||
await page.getByTestId("open-upload-avatar-dialog").click();
|
||||
const fileChooserPromise = page.waitForEvent("filechooser");
|
||||
await page.getByText("Choose a file...").click();
|
||||
const fileChooser = await fileChooserPromise;
|
||||
await fileChooser.setFiles(path.join(__dirname, "../../public/apple-touch-icon.png"));
|
||||
await page.getByTestId("upload-avatar").click();
|
||||
|
||||
// About text
|
||||
await page.locator('textarea[name="about"]').fill("This is a testing org");
|
||||
await page.locator("button[type=submit]").click();
|
||||
|
||||
// Waiting to be in next step URL
|
||||
await page.waitForURL("/settings/organizations/*/onboard-members");
|
||||
});
|
||||
|
||||
await test.step("On-board administrators", async () => {
|
||||
await page.waitForSelector('[data-testid="pending-member-list"]');
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
|
||||
|
||||
const adminEmail = users.trackEmail({ username: "rick", domain: `example.com` });
|
||||
|
||||
//can add members
|
||||
await page.getByTestId("new-member-button").click();
|
||||
await page.locator('[placeholder="email\\@example\\.com"]').fill(adminEmail);
|
||||
await page.getByTestId("invite-new-member-button").click();
|
||||
await expect(page.getByTestId("pending-member-item").filter({ hasText: adminEmail })).toBeVisible();
|
||||
// TODO: Check if invited admin received the invitation email
|
||||
// await expectInvitationEmailToBeReceived(
|
||||
// page,
|
||||
// emails,
|
||||
// adminEmail,
|
||||
// `${orgName}'s admin invited you to join the organization ${orgName} on Cal.com`
|
||||
// );
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
|
||||
|
||||
// can remove members
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
|
||||
const lastRemoveMemberButton = page.getByTestId("remove-member-button").last();
|
||||
await lastRemoveMemberButton.click();
|
||||
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
|
||||
await page.getByTestId("publish-button").click();
|
||||
// Waiting to be in next step URL
|
||||
await page.waitForURL("/settings/organizations/*/add-teams");
|
||||
});
|
||||
|
||||
await test.step("Move existing teams to org", async () => {
|
||||
// No easy way to get all team checkboxes so we fill all checkboxes on the page in
|
||||
const foundCheckboxes = page.locator('input[type="checkbox"]');
|
||||
const count = await foundCheckboxes.count();
|
||||
for (let i = 0; i < count; i++) {
|
||||
const checkbox = foundCheckboxes.nth(i);
|
||||
await checkbox.click();
|
||||
}
|
||||
});
|
||||
|
||||
await test.step("Create teams", async () => {
|
||||
// Filling one team
|
||||
await page.locator('input[name="teams.0.name"]').fill("Marketing");
|
||||
|
||||
// Adding another team
|
||||
await page.getByTestId("new-team-dialog-button").click();
|
||||
await page.locator('input[name="teams.1.name"]').fill("Sales");
|
||||
|
||||
// Finishing the creation wizard
|
||||
await page.getByTestId("continue_or_checkout").click();
|
||||
});
|
||||
|
||||
await test.step("Login as org owner and pay", async () => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
test.skip(!IS_TEAM_BILLING_ENABLED, "Skipping paying for org as stripe is disabled");
|
||||
await orgOwnerUser.apiLogin();
|
||||
await page.goto("/event-types");
|
||||
const upgradeButton = await page.getByTestId("upgrade_org_banner_button");
|
||||
|
||||
await expect(upgradeButton).toBeVisible();
|
||||
await upgradeButton.click();
|
||||
// Check that stripe checkout is present
|
||||
const expectedUrl = "https://checkout.stripe.com";
|
||||
|
||||
await page.waitForURL((url) => url.href.startsWith(expectedUrl));
|
||||
const url = page.url();
|
||||
|
||||
// Check that the URL matches the expected URL
|
||||
expect(url).toContain(expectedUrl);
|
||||
|
||||
await fillStripeTestCheckout(page);
|
||||
|
||||
const upgradeButtonHidden = await page.getByTestId("upgrade_org_banner_button");
|
||||
|
||||
await expect(upgradeButtonHidden).toBeHidden();
|
||||
});
|
||||
|
||||
await test.step("Ensure correctnumberOfTeams are migrated", async () => {
|
||||
// eslint-disable-next-line playwright/no-skipped-test
|
||||
await page.goto("/teams");
|
||||
const teamListItems = await page.getByTestId("team-list-item-link").all();
|
||||
|
||||
// Number of teams migrated + the two created in the create teams step
|
||||
expect(teamListItems.length).toBe(numberOfTeams + 2);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function fillAndSubmitFirstStepAsAdmin(
|
||||
page: Page,
|
||||
targetOrgEmail: string,
|
||||
orgName: string,
|
||||
orgSlug: string
|
||||
) {
|
||||
await page.locator("input[name=orgOwnerEmail]").fill(targetOrgEmail);
|
||||
// Since we are admin fill in this information instead of deriving it
|
||||
await page.locator("input[name=name]").fill(orgName);
|
||||
await page.locator("input[name=slug]").fill(orgSlug);
|
||||
|
||||
// Fill in seat information
|
||||
await page.locator("input[name=seats]").fill("30");
|
||||
await page.locator("input[name=pricePerSeat]").fill("30");
|
||||
|
||||
await Promise.all([
|
||||
page.waitForResponse("**/api/trpc/organizations/intentToCreateOrg**"),
|
||||
page.locator("button[type=submit]").click(),
|
||||
]);
|
||||
}
|
||||
@@ -3299,6 +3299,12 @@
|
||||
"organization_onboarding_already_completed": "Organization onboarding already completed",
|
||||
"organization_onboarding_already_exists": "Organization onboarding already exists",
|
||||
"organization_onboarding_not_found": "Organization onboarding not found",
|
||||
"no_onboarding_id_provided": "No onboarding ID provided",
|
||||
"onboarding_not_found": "Onboarding not found",
|
||||
"onboarding_already_complete": "Onboarding already complete",
|
||||
"onboarding_already_complete_description": "This onboarding has already been completed. You can view your organization in settings.",
|
||||
"resume_onboarding": "Resume Onboarding",
|
||||
"resume_onboarding_description": "Continue with your organization onboarding process",
|
||||
"payment_successful": "Payment successful!",
|
||||
"handover_onboarding_page_title": "Handover onboarding",
|
||||
"handover_onboarding_page_description": "Handover onboarding to the user",
|
||||
|
||||
Reference in new issue
Block a user