feat: remove 2 steps from the onboarding flow for google signups (#17787)
This commit is contained in:
@@ -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<typeof getServerSideProps>;
|
||||
// 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 (
|
||||
<div
|
||||
className={classNames(
|
||||
@@ -139,22 +167,19 @@ const OnboardingPage = (props: PageProps) => {
|
||||
</p>
|
||||
))}
|
||||
</header>
|
||||
<Steps maxSteps={steps.length} currentStep={currentStepIndex + 1} navigateToStep={goToIndex} />
|
||||
<Steps maxSteps={steps.length} currentStep={currentStepIndex + 1} nextStep={goToNextStep} />
|
||||
</div>
|
||||
<StepCard>
|
||||
<Suspense fallback={<Icon name="loader" />}>
|
||||
{currentStep === "user-settings" && (
|
||||
<UserSettings nextStep={() => goToIndex(1)} hideUsername={from === "signup"} />
|
||||
<UserSettings nextStep={goToNextStep} hideUsername={from === "signup"} />
|
||||
)}
|
||||
{currentStep === "connected-calendar" && <ConnectedCalendars nextStep={() => goToIndex(2)} />}
|
||||
{currentStep === "connected-calendar" && <ConnectedCalendars nextStep={goToNextStep} />}
|
||||
|
||||
{currentStep === "connected-video" && <ConnectedVideoStep nextStep={() => goToIndex(3)} />}
|
||||
{currentStep === "connected-video" && <ConnectedVideoStep nextStep={goToNextStep} />}
|
||||
|
||||
{currentStep === "setup-availability" && (
|
||||
<SetupAvailability
|
||||
nextStep={() => goToIndex(4)}
|
||||
defaultScheduleId={user.defaultScheduleId}
|
||||
/>
|
||||
<SetupAvailability nextStep={goToNextStep} defaultScheduleId={user.defaultScheduleId} />
|
||||
)}
|
||||
{currentStep === "user-profile" && <UserProfile />}
|
||||
</Suspense>
|
||||
@@ -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}
|
||||
|
||||
@@ -880,7 +880,8 @@ type CustomUserOptsKeys =
|
||||
| "organizationId"
|
||||
| "twoFactorEnabled"
|
||||
| "disableImpersonation"
|
||||
| "role";
|
||||
| "role"
|
||||
| "identityProvider";
|
||||
type CustomUserOpts = Partial<Pick<Prisma.User, CustomUserOptsKeys>> & {
|
||||
timeZone?: TimeZoneEnum;
|
||||
eventTypes?: SupportedTestEventTypes[];
|
||||
@@ -946,6 +947,7 @@ const createUser = (
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
identityProvider: opts?.identityProvider,
|
||||
};
|
||||
|
||||
function getOrganizationRelatedProps({
|
||||
|
||||
@@ -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("<p><br></p>", "").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("<p><br></p>", "").length).toBe(0);
|
||||
});
|
||||
});
|
||||
testOnboarding(IdentityProvider.GOOGLE);
|
||||
testOnboarding(IdentityProvider.CAL);
|
||||
testOnboarding(IdentityProvider.SAML);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 ? (
|
||||
<div
|
||||
key={`step-${index}`}
|
||||
onClick={() => navigateToStep?.(index)}
|
||||
onClick={() => nextStep?.()}
|
||||
className={classNames(
|
||||
"bg-inverted h-1 w-full rounded-[1px]",
|
||||
index < currentStep - 1 && !disableNavigation ? "cursor-pointer" : ""
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -60,7 +60,7 @@ function WizardForm<T extends DefaultStep>(props: {
|
||||
<Steps
|
||||
maxSteps={steps.length}
|
||||
currentStep={step}
|
||||
navigateToStep={noop}
|
||||
nextStep={noop}
|
||||
stepLabel={stepLabel}
|
||||
data-testid="wizard-step-component"
|
||||
/>
|
||||
|
||||
@@ -56,7 +56,7 @@ export function WizardLayout({
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
<Steps maxSteps={maxSteps} currentStep={currentStep} navigateToStep={noop} />
|
||||
<Steps maxSteps={maxSteps} currentStep={currentStep} nextStep={noop} />
|
||||
</div>
|
||||
<StepCard>{children}</StepCard>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user