From b19f6056148762a92f54c3f2901f3b3198ea4224 Mon Sep 17 00:00:00 2001 From: MelvinBot Date: Wed, 4 Feb 2026 18:12:22 -0500 Subject: [PATCH] Revert "Create navigation guards + implement Onboarding Guard" --- src/Expensify.tsx | 10 +- src/hooks/useOnboardingFlow.ts | 72 +++-- .../RootStackRouter.ts | 69 ++--- src/libs/Navigation/NavigationRoot.tsx | 32 +- src/libs/Navigation/guards/OnboardingGuard.ts | 169 ----------- src/libs/Navigation/guards/index.ts | 104 ------- src/libs/Navigation/guards/types.ts | 40 --- src/libs/actions/Link.ts | 44 ++- src/libs/actions/Welcome/index.ts | 101 ++++++- .../guards/NavigationGuards.test.ts | 210 ------------- .../Navigation/guards/OnboardingGuard.test.ts | 275 ------------------ 11 files changed, 250 insertions(+), 876 deletions(-) delete mode 100644 src/libs/Navigation/guards/OnboardingGuard.ts delete mode 100644 src/libs/Navigation/guards/index.ts delete mode 100644 src/libs/Navigation/guards/types.ts delete mode 100644 tests/unit/Navigation/guards/NavigationGuards.test.ts delete mode 100644 tests/unit/Navigation/guards/OnboardingGuard.test.ts diff --git a/src/Expensify.tsx b/src/Expensify.tsx index dade3083efc37..8bc31b6926f89 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -119,6 +119,9 @@ function Expensify() { const [isSidebarLoaded] = useOnyx(ONYXKEYS.IS_SIDEBAR_LOADED, {canBeMissing: true}); const [screenShareRequest] = useOnyx(ONYXKEYS.SCREEN_SHARE_REQUEST, {canBeMissing: true}); const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH, {canBeMissing: true}); + const [currentOnboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, {canBeMissing: true}); + const [currentOnboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE, {canBeMissing: true}); + const [onboardingInitialPath] = useOnyx(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH, {canBeMissing: true}); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: false}); const [hasLoadedApp] = useOnyx(ONYXKEYS.HAS_LOADED_APP, {canBeMissing: true}); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); @@ -336,7 +339,10 @@ function Expensify() { setInitialUrl(url as Route); if (url) { - openReportFromDeepLink(url, allReports, isAuthenticated, conciergeReportID); + if (conciergeReportID === undefined) { + Log.info('[Deep link] conciergeReportID is undefined when processing initial URL', false, {url}); + } + openReportFromDeepLink(url, currentOnboardingPurposeSelected, currentOnboardingCompanySize, onboardingInitialPath, allReports, isAuthenticated, conciergeReportID); } else { Report.doneCheckingPublicRoom(); } @@ -350,7 +356,7 @@ function Expensify() { Log.info('[Deep link] conciergeReportID is undefined when processing URL change', false, {url: state.url}); } const isCurrentlyAuthenticated = hasAuthToken(); - openReportFromDeepLink(state.url, allReports, isCurrentlyAuthenticated, conciergeReportID); + openReportFromDeepLink(state.url, currentOnboardingPurposeSelected, currentOnboardingCompanySize, onboardingInitialPath, allReports, isCurrentlyAuthenticated, conciergeReportID); }); return () => { diff --git a/src/hooks/useOnboardingFlow.ts b/src/hooks/useOnboardingFlow.ts index 8bbb7d03d439a..5b30092145932 100644 --- a/src/hooks/useOnboardingFlow.ts +++ b/src/hooks/useOnboardingFlow.ts @@ -1,7 +1,7 @@ import {isSingleNewDotEntrySelector} from '@selectors/HybridApp'; import {hasCompletedGuidedSetupFlowSelector, tryNewDotOnyxSelector} from '@selectors/Onboarding'; import {emailSelector} from '@selectors/Session'; -import {useEffect, useMemo} from 'react'; +import {useEffect, useMemo, useRef} from 'react'; import {InteractionManager} from 'react-native'; import {startOnboardingFlow} from '@libs/actions/Welcome/OnboardingFlow'; import Log from '@libs/Log'; @@ -29,16 +29,20 @@ function useOnboardingFlowRouter() { const [onboardingValues, isOnboardingCompletedMetadata] = useOnyx(ONYXKEYS.NVP_ONBOARDING, { canBeMissing: true, }); - const [account] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true}); + const [currentOnboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, {canBeMissing: true}); + const [currentOnboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE, {canBeMissing: true}); + const [onboardingInitialPath, onboardingInitialPathMetadata] = useOnyx(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH, {canBeMissing: true}); + const [account, accountMetadata] = useOnyx(ONYXKEYS.ACCOUNT, {canBeMissing: true}); + const isOnboardingLoading = isLoadingOnyxValue(onboardingInitialPathMetadata, accountMetadata); const [sessionEmail] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: true, selector: emailSelector}); const isLoggingInAsNewSessionUser = isLoggingInAsNewUser(currentUrl, sessionEmail); + const startedOnboardingFlowRef = useRef(false); const [tryNewDot, tryNewDotMetadata] = useOnyx(ONYXKEYS.NVP_TRY_NEW_DOT, { selector: tryNewDotOnyxSelector, canBeMissing: true, }); const {isHybridAppOnboardingCompleted, hasBeenAddedToNudgeMigration} = tryNewDot ?? {}; - const isOnboardingLoading = isLoadingOnyxValue(isOnboardingCompletedMetadata, tryNewDotMetadata); const [dismissedProductTraining, dismissedProductTrainingMetadata] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING, {canBeMissing: true}); @@ -52,7 +56,7 @@ function useOnboardingFlowRouter() { // This should delay opening the onboarding modal so it does not interfere with the ongoing ReportScreen params changes // eslint-disable-next-line @typescript-eslint/no-deprecated const handle = InteractionManager.runAfterInteractions(() => { - // Prevent showing onboarding if we are logging in as a new user with short lived token + // Prevent starting the onboarding flow if we are logging in as a new user with short lived token if (currentUrl?.includes(ROUTES.TRANSITION_BETWEEN_APPS) && isLoggingInAsNewSessionUser) { return; } @@ -74,22 +78,6 @@ function useOnboardingFlowRouter() { return; } - // Temporary solution to navigate to onboarding when trying to access the app - // Should be removed once Test Drive modal route has its own navigation guard - // Details: https://github.com/Expensify/App/pull/79898 - if (hasCompletedGuidedSetupFlowSelector(onboardingValues) && onboardingValues?.testDriveModalDismissed === false) { - Navigation.setNavigationActionToMicrotaskQueue(() => { - Log.info('[Onboarding] User has not completed the guided setup flow, starting onboarding flow from test drive modal'); - startOnboardingFlow({ - onboardingInitialPath: ROUTES.TEST_DRIVE_MODAL_ROOT.route, - isUserFromPublicDomain: false, - hasAccessiblePolicies: false, - currentOnboardingCompanySize: undefined, - currentOnboardingPurposeSelected: undefined, - onboardingValues, - }); - }); - } if (hasBeenAddedToNudgeMigration && !isProductTrainingElementDismissed('migratedUserWelcomeModal', dismissedProductTraining)) { const navigationState = navigationRef.getRootState(); const lastRoute = navigationState.routes.at(-1); @@ -101,6 +89,12 @@ function useOnboardingFlowRouter() { return; } + if (hasBeenAddedToNudgeMigration) { + return; + } + + const isOnboardingCompleted = hasCompletedGuidedSetupFlowSelector(onboardingValues) && onboardingValues?.testDriveModalDismissed !== false; + if (CONFIG.IS_HYBRID_APP) { // For single entries, such as using the Travel feature from OldDot, we don't want to show onboarding if (isSingleNewDotEntry) { @@ -111,6 +105,37 @@ function useOnboardingFlowRouter() { if (isHybridAppOnboardingCompleted === false) { Navigation.navigate(ROUTES.EXPLANATION_MODAL_ROOT); } + + // But if the hybrid app onboarding is completed, but NewDot onboarding is not completed, we start NewDot onboarding flow + // This is a special case when user created an account from NewDot without finishing the onboarding flow and then logged in from OldDot + if (isHybridAppOnboardingCompleted === true && isOnboardingCompleted === false && !startedOnboardingFlowRef.current) { + startedOnboardingFlowRef.current = true; + Log.info('[Onboarding] Hybrid app onboarding is completed, but NewDot onboarding is not completed, starting NewDot onboarding flow'); + startOnboardingFlow({ + onboardingValuesParam: onboardingValues, + isUserFromPublicDomain: !!account?.isFromPublicDomain, + hasAccessiblePolicies: !!account?.hasAccessibleDomainPolicies, + currentOnboardingCompanySize, + currentOnboardingPurposeSelected, + onboardingInitialPath, + onboardingValues, + }); + } + } + + // If the user is not transitioning from OldDot to NewDot, we should start NewDot onboarding flow if it's not completed yet + if (!CONFIG.IS_HYBRID_APP && isOnboardingCompleted === false && !startedOnboardingFlowRef.current) { + startedOnboardingFlowRef.current = true; + Log.info('[Onboarding] Not a hybrid app, NewDot onboarding is not completed, starting NewDot onboarding flow'); + startOnboardingFlow({ + onboardingValuesParam: onboardingValues, + isUserFromPublicDomain: !!account?.isFromPublicDomain, + hasAccessiblePolicies: !!account?.hasAccessibleDomainPolicies, + currentOnboardingCompanySize, + currentOnboardingPurposeSelected, + onboardingInitialPath, + onboardingValues, + }); } }); @@ -127,11 +152,16 @@ function useOnboardingFlowRouter() { hasBeenAddedToNudgeMigration, dismissedProductTrainingMetadata, dismissedProductTraining?.migratedUserWelcomeModal, + onboardingValues, dismissedProductTraining, + account?.isFromPublicDomain, + account?.hasAccessibleDomainPolicies, currentUrl, isLoggingInAsNewSessionUser, + currentOnboardingCompanySize, + currentOnboardingPurposeSelected, + onboardingInitialPath, isOnboardingLoading, - onboardingValues, ]); return { diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts index 8eceff88a361b..651bc15f14b11 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/RootStackRouter.ts @@ -1,11 +1,9 @@ -import {CommonActions, StackRouter} from '@react-navigation/native'; -import type {RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native'; +import type {CommonActions, RouterConfigOptions, StackActionType, StackNavigationState} from '@react-navigation/native'; +import {findFocusedRoute, StackRouter} from '@react-navigation/native'; import type {ParamListBase} from '@react-navigation/routers'; -import {createGuardContext, evaluateGuards} from '@libs/Navigation/guards'; -import getAdaptedStateFromPath from '@libs/Navigation/helpers/getAdaptedStateFromPath'; -import {isFullScreenName} from '@libs/Navigation/helpers/isNavigatorName'; +import {isFullScreenName, isOnboardingFlowName} from '@libs/Navigation/helpers/isNavigatorName'; import isSideModalNavigator from '@libs/Navigation/helpers/isSideModalNavigator'; -import {linkingConfig} from '@libs/Navigation/linkingConfig'; +import * as Welcome from '@userActions/Welcome'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import { @@ -58,45 +56,20 @@ function isPreloadAction(action: RootStackNavigatorAction): action is PreloadAct return action.type === CONST.NAVIGATION.ACTION_TYPE.PRELOAD; } -/** - * Evaluates navigation guards and handles BLOCK/REDIRECT results - * - * @param state - Current navigation state - * @param action - Navigation action being attempted - * @param configOptions - Router configuration options - * @param stackRouter - Stack router instance - * @returns Modified state if guard blocks/redirects, null if navigation should proceed - */ -function handleNavigationGuards( - state: StackNavigationState, - action: RootStackNavigatorAction, - configOptions: RouterConfigOptions, - stackRouter: ReturnType, -): ReturnType['getStateForAction']> | null { - const guardContext = createGuardContext(); - const guardResult = evaluateGuards(state, action, guardContext); - - if (guardResult.type === 'BLOCK') { - syncBrowserHistory(state); - return state; +function shouldPreventReset(state: StackNavigationState, action: CommonActions.Action | StackActionType) { + if (action.type !== CONST.NAVIGATION_ACTIONS.RESET || !action?.payload) { + return false; } + const currentFocusedRoute = findFocusedRoute(state); + const targetFocusedRoute = findFocusedRoute(action?.payload); - if (guardResult.type === 'REDIRECT') { - const redirectState = getAdaptedStateFromPath(guardResult.route, linkingConfig.config); - - if (!redirectState || !redirectState.routes) { - return null; - } - - const resetAction = CommonActions.reset({ - index: redirectState.index ?? redirectState.routes.length - 1, - routes: redirectState.routes, - }); - - return stackRouter.getStateForAction(state, resetAction, configOptions); + // We want to prevent the user from navigating back to a non-onboarding screen if they are currently on an onboarding screen + if (isOnboardingFlowName(currentFocusedRoute?.name) && !isOnboardingFlowName(targetFocusedRoute?.name)) { + Welcome.setOnboardingErrorMessage('onboarding.purpose.errorBackButton'); + return true; } - return null; + return false; } function isNavigatingToModalFromModal(state: StackNavigationState, action: CommonActions.Action | StackActionType): action is PushActionType { @@ -117,14 +90,6 @@ function RootStackRouter(options: RootStackNavigatorRouterOptions) { return { ...stackRouter, getStateForAction(state: StackNavigationState, action: RootStackNavigatorAction, configOptions: RouterConfigOptions) { - // Evaluate navigation guards FIRST - const guardState = handleNavigationGuards(state, action, configOptions, stackRouter); - if (guardState) { - return guardState; - } - - // Guards allowed navigation - continue with routing logic - if (isPreloadAction(action) && action.payload.name === state.routes.at(-1)?.name) { return state; } @@ -156,6 +121,12 @@ function RootStackRouter(options: RootStackNavigatorRouterOptions) { return handlePushFullscreenAction(state, action, configOptions, stackRouter); } + // Don't let the user navigate back to a non-onboarding screen if they are currently on an onboarding screen and it's not finished. + if (shouldPreventReset(state, action)) { + syncBrowserHistory(state); + return state; + } + if (isNavigatingToModalFromModal(state, action)) { return handleNavigatingToModalFromModal(state, action, configOptions, stackRouter); } diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index 7f65fb9dc4658..b71697b4de318 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -1,7 +1,8 @@ import type {NavigationState} from '@react-navigation/native'; import {DarkTheme, DefaultTheme, findFocusedRoute, getPathFromState, NavigationContainer} from '@react-navigation/native'; -import {hasCompletedGuidedSetupFlowSelector} from '@selectors/Onboarding'; +import {hasCompletedGuidedSetupFlowSelector, wasInvitedToNewDotSelector} from '@selectors/Onboarding'; import React, {useCallback, useContext, useEffect, useMemo, useRef} from 'react'; +import {useOnboardingValues} from '@components/OnyxListItemProvider'; import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider'; import {useCurrentReportIDActions} from '@hooks/useCurrentReportID'; import useOnyx from '@hooks/useOnyx'; @@ -16,6 +17,8 @@ import shouldOpenLastVisitedPath from '@libs/shouldOpenLastVisitedPath'; import {getPathFromURL} from '@libs/Url'; import {updateLastVisitedPath} from '@userActions/App'; import {updateOnboardingLastVisitedPath} from '@userActions/Welcome'; +import {getOnboardingInitialPath} from '@userActions/Welcome/OnboardingFlow'; +import CONFIG from '@src/CONFIG'; import CONST from '@src/CONST'; import {endSpan, getSpan, startSpan} from '@src/libs/telemetry/activeSpans'; import {navigationIntegration} from '@src/libs/telemetry/integrations'; @@ -99,11 +102,20 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N selector: hasCompletedGuidedSetupFlowSelector, canBeMissing: true, }); - + const [wasInvitedToNewDot = false] = useOnyx(ONYXKEYS.NVP_INTRO_SELECTED, { + selector: wasInvitedToNewDotSelector, + canBeMissing: true, + }); + const [hasNonPersonalPolicy] = useOnyx(ONYXKEYS.HAS_NON_PERSONAL_POLICY, {canBeMissing: true}); + const [currentOnboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, {canBeMissing: true}); + const [currentOnboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE, {canBeMissing: true}); + const [onboardingInitialPath] = useOnyx(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH, {canBeMissing: true}); + const onboardingValues = useOnboardingValues(); const previousAuthenticated = usePrevious(authenticated); const initialState = useMemo(() => { const path = initialUrl ? getPathFromURL(initialUrl) : null; + if (path?.includes(ROUTES.MIGRATED_USER_WELCOME_MODAL.route) && shouldOpenLastVisitedPath(lastVisitedPath) && isOnboardingCompleted && authenticated) { Navigation.isNavigationReady().then(() => { Navigation.navigate(ROUTES.MIGRATED_USER_WELCOME_MODAL.getRoute()); @@ -124,6 +136,22 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N return undefined; } + // If the user haven't completed the flow, we want to always redirect them to the onboarding flow. + // We also make sure that the user is authenticated, isn't part of a group workspace, isn't in the transition flow & wasn't invited to NewDot. + if (!CONFIG.IS_HYBRID_APP && !hasNonPersonalPolicy && !isOnboardingCompleted && !wasInvitedToNewDot && authenticated) { + return getAdaptedStateFromPath( + getOnboardingInitialPath({ + isUserFromPublicDomain: !!account.isFromPublicDomain, + hasAccessiblePolicies: !!account.hasAccessibleDomainPolicies, + currentOnboardingPurposeSelected, + currentOnboardingCompanySize, + onboardingInitialPath, + onboardingValues, + }), + linkingConfig.config, + ); + } + if (shouldOpenLastVisitedPath(lastVisitedPath) && authenticated) { // Only skip restoration if there's a specific deep link that's not the root // This allows restoration when app is killed and reopened without a deep link diff --git a/src/libs/Navigation/guards/OnboardingGuard.ts b/src/libs/Navigation/guards/OnboardingGuard.ts deleted file mode 100644 index e9ffc469b20aa..0000000000000 --- a/src/libs/Navigation/guards/OnboardingGuard.ts +++ /dev/null @@ -1,169 +0,0 @@ -import type {NavigationAction, NavigationState} from '@react-navigation/native'; -import {findFocusedRoute} from '@react-navigation/native'; -import {isSingleNewDotEntrySelector} from '@selectors/HybridApp'; -import {hasCompletedGuidedSetupFlowSelector, tryNewDotOnyxSelector, wasInvitedToNewDotSelector} from '@selectors/Onboarding'; -import Onyx from 'react-native-onyx'; -import type {OnyxEntry} from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; -import {setOnboardingErrorMessage} from '@libs/actions/Welcome'; -import Log from '@libs/Log'; -import {isOnboardingFlowName} from '@libs/Navigation/helpers/isNavigatorName'; -import {getOnboardingInitialPath} from '@userActions/Welcome/OnboardingFlow'; -import CONFIG from '@src/CONFIG'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Route} from '@src/ROUTES'; -import ROUTES from '@src/ROUTES'; -import type {Account, Onboarding} from '@src/types/onyx'; -import type {GuardResult, NavigationGuard} from './types'; - -type OnboardingCompanySize = ValueOf; -type OnboardingPurpose = ValueOf; - -/** - * Module-level Onyx subscriptions for OnboardingGuard - * These provide synchronous access to onboarding-related data - */ -let onboarding: OnyxEntry; -let account: OnyxEntry; -let tryNewDot: {isHybridAppOnboardingCompleted: boolean | undefined; hasBeenAddedToNudgeMigration: boolean} | undefined; -let hybridApp: {isSingleNewDotEntry?: boolean} | undefined; -let onboardingPurposeSelected: OnyxEntry; -let onboardingCompanySize: OnyxEntry; -let onboardingInitialPath: OnyxEntry; -let hasNonPersonalPolicy: OnyxEntry; -let wasInvitedToNewDot: boolean | undefined; - -Onyx.connectWithoutView({ - key: ONYXKEYS.NVP_ONBOARDING, - callback: (value) => { - onboarding = value; - }, -}); - -Onyx.connectWithoutView({ - key: ONYXKEYS.ACCOUNT, - callback: (value) => { - account = value; - }, -}); - -Onyx.connectWithoutView({ - key: ONYXKEYS.NVP_TRY_NEW_DOT, - callback: (value) => { - tryNewDot = value ? tryNewDotOnyxSelector(value) : undefined; - }, -}); - -Onyx.connectWithoutView({ - key: ONYXKEYS.HYBRID_APP, - callback: (value) => { - hybridApp = {isSingleNewDotEntry: value ? isSingleNewDotEntrySelector(value) : undefined}; - }, -}); - -Onyx.connectWithoutView({ - key: ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, - callback: (value) => { - onboardingPurposeSelected = value; - }, -}); - -Onyx.connectWithoutView({ - key: ONYXKEYS.ONBOARDING_COMPANY_SIZE, - callback: (value) => { - onboardingCompanySize = value; - }, -}); - -Onyx.connectWithoutView({ - key: ONYXKEYS.ONBOARDING_LAST_VISITED_PATH, - callback: (value) => { - onboardingInitialPath = value; - }, -}); - -Onyx.connectWithoutView({ - key: ONYXKEYS.HAS_NON_PERSONAL_POLICY, - callback: (value) => { - hasNonPersonalPolicy = value; - }, -}); - -Onyx.connectWithoutView({ - key: ONYXKEYS.NVP_INTRO_SELECTED, - callback: (value) => { - wasInvitedToNewDot = value ? wasInvitedToNewDotSelector(value) : undefined; - }, -}); - -/** - * Helper to get the correct onboarding route based on current progress - */ -function getOnboardingRoute(): Route { - return getOnboardingInitialPath({ - onboardingValuesParam: onboarding, - isUserFromPublicDomain: !!account?.isFromPublicDomain, - hasAccessiblePolicies: !!account?.hasAccessibleDomainPolicies, - currentOnboardingCompanySize: onboardingCompanySize, - currentOnboardingPurposeSelected: onboardingPurposeSelected, - onboardingInitialPath, - onboardingValues: onboarding, - }) as Route; -} - -function shouldPreventReset(state: NavigationState, action: NavigationAction) { - if (action.type !== CONST.NAVIGATION_ACTIONS.RESET || !action?.payload) { - return false; - } - - const currentFocusedRoute = findFocusedRoute(state); - const targetFocusedRoute = findFocusedRoute(action?.payload as NavigationState); - - // We want to prevent the user from navigating back to a non-onboarding screen if they are currently on an onboarding screen - if (isOnboardingFlowName(currentFocusedRoute?.name) && !isOnboardingFlowName(targetFocusedRoute?.name)) { - setOnboardingErrorMessage('onboarding.purpose.errorBackButton'); - return true; - } - - return false; -} - -/** - * OnboardingGuard handles ONLY the core NewDot onboarding flow - */ -const OnboardingGuard: NavigationGuard = { - name: 'OnboardingGuard', - - evaluate: (state, action, context): GuardResult => { - if (shouldPreventReset(state, action)) { - return {type: 'BLOCK', reason: 'Cannot reset to non-onboarding screen while on onboarding'}; - } - - const isTransitioning = context.currentUrl?.includes(ROUTES.TRANSITION_BETWEEN_APPS); - const isOnboardingCompleted = hasCompletedGuidedSetupFlowSelector(onboarding) ?? false; - const isMigratedUser = tryNewDot?.hasBeenAddedToNudgeMigration ?? false; - const isSingleEntry = hybridApp?.isSingleNewDotEntry ?? false; - const needsExplanationModal = (CONFIG.IS_HYBRID_APP && tryNewDot?.isHybridAppOnboardingCompleted !== true) ?? false; - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const isInvitedOrGroupMember = (!CONFIG.IS_HYBRID_APP && (hasNonPersonalPolicy || wasInvitedToNewDot)) ?? false; - - const shouldSkipOnboarding = context.isLoading || isTransitioning || isOnboardingCompleted || isMigratedUser || isSingleEntry || needsExplanationModal || isInvitedOrGroupMember; - - if (shouldSkipOnboarding) { - return {type: 'ALLOW'}; - } - - // User needs onboarding - calculate the correct step and redirect - const onboardingRoute = getOnboardingRoute(); - - Log.info('[OnboardingGuard] Redirecting to onboarding route', false, {onboardingRoute}); - - return { - type: 'REDIRECT', - route: onboardingRoute, - }; - }, -}; - -export default OnboardingGuard; diff --git a/src/libs/Navigation/guards/index.ts b/src/libs/Navigation/guards/index.ts deleted file mode 100644 index 1ee567679de70..0000000000000 --- a/src/libs/Navigation/guards/index.ts +++ /dev/null @@ -1,104 +0,0 @@ -import type {NavigationAction, NavigationState} from '@react-navigation/native'; -import Onyx from 'react-native-onyx'; -import type {OnyxEntry} from 'react-native-onyx'; -import getCurrentUrl from '@libs/Navigation/currentUrl'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Session} from '@src/types/onyx'; -import OnboardingGuard from './OnboardingGuard'; -import type {GuardContext, GuardResult, NavigationGuard} from './types'; - -/** - * Module-level Onyx subscriptions for common guard context values - * These provide synchronous access to shared data used by multiple guards - */ -let session: OnyxEntry; -let isLoadingApp = true; - -Onyx.connectWithoutView({ - key: ONYXKEYS.SESSION, - callback: (value) => { - session = value; - }, -}); - -Onyx.connectWithoutView({ - key: ONYXKEYS.IS_LOADING_APP, - callback: (value) => { - isLoadingApp = value ?? true; - }, -}); - -/** - * Registry of all navigation guards - * Guards are evaluated in the order they are registered - */ -const guards: NavigationGuard[] = []; - -/** - * Registers a navigation guard - */ -function registerGuard(guard: NavigationGuard): void { - guards.push(guard); -} - -/** - * Creates a guard context with common computed values - * Guards access specific Onyx data directly via their own subscriptions for runtime checks, - * - * @param overrides - Optional context overrides (e.g., account, onboarding data from hooks) - * @returns Guard context with common helper flags and optional Onyx data - */ -function createGuardContext(overrides?: Partial): GuardContext { - const isAuthenticated = !!session?.authToken; - const currentUrl = getCurrentUrl(); - const isLoading = isLoadingApp; - - return { - isAuthenticated, - isLoading, - currentUrl, - ...overrides, - }; -} - -/** - * Evaluates all registered guards for the given navigation action - * Evaluation short-circuits on the first BLOCK or REDIRECT result. - * - * - BLOCK: block navigation, return unchanged state - * - REDIRECT: create redirect action and process it - * - ALLOW: continue with normal navigation - */ -function evaluateGuards(state: NavigationState, action: NavigationAction, context: GuardContext): GuardResult { - for (const guard of guards) { - const result = guard.evaluate(state, action, context); - - if (result.type === 'BLOCK' || result.type === 'REDIRECT') { - return result; - } - } - - return {type: 'ALLOW'}; -} - -/** - * Gets all registered guards (useful for testing) - */ -function getRegisteredGuards(): readonly NavigationGuard[] { - return guards; -} - -/** - * Clears all registered guards (useful for testing) - */ -function clearGuards(): void { - guards.length = 0; -} - -// Register guards in order of evaluation -// IMPORTANT: Order matters! Guards evaluate in sequence and short-circuit on BLOCK/REDIRECT - -registerGuard(OnboardingGuard); - -export {registerGuard, createGuardContext, evaluateGuards, getRegisteredGuards, clearGuards}; -export type {NavigationGuard, GuardResult, GuardContext}; diff --git a/src/libs/Navigation/guards/types.ts b/src/libs/Navigation/guards/types.ts deleted file mode 100644 index 914dc1493c691..0000000000000 --- a/src/libs/Navigation/guards/types.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type {NavigationAction, NavigationState} from '@react-navigation/native'; -import type {Route} from '@src/ROUTES'; - -/** - * Result returned by a navigation guard after evaluation - */ -type GuardResult = {type: 'ALLOW'} | {type: 'BLOCK'; reason?: string} | {type: 'REDIRECT'; route: Route}; - -/** - * Context provided to guards during evaluation - */ -type GuardContext = { - /** Whether the user is authenticated */ - isAuthenticated: boolean; - - /** Whether the app is still loading initial data */ - isLoading: boolean; - - /** Current URL (for HybridApp and deep link checks) */ - currentUrl: string; -}; - -/** - * Navigation guard interface - * Guards can intercept navigation actions and allow, block, or redirect them - */ -type NavigationGuard = { - /** Guard name for debugging and logging */ - name: string; - - /** - * Evaluates the navigation action and returns a decision - * - ALLOW: Let navigation proceed normally - * - BLOCK: Prevent navigation and keep current state - * - REDIRECT: Replace the navigation with a redirect to a different route - */ - evaluate(state: NavigationState, action: NavigationAction, context: GuardContext): GuardResult; -}; - -export type {GuardResult, GuardContext, NavigationGuard}; diff --git a/src/libs/actions/Link.ts b/src/libs/actions/Link.ts index e194ed684aebd..8dfdcf2270dcd 100644 --- a/src/libs/actions/Link.ts +++ b/src/libs/actions/Link.ts @@ -9,6 +9,7 @@ import asyncOpenURL from '@libs/asyncOpenURL'; import * as Environment from '@libs/Environment/Environment'; import getIsNarrowLayout from '@libs/getIsNarrowLayout'; import isPublicScreenRoute from '@libs/isPublicScreenRoute'; +import Log from '@libs/Log'; import {isOnboardingFlowName} from '@libs/Navigation/helpers/isNavigatorName'; import normalizePath from '@libs/Navigation/helpers/normalizePath'; import shouldOpenOnAdminRoom from '@libs/Navigation/helpers/shouldOpenOnAdminRoom'; @@ -28,10 +29,12 @@ import ONYXKEYS from '@src/ONYXKEYS'; import type {Route} from '@src/ROUTES'; import ROUTES from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; -import type {Report} from '@src/types/onyx'; +import type {Account, Report} from '@src/types/onyx'; import {doneCheckingPublicRoom, navigateToConciergeChat, openReport} from './Report'; import {canAnonymousUserAccessRoute, isAnonymousUser, signOutAndRedirectToSignIn, waitForUserSignIn} from './Session'; -import {setOnboardingErrorMessage} from './Welcome'; +import {isOnboardingFlowCompleted, setOnboardingErrorMessage} from './Welcome'; +import {startOnboardingFlow} from './Welcome/OnboardingFlow'; +import type {OnboardingCompanySize, OnboardingPurpose} from './Welcome/OnboardingFlow'; let isNetworkOffline = false; let networkStatus: NetworkStatus; @@ -53,6 +56,15 @@ Onyx.connectWithoutView({ }, }); +let account: OnyxEntry; +// Use connectWithoutView to subscribe to account data without affecting UI +Onyx.connectWithoutView({ + key: ONYXKEYS.ACCOUNT, + callback: (value) => { + account = value; + }, +}); + function buildOldDotURL(url: string, shortLivedAuthToken?: string): Promise { const hashIndex = url.lastIndexOf('#'); const hasHashParams = hashIndex !== -1; @@ -230,7 +242,15 @@ function openLink(href: string, environmentURL: string, isAttachment = false) { openExternalLink(href); } -function openReportFromDeepLink(url: string, reports: OnyxCollection, isAuthenticated: boolean, conciergeReportID: string | undefined) { +function openReportFromDeepLink( + url: string, + currentOnboardingPurposeSelected: OnyxEntry, + currentOnboardingCompanySize: OnyxEntry, + onboardingInitialPath: OnyxEntry, + reports: OnyxCollection, + isAuthenticated: boolean, + conciergeReportID: string | undefined, +) { const reportID = getReportIDFromLink(url); if (reportID && !isAuthenticated) { @@ -366,7 +386,25 @@ function openReportFromDeepLink(url: string, reports: OnyxCollection, is if (isAnonymousUser()) { handleDeeplinkNavigation(); + return; } + // We need skip deeplinking if the user hasn't completed the guided setup flow. + isOnboardingFlowCompleted({ + onNotCompleted: () => { + Log.info('[Onboarding] User has not completed the guided setup flow, starting onboarding flow from deep link'); + startOnboardingFlow({ + onboardingValuesParam: val, + hasAccessiblePolicies: !!account?.hasAccessibleDomainPolicies, + isUserFromPublicDomain: !!account?.isFromPublicDomain, + currentOnboardingPurposeSelected, + currentOnboardingCompanySize, + onboardingInitialPath, + onboardingValues: val, + }); + }, + onCompleted: handleDeeplinkNavigation, + onCanceled: handleDeeplinkNavigation, + }); }); }, }); diff --git a/src/libs/actions/Welcome/index.ts b/src/libs/actions/Welcome/index.ts index a10f96e62a8e5..9c69f9ed7a689 100644 --- a/src/libs/actions/Welcome/index.ts +++ b/src/libs/actions/Welcome/index.ts @@ -11,21 +11,84 @@ import type {OnboardingAccounting} from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {OnboardingPurpose} from '@src/types/onyx'; +import type {Account, OnboardingPurpose} from '@src/types/onyx'; import type Onboarding from '@src/types/onyx/Onboarding'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {OnboardingCompanySize} from './OnboardingFlow'; +import {startOnboardingFlow} from './OnboardingFlow'; let isLoadingReportData = true; +let onboarding: Onboarding | undefined; +let account: Account | undefined; + +type HasCompletedOnboardingFlowProps = { + onCompleted?: () => void; + onNotCompleted?: () => void; + onCanceled?: () => void; +}; let resolveIsReadyPromise: (value?: Promise) => void | undefined; let isServerDataReadyPromise = new Promise((resolve) => { resolveIsReadyPromise = resolve; }); +let resolveOnboardingFlowStatus: () => void; +let isOnboardingFlowStatusKnownPromise = new Promise((resolve) => { + resolveOnboardingFlowStatus = resolve; +}); + function onServerDataReady(): Promise { return isServerDataReadyPromise; } +let isOnboardingInProgress = false; +function isOnboardingFlowCompleted({onCompleted, onNotCompleted, onCanceled}: HasCompletedOnboardingFlowProps) { + isOnboardingFlowStatusKnownPromise.then(() => { + // Don't trigger onboarding if we are showing the require 2FA page + const shouldShowRequire2FAPage = account && !!account.needsTwoFactorAuthSetup && (!account.requiresTwoFactorAuth || !!account.twoFactorAuthSetupInProgress); + if (shouldShowRequire2FAPage) { + return; + } + + if (isEmptyObject(onboarding) || onboarding?.hasCompletedGuidedSetupFlow === undefined) { + onCanceled?.(); + return; + } + + // The value `undefined` should not be used here because `testDriveModalDismissed` may not always exist in `onboarding`. + // So we only compare it to `false` to avoid unintentionally opening the test drive modal. + if (onboarding?.testDriveModalDismissed === false) { + Navigation.setNavigationActionToMicrotaskQueue(() => { + // Check if we're already on the test drive modal route or if navigation is in progress to prevent duplicate navigation + const currentRoute = Navigation.getActiveRoute(); + if (currentRoute?.includes(ROUTES.TEST_DRIVE_MODAL_ROOT.route)) { + return; + } + + Log.info('[Onboarding] User has not completed the guided setup flow, starting onboarding flow from test drive modal'); + startOnboardingFlow({ + onboardingInitialPath: ROUTES.TEST_DRIVE_MODAL_ROOT.route, + isUserFromPublicDomain: false, + hasAccessiblePolicies: false, + currentOnboardingCompanySize: undefined, + currentOnboardingPurposeSelected: undefined, + onboardingValues: onboarding, + }); + }); + + return; + } + + if (onboarding?.hasCompletedGuidedSetupFlow) { + isOnboardingInProgress = false; + onCompleted?.(); + } else if (!isOnboardingInProgress) { + isOnboardingInProgress = true; + onNotCompleted?.(); + } + }); +} + /** * Check if report data are loaded */ @@ -37,6 +100,17 @@ function checkServerDataReady() { resolveIsReadyPromise?.(); } +/** + * Check if the onboarding data is loaded + */ +function checkOnboardingDataReady() { + if (onboarding === undefined || account === undefined) { + return; + } + + resolveOnboardingFlowStatus(); +} + function setOnboardingPurposeSelected(value: OnboardingPurpose) { Onyx.set(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, value ?? null); } @@ -112,6 +186,26 @@ function completeHybridAppOnboarding() { }); } +// We use `connectWithoutView` here since this connection only updates a module-level variable +// and doesn't need to trigger component re-renders. +Onyx.connectWithoutView({ + key: ONYXKEYS.ACCOUNT, + callback: (value) => { + account = value; + checkOnboardingDataReady(); + }, +}); + +// We use `connectWithoutView` here since this connection only updates a module-level variable +// and doesn't need to trigger component re-renders. +Onyx.connectWithoutView({ + key: ONYXKEYS.NVP_ONBOARDING, + callback: (value) => { + onboarding = value; + checkOnboardingDataReady(); + }, +}); + // We use `connectWithoutView` here since this connection only to get loading flag // and doesn't need to trigger component re-renders. Onyx.connectWithoutView({ @@ -127,7 +221,11 @@ function resetAllChecks() { isServerDataReadyPromise = new Promise((resolve) => { resolveIsReadyPromise = resolve; }); + isOnboardingFlowStatusKnownPromise = new Promise((resolve) => { + resolveOnboardingFlowStatus = resolve; + }); isLoadingReportData = true; + isOnboardingInProgress = false; } function setSelfTourViewed(shouldUpdateOnyxDataOnlyLocally = false) { @@ -169,6 +267,7 @@ function dismissProductTraining(elementName: string, isDismissedUsingCloseButton export { onServerDataReady, + isOnboardingFlowCompleted, dismissProductTraining, setOnboardingPurposeSelected, updateOnboardingLastVisitedPath, diff --git a/tests/unit/Navigation/guards/NavigationGuards.test.ts b/tests/unit/Navigation/guards/NavigationGuards.test.ts deleted file mode 100644 index 6cd41fdb8216a..0000000000000 --- a/tests/unit/Navigation/guards/NavigationGuards.test.ts +++ /dev/null @@ -1,210 +0,0 @@ -import type {NavigationState} from '@react-navigation/native'; -import {clearGuards, createGuardContext, evaluateGuards, getRegisteredGuards, registerGuard} from '@libs/Navigation/guards'; -import type {GuardContext, NavigationGuard} from '@libs/Navigation/guards/types'; -import ROUTES from '@src/ROUTES'; - -describe('Navigation Guard System', () => { - beforeEach(() => { - // Clear all guards before each test - clearGuards(); - }); - - describe('registerGuard', () => { - it('should register a guard', () => { - const mockGuard: NavigationGuard = { - name: 'TestGuard', - evaluate: () => ({type: 'ALLOW'}), - }; - - registerGuard(mockGuard); - - expect(getRegisteredGuards()).toHaveLength(1); - expect(getRegisteredGuards().at(0)).toBe(mockGuard); - }); - - it('should register multiple guards in order', () => { - const guard1: NavigationGuard = { - name: 'Guard1', - evaluate: () => ({type: 'ALLOW'}), - }; - const guard2: NavigationGuard = { - name: 'Guard2', - evaluate: () => ({type: 'ALLOW'}), - }; - - registerGuard(guard1); - registerGuard(guard2); - - const guards = getRegisteredGuards(); - expect(guards).toHaveLength(2); - expect(guards.at(0)?.name).toBe('Guard1'); - expect(guards.at(1)?.name).toBe('Guard2'); - }); - }); - - describe('clearGuards', () => { - it('should remove all registered guards', () => { - const mockGuard: NavigationGuard = { - name: 'TestGuard', - evaluate: () => ({type: 'ALLOW'}), - }; - - registerGuard(mockGuard); - expect(getRegisteredGuards()).toHaveLength(1); - - clearGuards(); - expect(getRegisteredGuards()).toHaveLength(0); - }); - }); - - describe('createGuardContext', () => { - it('should create a guard context with expected properties', () => { - const context = createGuardContext(); - - expect(context).toHaveProperty('isAuthenticated'); - expect(context).toHaveProperty('isLoading'); - expect(context).toHaveProperty('currentUrl'); - expect(typeof context.isAuthenticated).toBe('boolean'); - expect(typeof context.isLoading).toBe('boolean'); - expect(typeof context.currentUrl).toBe('string'); - }); - }); - - describe('evaluateGuards', () => { - const mockState = { - key: 'test-key', - index: 0, - routeNames: [], - routes: [], - type: 'test', - stale: false, - } as NavigationState; - const mockAction = {type: 'NAVIGATE', payload: {name: 'TestScreen'}} as const; - const mockContext: GuardContext = { - isAuthenticated: true, - isLoading: false, - currentUrl: '', - }; - - it('should return ALLOW when no guards are registered', () => { - const result = evaluateGuards(mockState, mockAction, mockContext); - expect(result).toEqual({type: 'ALLOW'}); - }); - - it('should evaluate guards', () => { - const evaluateFn = jest.fn(() => ({type: 'ALLOW' as const})); - const mockGuard: NavigationGuard = { - name: 'TestGuard', - evaluate: evaluateFn, - }; - - registerGuard(mockGuard); - evaluateGuards(mockState, mockAction, mockContext); - - expect(evaluateFn).toHaveBeenCalledWith(mockState, mockAction, mockContext); - }); - - it('should short-circuit on BLOCK result', () => { - const guard1Evaluate = jest.fn(() => ({type: 'BLOCK' as const, reason: 'Blocked'})); - const guard2Evaluate = jest.fn(() => ({type: 'ALLOW' as const})); - - const guard1: NavigationGuard = { - name: 'BlockingGuard', - evaluate: guard1Evaluate, - }; - const guard2: NavigationGuard = { - name: 'AllowGuard', - evaluate: guard2Evaluate, - }; - - registerGuard(guard1); - registerGuard(guard2); - - const result = evaluateGuards(mockState, mockAction, mockContext); - - expect(result).toEqual({type: 'BLOCK', reason: 'Blocked'}); - expect(guard1Evaluate).toHaveBeenCalled(); - expect(guard2Evaluate).not.toHaveBeenCalled(); - }); - - it('should short-circuit on REDIRECT result', () => { - const guard1Evaluate = jest.fn(() => ({type: 'REDIRECT' as const, route: ROUTES.HOME})); - const guard2Evaluate = jest.fn(() => ({type: 'ALLOW' as const})); - - const guard1: NavigationGuard = { - name: 'RedirectGuard', - evaluate: guard1Evaluate, - }; - const guard2: NavigationGuard = { - name: 'AllowGuard', - evaluate: guard2Evaluate, - }; - - registerGuard(guard1); - registerGuard(guard2); - - const result = evaluateGuards(mockState, mockAction, mockContext); - - expect(result).toEqual({type: 'REDIRECT', route: ROUTES.HOME}); - expect(guard1Evaluate).toHaveBeenCalled(); - expect(guard2Evaluate).not.toHaveBeenCalled(); - }); - - it('should continue evaluation when guard returns ALLOW', () => { - const guard1Evaluate = jest.fn(() => ({type: 'ALLOW' as const})); - const guard2Evaluate = jest.fn(() => ({type: 'ALLOW' as const})); - - const guard1: NavigationGuard = { - name: 'AllowGuard1', - evaluate: guard1Evaluate, - }; - const guard2: NavigationGuard = { - name: 'AllowGuard2', - evaluate: guard2Evaluate, - }; - - registerGuard(guard1); - registerGuard(guard2); - - const result = evaluateGuards(mockState, mockAction, mockContext); - - expect(result).toEqual({type: 'ALLOW'}); - expect(guard1Evaluate).toHaveBeenCalled(); - expect(guard2Evaluate).toHaveBeenCalled(); - }); - - it('should evaluate guards in registration order', () => { - const executionOrder: string[] = []; - - const guard1: NavigationGuard = { - name: 'Guard1', - evaluate: () => { - executionOrder.push('Guard1'); - return {type: 'ALLOW'}; - }, - }; - const guard2: NavigationGuard = { - name: 'Guard2', - evaluate: () => { - executionOrder.push('Guard2'); - return {type: 'ALLOW'}; - }, - }; - const guard3: NavigationGuard = { - name: 'Guard3', - evaluate: () => { - executionOrder.push('Guard3'); - return {type: 'ALLOW'}; - }, - }; - - registerGuard(guard1); - registerGuard(guard2); - registerGuard(guard3); - - evaluateGuards(mockState, mockAction, mockContext); - - expect(executionOrder).toEqual(['Guard1', 'Guard2', 'Guard3']); - }); - }); -}); diff --git a/tests/unit/Navigation/guards/OnboardingGuard.test.ts b/tests/unit/Navigation/guards/OnboardingGuard.test.ts deleted file mode 100644 index cd632508c2c6c..0000000000000 --- a/tests/unit/Navigation/guards/OnboardingGuard.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -import type {NavigationAction, NavigationState} from '@react-navigation/native'; -import Onyx from 'react-native-onyx'; -import OnboardingGuard from '@libs/Navigation/guards/OnboardingGuard'; -import type {GuardContext} from '@libs/Navigation/guards/types'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import SCREENS from '@src/SCREENS'; -import waitForBatchedUpdates from '../../../utils/waitForBatchedUpdates'; - -describe('OnboardingGuard', () => { - const mockState: NavigationState = { - key: 'root', - index: 0, - routeNames: [SCREENS.HOME], - routes: [{key: 'home', name: SCREENS.HOME}], - stale: false, - type: 'root', - }; - - const mockAction: NavigationAction = { - type: 'NAVIGATE', - payload: {name: SCREENS.HOME}, - }; - - const authenticatedContext: GuardContext = { - isAuthenticated: true, - isLoading: false, - currentUrl: '', - }; - - beforeEach(async () => { - await Onyx.clear(); - await waitForBatchedUpdates(); - }); - - describe('early return when onboarding completed', () => { - it('should return ALLOW when user has completed onboarding', async () => { - await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { - hasCompletedGuidedSetupFlow: true, - }); - await waitForBatchedUpdates(); - - const result = OnboardingGuard.evaluate(mockState, mockAction, authenticatedContext); - expect(result.type).toBe('ALLOW'); - }); - - it('should return ALLOW when onboarding data is undefined (old/migrated accounts)', async () => { - // Empty/null onboarding means old account - considered completed - await Onyx.set(ONYXKEYS.NVP_ONBOARDING, null); - await waitForBatchedUpdates(); - - const result = OnboardingGuard.evaluate(mockState, mockAction, authenticatedContext); - expect(result.type).toBe('ALLOW'); - }); - }); - - describe('early exit conditions', () => { - it('should allow during app transition', () => { - const transitionContext: GuardContext = { - isAuthenticated: true, - isLoading: false, - currentUrl: 'https://new.expensify.com/transition', - }; - - const result = OnboardingGuard.evaluate(mockState, mockAction, transitionContext); - - expect(result.type).toBe('ALLOW'); - }); - - it('should BLOCK RESET action when user is on onboarding and tries to reset to non-onboarding screen', async () => { - // User is currently on an onboarding screen - const onboardingState: NavigationState = { - key: 'root', - index: 0, - routeNames: [SCREENS.ONBOARDING.PURPOSE], - routes: [{key: 'purpose', name: SCREENS.ONBOARDING.PURPOSE}], - stale: false, - type: 'root', - }; - - // RESET action trying to navigate to a non-onboarding screen (HOME) - const resetAction: NavigationAction = { - type: CONST.NAVIGATION_ACTIONS.RESET, - payload: { - key: 'root', - index: 0, - routeNames: [SCREENS.HOME], - routes: [{key: 'home', name: SCREENS.HOME}], - stale: false, - type: 'root', - }, - }; - - const result = OnboardingGuard.evaluate(onboardingState, resetAction, authenticatedContext) as {type: 'BLOCK'; reason?: string}; - - expect(result.type).toBe('BLOCK'); - expect(result.reason).toBe('Cannot reset to non-onboarding screen while on onboarding'); - }); - }); - - describe('skip onboarding conditions', () => { - it('should allow when onboarding is completed', async () => { - await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { - hasCompletedGuidedSetupFlow: true, - }); - await waitForBatchedUpdates(); - - const result = OnboardingGuard.evaluate(mockState, mockAction, authenticatedContext); - - expect(result.type).toBe('ALLOW'); - }); - - it('should allow migrated users', async () => { - await Onyx.merge(ONYXKEYS.NVP_TRY_NEW_DOT, { - classicRedirect: { - dismissed: false, - }, - nudgeMigration: { - timestamp: new Date(), - cohort: 'test', - }, - }); - await waitForBatchedUpdates(); - - const result = OnboardingGuard.evaluate(mockState, mockAction, authenticatedContext); - - expect(result.type).toBe('ALLOW'); - }); - - it('should allow users with single entry from HybridApp', async () => { - await Onyx.merge(ONYXKEYS.HYBRID_APP, { - isSingleNewDotEntry: true, - }); - await waitForBatchedUpdates(); - - const result = OnboardingGuard.evaluate(mockState, mockAction, authenticatedContext); - - expect(result.type).toBe('ALLOW'); - }); - - it('should allow users with non-personal policies', async () => { - await Onyx.merge(ONYXKEYS.HAS_NON_PERSONAL_POLICY, true); - await waitForBatchedUpdates(); - - const result = OnboardingGuard.evaluate(mockState, mockAction, authenticatedContext); - - expect(result.type).toBe('ALLOW'); - }); - - it('should allow invited users', async () => { - await Onyx.merge(ONYXKEYS.NVP_INTRO_SELECTED, { - choice: CONST.INTRO_CHOICES.SUBMIT, - }); - await waitForBatchedUpdates(); - - const result = OnboardingGuard.evaluate(mockState, mockAction, authenticatedContext); - - expect(result.type).toBe('ALLOW'); - }); - }); - - describe('redirect to onboarding', () => { - it('should redirect when authenticated user needs onboarding', async () => { - await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { - hasCompletedGuidedSetupFlow: false, - }); - await Onyx.merge(ONYXKEYS.ACCOUNT, { - isFromPublicDomain: true, - }); - await waitForBatchedUpdates(); - - const result = OnboardingGuard.evaluate(mockState, mockAction, authenticatedContext) as {type: 'REDIRECT'; route: string}; - - expect(result.type).toBe('REDIRECT'); - expect(result.route).toContain('onboarding'); - }); - - it('should redirect to correct step for users with accessible policies', async () => { - await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { - hasCompletedGuidedSetupFlow: false, - }); - await Onyx.merge(ONYXKEYS.ACCOUNT, { - isFromPublicDomain: false, - hasAccessibleDomainPolicies: true, - }); - await waitForBatchedUpdates(); - - const result = OnboardingGuard.evaluate(mockState, mockAction, authenticatedContext) as {type: 'REDIRECT'; route: string}; - - expect(result.type).toBe('REDIRECT'); - expect(result.route).toContain('onboarding'); - }); - - it('should redirect when user tries to access wrong onboarding step', async () => { - // User is on onboarding/purpose but should be on onboarding/work-email - const onboardingState: NavigationState = { - key: 'root', - index: 0, - routeNames: [SCREENS.ONBOARDING.PURPOSE], - routes: [{key: 'purpose', name: SCREENS.ONBOARDING.PURPOSE}], - stale: false, - type: 'root', - }; - - await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { - hasCompletedGuidedSetupFlow: false, - }); - await Onyx.merge(ONYXKEYS.ACCOUNT, { - isFromPublicDomain: true, - }); - await waitForBatchedUpdates(); - - const result = OnboardingGuard.evaluate(onboardingState, mockAction, authenticatedContext) as {type: 'REDIRECT'; route: string}; - - expect(result.type).toBe('REDIRECT'); - expect(result.route).toContain('onboarding'); - }); - - it('should redirect when user in onboarding tries to access non-onboarding path', async () => { - // User is on onboarding screen but tries to navigate to home - const onboardingState: NavigationState = { - key: 'root', - index: 0, - routeNames: [SCREENS.ONBOARDING.PURPOSE], - routes: [{key: 'purpose', name: SCREENS.ONBOARDING.PURPOSE}], - stale: false, - type: 'root', - }; - - const homeAction: NavigationAction = { - type: 'NAVIGATE', - payload: {name: SCREENS.HOME}, - }; - - await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { - hasCompletedGuidedSetupFlow: false, - }); - await Onyx.merge(ONYXKEYS.ACCOUNT, { - isFromPublicDomain: true, - }); - await waitForBatchedUpdates(); - - const result = OnboardingGuard.evaluate(onboardingState, homeAction, authenticatedContext) as {type: 'REDIRECT'; route: string}; - - expect(result.type).toBe('REDIRECT'); - expect(result.route).toContain('onboarding'); - }); - - it('should always redirect to correct onboarding step when user needs onboarding', async () => { - // Even if user is on an onboarding screen, guard redirects to the correct step - const onboardingState: NavigationState = { - key: 'root', - index: 0, - routeNames: [SCREENS.ONBOARDING.WORK_EMAIL], - routes: [{key: 'work-email', name: SCREENS.ONBOARDING.WORK_EMAIL}], - stale: false, - type: 'root', - }; - - await Onyx.merge(ONYXKEYS.NVP_ONBOARDING, { - hasCompletedGuidedSetupFlow: false, - }); - await Onyx.merge(ONYXKEYS.ACCOUNT, { - isFromPublicDomain: true, - }); - await waitForBatchedUpdates(); - - const result = OnboardingGuard.evaluate(onboardingState, mockAction, authenticatedContext) as {type: 'REDIRECT'; route: string}; - - // Guard should redirect to ensure user is on correct step - expect(result.type).toBe('REDIRECT'); - expect(result.route).toContain('onboarding'); - }); - }); -});