diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 13f056926a3db..347a6c11044d3 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -80,7 +80,7 @@ type DynamicRoutes = Record; const DYNAMIC_ROUTES = { VERIFY_ACCOUNT: { path: 'verify-account', - entryScreens: [], + entryScreens: [SCREENS.SETTINGS.WALLET.ROOT], }, } as const satisfies DynamicRoutes; @@ -333,7 +333,6 @@ const ROUTES = { SETTINGS_ABOUT: 'settings/about', SETTINGS_APP_DOWNLOAD_LINKS: 'settings/about/app-download-links', SETTINGS_WALLET: 'settings/wallet', - SETTINGS_WALLET_VERIFY_ACCOUNT: `settings/wallet/${VERIFY_ACCOUNT}`, SETTINGS_WALLET_DOMAIN_CARD: { route: 'settings/wallet/card/:cardID?', getRoute: (cardID: string) => `settings/wallet/card/${cardID}` as const, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 48c9fed435cba..bbf77f5262576 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -178,7 +178,6 @@ const SCREENS = { WALLET: { ROOT: 'Settings_Wallet', - VERIFY_ACCOUNT: 'Settings_Wallet_VerifyAccount', DOMAIN_CARD: 'Settings_Wallet_DomainCard', DOMAIN_CARD_CONFIRM_MAGIC_CODE: 'Settings_Wallet_DomainCard_ConfirmMagicCode', CARD_MISSING_DETAILS: 'Settings_Wallet_Card_MissingDetails', diff --git a/src/hooks/useDynamicBackPath.ts b/src/hooks/useDynamicBackPath.ts new file mode 100644 index 0000000000000..4054d7deb142c --- /dev/null +++ b/src/hooks/useDynamicBackPath.ts @@ -0,0 +1,33 @@ +import {useNavigationState} from '@react-navigation/native'; +import getPathFromState from '@libs/Navigation/helpers/getPathFromState'; +import splitPathAndQuery from '@libs/Navigation/helpers/splitPathAndQuery'; +import type {DynamicRouteSuffix, Route} from '@src/ROUTES'; +import ROUTES from '@src/ROUTES'; + +/** + * Returns the back path for a dynamic route by removing the dynamic suffix from the current URL. + * Only removes the suffix if it's the last segment of the path (ignoring trailing slashes and query parameters). + * @param dynamicRouteSuffix The dynamic route suffix to remove from the current path + * @returns The back path without the dynamic route suffix, or HOME if path is null/undefined + */ +function useDynamicBackPath(dynamicRouteSuffix: DynamicRouteSuffix): Route { + const path = useNavigationState((state) => getPathFromState(state)); + + if (!path) { + return ROUTES.HOME; + } + + const [normalizedPath, query] = splitPathAndQuery(path); + + if (normalizedPath?.endsWith(`/${dynamicRouteSuffix}`)) { + const backPathWithoutQuery = normalizedPath.slice(0, -(dynamicRouteSuffix.length + 1)); + const backPath = `${backPathWithoutQuery}${query ? `?${query}` : ''}`; + + return backPath as Route; + } + + // If suffix is not the last segment, return the original path + return path as Route; +} + +export default useDynamicBackPath; diff --git a/src/libs/AppState/index.ts b/src/libs/AppState/index.ts index 38e3d3f574d34..ddf8d4a82ac21 100644 --- a/src/libs/AppState/index.ts +++ b/src/libs/AppState/index.ts @@ -1,9 +1,8 @@ -import {getPathFromState} from '@react-navigation/native'; import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import Log from '@libs/Log'; -import {linkingConfig} from '@libs/Navigation/linkingConfig'; +import getPathFromState from '@libs/Navigation/helpers/getPathFromState'; import {navigationRef} from '@libs/Navigation/Navigation'; import {isAuthenticating as isAuthenticatingNetworkStore} from '@libs/Network/NetworkStore'; import CONST from '@src/CONST'; @@ -41,7 +40,7 @@ function captureNavigationState(): NavigationStateInfo { return {currentPath: undefined}; } - const routeFromState = getPathFromState(navigationRef.getRootState(), linkingConfig.config); + const routeFromState = getPathFromState(navigationRef.getRootState()); return { currentPath: routeFromState || undefined, }; diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 8e34f4043826b..dea04f4b494dd 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -353,6 +353,7 @@ const ConsoleModalStackNavigator = createModalStackNavigator({ + [SCREENS.SETTINGS.DYNAMIC_VERIFY_ACCOUNT]: () => require('../../../../pages/settings/DynamicVerifyAccountPage').default, [SCREENS.SETTINGS.SHARE_CODE]: () => require('../../../../pages/ShareCodePage').default, [SCREENS.SETTINGS.PROFILE.PRONOUNS]: () => require('../../../../pages/settings/Profile/PronounsPage').default, [SCREENS.SETTINGS.PROFILE.DISPLAY_NAME]: () => require('../../../../pages/settings/Profile/DisplayNamePage').default, @@ -381,7 +382,6 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/settings/AboutPage/ConsolePage').default, [SCREENS.SETTINGS.SHARE_LOG]: () => require('../../../../pages/settings/AboutPage/ShareLogPage').default, [SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS]: () => require('../../../../pages/settings/Profile/PersonalDetails/PersonalAddressPage').default, - [SCREENS.SETTINGS.WALLET.VERIFY_ACCOUNT]: () => require('../../../../pages/settings/Wallet/VerifyAccountPage').default, [SCREENS.SETTINGS.WALLET.DOMAIN_CARD]: () => require('../../../../pages/settings/Wallet/ExpensifyCardPage/index').default, [SCREENS.SETTINGS.WALLET.DOMAIN_CARD_CONFIRM_MAGIC_CODE]: () => require('../../../../pages/settings/Wallet/ExpensifyCardPage/ExpensifyCardVerifyAccountPage').default, diff --git a/src/libs/Navigation/AppNavigator/createRootStackNavigator/syncBrowserHistory/index.web.ts b/src/libs/Navigation/AppNavigator/createRootStackNavigator/syncBrowserHistory/index.web.ts index 77431c8ca14b1..a650fb00f8c78 100644 --- a/src/libs/Navigation/AppNavigator/createRootStackNavigator/syncBrowserHistory/index.web.ts +++ b/src/libs/Navigation/AppNavigator/createRootStackNavigator/syncBrowserHistory/index.web.ts @@ -1,11 +1,10 @@ import type {ParamListBase, StackNavigationState} from '@react-navigation/native'; -import {getPathFromState} from '@react-navigation/native'; -import {linkingConfig} from '@libs/Navigation/linkingConfig'; +import getPathFromState from '@libs/Navigation/helpers/getPathFromState'; function syncBrowserHistory(state: StackNavigationState) { // We reset the URL as the browser sets it in a way that doesn't match the navigation state // eslint-disable-next-line no-restricted-globals - history.replaceState({}, '', getPathFromState(state, linkingConfig.config)); + history.replaceState({}, '', getPathFromState(state)); } export default syncBrowserHistory; diff --git a/src/libs/Navigation/Navigation.ts b/src/libs/Navigation/Navigation.ts index 35c74372fcf5a..9f00beae85070 100644 --- a/src/libs/Navigation/Navigation.ts +++ b/src/libs/Navigation/Navigation.ts @@ -1,6 +1,6 @@ import {findFocusedRoute, getActionFromState} from '@react-navigation/core'; import type {EventArg, NavigationAction, NavigationContainerEventMap, NavigationState, PartialState} from '@react-navigation/native'; -import {CommonActions, getPathFromState, StackActions} from '@react-navigation/native'; +import {CommonActions, StackActions} from '@react-navigation/native'; import {Str} from 'expensify-common'; // eslint-disable-next-line you-dont-need-lodash-underscore/omit import omit from 'lodash/omit'; @@ -25,6 +25,7 @@ import type {Account, SidePanel} from '@src/types/onyx'; import getInitialSplitNavigatorState from './AppNavigator/createSplitNavigator/getInitialSplitNavigatorState'; import getSearchTopmostReportParams from './getSearchTopmostReportParams'; import originalCloseRHPFlow from './helpers/closeRHPFlow'; +import getPathFromState from './helpers/getPathFromState'; import getStateFromPath from './helpers/getStateFromPath'; import getTopmostReportParams from './helpers/getTopmostReportParams'; import {isFullScreenName, isOnboardingFlowName, isSplitNavigatorName} from './helpers/isNavigatorName'; @@ -212,7 +213,7 @@ function getActiveRoute(): string { return ''; } - const routeFromState = getPathFromState(navigationRef.getRootState(), linkingConfig.config); + const routeFromState = getPathFromState(navigationRef.getRootState()); if (routeFromState) { return routeFromState; diff --git a/src/libs/Navigation/NavigationRoot.tsx b/src/libs/Navigation/NavigationRoot.tsx index 7f65fb9dc4658..a251aedf62140 100644 --- a/src/libs/Navigation/NavigationRoot.tsx +++ b/src/libs/Navigation/NavigationRoot.tsx @@ -109,7 +109,7 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N Navigation.navigate(ROUTES.MIGRATED_USER_WELCOME_MODAL.getRoute()); }); - return getAdaptedStateFromPath(lastVisitedPath, linkingConfig.config); + return getAdaptedStateFromPath(lastVisitedPath); } if (!account || account.isFromPublicDomain) { @@ -132,7 +132,7 @@ function NavigationRoot({authenticated, lastVisitedPath, initialUrl, onReady}: N if (!isSpecificDeepLink) { Log.info('Restoring last visited path on app startup', false, {lastVisitedPath, initialUrl, path}); - return getAdaptedStateFromPath(lastVisitedPath, linkingConfig.config); + return getAdaptedStateFromPath(lastVisitedPath); } } diff --git a/src/libs/Navigation/helpers/createDynamicRoute.ts b/src/libs/Navigation/helpers/createDynamicRoute.ts new file mode 100644 index 0000000000000..17b71a29b025e --- /dev/null +++ b/src/libs/Navigation/helpers/createDynamicRoute.ts @@ -0,0 +1,27 @@ +import Navigation from '@libs/Navigation/Navigation'; +import type {DynamicRouteSuffix, Route} from '@src/ROUTES'; +import isDynamicRouteSuffix from './isDynamicRouteSuffix'; +import splitPathAndQuery from './splitPathAndQuery'; + +const combinePathAndSuffix = (path: string, suffix: string): Route => { + const [normalizedPath, query] = splitPathAndQuery(path); + let newPath = `${normalizedPath}/${suffix}`; + + if (query) { + newPath += `?${query}`; + } + return newPath as Route; +}; + +/** Adds dynamic route name to the current URL and returns it */ +const createDynamicRoute = (dynamicRouteSuffix: DynamicRouteSuffix): Route => { + if (!isDynamicRouteSuffix(dynamicRouteSuffix)) { + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + throw new Error(`The route name ${dynamicRouteSuffix} is not supported in createDynamicRoute`); + } + + const activeRoute = Navigation.getActiveRoute(); + return combinePathAndSuffix(activeRoute, dynamicRouteSuffix); +}; + +export default createDynamicRoute; diff --git a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts index fdae94a668e13..dfbabdad248c5 100644 --- a/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts +++ b/src/libs/Navigation/helpers/getAdaptedStateFromPath.ts @@ -1,21 +1,24 @@ -import type {NavigationState, PartialState, Route} from '@react-navigation/native'; -import {findFocusedRoute, getStateFromPath} from '@react-navigation/native'; +import type {NavigationState, PartialState, getStateFromPath as RNGetStateFromPath, Route} from '@react-navigation/native'; +import {findFocusedRoute} from '@react-navigation/native'; import pick from 'lodash/pick'; import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import getInitialSplitNavigatorState from '@libs/Navigation/AppNavigator/createSplitNavigator/getInitialSplitNavigatorState'; -import {config} from '@libs/Navigation/linkingConfig/config'; import {RHP_TO_DOMAIN, RHP_TO_SEARCH, RHP_TO_SETTINGS, RHP_TO_SIDEBAR, RHP_TO_WORKSPACE, RHP_TO_WORKSPACES_LIST} from '@libs/Navigation/linkingConfig/RELATIONS'; import type {NavigationPartialRoute, RootNavigatorParamList} from '@libs/Navigation/types'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Route as RoutePath} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import type {Report} from '@src/types/onyx'; +import getLastSuffixFromPath from './getLastSuffixFromPath'; import getMatchingNewRoute from './getMatchingNewRoute'; import getParamsFromRoute from './getParamsFromRoute'; import getRedirectedPath from './getRedirectedPath'; +import getStateFromPath from './getStateFromPath'; +import isDynamicRouteSuffix from './isDynamicRouteSuffix'; import {isFullScreenName} from './isNavigatorName'; import normalizePath from './normalizePath'; import replacePathInNestedState from './replacePathInNestedState'; @@ -31,7 +34,7 @@ Onyx.connect({ type GetAdaptedStateReturnType = ReturnType; -type GetAdaptedStateFromPath = (...args: [...Parameters, shouldReplacePathInNestedState?: boolean]) => GetAdaptedStateReturnType; +type GetAdaptedStateFromPath = (...args: [...Parameters, shouldReplacePathInNestedState?: boolean]) => GetAdaptedStateReturnType; // The function getPathFromState that we are using in some places isn't working correctly without defined index. const getRoutesWithIndex = (routes: NavigationPartialRoute[]): PartialState => ({routes, index: routes.length - 1}); @@ -64,7 +67,7 @@ function getSearchScreenNameForRoute(route: NavigationPartialRoute): string { function getMatchingFullScreenRoute(route: NavigationPartialRoute) { // Check for backTo param. One screen with different backTo value may need different screens visible under the overlay. if (isRouteWithBackToParam(route)) { - const stateForBackTo = getStateFromPath(route.params.backTo, config); + const stateForBackTo = getStateFromPath(route.params.backTo as RoutePath); // This may happen if the backTo url is invalid. const lastRoute = stateForBackTo?.routes.at(-1); @@ -87,6 +90,37 @@ function getMatchingFullScreenRoute(route: NavigationPartialRoute) { // If not, get the matching full screen route for the back to state. return getMatchingFullScreenRoute(focusedStateForBackToRoute); } + + // Handle dynamic routes: find the appropriate full screen route + const dynamicRouteSuffix = getLastSuffixFromPath(route.path); + if (isDynamicRouteSuffix(dynamicRouteSuffix)) { + // Remove dynamic suffix to get the base path + const pathWithoutDynamicSuffix = route.path?.replace(`/${dynamicRouteSuffix}`, ''); + + // Get navigation state for the base path without dynamic suffix + const stateUnderDynamicRoute = getStateFromPath(pathWithoutDynamicSuffix as RoutePath); + const lastRoute = stateUnderDynamicRoute?.routes.at(-1); + + if (!stateUnderDynamicRoute || !lastRoute || lastRoute.name === SCREENS.NOT_FOUND) { + return undefined; + } + + const isLastRouteFullScreen = isFullScreenName(lastRoute.name); + + if (isLastRouteFullScreen) { + return lastRoute; + } + + const focusedStateForDynamicRoute = findFocusedRoute(stateUnderDynamicRoute); + + if (!focusedStateForDynamicRoute) { + return undefined; + } + + // Recursively find the matching full screen route for the focused dynamic route + return getMatchingFullScreenRoute(focusedStateForDynamicRoute); + } + const routeNameForLookup = getSearchScreenNameForRoute(route); if (RHP_TO_SEARCH[routeNameForLookup]) { const paramsFromRoute = getParamsFromRoute(RHP_TO_SEARCH[routeNameForLookup]); @@ -278,7 +312,7 @@ const getAdaptedStateFromPath: GetAdaptedStateFromPath = (path, options, shouldR normalizedPath = '/'; } - const state = getStateFromPath(normalizedPath, options) as PartialState>; + const state = getStateFromPath(normalizedPath as RoutePath) as PartialState>; if (shouldReplacePathInNestedState) { replacePathInNestedState(state, normalizedPath); } diff --git a/src/libs/Navigation/helpers/getLastSuffixFromPath.ts b/src/libs/Navigation/helpers/getLastSuffixFromPath.ts new file mode 100644 index 0000000000000..5a07780c858e6 --- /dev/null +++ b/src/libs/Navigation/helpers/getLastSuffixFromPath.ts @@ -0,0 +1,23 @@ +import Log from '@libs/Log'; +import splitPathAndQuery from './splitPathAndQuery'; + +/** + * Extracts the last segment from a URL path, removing query parameters and trailing slashes. + * + * @param path - The URL path to extract the suffix from (can be undefined) + * @returns The last segment of the path as a string + */ +function getLastSuffixFromPath(path: string | undefined): string { + const [normalizedPath] = splitPathAndQuery(path ?? ''); + + if (!normalizedPath) { + Log.warn('[getLastSuffixFromPath.ts] Provided path is undefined or empty.'); + return ''; + } + + const lastSuffix = normalizedPath.split('/').pop() ?? ''; + + return lastSuffix; +} + +export default getLastSuffixFromPath; diff --git a/src/libs/Navigation/helpers/getPathFromState.ts b/src/libs/Navigation/helpers/getPathFromState.ts new file mode 100644 index 0000000000000..7718dbfdc405d --- /dev/null +++ b/src/libs/Navigation/helpers/getPathFromState.ts @@ -0,0 +1,37 @@ +import {findFocusedRoute, getPathFromState as RNGetPathFromState} from '@react-navigation/native'; +import type {NavigationState, PartialState} from '@react-navigation/routers'; +import {linkingConfig} from '@libs/Navigation/linkingConfig'; +import {normalizedConfigs} from '@libs/Navigation/linkingConfig/config'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {Screen} from '@src/SCREENS'; + +type State = NavigationState | Omit, 'stale'>; + +/** + * Checks if a screen name is a dynamic route screen + */ +function isDynamicRouteScreen(screenName: Screen): boolean { + for (const {path} of Object.values(DYNAMIC_ROUTES)) { + if (normalizedConfigs[screenName]?.path === path) { + return true; + } + } + return false; +} + +const getPathFromState = (state: State): string => { + const focusedRoute = findFocusedRoute(state); + const screenName = focusedRoute?.name ?? ''; + + // Handle dynamic route screens that require special path that is placed in state + if (isDynamicRouteScreen(screenName as Screen) && focusedRoute?.path) { + return focusedRoute.path; + } + + // For regular routes, use React Navigation's default path generation + const path = RNGetPathFromState(state, linkingConfig.config); + + return path; +}; + +export default getPathFromState; diff --git a/src/libs/Navigation/helpers/getStateForDynamicRoute.ts b/src/libs/Navigation/helpers/getStateForDynamicRoute.ts new file mode 100644 index 0000000000000..60bb53d0e13fb --- /dev/null +++ b/src/libs/Navigation/helpers/getStateForDynamicRoute.ts @@ -0,0 +1,67 @@ +import {normalizedConfigs} from '@libs/Navigation/linkingConfig/config'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {DynamicRouteSuffix} from '@src/ROUTES'; + +type LeafRoute = { + name: string; + path: string; +}; + +type NestedRoute = { + name: string; + state: { + routes: [RouteNode]; + index: 0; + }; +}; + +type RouteNode = LeafRoute | NestedRoute; + +function getRouteNamesForDynamicRoute(dynamicRouteName: DynamicRouteSuffix): string[] | null { + // Search through normalized configs to find matching path and extract navigation hierarchy + // routeNames contains the sequence of screen/navigator names that should be present in the navigation state + for (const [, config] of Object.entries(normalizedConfigs)) { + if (config.path === dynamicRouteName) { + return config.routeNames; + } + } + + return null; +} + +function getStateForDynamicRoute(path: string, dynamicRouteName: keyof typeof DYNAMIC_ROUTES) { + const routeConfig = getRouteNamesForDynamicRoute(DYNAMIC_ROUTES[dynamicRouteName].path); + + if (!routeConfig) { + throw new Error(`No route configuration found for dynamic route '${dynamicRouteName}'`); + } + + // Build navigation state by creating nested structure + const buildNestedState = (routes: string[], currentIndex: number): RouteNode => { + const currentRoute = routes.at(currentIndex); + + // If this is the last route, create leaf node with path + if (currentIndex === routes.length - 1) { + return { + name: currentRoute ?? '', + path, + }; + } + + // Create intermediate node with nested state + return { + name: currentRoute ?? '', + state: { + routes: [buildNestedState(routes, currentIndex + 1)], + index: 0, + }, + }; + }; + + // Start building from the first route + const rootRoute = {routes: [buildNestedState(routeConfig, 0)]}; + + return rootRoute; +} + +export default getStateForDynamicRoute; diff --git a/src/libs/Navigation/helpers/getStateFromPath.ts b/src/libs/Navigation/helpers/getStateFromPath.ts index b08d003e3c132..cd95bab26aafa 100644 --- a/src/libs/Navigation/helpers/getStateFromPath.ts +++ b/src/libs/Navigation/helpers/getStateFromPath.ts @@ -1,9 +1,15 @@ import type {NavigationState, PartialState} from '@react-navigation/native'; -import {getStateFromPath as RNGetStateFromPath} from '@react-navigation/native'; +import {findFocusedRoute, getStateFromPath as RNGetStateFromPath} from '@react-navigation/native'; +import Log from '@libs/Log'; import {linkingConfig} from '@libs/Navigation/linkingConfig'; import type {Route} from '@src/ROUTES'; +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {Screen} from '@src/SCREENS'; +import getLastSuffixFromPath from './getLastSuffixFromPath'; import getMatchingNewRoute from './getMatchingNewRoute'; import getRedirectedPath from './getRedirectedPath'; +import getStateForDynamicRoute from './getStateForDynamicRoute'; +import isDynamicRouteSuffix from './isDynamicRouteSuffix'; /** * @param path - The path to parse @@ -14,6 +20,32 @@ function getStateFromPath(path: Route): PartialState { const redirectedPath = getRedirectedPath(normalizedPath); const normalizedPathAfterRedirection = getMatchingNewRoute(redirectedPath) ?? redirectedPath; + const dynamicRouteSuffix = getLastSuffixFromPath(path); + if (isDynamicRouteSuffix(dynamicRouteSuffix)) { + const pathWithoutDynamicSuffix = path.replace(`/${dynamicRouteSuffix}`, ''); + + type DynamicRouteKey = keyof typeof DYNAMIC_ROUTES; + + // Find the dynamic route key that matches the extracted suffix + const dynamicRoute: string = Object.keys(DYNAMIC_ROUTES).find((key) => DYNAMIC_ROUTES[key as DynamicRouteKey].path === dynamicRouteSuffix) ?? ''; + + // Get the currently focused route from the base path to check permissions + const focusedRoute = findFocusedRoute(getStateFromPath(pathWithoutDynamicSuffix as Route) ?? {}); + const entryScreens: Screen[] = DYNAMIC_ROUTES[dynamicRoute as DynamicRouteKey]?.entryScreens ?? []; + + // Check if the focused route is allowed to access this dynamic route + if (focusedRoute?.name) { + if (entryScreens.includes(focusedRoute.name as Screen)) { + // Generate navigation state for the dynamic route + const verifyAccountState = getStateForDynamicRoute(normalizedPath, dynamicRoute as DynamicRouteKey); + return verifyAccountState; + } + + // Log an error to quickly identify and add forgotten screens to the Dynamic Routes configuration + Log.warn(`[getStateFromPath.ts][DynamicRoute] Focused route ${focusedRoute.name} is not allowed to access dynamic route with suffix ${dynamicRouteSuffix}`); + } + } + // This function is used in the linkTo function where we want to use default getStateFromPath function. const state = RNGetStateFromPath(normalizedPathAfterRedirection, linkingConfig.config); diff --git a/src/libs/Navigation/helpers/isDynamicRouteSuffix.ts b/src/libs/Navigation/helpers/isDynamicRouteSuffix.ts new file mode 100644 index 0000000000000..1e3a758bedc8c --- /dev/null +++ b/src/libs/Navigation/helpers/isDynamicRouteSuffix.ts @@ -0,0 +1,11 @@ +import {DYNAMIC_ROUTES} from '@src/ROUTES'; +import type {DynamicRouteSuffix} from '@src/ROUTES'; + +/** + * Checks if a suffix matches any dynamic route path in DYNAMIC_ROUTES. + */ +function isDynamicRouteSuffix(suffix: string): suffix is DynamicRouteSuffix { + return Object.values(DYNAMIC_ROUTES).some((route) => route.path === suffix); +} + +export default isDynamicRouteSuffix; diff --git a/src/libs/Navigation/helpers/splitPathAndQuery.ts b/src/libs/Navigation/helpers/splitPathAndQuery.ts new file mode 100644 index 0000000000000..9c87238b83915 --- /dev/null +++ b/src/libs/Navigation/helpers/splitPathAndQuery.ts @@ -0,0 +1,13 @@ +/** + * Splits a full path into its path and query components. + * @param fullPath - The full URL path (e.g., '/settings/wallet?param=value') + * @returns A tuple where the first element is the path without trailing slash + * and the second element is the query string (if any). + */ +function splitPathAndQuery(fullPath: string | undefined): [string | undefined, string | undefined] { + const [path, query] = fullPath?.split('?') ?? [undefined, undefined]; + const normalizedPath = path?.endsWith('/') && path.length > 1 ? path.slice(0, -1) : path; + return [normalizedPath, query]; +} + +export default splitPathAndQuery; diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts index e50234509d291..01a1fd6aee6bc 100755 --- a/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SETTINGS_TO_RHP.ts @@ -37,7 +37,6 @@ const SETTINGS_TO_RHP: Partial['config'] = { path: ROUTES.SETTINGS_WALLET_CARD_MISSING_DETAILS_CONFIRM_MAGIC_CODE.route, exact: true, }, - [SCREENS.SETTINGS.WALLET.VERIFY_ACCOUNT]: { - path: ROUTES.SETTINGS_WALLET_VERIFY_ACCOUNT, - exact: true, - }, [SCREENS.SETTINGS.WALLET.REPORT_VIRTUAL_CARD_FRAUD]: { path: ROUTES.SETTINGS_REPORT_FRAUD.route, exact: true, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 02d71cc6fca56..636eb21df1ee1 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -166,7 +166,6 @@ type SettingsNavigatorParamList = { // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md backTo: Routes; }; - [SCREENS.SETTINGS.DYNAMIC_VERIFY_ACCOUNT]: undefined; [SCREENS.SETTINGS.WALLET.CARDS_DIGITAL_DETAILS_UPDATE_ADDRESS]: undefined; [SCREENS.SETTINGS.WALLET.DOMAIN_CARD]: { /** cardID of selected card */ @@ -213,7 +212,6 @@ type SettingsNavigatorParamList = { [SCREENS.WORKSPACE.WORKFLOWS_PAYER]: { policyID: string; }; - [SCREENS.SETTINGS.WALLET.VERIFY_ACCOUNT]: undefined; [SCREENS.SETTINGS.WALLET.TRANSFER_BALANCE]: undefined; [SCREENS.SETTINGS.WALLET.CHOOSE_TRANSFER_ACCOUNT]: undefined; [SCREENS.SETTINGS.WALLET.IMPORT_TRANSACTIONS]: undefined; diff --git a/src/libs/actions/Welcome/OnboardingFlow.ts b/src/libs/actions/Welcome/OnboardingFlow.ts index 0a6c01e8adfec..c99144e736004 100644 --- a/src/libs/actions/Welcome/OnboardingFlow.ts +++ b/src/libs/actions/Welcome/OnboardingFlow.ts @@ -14,6 +14,7 @@ import IntlStore from '@src/languages/IntlStore'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type {Route} from '@src/ROUTES'; import {hasCompletedGuidedSetupFlowSelector} from '@src/selectors/Onboarding'; import type {Locale, Onboarding} from '@src/types/onyx'; @@ -83,7 +84,7 @@ Onyx.connectWithoutView({ */ function startOnboardingFlow(startOnboardingFlowParams: GetOnboardingInitialPathParamsType) { const currentRoute = navigationRef.getCurrentRoute(); - const adaptedState = getAdaptedStateFromPath(getOnboardingInitialPath(startOnboardingFlowParams), linkingConfig.config, false); + const adaptedState = getAdaptedStateFromPath(getOnboardingInitialPath(startOnboardingFlowParams) as Route, linkingConfig.config, false); const focusedRoute = findFocusedRoute(adaptedState as PartialState>); if (focusedRoute?.name === currentRoute?.name) { return; diff --git a/src/pages/settings/DynamicVerifyAccountPage.tsx b/src/pages/settings/DynamicVerifyAccountPage.tsx new file mode 100644 index 0000000000000..0436495c4d7ea --- /dev/null +++ b/src/pages/settings/DynamicVerifyAccountPage.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import VerifyAccountPageBase from './VerifyAccountPageBase'; + +function DynamicVerifyAccountPage() { + const backPath = useDynamicBackPath(DYNAMIC_ROUTES.VERIFY_ACCOUNT.path); + + let forwardPath = backPath; + if (backPath === ROUTES.SETTINGS_WALLET) { + forwardPath = ROUTES.SETTINGS_ENABLE_PAYMENTS; + } + + return ( + + ); +} + +export default DynamicVerifyAccountPage; diff --git a/src/pages/settings/Wallet/VerifyAccountPage.tsx b/src/pages/settings/Wallet/VerifyAccountPage.tsx deleted file mode 100644 index a0d9434442bb8..0000000000000 --- a/src/pages/settings/Wallet/VerifyAccountPage.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import VerifyAccountPageBase from '@pages/settings/VerifyAccountPageBase'; -import ROUTES from '@src/ROUTES'; - -function VerifyAccountPage() { - return ( - - ); -} - -export default VerifyAccountPage; diff --git a/src/pages/settings/Wallet/WalletPage/index.tsx b/src/pages/settings/Wallet/WalletPage/index.tsx index c065e508a60e5..dffbd14cbcbf6 100644 --- a/src/pages/settings/Wallet/WalletPage/index.tsx +++ b/src/pages/settings/Wallet/WalletPage/index.tsx @@ -35,6 +35,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {hasDisplayableAssignedCards, maskCardNumber} from '@libs/CardUtils'; import {convertToDisplayString} from '@libs/CurrencyUtils'; +import createDynamicRoute from '@libs/Navigation/helpers/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {formatPaymentMethods, getPaymentMethodDescription} from '@libs/PaymentUtils'; import {getDescriptionForPolicyDomainCard, hasEligibleActiveAdminFromWorkspaces} from '@libs/PolicyUtils'; @@ -47,7 +48,7 @@ import {clearWalletError, clearWalletTermsError, deletePaymentCard, getPaymentMe import {navigateToBankAccountRoute} from '@userActions/ReimbursementAccount'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import {getEmptyObject} from '@src/types/utils/EmptyObject'; import type {CardPressHandlerParams, PaymentMethodPressHandlerParams} from './types'; @@ -685,7 +686,7 @@ function WalletPage() { } if (!isUserValidated) { - Navigation.navigate(ROUTES.SETTINGS_WALLET_VERIFY_ACCOUNT); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.VERIFY_ACCOUNT.path)); return; } Navigation.navigate(ROUTES.SETTINGS_ENABLE_PAYMENTS); diff --git a/tests/navigation/isActiveRouteTests.tsx b/tests/navigation/isActiveRouteTests.tsx index 0e88faa69b58b..a592eb7f6d333 100644 --- a/tests/navigation/isActiveRouteTests.tsx +++ b/tests/navigation/isActiveRouteTests.tsx @@ -1,5 +1,5 @@ import {afterEach, beforeEach, describe, expect, it, jest} from '@jest/globals'; -import type {getPathFromState as GetPathFromState} from '@react-navigation/native'; +import getPathFromState from '@libs/Navigation/helpers/getPathFromState'; import Navigation from '@libs/Navigation/Navigation'; import navigationRef from '@libs/Navigation/navigationRef'; import type {Route} from '@src/ROUTES'; @@ -18,14 +18,7 @@ jest.mock('@libs/Navigation/navigationRef', () => { }; }); -jest.mock('@react-navigation/native', () => { - // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-assertion - const actual = jest.requireActual('@react-navigation/native') as {getPathFromState: typeof GetPathFromState}; - return { - ...actual, - getPathFromState: jest.fn(() => '/settings/profile?backTo=settings'), - }; -}); +jest.mock('@libs/Navigation/helpers/getPathFromState', () => jest.fn()); describe('Navigation', () => { afterEach(() => { @@ -39,8 +32,10 @@ describe('Navigation', () => { isReady: jest.Mock; }; + (getPathFromState as jest.Mock).mockReturnValue('/settings/profile?backTo=settings'); + navigationRefMock.current.getCurrentRoute.mockReturnValue({name: 'test'}); - navigationRefMock.getRootState.mockReturnValue({} as ReturnType); + navigationRefMock.getRootState.mockReturnValue({}); navigationRefMock.isReady.mockReturnValue(true); });