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:
co-authored by
Claude Opus 4.6
Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent
369ec831fe
commit
d80ee83da7
@@ -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)} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -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 } });
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user