Skip to content
Merged
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
17 changes: 17 additions & 0 deletions src/libs/IOUUtils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import Onyx from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import type {IOUAction, IOUType} from '@src/CONST';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
Expand All @@ -9,6 +10,7 @@ import type {IOURequestType} from './actions/IOU';
import {getCurrencyUnit} from './CurrencyUtils';
import DateUtils from './DateUtils';
import Navigation from './Navigation/Navigation';
import Performance from './Performance';
import {getReportTransactions} from './ReportUtils';
import {getCurrency, getTagArrayFromName} from './TransactionUtils';

Expand Down Expand Up @@ -37,6 +39,20 @@ function navigateToStartMoneyRequestStep(requestType: IOURequestType, iouType: I
}
}

function navigateToParticipantPage(iouType: ValueOf<typeof CONST.IOU.TYPE>, transactionID: string, reportID: string) {
Performance.markStart(CONST.TIMING.OPEN_CREATE_EXPENSE_CONTACT);
switch (iouType) {
case CONST.IOU.TYPE.REQUEST:
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(CONST.IOU.TYPE.SUBMIT, transactionID, reportID));
break;
case CONST.IOU.TYPE.SEND:
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(CONST.IOU.TYPE.PAY, transactionID, reportID));
break;
default:
Navigation.navigate(ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute(iouType, transactionID, reportID));
}
}

