diff --git a/src/hooks/useReceiptScanDrop.tsx b/src/hooks/useReceiptScanDrop.tsx index 91f13db7bf9b..567da1466670 100644 --- a/src/hooks/useReceiptScanDrop.tsx +++ b/src/hooks/useReceiptScanDrop.tsx @@ -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'; @@ -26,6 +25,10 @@ import useIsAnonymousUser from './useIsAnonymousUser'; import useOnyx from './useOnyx'; import useSelfDMReport from './useSelfDMReport'; +function areBlobBackedFiles(files: FileObject[]): files is Array { + 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. @@ -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, @@ -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) + ? initialTransaction : buildOptimisticTransactionAndCreateDraft({ - initialTransaction: initialTransaction as Partial, + initialTransaction: {...initialTransaction, category: initialTransaction.category ?? undefined}, reportID: newReportID, }); const transactionID = transaction.transactionID ?? CONST.IOU.OPTIMISTIC_TRANSACTION_ID; diff --git a/src/libs/actions/BankAccounts.ts b/src/libs/actions/BankAccounts.ts index b62b6e779fc3..ddbdf3a39878 100644 --- a/src/libs/actions/BankAccounts.ts +++ b/src/libs/actions/BankAccounts.ts @@ -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, { @@ -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, diff --git a/src/pages/ReimbursementAccount/USD/BankInfo/BankInfo.tsx b/src/pages/ReimbursementAccount/USD/BankInfo/BankInfo.tsx index b0263959d6a1..4acd26ea7755 100644 --- a/src/pages/ReimbursementAccount/USD/BankInfo/BankInfo.tsx +++ b/src/pages/ReimbursementAccount/USD/BankInfo/BankInfo.tsx @@ -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; diff --git a/src/pages/ReimbursementAccount/USD/USDVerifiedBankAccountFlowPage.tsx b/src/pages/ReimbursementAccount/USD/USDVerifiedBankAccountFlowPage.tsx index 4f82280c1bae..009c023837cf 100644 --- a/src/pages/ReimbursementAccount/USD/USDVerifiedBankAccountFlowPage.tsx +++ b/src/pages/ReimbursementAccount/USD/USDVerifiedBankAccountFlowPage.tsx @@ -40,9 +40,30 @@ type PageEntry = { lastSubPage?: string; }; +function CountryPage({onBackButtonPress, onSubmit, stepNames, policyID}: USDPageProps) { + return ( + + ); +} + +function BankInfoPage({onBackButtonPress, onSubmit, policyID}: USDPageProps) { + return ( + + ); +} + const pages: PageEntry[] = [ - {pageName: PAGE_NAMES.COUNTRY, component: Country as React.ComponentType}, - {pageName: PAGE_NAMES.BANK_ACCOUNT, component: BankInfo as React.ComponentType, 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, @@ -93,7 +114,7 @@ function USDVerifiedBankAccountFlowPage({route}: USDVerifiedBankAccountFlowPageP }, [currentPage]); const currentEntry = pages.at(currentPageIndex); - const CurrentPage = currentEntry?.component ?? (Country as React.ComponentType); + const CurrentPage = currentEntry?.component ?? CountryPage; const isRequestorStep = currentEntry?.pageName === PAGE_NAMES.REQUESTOR; const shouldSkipVerifyIdentity = useCallback((pageName?: string) => pageName === PAGE_NAMES.VERIFY_IDENTITY && isOnfidoSetupComplete, [isOnfidoSetupComplete]); diff --git a/src/pages/Search/SearchSavePage.tsx b/src/pages/Search/SearchSavePage.tsx index 15f5f3a46c5d..724a040cd21e 100644 --- a/src/pages/Search/SearchSavePage.tsx +++ b/src/pages/Search/SearchSavePage.tsx @@ -41,6 +41,11 @@ type FilterValueProps = { value: SearchFilter['value']; }; +type ArrayFilterValueProps = { + /** The array-valued search filter displayed by this component. */ + value: Extract; +}; + type FilterValueWithKeyProps = FilterValueProps & { filterKey: SearchFilter['key']; }; @@ -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) { @@ -87,7 +92,7 @@ function FilterValue({filterKey, value}: FilterValueWithKeyProps) { return ; } - if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED) { + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED && Array.isArray(value)) { return ; } @@ -95,7 +100,7 @@ function FilterValue({filterKey, value}: FilterValueWithKeyProps) { return ; } - if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE) { + if (filterKey === CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE && Array.isArray(value)) { return ; } diff --git a/src/pages/settings/Report/ReportDetailsColumnsPage.tsx b/src/pages/settings/Report/ReportDetailsColumnsPage.tsx index 5f8a85091d6f..84fd8918fc7d 100644 --- a/src/pages/settings/Report/ReportDetailsColumnsPage.tsx +++ b/src/pages/settings/Report/ReportDetailsColumnsPage.tsx @@ -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>(); const reportID = route.params.reportID; @@ -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. @@ -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; } @@ -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([CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT]); @@ -111,7 +117,7 @@ function ReportDetailsColumnsPage() { return ( ; + +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; +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; + +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 = ( @@ -58,7 +75,7 @@ function CertiniaPrerequisitesStep({onNext, currentPageName, onConnect, isSandbo ); - } else if (currentPageName === pageNames.SETUP_CONTACTS) { + } else if (pageName === PAGE_NAMES.SETUP_CONTACTS) { stepContent = ( {[ @@ -79,7 +96,7 @@ function CertiniaPrerequisitesStep({onNext, currentPageName, onConnect, isSandbo ); } else { - stepContent = {translate(descriptionKey)}; + stepContent = {translate('workspace.certinia.prerequisites.oauthDescription')}; } return ( diff --git a/tests/unit/hooks/useReceiptScanDrop.test.ts b/tests/unit/hooks/useReceiptScanDrop.test.ts index 3b3f8be37503..20f3509f739b 100644 --- a/tests/unit/hooks/useReceiptScanDrop.test.ts +++ b/tests/unit/hooks/useReceiptScanDrop.test.ts @@ -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[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>>({})); + 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>>({transactionID: CONST.IOU.OPTIMISTIC_TRANSACTION_ID})); + jest.mocked(buildOptimisticTransactionAndCreateDraft).mockReturnValue(createMock>({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(); + }); }); diff --git a/tests/unit/pages/Search/SearchSavePageTest.tsx b/tests/unit/pages/Search/SearchSavePageTest.tsx new file mode 100644 index 000000000000..7ba205241166 --- /dev/null +++ b/tests/unit/pages/Search/SearchSavePageTest.tsx @@ -0,0 +1,73 @@ +import {render} from '@testing-library/react-native'; + +import useFilterFeedValue from '@components/Search/hooks/useFilterFeedValue'; +import useFilterTaxRateValue from '@components/Search/hooks/useFilterTaxRateValue'; + +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; + +import * as SearchUIUtils from '@libs/SearchUIUtils'; + +import SearchSavePage from '@pages/Search/SearchSavePage'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {SearchAdvancedFiltersForm} from '@src/types/form'; +import type {Card, CardList} from '@src/types/onyx'; + +import React from 'react'; + +import createMock from '../../../utils/createMock'; +import {translateLocal} from '../../../utils/TestHelper'; + +jest.mock('@components/Form/FormProvider', () => jest.fn((props: React.PropsWithChildren) => props.children)); +jest.mock('@components/Form/InputWrapper', () => jest.fn(() => null)); +jest.mock('@components/HeaderWithBackButton', () => jest.fn(() => null)); +jest.mock('@components/ScreenWrapper', () => jest.fn((props: React.PropsWithChildren) => props.children)); +jest.mock('@components/Search/hooks/useFilterFeedValue'); +jest.mock('@components/Search/hooks/useFilterTaxRateValue'); +jest.mock('@components/Search/SearchContext', () => ({useSearchQueryContext: jest.fn(() => ({currentSearchQueryJSON: undefined}))})); +jest.mock('@expensify/react-native-hybrid-app', () => ({__esModule: true, default: {isHybridApp: jest.fn(() => false)}})); +jest.mock('@hooks/useAutoFocusInput', () => jest.fn(() => ({inputCallbackRef: jest.fn()}))); +jest.mock('@hooks/useCurrencyList', () => ({useCurrencyListActions: jest.fn(() => ({convertToDisplayStringWithoutCurrency: jest.fn()}))})); +jest.mock('@hooks/useLocalize'); +jest.mock('@hooks/useOnyx'); +jest.mock('@hooks/useThemeStyles', () => jest.fn(() => ({}))); +const cards = createMock({}); +cards[12] = createMock({cardID: 12, bank: CONST.COMPANY_CARD.FEED_BANK_NAME.UPLOAD, state: CONST.EXPENSIFY_CARD.STATE.OPEN, nameValuePairs: {cardTitle: 'Selected Alpha'}}); +cards[23] = createMock({cardID: 23, bank: CONST.COMPANY_CARD.FEED_BANK_NAME.UPLOAD, state: CONST.EXPENSIFY_CARD.STATE.OPEN, nameValuePairs: {cardTitle: 'Selected Beta'}}); +cards[123] = createMock({cardID: 123, bank: CONST.COMPANY_CARD.FEED_BANK_NAME.UPLOAD, state: CONST.EXPENSIFY_CARD.STATE.OPEN, nameValuePairs: {cardTitle: 'Unselected Overlap'}}); +let form: Partial; +jest.mocked(useLocalize).mockReturnValue(createMock>({translate: translateLocal, localeCompare: (a, b) => a.localeCompare(b)})); +jest.mocked(useFilterFeedValue).mockImplementation((value) => `feed:${value?.join('|')}`); +jest.mocked(useFilterTaxRateValue).mockImplementation((value) => `tax:${value.join('|')}`); +jest.mocked(useOnyx).mockImplementation((key) => { + switch (key) { + case ONYXKEYS.FORMS.SEARCH_ADVANCED_FILTERS_FORM: + return [form, {status: 'loaded'}]; + case ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST: + return [cards, {status: 'loaded'}]; + default: + return [undefined, {status: 'loaded'}]; + } +}); +beforeEach(() => jest.clearAllMocks()); +it.each([[['12']], [['12', '23']], [['123']]])('renders canonical card selection %j exactly', (cardID) => { + form = {cardID, feed: ['feed-a', 'feed-b'], taxRate: ['tax-a', 'tax-b'], merchant: 'Coffee Shop'}; + const output = JSON.stringify(render().toJSON()); + const expectedDescriptions = cardID.at(0) === '123' ? [false, false, true] : [true, cardID.length === 2, false]; + expect(['Selected Alpha', 'Selected Beta', 'Unselected Overlap'].map((text) => output.includes(text))).toEqual(expectedDescriptions); + expect(['12', '23', '123'].map((rawID) => output.includes(rawID))).toEqual([false, false, false]); + expect(jest.mocked(useFilterFeedValue)).toHaveBeenCalledWith(['feed-a', 'feed-b']); + expect(jest.mocked(useFilterTaxRateValue)).toHaveBeenCalledWith(['tax-a', 'tax-b']); + expect(['feed:feed-a|feed-b', 'tax:tax-a|tax-b', 'Coffee Shop'].every((text) => output.includes(text))).toBe(true); +}); +it('renders scalar feed and tax values without calling array display hooks', () => { + jest.spyOn(SearchUIUtils, 'mapFiltersFormToLabelValueList').mockReturnValueOnce([ + {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.FEED, label: 'Feed', value: 'feed-scalar'}, + {key: CONST.SEARCH.SYNTAX_FILTER_KEYS.TAX_RATE, label: 'Tax', value: 'tax-scalar'}, + ]); + const output = JSON.stringify(render().toJSON()); + expect(['feed-scalar', 'tax-scalar'].every((text) => output.includes(text))).toBe(true); + expect([jest.mocked(useFilterFeedValue).mock.calls.length, jest.mocked(useFilterTaxRateValue).mock.calls.length]).toEqual([0, 0]); +}); diff --git a/tests/unit/pages/USDVerifiedBankAccountFlowPageTest.tsx b/tests/unit/pages/USDVerifiedBankAccountFlowPageTest.tsx new file mode 100644 index 000000000000..938bcff02d94 --- /dev/null +++ b/tests/unit/pages/USDVerifiedBankAccountFlowPageTest.tsx @@ -0,0 +1,48 @@ +import {render} from '@testing-library/react-native'; + +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {ReimbursementAccountNavigatorParamList} from '@libs/Navigation/types'; + +import BankInfo from '@pages/ReimbursementAccount/USD/BankInfo/BankInfo'; +import Country from '@pages/ReimbursementAccount/USD/Country'; +import USDVerifiedBankAccountFlowPage from '@pages/ReimbursementAccount/USD/USDVerifiedBankAccountFlowPage'; + +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; + +import createMock from '../../utils/createMock'; + +jest.mock('@hooks/useOnyx', () => jest.fn(() => [undefined])); +jest.mock('@hooks/useThemeStyles', () => jest.fn(() => ({flex1: {}, appBG: {}}))); +jest.mock('@expensify/react-native-hybrid-app', () => ({__esModule: true, default: {isHybridApp: jest.fn(() => false)}})); +jest.mock('@libs/Navigation/Navigation', () => ({navigate: jest.fn(), goBack: jest.fn()})); +jest.mock('@pages/ReimbursementAccount/USD/BankInfo/BankInfo', () => jest.fn(() => null)); +jest.mock('@pages/ReimbursementAccount/USD/Country', () => jest.fn(() => null)); +const [mockBankInfo, mockCountry] = [jest.mocked(BankInfo), jest.mocked(Country)]; +type PageProps = PlatformStackScreenProps; +function renderPage(params: PageProps['route']['params']) { + const props = {route: createMock({params}), navigation: createMock({})} satisfies PageProps; + return render(); +} +it('preserves policy-less, valid-policy, and Country-to-Plaid routing behavior', () => { + renderPage({page: CONST.BANK_ACCOUNT.PAGE_NAMES.BANK_ACCOUNT}); + renderPage({policyID: '', page: CONST.BANK_ACCOUNT.PAGE_NAMES.BANK_ACCOUNT}); + renderPage({policyID: 'policy-1', page: CONST.BANK_ACCOUNT.PAGE_NAMES.BANK_ACCOUNT}); + const [{policyID: absent}, {policyID: empty}, props] = mockBankInfo.mock.calls.map(([callProps]) => callProps); + if (!props) { + throw new Error('Expected the selected BankInfo child to render'); + } + expect([absent, empty, props.policyID, typeof props.onSubmit, typeof props.onBackButtonPress]).toEqual([undefined, '', 'policy-1', 'function', 'function']); + renderPage({policyID: 'policy-1'}); + const countryProps = mockCountry.mock.calls.at(0)?.at(0); + if (!countryProps?.onSubmit) { + throw new Error('Expected the default Country child with a submit callback'); + } + expect([countryProps.stepNames, countryProps.policyID]).toEqual([CONST.BANK_ACCOUNT.STEP_NAMES, 'policy-1']); + countryProps.onSubmit(); + expect(jest.mocked(Navigation.navigate)).toHaveBeenCalledWith( + ROUTES.BANK_ACCOUNT_USD_SETUP.getRoute({policyID: 'policy-1', page: CONST.BANK_ACCOUNT.PAGE_NAMES.BANK_ACCOUNT, subPage: CONST.BANK_ACCOUNT.BANK_INFO_STEP.SUB_PAGE_NAMES.PLAID}), + ); +}); diff --git a/tests/unit/pages/settings/ReportDetailsColumnsPageTest.tsx b/tests/unit/pages/settings/ReportDetailsColumnsPageTest.tsx new file mode 100644 index 000000000000..5282ebb121da --- /dev/null +++ b/tests/unit/pages/settings/ReportDetailsColumnsPageTest.tsx @@ -0,0 +1,60 @@ +import {render} from '@testing-library/react-native'; + +import ColumnsSettingsList from '@components/ColumnsSettingsList'; + +import useOnyx from '@hooks/useOnyx'; + +import {setReportDetailsColumns} from '@libs/actions/ReportLayout'; +import Navigation from '@libs/Navigation/Navigation'; + +import ReportDetailsColumnsPage from '@pages/settings/Report/ReportDetailsColumnsPage'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import type * as ReactNavigation from '@react-navigation/native'; + +import {useRoute} from '@react-navigation/native'; +import React from 'react'; + +jest.mock('@components/ColumnsSettingsList', () => jest.fn(() => null)); +jest.mock('@hooks/useCurrentUserPersonalDetails', () => jest.fn(() => ({accountID: 1}))); +jest.mock('@hooks/useOnyx', () => jest.fn()); +jest.mock('@expensify/react-native-hybrid-app', () => ({__esModule: true, default: {isHybridApp: jest.fn(() => false)}})); +jest.mock('@libs/actions/ReportLayout', () => ({setReportDetailsColumns: jest.fn()})); +jest.mock('@libs/Navigation/Navigation', () => ({goBack: jest.fn()})); +jest.mock('@react-navigation/native', () => ({...jest.requireActual('@react-navigation/native'), useRoute: jest.fn()})); +let mockSavedColumns: string[] | undefined; +function renderColumns(savedColumns: string[] | undefined) { + mockSavedColumns = savedColumns; + render(); + const props = jest.mocked(ColumnsSettingsList).mock.calls.at(-1)?.at(0); + if (!props) { + throw new Error('Expected the rendered columns-list boundary'); + } + return props; +} +jest.mocked(useRoute).mockReturnValue({key: 'columns', name: 'columns', params: {reportID: 'report-1'}}); +jest.mocked(useOnyx).mockImplementation((key) => { + switch (key) { + case ONYXKEYS.NVP_REPORT_DETAILS_COLUMNS: + return [mockSavedColumns, {status: 'loaded'}]; + case ONYXKEYS.COLLECTION.TRANSACTION: + return [[], {status: 'loaded'}]; + default: + return [undefined, {status: 'loaded'}]; + } +}); +it('filters unsupported saved columns, defaults invalid-only storage, and preserves save behavior', () => { + const mixedProps = renderColumns(['invalid', CONST.SEARCH.TABLE_COLUMNS.MERCHANT, 'foreign', CONST.SEARCH.TABLE_COLUMNS.DATE]); + expect(mixedProps.currentColumns).toEqual([CONST.SEARCH.TABLE_COLUMNS.MERCHANT, CONST.SEARCH.TABLE_COLUMNS.DATE]); + const defaultProps = renderColumns(['invalid', 'foreign']); + expect([defaultProps.currentColumns, defaultProps.currentColumns.includes(CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT)]).toEqual([defaultProps.defaultSelectedColumns, true]); + const savedColumns = [CONST.SEARCH.TABLE_COLUMNS.DATE, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT]; + const props = renderColumns(savedColumns); + expect([props.currentColumns, jest.mocked(setReportDetailsColumns).mock.calls.length]).toEqual([mockSavedColumns, 0]); + props.onSave(savedColumns); + props.onSave([CONST.SEARCH.TABLE_COLUMNS.MERCHANT, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT]); + expect(jest.mocked(setReportDetailsColumns)).toHaveBeenCalledWith([CONST.SEARCH.TABLE_COLUMNS.MERCHANT, CONST.SEARCH.TABLE_COLUMNS.TOTAL_AMOUNT], mockSavedColumns); + expect(jest.mocked(Navigation.goBack)).toHaveBeenCalledTimes(2); +}); diff --git a/tests/unit/pages/workspace/accounting/certinia/CertiniaPrerequisitesStepTest.tsx b/tests/unit/pages/workspace/accounting/certinia/CertiniaPrerequisitesStepTest.tsx new file mode 100644 index 000000000000..4e4c4c390400 --- /dev/null +++ b/tests/unit/pages/workspace/accounting/certinia/CertiniaPrerequisitesStepTest.tsx @@ -0,0 +1,70 @@ +import {render} from '@testing-library/react-native'; + +import Button from '@components/ButtonComposed'; +import type {ScrollViewProps} from '@components/ScrollView'; +import TextLink from '@components/TextLink'; + +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; + +import CertiniaPrerequisitesStep from '@pages/workspace/accounting/certinia/prerequisites/CertiniaPrerequisitesStep'; + +import CONST from '@src/CONST'; + +import React from 'react'; + +import createMock from '../../../../../utils/createMock'; +import {translateLocal} from '../../../../../utils/TestHelper'; + +jest.mock('@components/ButtonComposed', () => ({ + __esModule: true, + default: Object.assign( + jest.fn(() => null), + {KeyboardShortcut: jest.fn(() => null), Text: jest.fn(() => null)}, + ), +})); +jest.mock('@components/FixedFooter', () => jest.fn((props: React.PropsWithChildren) => props.children)); +jest.mock('@components/ScrollView', () => jest.fn((props: ScrollViewProps) => props.children)); +jest.mock('@components/TextLink', () => jest.fn(() => null)); +jest.mock('@expensify/react-native-hybrid-app', () => ({__esModule: true, default: {isHybridApp: jest.fn(() => false)}})); +jest.mock('@hooks/useLocalize', () => jest.fn()); +jest.mock('@hooks/useNetwork', () => jest.fn()); +jest.mock('@hooks/useThemeStyles', () => jest.fn(() => ({}))); +const mockLocalize = createMock>({translate: translateLocal}); +const [mockTranslate, mockOnNext, mockOnConnect] = [jest.spyOn(mockLocalize, 'translate'), jest.fn(), jest.fn()]; +const baseProps = {isEditing: false, onNext: mockOnNext, onMove: jest.fn(), onConnect: mockOnConnect, isSandbox: true}; +function renderStep(currentPageName?: string) { + render(React.createElement(CertiniaPrerequisitesStep, {...baseProps, currentPageName})); + const buttonProps = jest.mocked(Button).mock.calls.at(-1)?.[0]; + if (!buttonProps) { + throw new Error('Expected the rendered prerequisite action'); + } + return buttonProps; +} +jest.mocked(useLocalize).mockReturnValue(mockLocalize); +jest.mocked(useNetwork).mockReturnValue({isOffline: false}); +beforeEach(() => jest.clearAllMocks()); +it.each([undefined, 'foreign', CONST.CERTINIA_PREREQUISITES.PAGE_NAME.INSTALL_BUNDLE])('uses the intentional complete install-bundle fallback for %s', (pageName) => { + const button = renderStep(pageName); + expect(mockTranslate).toHaveBeenCalledWith('workspace.certinia.prerequisites.installBundle'); + expect(mockTranslate).toHaveBeenCalledWith('workspace.certinia.prerequisites.installBundleDescription'); + expect(mockTranslate).toHaveBeenCalledWith('workspace.certinia.prerequisites.installBundleConfirm'); + expect(mockTranslate).toHaveBeenCalledWith('workspace.certinia.prerequisites.installBundlePSALink', {version: CONST.CERTINIA_PSA_BUNDLE_VERSION}); + expect(mockTranslate).toHaveBeenCalledWith('workspace.certinia.prerequisites.installBundleFFALink', {version: CONST.CERTINIA_FFA_BUNDLE_VERSION}); + expect(jest.mocked(TextLink).mock.calls.map(([props]) => props.href)).toEqual([CONST.CERTINIA_PSA_BUNDLE_INSTALL_URL.SANDBOX, CONST.CERTINIA_FFA_BUNDLE_INSTALL_URL.SANDBOX]); + expect(button.onPress).toBe(mockOnNext); +}); +it('preserves setup-contact and OAuth content, actions, and offline state', () => { + const setupButton = renderStep(CONST.CERTINIA_PREREQUISITES.PAGE_NAME.SETUP_CONTACTS); + expect(mockTranslate).toHaveBeenCalledWith('workspace.certinia.prerequisites.setupContacts'); + expect(mockTranslate).toHaveBeenCalledWith('workspace.certinia.prerequisites.setupContactsBullet1'); + expect(mockTranslate).toHaveBeenCalledWith('workspace.certinia.prerequisites.setupContactsBullet2'); + expect(mockTranslate).toHaveBeenCalledWith('workspace.certinia.prerequisites.setupContactsBullet3'); + expect(mockTranslate).toHaveBeenCalledWith('workspace.certinia.prerequisites.setupContactsConfirm'); + expect(setupButton.onPress).toBe(mockOnNext); + jest.mocked(useNetwork).mockReturnValue({isOffline: true}); + const oauthButton = renderStep(CONST.CERTINIA_PREREQUISITES.PAGE_NAME.OAUTH); + expect(mockTranslate).toHaveBeenCalledWith('workspace.certinia.prerequisites.oauthDescription'); + expect(mockTranslate).toHaveBeenCalledWith('workspace.certinia.prerequisites.connectButton'); + expect([oauthButton.onPress, oauthButton.isDisabled]).toEqual([mockOnConnect, true]); +});