feat: remove 2 steps from the onboarding flow for google signups (#17787)

This commit is contained in:
Benny Joo
2024-12-09 15:44:42 +00:00
committed by GitHub
parent a926e0b9d6
commit 046042a877
7 changed files with 170 additions and 126 deletions
@@ -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}
+3 -1
View File
@@ -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({
+79 -63
View File
@@ -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);
});
});
+4 -4
View File
@@ -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"
/>
+1 -1
View File
@@ -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>