/**
* Calculates the amount per user given a list of participants
*
Expand Down Expand Up @@ -210,4 +226,5 @@ export {
updateIOUOwnerAndTotal,
formatCurrentUserToAttendee,
shouldStartLocationPermissionFlow,
navigateToParticipantPage,
};
14 changes: 10 additions & 4 deletions src/libs/actions/IOU.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ import {
shouldCreateNewMoneyRequestReport as shouldCreateNewMoneyRequestReportReportUtils,
updateReportPreview,
} from '@libs/ReportUtils';
import {getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils';
import {buildCannedSearchQuery, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils';
import {getSession} from '@libs/SessionUtils';
import playSound, {SOUNDS} from '@libs/Sound';
import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils';
Expand All @@ -171,7 +171,6 @@ import {
getCurrency,
getDistanceInMeters,
getMerchant,
getTransaction,
getUpdatedTransaction,
hasAnyTransactionWithoutRTERViolation,
hasDuplicateTransactions,
Expand Down Expand Up @@ -803,8 +802,15 @@ Onyx.connect({
* It is a helper function used only in this file.
*/
function dismissModalAndOpenReportInInboxTab(reportID?: string) {
if (isSearchTopmostFullScreenRoute() || !reportID) {
const isSearchPageTopmostFullScreenRoute = isSearchTopmostFullScreenRoute();
if (isSearchPageTopmostFullScreenRoute || !reportID) {

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.

I think we should prevent this navigation on mobile. it would also affect the global create flow on search page, i wonder if we want that change.

@koko57 koko57 May 14, 2025

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.

For the Global Create - I think having two behaviors for Global Create and Create Expense after drop would be strange, especially since this Create Expense flow should work like it was opened from the Global Create (like it was mentioned in the docs) - but could you please LMK what do you think about it? @lakchote @dannymcclain @shawnborton @dubielzyk-expensify

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.

For the Global Create - I think having two behaviors for Global Create and Create Expense after drop would be strange, especially since this Create Expense flow should work like it was opened from the Global Create

I do agree that we should have a consistent behavior. Let's see what everyone thinks!

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.

@getusha for the mobile - looks like it doesn't cause any problem

Screen.Recording.2025-05-14.at.15.56.52.mp4

the only thing I've noticed that the query phrase looks strange when changing a tab (but it's from changing the tab)

Screen.Recording.2025-05-14.at.15.45.01.mp4

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.

the only thing I've noticed that the query phrase looks strange when changing a tab (but it's from changing the tab)

This can always be addressed in a follow-up PR as it's not related to this PR.

cc @luacmartins is this format of the search query expected or not?

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.

No, it's not. I think we have a bug reported for it somewhere. I can't find the link though 🤔

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.

ah here it is #61940

Navigation.dismissModal();
if (isSearchPageTopmostFullScreenRoute) {
const query = buildCannedSearchQuery();
InteractionManager.runAfterInteractions(() => {
Navigation.setParams({q: query});
});
}
return;
}
Navigation.dismissModalWithReport({reportID});
Expand Down Expand Up @@ -8205,7 +8211,7 @@ function getHoldReportActionsAndTransactions(reportID: string | undefined) {

Object.values(iouReportActions).forEach((action) => {
const transactionID = isMoneyRequestAction(action) ? getOriginalMessage(action)?.IOUTransactionID : undefined;
const transaction = getTransaction(transactionID);
const transaction = allTransactions?.[`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`];

if (transaction?.comment?.hold) {
holdReportActions.push(action as OnyxTypes.ReportAction<typeof CONST.REPORT.ACTIONS.TYPE.IOU>);
Expand Down
171 changes: 153 additions & 18 deletions src/pages/Search/SearchPage.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,20 @@
import {Str} from 'expensify-common';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {InteractionManager, View} from 'react-native';
import type {FileObject} from '@components/AttachmentModal';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import type {DropdownOption} from '@components/ButtonWithDropdownMenu/types';
import ConfirmModal from '@components/ConfirmModal';
import DecisionModal from '@components/DecisionModal';
import DragAndDropProvider from '@components/DragAndDrop/Provider';
import DropZoneUI from '@components/DropZoneUI';
import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import * as Expensicons from '@components/Icon/Expensicons';
import NavigationTabBar from '@components/Navigation/NavigationTabBar';
import NAVIGATION_TABS from '@components/Navigation/NavigationTabBar/NAVIGATION_TABS';
import TopBar from '@components/Navigation/TopBar';
import PDFThumbnail from '@components/PDFThumbnail';
import ScreenWrapper from '@components/ScreenWrapper';
import Search from '@components/Search';
import {useSearchContext} from '@components/Search/SearchContext';
Expand All @@ -21,6 +27,7 @@ import useActiveWorkspace from '@hooks/useActiveWorkspace';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
import usePermissions from '@hooks/usePermissions';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
Expand All @@ -36,14 +43,19 @@ import {
search,
unholdMoneyRequestOnSearch,
} from '@libs/actions/Search';
import {resizeImageIfNeeded, validateReceipt} from '@libs/fileDownload/FileUtils';
import {navigateToParticipantPage} from '@libs/IOUUtils';
import Navigation from '@libs/Navigation/Navigation';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types';
import {hasVBBA} from '@libs/PolicyUtils';
import {generateReportID} from '@libs/ReportUtils';
import {buildCannedSearchQuery, buildSearchQueryJSON} from '@libs/SearchQueryUtils';
import {isSearchDataLoaded} from '@libs/SearchUIUtils';
import variables from '@styles/variables';
import {initMoneyRequest, setMoneyRequestReceipt} from '@userActions/IOU';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
Expand All @@ -70,9 +82,17 @@ function SearchPage({route}: SearchPageProps) {
const [isDownloadErrorModalVisible, setIsDownloadErrorModalVisible] = useState(false);
const [isDeleteExpensesConfirmModalVisible, setIsDeleteExpensesConfirmModalVisible] = useState(false);
const [isDownloadExportModalVisible, setIsDownloadExportModalVisible] = useState(false);
// TODO: to be refactored in step 3
const [isAttachmentInvalid, setIsAttachmentInvalid] = useState(false);
const [attachmentInvalidReasonTitle, setAttachmentInvalidReasonTitle] = useState<TranslationPaths>();
const [attachmentInvalidReason, setAttachmentValidReason] = useState<TranslationPaths>();
const [pdfFile, setPdfFile] = useState<null | FileObject>(null);
const [isLoadingReceipt, setIsLoadingReceipt] = useState(false);

const {q, name} = route.params;

const {canUseMultiFilesDragAndDrop} = usePermissions();

const queryJSON = useMemo(() => buildSearchQueryJSON(q), [q]);

// eslint-disable-next-line rulesdir/no-default-id-values
Expand All @@ -93,6 +113,28 @@ function SearchPage({route}: SearchPageProps) {
const {status, hash} = queryJSON ?? {};
const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {});

// TODO: to be refactored in step 3
/**
* Sets the upload receipt error modal content when an invalid receipt is uploaded
*/
const setUploadReceiptError = (isInvalid: boolean, title: TranslationPaths, reason: TranslationPaths) => {
setIsAttachmentInvalid(isInvalid);
setAttachmentInvalidReasonTitle(title);
setAttachmentValidReason(reason);
setPdfFile(null);
};

// TODO: to be refactored in step 3
const getConfirmModalPrompt = () => {
if (!attachmentInvalidReason) {
return '';
}
if (attachmentInvalidReason === 'attachmentPicker.sizeExceededWithLimit') {
return translate(attachmentInvalidReason, {maxUploadSizeInMB: CONST.API_ATTACHMENT_VALIDATIONS.RECEIPT_MAX_SIZE / (1024 * 1024)});
}
return translate(attachmentInvalidReason);
};

const headerButtonsOptions = useMemo(() => {
if (selectedTransactionsKeys.length === 0 || !status || !hash) {
return [];
Expand Down Expand Up @@ -202,14 +244,24 @@ function SearchPage({route}: SearchPageProps) {
const lastPolicyPaymentMethod = getLastPolicyPaymentMethod(itemPolicyID, lastPaymentMethods);

if (!lastPolicyPaymentMethod) {
Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: item.reportID, backTo: activeRoute}));
Navigation.navigate(
ROUTES.SEARCH_REPORT.getRoute({
reportID: item.reportID,
backTo: activeRoute,
}),
);
return;
}

const hasPolicyVBBA = hasVBBA(itemPolicyID);

if (lastPolicyPaymentMethod !== CONST.IOU.PAYMENT_TYPE.ELSEWHERE && !hasPolicyVBBA) {
Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: item.reportID, backTo: activeRoute}));
Navigation.navigate(
ROUTES.SEARCH_REPORT.getRoute({
reportID: item.reportID,
backTo: activeRoute,
}),
);
return;
}
}
Expand Down Expand Up @@ -350,6 +402,50 @@ function SearchPage({route}: SearchPageProps) {
});
};

