feat: organization upgrade flow v3 (#25972)
* wip flow * add tests * WIP migrateing view * push back step * fix tests and logic for adding new members to existing teams * few UI fixes * type fixes * fix nits * few UI + re-route fixes * fix teamId when migrating
This commit is contained in:
1 parent
8848efef4f
commit
a359f0ba01
22 files changed
+1747
-79
No files matched your search
@@ -0,0 +1,38 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils";
|
||||
import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { OrganizationMigrateMembersView } from "~/onboarding/organization/migrate-members/organization-migrate-members-view";
|
||||
|
||||
export default async function MigrateMembersPage() {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const userEmail = session.user.email || "";
|
||||
const userId = session.user.id;
|
||||
|
||||
const gettingStartedPath = await OnboardingPathService.getGettingStartedPath(prisma);
|
||||
|
||||
if (!isCompanyEmail(userEmail)) {
|
||||
return redirect(gettingStartedPath);
|
||||
}
|
||||
|
||||
const userRepository = new UserRepository(prisma);
|
||||
const isMemberOfOrganization = await userRepository.findIfAMemberOfSomeOrganization({ user: { id: userId } });
|
||||
|
||||
if (isMemberOfOrganization) {
|
||||
return redirect(gettingStartedPath);
|
||||
}
|
||||
|
||||
return <OrganizationMigrateMembersView userEmail={userEmail} />;
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
|
||||
import { isCompanyEmail } from "@calcom/features/ee/organizations/lib/utils";
|
||||
import { TeamRepository } from "@calcom/features/ee/teams/repositories/TeamRepository";
|
||||
import { OnboardingPathService } from "@calcom/features/onboarding/lib/onboarding-path.service";
|
||||
import { UserRepository } from "@calcom/features/users/repositories/UserRepository";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
|
||||
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
|
||||
|
||||
import { OrganizationMigrateTeamsView } from "~/onboarding/organization/migrate-teams/organization-migrate-teams-view";
|
||||
|
||||
type PageProps = {
|
||||
searchParams: Promise<{ migrate?: string }>;
|
||||
};
|
||||
|
||||
export default async function MigrateTeamsPage({ searchParams }: PageProps) {
|
||||
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
|
||||
|
||||
if (!session?.user?.id) {
|
||||
return redirect("/auth/login");
|
||||
}
|
||||
|
||||
const userEmail = session.user.email || "";
|
||||
const userId = session.user.id;
|
||||
|
||||
const gettingStartedPath = await OnboardingPathService.getGettingStartedPath(prisma);
|
||||
|
||||
if (!isCompanyEmail(userEmail)) {
|
||||
return redirect(gettingStartedPath);
|
||||
}
|
||||
|
||||
const userRepository = new UserRepository(prisma);
|
||||
const { organizations } = await userRepository.findOrganizations({ userId });
|
||||
|
||||
if (organizations.length > 0) {
|
||||
return redirect(gettingStartedPath);
|
||||
}
|
||||
|
||||
// Check for migrate query param
|
||||
const params = await searchParams;
|
||||
const migrateParam = params?.migrate;
|
||||
|
||||
// If no migrate param, redirect to teams step
|
||||
if (migrateParam !== "true") {
|
||||
return redirect("/onboarding/organization/teams");
|
||||
}
|
||||
|
||||
// Check if user has teams to migrate
|
||||
const teamRepository = new TeamRepository(prisma);
|
||||
const ownedTeams = await teamRepository.findOwnedTeamsByUserId({ userId });
|
||||
|
||||
// If no teams, redirect to teams step
|
||||
if (ownedTeams.length === 0) {
|
||||
return redirect("/onboarding/organization/teams");
|
||||
}
|
||||
|
||||
return <OrganizationMigrateTeamsView userEmail={userEmail} />;
|
||||
}
|
||||
@@ -5,9 +5,11 @@ import { z } from "zod";
|
||||
|
||||
import stripe from "@calcom/features/ee/payments/server/stripe";
|
||||
import { FeaturesRepository } from "@calcom/features/flags/features.repository";
|
||||
import { OrganizationOnboardingRepository } from "@calcom/features/organizations/repositories/OrganizationOnboardingRepository";
|
||||
import { WEBAPP_URL } from "@calcom/lib/constants";
|
||||
import { HttpError } from "@calcom/lib/http-error";
|
||||
import { prisma } from "@calcom/prisma";
|
||||
import { orgOnboardingTeamsSchema } from "@calcom/prisma/zod-utils";
|
||||
|
||||
const querySchema = z.object({
|
||||
session_id: z.string().min(1),
|
||||
@@ -43,6 +45,19 @@ async function getHandler(req: NextRequest) {
|
||||
|
||||
// If onboarding-v3 is enabled AND organizationOnboardingId exists, redirect to onboarding flow
|
||||
if (isOnboardingV3Enabled && organizationOnboardingId) {
|
||||
// Check if this is a migration flow (user has already completed onboarding)
|
||||
const onboarding = await OrganizationOnboardingRepository.findById(organizationOnboardingId);
|
||||
const hasMigratedTeams =
|
||||
onboarding?.teams &&
|
||||
orgOnboardingTeamsSchema.parse(onboarding.teams).some((team) => team.isBeingMigrated);
|
||||
|
||||
if (hasMigratedTeams) {
|
||||
// Migration flow - user already completed onboarding, redirect to event-types
|
||||
const redirectUrl = new URL("/event-types?newOrganizationModal=true", WEBAPP_URL).toString();
|
||||
return NextResponse.redirect(redirectUrl);
|
||||
}
|
||||
|
||||
// Regular flow - redirect to personal onboarding
|
||||
params.append("fromTeamOnboarding", "true");
|
||||
const redirectUrl = new URL(
|
||||
`/onboarding/personal/settings?${params.toString()}`,
|
||||
|
||||
@@ -6,6 +6,8 @@ import { usePathname } from "next/navigation";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import classNames from "@calcom/ui/classNames";
|
||||
|
||||
import { useOnboardingStore, type Invite } from "../store/onboarding-store";
|
||||
|
||||
@@ -26,6 +28,7 @@ type DisplayItem = {
|
||||
email: string;
|
||||
team: string;
|
||||
isReal?: boolean;
|
||||
isMigrated?: boolean;
|
||||
};
|
||||
|
||||
export const OnboardingInviteBrowserView = ({
|
||||
@@ -35,8 +38,16 @@ export const OnboardingInviteBrowserView = ({
|
||||
}: OnboardingInviteBrowserViewProps) => {
|
||||
const pathname = usePathname();
|
||||
const { data: user } = trpc.viewer.me.get.useQuery();
|
||||
const { teamBrand, teamInvites, invites, teamDetails, organizationBrand, organizationDetails } =
|
||||
useOnboardingStore();
|
||||
const {
|
||||
teamBrand,
|
||||
teamInvites,
|
||||
invites,
|
||||
teamDetails,
|
||||
organizationBrand,
|
||||
organizationDetails,
|
||||
migratedMembers,
|
||||
teams,
|
||||
} = useOnboardingStore();
|
||||
const { t } = useLocale();
|
||||
|
||||
// Animation variants for entry and exit
|
||||
@@ -83,6 +94,32 @@ export const OnboardingInviteBrowserView = ({
|
||||
// Filter out empty invites (where email is empty or just whitespace)
|
||||
const validInvites = actualInvites.filter((invite) => invite.email && invite.email.trim().length > 0);
|
||||
|
||||
// Add migrated members if using organization invites
|
||||
const migratedInvites: Invite[] = [];
|
||||
if (useOrganizationInvites && migratedMembers.length > 0) {
|
||||
migratedInvites.push(
|
||||
...migratedMembers.map((member) => {
|
||||
// Find team name from teamId
|
||||
const team = teams.find((t) => t.id === member.teamId);
|
||||
return {
|
||||
email: member.email,
|
||||
team: team?.name || "",
|
||||
role: "MEMBER" as const,
|
||||
};
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// Combine form invites with migrated members, avoiding duplicates
|
||||
const allInvites = [...validInvites];
|
||||
const existingEmails = new Set(validInvites.map((inv) => inv.email.toLowerCase()));
|
||||
migratedInvites.forEach((migratedInvite) => {
|
||||
if (!existingEmails.has(migratedInvite.email.toLowerCase())) {
|
||||
allInvites.push(migratedInvite);
|
||||
existingEmails.add(migratedInvite.email.toLowerCase());
|
||||
}
|
||||
});
|
||||
|
||||
// Create empty state items
|
||||
const emptyStateItem = {
|
||||
name: t("team_member"),
|
||||
@@ -95,14 +132,19 @@ export const OnboardingInviteBrowserView = ({
|
||||
const displayItems: DisplayItem[] = [];
|
||||
const maxItems = 9;
|
||||
|
||||
// Add actual invites first
|
||||
for (let i = 0; i < validInvites.length && i < maxItems; i++) {
|
||||
const invite = validInvites[i];
|
||||
// Create a set of migrated member emails for quick lookup
|
||||
const migratedEmails = new Set(migratedMembers.map((member) => member.email.toLowerCase()));
|
||||
|
||||
// Add all invites (form invites + migrated members)
|
||||
for (let i = 0; i < allInvites.length && i < maxItems; i++) {
|
||||
const invite = allInvites[i];
|
||||
const isMigrated = migratedEmails.has(invite.email.toLowerCase());
|
||||
displayItems.push({
|
||||
name: invite.email.split("@")[0] || t("team_member"),
|
||||
email: invite.email,
|
||||
team: invite.team || t("team"),
|
||||
isReal: true,
|
||||
isMigrated,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -134,30 +176,65 @@ export const OnboardingInviteBrowserView = ({
|
||||
ease: "backOut",
|
||||
}}>
|
||||
<div className="bg-default border-subtle flex flex-col rounded-2xl border">
|
||||
<div className="relative p-1">
|
||||
{/* Banner Image */}
|
||||
{organizationBrand.banner && (
|
||||
<div className="border-subtle relative h-36 w-full overflow-hidden rounded-xl border">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={organizationBrand.banner}
|
||||
alt={displayName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{useOrganizationInvites || organizationBrand.banner || avatar ? (
|
||||
<div className="relative p-1">
|
||||
{/* Banner Image */}
|
||||
{useOrganizationInvites && (
|
||||
<div className="border-subtle relative h-36 w-full overflow-hidden rounded-xl border">
|
||||
{organizationBrand.banner ? (
|
||||
/* eslint-disable-next-line @next/next/no-img-element */
|
||||
<img
|
||||
src={organizationBrand.banner}
|
||||
alt={displayName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-emphasis h-full w-full" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!useOrganizationInvites && organizationBrand.banner && (
|
||||
<div className="border-subtle relative h-36 w-full overflow-hidden rounded-xl border">
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={organizationBrand.banner}
|
||||
alt={displayName}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Organization Avatar - Overlaying the banner */}
|
||||
{organizationBrand.banner && avatar && (
|
||||
<div className="absolute -bottom-6 left-4">
|
||||
<Avatar size="lg" imageSrc={avatar} alt={displayName} className="h-12 w-12 border" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* Organization Avatar - Overlaying the banner */}
|
||||
{useOrganizationInvites && avatar && (
|
||||
<div className="absolute -bottom-6 left-4">
|
||||
<Avatar
|
||||
size="lg"
|
||||
imageSrc={avatar}
|
||||
alt={displayName}
|
||||
className="h-12 w-12 border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!useOrganizationInvites && organizationBrand.banner && avatar && (
|
||||
<div className="absolute -bottom-6 left-4">
|
||||
<Avatar
|
||||
size="lg"
|
||||
imageSrc={avatar}
|
||||
alt={displayName}
|
||||
className="h-12 w-12 border"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Organization Info */}
|
||||
<div className={`flex flex-col items-start gap-1 px-4 pb-4 pt-8`}>
|
||||
{!organizationBrand.banner && avatar && (
|
||||
<div
|
||||
className={classNames(
|
||||
`flex flex-col items-start gap-1 px-4 pb-4`,
|
||||
useOrganizationInvites || organizationBrand.banner || avatar ? "pt-8" : "pt-4"
|
||||
)}>
|
||||
{!useOrganizationInvites && !organizationBrand.banner && avatar && (
|
||||
<Avatar
|
||||
size="lg"
|
||||
imageSrc={avatar}
|
||||
@@ -192,11 +269,18 @@ export const OnboardingInviteBrowserView = ({
|
||||
{item.email}
|
||||
</p>
|
||||
</div>
|
||||
{item.team && (
|
||||
<div className="bg-emphasis text-emphasis rounded-md px-2 py-0.5 text-xs">
|
||||
{item.team}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex flex-col items-center gap-1">
|
||||
{item.team && !item.isMigrated && (
|
||||
<div className="bg-emphasis text-emphasis line-clamp-1 max-w-[86px] truncate rounded-md px-2 py-0.5 text-xs">
|
||||
{item.team}
|
||||
</div>
|
||||
)}
|
||||
{item.isMigrated && (
|
||||
<Badge variant="green" className="text-xs">
|
||||
{t("migrating")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
type MigratedMember = {
|
||||
email: string;
|
||||
name?: string | null;
|
||||
avatarUrl?: string | null;
|
||||
teamId: number;
|
||||
teamName?: string;
|
||||
};
|
||||
|
||||
type OnboardingMigrateMembersBrowserViewProps = {
|
||||
members: MigratedMember[];
|
||||
organizationLogo?: string | null;
|
||||
organizationName?: string;
|
||||
organizationBanner?: string | null;
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
export const OnboardingMigrateMembersBrowserView = ({
|
||||
members,
|
||||
organizationLogo,
|
||||
organizationName,
|
||||
organizationBanner,
|
||||
slug,
|
||||
}: OnboardingMigrateMembersBrowserViewProps) => {
|
||||
const pathname = usePathname();
|
||||
const { t } = useLocale();
|
||||
const displayUrl = slug ? `${slug}.${subdomainSuffix()}` : subdomainSuffix();
|
||||
|
||||
const containerVariants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: 20,
|
||||
},
|
||||
};
|
||||
|
||||
const hasMembers = members.length > 0;
|
||||
|
||||
return (
|
||||
<div className="bg-default border-subtle hidden h-full w-full flex-col overflow-hidden rounded-l-2xl border-y border-s xl:flex">
|
||||
{/* Browser header */}
|
||||
<div className="border-subtle bg-default flex min-w-0 shrink-0 items-center gap-3 rounded-t-2xl border-b p-3">
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex shrink-0 items-center gap-4 opacity-50">
|
||||
<Icon name="arrow-left" className="text-subtle h-4 w-4" />
|
||||
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
|
||||
<Icon name="rotate-cw" className="text-subtle h-4 w-4" />
|
||||
</div>
|
||||
<div className="bg-cal-muted flex w-full min-w-0 items-center gap-2 rounded-[32px] px-3 py-2">
|
||||
<Icon name="lock" className="text-subtle h-4 w-4" />
|
||||
<p className="text-default truncate text-sm font-medium leading-tight">{displayUrl}/members</p>
|
||||
</div>
|
||||
<Icon name="ellipsis-vertical" className="text-subtle h-4 w-4" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-cal-muted h-full pl-11 pt-11">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={pathname}
|
||||
className="bg-default border-muted flex h-full w-full flex-col overflow-hidden rounded-tl-xl border"
|
||||
variants={containerVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
ease: "backOut",
|
||||
}}>
|
||||
{/* Members Header with Banner */}
|
||||
<div className="border-subtle flex flex-col border-b">
|
||||
<div className="relative">
|
||||
{/* Banner Image */}
|
||||
<div className="border-subtle relative h-40 w-full overflow-hidden border-b">
|
||||
{organizationBanner ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={organizationBanner}
|
||||
alt={organizationName || ""}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-emphasis h-full w-full" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Organization Logo - Overlaying the banner */}
|
||||
{organizationLogo && (
|
||||
<div className="absolute -bottom-8 left-4">
|
||||
<Avatar
|
||||
size="lg"
|
||||
imageSrc={organizationLogo}
|
||||
alt={organizationName || ""}
|
||||
className="border-4 border-white"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Members Info */}
|
||||
<div className="flex flex-col gap-2 px-4 pb-4 pt-12">
|
||||
<h2 className="text-emphasis text-xl font-semibold leading-tight">{t("members")}</h2>
|
||||
{hasMembers && (
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{members.length === 1
|
||||
? t("migrating_members_count", { count: members.length })
|
||||
: t("migrating_members_count_plural", { count: members.length })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Members List */}
|
||||
<div className="flex flex-col overflow-y-auto">
|
||||
{hasMembers ? (
|
||||
<>
|
||||
{members.map((member, index) => (
|
||||
<div key={member.email}>
|
||||
{index > 0 && <div className="border-subtle h-px border-t" />}
|
||||
<div className="flex items-center gap-3 px-5 py-4">
|
||||
<Avatar
|
||||
size="md"
|
||||
alt={member.name || member.email}
|
||||
imageSrc={member.avatarUrl || undefined}
|
||||
className="border-2 border-white"
|
||||
fallback={
|
||||
<div className="bg-emphasis flex h-full w-full items-center justify-center text-white">
|
||||
<span className="text-sm font-semibold">
|
||||
{(member.name || member.email).charAt(0).toUpperCase()}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-subtle text-sm font-semibold leading-none">
|
||||
{member.name || member.email}
|
||||
</h3>
|
||||
<Badge variant="green" className="text-xs">
|
||||
{t("migrating")}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="text-muted text-xs font-medium leading-tight">{member.email}</p>
|
||||
</div>
|
||||
<Icon name="check" className="text-emphasis h-5 w-5" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-5 py-12">
|
||||
<div className="bg-cal-muted flex h-16 w-16 items-center justify-center rounded-full">
|
||||
<Icon name="user-plus" className="text-subtle h-8 w-8" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<p className="text-default truncate text-sm font-semibold leading-tight">
|
||||
{t("no_members_to_migrate")}
|
||||
</p>
|
||||
<p className="text-subtle truncate text-xs font-medium leading-tight">
|
||||
{t("members_will_be_added_to_organization")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client";
|
||||
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { usePathname } from "next/navigation";
|
||||
|
||||
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
type OnboardingMigrateTeamsBrowserViewProps = {
|
||||
teams: Array<{ id: number; name: string; slug: string | null; isSelected?: boolean }>;
|
||||
organizationLogo?: string | null;
|
||||
organizationName?: string;
|
||||
organizationBanner?: string | null;
|
||||
slug?: string;
|
||||
};
|
||||
|
||||
export const OnboardingMigrateTeamsBrowserView = ({
|
||||
teams,
|
||||
organizationLogo,
|
||||
organizationName,
|
||||
organizationBanner,
|
||||
slug,
|
||||
}: OnboardingMigrateTeamsBrowserViewProps) => {
|
||||
const pathname = usePathname();
|
||||
const { t } = useLocale();
|
||||
const displayUrl = slug ? `${slug}.${subdomainSuffix()}` : subdomainSuffix();
|
||||
|
||||
const selectedTeams = teams.filter((team) => team.isSelected);
|
||||
const hasValidTeams = teams.length > 0;
|
||||
|
||||
const containerVariants = {
|
||||
initial: {
|
||||
opacity: 0,
|
||||
y: -20,
|
||||
},
|
||||
animate: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: 20,
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="bg-default border-subtle hidden h-full w-full flex-col overflow-hidden rounded-l-2xl border-y border-s xl:flex">
|
||||
{/* Browser header */}
|
||||
<div className="border-subtle bg-default flex min-w-0 shrink-0 items-center gap-3 rounded-t-2xl border-b p-3">
|
||||
{/* Navigation buttons */}
|
||||
<div className="flex shrink-0 items-center gap-4 opacity-50">
|
||||
<Icon name="arrow-left" className="text-subtle h-4 w-4" />
|
||||
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
|
||||
<Icon name="rotate-cw" className="text-subtle h-4 w-4" />
|
||||
</div>
|
||||
<div className="bg-cal-muted flex w-full min-w-0 items-center gap-2 rounded-[32px] px-3 py-2">
|
||||
<Icon name="lock" className="text-subtle h-4 w-4" />
|
||||
<p className="text-default truncate text-sm font-medium leading-tight">{displayUrl}/teams</p>
|
||||
</div>
|
||||
<Icon name="ellipsis-vertical" className="text-subtle h-4 w-4" />
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="bg-cal-muted h-full pl-11 pt-11">
|
||||
<AnimatePresence mode="wait">
|
||||
<motion.div
|
||||
key={pathname}
|
||||
className="bg-default border-muted flex h-full w-full flex-col overflow-hidden rounded-tl-xl border"
|
||||
variants={containerVariants}
|
||||
initial="initial"
|
||||
animate="animate"
|
||||
exit="exit"
|
||||
transition={{
|
||||
duration: 0.5,
|
||||
ease: "backOut",
|
||||
}}>
|
||||
{/* Teams Header with Banner */}
|
||||
<div className="border-subtle flex flex-col border-b">
|
||||
<div className="relative">
|
||||
{/* Banner Image */}
|
||||
<div className="border-subtle relative h-40 w-full overflow-hidden border-b">
|
||||
{organizationBanner ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={organizationBanner}
|
||||
alt={organizationName || ""}
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="bg-emphasis h-full w-full" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Organization Logo - Overlaying the banner */}
|
||||
{organizationLogo && (
|
||||
<div className="absolute -bottom-8 left-4">
|
||||
<Avatar
|
||||
size="lg"
|
||||
imageSrc={organizationLogo}
|
||||
alt={organizationName || ""}
|
||||
className="border-default border-4"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Teams Info */}
|
||||
<div className="flex flex-col gap-2 px-4 pb-4 pt-12">
|
||||
<h2 className="text-emphasis text-xl font-semibold leading-tight">{t("teams")}</h2>
|
||||
{selectedTeams.length > 0 && (
|
||||
<p className="text-subtle text-sm font-medium leading-tight">
|
||||
{selectedTeams.length === 1
|
||||
? t("migrating_teams_count", { count: selectedTeams.length })
|
||||
: t("migrating_teams_count_plural", { count: selectedTeams.length })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Teams List */}
|
||||
<div className="flex flex-col overflow-y-auto">
|
||||
{hasValidTeams ? (
|
||||
<>
|
||||
{teams.map((team, index) => (
|
||||
<div key={team.id}>
|
||||
{index > 0 && <div className="border-subtle h-px border-t" />}
|
||||
<div className="flex items-center gap-3 px-5 py-4">
|
||||
<Avatar
|
||||
size="md"
|
||||
alt={team.name}
|
||||
className="border-default border-2"
|
||||
fallback={
|
||||
<div className="bg-emphasis flex h-full w-full items-center justify-center text-white">
|
||||
<span className="text-sm font-semibold uppercase">{team.name.charAt(0)}</span>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h3 className="text-subtle line-clamp-1 min-w-0 truncate text-sm font-semibold leading-none">
|
||||
{team.name}
|
||||
</h3>
|
||||
{team.isSelected && (
|
||||
<Badge variant="green" className="shrink-0 text-xs">
|
||||
{t("migrating")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted text-xs font-medium leading-tight">
|
||||
{team.slug && slug
|
||||
? `${slug}.${subdomainSuffix()}/${team.slug}`
|
||||
: team.slug
|
||||
? `${team.slug}.${subdomainSuffix()}`
|
||||
: t("team_slug_pending")}
|
||||
</p>
|
||||
</div>
|
||||
{team.isSelected ? (
|
||||
<Icon name="check" className="text-emphasis h-5 w-5" />
|
||||
) : (
|
||||
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-3 px-5 py-12">
|
||||
<div className="bg-cal-muted flex h-16 w-16 items-center justify-center rounded-full">
|
||||
<Icon name="users" className="text-subtle h-8 w-8" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 text-center">
|
||||
<p className="text-default truncate text-sm font-semibold leading-tight">
|
||||
{t("no_teams_to_migrate")}
|
||||
</p>
|
||||
<p className="text-subtle truncate text-xs font-medium leading-tight">
|
||||
{t("create_teams_in_next_step")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -6,10 +6,11 @@ import { usePathname } from "next/navigation";
|
||||
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
|
||||
type OnboardingTeamsBrowserViewProps = {
|
||||
teams: Array<{ name: string }>;
|
||||
teams: Array<{ name: string; slug?: string | null; isMigrated?: boolean }>;
|
||||
organizationLogo?: string | null;
|
||||
organizationName?: string;
|
||||
organizationBanner?: string | null;
|
||||
@@ -135,12 +136,29 @@ export const OnboardingTeamsBrowserView = ({
|
||||
}
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<h3 className="text-subtle text-sm font-semibold leading-none">{team.name}</h3>
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<h3 className="text-subtle line-clamp-1 min-w-0 truncate text-sm font-semibold leading-none">
|
||||
{team.name}
|
||||
</h3>
|
||||
{team.isMigrated && (
|
||||
<Badge variant="green" className="shrink-0 text-xs">
|
||||
{t("migrating")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-muted text-xs font-medium leading-tight">
|
||||
{t("onboarding_teams_browser_view_team_description")}
|
||||
{team.slug && slug
|
||||
? `${slug}.${subdomainSuffix()}/${team.slug}`
|
||||
: team.slug
|
||||
? `${team.slug}.${subdomainSuffix()}`
|
||||
: t("onboarding_teams_browser_view_team_description")}
|
||||
</p>
|
||||
</div>
|
||||
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
|
||||
{team.isMigrated ? (
|
||||
<Icon name="check" className="text-emphasis h-5 w-5" />
|
||||
) : (
|
||||
<Icon name="arrow-right" className="text-subtle h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { CreationSource } from "@calcom/prisma/enums";
|
||||
|
||||
import type { OnboardingState } from "../../store/onboarding-store";
|
||||
import { useSubmitOnboarding } from "../useSubmitOnboarding";
|
||||
|
||||
vi.mock("react", async () => {
|
||||
const actual = await vi.importActual<typeof React>("react");
|
||||
return {
|
||||
...actual,
|
||||
useState: vi.fn((initial: any) => [initial, vi.fn()]),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@calcom/features/flags/context/provider", () => ({
|
||||
useFlagMap: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/features/ee/organizations/hooks/useWelcomeModal", () => ({
|
||||
setShowNewOrgModalFlag: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/ui/components/toast", () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@calcom/trpc/react", () => ({
|
||||
trpc: {
|
||||
viewer: {
|
||||
organizations: {
|
||||
intentToCreateOrg: {
|
||||
useMutation: vi.fn(),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("useSubmitOnboarding", () => {
|
||||
let mockMutateAsync: ReturnType<typeof vi.fn>;
|
||||
let mockResetOnboarding: ReturnType<typeof vi.fn>;
|
||||
let mockUseMutation: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockMutateAsync = vi.fn();
|
||||
mockResetOnboarding = vi.fn();
|
||||
mockUseMutation = vi.fn(() => ({
|
||||
mutateAsync: mockMutateAsync,
|
||||
}));
|
||||
|
||||
// Mock window for skipToPersonal
|
||||
global.window = {
|
||||
location: {
|
||||
href: "",
|
||||
},
|
||||
} as any;
|
||||
|
||||
const { trpc } = await import("@calcom/trpc/react");
|
||||
vi.mocked(trpc.viewer.organizations.intentToCreateOrg.useMutation).mockImplementation(
|
||||
mockUseMutation as any
|
||||
);
|
||||
});
|
||||
|
||||
it("should submit teams with migration data correctly", async () => {
|
||||
const hook = useSubmitOnboarding();
|
||||
const { submitOnboarding } = hook;
|
||||
|
||||
const store = {
|
||||
selectedPlan: "organization",
|
||||
organizationDetails: {
|
||||
name: "Test Org",
|
||||
link: "test-org",
|
||||
bio: "Test bio",
|
||||
},
|
||||
organizationBrand: {
|
||||
color: "#000000",
|
||||
logo: "logo.png",
|
||||
banner: "banner.png",
|
||||
},
|
||||
teams: [
|
||||
{ id: 1, name: "Existing Team", slug: "existing-team", isBeingMigrated: true },
|
||||
{ id: -1, name: "New Team", slug: null, isBeingMigrated: false },
|
||||
],
|
||||
invites: [{ email: "invite@example.com", team: "New Team", role: "MEMBER" }],
|
||||
inviteRole: "MEMBER",
|
||||
migratedMembers: [{ email: "migrated@example.com", teamId: 1, role: "MEMBER" as const }],
|
||||
resetOnboarding: mockResetOnboarding,
|
||||
} as unknown as OnboardingState;
|
||||
|
||||
mockMutateAsync.mockResolvedValue({
|
||||
checkoutUrl: "https://stripe.com/checkout",
|
||||
});
|
||||
|
||||
await submitOnboarding(store, "user@example.com", store.invites);
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
bio: "Test bio",
|
||||
logo: "logo.png",
|
||||
brandColor: "#000000",
|
||||
bannerUrl: "banner.png",
|
||||
orgOwnerEmail: "user@example.com",
|
||||
seats: null,
|
||||
pricePerSeat: null,
|
||||
isPlatform: false,
|
||||
creationSource: CreationSource.WEBAPP,
|
||||
teams: [
|
||||
{ id: 1, name: "Existing Team", isBeingMigrated: true, slug: "existing-team" },
|
||||
{ id: -1, name: "New Team", isBeingMigrated: false, slug: null },
|
||||
],
|
||||
invitedMembers: [
|
||||
{ email: "invite@example.com", teamName: "New Team", teamId: -1, role: "MEMBER" },
|
||||
{ email: "migrated@example.com", teamId: 1, role: "MEMBER" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should use teamId for invites to migrated teams", async () => {
|
||||
const hook = useSubmitOnboarding();
|
||||
const { submitOnboarding } = hook;
|
||||
|
||||
const store = {
|
||||
selectedPlan: "organization",
|
||||
organizationDetails: {
|
||||
name: "Test Org",
|
||||
link: "test-org",
|
||||
bio: "Test bio",
|
||||
},
|
||||
organizationBrand: {
|
||||
color: "#000000",
|
||||
logo: "logo.png",
|
||||
banner: "banner.png",
|
||||
},
|
||||
teams: [
|
||||
{ id: 1, name: "Existing Team", slug: "existing-team", isBeingMigrated: true },
|
||||
{ id: 2, name: "Another Existing Team", slug: "another-existing-team", isBeingMigrated: true },
|
||||
{ id: -1, name: "New Team", slug: null, isBeingMigrated: false },
|
||||
],
|
||||
invites: [
|
||||
{ email: "invite1@example.com", team: "Existing Team", role: "MEMBER" },
|
||||
{ email: "invite2@example.com", team: "New Team", role: "ADMIN" },
|
||||
{ email: "invite3@example.com", team: "another existing team", role: "MEMBER" },
|
||||
],
|
||||
inviteRole: "MEMBER",
|
||||
migratedMembers: [],
|
||||
resetOnboarding: mockResetOnboarding,
|
||||
} as unknown as OnboardingState;
|
||||
|
||||
mockMutateAsync.mockResolvedValue({
|
||||
checkoutUrl: "https://stripe.com/checkout",
|
||||
});
|
||||
|
||||
await submitOnboarding(store, "user@example.com", store.invites);
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
bio: "Test bio",
|
||||
logo: "logo.png",
|
||||
brandColor: "#000000",
|
||||
bannerUrl: "banner.png",
|
||||
orgOwnerEmail: "user@example.com",
|
||||
seats: null,
|
||||
pricePerSeat: null,
|
||||
isPlatform: false,
|
||||
creationSource: CreationSource.WEBAPP,
|
||||
teams: [
|
||||
{ id: 1, name: "Existing Team", isBeingMigrated: true, slug: "existing-team" },
|
||||
{ id: 2, name: "Another Existing Team", isBeingMigrated: true, slug: "another-existing-team" },
|
||||
{ id: -1, name: "New Team", isBeingMigrated: false, slug: null },
|
||||
],
|
||||
invitedMembers: [
|
||||
// Migrated team invite should use teamId
|
||||
{ email: "invite1@example.com", teamId: 1, teamName: undefined, role: "MEMBER" },
|
||||
// New team invite should use teamName (note: implementation uses inviteRole from store, not invite.role)
|
||||
{ email: "invite2@example.com", teamName: "New Team", teamId: -1, role: "MEMBER" },
|
||||
// Migrated team invite with different case should still use teamId
|
||||
{ email: "invite3@example.com", teamId: 2, teamName: undefined, role: "MEMBER" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle invites without teams correctly", async () => {
|
||||
const hook = useSubmitOnboarding();
|
||||
const { submitOnboarding } = hook;
|
||||
|
||||
const store = {
|
||||
selectedPlan: "organization",
|
||||
organizationDetails: {
|
||||
name: "Test Org",
|
||||
link: "test-org",
|
||||
bio: "Test bio",
|
||||
},
|
||||
organizationBrand: {
|
||||
color: "#000000",
|
||||
logo: "logo.png",
|
||||
banner: "banner.png",
|
||||
},
|
||||
teams: [{ id: 1, name: "Existing Team", slug: "existing-team", isBeingMigrated: true }],
|
||||
invites: [
|
||||
{ email: "invite1@example.com", team: "", role: "MEMBER" },
|
||||
{ email: "invite2@example.com", team: " ", role: "ADMIN" },
|
||||
],
|
||||
inviteRole: "MEMBER",
|
||||
migratedMembers: [],
|
||||
resetOnboarding: mockResetOnboarding,
|
||||
} as unknown as OnboardingState;
|
||||
|
||||
mockMutateAsync.mockResolvedValue({
|
||||
checkoutUrl: "https://stripe.com/checkout",
|
||||
});
|
||||
|
||||
await submitOnboarding(store, "user@example.com", store.invites);
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith({
|
||||
name: "Test Org",
|
||||
slug: "test-org",
|
||||
bio: "Test bio",
|
||||
logo: "logo.png",
|
||||
brandColor: "#000000",
|
||||
bannerUrl: "banner.png",
|
||||
orgOwnerEmail: "user@example.com",
|
||||
seats: null,
|
||||
pricePerSeat: null,
|
||||
isPlatform: false,
|
||||
creationSource: CreationSource.WEBAPP,
|
||||
teams: [{ id: 1, name: "Existing Team", isBeingMigrated: true, slug: "existing-team" }],
|
||||
invitedMembers: [
|
||||
// Invites without teams should have neither teamId nor teamName (note: implementation uses inviteRole from store)
|
||||
{ email: "invite1@example.com", teamId: undefined, teamName: undefined, role: "MEMBER" },
|
||||
{ email: "invite2@example.com", teamId: undefined, teamName: undefined, role: "MEMBER" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle teams with null slugs correctly", async () => {
|
||||
const hook = useSubmitOnboarding();
|
||||
const { submitOnboarding } = hook;
|
||||
|
||||
const store = {
|
||||
selectedPlan: "organization",
|
||||
organizationDetails: {
|
||||
name: "Test Org",
|
||||
link: "test-org",
|
||||
bio: "",
|
||||
},
|
||||
organizationBrand: {
|
||||
color: "#000000",
|
||||
logo: null,
|
||||
banner: null,
|
||||
},
|
||||
teams: [{ id: -1, name: "New Team", slug: null, isBeingMigrated: false }],
|
||||
invites: [],
|
||||
inviteRole: "MEMBER",
|
||||
migratedMembers: [],
|
||||
resetOnboarding: mockResetOnboarding,
|
||||
} as unknown as OnboardingState;
|
||||
|
||||
mockMutateAsync.mockResolvedValue({
|
||||
checkoutUrl: null,
|
||||
organizationId: 123,
|
||||
});
|
||||
|
||||
await submitOnboarding(store, "user@example.com", []);
|
||||
|
||||
expect(mockMutateAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teams: [{ id: -1, name: "New Team", isBeingMigrated: false, slug: null }],
|
||||
invitedMembers: [],
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
|
||||
export const useMigrationFlow = () => {
|
||||
const searchParams = useSearchParams();
|
||||
const migrateParam = searchParams?.get("migrate");
|
||||
const isMigrationFlow = migrateParam === "true";
|
||||
|
||||
const { data: teams, isLoading } = trpc.viewer.teams.listOwnedTeams.useQuery(undefined, {
|
||||
enabled: isMigrationFlow, // Only fetch if migration flow
|
||||
});
|
||||
|
||||
const hasTeams = (teams?.length ?? 0) > 0;
|
||||
|
||||
return {
|
||||
isMigrationFlow,
|
||||
hasTeams,
|
||||
teams: teams ?? [],
|
||||
isLoading,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -24,32 +24,65 @@ export const useSubmitOnboarding = () => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const { selectedPlan, organizationDetails, organizationBrand, teams, inviteRole, resetOnboarding } =
|
||||
store;
|
||||
const {
|
||||
selectedPlan,
|
||||
organizationDetails,
|
||||
organizationBrand,
|
||||
teams,
|
||||
inviteRole,
|
||||
resetOnboarding,
|
||||
migratedMembers,
|
||||
} = store;
|
||||
|
||||
if (selectedPlan !== "organization") {
|
||||
throw new Error("Only organization plan is currently supported");
|
||||
}
|
||||
|
||||
// Prepare teams data
|
||||
const teamsData = teams
|
||||
.filter((team) => team.name.trim().length > 0)
|
||||
.map((team) => ({
|
||||
id: -1, // New team
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
isBeingMigrated: false,
|
||||
slug: null,
|
||||
isBeingMigrated: team.isBeingMigrated,
|
||||
slug: team.slug,
|
||||
}));
|
||||
|
||||
// Prepare invites data
|
||||
const invitedMembersData = invitesToSubmit
|
||||
.filter((invite) => invite.email.trim().length > 0)
|
||||
.map((invite) => ({
|
||||
email: invite.email,
|
||||
teamName: invite.team,
|
||||
teamId: -1,
|
||||
role: inviteRole,
|
||||
}));
|
||||
.map((invite) => {
|
||||
// If invite has a team name, try to find the team ID (for migrated teams)
|
||||
let teamId: number | undefined = undefined;
|
||||
let teamName: string | undefined = undefined;
|
||||
|
||||
if (invite.team && invite.team.trim().length > 0) {
|
||||
const matchingTeam = teams.find(
|
||||
(team) => team.name.toLowerCase() === invite.team.toLowerCase()
|
||||
);
|
||||
if (matchingTeam?.isBeingMigrated && matchingTeam.id !== -1) {
|
||||
// Use team ID for migrated teams
|
||||
teamId = matchingTeam.id;
|
||||
} else {
|
||||
// Use team name for new teams (will be matched after creation)
|
||||
teamName = invite.team;
|
||||
teamId = -1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
email: invite.email,
|
||||
teamName,
|
||||
teamId,
|
||||
role: inviteRole,
|
||||
};
|
||||
});
|
||||
|
||||
const migratedMembersData = migratedMembers.map((member) => ({
|
||||
email: member.email,
|
||||
teamId: member.teamId,
|
||||
role: member.role,
|
||||
}));
|
||||
|
||||
const allInvitedMembers = [...invitedMembersData, ...migratedMembersData];
|
||||
|
||||
const result = await intentToCreateOrg.mutateAsync({
|
||||
name: organizationDetails.name,
|
||||
@@ -64,7 +97,7 @@ export const useSubmitOnboarding = () => {
|
||||
isPlatform: false,
|
||||
creationSource: CreationSource.WEBAPP,
|
||||
teams: teamsData,
|
||||
invitedMembers: invitedMembersData,
|
||||
invitedMembers: allInvitedMembers,
|
||||
});
|
||||
|
||||
// If there's a checkout URL, redirect to Stripe (billing enabled flow)
|
||||
@@ -76,9 +109,19 @@ export const useSubmitOnboarding = () => {
|
||||
// No checkout URL means billing is disabled (self-hosted flow)
|
||||
// Organization has already been created by the backend
|
||||
showToast("Organization created successfully!", "success");
|
||||
// Set flag to show welcome modal after personal onboarding redirect
|
||||
// Set flag to show welcome modal after redirect
|
||||
setShowNewOrgModalFlag();
|
||||
skipToPersonal(resetOnboarding);
|
||||
|
||||
// Check if this is a migration flow (user has already completed onboarding)
|
||||
const hasMigratedTeams = teams.some((team) => team.isBeingMigrated);
|
||||
if (hasMigratedTeams) {
|
||||
// Migration flow - user already completed onboarding, redirect to event-types
|
||||
resetOnboarding();
|
||||
window.location.href = "/event-types?newOrganizationModal=true";
|
||||
} else {
|
||||
// Regular flow - redirect to personal onboarding
|
||||
skipToPersonal(resetOnboarding);
|
||||
}
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : "Failed to create organization";
|
||||
setError(errorMessage);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import posthog from "posthog-js";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ColorPicker, Label } from "@calcom/ui/components/form";
|
||||
import { OnboardingCard } from "../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../components/OnboardingLayout";
|
||||
import { OnboardingOrganizationBrowserView } from "../../components/onboarding-organization-browser-view";
|
||||
import { useMigrationFlow } from "../../hooks/useMigrationFlow";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
|
||||
type OrganizationBrandViewProps = {
|
||||
@@ -19,8 +20,10 @@ type OrganizationBrandViewProps = {
|
||||
|
||||
export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useLocale();
|
||||
const { organizationDetails, organizationBrand, setOrganizationBrand } = useOnboardingStore();
|
||||
const { isMigrationFlow, hasTeams } = useMigrationFlow();
|
||||
|
||||
const [_logoFile, setLogoFile] = useState<File | null>(null);
|
||||
const [_bannerFile, setBannerFile] = useState<File | null>(null);
|
||||
@@ -72,6 +75,17 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
|
||||
setOrganizationBrand({ color });
|
||||
};
|
||||
|
||||
const getNextStep = () => {
|
||||
const migrateParam = searchParams?.get("migrate");
|
||||
const queryString = migrateParam ? `?migrate=${migrateParam}` : "";
|
||||
|
||||
// If migration flow and has teams, go to migrate-teams, otherwise go to teams
|
||||
if (isMigrationFlow && hasTeams) {
|
||||
return `/onboarding/organization/migrate-teams${queryString}`;
|
||||
}
|
||||
return `/onboarding/organization/teams${queryString}`;
|
||||
};
|
||||
|
||||
const handleContinue = () => {
|
||||
posthog.capture("onboarding_organization_brand_continue_clicked", {
|
||||
has_logo: !!logoPreview,
|
||||
@@ -84,7 +98,7 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
|
||||
banner: bannerPreview,
|
||||
color: brandColor,
|
||||
});
|
||||
router.push("/onboarding/organization/teams");
|
||||
router.push(getNextStep());
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
@@ -93,8 +107,10 @@ export const OrganizationBrandView = ({ userEmail }: OrganizationBrandViewProps)
|
||||
router.push("/onboarding/organization/teams");
|
||||
};
|
||||
|
||||
const totalSteps = isMigrationFlow && hasTeams ? 6 : 4;
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={4}>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={totalSteps}>
|
||||
{/* Left column - Main content */}
|
||||
<OnboardingCard
|
||||
title={t("onboarding_org_brand_title")}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import posthog from "posthog-js";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Label, TextField, TextArea } from "@calcom/ui/components/form";
|
||||
import { OnboardingCard } from "../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../components/OnboardingLayout";
|
||||
import { OnboardingOrganizationBrowserView } from "../../components/onboarding-organization-browser-view";
|
||||
import { useMigrationFlow } from "../../hooks/useMigrationFlow";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
import { ValidatedOrganizationSlug } from "./validated-organization-slug";
|
||||
|
||||
@@ -30,8 +31,10 @@ const slugify = (text: string): string => {
|
||||
|
||||
export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useLocale();
|
||||
const { organizationDetails, setOrganizationDetails } = useOnboardingStore();
|
||||
const { organizationDetails, setOrganizationDetails, selectedPlan, setSelectedPlan } = useOnboardingStore();
|
||||
const { isMigrationFlow, hasTeams } = useMigrationFlow();
|
||||
|
||||
const [organizationName, setOrganizationName] = useState("");
|
||||
const [organizationLink, setOrganizationLink] = useState("");
|
||||
@@ -39,6 +42,13 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr
|
||||
const [isSlugValid, setIsSlugValid] = useState(false);
|
||||
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
|
||||
|
||||
// Ensure selectedPlan is set to "organization" when entering organization onboarding
|
||||
useEffect(() => {
|
||||
if (selectedPlan !== "organization") {
|
||||
setSelectedPlan("organization");
|
||||
}
|
||||
}, [selectedPlan, setSelectedPlan]);
|
||||
|
||||
// Load from store on mount
|
||||
useEffect(() => {
|
||||
setOrganizationName(organizationDetails.name);
|
||||
@@ -78,11 +88,15 @@ export const OrganizationDetailsView = ({ userEmail }: OrganizationDetailsViewPr
|
||||
link: organizationLink,
|
||||
bio: organizationBio,
|
||||
});
|
||||
router.push("/onboarding/organization/brand");
|
||||
const migrateParam = searchParams?.get("migrate");
|
||||
const nextUrl = `/onboarding/organization/brand${migrateParam ? `?migrate=${migrateParam}` : ""}`;
|
||||
router.push(nextUrl);
|
||||
};
|
||||
|
||||
const totalSteps = isMigrationFlow && hasTeams ? 6 : 4;
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={4}>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={totalSteps}>
|
||||
{/* Left column - Main content */}
|
||||
<OnboardingCard
|
||||
title={t("onboarding_org_details_title")}
|
||||
|
||||
+14
-6
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import React from "react";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
@@ -35,8 +35,12 @@ type FormValues = {
|
||||
|
||||
export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEmailViewProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useLocale();
|
||||
const flags = useFlags();
|
||||
const { teams } = useOnboardingStore();
|
||||
const migratedTeams = teams.filter((team) => team.isBeingMigrated);
|
||||
const isMigrationFlow = searchParams?.get("migrate") === "true";
|
||||
|
||||
const store = useOnboardingStore();
|
||||
const usersEmailDomain = userEmail.split("@")[1];
|
||||
@@ -54,11 +58,11 @@ export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEma
|
||||
const googleWorkspaceEnabled = flags["google-workspace-directory"];
|
||||
|
||||
const filteredTeams = store.teams.filter((team) => team.name && team.name.trim().length > 0);
|
||||
const teams =
|
||||
const teamOptions =
|
||||
filteredTeams.length > 0
|
||||
? filteredTeams.map((team) => ({ value: team.name.toLowerCase(), label: team.name }))
|
||||
: [];
|
||||
const hasTeams = teams.length > 0;
|
||||
const hasTeams = teamOptions.length > 0;
|
||||
|
||||
// Conditional form schema - team is optional when there are no teams
|
||||
const formSchema = z.object({
|
||||
@@ -102,7 +106,7 @@ export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEma
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
router.push("/onboarding/organization/teams");
|
||||
router.back();
|
||||
};
|
||||
|
||||
const handleSkip = async () => {
|
||||
@@ -136,8 +140,12 @@ export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEma
|
||||
return email && email.trim().length > 0 && team && team.trim().length > 0;
|
||||
});
|
||||
|
||||
// Calculate total steps dynamically
|
||||
const totalSteps = isMigrationFlow && migratedTeams.length > 0 ? 6 : 4;
|
||||
const currentStep = isMigrationFlow && migratedTeams.length > 0 ? 6 : 4;
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={4} totalSteps={4}>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={currentStep} totalSteps={totalSteps}>
|
||||
<div className="flex h-full w-full flex-col gap-4">
|
||||
<OnboardingCard
|
||||
title={t("onboarding_org_invite_title")}
|
||||
@@ -176,7 +184,7 @@ export const OrganizationInviteEmailView = ({ userEmail }: OrganizationInviteEma
|
||||
remove={remove}
|
||||
defaultRole={inviteRole}
|
||||
showTeamSelect={hasTeams}
|
||||
teams={teams}
|
||||
teams={teamOptions}
|
||||
emailPlaceholder={`dave@${usersEmailDomain}`}
|
||||
/>
|
||||
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import type { RouterOutputs } from "@calcom/trpc/react";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Avatar } from "@calcom/ui/components/avatar";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { SkeletonButton, SkeletonContainer, SkeletonText } from "@calcom/ui/components/skeleton";
|
||||
import { Tooltip } from "@calcom/ui/components/tooltip";
|
||||
|
||||
import { OnboardingCard } from "../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../components/OnboardingLayout";
|
||||
import { OnboardingMigrateMembersBrowserView } from "../../components/onboarding-migrate-members-browser-view";
|
||||
import { useMigrationFlow } from "../../hooks/useMigrationFlow";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
|
||||
type TeamMember = RouterOutputs["viewer"]["teams"]["listMembers"]["members"][number];
|
||||
|
||||
type OrganizationMigrateMembersViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
export const OrganizationMigrateMembersView = ({ userEmail }: OrganizationMigrateMembersViewProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useLocale();
|
||||
const { isLoading: isMigrationFlowLoading } = useMigrationFlow();
|
||||
const { teams, organizationBrand, organizationDetails, setMigratedMembers } = useOnboardingStore();
|
||||
|
||||
const teamsToMigrate = teams.filter((team) => team.isBeingMigrated && team.id > 0);
|
||||
const teamIds = teamsToMigrate.map((team) => team.id);
|
||||
|
||||
const results = trpc.useQueries((t) => teamIds.map((teamId) => t.viewer.teams.listMembers({ teamId })));
|
||||
|
||||
const isLoading = results.some((result) => result.isLoading);
|
||||
|
||||
const uniqueMembers = results
|
||||
.reduce<TeamMember[]>((acc, result) => {
|
||||
if (result.data?.members) {
|
||||
return [...acc, ...result.data.members];
|
||||
}
|
||||
return acc;
|
||||
}, [])
|
||||
.filter((member, index, self) => index === self.findIndex((m) => m.email === member.email));
|
||||
|
||||
const handleContinue = () => {
|
||||
const migratedMembersData = uniqueMembers.map((member) => ({
|
||||
email: member.email,
|
||||
name: member.name || undefined,
|
||||
teamId: member.teamId,
|
||||
role: member.role === "OWNER" ? ("ADMIN" as const) : member.role,
|
||||
}));
|
||||
|
||||
setMigratedMembers(migratedMembersData);
|
||||
|
||||
const migrateParam = searchParams?.get("migrate");
|
||||
const nextUrl = `/onboarding/organization/invite/email${migrateParam ? `?migrate=${migrateParam}` : ""}`;
|
||||
router.push(nextUrl);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={5} totalSteps={6}>
|
||||
<SkeletonContainer as="div" className="stack-y-6">
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonText className="h-8 w-full" />
|
||||
<SkeletonButton className="mr-6 h-8 w-20 rounded-md p-5" />
|
||||
</SkeletonContainer>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (teamsToMigrate.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={5} totalSteps={6}>
|
||||
<OnboardingCard
|
||||
title={t("migrate_team_members")}
|
||||
subtitle={t("members_from_migrated_teams_will_be_included")}
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<Button color="minimal" className="rounded-[10px]" onClick={() => router.back()}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
onClick={handleContinue}
|
||||
disabled={isLoading || isMigrationFlowLoading}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
{uniqueMembers.length > 0 ? (
|
||||
<ul className="border-subtle divide-subtle max-h-[300px] divide-y overflow-y-auto rounded-md border">
|
||||
{uniqueMembers.map((member) => (
|
||||
<li key={member.email} className="flex items-center justify-between px-5 py-2">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Avatar size="sm" alt={member.email} imageSrc={member.avatarUrl} />
|
||||
<div className="flex gap-1">
|
||||
<Tooltip content={member.email}>
|
||||
<span className="text-emphasis text-sm font-medium">
|
||||
{member.name || member.email}
|
||||
</span>
|
||||
</Tooltip>
|
||||
<Badge variant="green">{t("migrating_from_team")}</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="text-subtle text-sm">{t("no_members_to_migrate")}</p>
|
||||
)}
|
||||
</div>
|
||||
</OnboardingCard>
|
||||
|
||||
{/* Right column - Browser view */}
|
||||
<OnboardingMigrateMembersBrowserView
|
||||
members={uniqueMembers.map((member) => ({
|
||||
email: member.email,
|
||||
name: member.name || null,
|
||||
avatarUrl: member.avatarUrl || null,
|
||||
teamId: member.teamId,
|
||||
}))}
|
||||
organizationLogo={organizationBrand.logo}
|
||||
organizationName={organizationDetails.name}
|
||||
organizationBanner={organizationBrand.banner}
|
||||
slug={organizationDetails.link}
|
||||
/>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
+234
@@ -0,0 +1,234 @@
|
||||
"use client";
|
||||
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import { useEffect } from "react";
|
||||
import { Controller, useForm } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { trpc } from "@calcom/trpc/react";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { CheckboxField, TextField } from "@calcom/ui/components/form";
|
||||
import { Form } from "@calcom/ui/components/form";
|
||||
|
||||
import { OnboardingCard } from "../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../components/OnboardingLayout";
|
||||
import { OnboardingMigrateTeamsBrowserView } from "../../components/onboarding-migrate-teams-browser-view";
|
||||
import { useMigrationFlow } from "../../hooks/useMigrationFlow";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
import { getSuggestedSlug } from "./utils";
|
||||
|
||||
type OrganizationMigrateTeamsViewProps = {
|
||||
userEmail: string;
|
||||
};
|
||||
|
||||
const schema = z.object({
|
||||
moveTeams: z.array(
|
||||
z.object({
|
||||
id: z.number(),
|
||||
shouldMove: z.boolean(),
|
||||
newSlug: z.string().optional(),
|
||||
})
|
||||
),
|
||||
});
|
||||
|
||||
type FormValues = z.infer<typeof schema>;
|
||||
|
||||
export const OrganizationMigrateTeamsView = ({ userEmail }: OrganizationMigrateTeamsViewProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useLocale();
|
||||
const { isMigrationFlow, hasTeams, teams, isLoading } = useMigrationFlow();
|
||||
const { organizationDetails, organizationBrand, teams: teamsFromStore, setTeams } = useOnboardingStore();
|
||||
const orgSlug = organizationDetails.link;
|
||||
|
||||
const teamsToMigrateFromStore = teamsFromStore.filter((team) => team.isBeingMigrated);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
moveTeams: [],
|
||||
},
|
||||
resolver: async (data) => {
|
||||
try {
|
||||
const validatedData = await schema.parseAsync(data);
|
||||
return { values: validatedData, errors: {} };
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
return {
|
||||
values: {
|
||||
moveTeams: [],
|
||||
},
|
||||
errors: error.formErrors.fieldErrors,
|
||||
};
|
||||
}
|
||||
return { values: {}, errors: { moveTeams: { message: "Error validating input" } } };
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const { register, control, watch, getValues, setValue, reset } = form;
|
||||
const moveTeams = watch("moveTeams");
|
||||
|
||||
useEffect(() => {
|
||||
if (teams.length === 0) return;
|
||||
|
||||
const currentMoveTeams = getValues("moveTeams");
|
||||
if (currentMoveTeams.length === 0 || currentMoveTeams.length !== teams.length) {
|
||||
reset({
|
||||
moveTeams: teams.map((team) => {
|
||||
const teamToMigrateInStore = teamsToMigrateFromStore.find((t) => t.id === team.id);
|
||||
const slugConflictsWithOrg = team.slug === orgSlug;
|
||||
return {
|
||||
id: team.id,
|
||||
shouldMove: slugConflictsWithOrg || !!teamToMigrateInStore,
|
||||
newSlug:
|
||||
teamToMigrateInStore?.slug ?? getSuggestedSlug({ teamSlug: team.slug, orgSlug }) ?? undefined,
|
||||
};
|
||||
}),
|
||||
});
|
||||
}
|
||||
}, [teams, reset, teamsToMigrateFromStore, orgSlug, getValues]);
|
||||
|
||||
const handleFormSubmit = () => {
|
||||
const moveTeamsData = getValues("moveTeams");
|
||||
|
||||
const teamsBeingMoved = moveTeamsData
|
||||
.filter((team) => team.shouldMove)
|
||||
.map((team) => {
|
||||
const originalTeam = teams.find((t) => t.id === team.id);
|
||||
if (!originalTeam) {
|
||||
throw new Error(`Team with id ${team.id} not found`);
|
||||
}
|
||||
// Use newSlug if provided and not empty, otherwise fall back to original team slug
|
||||
// This matches the settings flow behavior - backend will use original slug if newSlug is null
|
||||
const slug = team.newSlug?.trim() || originalTeam.slug || null;
|
||||
return {
|
||||
id: team.id,
|
||||
isBeingMigrated: true,
|
||||
slug,
|
||||
name: originalTeam.name,
|
||||
};
|
||||
});
|
||||
|
||||
const existingNewTeams = teamsFromStore.filter((team) => !team.isBeingMigrated);
|
||||
|
||||
setTeams([...teamsBeingMoved, ...existingNewTeams]);
|
||||
|
||||
const migrateParam = searchParams?.get("migrate");
|
||||
const nextUrl = `/onboarding/organization/teams${migrateParam ? `?migrate=${migrateParam}` : ""}`;
|
||||
router.push(nextUrl);
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
const migrateParam = searchParams?.get("migrate");
|
||||
const nextUrl = `/onboarding/organization/teams${migrateParam ? `?migrate=${migrateParam}` : ""}`;
|
||||
router.push(nextUrl);
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={3} totalSteps={6}>
|
||||
<OnboardingCard title={t("loading")} subtitle={t("please_wait")} />
|
||||
</OnboardingLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isMigrationFlow || !hasTeams) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const hasSelectedTeams = moveTeams.some((team) => team.shouldMove);
|
||||
|
||||
const browserViewTeams = teams.map((team) => {
|
||||
const moveTeam = moveTeams.find((mt) => mt.id === team.id);
|
||||
return {
|
||||
id: team.id,
|
||||
name: team.name,
|
||||
slug: moveTeam?.newSlug || team.slug,
|
||||
isSelected: moveTeam?.shouldMove || false,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={3} totalSteps={6}>
|
||||
<OnboardingCard
|
||||
title={t("migrate_existing_teams")}
|
||||
subtitle={t("select_teams_to_migrate_to_organization")}
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between gap-4">
|
||||
<Button color="minimal" className="rounded-[10px]" onClick={() => router.back()}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button color="minimal" className="rounded-[10px]" onClick={handleSkip}>
|
||||
{t("onboarding_skip_for_now")}
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="migrate-teams-form"
|
||||
color="primary"
|
||||
className="rounded-[10px]"
|
||||
disabled={!hasSelectedTeams}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
}>
|
||||
<Form id="migrate-teams-form" form={form} handleSubmit={handleFormSubmit} className="w-full">
|
||||
{moveTeams.length > 0 ? (
|
||||
<div className="flex w-full flex-col gap-4">
|
||||
<label className="text-emphasis mb-2 block text-sm font-medium leading-none">
|
||||
{t("move_existing_teams")}
|
||||
</label>
|
||||
<ul className="mb-8 flex flex-col gap-4">
|
||||
{moveTeams.map((team, index) => {
|
||||
const currentTeam = teams.find((t) => t.id === team.id);
|
||||
const slugConflictsWithOrg = currentTeam?.slug === orgSlug;
|
||||
return (
|
||||
<li key={team.id}>
|
||||
<Controller
|
||||
name={`moveTeams.${index}.shouldMove`}
|
||||
control={control}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<CheckboxField
|
||||
checked={value || slugConflictsWithOrg}
|
||||
onChange={onChange}
|
||||
description={currentTeam?.name ?? ""}
|
||||
disabled={slugConflictsWithOrg}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{moveTeams[index].shouldMove ? (
|
||||
<TextField
|
||||
placeholder={t("new_slug")}
|
||||
defaultValue={teams.find((t) => t.id === team.id)?.slug ?? ""}
|
||||
{...register(`moveTeams.${index}.newSlug`)}
|
||||
onChange={(e) => {
|
||||
const slug = slugify(e.target.value, true);
|
||||
setValue(`moveTeams.${index}.newSlug`, slug);
|
||||
}}
|
||||
className="mt-2"
|
||||
label=""
|
||||
/>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</Form>
|
||||
</OnboardingCard>
|
||||
|
||||
{/* Right column - Browser view */}
|
||||
<OnboardingMigrateTeamsBrowserView
|
||||
teams={browserViewTeams}
|
||||
organizationLogo={organizationBrand.logo}
|
||||
organizationName={organizationDetails.name}
|
||||
organizationBanner={organizationBrand.banner}
|
||||
slug={organizationDetails.link}
|
||||
/>
|
||||
</OnboardingLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
export const getSuggestedSlug = ({
|
||||
teamSlug,
|
||||
orgSlug,
|
||||
}: {
|
||||
teamSlug: string | null;
|
||||
orgSlug: string | null;
|
||||
}) => {
|
||||
// If there is no orgSlug, we can't suggest a slug
|
||||
if (!teamSlug || !orgSlug) {
|
||||
return teamSlug;
|
||||
}
|
||||
|
||||
return teamSlug.replace(`${orgSlug}-`, "").replace(`-${orgSlug}`, "");
|
||||
};
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRouter, useSearchParams } from "next/navigation";
|
||||
import posthog from "posthog-js";
|
||||
import { useForm, useFieldArray } from "react-hook-form";
|
||||
import { z } from "zod";
|
||||
|
||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||
import slugify from "@calcom/lib/slugify";
|
||||
import { Badge } from "@calcom/ui/components/badge";
|
||||
import { Button } from "@calcom/ui/components/button";
|
||||
import { Form, TextField } from "@calcom/ui/components/form";
|
||||
import { Icon } from "@calcom/ui/components/icon";
|
||||
@@ -14,6 +16,7 @@ import { Icon } from "@calcom/ui/components/icon";
|
||||
import { OnboardingCard } from "../../components/OnboardingCard";
|
||||
import { OnboardingLayout } from "../../components/OnboardingLayout";
|
||||
import { OnboardingTeamsBrowserView } from "../../components/onboarding-teams-browser-view";
|
||||
import { useMigrationFlow } from "../../hooks/useMigrationFlow";
|
||||
import { useOnboardingStore } from "../../store/onboarding-store";
|
||||
|
||||
type OrganizationTeamsViewProps = {
|
||||
@@ -26,8 +29,14 @@ type FormValues = {
|
||||
|
||||
export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps) => {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { t } = useLocale();
|
||||
const { teams: storedTeams, setTeams, organizationBrand, organizationDetails } = useOnboardingStore();
|
||||
const { isMigrationFlow } = useMigrationFlow();
|
||||
|
||||
// Filter out migrated teams - only show new teams
|
||||
const newTeams = storedTeams.filter((team) => !team.isBeingMigrated);
|
||||
const migratedTeams = storedTeams.filter((team) => team.isBeingMigrated);
|
||||
|
||||
const formSchema = z.object({
|
||||
teams: z.array(
|
||||
@@ -40,7 +49,7 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
teams: storedTeams.length > 0 ? storedTeams : [{ name: "" }],
|
||||
teams: newTeams.length > 0 ? newTeams.map((team) => ({ name: team.name })) : [{ name: "" }],
|
||||
},
|
||||
});
|
||||
|
||||
@@ -49,20 +58,42 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
|
||||
name: "teams",
|
||||
});
|
||||
|
||||
const getNextStep = () => {
|
||||
const migrateParam = searchParams?.get("migrate");
|
||||
const queryString = migrateParam ? `?migrate=${migrateParam}` : "";
|
||||
|
||||
// If migration flow and teams were migrated, go to migrate-members
|
||||
if (isMigrationFlow && migratedTeams.length > 0) {
|
||||
return `/onboarding/organization/migrate-members${queryString}`;
|
||||
}
|
||||
return `/onboarding/organization/invite/email${queryString}`;
|
||||
};
|
||||
|
||||
const handleContinue = (data: FormValues) => {
|
||||
const validTeams = data.teams.filter((team) => team.name.trim().length > 0);
|
||||
// Convert form data to Team structure and combine with migrated teams
|
||||
const newTeamsData = data.teams
|
||||
.filter((team) => team.name && team.name.trim().length > 0)
|
||||
.map((team) => ({
|
||||
id: -1,
|
||||
name: team.name,
|
||||
slug: null,
|
||||
isBeingMigrated: false,
|
||||
}));
|
||||
|
||||
posthog.capture("onboarding_organization_teams_continue_clicked", {
|
||||
team_count: validTeams.length,
|
||||
team_count: newTeamsData.length,
|
||||
});
|
||||
// Save teams to store
|
||||
setTeams(data.teams);
|
||||
router.push("/onboarding/organization/invite/email");
|
||||
|
||||
// Combine migrated teams with new teams
|
||||
setTeams([...migratedTeams, ...newTeamsData]);
|
||||
router.push(getNextStep());
|
||||
};
|
||||
|
||||
const handleSkip = () => {
|
||||
posthog.capture("onboarding_organization_teams_skip_clicked");
|
||||
// Skip teams and go to invite
|
||||
router.push("/onboarding/organization/invite/email");
|
||||
// Skip teams - keep migrated teams if any, otherwise empty
|
||||
setTeams(migratedTeams);
|
||||
router.push(getNextStep());
|
||||
};
|
||||
|
||||
const hasValidTeams = fields.some((_, index) => {
|
||||
@@ -70,8 +101,12 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
|
||||
return teamName && teamName.trim().length > 0;
|
||||
});
|
||||
|
||||
// Calculate total steps dynamically
|
||||
const totalSteps = isMigrationFlow && migratedTeams.length > 0 ? 6 : 4;
|
||||
const currentStep = isMigrationFlow && migratedTeams.length > 0 ? 4 : 3;
|
||||
|
||||
return (
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={3} totalSteps={4}>
|
||||
<OnboardingLayout userEmail={userEmail} currentStep={currentStep} totalSteps={totalSteps}>
|
||||
{/* Left column - Main content */}
|
||||
<OnboardingCard
|
||||
title={t("onboarding_org_teams_title")}
|
||||
@@ -84,7 +119,7 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
|
||||
className="rounded-[10px]"
|
||||
onClick={() => {
|
||||
posthog.capture("onboarding_organization_teams_back_clicked");
|
||||
router.push("/onboarding/organization/brand");
|
||||
router.back();
|
||||
}}>
|
||||
{t("back")}
|
||||
</Button>
|
||||
@@ -105,7 +140,36 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
|
||||
}>
|
||||
<Form id="teams-form" form={form} handleSubmit={handleContinue} className="w-full">
|
||||
<div className="w-full ">
|
||||
<div className="flex w-full flex-col gap-8 ">
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Migrated teams section */}
|
||||
{migratedTeams.length > 0 && (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">{t("migrated_teams")}</p>
|
||||
<p className="text-subtle text-xs leading-4">{t("migrated_teams_description")}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2">
|
||||
{migratedTeams.map((team) => (
|
||||
<div key={team.id} className="flex w-full items-center gap-1">
|
||||
<div className="flex-1">
|
||||
<TextField
|
||||
labelSrOnly
|
||||
value={team.name}
|
||||
disabled
|
||||
placeholder={t("team")}
|
||||
className="h-7 w-full rounded-[10px] text-sm opacity-60"
|
||||
/>
|
||||
</div>
|
||||
<Badge variant="green" className="text-xs">
|
||||
{t("migrating")}
|
||||
</Badge>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New teams section */}
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="text-emphasis text-sm font-medium leading-4">{t("team")}</p>
|
||||
@@ -165,7 +229,17 @@ export const OrganizationTeamsView = ({ userEmail }: OrganizationTeamsViewProps)
|
||||
|
||||
{/* Right column - Browser view */}
|
||||
<OnboardingTeamsBrowserView
|
||||
teams={form.watch("teams")}
|
||||
teams={[
|
||||
...migratedTeams.map((team) => ({
|
||||
name: team.name,
|
||||
slug: team.slug,
|
||||
isMigrated: true,
|
||||
})),
|
||||
...form
|
||||
.watch("teams")
|
||||
.filter((t) => t.name && t.name.trim().length > 0)
|
||||
.map((t) => ({ name: t.name, isMigrated: false })),
|
||||
]}
|
||||
organizationLogo={organizationBrand.logo}
|
||||
organizationName={organizationDetails.name}
|
||||
organizationBanner={organizationBrand.banner}
|
||||
|
||||
@@ -19,7 +19,10 @@ export interface OrganizationBrand {
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
id: number; // -1 for new teams, actual ID for existing teams
|
||||
name: string;
|
||||
slug: string | null; // null for new teams, slug for migrated teams
|
||||
isBeingMigrated: boolean; // true if migrating existing team
|
||||
}
|
||||
|
||||
export interface Invite {
|
||||
@@ -59,6 +62,9 @@ export interface OnboardingState {
|
||||
invites: Invite[];
|
||||
inviteRole: InviteRole;
|
||||
|
||||
// Migration state
|
||||
migratedMembers: { email: string; name?: string; teamId: number; role: "MEMBER" | "ADMIN" }[];
|
||||
|
||||
// Team-specific state
|
||||
teamDetails: TeamDetails;
|
||||
teamBrand: TeamBrand;
|
||||
@@ -76,6 +82,9 @@ export interface OnboardingState {
|
||||
setInvites: (invites: Invite[]) => void;
|
||||
setInviteRole: (role: InviteRole) => void;
|
||||
|
||||
// Migration actions
|
||||
setMigratedMembers: (members: { email: string; name?: string; teamId: number; role: "MEMBER" | "ADMIN" }[]) => void;
|
||||
|
||||
// Team actions
|
||||
setTeamDetails: (details: Partial<TeamDetails>) => void;
|
||||
setTeamBrand: (brand: Partial<TeamBrand>) => void;
|
||||
@@ -105,6 +114,7 @@ const initialState = {
|
||||
teams: [],
|
||||
invites: [],
|
||||
inviteRole: "MEMBER" as InviteRole,
|
||||
migratedMembers: [],
|
||||
teamDetails: {
|
||||
name: "",
|
||||
slug: "",
|
||||
@@ -148,6 +158,8 @@ export const useOnboardingStore = create<OnboardingState>()(
|
||||
|
||||
setInviteRole: (role) => set({ inviteRole: role }),
|
||||
|
||||
setMigratedMembers: (members) => set({ migratedMembers: members }),
|
||||
|
||||
setTeamDetails: (details) =>
|
||||
set((state) => ({
|
||||
teamDetails: { ...state.teamDetails, ...details },
|
||||
@@ -185,6 +197,7 @@ export const useOnboardingStore = create<OnboardingState>()(
|
||||
teams: state.teams,
|
||||
invites: state.invites,
|
||||
inviteRole: state.inviteRole,
|
||||
migratedMembers: state.migratedMembers,
|
||||
teamDetails: state.teamDetails,
|
||||
teamBrand: state.teamBrand,
|
||||
teamInvites: state.teamInvites,
|
||||
|
||||
@@ -20,7 +20,7 @@ export const CompanyEmailOrganizationBanner = ({ onDismissAction }: CompanyEmail
|
||||
|
||||
const handleLearnMore = () => {
|
||||
const redirectPath = flags["onboarding-v3"]
|
||||
? "/onboarding/organization/details"
|
||||
? "/onboarding/organization/details?migrate=true"
|
||||
: "/settings/organizations/new";
|
||||
|
||||
posthog.capture("company_email_banner_upgrade_clicked");
|
||||
|
||||
@@ -3531,6 +3531,22 @@
|
||||
"icon_showcase": "Icon showcase",
|
||||
"icons_showcase": "Icons showcase",
|
||||
"migrating_from_team": "Migrating from team",
|
||||
"migrate_existing_teams": "Migrate existing teams",
|
||||
"select_teams_to_migrate_to_organization": "Select which teams you'd like to migrate to your organization",
|
||||
"move_existing_teams": "Move existing teams",
|
||||
"migrating_teams_count": "Migrating {{count}} team",
|
||||
"migrating_teams_count_plural": "Migrating {{count}} teams",
|
||||
"team_slug_pending": "Slug pending",
|
||||
"no_teams_to_migrate": "No teams to migrate",
|
||||
"create_teams_in_next_step": "You can create teams in the next step",
|
||||
"migrating": "Migrating",
|
||||
"migrated_teams": "Migrated teams",
|
||||
"migrated_teams_description": "These teams will be moved to your organization",
|
||||
"migrate_team_members": "Migrate team members",
|
||||
"members_from_migrated_teams_will_be_included": "Members from migrated teams will be automatically included in your organization",
|
||||
"migrating_members_count": "Migrating {{count}} member",
|
||||
"migrating_members_count_plural": "Migrating {{count}} members",
|
||||
"members_will_be_added_to_organization": "Members will be automatically added to your organization",
|
||||
"feature_currently_disabled": "Feature is currently disabled",
|
||||
"choose_an_option": "Choose an option",
|
||||
"enter_option_value": "Enter option value",
|
||||
|
||||
@@ -612,10 +612,12 @@ export abstract class BaseOnboardingService implements IOrganizationOnboardingSe
|
||||
for (const member of invitedMembers) {
|
||||
let targetTeamId: number | undefined;
|
||||
|
||||
if (member.teamId !== undefined) {
|
||||
// For new teams (teamId === -1), multiple teams share the same -1 ID, so we must use teamName to match
|
||||
if (member.teamId !== undefined && member.teamId !== -1) {
|
||||
targetTeamId = teamIdMap.get(member.teamId) || member.teamId;
|
||||
log.debug(`Member ${member.email}: teamId ${member.teamId} -> resolved to ${targetTeamId}`);
|
||||
} else if (member.teamName) {
|
||||
// New team or teamId is -1: use teamName to match
|
||||
targetTeamId = teamNameToId.get(member.teamName.toLowerCase());
|
||||
log.debug(
|
||||
`Member ${member.email}: teamName "${member.teamName}" -> resolved to ${
|
||||
|
||||
+196
@@ -462,6 +462,202 @@ describe("BillingEnabledOrgOnboardingService", () => {
|
||||
expect(result.handoverUrl).toContain("/settings/organizations/new/resume");
|
||||
expect(mockPaymentService.createPaymentIntent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle explicitly migrated teams with slugs", async () => {
|
||||
const existingTeam1 = await prismock.team.create({
|
||||
data: {
|
||||
id: 101,
|
||||
name: "Engineering",
|
||||
slug: "engineering",
|
||||
},
|
||||
});
|
||||
|
||||
const existingTeam2 = await prismock.team.create({
|
||||
data: {
|
||||
id: 102,
|
||||
name: "Marketing",
|
||||
slug: "marketing",
|
||||
},
|
||||
});
|
||||
|
||||
await prismock.membership.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
teamId: existingTeam1.id,
|
||||
role: MembershipRole.OWNER,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
await prismock.membership.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
teamId: existingTeam2.id,
|
||||
role: MembershipRole.OWNER,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const inputWithMigratedTeams = {
|
||||
...mockInput,
|
||||
teams: [
|
||||
{ id: 101, name: "Engineering", isBeingMigrated: true, slug: "engineering" },
|
||||
{ id: 102, name: "Marketing", isBeingMigrated: true, slug: "marketing" },
|
||||
{ id: -1, name: "Sales", isBeingMigrated: false, slug: null },
|
||||
],
|
||||
};
|
||||
|
||||
await service.createOnboardingIntent(inputWithMigratedTeams);
|
||||
|
||||
expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teams: expect.arrayContaining([
|
||||
{ id: 101, name: "Engineering", isBeingMigrated: true, slug: "engineering" },
|
||||
{ id: 102, name: "Marketing", isBeingMigrated: true, slug: "marketing" },
|
||||
{ id: -1, name: "Sales", isBeingMigrated: false, slug: null },
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle migrated members with teamId", async () => {
|
||||
const existingTeam = await prismock.team.create({
|
||||
data: {
|
||||
id: 103,
|
||||
name: "Engineering",
|
||||
slug: "engineering",
|
||||
},
|
||||
});
|
||||
|
||||
await prismock.membership.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
teamId: existingTeam.id,
|
||||
role: MembershipRole.OWNER,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const inputWithMigratedMembers = {
|
||||
...mockInput,
|
||||
teams: [{ id: 103, name: "Engineering", isBeingMigrated: true, slug: "engineering" }],
|
||||
invitedMembers: [
|
||||
{ email: "migrated1@example.com", teamId: 103, role: "MEMBER" },
|
||||
{ email: "migrated2@example.com", teamId: 103, role: "MEMBER" },
|
||||
{ email: "newinvite@example.com", teamName: "Engineering", teamId: -1, role: "ADMIN" },
|
||||
],
|
||||
};
|
||||
|
||||
await service.createOnboardingIntent(inputWithMigratedMembers);
|
||||
|
||||
expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
invitedMembers: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
email: "migrated1@example.com",
|
||||
teamId: 103,
|
||||
role: "MEMBER",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
email: "migrated2@example.com",
|
||||
teamId: 103,
|
||||
role: "MEMBER",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
email: "newinvite@example.com",
|
||||
teamName: "Engineering",
|
||||
teamId: -1,
|
||||
role: "ADMIN",
|
||||
}),
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("should handle mixed migrated teams and members with new teams and invites", async () => {
|
||||
const existingTeam1 = await prismock.team.create({
|
||||
data: {
|
||||
id: 104,
|
||||
name: "Engineering",
|
||||
slug: "engineering",
|
||||
},
|
||||
});
|
||||
|
||||
const existingTeam2 = await prismock.team.create({
|
||||
data: {
|
||||
id: 105,
|
||||
name: "Marketing",
|
||||
slug: "marketing",
|
||||
},
|
||||
});
|
||||
|
||||
await prismock.membership.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
teamId: existingTeam1.id,
|
||||
role: MembershipRole.OWNER,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
await prismock.membership.create({
|
||||
data: {
|
||||
userId: mockUser.id,
|
||||
teamId: existingTeam2.id,
|
||||
role: MembershipRole.OWNER,
|
||||
accepted: true,
|
||||
},
|
||||
});
|
||||
|
||||
const inputWithMixedMigration = {
|
||||
...mockInput,
|
||||
teams: [
|
||||
{ id: 104, name: "Engineering", isBeingMigrated: true, slug: "engineering" },
|
||||
{ id: 105, name: "Marketing", isBeingMigrated: true, slug: "marketing" },
|
||||
{ id: -1, name: "Sales", isBeingMigrated: false, slug: null },
|
||||
],
|
||||
invitedMembers: [
|
||||
{ email: "eng-member@example.com", teamId: 104, role: "MEMBER" },
|
||||
{ email: "marketing-member@example.com", teamId: 105, role: "MEMBER" },
|
||||
{ email: "new-sales-member@example.com", teamName: "Sales", teamId: -1, role: "MEMBER" },
|
||||
{ email: "org-member@example.com", role: "ADMIN" },
|
||||
],
|
||||
};
|
||||
|
||||
await service.createOnboardingIntent(inputWithMixedMigration);
|
||||
|
||||
expect(mockPaymentService.createOrganizationOnboarding).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
teams: expect.arrayContaining([
|
||||
{ id: 104, name: "Engineering", isBeingMigrated: true, slug: "engineering" },
|
||||
{ id: 105, name: "Marketing", isBeingMigrated: true, slug: "marketing" },
|
||||
{ id: -1, name: "Sales", isBeingMigrated: false, slug: null },
|
||||
]),
|
||||
invitedMembers: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
email: "eng-member@example.com",
|
||||
teamId: 104,
|
||||
role: "MEMBER",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
email: "marketing-member@example.com",
|
||||
teamId: 105,
|
||||
role: "MEMBER",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
email: "new-sales-member@example.com",
|
||||
teamName: "Sales",
|
||||
teamId: -1,
|
||||
role: "MEMBER",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
email: "org-member@example.com",
|
||||
role: "ADMIN",
|
||||
}),
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createOrganization", () => {
|
||||
|
||||
Reference in new issue
Block a user