diff --git a/apps/web/modules/getting-started/[[...step]]/onboarding-view.tsx b/apps/web/modules/getting-started/[[...step]]/onboarding-view.tsx index 9abf762cef..6bf55279c1 100644 --- a/apps/web/modules/getting-started/[[...step]]/onboarding-view.tsx +++ b/apps/web/modules/getting-started/[[...step]]/onboarding-view.tsx @@ -1,6 +1,7 @@ "use client"; import { signOut } from "next-auth/react"; +import type { TFunction } from "next-i18next"; import Head from "next/head"; import { usePathname, useRouter } from "next/navigation"; import { Suspense } from "react"; @@ -11,6 +12,7 @@ import { classNames } from "@calcom/lib"; import { APP_NAME } from "@calcom/lib/constants"; import { useLocale } from "@calcom/lib/hooks/useLocale"; import { useParamsWithFallback } from "@calcom/lib/hooks/useParamsWithFallback"; +import { IdentityProvider } from "@calcom/prisma/enums"; import { trpc } from "@calcom/trpc"; import type { inferSSRProps } from "@calcom/types/inferSSRProps"; import { Button, StepCard, Steps } from "@calcom/ui"; @@ -25,29 +27,73 @@ import UserProfile from "@components/getting-started/steps-views/UserProfile"; import { UserSettings } from "@components/getting-started/steps-views/UserSettings"; const INITIAL_STEP = "user-settings"; -const steps = [ - "user-settings", - "connected-calendar", - "connected-video", - "setup-availability", - "user-profile", -] as const; +const BASE_STEPS = ["user-settings", "setup-availability", "user-profile"] as const; +const EXTRA_STEPS = ["connected-calendar", "connected-video"] as const; +type StepType = (typeof BASE_STEPS)[number] | (typeof EXTRA_STEPS)[number]; -const stepTransform = (step: (typeof steps)[number]) => { - const stepIndex = steps.indexOf(step); - if (stepIndex > -1) { - return steps[stepIndex]; +const getStepsAndHeadersForUser = (identityProvider: IdentityProvider, t: TFunction) => { + const baseHeaders: { + title: string; + subtitle: string[]; + skipText?: string; + }[] = [ + { + title: t("welcome_to_cal_header", { appName: APP_NAME }), + subtitle: [t("we_just_need_basic_info"), t("edit_form_later_subtitle")], + }, + { + title: t("set_availability"), + subtitle: [ + t("set_availability_getting_started_subtitle_1"), + t("set_availability_getting_started_subtitle_2"), + ], + }, + { + title: t("nearly_there"), + subtitle: [t("nearly_there_instructions")], + }, + ]; + + const additionalHeaders: { + title: string; + subtitle: string[]; + skipText?: string; + }[] = [ + { + title: t("connect_your_calendar"), + subtitle: [t("connect_your_calendar_instructions")], + skipText: t("connect_calendar_later"), + }, + { + title: t("connect_your_video_app"), + subtitle: [t("connect_your_video_app_instructions")], + skipText: t("set_up_later"), + }, + ]; + + if (identityProvider === IdentityProvider.GOOGLE) { + return { + steps: [...BASE_STEPS] as StepType[], + headers: [...baseHeaders], + }; } - return INITIAL_STEP; + + return { + steps: [...BASE_STEPS.slice(0, 1), ...EXTRA_STEPS, ...BASE_STEPS.slice(1)] as StepType[], + headers: [baseHeaders[0], ...additionalHeaders, ...baseHeaders.slice(1)], + }; }; const stepRouteSchema = z.object({ - step: z.array(z.enum(steps)).default([INITIAL_STEP]), + step: z + .array( + z.enum(["user-settings", "setup-availability", "user-profile", "connected-calendar", "connected-video"]) + ) + .default([INITIAL_STEP]), from: z.string().optional(), }); export type PageProps = inferSSRProps; -// TODO: Refactor how steps work to be contained in one array/object. Currently we have steps,initalsteps,headers etc. These can all be in one place const OnboardingPage = (props: PageProps) => { const pathname = usePathname(); const params = useParamsWithFallback(); @@ -63,33 +109,6 @@ const OnboardingPage = (props: PageProps) => { const currentStep = result.success ? result.data.step[0] : INITIAL_STEP; const from = result.success ? result.data.from : ""; - const headers = [ - { - title: `${t("welcome_to_cal_header", { appName: APP_NAME })}`, - subtitle: [`${t("we_just_need_basic_info")}`, `${t("edit_form_later_subtitle")}`], - }, - { - title: `${t("connect_your_calendar")}`, - subtitle: [`${t("connect_your_calendar_instructions")}`], - skipText: `${t("connect_calendar_later")}`, - }, - { - title: `${t("connect_your_video_app")}`, - subtitle: [`${t("connect_your_video_app_instructions")}`], - skipText: `${t("set_up_later")}`, - }, - { - title: `${t("set_availability")}`, - subtitle: [ - `${t("set_availability_getting_started_subtitle_1")}`, - `${t("set_availability_getting_started_subtitle_2")}`, - ], - }, - { - title: `${t("nearly_there")}`, - subtitle: [`${t("nearly_there_instructions")}`], - }, - ]; // TODO: Add this in when we have solved the ability to move to tokens accept invite and note invitedto // Ability to accept other pending invites if any (low priority) @@ -101,14 +120,23 @@ const OnboardingPage = (props: PageProps) => { // } // ); // } + const { steps, headers } = getStepsAndHeadersForUser(user.identityProvider, t); + const stepTransform = (step: StepType) => { + const stepIndex = steps.indexOf(step as (typeof steps)[number]); - const goToIndex = (index: number) => { - const newStep = steps[index]; + if (stepIndex > -1) { + return steps[stepIndex]; + } + return INITIAL_STEP; + }; + const currentStepIndex = steps.indexOf(currentStep); + + const goToNextStep = () => { + const nextIndex = currentStepIndex + 1; + const newStep = steps[nextIndex]; router.push(`/getting-started/${stepTransform(newStep)}`); }; - const currentStepIndex = steps.indexOf(currentStep); - return (
{

))} - +
}> {currentStep === "user-settings" && ( - goToIndex(1)} hideUsername={from === "signup"} /> + )} - {currentStep === "connected-calendar" && goToIndex(2)} />} + {currentStep === "connected-calendar" && } - {currentStep === "connected-video" && goToIndex(3)} />} + {currentStep === "connected-video" && } {currentStep === "setup-availability" && ( - goToIndex(4)} - defaultScheduleId={user.defaultScheduleId} - /> + )} {currentStep === "user-profile" && } @@ -167,7 +192,7 @@ const OnboardingPage = (props: PageProps) => { data-testid="skip-step" onClick={(event) => { event.preventDefault(); - goToIndex(currentStepIndex + 1); + goToNextStep(); }} className="mt-8 cursor-pointer px-4 py-2 font-sans text-sm font-medium"> {headers[currentStepIndex]?.skipText} diff --git a/apps/web/playwright/fixtures/users.ts b/apps/web/playwright/fixtures/users.ts index 55b86b50d8..b2b7340ea1 100644 --- a/apps/web/playwright/fixtures/users.ts +++ b/apps/web/playwright/fixtures/users.ts @@ -880,7 +880,8 @@ type CustomUserOptsKeys = | "organizationId" | "twoFactorEnabled" | "disableImpersonation" - | "role"; + | "role" + | "identityProvider"; type CustomUserOpts = Partial> & { timeZone?: TimeZoneEnum; eventTypes?: SupportedTestEventTypes[]; @@ -946,6 +947,7 @@ const createUser = ( }, } : undefined, + identityProvider: opts?.identityProvider, }; function getOrganizationRelatedProps({ diff --git a/apps/web/playwright/onboarding.e2e.ts b/apps/web/playwright/onboarding.e2e.ts index f70fe37261..5fbd67dfc6 100644 --- a/apps/web/playwright/onboarding.e2e.ts +++ b/apps/web/playwright/onboarding.e2e.ts @@ -1,6 +1,8 @@ /* eslint-disable playwright/no-skipped-test */ import { expect } from "@playwright/test"; +import { IdentityProvider } from "@calcom/prisma/enums"; + import { test } from "./lib/fixtures"; test.describe.configure({ mode: "serial" }); @@ -9,75 +11,89 @@ test.afterEach(({ users }) => users.deleteAll()); test.describe("Onboarding", () => { test.describe("Onboarding v2", () => { - test("Onboarding Flow", async ({ page, users }) => { - const user = await users.create({ completedOnboarding: false, name: null }); - await user.apiLogin(); - await page.goto("/getting-started"); - // tests whether the user makes it to /getting-started - // after login with completedOnboarding false - await page.waitForURL("/getting-started"); + const testOnboarding = (identityProvider: IdentityProvider) => { + test(`Onboarding Flow - ${identityProvider} user`, async ({ page, users }) => { + const user = await users.create({ + completedOnboarding: false, + name: null, + identityProvider, + }); + await user.apiLogin(); + await page.goto("/getting-started"); + // tests whether the user makes it to /getting-started + // after login with completedOnboarding false + await page.waitForURL("/getting-started"); - await test.step("step 1", async () => { - // Check required fields - await page.locator("button[type=submit]").click(); - await expect(page.locator("data-testid=required")).toBeVisible(); + await test.step("step 1 - User Settings", async () => { + // Check required fields + await page.locator("button[type=submit]").click(); + await expect(page.locator("data-testid=required")).toBeVisible(); - // happy path - await page.locator("input[name=username]").fill("new user onboarding"); - await page.locator("input[name=name]").fill("new user 2"); - await page.locator("input[role=combobox]").click(); - await page - .locator("*") - .filter({ hasText: /^Europe\/London/ }) - .first() - .click(); + // happy path + await page.locator("input[name=username]").fill("new user onboarding"); + await page.locator("input[name=name]").fill("new user 2"); + await page.locator("input[role=combobox]").click(); + await page + .locator("*") + .filter({ hasText: /^Europe\/London/ }) + .first() + .click(); + await page.locator("button[type=submit]").click(); - await page.locator("button[type=submit]").click(); + if (identityProvider === IdentityProvider.GOOGLE) { + await expect(page).toHaveURL(/.*setup-availability/); + } else { + await expect(page).toHaveURL(/.*connected-calendar/); + } - // should be on step 2 now. - await expect(page).toHaveURL(/.*connected-calendar/); + const userComplete = await user.self(); + expect(userComplete.name).toBe("new user 2"); + }); - const userComplete = await user.self(); - expect(userComplete.name).toBe("new user 2"); + if (identityProvider !== IdentityProvider.GOOGLE) { + await test.step("step 2 - Connected Calendar", async () => { + const isDisabled = await page.locator("button[data-testid=save-calendar-button]").isDisabled(); + await expect(isDisabled).toBe(true); + // tests skip button, we don't want to test entire flow. + await page.locator("button[data-testid=skip-step]").click(); + + await expect(page).toHaveURL(/.*connected-video/); + }); + + await test.step("step 3 - Connected Video", async () => { + const isDisabled = await page.locator("button[data-testid=save-video-button]").isDisabled(); + await expect(isDisabled).toBe(true); + // tests skip button, we don't want to test entire flow. + await page.locator("button[data-testid=skip-step]").click(); + + await expect(page).toHaveURL(/.*setup-availability/); + }); + } + + await test.step("step 4 - Setup Availability", async () => { + const isDisabled = await page.locator("button[data-testid=save-availability]").isDisabled(); + await expect(isDisabled).toBe(false); + // same here, skip this step. + await page.locator("button[data-testid=save-availability]").click(); + + await expect(page).toHaveURL(/.*user-profile/); + }); + + await test.step("step 5- User Profile", async () => { + await page.locator("button[type=submit]").click(); + + // should redirect to /event-types after onboarding + await page.waitForURL("/event-types"); + + const userComplete = await user.self(); + + expect(userComplete.bio?.replace("


", "").length).toBe(0); + }); }); + }; - await test.step("step 2", async () => { - const isDisabled = await page.locator("button[data-testid=save-calendar-button]").isDisabled(); - await expect(isDisabled).toBe(true); - // tests skip button, we don't want to test entire flow. - await page.locator("button[data-testid=skip-step]").click(); - - await expect(page).toHaveURL(/.*connected-video/); - }); - - await test.step("step 3", async () => { - const isDisabled = await page.locator("button[data-testid=save-video-button]").isDisabled(); - await expect(isDisabled).toBe(true); - // tests skip button, we don't want to test entire flow. - await page.locator("button[data-testid=skip-step]").click(); - - await expect(page).toHaveURL(/.*setup-availability/); - }); - - await test.step("step 4", async () => { - const isDisabled = await page.locator("button[data-testid=save-availability]").isDisabled(); - await expect(isDisabled).toBe(false); - // same here, skip this step. - await page.locator("button[data-testid=save-availability]").click(); - - await expect(page).toHaveURL(/.*user-profile/); - }); - - await test.step("step 5", async () => { - await page.locator("button[type=submit]").click(); - - // should redirect to /event-types after onboarding - await page.waitForURL("/event-types"); - - const userComplete = await user.self(); - - expect(userComplete.bio?.replace("


", "").length).toBe(0); - }); - }); + testOnboarding(IdentityProvider.GOOGLE); + testOnboarding(IdentityProvider.CAL); + testOnboarding(IdentityProvider.SAML); }); }); diff --git a/packages/ui/components/form/step/Steps.tsx b/packages/ui/components/form/step/Steps.tsx index fb042d69a1..e1f62674cc 100644 --- a/packages/ui/components/form/step/Steps.tsx +++ b/packages/ui/components/form/step/Steps.tsx @@ -3,7 +3,7 @@ import classNames from "@calcom/lib/classNames"; type StepWithNav = { maxSteps: number; currentStep: number; - navigateToStep: (step: number) => void; + nextStep: () => void; disableNavigation?: false; stepLabel?: (currentStep: number, maxSteps: number) => string; }; @@ -11,7 +11,7 @@ type StepWithNav = { type StepWithoutNav = { maxSteps: number; currentStep: number; - navigateToStep?: undefined; + nextStep?: undefined; disableNavigation: true; stepLabel?: (currentStep: number, maxSteps: number) => string; }; @@ -23,7 +23,7 @@ const Steps = (props: StepsProps) => { const { maxSteps, currentStep, - navigateToStep, + nextStep, disableNavigation = false, stepLabel = (currentStep, totalSteps) => `Step ${currentStep} of ${totalSteps}`, } = props; @@ -35,7 +35,7 @@ const Steps = (props: StepsProps) => { return index <= currentStep - 1 ? (
navigateToStep?.(index)} + onClick={() => nextStep?.()} className={classNames( "bg-inverted h-1 w-full rounded-[1px]", index < currentStep - 1 && !disableNavigation ? "cursor-pointer" : "" diff --git a/packages/ui/components/form/step/steps.test.tsx b/packages/ui/components/form/step/steps.test.tsx index 57af482922..122596f177 100644 --- a/packages/ui/components/form/step/steps.test.tsx +++ b/packages/ui/components/form/step/steps.test.tsx @@ -13,8 +13,9 @@ const mockNavigateToStep = vi.fn(); const Props = { maxSteps: MAX_STEPS, currentStep: CURRENT_STEP, - navigateToStep: mockNavigateToStep, + nextStep: mockNavigateToStep, stepLabel: (currentStep: number, totalSteps: number) => `Test Step ${currentStep} of ${totalSteps}`, + disableNavigation: undefined, }; describe("Tests for Steps Component", () => { @@ -52,7 +53,7 @@ describe("Tests for Steps Component", () => { const stepIndicator = getByTestId(`step-indicator-${i}`); if (i < CURRENT_STEP - 1) { fireEvent.click(stepIndicator); - expect(mockNavigateToStep).toHaveBeenCalledWith(i); + expect(mockNavigateToStep).toHaveBeenCalled(); mockNavigateToStep.mockClear(); } else { expect(mockNavigateToStep).not.toHaveBeenCalled(); diff --git a/packages/ui/components/form/wizard/WizardForm.tsx b/packages/ui/components/form/wizard/WizardForm.tsx index ea9a297452..01595c5d73 100644 --- a/packages/ui/components/form/wizard/WizardForm.tsx +++ b/packages/ui/components/form/wizard/WizardForm.tsx @@ -60,7 +60,7 @@ function WizardForm(props: { diff --git a/packages/ui/layouts/WizardLayout.tsx b/packages/ui/layouts/WizardLayout.tsx index 4d129f6593..8399428a0c 100644 --- a/packages/ui/layouts/WizardLayout.tsx +++ b/packages/ui/layouts/WizardLayout.tsx @@ -56,7 +56,7 @@ export function WizardLayout({ )} - +
{children}