// TODO: to be refactored in step 3
const hideReceiptModal = () => {
setIsAttachmentInvalid(false);
};

// TODO: to be refactored in step 3
const setReceiptAndNavigate = (originalFile: FileObject, isPdfValidated?: boolean) => {
validateReceipt(originalFile, setUploadReceiptError).then((isFileValid) => {
if (!isFileValid) {
return;
}

// If we have a pdf file and if it is not validated then set the pdf file for validation and return
if (Str.isPDF(originalFile.name ?? '') && !isPdfValidated) {
setPdfFile(originalFile);
return;
}

// With the image size > 24MB, we use manipulateAsync to resize the image.
// It takes a long time so we should display a loading indicator while the resize image progresses.
if (Str.isImage(originalFile.name ?? '') && (originalFile?.size ?? 0) > CONST.API_ATTACHMENT_VALIDATIONS.MAX_SIZE) {
setIsLoadingReceipt(true);
}
resizeImageIfNeeded(originalFile).then((resizedFile) => {
setIsLoadingReceipt(false);
// Store the receipt on the transaction object in Onyx
const source = URL.createObjectURL(resizedFile as Blob);
const newReportID = generateReportID();
initMoneyRequest(newReportID, undefined, true, undefined, CONST.IOU.REQUEST_TYPE.SCAN);
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
setMoneyRequestReceipt(CONST.IOU.OPTIMISTIC_TRANSACTION_ID, source, resizedFile.name || '', true);
navigateToParticipantPage(CONST.IOU.TYPE.CREATE, CONST.IOU.OPTIMISTIC_TRANSACTION_ID, newReportID);
});
});
};

