Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
34b0c2f
Support transaction thread open and creation in different scenarios
VickyStash Aug 21, 2025
04bcace
Add createAndOpenTransactionThreadReport utility method
VickyStash Aug 22, 2025
e2ed2e3
Fix one transaction expense open from Reports tab
VickyStash Aug 22, 2025
7c0b4b8
Fix expense open from Reports->Chats tab
VickyStash Aug 22, 2025
4efff14
Merge branch 'main' into VickyStash/refactor/67890-reports-tab
VickyStash Aug 25, 2025
2421c3c
Update the approach for opening one transaction expenses from Reports…
VickyStash Aug 25, 2025
6513896
Merge branch 'main' into VickyStash/refactor/67890-reports-tab
VickyStash Aug 25, 2025
a2159e7
Merge branch 'main' into VickyStash/refactor/67890-reports-tab
VickyStash Aug 26, 2025
58ed817
Minor clean up after merging main
VickyStash Aug 26, 2025
c3a64b9
Rename function and add unit tests
VickyStash Aug 26, 2025
3ed7bd2
Merge branch 'main' into VickyStash/refactor/67890-reports-tab
VickyStash Aug 29, 2025
f08c1a2
Merge branch 'main' into VickyStash/refactor/67890-reports-tab
VickyStash Sep 3, 2025
f720ecd
Clean up after merging main
VickyStash Sep 3, 2025
72f3d23
Create createAndOpenSearchTransactionThread utility function
VickyStash Sep 4, 2025
fa00275
Minor improvement
VickyStash Sep 4, 2025
9e9658f
Add simple unit test
VickyStash Sep 4, 2025
9b1712e
Lint fix
VickyStash Sep 4, 2025
1b18e88
Merge branch 'main' into VickyStash/refactor/67890-reports-tab
VickyStash Sep 4, 2025
9da1fdf
Fixes
VickyStash Sep 4, 2025
bbffdeb
Fix tread open from Reports tab after re-login
VickyStash Sep 4, 2025
d0ac8f8
Merge branch 'main' into VickyStash/refactor/67890-reports-tab
VickyStash Sep 8, 2025
35e0050
Apply changes for benchmark PR
tomerqodo Dec 4, 2025
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
28 changes: 12 additions & 16 deletions src/components/Search/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useSearchHighlightAndScroll from '@hooks/useSearchHighlightAndScroll';
import useThemeStyles from '@hooks/useThemeStyles';
import {turnOffMobileSelectionMode, turnOnMobileSelectionMode} from '@libs/actions/MobileSelectionMode';
import {openSearch, updateSearchResultsWithTransactionThreadReportID} from '@libs/actions/Search';
import {openSearch} from '@libs/actions/Search';
import Timing from '@libs/actions/Timing';
import {canUseTouchScreen} from '@libs/DeviceCapabilities';
import Log from '@libs/Log';
Expand All @@ -29,6 +29,7 @@ import {getIOUActionForTransactionID, isExportIntegrationAction, isIntegrationMe
import {canEditFieldOfMoneyRequest, isArchivedReport} from '@libs/ReportUtils';
import {buildCannedSearchQuery, buildSearchQueryJSON, buildSearchQueryString} from '@libs/SearchQueryUtils';
import {
createAndOpenSearchTransactionThread,
getColumnsToShow,
getListItem,
getSections,
Expand All @@ -52,7 +53,6 @@ import {isOnHold, isTransactionPendingDelete} from '@libs/TransactionUtils';
import Navigation, {navigationRef} from '@navigation/Navigation';
import type {SearchFullscreenNavigatorParamList} from '@navigation/types';
import EmptySearchView from '@pages/Search/EmptySearchView';
import {createTransactionThreadReport} from '@userActions/Report';
import CONST from '@src/CONST';
import NAVIGATORS from '@src/NAVIGATORS';
import ONYXKEYS from '@src/ONYXKEYS';
Expand Down Expand Up @@ -595,6 +595,16 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
return;
}

const backTo = Navigation.getActiveRoute();
const isTransactionItem = isTransactionListItemType(item);

// If we're trying to open a transaction without a transaction thread, let's create the thread and navigate the user
if (isTransactionItem && item.transactionThreadReportID === CONST.REPORT.UNREPORTED_REPORT_ID) {
const iouReportAction = getIOUActionForTransactionID(reportActionsArray, item.transactionID);
createAndOpenSearchTransactionThread(item, iouReportAction, hash, backTo);
return;
}

if (isTransactionMemberGroupListItemType(item)) {
const newFlatFilters = queryJSON.flatFilters.filter((filter) => filter.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM);
newFlatFilters.push({key: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, filters: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: item.accountID}]});
Expand Down Expand Up @@ -635,7 +645,6 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
}

