From afeb3fe6936126d12428ad0f37ec220ac2615695 Mon Sep 17 00:00:00 2001 From: Cong Pham Date: Fri, 24 Jul 2026 13:02:56 +0700 Subject: [PATCH] Migrate money request subrate step to dynamic routes (#83850 part 3) Migrate MONEY_REQUEST_STEP_SUBRATE (path `subrate/:pageIndex`, entry STEP_TIME) and MONEY_REQUEST_STEP_SUBRATE_EDIT (path `subrate-edit/:pageIndex`, entry STEP_CONFIRMATION) to dynamic routes, removing their static routes and the `backTo` param. The `pageIndex` path param is carried via getRoute. - Rename IOURequestStepSubrate -> DynamicIOURequestStepSubrate; edit mode from route.name, back path from useDynamicBackPath (parametric suffix). - Time forward and confirmation goBack build the subrate route on the time base via createDynamicRoute; confirmation edit uses getRoute(index). - Add OldRoutes redirects (pageIndex preserved) + getMatchingNewRoute tests. Co-Authored-By: Claude Opus 4.8 --- config/eslint/eslint.seatbelt.tsv | 4 +-- src/ROUTES.ts | 20 +++++------ src/SCREENS.ts | 4 +-- .../sections/PerDiemFields.tsx | 5 +-- .../ModalStackNavigators/index.tsx | 4 +-- .../Navigation/linkingConfig/OldRoutes.ts | 7 ++++ src/libs/Navigation/linkingConfig/config.ts | 4 +-- src/libs/Navigation/types.ts | 9 ++--- ...e.tsx => DynamicIOURequestStepSubrate.tsx} | 35 ++++++++++--------- .../step/IOURequestStepConfirmation.tsx | 10 ++++-- .../iou/request/step/IOURequestStepTime.tsx | 7 ++-- .../step/withFullTransactionOrNotFound.tsx | 3 +- .../step/withWritableReportOrNotFound.tsx | 3 +- .../navigation/dynamicRoutesUniquePathTest.ts | 16 +++++++++ tests/navigation/getMatchingNewRouteTest.ts | 16 +++++++++ 15 files changed, 99 insertions(+), 48 deletions(-) rename src/pages/iou/request/step/{IOURequestStepSubrate.tsx => DynamicIOURequestStepSubrate.tsx} (89%) create mode 100644 tests/navigation/dynamicRoutesUniquePathTest.ts diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 1518f2907278..6e7270fb771f 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -1342,8 +1342,8 @@ "../../src/pages/iou/request/step/IOURequestStepScan/hooks/useScanRouteParams.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/pages/iou/request/step/IOURequestStepScan/utils/buildReceiptFiles.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 "../../src/pages/iou/request/step/IOURequestStepScan/utils/getFileSource.ts" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/iou/request/step/IOURequestStepSubrate.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 -"../../src/pages/iou/request/step/IOURequestStepSubrate.tsx" "react-hooks/set-state-in-effect" 1 +"../../src/pages/iou/request/step/DynamicIOURequestStepSubrate.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1 +"../../src/pages/iou/request/step/DynamicIOURequestStepSubrate.tsx" "react-hooks/set-state-in-effect" 1 "../../src/pages/iou/request/step/IOURequestStepTag.tsx" "no-restricted-imports" 1 "../../src/pages/iou/request/step/IOURequestStepUpgrade.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1 "../../src/pages/iou/request/step/IOURequestStepUpgrade.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2 diff --git a/src/ROUTES.ts b/src/ROUTES.ts index c55bfb86f95d..e85f8b9949fc 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -235,6 +235,16 @@ const DYNAMIC_ROUTES = { ], getRoute: (cardID: string) => `missing-personal-details/${cardID}/confirm-magic-code` as const, }, + MONEY_REQUEST_STEP_SUBRATE: { + path: 'per-diem-subrate/:pageIndex', + entryScreens: [SCREENS.MONEY_REQUEST.STEP_TIME], + getRoute: (pageIndex: string | number = 0) => `per-diem-subrate/${pageIndex}` as const, + }, + MONEY_REQUEST_STEP_SUBRATE_EDIT: { + path: 'per-diem-subrate-edit/:pageIndex', + entryScreens: [SCREENS.MONEY_REQUEST.STEP_CONFIRMATION], + getRoute: (pageIndex: string | number) => `per-diem-subrate-edit/${pageIndex}` as const, + }, PROFILE: { path: 'a/:accountID', entryScreens: ['*'], @@ -1948,11 +1958,6 @@ const ROUTES = { getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backToReport?: string, backTo = '') => getUrlWithBackToParam(`${action as string}/${iouType as string}/time/${transactionID}/${reportID}${backToReport ? `/${backToReport}` : ''}`, backTo), }, - MONEY_REQUEST_STEP_SUBRATE: { - route: ':action/:iouType/subrate/:transactionID/:reportID/:backToReport?/:pageIndex', - getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backToReport?: string, backTo = '') => - getUrlWithBackToParam(`${action as string}/${iouType as string}/subrate/${transactionID}/${reportID}${backToReport ? `/${backToReport}` : ''}/0`, backTo), - }, MONEY_REQUEST_STEP_DESTINATION_EDIT: { route: ':action/:iouType/destination/:transactionID/:reportID/edit', getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => @@ -1963,11 +1968,6 @@ const ROUTES = { getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '') => getUrlWithBackToParam(`${action as string}/${iouType as string}/time/${transactionID}/${reportID}/edit`, backTo), }, - MONEY_REQUEST_STEP_SUBRATE_EDIT: { - route: ':action/:iouType/subrate/:transactionID/:reportID/edit/:pageIndex', - getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, pageIndex = 0, backTo = '') => - getUrlWithBackToParam(`${action as string}/${iouType as string}/subrate/${transactionID}/${reportID}/edit/${pageIndex}`, backTo), - }, MONEY_REQUEST_STEP_REPORT: { route: ':action/:iouType/report/:transactionID/:reportID/:reportActionID?', getRoute: (action: IOUAction, iouType: IOUType, transactionID: string, reportID: string, backTo = '', reportActionID?: string) => diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 8f58e99213a8..ef42f761fc3a 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -357,10 +357,10 @@ const SCREENS = { STEP_ACCOUNTANT: 'Money_Request_Accountant', STEP_DESTINATION: 'Money_Request_Destination', STEP_TIME: 'Money_Request_Time', - STEP_SUBRATE: 'Money_Request_SubRate', + DYNAMIC_STEP_SUBRATE: 'Dynamic_Money_Request_SubRate', STEP_DESTINATION_EDIT: 'Money_Request_Destination_Edit', STEP_TIME_EDIT: 'Money_Request_Time_Edit', - STEP_SUBRATE_EDIT: 'Money_Request_SubRate_Edit', + DYNAMIC_STEP_SUBRATE_EDIT: 'Dynamic_Money_Request_SubRate_Edit', STEP_REPORT: 'Money_Request_Report', EDIT_REPORT: 'Money_Request_Edit_Report', SPLIT_EXPENSE: 'Money_Request_Split_Expense', diff --git a/src/components/MoneyRequestConfirmationList/sections/PerDiemFields.tsx b/src/components/MoneyRequestConfirmationList/sections/PerDiemFields.tsx index 116831d45e35..09737ef55c08 100644 --- a/src/components/MoneyRequestConfirmationList/sections/PerDiemFields.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/PerDiemFields.tsx @@ -5,12 +5,13 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import {getDestinationForDisplay, getSubratesFields, getSubratesForDisplay, getTimeDifferenceIntervals, getTimeForDisplay} from '@libs/PerDiemRequestUtils'; import CONST from '@src/CONST'; import type {IOUAction, IOUType} from '@src/CONST'; -import ROUTES from '@src/ROUTES'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import type {CustomUnit} from '@src/types/onyx/Policy'; @@ -52,7 +53,7 @@ function PerDiemFields({perDiemCustomUnit, transaction, isReadOnly, didConfirm, if (!transactionID) { return; } - Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_SUBRATE_EDIT.getRoute(action, iouType, transactionID, reportID, index, Navigation.getActiveRoute())); + Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_SUBRATE_EDIT.getRoute(index))); }} disabled={didConfirm} interactive={!isReadOnly} diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index e8574be05242..b26f15c328d8 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -209,10 +209,10 @@ const MoneyRequestModalStackNavigator = createModalStackNavigator require('../../../../pages/iou/request/step/IOURequestStepUpgrade').default, [SCREENS.MONEY_REQUEST.STEP_DESTINATION]: () => require('../../../../pages/iou/request/step/IOURequestStepDestination').default, [SCREENS.MONEY_REQUEST.STEP_TIME]: () => require('../../../../pages/iou/request/step/IOURequestStepTime').default, - [SCREENS.MONEY_REQUEST.STEP_SUBRATE]: () => require('../../../../pages/iou/request/step/IOURequestStepSubrate').default, + [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SUBRATE]: () => require('../../../../pages/iou/request/step/DynamicIOURequestStepSubrate').default, [SCREENS.MONEY_REQUEST.STEP_DESTINATION_EDIT]: () => require('../../../../pages/iou/request/step/IOURequestStepDestination').default, [SCREENS.MONEY_REQUEST.STEP_TIME_EDIT]: () => require('../../../../pages/iou/request/step/IOURequestStepTime').default, - [SCREENS.MONEY_REQUEST.STEP_SUBRATE_EDIT]: () => require('../../../../pages/iou/request/step/IOURequestStepSubrate').default, + [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SUBRATE_EDIT]: () => require('../../../../pages/iou/request/step/DynamicIOURequestStepSubrate').default, [SCREENS.MONEY_REQUEST.RECEIPT_VIEW]: () => require('../../../../pages/iou/request/step/IOURequestStepScan/ReceiptView').default, [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE]: () => require('../../../../pages/iou/SplitExpensePage').default, [SCREENS.MONEY_REQUEST.SPLIT_EXPENSE_SEARCH]: () => require('../../../../pages/iou/SplitExpensePage').default, diff --git a/src/libs/Navigation/linkingConfig/OldRoutes.ts b/src/libs/Navigation/linkingConfig/OldRoutes.ts index 27ed312e628c..3e1fe50d9274 100644 --- a/src/libs/Navigation/linkingConfig/OldRoutes.ts +++ b/src/libs/Navigation/linkingConfig/OldRoutes.ts @@ -1,5 +1,12 @@ const oldRoutes: Record = { /* eslint-disable @typescript-eslint/naming-convention */ + // Per diem subrate step migrated to dynamic routes (#83850). Dynamic suffixes are namespaced `per-diem-*` because + // dynamic route paths must be globally unique across DYNAMIC_ROUTES (react-navigation flattens them). The + // `:pageIndex` path param is preserved as the final segment. Edit variant redirects to the confirmation-based + // suffix; the wizard variant to the time-based suffix. Query/backToReport is not preserved (accepted - callers + // dropped them). + '/*/*/subrate/*/*/edit/*': '/$1/$2/confirmation/$3/$4/per-diem-subrate-edit/$5', + '/*/*/subrate/*/*/*': '/$1/$2/time/$3/$4/per-diem-subrate/$5', '/settings/*/category/*/edit': '/settings/$1/categories/category-settings/$2/category-edit', '/settings/*/category/*': '/settings/$1/categories/category-settings/$2', '/settings/*/tags/*/edit': '/settings/$1/tags/settings-tags-edit/$2', diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index f4839edce363..3e22b986c929 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -1786,10 +1786,10 @@ const config: LinkingOptions['config'] = { [SCREENS.MONEY_REQUEST.STEP_UPGRADE]: ROUTES.MONEY_REQUEST_UPGRADE.route, [SCREENS.MONEY_REQUEST.STEP_DESTINATION]: ROUTES.MONEY_REQUEST_STEP_DESTINATION.route, [SCREENS.MONEY_REQUEST.STEP_TIME]: ROUTES.MONEY_REQUEST_STEP_TIME.route, - [SCREENS.MONEY_REQUEST.STEP_SUBRATE]: ROUTES.MONEY_REQUEST_STEP_SUBRATE.route, + [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SUBRATE]: DYNAMIC_ROUTES.MONEY_REQUEST_STEP_SUBRATE.path, [SCREENS.MONEY_REQUEST.STEP_DESTINATION_EDIT]: ROUTES.MONEY_REQUEST_STEP_DESTINATION_EDIT.route, [SCREENS.MONEY_REQUEST.STEP_TIME_EDIT]: ROUTES.MONEY_REQUEST_STEP_TIME_EDIT.route, - [SCREENS.MONEY_REQUEST.STEP_SUBRATE_EDIT]: ROUTES.MONEY_REQUEST_STEP_SUBRATE_EDIT.route, + [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SUBRATE_EDIT]: DYNAMIC_ROUTES.MONEY_REQUEST_STEP_SUBRATE_EDIT.path, [SCREENS.MONEY_REQUEST.STEP_TIME_RATE]: ROUTES.MONEY_REQUEST_STEP_TIME_RATE.route, [SCREENS.MONEY_REQUEST.STEP_HOURS]: ROUTES.MONEY_REQUEST_STEP_HOURS.route, [SCREENS.MONEY_REQUEST.STEP_HOURS_EDIT]: ROUTES.MONEY_REQUEST_STEP_HOURS_EDIT.route, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 8685eba094e9..088992cbc54a 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2286,12 +2286,10 @@ type MoneyRequestNavigatorParamList = { // 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 | undefined; }; - [SCREENS.MONEY_REQUEST.STEP_SUBRATE]: { + [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SUBRATE]: { iouType: Exclude; reportID: string; backToReport?: string; - // 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 | undefined; action: IOUAction; pageIndex: string; transactionID: string; @@ -2313,14 +2311,13 @@ type MoneyRequestNavigatorParamList = { // 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 | undefined; }; - [SCREENS.MONEY_REQUEST.STEP_SUBRATE_EDIT]: { + [SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SUBRATE_EDIT]: { iouType: Exclude; reportID: string; - // 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 | undefined; action: IOUAction; pageIndex: string; transactionID: string; + backToReport?: string; }; [SCREENS.MONEY_REQUEST.DISTANCE_CREATE]: { iouType: IOUType; diff --git a/src/pages/iou/request/step/IOURequestStepSubrate.tsx b/src/pages/iou/request/step/DynamicIOURequestStepSubrate.tsx similarity index 89% rename from src/pages/iou/request/step/IOURequestStepSubrate.tsx rename to src/pages/iou/request/step/DynamicIOURequestStepSubrate.tsx index bba877f6b2ef..87dfafb74b90 100644 --- a/src/pages/iou/request/step/IOURequestStepSubrate.tsx +++ b/src/pages/iou/request/step/DynamicIOURequestStepSubrate.tsx @@ -11,6 +11,7 @@ import TextInput from '@components/TextInput'; import ValuePicker from '@components/ValuePicker'; import useConfirmModal from '@hooks/useConfirmModal'; +import useDynamicBackPath from '@hooks/useDynamicBackPath'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import usePolicyForTransaction from '@hooks/usePolicyForTransaction'; @@ -27,8 +28,8 @@ import {addSubrate, removeSubrate, updateSubrate} from '@userActions/IOU/PerDiem import CONST from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; -import type SCREENS from '@src/SCREENS'; +import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; import type * as OnyxTypes from '@src/types/onyx'; import type {Subrate} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -45,7 +46,7 @@ import type {WithWritableReportOrNotFoundProps} from './withWritableReportOrNotF import withFullTransactionOrNotFound from './withFullTransactionOrNotFound'; import withWritableReportOrNotFound from './withWritableReportOrNotFound'; -type IOURequestStepSubrateProps = WithWritableReportOrNotFoundProps & { +type DynamicIOURequestStepSubrateProps = WithWritableReportOrNotFoundProps & { transaction: OnyxEntry; /** The report linked to the transaction */ @@ -70,13 +71,19 @@ function getSubrateOptions(subRates: Subrate[], filledSubRates: CommentSubrate[] })); } -function IOURequestStepSubrate({ +function DynamicIOURequestStepSubrate({ route: { - params: {action, backTo, iouType, pageIndex, reportID, transactionID, backToReport}, + params: {action, iouType, pageIndex, reportID, transactionID, backToReport}, + name: routeName, }, transaction, report, -}: IOURequestStepSubrateProps) { +}: DynamicIOURequestStepSubrateProps) { + const isEditPage = routeName === SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SUBRATE_EDIT; + // Back removes this step's dynamic suffix. In edit mode (`subrate-edit/:pageIndex`) the suffix sits on the + // confirmation base, so back returns to confirmation; in the wizard flow (`subrate/:pageIndex`) it sits on the + // time base, so back returns to the time step. + const backPath = useDynamicBackPath(isEditPage ? DYNAMIC_ROUTES.MONEY_REQUEST_STEP_SUBRATE_EDIT.path : DYNAMIC_ROUTES.MONEY_REQUEST_STEP_SUBRATE.path); const styles = useThemeStyles(); const iouPolicyID = getIOURequestPolicyID(transaction, report); const {policy} = usePolicyForTransaction({ @@ -123,11 +130,7 @@ function IOURequestStepSubrate({ const validOptions = getSubrateOptions(allPossibleSubrates, allSubrates, currentSubrate?.id); const goBack = () => { - if (backTo) { - Navigation.goBack(backTo); - return; - } - Navigation.goBack(ROUTES.MONEY_REQUEST_STEP_TIME.getRoute(action, iouType, transactionID, reportID, backToReport)); + Navigation.goBack(backPath); }; const validate = (values: FormOnyxValues): Partial> => { @@ -163,7 +166,7 @@ function IOURequestStepSubrate({ updateSubrate(transaction, pageIndex, quantityInt, subrateVal, name, rate); } - if (backTo) { + if (isEditPage) { goBack(); } else { Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_CONFIRMATION.getRoute(action, iouType, transactionID, reportID, backToReport)); @@ -206,11 +209,11 @@ function IOURequestStepSubrate({ { + it('has globally unique path patterns', () => { + const paths = Object.values(DYNAMIC_ROUTES).map((route) => route.path); + const duplicates = paths.filter((path, index) => paths.indexOf(path) !== index); + expect(duplicates).toEqual([]); + }); +}); diff --git a/tests/navigation/getMatchingNewRouteTest.ts b/tests/navigation/getMatchingNewRouteTest.ts index 673d40e1f942..70f4dd51a182 100644 --- a/tests/navigation/getMatchingNewRouteTest.ts +++ b/tests/navigation/getMatchingNewRouteTest.ts @@ -403,4 +403,20 @@ describe('getBestMatchingPath', () => { expect(getMatchingNewRoute('/workspaces/p123/expensify-card/card-details/456')).toBe('/workspaces/p123/expensify-card/card-details/456'); expect(getMatchingNewRoute('/workspaces/p123/expensify-card/card-details/456/edit/limit')).toBe('/workspaces/p123/expensify-card/card-details/456/edit/limit'); }); + + it('redirects legacy per diem subrate step to the new time-based dynamic route keeping the pageIndex (#83850)', () => { + expect(getMatchingNewRoute('/create/submit/subrate/123/456/0')).toBe('/create/submit/time/123/456/per-diem-subrate/0'); + }); + + // NOTE: the redirect keeps the trailing `:pageIndex` and appends the dynamic suffix. Query / legacy backToReport are + // not preserved (the greedy trailing wildcard captures the final segment only) - acceptable because callers no + // longer append `?backTo=` and backToReport now lives on the base path, not the subrate suffix. + it('redirects legacy per diem subrate edit step to the new confirmation-based dynamic route (#83850)', () => { + expect(getMatchingNewRoute('/create/submit/subrate/123/456/edit/2')).toBe('/create/submit/confirmation/123/456/per-diem-subrate-edit/2'); + }); + + it('does not redirect the already-migrated per diem subrate dynamic routes (#83850)', () => { + expect(getMatchingNewRoute('/create/submit/time/123/456/per-diem-subrate/0')).toBe(undefined); + expect(getMatchingNewRoute('/create/submit/confirmation/123/456/per-diem-subrate-edit/2')).toBe(undefined); + }); });