Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -94,8 +93,8 @@ function FixCompanyCardConnection({card, policyID, policyName}: FixCompanyCardCo
return <FixCompanyCardConnectionSkeleton />;
}

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');
Expand Down
20 changes: 15 additions & 5 deletions src/pages/inbox/ReportHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<ReportScreenNavigationProps['route'], 'params'> & {
readonly params?: Readonly<
Partial<Pick<ReportScreenNavigationProps['route']['params'], 'reportID'>> & {
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<ReportHeaderRoute>();
const routeParams = route.params;
const reportIDFromRoute = getNonEmptyStringOnyxID(routeParams?.reportID);
const backTo = routeParams?.backTo;

Expand Down Expand Up @@ -58,15 +68,15 @@ function ReportHeader() {
return;
}
if (prioritizeBackTo && backTo) {
Navigation.goBack(backTo as Route, options);
Navigation.goBack(backTo, options);
return;
}
if (isInNarrowPaneModal) {
Navigation.goBack(undefined, options);
return;
}
if (backTo) {
Navigation.goBack(backTo as Route, options);
Navigation.goBack(backTo, options);
return;
}
Navigation.goBack(undefined, options);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<MovedTransactionAction
action={action as OnyxTypes.ReportAction<typeof CONST.REPORT.ACTIONS.TYPE.MOVED_TRANSACTION>}
action={action}
originalReport={originalReport}
/>
);
Expand Down Expand Up @@ -420,10 +420,10 @@ function ActionContentRouter({
</ReportActionItemBasicMessage>
);
}
if (action.actionName === CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION) {
if (isActionOfType(action, CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION)) {
return (
<UnreportedTransactionAction
action={action as OnyxTypes.ReportAction<typeof CONST.REPORT.ACTIONS.TYPE.UNREPORTED_TRANSACTION>}
action={action}
originalReport={originalReport}
/>
);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Pick<ScanRoute['params'], 'iouType'>> & {
/** 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.
*/
Expand All @@ -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;
188 changes: 188 additions & 0 deletions tests/unit/hooks/useScanRouteParamsTest.tsx
Original file line number Diff line number Diff line change
@@ -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<ReturnType<typeof useNavigation>, Parameters<typeof useNavigation>>(),
useRoute: jest.fn<ReturnType<typeof useRoute>, Parameters<typeof useRoute>>(),
}));

const mockUseNavigation = jest.mocked(useNavigation);
const mockUseRoute = jest.mocked(useRoute);
const scanNavigation = createMock<NavigationProp<ReactNavigation.RootParamList>>({
// 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<NavigationState | PartialState<NavigationState> | undefined, Parameters<NavigationProp<ReactNavigation.RootParamList>['getState']>>();
const scanIOUTypes = Object.values(CONST.IOU.TYPE) satisfies Array<ScanRoute['params']['iouType']>;

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<ScanRoute['params']>;
mockUseRoute.mockReturnValue(createMock<ScanRoute>({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<ScanRoute['params']>])('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<ScanRoute['params']>;
mockGetState.mockReturnValue({
index: 1,
routes: [{name: SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN, params: {iouType: CONST.IOU.TYPE.SPLIT}}, createMock<ScanRoute>({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<NavigationState>;
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<NavigationState> | 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<PartialState<NavigationState>>({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<ReturnType<typeof useRoute>>({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<NavigationState>;
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});
});
});
Loading