feat: redesign team creation flow to match onboarding (#27698)

* Revert "Revert "feat: redesign team creation flow to match onboarding-v3 design (#26733)""

This re-applies the team creation redesign from PR #26733 which was
previously reverted. Cherry-picked from 2540423ba3.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: check org/username slug when in org context

* fix: use expect(page).toHaveURL() instead of page.waitForURL() in team e2e test

Co-Authored-By: unknown <>

* fix: redirect URL

* fix: flag import

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
sean-brydon
2026-02-06 13:19:39 +00:00
committed by GitHub
co-authored by Claude Opus 4.6 Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent 369ec831fe
commit d80ee83da7
14 changed files with 986 additions and 133 deletions
@@ -0,0 +1,34 @@
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { TeamInviteEmailView } from "~/settings/teams/new/invite/email/team-invite-email-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => `${APP_NAME} - ${t("invite")}`,
() => "",
true,
undefined,
"/settings/teams/new/invite/email"
);
};
const ServerPage = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const userEmail = session.user.email || "";
return <TeamInviteEmailView userEmail={userEmail} />;
};
export default ServerPage;
@@ -0,0 +1,34 @@
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { APP_NAME } from "@calcom/lib/constants";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { TeamInviteView } from "~/settings/teams/new/invite/team-invite-view";
export const generateMetadata = async () => {
return await _generateMetadata(
(t) => `${APP_NAME} - ${t("invite")}`,
() => "",
true,
undefined,
"/settings/teams/new/invite"
);
};
const ServerPage = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const userEmail = session.user.email || "";
return <TeamInviteView userEmail={userEmail} />;
};
export default ServerPage;
@@ -1,6 +1,12 @@
import { _generateMetadata } from "app/_utils";
import { cookies, headers } from "next/headers";
import { redirect } from "next/navigation";
import CreateNewTeamView, { LayoutWrapper } from "~/settings/teams/new/create-new-team-view";
import { getServerSession } from "@calcom/features/auth/lib/getServerSession";
import { buildLegacyRequest } from "@lib/buildLegacyCtx";
import { CreateNewTeamView, LayoutWrapper } from "~/settings/teams/new/create-new-team-view";
export const generateMetadata = async () =>
await _generateMetadata(
@@ -12,9 +18,17 @@ export const generateMetadata = async () =>
);
const ServerPage = async () => {
const session = await getServerSession({ req: buildLegacyRequest(await headers(), await cookies()) });
if (!session?.user?.id) {
return redirect("/auth/login");
}
const userEmail = session.user.email || "";
return (
<LayoutWrapper>
<CreateNewTeamView />
<CreateNewTeamView userEmail={userEmail} />
</LayoutWrapper>
);
};
@@ -134,12 +134,14 @@ export const OnboardingBrowserView = ({
{/* Profile Header */}
<div className="border-subtle flex flex-col gap-4 border-b p-4">
<div className="flex flex-col items-start gap-4">
<Avatar
size="lg"
imageSrc={avatar || undefined}
alt={name || ""}
className="border-2 border-white"
/>
{avatar && (
<Avatar
size="lg"
imageSrc={avatar}
alt={name || ""}
className="border-2 border-white"
/>
)}
<div className="flex w-full flex-col gap-2">
<h2 className="text-emphasis w-full truncate text-xl font-semibold leading-tight">
{name || t("your_name")}
@@ -1,15 +1,18 @@
import { useFlagMap } from "@calcom/features/flags/context/provider";
import { CreationSource, MembershipRole } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import { useRouter } from "next/navigation";
import { useState } from "react";
import { useFlagMap } from "@calcom/features/flags/context/provider";
import { MembershipRole } from "@calcom/prisma/enums";
import { CreationSource } from "@calcom/prisma/enums";
import { trpc } from "@calcom/trpc/react";
import type { OnboardingState } from "../store/onboarding-store";
import { useOnboardingStore } from "../store/onboarding-store";
export function useCreateTeam() {
type UseCreateTeamOptions = {
redirectBasePath?: string;
skipRedirectAfterInvite?: boolean;
};
export function useCreateTeam(options: UseCreateTeamOptions = {}) {
const { redirectBasePath = "/onboarding/teams", skipRedirectAfterInvite = false } = options;
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const flags = useFlagMap();
@@ -28,7 +31,7 @@ export function useCreateTeam() {
// Validate team details - if empty, redirect back to team details step
if (!teamDetails.name || !teamDetails.name.trim() || !teamDetails.slug || !teamDetails.slug.trim()) {
router.push("/onboarding/teams/details");
router.push(`${redirectBasePath}/details`);
setIsSubmitting(false);
return;
}
@@ -51,7 +54,7 @@ export function useCreateTeam() {
if (result.team) {
// Store the teamId and redirect to invite flow after team creation
setTeamId(result.team.id);
router.push(`/onboarding/teams/invite?teamId=${result.team.id}`);
router.push(`${redirectBasePath}/invite/email?teamId=${result.team.id}`);
}
} catch (error) {
console.error("Failed to create team:", error);
@@ -81,14 +84,17 @@ export function useCreateTeam() {
// Group invites by role and send separate requests for each role
// This is necessary because the schema validation expects array of strings when using bulk invites
const invitesByRole = validInvites.reduce((acc, invite) => {
const role = invite.role === "ADMIN" ? MembershipRole.ADMIN : MembershipRole.MEMBER;
if (!acc[role]) {
acc[role] = [];
}
acc[role].push(invite.email.trim().toLowerCase());
return acc;
}, {} as Record<MembershipRole, string[]>);
const invitesByRole = validInvites.reduce(
(acc, invite) => {
const role = invite.role === "ADMIN" ? MembershipRole.ADMIN : MembershipRole.MEMBER;
if (!acc[role]) {
acc[role] = [];
}
acc[role].push(invite.email.trim().toLowerCase());
return acc;
},
{} as Record<MembershipRole, string[]>
);
// Send invites for each role group
await Promise.all(
@@ -103,11 +109,13 @@ export function useCreateTeam() {
)
);
// Redirect to personal settings after successful invite
const gettingStartedPath = flags["onboarding-v3"]
? "/onboarding/personal/settings?fromTeamOnboarding=true"
: "/getting-started";
router.replace(gettingStartedPath);
// Redirect to personal settings after successful invite (unless caller handles its own redirect)
if (!skipRedirectAfterInvite) {
const gettingStartedPath = flags["onboarding-v3"]
? "/onboarding/personal/settings?fromTeamOnboarding=true"
: "/getting-started";
router.replace(gettingStartedPath);
}
} catch (error) {
console.error("Failed to invite members:", error);
// Extract error message from TRPC error
@@ -26,11 +26,13 @@ export async function checkTeamSlugAvailability(slug: string): Promise<{
return { available: false, message: "Unauthorized" };
}
// Check if slug already exists (teams have parentId, organizations don't)
const organizationId = session.user.profile?.organizationId ?? null;
// Check if slug already exists within the same parent context
const existingTeam = await prisma.team.findFirst({
where: {
slug,
parentId: null,
parentId: organizationId,
},
select: {
id: true,
@@ -41,5 +43,22 @@ export async function checkTeamSlugAvailability(slug: string): Promise<{
return { available: false, message: "This slug is already taken" };
}
// For org child teams, also check if slug conflicts with a user's username in the org
if (organizationId) {
const userWithSlug = await prisma.profile.findFirst({
where: {
organizationId,
username: slug,
},
select: {
id: true,
},
});
if (userWithSlug) {
return { available: false, message: "This slug is already taken by a user in your organization" };
}
}
return { available: true };
}
@@ -2,7 +2,8 @@
import { useCallback, useEffect, useRef, useState, useTransition } from "react";
import { WEBAPP_URL } from "@calcom/lib/constants";
import { useOrgBranding } from "@calcom/features/ee/organizations/context/provider";
import { subdomainSuffix } from "@calcom/features/ee/organizations/lib/orgDomains";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import classNames from "@calcom/ui/classNames";
@@ -21,6 +22,7 @@ type ValidatedTeamSlugProps = {
export function ValidatedTeamSlug({ value, onChange, onValidationChange }: ValidatedTeamSlugProps) {
const { t } = useLocale();
const orgBranding = useOrgBranding();
const [validationState, setValidationState] = useState<ValidationState>("idle");
const [errorMessage, setErrorMessage] = useState<string>("");
const [_isPending, startTransition] = useTransition();
@@ -83,7 +85,9 @@ export function ValidatedTeamSlug({ value, onChange, onValidationChange }: Valid
}
};
const urlPrefix = `${WEBAPP_URL}/team/`;
const urlPrefix = orgBranding
? `${orgBranding.fullDomain.replace("https://", "").replace("http://", "")}/`
: `${subdomainSuffix()}/team/`;
return (
<div className="flex w-full flex-col gap-1.5">
@@ -1,58 +1,210 @@
"use client";
import { useRouter } from "next/navigation";
import React from "react";
import { z } from "zod";
import posthog from "posthog-js";
import React, { useEffect, useRef, useState, type FormEvent } from "react";
import { HOSTED_CAL_FEATURES } from "@calcom/lib/constants";
import { getSafeRedirectUrl } from "@calcom/lib/getSafeRedirectUrl";
import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback";
import type { RouterOutputs } from "@calcom/trpc/react";
import { WizardLayout } from "@calcom/ui/components/layout";
import { CreateANewTeamForm } from "@calcom/web/modules/ee/teams/components/CreateANewTeamForm";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import slugify from "@calcom/lib/slugify";
import { Avatar } from "@calcom/ui/components/avatar";
import { Button } from "@calcom/ui/components/button";
import { Label, TextField, TextArea } from "@calcom/ui/components/form";
import { ImageUploader } from "@calcom/ui/components/image-uploader";
import { showToast } from "@calcom/ui/components/toast";
const querySchema = z.object({
returnTo: z.string().optional(),
slug: z.string().optional(),
});
import { OnboardingCard } from "~/onboarding/components/OnboardingCard";
import { OnboardingLayout } from "~/onboarding/components/OnboardingLayout";
import { OnboardingBrowserView } from "~/onboarding/components/onboarding-browser-view";
import { useCreateTeam } from "~/onboarding/hooks/useCreateTeam";
import { useOnboardingStore } from "~/onboarding/store/onboarding-store";
import { ValidatedTeamSlug } from "~/onboarding/teams/details/validated-team-slug";
const CreateNewTeamPage = () => {
const params = useParamsWithFallback();
const parsedQuery = querySchema.safeParse(params);
type CreateNewTeamViewProps = {
userEmail: string;
};
export const CreateNewTeamView = ({ userEmail }: CreateNewTeamViewProps) => {
const router = useRouter();
const { t } = useLocale();
const store = useOnboardingStore();
const { teamDetails, teamBrand, setTeamDetails, setTeamBrand, resetOnboardingPreservingPlan } = store;
const { createTeam, isSubmitting } = useCreateTeam({ redirectBasePath: "/settings/teams/new" });
const isTeamBillingEnabledClient = !!process.env.NEXT_PUBLIC_STRIPE_PUBLIC_KEY && HOSTED_CAL_FEATURES;
const flag = isTeamBillingEnabledClient
? {
submitLabel: "checkout",
}
: {
submitLabel: "continue",
};
const logoRef = useRef<HTMLInputElement>(null);
const [teamName, setTeamName] = useState("");
const [teamSlug, setTeamSlug] = useState("");
const [teamBio, setTeamBio] = useState("");
const [teamLogo, setTeamLogo] = useState<string>("");
const [isSlugValid, setIsSlugValid] = useState(false);
const [isSlugManuallyEdited, setIsSlugManuallyEdited] = useState(false);
const returnToParam =
(parsedQuery.success ? getSafeRedirectUrl(parsedQuery.data.returnTo) : "/teams") || "/teams";
// Reset onboarding store when entering the team creation flow from settings
useEffect(() => {
resetOnboardingPreservingPlan();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const onSuccess = (data: RouterOutputs["viewer"]["teams"]["create"]) => {
// telemetry.event(flag.telemetryEvent);
router.push(data.url);
useEffect(() => {
setTeamName(teamDetails.name);
setTeamSlug(teamDetails.slug);
setTeamBio(teamDetails.bio);
setTeamLogo(teamBrand.logo || "");
if (teamDetails.slug) {
setIsSlugManuallyEdited(true);
}
}, [teamDetails, teamBrand]);
useEffect(() => {
if (!isSlugManuallyEdited && teamName) {
const slugifiedName = slugify(teamName);
setTeamSlug(slugifiedName);
}
}, [teamName, isSlugManuallyEdited]);
const handleSlugChange = (value: string) => {
setTeamSlug(value);
setIsSlugManuallyEdited(true);
};
const handleLogoChange = (newLogo: string) => {
if (logoRef.current) {
logoRef.current.value = newLogo;
}
setTeamLogo(newLogo);
};
const handleContinue = async (e?: FormEvent) => {
e?.preventDefault();
if (!isSlugValid) {
return;
}
posthog.capture("settings_team_details_continue_clicked", {
has_logo: !!teamLogo,
has_bio: !!teamBio,
});
setTeamDetails({
name: teamName,
slug: teamSlug,
bio: teamBio,
});
setTeamBrand({
logo: teamLogo || null,
});
try {
await createTeam();
} catch (error) {
const message = error instanceof Error ? error.message : t("something_went_wrong");
showToast(message, "error");
}
};
const handleCancel = () => {
posthog.capture("settings_team_details_cancel_clicked");
resetOnboardingPreservingPlan();
router.push("/teams");
};
return (
<CreateANewTeamForm
slug={parsedQuery.success ? parsedQuery.data.slug : ""}
onCancel={() => router.push(returnToParam)}
submitLabel={flag.submitLabel}
onSuccess={onSuccess}
/>
);
};
export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
return (
<WizardLayout currentStep={1} maxSteps={3}>
{children}
</WizardLayout>
<OnboardingLayout userEmail={userEmail} currentStep={1} totalSteps={3}>
{/* Left column - Main content */}
<div className="flex h-full w-full flex-col gap-4">
<form onSubmit={handleContinue} className="flex h-full w-full flex-col gap-4">
<OnboardingCard
title={t("create_your_team")}
subtitle={t("team_onboarding_details_subtitle")}
footer={
<div className="flex w-full items-center justify-end gap-4">
<Button
type="button"
color="minimal"
className="rounded-[10px]"
onClick={handleCancel}
disabled={isSubmitting}>
{t("cancel")}
</Button>
<Button
type="submit"
color="primary"
className="rounded-[10px]"
disabled={!isSlugValid || !teamName || !teamSlug || isSubmitting}>
{t("continue")}
</Button>
</div>
}>
<div className="flex h-full w-full flex-col gap-4">
{/* Team Profile Picture */}
<div className="flex w-full flex-col gap-2">
<Label className="text-emphasis text-sm font-medium leading-4">{t("team_logo")}</Label>
<div className="flex flex-row items-center justify-start gap-2 rtl:justify-end">
<div className="relative shrink-0">
<Avatar
size="lg"
imageSrc={teamLogo || undefined}
alt={teamName || "Team"}
className="border-2 border-white"
/>
</div>
<input ref={logoRef} type="hidden" name="logo" id="logo" defaultValue={teamLogo} />
<ImageUploader
target="avatar"
id="team-logo-upload"
buttonMsg={t("upload")}
handleAvatarChange={handleLogoChange}
imageSrc={teamLogo}
/>
</div>
<p className="text-subtle text-xs font-normal leading-3">{t("onboarding_logo_size_hint")}</p>
</div>
{/* Team Name */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">{t("team_name")}</Label>
<TextField
name="name"
data-testid="team-name-input"
value={teamName}
onChange={(e) => setTeamName(e.target.value)}
placeholder="Acme Inc."
className="border-default h-7 rounded-[10px] border px-2 py-1.5 text-sm"
/>
</div>
{/* Team Slug */}
<ValidatedTeamSlug
value={teamSlug}
onChange={handleSlugChange}
onValidationChange={setIsSlugValid}
/>
{/* Team Bio */}
<div className="flex w-full flex-col gap-1.5">
<Label className="text-emphasis mb-0 text-sm font-medium leading-4">{t("team_bio")}</Label>
<TextArea
value={teamBio}
onChange={(e) => setTeamBio(e.target.value)}
placeholder={t("team_bio_placeholder")}
className="border-default max-h-[150px] min-h-[80px] rounded-[10px] border px-2 py-1.5 text-sm"
rows={3}
/>
</div>
</div>
</OnboardingCard>
</form>
</div>
{/* Right column - Browser view */}
<OnboardingBrowserView teamSlug={teamSlug} name={teamName} bio={teamBio} avatar={teamLogo || null} />
</OnboardingLayout>
);
};
export default CreateNewTeamPage;
export const LayoutWrapper = ({ children }: { children: React.ReactNode }) => {
return <>{children}</>;
};
export default CreateNewTeamView;
@@ -0,0 +1,263 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useRef, useState } from "react";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { Dialog, DialogContent } from "@calcom/ui/components/dialog";
import { Icon } from "@calcom/ui/components/icon";
import { Logo } from "@calcom/ui/components/logo";
import { showToast } from "@calcom/ui/components/toast";
import { useOnboardingStore, type Invite } from "~/onboarding/store/onboarding-store";
/**
* Parse a CSV line handling quoted fields that may contain commas.
* Example: '"Name, Inc",admin' -> ['Name, Inc', 'admin']
*/
function parseCSVLine(line: string): string[] {
const result: string[] = [];
let current = "";
let inQuotes = false;
for (let i = 0; i < line.length; i++) {
const char = line[i];
if (char === '"') {
inQuotes = !inQuotes;
} else if (char === "," && !inQuotes) {
result.push(current.trim());
current = "";
} else {
current += char;
}
}
result.push(current.trim());
return result;
}
type CSVUploadModalProps = {
isOpen: boolean;
onClose: () => void;
};
export const CSVUploadModal = ({ isOpen, onClose }: CSVUploadModalProps) => {
const { t } = useLocale();
const router = useRouter();
const searchParams = useSearchParams();
const store = useOnboardingStore();
const { setTeamInvites, teamDetails, teamId } = store;
const fileInputRef = useRef<HTMLInputElement>(null);
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [isUploading, setIsUploading] = useState(false);
const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) {
if (!file.name.endsWith(".csv")) {
showToast(t("please_upload_csv_file"), "error");
return;
}
setSelectedFile(file);
}
};
const handleDownloadTemplate = () => {
const headers = ["email", "role"];
const exampleRow = ["example@email.com", "MEMBER"];
const csvContent = [headers.join(","), exampleRow.join(",")].join("\n");
const blob = new Blob([csvContent], { type: "text/csv" });
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "team_invite_template.csv";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
showToast(t("template_downloaded"), "success");
};
const handleUpload = async () => {
if (!selectedFile) {
showToast(t("please_select_file"), "error");
return;
}
setIsUploading(true);
try {
const text = await selectedFile.text();
const lines = text.split("\n").filter((line) => line.trim());
if (lines.length < 2) {
showToast(t("csv_file_empty"), "error");
setIsUploading(false);
return;
}
const headers = parseCSVLine(lines[0]).map((h) => h.toLowerCase());
const emailIndex = headers.indexOf("email");
const roleIndex = headers.indexOf("role");
if (emailIndex === -1) {
showToast(t("csv_missing_email_column"), "error");
setIsUploading(false);
return;
}
const parsedInvites = lines
.slice(1)
.map((line) => {
const values = parseCSVLine(line);
return {
email: values[emailIndex]?.trim(),
role: (roleIndex !== -1 ? values[roleIndex]?.trim().toUpperCase() : "MEMBER") as
| "MEMBER"
| "ADMIN",
};
})
.filter((invite) => invite.email && invite.email.length > 0);
if (parsedInvites.length === 0) {
showToast(t("csv_file_empty"), "error");
setIsUploading(false);
return;
}
const invites: Invite[] = parsedInvites.map((invite) => ({
email: invite.email,
team: teamDetails.name,
role: invite.role === "ADMIN" ? "ADMIN" : "MEMBER",
}));
setTeamInvites(invites);
showToast(t("csv_uploaded_successfully", { count: invites.length }), "success");
onClose();
// Navigate to email page to display the invites - use settings path
const teamIdParam = searchParams?.get("teamId") || teamId;
router.push(`/settings/teams/new/invite/email?teamId=${teamIdParam}`);
} catch (error) {
console.error("Error uploading CSV:", error);
showToast(t("error_uploading_csv"), "error");
} finally {
setIsUploading(false);
}
};
const handleClose = () => {
setSelectedFile(null);
if (fileInputRef.current) {
fileInputRef.current.value = "";
}
onClose();
};
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent size="default" className="!p-0">
<div className="flex flex-col gap-4 p-6">
<div className="flex flex-col items-center gap-1">
<Logo className="h-10 w-auto" />
</div>
<div
className="relative mx-auto flex items-center justify-center"
style={{ width: 320, height: 180 }}>
<div
className="from-default to-muted border-subtle flex items-center justify-center rounded-2xl border bg-gradient-to-br p-8 shadow-sm"
style={{ width: 200, height: 150 }}>
{selectedFile ? (
<div className="flex flex-col items-center gap-3">
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-4 shadow-sm">
<Icon name="file-text" className="text-emphasis" style={{ width: 32, height: 32 }} />
</div>
<div className="flex flex-col items-center gap-1">
<p className="text-emphasis text-sm font-medium">{selectedFile.name}</p>
<p className="text-subtle text-xs">{(selectedFile.size / 1024).toFixed(2)} KB</p>
</div>
</div>
) : (
<div className="flex flex-col items-center gap-3">
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-4 shadow-sm">
<Icon
name="upload"
className="text-emphasis opacity-70"
style={{ width: 32, height: 32 }}
/>
</div>
<p className="text-subtle text-center text-sm">{t("upload_csv_subtitle")}</p>
</div>
)}
</div>
</div>
<div className="mb-2 flex flex-col gap-2 text-center">
<h2 className="font-heading text-emphasis text-2xl leading-none">{t("upload_csv_file")}</h2>
<p className="text-default text-sm leading-normal">{t("upload_csv_description")}</p>
</div>
<div className="mb-2 flex flex-col gap-3">
<div className="bg-cal-muted border-subtle flex items-center gap-3 rounded-lg border p-4">
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-2 shadow-sm">
<Icon name="download" className="text-emphasis" style={{ width: 16, height: 16 }} />
</div>
<div className="flex-1">
<p className="text-emphasis text-sm font-medium">{t("need_template")}</p>
<p className="text-subtle text-xs">{t("download_csv_template_description")}</p>
</div>
<Button color="secondary" StartIcon="download" size="sm" onClick={handleDownloadTemplate}>
{t("download_template")}
</Button>
</div>
<div className="bg-cal-muted border-subtle flex items-center gap-3 rounded-lg border p-4">
<div className="from-default to-muted border-subtle flex items-center justify-center rounded-full border bg-gradient-to-b p-2 shadow-sm">
<Icon name="upload" className="text-emphasis" style={{ width: 16, height: 16 }} />
</div>
<div className="flex-1">
<p className="text-emphasis text-sm font-medium">{t("upload_your_file")}</p>
<p className="text-subtle text-xs">
{selectedFile ? selectedFile.name : t("upload_csv_description")}
</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".csv"
onChange={handleFileSelect}
className="hidden"
/>
<Button
color="secondary"
StartIcon={selectedFile ? "file-text" : "upload"}
size="sm"
onClick={() => fileInputRef.current?.click()}>
{t("choose_file")}
</Button>
</div>
</div>
</div>
<div className="bg-cal-muted border-subtle mt-6 flex items-center justify-between rounded-b-2xl border-t px-8 py-6">
<Button color="minimal" onClick={handleClose} disabled={isUploading}>
{t("cancel")}
</Button>
<Button
color="primary"
onClick={handleUpload}
disabled={!selectedFile || isUploading}
loading={isUploading}>
{t("upload")}
</Button>
</div>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,210 @@
"use client";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { trpc } from "@calcom/trpc/react";
import { Button } from "@calcom/ui/components/button";
import { Form } from "@calcom/ui/components/form";
import { showToast } from "@calcom/ui/components/toast";
import { revalidateTeamsList } from "@calcom/web/app/(use-page-wrapper)/(main-nav)/teams/actions";
import { zodResolver } from "@hookform/resolvers/zod";
import { useRouter, useSearchParams } from "next/navigation";
import React, { useEffect } from "react";
import { useFieldArray, useForm } from "react-hook-form";
import { z } from "zod";
import { EmailInviteForm } from "~/onboarding/components/EmailInviteForm";
import { OnboardingCard } from "~/onboarding/components/OnboardingCard";
import { OnboardingLayout } from "~/onboarding/components/OnboardingLayout";
import { OnboardingInviteBrowserView } from "~/onboarding/components/onboarding-invite-browser-view";
import { RoleSelector } from "~/onboarding/components/RoleSelector";
import { useCreateTeam } from "~/onboarding/hooks/useCreateTeam";
import { type InviteRole, useOnboardingStore } from "~/onboarding/store/onboarding-store";
type TeamInviteEmailViewProps = {
userEmail: string;
};
type FormValues = {
invites: {
email: string;
role: InviteRole;
}[];
};
export const TeamInviteEmailView = ({ userEmail }: TeamInviteEmailViewProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { t, i18n } = useLocale();
const utils = trpc.useUtils();
const store = useOnboardingStore();
const { teamInvites, setTeamInvites, teamDetails, setTeamId, teamId, resetOnboardingPreservingPlan } =
store;
const [inviteRole, setInviteRole] = React.useState<InviteRole>("MEMBER");
const { inviteMembers, isSubmitting } = useCreateTeam({
redirectBasePath: "/settings/teams/new",
skipRedirectAfterInvite: true,
});
// Read teamId from query params and store it (from payment callback or redirect)
useEffect(() => {
const teamIdParam = searchParams?.get("teamId");
if (teamIdParam && !teamId) {
const parsedTeamId = parseInt(teamIdParam, 10);
if (!isNaN(parsedTeamId)) {
setTeamId(parsedTeamId);
}
}
}, [searchParams, setTeamId, teamId]);
const formSchema = z.object({
invites: z.array(
z.object({
email: z.union([z.literal(""), z.string().email(t("invalid_email_address"))]),
role: z.enum(["MEMBER", "ADMIN"]),
})
),
});
const form = useForm<FormValues>({
resolver: zodResolver(formSchema),
defaultValues: {
invites:
teamInvites.length > 0
? teamInvites.map((inv) => ({ email: inv.email, role: inv.role }))
: [{ email: "", role: inviteRole }],
},
});
const { fields, append, remove } = useFieldArray({
control: form.control,
name: "invites",
});
const handleContinue = async (data: FormValues) => {
const teamIdParam = searchParams?.get("teamId");
const parsedTeamId = !teamId ? parseInt(teamIdParam || "", 10) : teamId;
if (!parsedTeamId) {
console.log("Team ID is missing. Please go back and create your team first.");
showToast(
t("team_id_missing") || "Team ID is missing. Please go back and create your team first.",
"error"
);
return;
}
const invitesWithTeam = data.invites.map((invite) => ({
email: invite.email,
role: invite.role,
team: teamDetails.name,
}));
setTeamInvites(invitesWithTeam);
// Filter out empty emails and invite members
const validInvites = data.invites.filter((invite) => invite.email && invite.email.trim().length > 0);
if (validInvites.length > 0) {
try {
await inviteMembers(
validInvites.map((invite) => ({
email: invite.email,
role: invite.role,
})),
i18n.language
);
// Invalidate both server-side cache and client-side tRPC cache
await revalidateTeamsList();
await utils.viewer.teams.list.invalidate();
resetOnboardingPreservingPlan();
router.replace("/teams");
} catch (error) {
const errorMessage =
error instanceof Error ? error.message : t("something_went_wrong") || "Something went wrong";
showToast(errorMessage, "error");
}
} else {
// No invites, skip to teams page
await revalidateTeamsList();
await utils.viewer.teams.list.invalidate();
resetOnboardingPreservingPlan();
router.replace("/teams");
}
};
const handleSkip = async () => {
setTeamInvites([]);
await revalidateTeamsList();
await utils.viewer.teams.list.invalidate();
resetOnboardingPreservingPlan();
router.replace("/teams");
};
// Watch form values to pass to browser view for real-time updates
const watchedInvites = form.watch("invites");
const hasValidInvites = watchedInvites.some((invite) => {
return invite.email && invite.email.trim().length > 0;
});
return (
<OnboardingLayout userEmail={userEmail} currentStep={3} totalSteps={3}>
{/* Left column - Main content */}
<div className="flex h-full w-full flex-col gap-4">
<Form form={form} handleSubmit={handleContinue} className="flex h-full w-full flex-col gap-4">
<OnboardingCard
title={t("invite")}
subtitle={t("team_invite_subtitle")}
footer={
<div className="flex w-full items-center justify-end gap-4">
<div className="flex items-center gap-2">
<Button
type="button"
color="minimal"
className="rounded-[10px]"
onClick={handleSkip}
disabled={isSubmitting}
data-testid="skip-invite-button">
{t("onboarding_skip_for_now")}
</Button>
<Button
type="submit"
color="primary"
className="rounded-[10px]"
disabled={!hasValidInvites || isSubmitting}
loading={isSubmitting}>
{t("continue")}
</Button>
</div>
</div>
}>
<div className="flex w-full flex-col gap-4 px-1">
<div className="flex w-full flex-col gap-4">
<EmailInviteForm
fields={fields}
append={append}
remove={remove}
defaultRole={inviteRole}
emailPlaceholder="rick@cal.com"
/>
<RoleSelector
value={inviteRole}
onValueChange={(value) => {
setInviteRole(value);
fields.forEach((_, index) => {
form.setValue(`invites.${index}.role`, value);
});
}}
showInfoBadge
/>
</div>
</div>
</OnboardingCard>
</Form>
</div>
{/* Right column - Browser view */}
<OnboardingInviteBrowserView teamName={teamDetails.name} watchedInvites={watchedInvites} />
</OnboardingLayout>
);
};
@@ -0,0 +1,125 @@
"use client";
import { useRouter, useSearchParams } from "next/navigation";
import posthog from "posthog-js";
import React, { useEffect } from "react";
import { useFlags } from "@calcom/web/modules/feature-flags/hooks/useFlags";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import { Button } from "@calcom/ui/components/button";
import { InviteOptions } from "~/onboarding/components/InviteOptions";
import { OnboardingCard } from "~/onboarding/components/OnboardingCard";
import { OnboardingLayout } from "~/onboarding/components/OnboardingLayout";
import { OnboardingInviteBrowserView } from "~/onboarding/components/onboarding-invite-browser-view";
import { useCreateTeam } from "~/onboarding/hooks/useCreateTeam";
import { useOnboardingStore } from "~/onboarding/store/onboarding-store";
import { CSVUploadModal } from "./csv-upload-modal";
type TeamInviteViewProps = {
userEmail: string;
};
export const TeamInviteView = ({ userEmail }: TeamInviteViewProps) => {
const router = useRouter();
const searchParams = useSearchParams();
const { t } = useLocale();
const flags = useFlags();
const store = useOnboardingStore();
const { setTeamInvites, teamDetails, setTeamId, teamId, resetOnboardingPreservingPlan } = store;
const { isSubmitting } = useCreateTeam({ redirectBasePath: "/settings/teams/new" });
const [isCSVModalOpen, setIsCSVModalOpen] = React.useState(false);
// Read teamId from query params and store it (from payment callback)
useEffect(() => {
const teamIdParam = searchParams?.get("teamId");
if (teamIdParam) {
const parsedTeamId = parseInt(teamIdParam, 10);
if (!isNaN(parsedTeamId)) {
setTeamId(parsedTeamId);
}
}
}, [searchParams, setTeamId]);
const googleWorkspaceEnabled = flags["google-workspace-directory"];
const handleGoogleWorkspaceConnect = () => {
// TODO: Implement Google Workspace connection
console.log("Connect Google Workspace");
};
const handleInviteViaEmail = () => {
router.push(`/settings/teams/new/invite/email?teamId=${teamId}`);
};
const handleUploadCSV = () => {
setIsCSVModalOpen(true);
};
const handleCopyInviteLink = () => {
// Disabled for now as per requirements
console.log("Copy invite link - disabled");
};
const handleSkip = async () => {
posthog.capture("settings_team_invite_skip_clicked");
setTeamInvites([]);
resetOnboardingPreservingPlan();
// Redirect to teams page after skipping
router.push("/teams");
};
const handleBack = () => {
posthog.capture("settings_team_invite_back_clicked");
router.push("/settings/teams/new");
};
return (
<>
<OnboardingLayout userEmail={userEmail} currentStep={2} totalSteps={3}>
{/* Left column - Main content */}
<div className="flex w-full flex-col gap-4">
<OnboardingCard
title={t("invite")}
subtitle={t("onboarding_invite_subtitle")}
footer={
<div className="flex w-full items-center justify-between gap-4">
<Button
color="minimal"
className="rounded-[10px]"
onClick={handleBack}
disabled={isSubmitting}>
{t("back")}
</Button>
<div className="flex items-center gap-2">
<Button
color="minimal"
className="rounded-[10px]"
onClick={handleSkip}
disabled={isSubmitting}>
{t("onboarding_skip_for_now")}
</Button>
</div>
</div>
}>
<InviteOptions
onInviteViaEmail={handleInviteViaEmail}
onUploadCSV={handleUploadCSV}
onCopyInviteLink={handleCopyInviteLink}
onConnectGoogleWorkspace={googleWorkspaceEnabled ? handleGoogleWorkspaceConnect : undefined}
isSubmitting={isSubmitting}
/>
</OnboardingCard>
</div>
{/* Right column - Browser view */}
<OnboardingInviteBrowserView teamName={teamDetails.name} />
</OnboardingLayout>
{/* CSV Upload Modal */}
<CSVUploadModal isOpen={isCSVModalOpen} onClose={() => setIsCSVModalOpen(false)} />
</>
);
};
+9 -7
View File
@@ -26,16 +26,18 @@ test.describe("Can signup from a team invite", async () => {
await page.goto("/settings/teams/new");
await page.waitForLoadState("networkidle");
// Create a new team
await page.locator('input[name="name"]').fill(teamName);
await page.locator('input[name="slug"]').fill(teamName);
// Create a new team (new onboarding-v3 style flow)
await page.locator('[data-testid="team-name-input"]').fill(teamName);
await page.locator('button[type="submit"]').click();
// Add new member to team
await page.click('[data-testid="new-member-button"]');
await page.fill('input[id="inviteUser"]', testUser.email);
// Wait for the invite email page
await page.waitForURL(/\/settings\/teams\/new\/invite\/email.*$/i);
await page.waitForLoadState("networkidle");
// Add new member to team via email invite form
await page.locator('input[type="email"]').first().fill(testUser.email);
const submitPromise = page.waitForResponse("/api/trpc/teams/inviteMember?batch=1");
await page.getByTestId("invite-new-member-button").click();
await page.locator('button[type="submit"]').click();
const response = await submitPromise;
expect(response.status()).toBe(200);
@@ -29,51 +29,30 @@ test.describe("Teams", () => {
// Click text=Create Team
await page.locator("text=Create a new Team").click();
await page.waitForLoadState("networkidle");
// Fill input[name="name"]
await page.locator('input[name="name"]').fill(`${user.username}'s Team`);
// Fill team name input (new onboarding-v3 style flow)
await page.locator('[data-testid="team-name-input"]').fill(`${user.username}'s Team`);
// Click text=Continue
await page.click("[type=submit]");
// TODO: Figure out a way to make this more reliable
// eslint-disable-next-line playwright/no-conditional-in-test
if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page);
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i);
await page.waitForSelector('[data-testid="pending-member-list"]');
await expect(page.getByTestId("pending-member-item")).toHaveCount(1);
// Wait for the invite email page (new flow)
await expect(page).toHaveURL(/\/settings\/teams\/new\/invite\/email.*$/i);
await page.waitForLoadState("networkidle");
});
await test.step("Can add members", async () => {
await page.getByTestId("new-member-button").click();
await page.locator('[placeholder="email\\@example\\.com"]').fill(inviteeEmail);
await page.getByTestId("invite-new-member-button").click();
await expect(page.getByTestId("pending-member-item").filter({ hasText: inviteeEmail })).toBeVisible();
// locator.count() does not await for the expected number of elements
// https://github.com/microsoft/playwright/issues/14278
// using toHaveCount() is more reliable
await expect(page.getByTestId("pending-member-item")).toHaveCount(2);
await test.step("Can add members via email invite", async () => {
// Add member via email invite form
await page.locator('input[type="email"]').first().fill(inviteeEmail);
await page.click("[type=submit]");
await page.waitForLoadState("networkidle");
// Wait for redirect to teams page
await expect(page).toHaveURL(/\/teams$/i);
});
await test.step("Can remove members", async () => {
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);
// Cleanup here since this user is created without our fixtures.
await prisma.user.delete({ where: { email: inviteeEmail } });
});
await test.step("Can finish team invitation", async () => {
await page.getByTestId("publish-button").click();
await expect(page).toHaveURL(/\/settings\/teams\/(\d+)\/event-type$/i);
});
await test.step("Can finish team creation", async () => {
const locator = page.locator('div:has(input[value="ROUND_ROBIN"]) > button');
await expect(locator).toBeVisible();
await locator.click();
await page.fill("[name=title]", "roundRobin");
await page.getByTestId("finish-button").click();
await test.step("Can navigate to team settings", async () => {
// Click on the team to go to settings
await page.locator(`text=${user.username}'s Team`).click();
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
});
@@ -82,6 +61,13 @@ test.describe("Teams", () => {
await page.getByTestId("dialog-confirmation").click();
await page.waitForURL("/teams");
expect(await page.locator(`text=${user.username}'s Team`).count()).toEqual(0);
// Cleanup the invited user since they were created without our fixtures
const invitedUser = await prisma.user.findUnique({ where: { email: inviteeEmail } });
// eslint-disable-next-line playwright/no-conditional-in-test
if (invitedUser) {
await prisma.user.delete({ where: { email: inviteeEmail } });
}
});
});
});
+14 -14
View File
@@ -139,11 +139,11 @@ test.describe("Teams - NonOrg", () => {
const uniqueName = "test-unique-team-name";
// Go directly to the create team page
// Go directly to the create team page (new onboarding-v3 style flow)
await page.goto("/settings/teams/new");
await page.waitForLoadState("networkidle");
// Fill input[name="name"]
await page.locator('input[name="name"]').fill(uniqueName);
// Fill team name input
await page.locator('[data-testid="team-name-input"]').fill(uniqueName);
await page.click("[type=submit]");
// cleanup
@@ -163,22 +163,22 @@ test.describe("Teams - NonOrg", () => {
// Click text=Create Team
await page.locator("text=Create Team").click();
await page.waitForLoadState("networkidle");
// Fill input[name="name"]
await page.locator('input[name="name"]').fill(uniqueName);
// Click text=Continue
// Fill team name input (new onboarding-v3 style flow)
await page.locator('[data-testid="team-name-input"]').fill(uniqueName);
// Click Continue
await page.click("[type=submit]");
// TODO: Figure out a way to make this more reliable
// eslint-disable-next-line playwright/no-conditional-in-test
if (IS_TEAM_BILLING_ENABLED) await fillStripeTestCheckout(page);
await page.waitForURL(/\/settings\/teams\/(\d+)\/onboard-members.*$/i);
// Wait for the page to fully load and the publish button to be visible
// Wait for the invite email page
await expect(page).toHaveURL(/\/settings\/teams\/new\/invite\/email.*$/i);
await page.waitForLoadState("networkidle");
const publishButton = page.locator("[data-testid=publish-button]");
await publishButton.waitFor({ state: "visible", timeout: 10000 });
await publishButton.click();
await page.waitForURL(/\/settings\/teams\/(\d+)\/event-type*$/i);
await page.locator("[data-testid=handle-later-button]").click();
await page.waitForURL(/\/settings\/teams\/(\d+)\/profile$/i);
// Skip the invite step
const skipButton = page.locator("[data-testid=skip-invite-button]");
await skipButton.waitFor({ state: "visible", timeout: 10000 });
await skipButton.click();
// Wait for redirect to teams page
await page.waitForURL(/\/teams$/i);
});
await test.step("Can access user and team with same slug", async () => {