diff --git a/src/pages/home/TimeSensitiveSection/items/FixCompanyCardConnection.tsx b/src/pages/home/TimeSensitiveSection/items/FixCompanyCardConnection.tsx index debc39c40f38..da638d5b673e 100644 --- a/src/pages/home/TimeSensitiveSection/items/FixCompanyCardConnection.tsx +++ b/src/pages/home/TimeSensitiveSection/items/FixCompanyCardConnection.tsx @@ -17,7 +17,6 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type {Card} from '@src/types/onyx'; -import type {CompanyCardFeed} from '@src/types/onyx/CardFeeds'; import React, {useEffect, useEffectEvent} from 'react'; @@ -94,8 +93,8 @@ function FixCompanyCardConnection({card, policyID, policyName}: FixCompanyCardCo return ; } - const customFeedName = cardFeeds?.settings?.companyCardNicknames?.[card.bank as CompanyCardFeed]; - const feedName = getCustomOrFormattedFeedName(translate, card.bank as CompanyCardFeed, customFeedName, false) ?? ''; + const customFeedName = cardFeeds?.settings?.companyCardNicknames?.[card.bank]; + const feedName = getCustomOrFormattedFeedName(translate, card.bank, customFeedName, false) ?? ''; const subtitle = policyName ? translate('homePage.timeSensitiveSection.fixCompanyCardConnection.subtitle', {policyName}) : translate('homePage.timeSensitiveSection.fixCompanyCardConnection.defaultSubtitle'); diff --git a/src/pages/inbox/ReportHeader.tsx b/src/pages/inbox/ReportHeader.tsx index 6d72ee5e5eb1..5134a48673e6 100644 --- a/src/pages/inbox/ReportHeader.tsx +++ b/src/pages/inbox/ReportHeader.tsx @@ -15,21 +15,31 @@ import {getReportName} from '@libs/ReportNameUtils'; import {getReportOfflinePendingActionAndErrors, isInvoiceReport, isMoneyRequestReport, isReportTransactionThread} from '@libs/ReportUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Route} from '@src/ROUTES'; import SCREENS from '@src/SCREENS'; import {useRoute} from '@react-navigation/native'; import React from 'react'; +import type ReportScreenNavigationProps from './types'; + import HeaderView from './HeaderView'; +// navigateToLinkedReportAction emits a SearchReport marker that the registered backTo declaration omits. +type ReportHeaderRoute = Omit & { + readonly params?: Readonly< + Partial> & { + backTo?: ReportScreenNavigationProps['route']['params']['backTo'] | typeof SCREENS.RIGHT_MODAL.SEARCH_REPORT; + } + >; +}; + /** * Owns header variant selection, back button logic, and OfflineWithFeedback wrapper. * Subscribes to report type internally — ReportScreen passes nothing. */ function ReportHeader() { - const route = useRoute(); - const routeParams = route.params as {reportID?: string; backTo?: string} | undefined; + const route = useRoute(); + const routeParams = route.params; const reportIDFromRoute = getNonEmptyStringOnyxID(routeParams?.reportID); const backTo = routeParams?.backTo; @@ -58,7 +68,7 @@ function ReportHeader() { return; } if (prioritizeBackTo && backTo) { - Navigation.goBack(backTo as Route, options); + Navigation.goBack(backTo, options); return; } if (isInNarrowPaneModal) { @@ -66,7 +76,7 @@ function ReportHeader() { return; } if (backTo) { - Navigation.goBack(backTo as Route, options); + Navigation.goBack(backTo, options); return; } Navigation.goBack(undefined, options); diff --git a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx index 73ae231973c4..9e8c2ccfd662 100644 --- a/src/pages/inbox/report/actionContents/ActionContentRouter.tsx +++ b/src/pages/inbox/report/actionContents/ActionContentRouter.tsx @@ -383,10 +383,10 @@ function ActionContentRouter({ /> ); } - if (action.actionName === CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION) { + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION)) { return ( } + action={action} originalReport={originalReport} /> ); @@ -420,10 +420,10 @@ function ActionContentRouter({ ); } - if (action.actionName === CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION) { + if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION)) { return ( } + action={action} originalReport={originalReport} /> ); diff --git a/src/pages/inbox/sidebar/FABPopoverContent/useScanActions.ts b/src/pages/inbox/sidebar/FABPopoverContent/useScanActions.ts index c9f9e89c0363..2cd7bdf60803 100644 --- a/src/pages/inbox/sidebar/FABPopoverContent/useScanActions.ts +++ b/src/pages/inbox/sidebar/FABPopoverContent/useScanActions.ts @@ -39,8 +39,7 @@ function useScanActions() { // useState lazy initializer generates the ID once on mount and keeps it stable across renders const [reportID] = useState(() => generateReportID()); - const policyChatForActivePolicy: OnyxTypes.Report = - !isEmptyObject(activePolicy) && isGroupPolicy(activePolicy) && policyChats.length > 0 ? (policyChats.at(0) ?? ({} as OnyxTypes.Report)) : ({} as OnyxTypes.Report); + const policyChatForActivePolicy = !isEmptyObject(activePolicy) && isGroupPolicy(activePolicy) && policyChats.length > 0 ? policyChats.at(0) : undefined; const startScan = () => { interceptAnonymousUser(() => { diff --git a/src/pages/iou/request/step/IOURequestStepScan/hooks/useScanRouteParams.ts b/src/pages/iou/request/step/IOURequestStepScan/hooks/useScanRouteParams.ts index b274c5509122..ab03cf29bad6 100644 --- a/src/pages/iou/request/step/IOURequestStepScan/hooks/useScanRouteParams.ts +++ b/src/pages/iou/request/step/IOURequestStepScan/hooks/useScanRouteParams.ts @@ -1,16 +1,16 @@ import type {ScanRoute} from '@pages/iou/request/step/IOURequestStepScan/types'; -import {useNavigation, useRoute} from '@react-navigation/native'; +import CONST from '@src/CONST'; -type ScanRouteParams = ScanRoute['params']; +import {useNavigation, useRoute} from '@react-navigation/native'; -type ScanRouteInfo = ScanRouteParams & { +type ScanRouteInfo = Partial> & { /** The route name — used to determine if we're in the create-scan flow vs edit/replace */ routeName: string; }; /** - * Returns typed route params and route name for the scan screen. + * Returns the consumed IOU type and route name for the scan screen. * When rendered as a standalone screen (STEP_SCAN), params come from useRoute() directly. * When rendered inside a TopTab (CREATE flow), params come from the parent navigator's current route. */ @@ -19,12 +19,16 @@ function useScanRouteParams(): ScanRouteInfo { const navigation = useNavigation(); if (route.params) { - return {...(route.params as ScanRouteParams), routeName: route.name}; + const params = route.params; + const iouType = 'iouType' in params ? Object.values(CONST.IOU.TYPE).find((value) => value === params.iouType) : undefined; + return {iouType, routeName: route.name}; } const parentState = navigation.getParent()?.getState(); const parentRoute = parentState?.routes[parentState?.index ?? 0]; - return {...(parentRoute?.params as ScanRouteParams), routeName: parentRoute?.name ?? ''}; + const params = parentRoute?.params; + const iouType = params && 'iouType' in params ? Object.values(CONST.IOU.TYPE).find((value) => value === params.iouType) : undefined; + return {iouType, routeName: parentRoute?.name ?? ''}; } export default useScanRouteParams; diff --git a/tests/unit/hooks/useScanRouteParamsTest.tsx b/tests/unit/hooks/useScanRouteParamsTest.tsx new file mode 100644 index 000000000000..1069f55db8bb --- /dev/null +++ b/tests/unit/hooks/useScanRouteParamsTest.tsx @@ -0,0 +1,188 @@ +import {renderHook} from '@testing-library/react-native'; + +import useScanRouteParams from '@pages/iou/request/step/IOURequestStepScan/hooks/useScanRouteParams'; +import type {ScanRoute} from '@pages/iou/request/step/IOURequestStepScan/types'; + +import CONST from '@src/CONST'; +import SCREENS from '@src/SCREENS'; + +import type {NavigationProp, NavigationState, PartialState} from '@react-navigation/native'; + +import {useNavigation, useRoute} from '@react-navigation/native'; + +import createMock from '../../utils/createMock'; + +jest.mock('@react-navigation/native', () => ({ + useNavigation: jest.fn, Parameters>(), + useRoute: jest.fn, Parameters>(), +})); + +const mockUseNavigation = jest.mocked(useNavigation); +const mockUseRoute = jest.mocked(useRoute); +const scanNavigation = createMock>({ + // A spy supplies the parent while retaining getParent's generic call contract on the navigation prop. + getParent: () => { + throw new Error('Scan parent navigation mock must be configured'); + }, +}); +const mockGetParent = jest.spyOn(scanNavigation, 'getParent'); +// The parent hook boundary can expose an absent or partially rehydrated state. Do not claim a complete NavigationState. +const mockGetState = jest.fn | undefined, Parameters['getState']>>(); +const scanIOUTypes = Object.values(CONST.IOU.TYPE) satisfies Array; + +const invalidScanParams = [ + {description: 'a missing IOU type', params: {}}, + {description: 'an undefined IOU type', params: {iouType: undefined}}, + {description: 'an empty IOU type', params: {iouType: ''}}, + {description: 'an unrecognized IOU type', params: {iouType: 'refund'}}, + {description: 'a numeric IOU type', params: {iouType: 1}}, + {description: 'a null IOU type', params: {iouType: null}}, + {description: 'a boolean IOU type', params: {iouType: false}}, + {description: 'an object IOU type', params: {iouType: {type: CONST.IOU.TYPE.CREATE}}}, + {description: 'an array IOU type', params: {iouType: [CONST.IOU.TYPE.SPLIT]}}, +]; + +describe('useScanRouteParams', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockGetState.mockReset(); + mockUseNavigation.mockReturnValue(scanNavigation); + mockUseRoute.mockReturnValue({key: 'scan-tab', name: CONST.IOU.REQUEST_TYPE.SCAN}); + // getParent is a generic navigation boundary. Only the getState method used by this hook is mocked. + mockGetParent.mockReturnValue({getState: mockGetState}); + }); + + it.each(scanIOUTypes)('preserves direct %s params ahead of a conflicting parent', (iouType) => { + // Given a standalone scan route and a different parent, direct params must win without leaking other fields. + const params = {iouType, transactionID: '123', reportID: '456'} satisfies Partial; + mockUseRoute.mockReturnValue(createMock({name: SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN, params})); + mockGetState.mockReturnValue({ + index: 0, + routes: [{name: SCREENS.MONEY_REQUEST.CREATE, params: {iouType: CONST.IOU.TYPE.SPLIT}}], + }); + + // When the real hook consumes the navigation hooks, it must preserve every enum member, including split-expense. + const {result} = renderHook(() => useScanRouteParams()); + + // Then the complete view contains only the direct IOU type and route name. + expect(result.current).toStrictEqual({iouType, routeName: SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN}); + expect(mockGetParent).not.toHaveBeenCalled(); + }); + + it.each([{}, {transactionID: '123'} satisfies Partial])('keeps direct params priority when iouType is absent: %j', (params) => { + // Given truthy direct params without iouType, the parent's valid value must not fill the gap. + mockUseRoute.mockReturnValue({key: 'direct-scan', name: SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN, params}); + mockGetState.mockReturnValue({index: 0, routes: [{name: SCREENS.MONEY_REQUEST.CREATE, params: {iouType: CONST.IOU.TYPE.CREATE}}]}); + + // When a direct route is present, even an empty params object selects that route. + const {result} = renderHook(() => useScanRouteParams()); + + // Then the undefined type and direct name are explicit, and no extra params survive. + expect(result.current).toStrictEqual({iouType: undefined, routeName: SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN}); + expect(mockGetParent).not.toHaveBeenCalled(); + }); + + it.each(scanIOUTypes)('preserves parent %s params at a nonzero selected index', (iouType) => { + // Given a scan tab without params, the selected parent route must win over the first route. + const params = {iouType, transactionID: '789', reportID: '456'} satisfies Partial; + mockGetState.mockReturnValue({ + index: 1, + routes: [{name: SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN, params: {iouType: CONST.IOU.TYPE.SPLIT}}, createMock({name: SCREENS.MONEY_REQUEST.CREATE, params})], + }); + + // When the real hook follows the tab's parent, all enum values must remain distinct. + const {result} = renderHook(() => useScanRouteParams()); + + // Then only the selected parent's consumed fields are returned. + expect(result.current).toStrictEqual({iouType, routeName: SCREENS.MONEY_REQUEST.CREATE}); + }); + + it('selects parent index zero when a partial state has no index', () => { + // Given a partially rehydrated parent with two routes, an absent index means the first route. + const parentState = { + routes: [ + {name: SCREENS.MONEY_REQUEST.CREATE, params: {iouType: CONST.IOU.TYPE.TRACK}}, + {name: SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN, params: {iouType: CONST.IOU.TYPE.SPLIT}}, + ], + } satisfies PartialState; + mockGetState.mockReturnValue(parentState); + + // When the tab's hook resolves its parent, missing index uses the existing zero fallback. + const {result} = renderHook(() => useScanRouteParams()); + + // Then the first route's exact type and name are the complete result. + expect(result.current).toStrictEqual({iouType: CONST.IOU.TYPE.TRACK, routeName: SCREENS.MONEY_REQUEST.CREATE}); + }); + + it('returns an absent view when there is no parent navigator', () => { + // Given a tab with no direct params or parent, no create flow can be inferred. + mockGetParent.mockReturnValue(undefined); + + // When the real hook resolves navigation, the missing parent must be safe. + const {result} = renderHook(() => useScanRouteParams()); + + // Then the empty name stays distinct from the create screen and iouType stays explicitly undefined. + expect(result.current).toStrictEqual({iouType: undefined, routeName: ''}); + }); + + it.each<[string, NavigationState | PartialState | undefined]>([ + ['missing state', undefined], + ['empty routes', {index: 0, routes: []}], + ['an out-of-range index', {index: 2, routes: [{name: SCREENS.MONEY_REQUEST.CREATE, params: {iouType: CONST.IOU.TYPE.CREATE}}]}], + ])('returns an absent view for %s', (_description, parentState) => { + // Given an absent or partial parent state without a selected route, no create route should be fabricated. + mockGetState.mockReturnValue(parentState); + + // When the real hook looks up the selected parent, missing data must be safe. + const {result} = renderHook(() => useScanRouteParams()); + + // Then both absence fields are explicit, with no default CREATE name. + expect(result.current).toStrictEqual({iouType: undefined, routeName: ''}); + }); + + it('keeps the empty name fallback when erased parent route data has no name', () => { + // Given an incomplete route at the parent-state boundary, its valid type must not imply a CREATE name. + mockGetState.mockReturnValue(createMock>({index: 0, routes: [{params: {iouType: CONST.IOU.TYPE.TRACK}}]})); + + // When the real hook consumes the incomplete route, only the missing name uses the empty fallback. + const {result} = renderHook(() => useScanRouteParams()); + + // Then the supported type survives while the name remains absent, rather than fabricating a create flow. + expect(result.current).toStrictEqual({iouType: CONST.IOU.TYPE.TRACK, routeName: ''}); + }); + + it('preserves a selected parent name when that route has no params', () => { + // Given a selected parent route without params, its identity still identifies the scan flow. + mockGetState.mockReturnValue({index: 0, routes: [{name: SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN}]}); + + // When the hook consumes that partial route, params absence must not discard the name. + const {result} = renderHook(() => useScanRouteParams()); + + // Then the parent name remains present with an explicit undefined IOU type. + expect(result.current).toStrictEqual({iouType: undefined, routeName: SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN}); + }); + + it.each(invalidScanParams)('returns undefined for direct $description at the erased params boundary', ({params}) => { + // Given erased direct params with an unsupported type, a valid parent must not override direct priority. + mockUseRoute.mockReturnValue(createMock>({name: SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN, params})); + mockGetState.mockReturnValue({index: 0, routes: [{name: SCREENS.MONEY_REQUEST.CREATE, params: {iouType: CONST.IOU.TYPE.CREATE}}]}); + + // When the real hook extracts the enum, it must reject only the unsupported IOU type. + const {result} = renderHook(() => useScanRouteParams()); + + // Then the entire direct view retains the route name and normalizes the type to undefined. + expect(result.current).toStrictEqual({iouType: undefined, routeName: SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN}); + }); + + it.each(invalidScanParams)('returns undefined for parent $description at the erased params boundary', ({params}) => { + // Given erased parent params with an unsupported type, the selected route name is still meaningful. + const parentState = {index: 0, routes: [{name: SCREENS.MONEY_REQUEST.CREATE, params}]} satisfies PartialState; + mockGetState.mockReturnValue(parentState); + + // When the real hook extracts the parent's enum, malformed values must not become a supported type. + const {result} = renderHook(() => useScanRouteParams()); + + // Then the complete parent view contains no invalid type or extra fields. + expect(result.current).toStrictEqual({iouType: undefined, routeName: SCREENS.MONEY_REQUEST.CREATE}); + }); +});