const isFromSelfDM = item.reportID === CONST.REPORT.UNREPORTED_REPORT_ID;
const isTransactionItem = isTransactionListItemType(item);

const reportID =
isTransactionItem && (!item.isFromOneTransactionReport || isFromSelfDM) && item.transactionThreadReportID !== CONST.REPORT.UNREPORTED_REPORT_ID
Expand All @@ -649,24 +658,11 @@ function Search({queryJSON, searchResults, onSearchListScroll, contentContainerS
Performance.markStart(CONST.TIMING.OPEN_REPORT_SEARCH);
Timing.start(CONST.TIMING.OPEN_REPORT_SEARCH);

const backTo = Navigation.getActiveRoute();

if (isTransactionGroupListItemType(item)) {
Navigation.navigate(ROUTES.SEARCH_MONEY_REQUEST_REPORT.getRoute({reportID, backTo}));
return;
}

// If we're trying to open a legacy transaction without a transaction thread, let's create the thread and navigate the user
if (isTransactionItem && reportID === CONST.REPORT.UNREPORTED_REPORT_ID) {
const iouReportAction = getIOUActionForTransactionID(reportActionsArray, item.transactionID);
const transactionThreadReport = createTransactionThreadReport(item.report, iouReportAction);
if (transactionThreadReport?.reportID) {
updateSearchResultsWithTransactionThreadReportID(hash, item.transactionID, transactionThreadReport?.reportID);
}
Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: transactionThreadReport?.reportID, backTo}));
return;
}

if (isReportActionListItemType(item)) {
const reportActionID = item.reportActionID;
Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID, reportActionID, backTo}));
Expand Down
11 changes: 9 additions & 2 deletions src/components/SelectionList/Search/TransactionGroupListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ import useThemeStyles from '@hooks/useThemeStyles';
import {search} from '@libs/actions/Search';
import {getReportIDForTransaction} from '@libs/MoneyRequestReportUtils';
import Navigation from '@libs/Navigation/Navigation';
import {getColumnsToShow, getSections} from '@libs/SearchUIUtils';
import {getReportAction} from '@libs/ReportActionsUtils';
import {createAndOpenSearchTransactionThread, getColumnsToShow, getSections} from '@libs/SearchUIUtils';
import variables from '@styles/variables';
import {setActiveTransactionThreadIDs} from '@userActions/TransactionThreadNavigation';
import CONST from '@src/CONST';
Expand Down Expand Up @@ -63,7 +64,7 @@ function TransactionGroupListItem<TItem extends ListItem>({
const theme = useTheme();
const styles = useThemeStyles();
const {translate, formatPhoneNumber} = useLocalize();
const {selectedTransactions} = useSearchContext();
const {selectedTransactions, currentSearchHash} = useSearchContext();
const selectedTransactionIDs = Object.keys(selectedTransactions);
const selectedTransactionIDsSet = useMemo(() => new Set(selectedTransactionIDs), [selectedTransactionIDs]);
const [transactionsSnapshot] = useOnyx(`${ONYXKEYS.COLLECTION.SNAPSHOT}${groupItem.transactionsQueryJSON?.hash}`, {canBeMissing: true});
Expand Down Expand Up @@ -161,6 +162,12 @@ function TransactionGroupListItem<TItem extends ListItem>({
// When opening the transaction thread in RHP we need to find every other ID for the rest of transactions
// to display prev/next arrows in RHP for navigation
setActiveTransactionThreadIDs(siblingTransactionThreadIDs).then(() => {
// If we're trying to open a transaction without a transaction thread, let's create the thread and navigate the user
if (transactionItem.transactionThreadReportID === CONST.REPORT.UNREPORTED_REPORT_ID) {
const iouAction = getReportAction(transactionItem.report.reportID, transactionItem.moneyRequestReportActionID);
createAndOpenSearchTransactionThread(transactionItem, iouAction, currentSearchHash, backTo);
return;
}
Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID, backTo}));
});
};
Expand Down
3 changes: 3 additions & 0 deletions src/components/SelectionList/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -340,6 +340,9 @@ type TransactionGroupListItemType = ListItem & {
/** List of grouped transactions */
transactions: TransactionListItemType[];

/** Whether the report has a single transaction */
isOneTransactionReport?: boolean;

/** The hash of the query to get the transactions data */
transactionsQueryJSON?: SearchQueryJSON;
};
Expand Down
17 changes: 16 additions & 1 deletion src/libs/SearchUIUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,8 @@ import type {
} from '@src/types/onyx/SearchResults';
import type IconAsset from '@src/types/utils/IconAsset';
import {canApproveIOU, canIOUBePaid, canSubmitReport} from './actions/IOU';
import {createNewReport} from './actions/Report';
import {createNewReport, createTransactionThreadReport, openReport} from './actions/Report';
import {updateSearchResultsWithTransactionThreadReportID} from './actions/Search';
import type {CardFeedForDisplay} from './CardFeedUtils';
import {getCardFeedsForDisplay} from './CardFeedUtils';
import {convertToDisplayString, getCurrencySymbol} from './CurrencyUtils';
Expand Down Expand Up @@ -1276,6 +1277,19 @@ function getTaskSections(
);
}