const initScanRequest = (e: DragEvent) => {
const file = e?.dataTransfer?.files[0];
if (file) {
file.uri = URL.createObjectURL(file);
setReceiptAndNavigate(file);
}
};

const createExportAll = useCallback(() => {
if (selectedTransactionsKeys.length === 0 || !status || !hash) {
return [];
Expand Down Expand Up @@ -378,6 +474,24 @@ function SearchPage({route}: SearchPageProps) {
const isDataLoaded = isSearchDataLoaded(currentSearchResults, lastNonEmptySearchResults, queryJSON);
const shouldShowLoadingState = !isOffline && !isDataLoaded;

// TODO: to be refactored in step 3
const PDFThumbnailView = pdfFile ? (
<PDFThumbnail
style={styles.invisiblePDF}
previewSourceURL={pdfFile.uri ?? ''}
onLoadSuccess={() => {
setPdfFile(null);
setReceiptAndNavigate(pdfFile, true);
}}
onPassword={() => {
setUploadReceiptError(true, 'attachmentPicker.attachmentError', 'attachmentPicker.protectedPDFNotSupported');
}}
onLoadError={() => {
setUploadReceiptError(true, 'attachmentPicker.attachmentError', 'attachmentPicker.errorWhileSelectingCorruptedAttachment');
}}
/>
) : null;

// Handles video player cleanup:
// 1. On mount: Resets player if navigating from report screen
// 2. On unmount: Stops video when leaving this screen
Expand Down Expand Up @@ -494,23 +608,44 @@ function SearchPage({route}: SearchPageProps) {
shouldShowOfflineIndicatorInWideScreen={!!shouldShowOfflineIndicator}
offlineIndicatorStyle={styles.mtAuto}
>
<SearchPageHeader
queryJSON={queryJSON}
headerButtonsOptions={headerButtonsOptions}
handleSearch={handleSearchAction}
/>
<SearchStatusBar
queryJSON={queryJSON}
headerButtonsOptions={headerButtonsOptions}
/>
<Search
key={queryJSON.hash}
queryJSON={queryJSON}
currentSearchResults={currentSearchResults}
lastNonEmptySearchResults={lastNonEmptySearchResults}
handleSearch={handleSearchAction}
/>
{isLoadingReceipt && <FullScreenLoadingIndicator />}
<DragAndDropProvider isDisabled={!canUseMultiFilesDragAndDrop}>
{PDFThumbnailView}
<SearchPageHeader
queryJSON={queryJSON}
headerButtonsOptions={headerButtonsOptions}
handleSearch={handleSearchAction}
/>
<SearchStatusBar
queryJSON={queryJSON}
headerButtonsOptions={headerButtonsOptions}
/>
<Search
key={queryJSON.hash}
queryJSON={queryJSON}
currentSearchResults={currentSearchResults}
lastNonEmptySearchResults={lastNonEmptySearchResults}
handleSearch={handleSearchAction}
/>
<DropZoneUI
onDrop={initScanRequest}
icon={Expensicons.SmartScan}
dropTitle={translate('dropzone.scanReceipts')}
dropStyles={styles.receiptDropOverlay}
dropTextStyles={styles.receiptDropText}
dropInnerWrapperStyles={styles.receiptDropInnerWrapper}
/>
</DragAndDropProvider>
</ScreenWrapper>
<ConfirmModal
title={attachmentInvalidReasonTitle ? translate(attachmentInvalidReasonTitle) : ''}
onConfirm={hideReceiptModal}
onCancel={hideReceiptModal}
isVisible={isAttachmentInvalid}
prompt={getConfirmModalPrompt()}
confirmText={translate('common.close')}
shouldShowCancelButton={false}
/>
</View>
)}
<ConfirmModal
Expand Down
Loading