From 537617300617c9213b69985893e5e6e5e8ed5bf6 Mon Sep 17 00:00:00 2001 From: romit Date: Thu, 7 Aug 2025 21:37:26 +0530 Subject: [PATCH 1/4] refactor - app installation steps to separate hook --- .../installation/[[...step]]/step-view.tsx | 89 ++++++++--------- .../[[...step]]/useStepManager.ts | 95 +++++++++++++++++++ 2 files changed, 133 insertions(+), 51 deletions(-) create mode 100644 apps/web/modules/apps/installation/[[...step]]/useStepManager.ts diff --git a/apps/web/modules/apps/installation/[[...step]]/step-view.tsx b/apps/web/modules/apps/installation/[[...step]]/step-view.tsx index 9b452f37bf6e1d..0484ec7f477bf4 100644 --- a/apps/web/modules/apps/installation/[[...step]]/step-view.tsx +++ b/apps/web/modules/apps/installation/[[...step]]/step-view.tsx @@ -1,7 +1,7 @@ "use client"; import { usePathname, useRouter } from "next/navigation"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { useForm } from "react-hook-form"; import { Toaster } from "sonner"; import type { z } from "zod"; @@ -22,6 +22,7 @@ import type { EventTypeMetaDataSchema } from "@calcom/prisma/zod-utils"; import { trpc } from "@calcom/trpc/react"; import type { AppMeta } from "@calcom/types/App"; import { Form, Steps } from "@calcom/ui/components/form"; +import { SkeletonText } from "@calcom/ui/components/skeleton"; import { showToast } from "@calcom/ui/components/toast"; import { HttpError } from "@lib/core/http/error"; @@ -32,7 +33,9 @@ import { ConfigureStepCard } from "@components/apps/installation/ConfigureStepCa import { EventTypesStepCard } from "@components/apps/installation/EventTypesStepCard"; import { StepHeader } from "@components/apps/installation/StepHeader"; -import { STEPS } from "~/apps/installation/[[...step]]/constants"; +import type { STEPS } from "~/apps/installation/[[...step]]/constants"; + +import { useStepManager } from "./useStepManager"; export type TEventType = EventTypeAppSettingsComponentProps["eventType"] & Pick< @@ -66,15 +69,6 @@ export type TEventTypesForm = { type StepType = (typeof STEPS)[number]; -type StepObj = Record< - StepType, - { - getTitle: (appName: string) => string; - getDescription: (appName: string) => string; - stepNumber: number; - } ->; - export type TTeams = (Pick & { alreadyInstalled: boolean; })[]; @@ -116,42 +110,17 @@ const OnboardingPage = ({ const pathname = usePathname(); const router = useRouter(); - const STEPS_MAP: StepObj = { - [AppOnboardingSteps.ACCOUNTS_STEP]: { - getTitle: () => `${t("select_account_header")}`, - getDescription: (appName) => - `${t("select_account_description", { appName, interpolation: { escapeValue: false } })}`, - stepNumber: 1, - }, - [AppOnboardingSteps.EVENT_TYPES_STEP]: { - getTitle: () => `${t("select_event_types_header")}`, - getDescription: (appName) => - `${t("select_event_types_description", { appName, interpolation: { escapeValue: false } })}`, - stepNumber: installableOnTeams ? 2 : 1, - }, - [AppOnboardingSteps.CONFIGURE_STEP]: { - getTitle: (appName) => - `${t("configure_app_header", { appName, interpolation: { escapeValue: false } })}`, - getDescription: () => `${t("configure_app_description")}`, - stepNumber: installableOnTeams ? 3 : 2, - }, - } as const; const [configureStep, setConfigureStep] = useState(false); - const currentStep: AppOnboardingSteps = useMemo(() => { - if (step == AppOnboardingSteps.EVENT_TYPES_STEP && configureStep) { - return AppOnboardingSteps.CONFIGURE_STEP; - } - return step; - }, [step, configureStep]); - const stepObj = STEPS_MAP[currentStep]; + const isOnlySingleAccountToSelect = !teams?.length || !installableOnTeams; - const maxSteps = useMemo(() => { - if (!showEventTypesStep) { - return 1; - } - return installableOnTeams ? STEPS.length : STEPS.length - 1; - }, [showEventTypesStep, installableOnTeams]); + const stepManager = useStepManager({ + step, + configureStep, + showEventTypesStep, + isOnlySingleAccountToSelect, + appName: appMetadata.name, + }); const utils = trpc.useContext(); @@ -230,6 +199,22 @@ const OnboardingPage = ({ router.push(`/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`); }; + useEffect(() => { + // Auto-skip accounts step if only personal account is available + // This should only happen on initial load when user navigates directly to accounts step + if (isOnlySingleAccountToSelect && !mutation.isPending && step === AppOnboardingSteps.ACCOUNTS_STEP) { + handleSelectAccount(); + } + }, [isOnlySingleAccountToSelect, handleSelectAccount, mutation.isPending, step]); + + if (mutation.isPending) { + return ( +
+ +
+ ); + } + return (
- - + + - {currentStep === AppOnboardingSteps.ACCOUNTS_STEP && ( + {stepManager.currentStep === AppOnboardingSteps.ACCOUNTS_STEP && ( )} - {currentStep === AppOnboardingSteps.EVENT_TYPES_STEP && + {stepManager.currentStep === AppOnboardingSteps.EVENT_TYPES_STEP && eventTypeGroups && Boolean(eventTypeGroups?.length) && ( )} - {currentStep === AppOnboardingSteps.CONFIGURE_STEP && + {stepManager.currentStep === AppOnboardingSteps.CONFIGURE_STEP && formPortalRef.current && eventTypeGroups && ( string; + getDescription: (appName?: string) => string; + stepNumber: number; +}; + +type UseStepManagerProps = { + step: AppOnboardingSteps; + configureStep: boolean; + showEventTypesStep: boolean; + isOnlySingleAccountToSelect: boolean; + appName: string; +}; + +type StepManagerReturn = { + currentStep: AppOnboardingSteps; + maxSteps: number; + title: string; + description: string; + stepNumber: number; +}; + +export function useStepManager({ + step, + configureStep, + showEventTypesStep, + isOnlySingleAccountToSelect, + appName, +}: UseStepManagerProps): StepManagerReturn { + const { t } = useLocale(); + + const excludeSteps: AppOnboardingSteps[] = []; + + if (isOnlySingleAccountToSelect) { + excludeSteps.push(AppOnboardingSteps.ACCOUNTS_STEP); + } + + if (!showEventTypesStep) { + excludeSteps.push(AppOnboardingSteps.EVENT_TYPES_STEP); + excludeSteps.push(AppOnboardingSteps.CONFIGURE_STEP); + } + + const visibleSteps = STEPS.filter((step) => !excludeSteps.includes(step)); + + const getStepNumber = (step: AppOnboardingSteps): number => { + const stepIndex = visibleSteps.indexOf(step); + return stepIndex === -1 ? 0 : stepIndex + 1; + }; + + const stepConfigs: Record = { + [AppOnboardingSteps.ACCOUNTS_STEP]: { + getTitle: () => t("select_account_header"), + getDescription: (appName) => + t("select_account_description", { appName, interpolation: { escapeValue: false } }), + stepNumber: getStepNumber(AppOnboardingSteps.ACCOUNTS_STEP), + }, + [AppOnboardingSteps.EVENT_TYPES_STEP]: { + getTitle: () => t("select_event_types_header"), + getDescription: (appName) => + t("select_event_types_description", { appName, interpolation: { escapeValue: false } }), + stepNumber: getStepNumber(AppOnboardingSteps.EVENT_TYPES_STEP), + }, + [AppOnboardingSteps.CONFIGURE_STEP]: { + getTitle: (appName) => t("configure_app_header", { appName, interpolation: { escapeValue: false } }), + getDescription: () => t("configure_app_description"), + stepNumber: getStepNumber(AppOnboardingSteps.CONFIGURE_STEP), + }, + }; + + const currentStep: AppOnboardingSteps = useMemo(() => { + if (step === AppOnboardingSteps.EVENT_TYPES_STEP && configureStep) { + return AppOnboardingSteps.CONFIGURE_STEP; + } + return step; + }, [step, configureStep]); + + const maxSteps = visibleSteps.length; + + const currentStepConfig = stepConfigs[currentStep]; + + return { + currentStep, + maxSteps, + title: currentStepConfig.getTitle(appName), + description: currentStepConfig.getDescription(appName), + stepNumber: currentStepConfig.stepNumber, + }; +} From 192118953f3ca0c2325b9d8073b1c7e95ef67de8 Mon Sep 17 00:00:00 2001 From: romit Date: Thu, 7 Aug 2025 23:22:02 +0530 Subject: [PATCH 2/4] corrected routing logic in case of skipping --- .../apps/installation/[[...step]]/step-view.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/apps/web/modules/apps/installation/[[...step]]/step-view.tsx b/apps/web/modules/apps/installation/[[...step]]/step-view.tsx index 0484ec7f477bf4..b43913c60b3196 100644 --- a/apps/web/modules/apps/installation/[[...step]]/step-view.tsx +++ b/apps/web/modules/apps/installation/[[...step]]/step-view.tsx @@ -177,6 +177,10 @@ const OnboardingPage = ({ }); const handleSelectAccount = async (teamId?: number) => { + if (mutation.isPending) { + return; + } + mutation.mutate({ type: appMetadata.type, variant: appMetadata.variant, @@ -201,11 +205,14 @@ const OnboardingPage = ({ useEffect(() => { // Auto-skip accounts step if only personal account is available - // This should only happen on initial load when user navigates directly to accounts step + // Replace URL to make it look like user came from app page if (isOnlySingleAccountToSelect && !mutation.isPending && step === AppOnboardingSteps.ACCOUNTS_STEP) { + // Replace current URL with app page using Next.js router + router.replace(`/apps/${appMetadata.slug}`); + handleSelectAccount(); } - }, [isOnlySingleAccountToSelect, handleSelectAccount, mutation.isPending, step]); + }, [isOnlySingleAccountToSelect, handleSelectAccount, mutation.isPending, step, appMetadata.slug]); if (mutation.isPending) { return ( From 8b10744e05c73927cdc85a3f448e34a4cdc67e57 Mon Sep 17 00:00:00 2001 From: romit Date: Fri, 8 Aug 2025 00:06:42 +0530 Subject: [PATCH 3/4] replace url once mutation complete --- .../apps/installation/[[...step]]/step-view.tsx | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/apps/web/modules/apps/installation/[[...step]]/step-view.tsx b/apps/web/modules/apps/installation/[[...step]]/step-view.tsx index b43913c60b3196..64fcde828cba99 100644 --- a/apps/web/modules/apps/installation/[[...step]]/step-view.tsx +++ b/apps/web/modules/apps/installation/[[...step]]/step-view.tsx @@ -133,6 +133,7 @@ const OnboardingPage = ({ }); const mutation = useAddAppMutation(null, { onSuccess: (data) => { + router.replace(`/apps/${appMetadata.slug}`); if (data?.setupPending) return; showToast(t("app_successfully_installed"), "success"); }, @@ -205,16 +206,14 @@ const OnboardingPage = ({ useEffect(() => { // Auto-skip accounts step if only personal account is available - // Replace URL to make it look like user came from app page if (isOnlySingleAccountToSelect && !mutation.isPending && step === AppOnboardingSteps.ACCOUNTS_STEP) { - // Replace current URL with app page using Next.js router - router.replace(`/apps/${appMetadata.slug}`); - + // Replace URL to make it look like user came from app page handleSelectAccount(); } - }, [isOnlySingleAccountToSelect, handleSelectAccount, mutation.isPending, step, appMetadata.slug]); + }, [isOnlySingleAccountToSelect, step]); - if (mutation.isPending) { + // Show loading when mutation is pending OR when auto-skip conditions are met + if (mutation.isPending || (isOnlySingleAccountToSelect && step === AppOnboardingSteps.ACCOUNTS_STEP)) { return (
From 7cbac36888400e6d7d25af56f937eb869405acf2 Mon Sep 17 00:00:00 2001 From: romit Date: Fri, 8 Aug 2025 18:48:34 +0530 Subject: [PATCH 4/4] updated routing logic --- .../apps/installation/[[...step]]/step-view.tsx | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/apps/web/modules/apps/installation/[[...step]]/step-view.tsx b/apps/web/modules/apps/installation/[[...step]]/step-view.tsx index 64fcde828cba99..5d46003c5ab98f 100644 --- a/apps/web/modules/apps/installation/[[...step]]/step-view.tsx +++ b/apps/web/modules/apps/installation/[[...step]]/step-view.tsx @@ -133,7 +133,6 @@ const OnboardingPage = ({ }); const mutation = useAddAppMutation(null, { onSuccess: (data) => { - router.replace(`/apps/${appMetadata.slug}`); if (data?.setupPending) return; showToast(t("app_successfully_installed"), "success"); }, @@ -204,16 +203,20 @@ const OnboardingPage = ({ router.push(`/apps/installed/${appMetadata.categories[0]}?hl=${appMetadata.slug}`); }; + async function skipAccountsStep() { + await handleSelectAccount(); + // Replace URL to make it look like user came from app page + router.replace(`/apps/${appMetadata.slug}`); + } + useEffect(() => { - // Auto-skip accounts step if only personal account is available if (isOnlySingleAccountToSelect && !mutation.isPending && step === AppOnboardingSteps.ACCOUNTS_STEP) { - // Replace URL to make it look like user came from app page - handleSelectAccount(); + skipAccountsStep(); } }, [isOnlySingleAccountToSelect, step]); // Show loading when mutation is pending OR when auto-skip conditions are met - if (mutation.isPending || (isOnlySingleAccountToSelect && step === AppOnboardingSteps.ACCOUNTS_STEP)) { + if (mutation.isPending) { return (