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
19 changes: 15 additions & 4 deletions src/hooks/useReceiptScanDrop.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import {buildOptimisticTransactionAndCreateDraft} from '@userActions/Transaction
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {Transaction} from '@src/types/onyx';
import type {FileObject} from '@src/types/utils/Attachment';

import {validTransactionDraftIDsSelector} from '@selectors/TransactionDraft';
Expand All @@ -26,6 +25,10 @@ import useIsAnonymousUser from './useIsAnonymousUser';
import useOnyx from './useOnyx';
import useSelfDMReport from './useSelfDMReport';

function areBlobBackedFiles(files: FileObject[]): files is Array<FileObject & Blob> {
return files.every((file) => file instanceof Blob);
}

/**
* Encapsulates the receipt scan drag-and-drop logic used by SearchPage and HomePage.
* Returns the drop handler and sibling-safe auxiliary UI needed for receipt scanning.
Expand All @@ -51,6 +54,10 @@ function useReceiptScanDrop() {
const newReportID = useMemo(() => generateReportID(), []);

const saveFileAndInitMoneyRequest = (files: FileObject[]) => {
if (!areBlobBackedFiles(files)) {
return;
}

const initialTransaction = initMoneyRequest({
isFromGlobalCreate: true,
isFromFloatingActionButton: true,
Expand All @@ -64,15 +71,19 @@ function useReceiptScanDrop() {
draftTransactionIDs,
});

if (!initialTransaction) {
return;
}

const newReceiptFiles: ReceiptFile[] = [];

for (const [index, file] of files.entries()) {
const source = URL.createObjectURL(file as Blob);
const source = URL.createObjectURL(file);
const transaction =
index === 0
? (initialTransaction as Partial<Transaction>)
? initialTransaction
: buildOptimisticTransactionAndCreateDraft({
initialTransaction: initialTransaction as Partial<Transaction>,
initialTransaction: {...initialTransaction, category: initialTransaction.category ?? undefined},
reportID: newReportID,
});
const transactionID = transaction.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID;
Expand Down
4 changes: 2 additions & 2 deletions src/libs/actions/BankAccounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,7 @@ function getOnyxDataForConnectingVBBAAndLastPaymentMethod(policyID?: string, las
/**
* Submit Bank Account step with Plaid data so php can perform some checks.
*/
function connectBankAccountWithPlaid(bankAccountID: number, selectedPlaidBankAccount: PlaidBankAccount, policyID: string) {
function connectBankAccountWithPlaid(bankAccountID: number, selectedPlaidBankAccount: PlaidBankAccount, policyID: string | undefined) {
const isChaseBank = selectedPlaidBankAccount.bankName?.toLowerCase() === CONST.BANK_NAMES.CHASE;
if (bankAccountID === CONST.DEFAULT_NUMBER_ID && isChaseBank) {
Onyx.merge(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {
Expand Down Expand Up @@ -1378,7 +1378,7 @@ function acceptACHContractForBankAccount(bankAccountID: number, params: ACHContr
/**
* Create the bank account with manually entered data.
*/
function connectBankAccountManually(bankAccountID: number, bankAccount: PlaidBankAccount, policyID: string) {
function connectBankAccountManually(bankAccountID: number, bankAccount: PlaidBankAccount, policyID: string | undefined) {
const parameters: ConnectBankAccountParams = {
bankAccountID: !Number.isNaN(bankAccountID) ? bankAccountID : CONST.DEFAULT_NUMBER_ID,
routingNumber: bankAccount.routingNumber,
Expand Down
2 changes: 1 addition & 1 deletion src/pages/ReimbursementAccount/USD/BankInfo/BankInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ type BankInfoProps = {
onSubmit?: () => void;

/** Current Policy ID */
policyID: string;
policyID?: string;
};

const BANK_INFO_STEP_KEYS = INPUT_IDS.BANK_INFO_STEP;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,30 @@ type PageEntry = {
lastSubPage?: string;
};

function CountryPage({onBackButtonPress, onSubmit, stepNames, policyID}: USDPageProps) {
return (
<Country
onBackButtonPress={onBackButtonPress}
onSubmit={onSubmit}
stepNames={stepNames ?? CONST.BANK_ACCOUNT.STEP_NAMES}
policyID={policyID}
/>
);
}

function BankInfoPage({onBackButtonPress, onSubmit, policyID}: USDPageProps) {
return (
<BankInfo
onBackButtonPress={onBackButtonPress}
onSubmit={onSubmit}
policyID={policyID}
/>
);
}
Comment on lines +54 to +62

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 USDVerifiedBankAccountFlowPage.tsx: BankInfoPage's if (!policyID) return null breaks the policy-less bank-account flow (codex P1 confirmed)

function BankInfoPage({onBackButtonPress, onSubmit, policyID}: USDPageProps) {
    if (!policyID) {
        return null;   // ← blanks the Bank info step for non-workspace-linked accounts
    }
    return <BankInfo ... policyID={policyID} />;
}

The old code cast BankInfo as React.ComponentType<USDPageProps> (hiding that BankInfo requires policyID: string while USDPageProps.policyID is optional). The honest fix would keep BankInfo rendering; instead this short-circuits to null whenever policyID is falsy, and policyID is legitimately absent/empty here:

  • route.params?.policyID is optional, and navigateToBankAccountRoute (ReimbursementAccount/navigation.ts) documents "There can be bank accounts that are not linked to any workspace" and defaults policyID = ''.
  • Real callers reach this with no policyID: WalletPage/index.tsx:243 calls navigateToBankAccountRoute({bankAccountID, backTo: ROUTES.SETTINGS_WALLET}) (bankAccountID only), and CountrySelection.tsx:75 passes neither.
  • BankInfo derives the account from bankAccountID = getBankAccountIDAsNumber(reimbursementAccount?.achData) (Onyx), not policyID, so it functioned fine when the old code rendered <BankInfo policyID={''} />, and it passes that '' straight through to connectBankAccountManually/WithPlaid on submit.

Because the guard is a falsy check, '' (the navigateToBankAccountRoute default) also returns null, so a user continuing setup for a personal/wallet bank account now lands on a blank Bank info step and can't proceed, a flow that worked before this diff.

Why the test doesn't catch it: USDVerifiedBankAccountFlowPageTest asserts mockBankInfo.mock.calls.length === 0 for renderPage({page: BANK_ACCOUNT}) (no policyID), i.e. it encodes "policy-less → BankInfo not rendered" as expected. So the suite is green while the regression is baked in.

Suggested fix (removes the cast without the regression): make BankInfo's policyID optional to match how it's actually used, e.g. policyID?: string in BankInfoProps (it already reads bankAccountID from Onyx and forwards policyID to the connect actions, which the policy-less path invokes with '' today).

Then BankInfoPage can render unconditionally: <BankInfo policyID={policyID ?? ''} .../>. If BankInfo genuinely cannot work without a policyID, that's a product decision that needs an explicit non-blank handling (redirect/error), not a silent return null.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 694e0e0.

The Bank info page no longer returns null when policyID is absent or empty. BankInfo and both BankAccount action boundaries now truthfully accept string | undefined, while the action bodies remain unchanged. Focused coverage verifies that undefined, '', and a valid policy ID all reach BankInfo unchanged, and that Country-to-Plaid routing remains intact.


const pages: PageEntry[] = [
{pageName: PAGE_NAMES.COUNTRY, component: Country as React.ComponentType<USDPageProps>},
{pageName: PAGE_NAMES.BANK_ACCOUNT, component: BankInfo as React.ComponentType<USDPageProps>, firstSubPage: BANK_INFO_SUB_PAGES.PLAID, lastSubPage: BANK_INFO_SUB_PAGES.PLAID},
{pageName: PAGE_NAMES.COUNTRY, component: CountryPage},
{pageName: PAGE_NAMES.BANK_ACCOUNT, component: BankInfoPage, firstSubPage: BANK_INFO_SUB_PAGES.PLAID, lastSubPage: BANK_INFO_SUB_PAGES.PLAID},
{
pageName: PAGE_NAMES.REQUESTOR,
component: RequestorStep as React.ComponentType<USDPageProps>,
Expand Down Expand Up @@ -93,7 +114,7 @@ function USDVerifiedBankAccountFlowPage({route}: USDVerifiedBankAccountFlowPageP
}, [currentPage]);

const currentEntry = pages.at(currentPageIndex);
const CurrentPage = currentEntry?.component ?? (Country as React.ComponentType<USDPageProps>);
const CurrentPage = currentEntry?.component ?? CountryPage;
const isRequestorStep = currentEntry?.pageName === PAGE_NAMES.REQUESTOR;

const shouldSkipVerifyIdentity = useCallback((pageName?: string) => pageName === PAGE_NAMES.VERIFY_IDENTITY && isOnfidoSetupComplete, [isOnfidoSetupComplete]);
Expand Down
19 changes: 12 additions & 7 deletions src/pages/Search/SearchSavePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ type FilterValueProps = {
value: SearchFilter['value'];
};

type ArrayFilterValueProps = {
/** The array-valued search filter displayed by this component. */
value: Extract<SearchFilter['value'], string[]>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

❌ CONSISTENCY-13 (docs)

The newly added ArrayFilterValueProps type declares its own value prop but has no /** ... */ block comment documenting it. Per STYLE.md, every component prop should be documented with a JSDoc block comment above it.

Add a block comment above the prop:

type ArrayFilterValueProps = {
    /** The array of filter values to render */
    value: Extract<SearchFilter['value'], string[]>;
};

Reviewed at: 360f4f0 | Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 SearchSavePage.tsxArrayFilterValueProps missing a prop JSDoc (codex CONSISTENCY-13, minor)

The bot flags that the new type ArrayFilterValueProps = { value: ... } has no /** ... */ on value. It's technically per STYLE.md, but low-value in context: the pre-existing sibling FilterValueProps right above it (value: SearchFilter['value']) is also undocumented, so the new type just follows this file's local convention. If you want to satisfy the rule, document both for consistency rather than only the new one — otherwise it reads as arbitrary. Trivial either way; not blocking.

Worth a sanity check on the same file's logic change: gating FEED/TAX_RATE on && Array.isArray(value) means a non-array value for those keys now renders nothing (the old as string[] cast rendered regardless).

That's correct if those values are always arrays when the key is present (the common case), but confirm no path passes a string feed:/tax: value that should still display. The FilterCardValue change (Array.isArray(value) ? value : value.split(', ')) is a genuine improvement — it decodes a comma-joined string instead of casting it, and the new test verifies raw IDs never leak.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 694e0e0.

I added the requested JSDoc above ArrayFilterValueProps.value. I also confirmed the scalar behavior: FEED and TAX_RATE call their display hooks only for arrays, while scalar values use the raw fallback. CARD_ID strings are split into exact comma-space-delimited IDs. Focused tests cover the scalar fallback and the 12/23/123 collision case.

};

type FilterValueWithKeyProps = FilterValueProps & {
filterKey: SearchFilter['key'];
};
Expand All @@ -53,16 +58,16 @@ function FilterWorkspaceValue({value}: FilterValueProps) {
return useFilterWorkspaceValue(value);
}

function FilterFeedValue({value}: FilterValueProps) {
return useFilterFeedValue(value as string[]);
function FilterFeedValue({value}: ArrayFilterValueProps) {
return useFilterFeedValue(value);
}

function FilterCardValue({value}: FilterValueProps) {
return useFilterCardValue(value as string[]);
return useFilterCardValue(Array.isArray(value) ? value : value.split(', '));
}

function FilterTaxRateValue({value}: FilterValueProps) {
return useFilterTaxRateValue(value as string[]);
function FilterTaxRateValue({value}: ArrayFilterValueProps) {
return useFilterTaxRateValue(value);
}

function FilterReportValue({value}: FilterValueProps) {
Expand All @@ -87,15 +92,15 @@ function FilterValue({filterKey, value}: FilterValueWithKeyProps) {
return <FilterWorkspaceValue value={value} />;
}

if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED) {
if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED && Array.isArray(value)) {
return <FilterFeedValue value={value} />;
}

if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.CARD_ID) {
return <FilterCardValue value={value} />;
}

if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE) {
if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE && Array.isArray(value)) {
return <FilterTaxRateValue value={value} />;
}

Expand Down
18 changes: 12 additions & 6 deletions src/pages/settings/Report/ReportDetailsColumnsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ const REPORT_DETAILS_DEFAULT_COLUMNS: SearchCustomColumnIds[] = [
CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT,
];

const REPORT_DETAILS_CUSTOM_COLUMNS = Object.values(CONST.SEARCH.REPORT_DETAILS_CUSTOM_COLUMNS);

function isReportDetailsCustomColumn(column: string): column is SearchCustomColumnIds {
return REPORT_DETAILS_CUSTOM_COLUMNS.some((customColumn) => customColumn === column);
}

const ALL_REPORT_DETAILS_CUSTOM_COLUMNS = REPORT_DETAILS_CUSTOM_COLUMNS.filter(isReportDetailsCustomColumn);

function ReportDetailsColumnsPage() {
const route = useRoute<PlatformStackRouteProp<ReportSettingsNavigatorParamList, typeof SCREENS.REPORT_SETTINGS.COLUMNS>>();
const reportID = route.params.reportID;
Expand All @@ -58,8 +66,6 @@ function ReportDetailsColumnsPage() {
});
const currentUserDetails = useCurrentUserPersonalDetails();

const allTypeCustomColumns = Object.values(CONST.SEARCH.REPORT_DETAILS_CUSTOM_COLUMNS) as SearchCustomColumnIds[];

// Wait for transactions to load before rendering. ColumnsSettingsList snapshots
// currentColumns in useState on mount and does not sync prop updates, so we must
// pass the final value on first render.
Expand All @@ -69,7 +75,7 @@ function ReportDetailsColumnsPage() {
// return for this report so data-driven columns (e.g. Exchange rate, Original amount,
// Tax rate, Tax amount) appear pre-selected when they have data on the table.
const effectiveColumns = useMemo(() => {
const savedColumns = (reportDetailsColumns ?? []) as SearchCustomColumnIds[];
const savedColumns = (reportDetailsColumns ?? []).filter(isReportDetailsCustomColumn);
if (savedColumns.length > 0) {
return savedColumns;
}
Expand All @@ -91,8 +97,8 @@ function ReportDetailsColumnsPage() {
});

// Filter to only columns available in the custom columns list (drops RECEIPT/TYPE/COMMENTS etc.)
return visibleColumns.filter((col) => allTypeCustomColumns.includes(col as SearchCustomColumnIds)) as SearchCustomColumnIds[];
}, [reportDetailsColumns, reportTransactions, currentUserDetails?.accountID, report, policy, allTypeCustomColumns]);
return visibleColumns.filter(isReportDetailsCustomColumn);
}, [reportDetailsColumns, reportTransactions, currentUserDetails?.accountID, report, policy]);

const requiredColumns = new Set<SearchCustomColumnIds>([CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT]);

Expand All @@ -111,7 +117,7 @@ function ReportDetailsColumnsPage() {

return (
<ColumnsSettingsList
allColumns={allTypeCustomColumns}
allColumns={ALL_REPORT_DETAILS_CUSTOM_COLUMNS}
defaultSelectedColumns={REPORT_DETAILS_DEFAULT_COLUMNS}
currentColumns={effectiveColumns}
requiredColumns={requiredColumns}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';

import type {ValueOf} from 'type-fest';

import React from 'react';
import {View} from 'react-native';

Expand All @@ -20,22 +22,37 @@ type CertiniaPrerequisitesStepProps = SubPageProps & {
isSandbox: boolean;
};

const PAGE_NAMES = CONST.CERTINIA_PREREQUISITES.PAGE_NAME;
type PageName = ValueOf<typeof PAGE_NAMES>;

const PAGE_NAME_VALUES = Object.values(PAGE_NAMES);
const TITLE_KEYS = {
[PAGE_NAMES.INSTALL_BUNDLE]: 'workspace.certinia.prerequisites.installBundle',
[PAGE_NAMES.SETUP_CONTACTS]: 'workspace.certinia.prerequisites.setupContacts',
[PAGE_NAMES.OAUTH]: 'workspace.certinia.prerequisites.oauth',
} satisfies Record<PageName, TranslationPaths>;
const BUTTON_KEYS = {
[PAGE_NAMES.INSTALL_BUNDLE]: 'workspace.certinia.prerequisites.installBundleConfirm',
[PAGE_NAMES.SETUP_CONTACTS]: 'workspace.certinia.prerequisites.setupContactsConfirm',
[PAGE_NAMES.OAUTH]: 'workspace.certinia.prerequisites.connectButton',
} satisfies Record<PageName, TranslationPaths>;

function isPageName(pageName: string | undefined): pageName is PageName {
return pageName !== undefined && PAGE_NAME_VALUES.some((configuredPageName) => configuredPageName === pageName);
}

function CertiniaPrerequisitesStep({onNext, currentPageName, onConnect, isSandbox}: CertiniaPrerequisitesStepProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {isOffline} = useNetwork();

const isLastStep = currentPageName === CONST.CERTINIA_PREREQUISITES.PAGE_NAME.OAUTH;

const pageNames = CONST.CERTINIA_PREREQUISITES.PAGE_NAME;
const titleKey = `workspace.certinia.prerequisites.${currentPageName}` as TranslationPaths;
const descriptionKey = `workspace.certinia.prerequisites.${currentPageName}Description` as TranslationPaths;
const buttonKey = isLastStep
? ('workspace.certinia.prerequisites.connectButton' as TranslationPaths)
: (`workspace.certinia.prerequisites.${currentPageName}Confirm` as TranslationPaths);
const pageName = isPageName(currentPageName) ? currentPageName : PAGE_NAMES.INSTALL_BUNDLE;
const isLastStep = pageName === PAGE_NAMES.OAUTH;
const titleKey = TITLE_KEYS[pageName];
const buttonKey = BUTTON_KEYS[pageName];

let stepContent;
if (currentPageName === pageNames.INSTALL_BUNDLE) {
if (pageName === PAGE_NAMES.INSTALL_BUNDLE) {
stepContent = (
<View style={[styles.flex1, styles.mb3, styles.ph5]}>
<View>
Expand All @@ -58,7 +75,7 @@ function CertiniaPrerequisitesStep({onNext, currentPageName, onConnect, isSandbo
</View>
</View>
);
} else if (currentPageName === pageNames.SETUP_CONTACTS) {
} else if (pageName === PAGE_NAMES.SETUP_CONTACTS) {
stepContent = (
<View style={[styles.flex1, styles.mb3, styles.ph5]}>
{[
Expand All @@ -79,7 +96,7 @@ function CertiniaPrerequisitesStep({onNext, currentPageName, onConnect, isSandbo
</View>
);
} else {
stepContent = <Text style={[styles.flex1, styles.mb3, styles.ph5, styles.mutedTextLabel]}>{translate(descriptionKey)}</Text>;
stepContent = <Text style={[styles.flex1, styles.mb3, styles.ph5, styles.mutedTextLabel]}>{translate('workspace.certinia.prerequisites.oauthDescription')}</Text>;
}

return (
Expand Down
52 changes: 48 additions & 4 deletions tests/unit/hooks/useReceiptScanDrop.test.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,74 @@
import {renderHook} from '@testing-library/react-native';
import {act, renderHook} from '@testing-library/react-native';

import useFilesValidation from '@hooks/useFilesValidation';
import useReceiptScanDrop from '@hooks/useReceiptScanDrop';

import {navigateToParticipantPage} from '@libs/IOUUtils';
import Navigation from '@libs/Navigation/Navigation';

import {initMoneyRequest, setMoneyRequestParticipantsFromReport} from '@userActions/IOU/MoneyRequest';
import {setMoneyRequestReceipt} from '@userActions/IOU/Receipt';
import {buildOptimisticTransactionAndCreateDraft} from '@userActions/TransactionEdit';

import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';

import Onyx from 'react-native-onyx';

import createMock from '../../utils/createMock';
import waitForBatchedUpdatesWithAct from '../../utils/waitForBatchedUpdatesWithAct';

jest.mock('@hooks/useFilesValidation');
jest.mock('@expensify/react-native-hybrid-app', () => ({__esModule: true, default: {isHybridApp: jest.fn(() => false)}}));
jest.mock('@libs/IOUUtils', () => ({navigateToParticipantPage: jest.fn()}));
jest.mock('@libs/Navigation/Navigation');
jest.mock('@userActions/IOU/MoneyRequest', () => ({initMoneyRequest: jest.fn(), setMoneyRequestParticipantsFromReport: jest.fn()}));
jest.mock('@userActions/IOU/Receipt', () => ({setMoneyRequestReceipt: jest.fn()}));
jest.mock('@userActions/TransactionEdit', () => ({buildOptimisticTransactionAndCreateDraft: jest.fn()}));
let mockOnFilesValidated: Parameters<typeof useFilesValidation>[0] = jest.fn();
describe('useReceiptScanDrop', () => {
afterEach(async () => {
beforeEach(async () => {
await Onyx.clear();
jest.clearAllMocks();
jest.mocked(useFilesValidation).mockImplementation((onFilesValidated) => {
mockOnFilesValidated = onFilesValidated;
return {validateFiles: jest.fn(), PDFValidationComponent: undefined};
});
});

it('should disable drag for anonymous users', async () => {
await Onyx.merge(ONYXKEYS.SESSION, {authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS});
const {result} = renderHook(() => useReceiptScanDrop());
await waitForBatchedUpdatesWithAct();
expect(result.current.isDragDisabled).toBe(true);
});

it('should enable drag for logged-in users', async () => {
await Onyx.merge(ONYXKEYS.SESSION, {authToken: 'test-token'});
const {result} = renderHook(() => useReceiptScanDrop());
await waitForBatchedUpdatesWithAct();
expect(result.current.isDragDisabled).toBe(false);
});
it('keeps one and multiple valid receipts ordered on their intended transactions', async () => {
const createObjectURLSpy = jest.spyOn(URL, 'createObjectURL').mockReturnValueOnce('blob:first').mockReturnValueOnce('blob:first').mockReturnValueOnce('blob:second');
renderHook(() => useReceiptScanDrop());
await waitForBatchedUpdatesWithAct();
const [firstFile, secondFile] = [new File(['first'], 'first.png', {type: 'image/png'}), new File(['second'], 'second.png', {type: 'image/png'})];
jest.mocked(initMoneyRequest).mockReturnValue(createMock<NonNullable<ReturnType<typeof initMoneyRequest>>>({}));
mockOnFilesValidated([firstFile], []);
expect(jest.mocked(setMoneyRequestReceipt)).toHaveBeenLastCalledWith(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, 'blob:first', 'first.png', true, 'image/png');
expect(jest.mocked(navigateToParticipantPage)).toHaveBeenCalledWith(CONST.IOU.TYPE.CREATE, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, expect.any(String));
jest.clearAllMocks();
await act(() => Onyx.set(ONYXKEYS.NVP_ACTIVE_POLICY_ID, 'policy'));
await act(() => Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}policy`, {id: 'policy', type: CONST.POLICY.TYPE.TEAM}));
await waitForBatchedUpdatesWithAct();
jest.mocked(initMoneyRequest).mockReturnValue(createMock<NonNullable<ReturnType<typeof initMoneyRequest>>>({transactionID: CONST.IOU.OPTIMISTIC_TRANSACTION_ID}));
jest.mocked(buildOptimisticTransactionAndCreateDraft).mockReturnValue(createMock<ReturnType<typeof buildOptimisticTransactionAndCreateDraft>>({transactionID: 'later'}));
mockOnFilesValidated([firstFile, secondFile], []);
await waitForBatchedUpdatesWithAct();
expect(createObjectURLSpy.mock.calls.slice(-2).map(([file]) => file)).toEqual([firstFile, secondFile]);
expect(jest.mocked(setMoneyRequestReceipt).mock.calls.map(([transactionID]) => transactionID)).toEqual([CONST.IOU.OPTIMISTIC_TRANSACTION_ID, 'later']);
expect(jest.mocked(buildOptimisticTransactionAndCreateDraft)).toHaveBeenCalledTimes(1);
expect(jest.mocked(setMoneyRequestParticipantsFromReport).mock.calls.map(([transactionID]) => transactionID)).toEqual([CONST.IOU.OPTIMISTIC_TRANSACTION_ID, 'later']);
expect(jest.mocked(Navigation.navigate)).toHaveBeenCalledTimes(1);
expect(jest.mocked(navigateToParticipantPage)).not.toHaveBeenCalled();
});
});
Loading
Loading