Skip to content
Open
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
4 changes: 4 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,9 @@ const ONYXKEYS = {
/** Keeps track of whether the "Confirm Navigate to Expensify Classic" modal is opened */
IS_OPEN_CONFIRM_NAVIGATE_EXPENSIFY_CLASSIC_MODAL_OPEN: 'IsOpenConfirmNavigateExpensifyClassicModalOpen',

/** The transaction IDs to be highlighted when opening the Expenses search route page */
TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE: 'transactionIdsHighlightOnSearchRoute',

/** Collection Keys */
COLLECTION: {
DOMAIN: 'domain_',
Expand Down Expand Up @@ -1378,6 +1381,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.HAS_DENIED_CONTACT_IMPORT_PROMPT]: boolean | undefined;
[ONYXKEYS.IS_OPEN_CONFIRM_NAVIGATE_EXPENSIFY_CLASSIC_MODAL_OPEN]: boolean;
[ONYXKEYS.PERSONAL_POLICY_ID]: string;
[ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE]: Record<string, Record<string, boolean>>;
};

type OnyxDerivedValuesMapping = {
Expand Down
48 changes: 45 additions & 3 deletions src/hooks/useSearchHighlightAndScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,13 @@ import type {SearchListItem, SelectionListHandle, TransactionGroupListItemType,
import {search} from '@libs/actions/Search';
import {isReportActionEntry} from '@libs/SearchUIUtils';
import type {SearchKey} from '@libs/SearchUIUtils';
import {mergeTransactionIdsHighlightOnSearchRoute} from '@libs/TransactionUtils';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {ReportActions, SearchResults, Transaction} from '@src/types/onyx';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import useNetwork from './useNetwork';
import useOnyx from './useOnyx';
import usePrevious from './usePrevious';

type UseSearchHighlightAndScroll = {
Expand Down Expand Up @@ -52,6 +55,12 @@ function useSearchHighlightAndScroll({
const hasPendingSearchRef = useRef(false);
const isChat = queryJSON.type === CONST.SEARCH.DATA_TYPES.CHAT;

const transactionIDsToHighlightSelector = useCallback((allTransactionIDs: OnyxEntry<Record<string, Record<string, boolean>>>) => allTransactionIDs?.[queryJSON.type], [queryJSON.type]);
const [transactionIDsToHighlight] = useOnyx(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, {
canBeMissing: true,
selector: transactionIDsToHighlightSelector,
});

const existingSearchResultIDs = useMemo(() => {
if (!searchResults?.data) {
return [];
Expand Down Expand Up @@ -201,11 +210,20 @@ function useSearchHighlightAndScroll({
} else {
const previousTransactionIDs = extractTransactionIDsFromSearchResults(previousSearchResults);
const currentTransactionIDs = extractTransactionIDsFromSearchResults(searchResults.data);
const manualHighlightTransactionIDs = new Set(Object.keys(transactionIDsToHighlight ?? {}).filter((id) => !!transactionIDsToHighlight?.[id]));

// Find new transaction IDs that are not in the previousTransactionIDs and not already highlighted
const newTransactionIDs = currentTransactionIDs.filter((id) => !previousTransactionIDs.includes(id) && !highlightedIDs.current.has(id));
const newTransactionIDs = currentTransactionIDs.filter((id) => {
if (manualHighlightTransactionIDs.has(id)) {
return true;
}
if (!triggeredByHookRef.current || !hasNewItemsRef.current) {
return false;
}
return !previousTransactionIDs.includes(id) && !highlightedIDs.current.has(id);
});

if (!triggeredByHookRef.current || newTransactionIDs.length === 0 || !hasNewItemsRef.current) {
if (newTransactionIDs.length === 0) {
return;
}

Expand All @@ -217,7 +235,31 @@ function useSearchHighlightAndScroll({
}
setNewSearchResultKeys(newKeys);
}
}, [searchResults?.data, previousSearchResults, isChat]);
}, [searchResults?.data, previousSearchResults, isChat, transactionIDsToHighlight]);

// Reset transactionIDsToHighlight after they have been highlighted
useEffect(() => {
if (isEmptyObject(transactionIDsToHighlight) || newSearchResultKeys === null) {
return;
}

const highlightedTransactionIDs = Object.keys(transactionIDsToHighlight).filter(
(id) => transactionIDsToHighlight[id] && newSearchResultKeys?.has(`${ONYXKEYS.COLLECTION.TRANSACTION}${id}`),
);

const timer = setTimeout(() => {
mergeTransactionIdsHighlightOnSearchRoute(queryJSON.type, Object.fromEntries(highlightedTransactionIDs.map((id) => [id, false])));
}, CONST.ANIMATED_HIGHLIGHT_START_DURATION);
return () => clearTimeout(timer);
}, [transactionIDsToHighlight, queryJSON.type, newSearchResultKeys]);

// Remove transactionIDsToHighlight when the user leaves the current search type
useEffect(
() => () => {
mergeTransactionIdsHighlightOnSearchRoute(queryJSON.type, null);
},
[queryJSON.type],
);

// Reset newSearchResultKey after it's been used
useEffect(() => {
Expand Down
7 changes: 7 additions & 0 deletions src/libs/TransactionUtils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ import type {Attendee, Participant, SplitExpense} from '@src/types/onyx/IOU';
import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon';
import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails';
import type {OnyxData} from '@src/types/onyx/Request';
import type {SearchDataTypes} from '@src/types/onyx/SearchResults';
import type {
Comment,
Receipt,
Expand Down Expand Up @@ -2460,6 +2461,11 @@ function shouldReuseInitialTransaction(
return !isMultiScanEnabled || (transactions.length === 1 && (!initialTransaction.receipt?.source || initialTransaction.receipt?.isTestReceipt === true));
}

function mergeTransactionIdsHighlightOnSearchRoute(type: SearchDataTypes, data: Record<string, boolean> | null) {

Choose a reason for hiding this comment

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

❌ CONSISTENCY-5 (docs)

This ESLint rule disable lacks a clear justification comment.

Why this matters: ESLint rule disables without justification can mask underlying issues and reduce code quality. Clear documentation ensures team members understand exceptions, promoting better maintainability.

Suggested fix: Add a comment explaining why the rule needs to be disabled. For example:

function mergeTransactionIdsHighlightOnSearchRoute(type: SearchDataTypes, data: Record<string, boolean> | null) {
    // eslint-disable-next-line rulesdir/prefer-actions-set-data
    // We use Onyx.merge here instead of actions/setData because this is a utility function
    // that needs to be called from multiple places without circular dependencies
    return Onyx.merge(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, {[type]: data});
}

Please rate this suggestion with 👍 or 👎 to help us improve! Reactions are used to monitor reviewer efficiency.

// eslint-disable-next-line rulesdir/prefer-actions-set-data
return Onyx.merge(ONYXKEYS.TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, {[type]: data});
}

export {
buildOptimisticTransaction,
calculateTaxAmount,
Expand Down Expand Up @@ -2587,6 +2593,7 @@ export {
getOriginalAmountForDisplay,
getOriginalCurrencyForDisplay,
shouldShowExpenseBreakdown,
mergeTransactionIdsHighlightOnSearchRoute,
};

export type {TransactionChanges};
17 changes: 9 additions & 8 deletions src/libs/actions/IOU/SendInvoice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,6 @@ import DateUtils from '@libs/DateUtils';
import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils';
import {formatPhoneNumber} from '@libs/LocalePhoneNumber';
import Log from '@libs/Log';
import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute';
import Navigation from '@libs/Navigation/Navigation';
import {getReportActionHtml, getReportActionText} from '@libs/ReportActionsUtils';
import type {OptimisticChatReport, OptimisticCreatedReportAction, OptimisticIOUReportAction} from '@libs/ReportUtils';
import {
Expand All @@ -34,7 +32,7 @@ import type {InvoiceReceiver, InvoiceReceiverType} from '@src/types/onyx/Report'
import type {OnyxData} from '@src/types/onyx/Request';
import type {Receipt} from '@src/types/onyx/Transaction';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import {getAllPersonalDetails, getReceiptError, getSearchOnyxUpdate, mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies} from '.';
import {getAllPersonalDetails, getReceiptError, getSearchOnyxUpdate, handleNavigateAfterExpenseCreate, mergePolicyRecentlyUsedCategories, mergePolicyRecentlyUsedCurrencies} from '.';
import type {BasePolicyParams} from '.';

type SendInvoiceInformation = {
Expand Down Expand Up @@ -65,6 +63,7 @@ type SendInvoiceOptions = {
companyWebsite?: string;
policyRecentlyUsedCategories?: OnyxEntry<OnyxTypes.RecentlyUsedCategories>;
policyRecentlyUsedTags?: OnyxEntry<OnyxTypes.RecentlyUsedTags>;
isFromGlobalCreate?: boolean;
};

type BuildOnyxDataForInvoiceParams = {
Expand Down Expand Up @@ -675,6 +674,7 @@ function sendInvoice({
companyWebsite,
policyRecentlyUsedCategories,
policyRecentlyUsedTags,
isFromGlobalCreate,
}: SendInvoiceOptions) {
const parsedComment = getParsedComment(transaction?.comment?.comment?.trim() ?? '');
if (transaction?.comment) {
Expand Down Expand Up @@ -738,11 +738,12 @@ function sendInvoice({
// eslint-disable-next-line @typescript-eslint/no-deprecated
InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID));

if (isSearchTopmostFullScreenRoute()) {
Navigation.dismissModal();
} else {
Navigation.dismissModalWithReport({reportID: invoiceRoom.reportID});
}
handleNavigateAfterExpenseCreate({
activeReportID: invoiceRoom.reportID,
transactionID,
isFromGlobalCreate,
isInvoice: true,
});

notifyNewAction(invoiceRoom.reportID, currentUserAccountID);
}
Expand Down
89 changes: 77 additions & 12 deletions src/libs/actions/IOU/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import * as Localize from '@libs/Localize';
import Log from '@libs/Log';
import {validateAmount} from '@libs/MoneyRequestUtils';
import isReportOpenInRHP from '@libs/Navigation/helpers/isReportOpenInRHP';
import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator';
import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute';
import Navigation, {navigationRef} from '@libs/Navigation/Navigation';
import {isOffline} from '@libs/Network/NetworkStore';
Expand Down Expand Up @@ -199,7 +200,7 @@ import {
shouldEnableNegative,
updateReportPreview,
} from '@libs/ReportUtils';
import {getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils';
import {buildCannedSearchQuery, getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils';
import {getSuggestedSearches} from '@libs/SearchUIUtils';
import playSound, {SOUNDS} from '@libs/Sound';
import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils';
Expand Down Expand Up @@ -230,6 +231,7 @@ import {
isPerDiemRequest as isPerDiemRequestTransactionUtils,
isScanning,
isScanRequest as isScanRequestTransactionUtils,
mergeTransactionIdsHighlightOnSearchRoute,
removeTransactionFromDuplicateTransactionViolation,
} from '@libs/TransactionUtils';
import ViolationsUtils from '@libs/Violations/ViolationsUtils';
Expand Down Expand Up @@ -281,6 +283,7 @@ type BaseTransactionParams = {
billable?: boolean;
reimbursable?: boolean;
customUnitRateID?: string;
isFromGlobalCreate?: boolean;
};

type InitMoneyRequestParams = {
Expand Down Expand Up @@ -652,6 +655,7 @@ type TrackExpenseTransactionParams = {
customUnitRateID?: string;
attendees?: Attendee[];
isLinkedTrackedExpenseReportArchived?: boolean;
isFromGlobalCreate?: boolean;
};

type TrackExpenseAccountantParams = {
Expand Down Expand Up @@ -950,9 +954,9 @@ Onyx.connect({
* If the action is done from the report RHP, then we just want to dismiss the money request flow screens.
* It is a helper function used only in this file.
*/
function dismissModalAndOpenReportInInboxTab(reportID?: string) {
function dismissModalAndOpenReportInInboxTab(reportID?: string, isInvoice?: boolean) {
const rootState = navigationRef.getRootState();
if (isReportOpenInRHP(rootState)) {
if (!isInvoice && isReportOpenInRHP(rootState)) {
const rhpKey = rootState.routes.at(-1)?.state?.key;
if (rhpKey) {
const hasMultipleTransactions = Object.values(allTransactions).filter((transaction) => transaction?.reportID === reportID).length > 0;
Expand All @@ -975,6 +979,52 @@ function dismissModalAndOpenReportInInboxTab(reportID?: string) {
Navigation.dismissModalWithReport({reportID});
}

/**
* Helper to navigate after an expense is created in order to standardize the post‑creation experience
* when creating an expense from the global create button.
* If the expense is created from the global create button then:
* - If it is created on the inbox tab, it will open the chat report containing that expense.
* - If it is created elsewhere, it will navigate to Reports > Expense and highlight the newly created expense.
*/
function handleNavigateAfterExpenseCreate({
activeReportID,
transactionID,
isFromGlobalCreate,
isInvoice,
shouldHandleNavigation = true,
}: {
activeReportID?: string;
transactionID?: string;
isFromGlobalCreate?: boolean;
isInvoice?: boolean;
shouldHandleNavigation?: boolean;
}) {
const isUserOnInbox = isReportTopmostSplitNavigator();

// If the expense is not created from global create or is currently on the inbox tab,
// we just need to dismiss the money request flow screens
// and open the report chat containing the IOU report
if (!isFromGlobalCreate || isUserOnInbox || !transactionID) {
if (shouldHandleNavigation) {
dismissModalAndOpenReportInInboxTab(activeReportID, isInvoice);
}
return;
}

const type = isInvoice ? CONST.SEARCH.DATA_TYPES.INVOICE : CONST.SEARCH.DATA_TYPES.EXPENSE;
// We mark this transaction to be highlighted when opening the expense search route page
mergeTransactionIdsHighlightOnSearchRoute(type, {[transactionID]: true});

if (!shouldHandleNavigation) {
return;
}
const queryString = buildCannedSearchQuery({type});
Navigation.dismissModal();
Navigation.setNavigationActionToMicrotaskQueue(() => {
Navigation.navigate(ROUTES.SEARCH_ROOT.getRoute({query: queryString}));
});
}

/**
* Find the report preview action from given chat report and iou report
*/
Expand Down Expand Up @@ -5808,6 +5858,7 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep
customUnitRateID,
isTestDrive,
isLinkedTrackedExpenseReportArchived,
isFromGlobalCreate,
} = transactionParams;

const testDriveCommentReportActionID = isTestDrive ? NumberUtils.rand64() : undefined;
Expand Down Expand Up @@ -5994,16 +6045,22 @@ function requestMoney(requestMoneyInformation: RequestMoneyInformation): {iouRep
if (shouldHandleNavigation) {
// eslint-disable-next-line @typescript-eslint/no-deprecated
InteractionManager.runAfterInteractions(() => removeDraftTransactions());
if (!requestMoneyInformation.isRetry) {
dismissModalAndOpenReportInInboxTab(backToReport ?? activeReportID);
}

const trackReport = Navigation.getReportRouteByID(linkedTrackedExpenseReportAction?.childReportID);
if (trackReport?.key) {
Navigation.removeScreenByKey(trackReport.key);
}
}

if (!requestMoneyInformation.isRetry) {
handleNavigateAfterExpenseCreate({
activeReportID: backToReport ?? activeReportID,
transactionID: transaction.transactionID,
isFromGlobalCreate,
shouldHandleNavigation,
});
}

if (activeReportID && !isMoneyRequestReport) {
Navigation.setNavigationActionToMicrotaskQueue(() =>
setTimeout(() => {
Expand Down Expand Up @@ -6032,7 +6089,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf
policyRecentlyUsedCurrencies,
} = submitPerDiemExpenseInformation;
const {payeeAccountID} = participantParams;
const {currency, comment = '', category, tag, created, customUnit, attendees} = transactionParams;
const {currency, comment = '', category, tag, created, customUnit, attendees, isFromGlobalCreate} = transactionParams;

if (
isEmptyObject(policyParams.policy) ||
Expand Down Expand Up @@ -6117,7 +6174,7 @@ function submitPerDiemExpense(submitPerDiemExpenseInformation: PerDiemExpenseInf

// eslint-disable-next-line @typescript-eslint/no-deprecated
InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID));
dismissModalAndOpenReportInInboxTab(activeReportID);
handleNavigateAfterExpenseCreate({activeReportID, transactionID: transaction.transactionID, isFromGlobalCreate});

if (activeReportID) {
notifyNewAction(activeReportID, payeeAccountID);
Expand Down Expand Up @@ -6170,6 +6227,7 @@ function trackExpense(params: CreateTrackExpenseParams) {
linkedTrackedExpenseReportID,
customUnitRateID,
attendees,
isFromGlobalCreate,
} = transactionData;
const isMoneyRequestReport = isMoneyRequestReportReportUtils(report);
const currentChatReport = isMoneyRequestReport ? getReportOrDraftReport(report?.chatReportID) : report;
Expand Down Expand Up @@ -6442,10 +6500,15 @@ function trackExpense(params: CreateTrackExpenseParams) {
if (shouldHandleNavigation) {
// eslint-disable-next-line @typescript-eslint/no-deprecated
InteractionManager.runAfterInteractions(() => removeDraftTransactions());
}

if (!params.isRetry) {
dismissModalAndOpenReportInInboxTab(activeReportID);
}
if (!params.isRetry) {
handleNavigateAfterExpenseCreate({
activeReportID,
transactionID: transaction?.transactionID,
isFromGlobalCreate,
shouldHandleNavigation,
});
}

notifyNewAction(activeReportID, payeeAccountID);
Expand Down Expand Up @@ -8008,6 +8071,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest
splitShares = {},
attendees,
receipt,
isFromGlobalCreate,
} = transactionParams;

// If the report is an iou or expense report, we should get the linked chat report to be passed to the getMoneyRequestInformation function
Expand Down Expand Up @@ -8187,7 +8251,7 @@ function createDistanceRequest(distanceRequestInformation: CreateDistanceRequest
// eslint-disable-next-line @typescript-eslint/no-deprecated
InteractionManager.runAfterInteractions(() => removeDraftTransaction(CONST.IOU.OPTIMISTIC_TRANSACTION_ID));
const activeReportID = isMoneyRequestReport && report?.reportID ? report.reportID : parameters.chatReportID;
dismissModalAndOpenReportInInboxTab(backToReport ?? activeReportID);
handleNavigateAfterExpenseCreate({activeReportID: backToReport ?? activeReportID, isFromGlobalCreate, transactionID: parameters.transactionID});

if (!isMoneyRequestReport) {
notifyNewAction(activeReportID, userAccountID);
Expand Down Expand Up @@ -14757,6 +14821,7 @@ export {
getAllPersonalDetails,
getReceiptError,
getSearchOnyxUpdate,
handleNavigateAfterExpenseCreate,
};
export type {
GPSPoint as GpsPoint,
Expand Down
Loading
Loading