/** Creates transaction thread report and navigates to it from the search page */
function createAndOpenSearchTransactionThread(item: TransactionListItemType, iouReportAction: OnyxEntry<OnyxTypes.ReportAction>, hash: number, backTo: string) {
// We know that iou report action exists, but it wasn't loaded yet. We need to load iou report to have the necessary data in the onyx.
if (!iouReportAction) {
openReport(item.report.reportID);
}
const transactionThreadReport = createTransactionThreadReport(item.report, iouReportAction ?? ({reportActionID: item.moneyRequestReportActionID} as OnyxTypes.ReportAction));
if (transactionThreadReport?.reportID) {
updateSearchResultsWithTransactionThreadReportID(hash, item.transactionID, item.report.reportID);
}
Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: transactionThreadReport?.reportID, backTo}));
}

/**
* @private
* Organizes data into List Sections for display, for the ReportActionListItemType of Search Results.
Expand Down Expand Up @@ -2308,6 +2322,7 @@ export {
isTransactionAmountTooLong,
isTransactionTaxAmountTooLong,
getDatePresets,
createAndOpenSearchTransactionThread,
getWithdrawalTypeOptions,
getActionOptions,
getColumnsToShow,
Expand Down
31 changes: 29 additions & 2 deletions src/pages/Search/SearchMoneyRequestReportPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,23 @@ import DragAndDropProvider from '@components/DragAndDrop/Provider';
import MoneyRequestReportView from '@components/MoneyRequestReportView/MoneyRequestReportView';
import ScreenWrapper from '@components/ScreenWrapper';
import useIsReportReadyToDisplay from '@hooks/useIsReportReadyToDisplay';
import useNetwork from '@hooks/useNetwork';
import useOnyx from '@hooks/useOnyx';
import usePaginatedReportActions from '@hooks/usePaginatedReportActions';
import useReportIsArchived from '@hooks/useReportIsArchived';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils';
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types';
import {getFilteredReportActionsForReportView, getIOUActionForTransactionID, getOneTransactionThreadReportID} from '@libs/ReportActionsUtils';
import {isValidReportIDFromPath} from '@libs/ReportUtils';
import Navigation from '@navigation/Navigation';
import ReactionListWrapper from '@pages/home/ReactionListWrapper';
import {openReport} from '@userActions/Report';
import {createTransactionThreadReport, openReport} from '@userActions/Report';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {ActionListContextType, ScrollPosition} from '@src/pages/home/ReportScreenContext';
import {ActionListContext} from '@src/pages/home/ReportScreenContext';
Expand All @@ -40,6 +46,7 @@ const defaultReportMetadata = {
function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) {
const {shouldUseNarrowLayout} = useResponsiveLayout();
const styles = useThemeStyles();
const {isOffline} = useNetwork();

const reportIDFromRoute = getNonEmptyStringOnyxID(route.params?.reportID);
const [report] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${reportIDFromRoute}`, {allowStaleData: true, canBeMissing: true});
Expand All @@ -55,11 +62,31 @@ function SearchMoneyRequestReportPage({route}: SearchMoneyRequestPageProps) {
const flatListRef = useRef<FlatList>(null);
const actionListValue = useMemo((): ActionListContextType => ({flatListRef, scrollPosition, setScrollPosition}), [flatListRef, scrollPosition, setScrollPosition]);

const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`, {canBeMissing: true});
const {reportActions: unfilteredReportActions} = usePaginatedReportActions(reportIDFromRoute);
const {transactions: allReportTransactions} = useTransactionsAndViolationsForReport(reportIDFromRoute);
const reportActions = useMemo(() => getFilteredReportActionsForReportView(unfilteredReportActions), [unfilteredReportActions]);
const reportTransactions = useMemo(() => getAllNonDeletedTransactions(allReportTransactions, reportActions), [allReportTransactions, reportActions]);
const visibleTransactions = useMemo(
() => reportTransactions?.filter((transaction) => isOffline || transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE),
[reportTransactions, isOffline],
);
const reportTransactionIDs = useMemo(() => visibleTransactions?.map((transaction) => transaction.transactionID), [visibleTransactions]);
const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, reportActions ?? [], isOffline, reportTransactionIDs);
const oneTransactionID = reportTransactions.at(0)?.transactionID;

const reportID = report?.reportID;

useEffect(() => {
if (transactionThreadReportID === CONST.FAKE_REPORT_ID && oneTransactionID) {
const iouAction = getIOUActionForTransactionID(reportActions, oneTransactionID);
createTransactionThreadReport(report, iouAction);
}

openReport(reportIDFromRoute, '', [], undefined, undefined, false, [], undefined);
}, [reportIDFromRoute]);
// We don't want this hook to re-run on the every report change
// eslint-disable-next-line react-compiler/react-compiler, react-hooks/exhaustive-deps
}, [reportIDFromRoute, transactionThreadReportID]);

// eslint-disable-next-line rulesdir/no-negated-variables
const shouldShowNotFoundPage = useMemo(
Expand Down
50 changes: 50 additions & 0 deletions tests/unit/Search/SearchUIUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,18 +11,38 @@ import type {
TransactionReportGroupListItemType,
TransactionWithdrawalIDGroupListItemType,
} from '@components/SelectionList/types';
import Navigation from '@navigation/Navigation';
// eslint-disable-next-line no-restricted-syntax
import type * as ReportUserActions from '@userActions/Report';
import {createTransactionThreadReport, openReport} from '@userActions/Report';
// eslint-disable-next-line no-restricted-syntax
import type * as SearchUtils from '@userActions/Search';
import {updateSearchResultsWithTransactionThreadReportID} from '@userActions/Search';
import * as Expensicons from '@src/components/Icon/Expensicons';
import CONST from '@src/CONST';
import IntlStore from '@src/languages/IntlStore';
import type {CardFeedForDisplay} from '@src/libs/CardFeedUtils';
import * as SearchUIUtils from '@src/libs/SearchUIUtils';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type * as OnyxTypes from '@src/types/onyx';
import type {SearchDataTypes} from '@src/types/onyx/SearchResults';
import {formatPhoneNumber, localeCompare} from '../../utils/TestHelper';
import waitForBatchedUpdates from '../../utils/waitForBatchedUpdates';

jest.mock('@src/components/ConfirmedRoute.tsx');
jest.mock('@src/libs/Navigation/Navigation', () => ({
navigate: jest.fn(),
}));
jest.mock('@userActions/Report', () => ({
...jest.requireActual<typeof ReportUserActions>('@userActions/Report'),
createTransactionThreadReport: jest.fn(),
openReport: jest.fn(),
}));
jest.mock('@userActions/Search', () => ({
...jest.requireActual<typeof SearchUtils>('@userActions/Search'),
updateSearchResultsWithTransactionThreadReportID: jest.fn(),
}));

const adminAccountID = 18439984;
const adminEmail = 'admin@policy.com';
Expand Down Expand Up @@ -2603,4 +2623,34 @@ describe('SearchUIUtils', () => {
expect(columns[CONST.SEARCH.TABLE_COLUMNS.TAG]).toBe(false);
});
});

describe('createAndOpenSearchTransactionThread', () => {
const threadReportID = 'thread-report-123';
const threadReport = {reportID: threadReportID};
// eslint-disable-next-line @typescript-eslint/non-nullable-type-assertion-style
const transactionListItem = transactionsListItems.at(0) as TransactionListItemType;
const iouReportAction = {reportActionID: 'action-123'} as OnyxTypes.ReportAction;
const hash = 12345;
const backTo = '/search/all';

test('Should create transaction thread report and navigate to it', () => {
(createTransactionThreadReport as jest.Mock).mockReturnValue(threadReport);

SearchUIUtils.createAndOpenSearchTransactionThread(transactionListItem, iouReportAction, hash, backTo);

expect(createTransactionThreadReport).toHaveBeenCalledWith(report1, iouReportAction);
expect(updateSearchResultsWithTransactionThreadReportID).toHaveBeenCalledWith(hash, transactionID, threadReportID);
expect(Navigation.navigate).toHaveBeenCalledWith(ROUTES.SEARCH_REPORT.getRoute({reportID: threadReportID, backTo}));
});

test('Should not load iou report if iouReportAction was provided', () => {
SearchUIUtils.createAndOpenSearchTransactionThread(transactionListItem, iouReportAction, hash, backTo);
expect(openReport).not.toHaveBeenCalled();
});

test('Should load iou report if iouReportAction was not provided', () => {
SearchUIUtils.createAndOpenSearchTransactionThread(transactionListItem, undefined, hash, backTo);
expect(openReport).toHaveBeenCalled();
});
});
});
Loading