From c04cd6bca7d65b4f9da2e448fee693813944133c Mon Sep 17 00:00:00 2001 From: truph01 Date: Thu, 19 Mar 2026 19:03:39 +0700 Subject: [PATCH 01/21] fix: refactor prepareReportOptionsForDisplay -> getValidOptions --- src/ONYXKEYS.ts | 2 + .../SearchFiltersParticipantsSelector.tsx | 3 + src/hooks/useSearchSelector.base.ts | 6 + src/libs/OptionsListUtils/index.ts | 14 +- src/libs/OptionsListUtils/types.ts | 2 + .../OnyxDerived/ONYX_DERIVED_VALUES.ts | 2 + .../configs/sortedReportActions.ts | 53 ++++++ src/pages/NewChatPage.tsx | 2 + src/types/onyx/DerivedValues.ts | 12 +- src/types/onyx/index.ts | 2 + tests/unit/OptionsListUtilsTest.tsx | 151 ++++++++++++++++++ 11 files changed, 245 insertions(+), 4 deletions(-) create mode 100644 src/libs/actions/OnyxDerived/configs/sortedReportActions.ts diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 152fec8fbd87..71d4a697d1f7 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1040,6 +1040,7 @@ const ONYXKEYS = { PERSONAL_AND_WORKSPACE_CARD_LIST: 'personalAndWorkspaceCardList', CARD_FEED_ERRORS: 'cardFeedErrors', TODOS: 'todos', + SORTED_REPORT_ACTIONS: 'sortedReportActions', }, /** Stores HybridApp specific state required to interoperate with OldDot */ @@ -1470,6 +1471,7 @@ type OnyxDerivedValuesMapping = { [ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST]: OnyxTypes.PersonalAndWorkspaceCardListDerivedValue; [ONYXKEYS.DERIVED.CARD_FEED_ERRORS]: OnyxTypes.CardFeedErrorsDerivedValue; [ONYXKEYS.DERIVED.TODOS]: OnyxTypes.TodosDerivedValue; + [ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS]: OnyxTypes.SortedReportActionsDerivedValue; }; type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping & OnyxDerivedValuesMapping; diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index 9458f6b0cede..d82e69c7ee19 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -97,6 +97,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, const [nvpDismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [recentAttendees] = useOnyx(ONYXKEYS.NVP_RECENT_ATTENDEES); + const [sortedReportActionsData] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); const privateIsArchivedMap = usePrivateIsArchivedMap(); // Transform raw recentAttendees into Option[] format for use with getValidOptions (only for attendee filter) @@ -130,6 +131,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, includeRecentReports: !shouldAllowNameOnlyOptions, personalDetails, countryCode, + sortedReportActionsData, }, ); }, [ @@ -146,6 +148,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, personalDetails, currentUserAccountID, currentUserEmail, + sortedReportActionsData, ]); const unselectedOptions = useMemo(() => { diff --git a/src/hooks/useSearchSelector.base.ts b/src/hooks/useSearchSelector.base.ts index 9d950647dcdd..8291c7750b7d 100644 --- a/src/hooks/useSearchSelector.base.ts +++ b/src/hooks/useSearchSelector.base.ts @@ -173,6 +173,7 @@ function useSearchSelectorBase({ const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); const [nvpDismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING); const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); + const [sortedReportActionsData] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserPersonalDetails.accountID; const currentUserEmail = currentUserPersonalDetails.email ?? ''; @@ -233,6 +234,7 @@ function useSearchSelectorBase({ countryCode, reportAttributesDerived: reportAttributesDerived?.reports, allPolicyTags, + sortedReportActionsData, }); case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL: return getValidOptions(optionsWithContacts, allPolicies, draftComments, nvpDismissedProductTraining, loginList, currentUserAccountID, currentUserEmail, { @@ -249,6 +251,7 @@ function useSearchSelectorBase({ countryCode, reportAttributesDerived: reportAttributesDerived?.reports, allPolicyTags, + sortedReportActionsData, }); case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_SHARE_DESTINATION: return getValidOptions(optionsWithContacts, allPolicies, draftComments, nvpDismissedProductTraining, loginList, currentUserAccountID, currentUserEmail, { @@ -272,6 +275,7 @@ function useSearchSelectorBase({ countryCode, reportAttributesDerived: reportAttributesDerived?.reports, allPolicyTags, + sortedReportActionsData, }); case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_ATTENDEES: return getValidOptions(optionsWithContacts, allPolicies, draftComments, nvpDismissedProductTraining, loginList, currentUserAccountID, currentUserEmail, { @@ -294,6 +298,7 @@ function useSearchSelectorBase({ countryCode, reportAttributesDerived: reportAttributesDerived?.reports, allPolicyTags, + sortedReportActionsData, }); default: return getEmptyOptions(); @@ -325,6 +330,7 @@ function useSearchSelectorBase({ reportAttributesDerived?.reports, trimmedSearchInput, allPolicyTags, + sortedReportActionsData, ]); const isOptionSelected = useMemo(() => { diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 30486acf9285..426549d65712 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -176,6 +176,7 @@ import type { ReportActions, ReportAttributesDerivedValue, ReportMetadata, + SortedReportActionsDerivedValue, VisibleReportActionsDerivedValue, } from '@src/types/onyx'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; @@ -2227,6 +2228,7 @@ function prepareReportOptionsForDisplay( visibleReportActionsData: VisibleReportActionsDerivedValue = {}, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], policyTags?: OnyxCollection, + sortedReportActionsData?: SortedReportActionsDerivedValue, ): Array> { const { showChatPreviewLine = false, @@ -2277,9 +2279,10 @@ function prepareReportOptionsForDisplay( let isOptionUnread = option.isUnread; if (shouldUnreadBeBold) { const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report.chatReportID}`]; + const resolvedSortedActions = sortedReportActionsData?.sortedActions ?? allSortedReportActions; const oneTransactionThreadReportID = report.type === CONST.REPORT.TYPE.IOU || report.type === CONST.REPORT.TYPE.EXPENSE || report.type === CONST.REPORT.TYPE.INVOICE - ? getOneTransactionThreadReportID(report, chatReport, allSortedReportActions[report.reportID]) + ? getOneTransactionThreadReportID(report, chatReport, resolvedSortedActions[report.reportID]) : undefined; const oneTransactionThreadReport = oneTransactionThreadReportID ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${oneTransactionThreadReportID}`] : undefined; @@ -2289,11 +2292,12 @@ function prepareReportOptionsForDisplay( let lastIOUCreationDate; // Add a field to sort the recent reports by the time of last IOU request for create actions if (preferRecentExpenseReports) { - const reportPreviewAction = allSortedReportActions[option.reportID]?.find((reportAction) => isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW)); + const sortedActionsForLookup = sortedReportActionsData?.sortedActions ?? allSortedReportActions; + const reportPreviewAction = sortedActionsForLookup[option.reportID]?.find((reportAction) => isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW)); if (reportPreviewAction) { const iouReportID = getIOUReportIDFromReportActionPreview(reportPreviewAction); - const iouReportActions = iouReportID ? (allSortedReportActions[iouReportID] ?? []) : []; + const iouReportActions = iouReportID ? (sortedActionsForLookup[iouReportID] ?? []) : []; const lastIOUAction = iouReportActions.find((iouAction) => iouAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); if (lastIOUAction) { lastIOUCreationDate = lastIOUAction.lastModified; @@ -2415,6 +2419,7 @@ function getValidOptions( countryCode = CONST.DEFAULT_COUNTRY_CODE, visibleReportActionsData = {}, reportAttributesDerived, + sortedReportActionsData, ...config }: GetOptionsConfig = {}, ): Options { @@ -2521,6 +2526,7 @@ function getValidOptions( visibleReportActionsData, reportAttributesDerived, allPolicyTags, + sortedReportActionsData, ).at(0); } @@ -2543,6 +2549,7 @@ function getValidOptions( visibleReportActionsData, reportAttributesDerived, allPolicyTags, + sortedReportActionsData, ); workspaceChats = prepareReportOptionsForDisplay( @@ -2561,6 +2568,7 @@ function getValidOptions( visibleReportActionsData, reportAttributesDerived, allPolicyTags, + sortedReportActionsData, ); } else if (recentAttendees && recentAttendees?.length > 0) { recentAttendees.filter((attendee) => { diff --git a/src/libs/OptionsListUtils/types.ts b/src/libs/OptionsListUtils/types.ts index b5ef8448b05d..02a9f9bdd2a9 100644 --- a/src/libs/OptionsListUtils/types.ts +++ b/src/libs/OptionsListUtils/types.ts @@ -12,6 +12,7 @@ import type { Report, ReportActions, ReportAttributesDerivedValue, + SortedReportActionsDerivedValue, TransactionViolation, VisibleReportActionsDerivedValue, } from '@src/types/onyx'; @@ -221,6 +222,7 @@ type GetOptionsConfig = { countryCode?: number; visibleReportActionsData?: VisibleReportActionsDerivedValue; reportAttributesDerived?: ReportAttributesDerivedValue['reports']; + sortedReportActionsData?: SortedReportActionsDerivedValue; } & GetValidReportsConfig; type GetUserToInviteConfig = { diff --git a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts index 5ad7a2defcde..a4414366a8c0 100644 --- a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts +++ b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts @@ -6,6 +6,7 @@ import outstandingReportsByPolicyIDConfig from './configs/outstandingReportsByPo import personalAndWorkspaceCardListConfig from './configs/personalAndWorkspaceCardList'; import reportAttributesConfig from './configs/reportAttributes'; import reportTransactionsAndViolationsConfig from './configs/reportTransactionsAndViolations'; +import sortedReportActionsConfig from './configs/sortedReportActions'; import todosConfig from './configs/todos'; import visibleReportActionsConfig from './configs/visibleReportActions'; import type {OnyxDerivedValueConfig} from './types'; @@ -23,6 +24,7 @@ const ONYX_DERIVED_VALUES = { [ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST]: personalAndWorkspaceCardListConfig, [ONYXKEYS.DERIVED.CARD_FEED_ERRORS]: cardFeedErrorsConfig, [ONYXKEYS.DERIVED.TODOS]: todosConfig, + [ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS]: sortedReportActionsConfig, } as const satisfies { // eslint-disable-next-line @typescript-eslint/no-explicit-any [Key in ValueOf]: OnyxDerivedValueConfig; diff --git a/src/libs/actions/OnyxDerived/configs/sortedReportActions.ts b/src/libs/actions/OnyxDerived/configs/sortedReportActions.ts new file mode 100644 index 000000000000..4bfec5be4506 --- /dev/null +++ b/src/libs/actions/OnyxDerived/configs/sortedReportActions.ts @@ -0,0 +1,53 @@ +import {getCombinedReportActions, getOneTransactionThreadReportID, getSortedReportActions, withDEWRoutedActionsArray} from '@libs/ReportActionsUtils'; +import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {SortedReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; + +const EMPTY_VALUE: SortedReportActionsDerivedValue = {sortedActions: {}, lastActions: {}, transactionThreadIDs: {}}; + +export default createOnyxDerivedValueConfig({ + key: ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS, + dependencies: [ONYXKEYS.COLLECTION.REPORT_ACTIONS, ONYXKEYS.COLLECTION.REPORT], + compute: ([allReportActions, allReports]): SortedReportActionsDerivedValue => { + if (!allReportActions) { + return EMPTY_VALUE; + } + + const sortedActions: SortedReportActionsDerivedValue['sortedActions'] = {}; + const lastActions: SortedReportActionsDerivedValue['lastActions'] = {}; + const transactionThreadIDs: SortedReportActionsDerivedValue['transactionThreadIDs'] = {}; + + for (const [key, actions] of Object.entries(allReportActions)) { + if (!actions) { + continue; + } + + const reportID = key.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''); + if (!reportID) { + continue; + } + + const reportActionsArray = Object.values(actions); + let sortedReportActions = getSortedReportActions(withDEWRoutedActionsArray(reportActionsArray), true); + sortedActions[reportID] = sortedReportActions; + + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`]; + + const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, actions); + transactionThreadIDs[reportID] = transactionThreadReportID; + + if (transactionThreadReportID) { + const transactionThreadReportActionsArray = Object.values(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`] ?? {}); + sortedReportActions = getCombinedReportActions(sortedReportActions, transactionThreadReportID, transactionThreadReportActionsArray, reportID); + } + + const firstReportAction = sortedReportActions.at(0); + if (firstReportAction) { + lastActions[reportID] = firstReportAction; + } + } + + return {sortedActions, lastActions, transactionThreadIDs}; + }, +}); diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index ddaf2206f10e..59a4babdefba 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -74,6 +74,7 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); const allPersonalDetails = usePersonalDetails(); const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); + const [sortedReportActionsData] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); const { options: listOptions, @@ -115,6 +116,7 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor allPolicyTags, countryCode, reportAttributesDerived, + sortedReportActionsData, }, ); diff --git a/src/types/onyx/DerivedValues.ts b/src/types/onyx/DerivedValues.ts index 80ed1e688bc0..da97e8ee7b00 100644 --- a/src/types/onyx/DerivedValues.ts +++ b/src/types/onyx/DerivedValues.ts @@ -1,7 +1,7 @@ import type {OnyxCollection} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import type CONST from '@src/CONST'; -import type {Card} from '.'; +import type {Card, ReportAction} from '.'; import type {CardList} from './Card'; import type {CardFeedWithDomainID, CompanyCardFeedWithNumber} from './CardFeeds'; import type {Errors} from './OnyxCommon'; @@ -259,6 +259,15 @@ type TodosDerivedValue = { transactionsByReportID: Record; }; +/** + * The derived value for sorted report actions, last report actions, and cached transaction thread report IDs. + */ +type SortedReportActionsDerivedValue = { + sortedActions: Record; + lastActions: Record; + transactionThreadIDs: Record; +}; + /** * The derived value for merged personal and workspace card feeds. */ @@ -272,6 +281,7 @@ export type { ReportTransactionsAndViolations, OutstandingReportsByPolicyIDDerivedValue, VisibleReportActionsDerivedValue, + SortedReportActionsDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, PersonalAndWorkspaceCardListDerivedValue, CardFeedErrorsDerivedValue, diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index d6be837a45fd..1c830e1ce95b 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -52,6 +52,7 @@ import type { TodoMetadata, TodosDerivedValue, VisibleReportActionsDerivedValue, + SortedReportActionsDerivedValue, } from './DerivedValues'; import type DeviceBiometrics from './DeviceBiometrics'; import type DismissedProductTraining from './DismissedProductTraining'; @@ -362,6 +363,7 @@ export type { ReportTransactionsAndViolationsDerivedValue, OutstandingReportsByPolicyIDDerivedValue, VisibleReportActionsDerivedValue, + SortedReportActionsDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, PersonalAndWorkspaceCardListDerivedValue, CardFeedErrorsDerivedValue, diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 2ba7b1d614bd..8a57f7335958 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -6951,4 +6951,155 @@ describe('OptionsListUtils', () => { expect(result).toBeUndefined(); }); }); + + describe('prepareReportOptionsForDisplay with sortedReportActionsData', () => { + it('should use sortedReportActionsData to compute lastIOUCreationDate for expense report sorting', async () => { + const reportID = 'sorted-test-1'; + const iouReportID = 'sorted-iou-1'; + const iouActionModified = '2025-06-15 10:30:00.000'; + + const report: Report = { + ...createRegularChat(Number(reportID), [1]), + reportID, + reportName: 'Test Report', + lastVisibleActionCreated: '2025-06-15 10:00:00.000', + lastActorAccountID: 1, + lastMessageText: 'Test', + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await waitForBatchedUpdates(); + + const reportPreviewAction: ReportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + originalMessage: {linkedReportID: iouReportID}, + } as ReportAction; + + const iouAction: ReportAction = { + ...createRandomReportAction(2), + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + lastModified: iouActionModified, + } as ReportAction; + + const inputOption: SearchOption = { + item: report, + reportID, + text: 'Test Report', + isUnread: false, + participantsList: [], + keyForList: reportID, + isChatRoom: true, + policyID: '123', + lastMessageText: 'Test', + lastVisibleActionCreated: report.lastVisibleActionCreated, + notificationPreference: 'always', + accountID: 0, + login: '', + alternateText: '', + subtitle: '', + firstName: '', + lastName: '', + icons: [], + isSelected: false, + isDisabled: false, + brickRoadIndicator: null, + isBold: false, + }; + + const sortedReportActionsData = { + sortedActions: { + [reportID]: [reportPreviewAction], + [iouReportID]: [iouAction], + }, + lastActions: {}, + transactionThreadIDs: {}, + }; + + const results = getValidOptions( + {reports: [inputOption], personalDetails: []}, + allPolicies, + {}, + nvpDismissedProductTraining, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + { + includeRecentReports: true, + includeMultipleParticipantReports: true, + action: CONST.IOU.ACTION.CREATE, + sortedReportActionsData, + }, + ); + + expect(results.recentReports.length).toBe(1); + const resultOption = results.recentReports.at(0); + expect(resultOption?.lastIOUCreationDate).toBe(iouActionModified); + }); + + it('should not have lastIOUCreationDate when sortedReportActionsData is empty', async () => { + const reportID = 'sorted-test-2'; + + const report: Report = { + ...createRegularChat(Number(reportID), [1]), + reportID, + reportName: 'Test Report 2', + lastVisibleActionCreated: '2025-06-15 10:00:00.000', + lastActorAccountID: 1, + lastMessageText: 'Test', + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await waitForBatchedUpdates(); + + const inputOption: SearchOption = { + item: report, + reportID, + text: 'Test Report 2', + isUnread: false, + participantsList: [], + keyForList: reportID, + isChatRoom: true, + policyID: '123', + lastMessageText: 'Test', + lastVisibleActionCreated: report.lastVisibleActionCreated, + notificationPreference: 'always', + accountID: 0, + login: '', + alternateText: '', + subtitle: '', + firstName: '', + lastName: '', + icons: [], + isSelected: false, + isDisabled: false, + brickRoadIndicator: null, + isBold: false, + }; + + const sortedReportActionsData = { + sortedActions: {}, + lastActions: {}, + transactionThreadIDs: {}, + }; + + const results = getValidOptions( + {reports: [inputOption], personalDetails: []}, + allPolicies, + {}, + nvpDismissedProductTraining, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + { + includeRecentReports: true, + action: CONST.IOU.ACTION.CREATE, + sortedReportActionsData, + }, + ); + + const resultOption = results.recentReports.at(0); + expect(resultOption?.lastIOUCreationDate).toBeUndefined(); + }); + }); }); From b453829984e42832c54f7e8362a2385cf30ddec4 Mon Sep 17 00:00:00 2001 From: truph01 Date: Thu, 19 Mar 2026 19:07:37 +0700 Subject: [PATCH 02/21] fix: add deprecate prefix --- src/libs/OptionsListUtils/index.ts | 39 ++++++++++++++++++------------ 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 426549d65712..b4b1cd6890e3 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -216,10 +216,14 @@ Onyx.connect({ }, }); -const lastReportActions: ReportActions = {}; -const allSortedReportActions: Record = {}; -const cachedOneTransactionThreadReportIDs: Record = {}; -let allReportActions: OnyxCollection; +/** @deprecated Use sortedReportActionsData from ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS instead. Will be removed once all flows are migrated. */ +const deprecatedLastReportActions: ReportActions = {}; +/** @deprecated Use sortedReportActionsData from ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS instead. Will be removed once all flows are migrated. */ +const deprecatedAllSortedReportActions: Record = {}; +/** @deprecated Use sortedReportActionsData from ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS instead. Will be removed once all flows are migrated. */ +const deprecatedCachedOneTransactionThreadReportIDs: Record = {}; +/** @deprecated Use sortedReportActionsData from ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS instead. Will be removed once all flows are migrated. */ +let deprecatedAllReportActions: OnyxCollection; Onyx.connect({ key: ONYXKEYS.COLLECTION.REPORT_ACTIONS, waitForCollectionCallback: true, @@ -228,10 +232,10 @@ Onyx.connect({ return; } - allReportActions = actions ?? {}; + deprecatedAllReportActions = actions ?? {}; // Iterate over the report actions to build the sorted report actions objects - for (const reportActions of Object.entries(allReportActions)) { + for (const reportActions of Object.entries(deprecatedAllReportActions)) { const reportID = reportActions[0].split('_').at(1); if (!reportID) { continue; @@ -239,7 +243,7 @@ Onyx.connect({ const reportActionsArray = Object.values(reportActions[1] ?? {}); let sortedReportActions = getSortedReportActions(withDEWRoutedActionsArray(reportActionsArray), true); - allSortedReportActions[reportID] = sortedReportActions; + deprecatedAllSortedReportActions[reportID] = sortedReportActions; const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`]; @@ -247,7 +251,7 @@ Onyx.connect({ // to the transaction thread or the report itself. // Cache the result for O(1) lookup in renderItem. const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, actions[reportActions[0]]); - cachedOneTransactionThreadReportIDs[reportID] = transactionThreadReportID; + deprecatedCachedOneTransactionThreadReportIDs[reportID] = transactionThreadReportID; if (transactionThreadReportID) { const transactionThreadReportActionsArray = Object.values(actions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`] ?? {}); @@ -256,9 +260,9 @@ Onyx.connect({ const firstReportAction = sortedReportActions.at(0); if (!firstReportAction) { - delete lastReportActions[reportID]; + delete deprecatedLastReportActions[reportID]; } else { - lastReportActions[reportID] = firstReportAction; + deprecatedLastReportActions[reportID] = firstReportAction; } } }, @@ -619,7 +623,8 @@ function getLastMessageTextForReport({ const canUserPerformWrite = canUserPerformWriteAction(report, isReportArchived); let lastReportAction = lastAction ?? getLastVisibleAction(reportID, canUserPerformWrite, {}, undefined, visibleReportActionsDataParam); - const transactionThreadReportID = reportID ? cachedOneTransactionThreadReportIDs[reportID] : undefined; + // eslint-disable-next-line @typescript-eslint/no-deprecated + const transactionThreadReportID = reportID ? deprecatedCachedOneTransactionThreadReportIDs[reportID] : undefined; if (reportID && !lastAction && transactionThreadReportID) { lastReportAction = @@ -649,7 +654,8 @@ function getLastMessageTextForReport({ } // some types of actions are filtered out for lastReportAction, in some cases we need to check the actual last action - const lastOriginalReportAction = reportID ? lastReportActions[reportID] : undefined; + // eslint-disable-next-line @typescript-eslint/no-deprecated + const lastOriginalReportAction = reportID ? deprecatedLastReportActions[reportID] : undefined; let lastMessageTextFromReport = ''; if (isArchivedNonExpenseReport(report, isReportArchived)) { @@ -682,7 +688,8 @@ function getLastMessageTextForReport({ const iouReportID = iouReport?.reportID; const reportCache = iouReportID ? visibleReportActionsDataParam?.[iouReportID] : undefined; const visibleReportActionsForIOUReport = reportCache && Object.keys(reportCache).length > 0 ? visibleReportActionsDataParam : undefined; - const iouReportActions = iouReportID ? allSortedReportActions[iouReportID] : undefined; + // eslint-disable-next-line @typescript-eslint/no-deprecated + const iouReportActions = iouReportID ? deprecatedAllSortedReportActions[iouReportID] : undefined; const canPerformWrite = canUserPerformWriteAction(report, isReportArchived); const lastIOUMoneyReportAction = iouReportID && iouReportActions @@ -2279,7 +2286,8 @@ function prepareReportOptionsForDisplay( let isOptionUnread = option.isUnread; if (shouldUnreadBeBold) { const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report.chatReportID}`]; - const resolvedSortedActions = sortedReportActionsData?.sortedActions ?? allSortedReportActions; + // eslint-disable-next-line @typescript-eslint/no-deprecated + const resolvedSortedActions = sortedReportActionsData?.sortedActions ?? deprecatedAllSortedReportActions; const oneTransactionThreadReportID = report.type === CONST.REPORT.TYPE.IOU || report.type === CONST.REPORT.TYPE.EXPENSE || report.type === CONST.REPORT.TYPE.INVOICE ? getOneTransactionThreadReportID(report, chatReport, resolvedSortedActions[report.reportID]) @@ -2292,7 +2300,8 @@ function prepareReportOptionsForDisplay( let lastIOUCreationDate; // Add a field to sort the recent reports by the time of last IOU request for create actions if (preferRecentExpenseReports) { - const sortedActionsForLookup = sortedReportActionsData?.sortedActions ?? allSortedReportActions; + // eslint-disable-next-line @typescript-eslint/no-deprecated + const sortedActionsForLookup = sortedReportActionsData?.sortedActions ?? deprecatedAllSortedReportActions; const reportPreviewAction = sortedActionsForLookup[option.reportID]?.find((reportAction) => isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW)); if (reportPreviewAction) { From 0343dd6e8f38c059c7d0a73ffacb3a4d9d5eba6f Mon Sep 17 00:00:00 2001 From: truph01 Date: Fri, 20 Mar 2026 04:25:35 +0700 Subject: [PATCH 03/21] fix: lint --- src/libs/OptionsListUtils/index.ts | 7 ++++++- src/types/onyx/index.ts | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index b4b1cd6890e3..32515d3a32a9 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -231,10 +231,11 @@ Onyx.connect({ if (!actions) { return; } - + // eslint-disable-next-line @typescript-eslint/no-deprecated deprecatedAllReportActions = actions ?? {}; // Iterate over the report actions to build the sorted report actions objects + // eslint-disable-next-line @typescript-eslint/no-deprecated for (const reportActions of Object.entries(deprecatedAllReportActions)) { const reportID = reportActions[0].split('_').at(1); if (!reportID) { @@ -243,6 +244,7 @@ Onyx.connect({ const reportActionsArray = Object.values(reportActions[1] ?? {}); let sortedReportActions = getSortedReportActions(withDEWRoutedActionsArray(reportActionsArray), true); + // eslint-disable-next-line @typescript-eslint/no-deprecated deprecatedAllSortedReportActions[reportID] = sortedReportActions; const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`]; @@ -251,6 +253,7 @@ Onyx.connect({ // to the transaction thread or the report itself. // Cache the result for O(1) lookup in renderItem. const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, actions[reportActions[0]]); + // eslint-disable-next-line @typescript-eslint/no-deprecated deprecatedCachedOneTransactionThreadReportIDs[reportID] = transactionThreadReportID; if (transactionThreadReportID) { @@ -260,8 +263,10 @@ Onyx.connect({ const firstReportAction = sortedReportActions.at(0); if (!firstReportAction) { + // eslint-disable-next-line @typescript-eslint/no-deprecated delete deprecatedLastReportActions[reportID]; } else { + // eslint-disable-next-line @typescript-eslint/no-deprecated deprecatedLastReportActions[reportID] = firstReportAction; } } diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 1c830e1ce95b..53d698daa64a 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -49,10 +49,10 @@ import type { PersonalAndWorkspaceCardListDerivedValue, ReportAttributesDerivedValue, ReportTransactionsAndViolationsDerivedValue, + SortedReportActionsDerivedValue, TodoMetadata, TodosDerivedValue, VisibleReportActionsDerivedValue, - SortedReportActionsDerivedValue, } from './DerivedValues'; import type DeviceBiometrics from './DeviceBiometrics'; import type DismissedProductTraining from './DismissedProductTraining'; From 670c63f2a92dd9679fa6829e1ef956b72739725a Mon Sep 17 00:00:00 2001 From: truph01 Date: Fri, 20 Mar 2026 04:29:35 +0700 Subject: [PATCH 04/21] fix: lint --- src/types/onyx/DerivedValues.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/types/onyx/DerivedValues.ts b/src/types/onyx/DerivedValues.ts index da97e8ee7b00..c3e3bd49f259 100644 --- a/src/types/onyx/DerivedValues.ts +++ b/src/types/onyx/DerivedValues.ts @@ -263,8 +263,11 @@ type TodosDerivedValue = { * The derived value for sorted report actions, last report actions, and cached transaction thread report IDs. */ type SortedReportActionsDerivedValue = { + /** Sorted report actions keyed by report ID */ sortedActions: Record; + /** Last report action for each report, keyed by report ID */ lastActions: Record; + /** Transaction thread report IDs keyed by parent report action ID */ transactionThreadIDs: Record; }; From d3d8ba3715b9375c7af0ffdec52c647458723b76 Mon Sep 17 00:00:00 2001 From: truph01 Date: Fri, 20 Mar 2026 04:39:28 +0700 Subject: [PATCH 05/21] fix: add comment --- src/libs/actions/OnyxDerived/configs/sortedReportActions.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/libs/actions/OnyxDerived/configs/sortedReportActions.ts b/src/libs/actions/OnyxDerived/configs/sortedReportActions.ts index 4bfec5be4506..80ecf16d78fb 100644 --- a/src/libs/actions/OnyxDerived/configs/sortedReportActions.ts +++ b/src/libs/actions/OnyxDerived/configs/sortedReportActions.ts @@ -17,6 +17,7 @@ export default createOnyxDerivedValueConfig({ const lastActions: SortedReportActionsDerivedValue['lastActions'] = {}; const transactionThreadIDs: SortedReportActionsDerivedValue['transactionThreadIDs'] = {}; + // Iterate over the report actions to build the sorted report actions objects for (const [key, actions] of Object.entries(allReportActions)) { if (!actions) { continue; @@ -34,6 +35,9 @@ export default createOnyxDerivedValueConfig({ const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`]; + // If the report is a one-transaction report, we need to return the combined reportActions so that the LHN can display modifications + // to the transaction thread or the report itself. + // Cache the result for O(1) lookup in renderItem. const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, actions); transactionThreadIDs[reportID] = transactionThreadReportID; From 117490d2111b76e2f8fdaaf33738ddee26e79043 Mon Sep 17 00:00:00 2001 From: truph01 Date: Fri, 20 Mar 2026 05:28:42 +0700 Subject: [PATCH 06/21] fix: only pass sortedActions param --- .../SearchFiltersParticipantsSelector.tsx | 6 ++--- src/hooks/useSearchSelector.base.ts | 12 ++++----- src/libs/OptionsListUtils/index.ts | 19 ++++++-------- src/libs/OptionsListUtils/types.ts | 4 +-- src/pages/NewChatPage.tsx | 4 +-- tests/unit/OptionsListUtilsTest.tsx | 26 +++++++------------ 6 files changed, 30 insertions(+), 41 deletions(-) diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index d82e69c7ee19..605b41918a67 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -97,7 +97,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, const [nvpDismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [recentAttendees] = useOnyx(ONYXKEYS.NVP_RECENT_ATTENDEES); - const [sortedReportActionsData] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); + const [{sortedActions} = {}] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); const privateIsArchivedMap = usePrivateIsArchivedMap(); // Transform raw recentAttendees into Option[] format for use with getValidOptions (only for attendee filter) @@ -131,7 +131,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, includeRecentReports: !shouldAllowNameOnlyOptions, personalDetails, countryCode, - sortedReportActionsData, + sortedActions, }, ); }, [ @@ -148,7 +148,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, personalDetails, currentUserAccountID, currentUserEmail, - sortedReportActionsData, + sortedActions, ]); const unselectedOptions = useMemo(() => { diff --git a/src/hooks/useSearchSelector.base.ts b/src/hooks/useSearchSelector.base.ts index 8291c7750b7d..95847b0bf82d 100644 --- a/src/hooks/useSearchSelector.base.ts +++ b/src/hooks/useSearchSelector.base.ts @@ -173,7 +173,7 @@ function useSearchSelectorBase({ const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); const [nvpDismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING); const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - const [sortedReportActionsData] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); + const [{sortedActions} = {}] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserPersonalDetails.accountID; const currentUserEmail = currentUserPersonalDetails.email ?? ''; @@ -234,7 +234,7 @@ function useSearchSelectorBase({ countryCode, reportAttributesDerived: reportAttributesDerived?.reports, allPolicyTags, - sortedReportActionsData, + sortedActions, }); case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL: return getValidOptions(optionsWithContacts, allPolicies, draftComments, nvpDismissedProductTraining, loginList, currentUserAccountID, currentUserEmail, { @@ -251,7 +251,7 @@ function useSearchSelectorBase({ countryCode, reportAttributesDerived: reportAttributesDerived?.reports, allPolicyTags, - sortedReportActionsData, + sortedActions, }); case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_SHARE_DESTINATION: return getValidOptions(optionsWithContacts, allPolicies, draftComments, nvpDismissedProductTraining, loginList, currentUserAccountID, currentUserEmail, { @@ -275,7 +275,7 @@ function useSearchSelectorBase({ countryCode, reportAttributesDerived: reportAttributesDerived?.reports, allPolicyTags, - sortedReportActionsData, + sortedActions, }); case CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_ATTENDEES: return getValidOptions(optionsWithContacts, allPolicies, draftComments, nvpDismissedProductTraining, loginList, currentUserAccountID, currentUserEmail, { @@ -298,7 +298,7 @@ function useSearchSelectorBase({ countryCode, reportAttributesDerived: reportAttributesDerived?.reports, allPolicyTags, - sortedReportActionsData, + sortedActions, }); default: return getEmptyOptions(); @@ -330,7 +330,7 @@ function useSearchSelectorBase({ reportAttributesDerived?.reports, trimmedSearchInput, allPolicyTags, - sortedReportActionsData, + sortedActions, ]); const isOptionSelected = useMemo(() => { diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 32515d3a32a9..c5a350157a29 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -176,7 +176,6 @@ import type { ReportActions, ReportAttributesDerivedValue, ReportMetadata, - SortedReportActionsDerivedValue, VisibleReportActionsDerivedValue, } from '@src/types/onyx'; import type {Attendee, Participant} from '@src/types/onyx/IOU'; @@ -2239,8 +2238,8 @@ function prepareReportOptionsForDisplay( config: GetValidReportsConfig, visibleReportActionsData: VisibleReportActionsDerivedValue = {}, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], + sortedActions: Record = deprecatedAllSortedReportActions, policyTags?: OnyxCollection, - sortedReportActionsData?: SortedReportActionsDerivedValue, ): Array> { const { showChatPreviewLine = false, @@ -2292,10 +2291,9 @@ function prepareReportOptionsForDisplay( if (shouldUnreadBeBold) { const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report.chatReportID}`]; // eslint-disable-next-line @typescript-eslint/no-deprecated - const resolvedSortedActions = sortedReportActionsData?.sortedActions ?? deprecatedAllSortedReportActions; const oneTransactionThreadReportID = report.type === CONST.REPORT.TYPE.IOU || report.type === CONST.REPORT.TYPE.EXPENSE || report.type === CONST.REPORT.TYPE.INVOICE - ? getOneTransactionThreadReportID(report, chatReport, resolvedSortedActions[report.reportID]) + ? getOneTransactionThreadReportID(report, chatReport, sortedActions[report.reportID]) : undefined; const oneTransactionThreadReport = oneTransactionThreadReportID ? allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${oneTransactionThreadReportID}`] : undefined; @@ -2306,12 +2304,11 @@ function prepareReportOptionsForDisplay( // Add a field to sort the recent reports by the time of last IOU request for create actions if (preferRecentExpenseReports) { // eslint-disable-next-line @typescript-eslint/no-deprecated - const sortedActionsForLookup = sortedReportActionsData?.sortedActions ?? deprecatedAllSortedReportActions; - const reportPreviewAction = sortedActionsForLookup[option.reportID]?.find((reportAction) => isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW)); + const reportPreviewAction = sortedActions[option.reportID]?.find((reportAction) => isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW)); if (reportPreviewAction) { const iouReportID = getIOUReportIDFromReportActionPreview(reportPreviewAction); - const iouReportActions = iouReportID ? (sortedActionsForLookup[iouReportID] ?? []) : []; + const iouReportActions = iouReportID ? (sortedActions[iouReportID] ?? []) : []; const lastIOUAction = iouReportActions.find((iouAction) => iouAction.actionName === CONST.REPORT.ACTIONS.TYPE.IOU); if (lastIOUAction) { lastIOUCreationDate = lastIOUAction.lastModified; @@ -2433,7 +2430,7 @@ function getValidOptions( countryCode = CONST.DEFAULT_COUNTRY_CODE, visibleReportActionsData = {}, reportAttributesDerived, - sortedReportActionsData, + sortedActions, ...config }: GetOptionsConfig = {}, ): Options { @@ -2539,8 +2536,8 @@ function getValidOptions( }, visibleReportActionsData, reportAttributesDerived, + sortedActions, allPolicyTags, - sortedReportActionsData, ).at(0); } @@ -2562,8 +2559,8 @@ function getValidOptions( }, visibleReportActionsData, reportAttributesDerived, + sortedActions, allPolicyTags, - sortedReportActionsData, ); workspaceChats = prepareReportOptionsForDisplay( @@ -2581,8 +2578,8 @@ function getValidOptions( }, visibleReportActionsData, reportAttributesDerived, + sortedActions, allPolicyTags, - sortedReportActionsData, ); } else if (recentAttendees && recentAttendees?.length > 0) { recentAttendees.filter((attendee) => { diff --git a/src/libs/OptionsListUtils/types.ts b/src/libs/OptionsListUtils/types.ts index 02a9f9bdd2a9..38b4a839ec50 100644 --- a/src/libs/OptionsListUtils/types.ts +++ b/src/libs/OptionsListUtils/types.ts @@ -10,9 +10,9 @@ import type { PersonalDetailsList, PolicyTagLists, Report, + ReportAction, ReportActions, ReportAttributesDerivedValue, - SortedReportActionsDerivedValue, TransactionViolation, VisibleReportActionsDerivedValue, } from '@src/types/onyx'; @@ -222,7 +222,7 @@ type GetOptionsConfig = { countryCode?: number; visibleReportActionsData?: VisibleReportActionsDerivedValue; reportAttributesDerived?: ReportAttributesDerivedValue['reports']; - sortedReportActionsData?: SortedReportActionsDerivedValue; + sortedActions?: Record; } & GetValidReportsConfig; type GetUserToInviteConfig = { diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index 59a4babdefba..2d5808f26091 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -74,7 +74,7 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); const allPersonalDetails = usePersonalDetails(); const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); - const [sortedReportActionsData] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); + const [{sortedActions} = {}] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); const { options: listOptions, @@ -116,7 +116,7 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor allPolicyTags, countryCode, reportAttributesDerived, - sortedReportActionsData, + sortedActions, }, ); diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index 2f52ac37d6dd..e5575d142373 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -6953,8 +6953,8 @@ describe('OptionsListUtils', () => { }); }); - describe('prepareReportOptionsForDisplay with sortedReportActionsData', () => { - it('should use sortedReportActionsData to compute lastIOUCreationDate for expense report sorting', async () => { + describe('prepareReportOptionsForDisplay with sortedActions', () => { + it('should use sortedActions to compute lastIOUCreationDate for expense report sorting', async () => { const reportID = 'sorted-test-1'; const iouReportID = 'sorted-iou-1'; const iouActionModified = '2025-06-15 10:30:00.000'; @@ -7008,13 +7008,9 @@ describe('OptionsListUtils', () => { isBold: false, }; - const sortedReportActionsData = { - sortedActions: { - [reportID]: [reportPreviewAction], - [iouReportID]: [iouAction], - }, - lastActions: {}, - transactionThreadIDs: {}, + const sortedActions = { + [reportID]: [reportPreviewAction], + [iouReportID]: [iouAction], }; const results = getValidOptions( @@ -7029,7 +7025,7 @@ describe('OptionsListUtils', () => { includeRecentReports: true, includeMultipleParticipantReports: true, action: CONST.IOU.ACTION.CREATE, - sortedReportActionsData, + sortedActions, }, ); @@ -7038,7 +7034,7 @@ describe('OptionsListUtils', () => { expect(resultOption?.lastIOUCreationDate).toBe(iouActionModified); }); - it('should not have lastIOUCreationDate when sortedReportActionsData is empty', async () => { + it('should not have lastIOUCreationDate when sortedActions is empty', async () => { const reportID = 'sorted-test-2'; const report: Report = { @@ -7078,11 +7074,7 @@ describe('OptionsListUtils', () => { isBold: false, }; - const sortedReportActionsData = { - sortedActions: {}, - lastActions: {}, - transactionThreadIDs: {}, - }; + const sortedActions = {}; const results = getValidOptions( {reports: [inputOption], personalDetails: []}, @@ -7095,7 +7087,7 @@ describe('OptionsListUtils', () => { { includeRecentReports: true, action: CONST.IOU.ACTION.CREATE, - sortedReportActionsData, + sortedActions, }, ); From c8169021ff667434b8b1d522f3d13b9f832559d0 Mon Sep 17 00:00:00 2001 From: truph01 Date: Fri, 20 Mar 2026 11:28:35 +0700 Subject: [PATCH 07/21] fix: lint --- .../Search/SearchFiltersParticipantsSelector.tsx | 4 +++- src/hooks/useSearchSelector.base.ts | 9 +++++---- src/libs/OptionsListUtils/index.ts | 1 + src/pages/NewChatPage.tsx | 4 +++- 4 files changed, 12 insertions(+), 6 deletions(-) diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index 605b41918a67..f3b36cdbba44 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -20,7 +20,9 @@ import Navigation from '@navigation/Navigation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; +import {getEmptyObject} from '@src/types/utils/EmptyObject'; import SearchFilterPageFooterButtons from './SearchFilterPageFooterButtons'; const defaultListOptions = { @@ -97,7 +99,7 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, const [nvpDismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [recentAttendees] = useOnyx(ONYXKEYS.NVP_RECENT_ATTENDEES); - const [{sortedActions} = {}] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); + const [{sortedActions} = getEmptyObject()] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); const privateIsArchivedMap = usePrivateIsArchivedMap(); // Transform raw recentAttendees into Option[] format for use with getValidOptions (only for attendee filter) diff --git a/src/hooks/useSearchSelector.base.ts b/src/hooks/useSearchSelector.base.ts index 95847b0bf82d..50170ff999d3 100644 --- a/src/hooks/useSearchSelector.base.ts +++ b/src/hooks/useSearchSelector.base.ts @@ -7,7 +7,8 @@ import {getEmptyOptions, getSearchOptions, getSearchValueForPhoneOrEmail, getVal import type {OptionData} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetails} from '@src/types/onyx'; +import type * as OnyxTypes from '@src/types/onyx'; +import {getEmptyObject} from '@src/types/utils/EmptyObject'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useDebounce from './useDebounce'; import useDebouncedState from './useDebouncedState'; @@ -66,7 +67,7 @@ type UseSearchSelectorConfig = { shouldInitialize?: boolean; /** Additional contact options to merge (used by platform-specific implementations) */ - contactOptions?: Array>; + contactOptions?: Array>; }; type ContactState = { @@ -74,7 +75,7 @@ type ContactState = { permissionStatus: PermissionStatus; /** Contact options from device */ - contactOptions: Array>; + contactOptions: Array>; /** Whether to show import UI */ showImportUI: boolean; @@ -173,7 +174,7 @@ function useSearchSelectorBase({ const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); const [nvpDismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING); const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - const [{sortedActions} = {}] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); + const [{sortedActions} = getEmptyObject()] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserPersonalDetails.accountID; const currentUserEmail = currentUserPersonalDetails.email ?? ''; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index c5a350157a29..991aff0b6411 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -2238,6 +2238,7 @@ function prepareReportOptionsForDisplay( config: GetValidReportsConfig, visibleReportActionsData: VisibleReportActionsDerivedValue = {}, reportAttributesDerived?: ReportAttributesDerivedValue['reports'], + // eslint-disable-next-line @typescript-eslint/no-deprecated sortedActions: Record = deprecatedAllSortedReportActions, policyTags?: OnyxCollection, ): Array> { diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index 2d5808f26091..64d27fb03a33 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -47,8 +47,10 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; +import type * as OnyxTypes from '@src/types/onyx'; import type {ReportAttributesDerivedValue} from '@src/types/onyx/DerivedValues'; import type {SelectedParticipant} from '@src/types/onyx/NewGroupChatDraft'; +import {getEmptyObject} from '@src/types/utils/EmptyObject'; import KeyboardUtils from '@src/utils/keyboard'; const excludedGroupEmails = new Set(CONST.EXPENSIFY_EMAILS.filter((value) => value !== CONST.EMAIL.CONCIERGE)); @@ -74,7 +76,7 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); const allPersonalDetails = usePersonalDetails(); const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); - const [{sortedActions} = {}] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); + const [{sortedActions} = getEmptyObject()] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); const { options: listOptions, From a758c5b3cd99fc3386ce4ec2668140b3ccd45380 Mon Sep 17 00:00:00 2001 From: truph01 Date: Sun, 22 Mar 2026 18:31:58 +0700 Subject: [PATCH 08/21] fix: conflicts --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index c58bf3cb2b5a..9be7b50b4eca 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit c58bf3cb2b5af52846f68f0901a4a3db2fb9909b +Subproject commit 9be7b50b4ecad14778fa30f616886515ea7f8902 From 6a1a0d13d791ca4c087171003d2cfbd6ec63c5ff Mon Sep 17 00:00:00 2001 From: truph01 Date: Sun, 22 Mar 2026 19:33:58 +0700 Subject: [PATCH 09/21] fix: use ramOnlyKeys --- .../SearchFiltersParticipantsSelector.tsx | 5 +- src/hooks/useSearchSelector.base.ts | 4 +- .../configs/sortedReportActions.ts | 95 ++++++++++++++----- src/pages/NewChatPage.tsx | 5 +- src/setup/index.ts | 1 + 5 files changed, 79 insertions(+), 31 deletions(-) diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index c3899c0a2324..fa7a5d9e467c 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -17,9 +17,7 @@ import Navigation from '@navigation/Navigation'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type * as OnyxTypes from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; -import {getEmptyObject} from '@src/types/utils/EmptyObject'; import SearchFilterPageFooterButtons from './SearchFilterPageFooterButtons'; /** @@ -70,7 +68,8 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, const currentUserAccountID = currentUserPersonalDetails.accountID; const currentUserEmail = currentUserPersonalDetails.email ?? ''; const [recentAttendees] = useOnyx(ONYXKEYS.NVP_RECENT_ATTENDEES); - const [{sortedActions} = getEmptyObject()] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); + const [sortedReportActionsData] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); + const sortedActions = sortedReportActionsData?.sortedActions; // Transform raw recentAttendees into Option[] format for use with getValidOptions (only for attendee filter) const recentAttendeeLists = useMemo( diff --git a/src/hooks/useSearchSelector.base.ts b/src/hooks/useSearchSelector.base.ts index a904e4580d6a..f682bbe03168 100644 --- a/src/hooks/useSearchSelector.base.ts +++ b/src/hooks/useSearchSelector.base.ts @@ -8,7 +8,6 @@ import type {OptionData} from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type * as OnyxTypes from '@src/types/onyx'; -import {getEmptyObject} from '@src/types/utils/EmptyObject'; import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; import useDebounce from './useDebounce'; import useDebouncedState from './useDebouncedState'; @@ -194,7 +193,8 @@ function useSearchSelectorBase({ const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); const [nvpDismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING); const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - const [{sortedActions} = getEmptyObject()] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); + const [sortedReportActionsData] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); + const sortedActions = sortedReportActionsData?.sortedActions; const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserPersonalDetails.accountID; const currentUserEmail = currentUserPersonalDetails.email ?? ''; diff --git a/src/libs/actions/OnyxDerived/configs/sortedReportActions.ts b/src/libs/actions/OnyxDerived/configs/sortedReportActions.ts index 80ecf16d78fb..bc5da9d09d21 100644 --- a/src/libs/actions/OnyxDerived/configs/sortedReportActions.ts +++ b/src/libs/actions/OnyxDerived/configs/sortedReportActions.ts @@ -1,23 +1,86 @@ +import type {OnyxCollection} from 'react-native-onyx'; import {getCombinedReportActions, getOneTransactionThreadReportID, getSortedReportActions, withDEWRoutedActionsArray} from '@libs/ReportActionsUtils'; import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAction, ReportActions} from '@src/types/onyx'; import type {SortedReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; const EMPTY_VALUE: SortedReportActionsDerivedValue = {sortedActions: {}, lastActions: {}, transactionThreadIDs: {}}; +function computeForReport( + reportID: string, + actions: ReportActions, + allReportActions: OnyxCollection, + allReports: OnyxCollection, +): {sortedReportActions: ReportAction[]; transactionThreadReportID: string | undefined; lastAction: ReportAction | undefined} { + const reportActionsArray = Object.values(actions); + let sortedReportActions = getSortedReportActions(withDEWRoutedActionsArray(reportActionsArray), true); + + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`]; + + const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, actions); + + if (transactionThreadReportID && allReportActions) { + const transactionThreadReportActionsArray = Object.values(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`] ?? {}); + sortedReportActions = getCombinedReportActions(sortedReportActions, transactionThreadReportID, transactionThreadReportActionsArray, reportID); + } + + return { + sortedReportActions, + transactionThreadReportID, + lastAction: sortedReportActions.at(0), + }; +} + export default createOnyxDerivedValueConfig({ key: ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS, dependencies: [ONYXKEYS.COLLECTION.REPORT_ACTIONS, ONYXKEYS.COLLECTION.REPORT], - compute: ([allReportActions, allReports]): SortedReportActionsDerivedValue => { + compute: ([allReportActions, allReports], {sourceValues, currentValue}): SortedReportActionsDerivedValue => { if (!allReportActions) { return EMPTY_VALUE; } + const reportActionsUpdates = sourceValues?.[ONYXKEYS.COLLECTION.REPORT_ACTIONS]; + + // Incremental update: only recompute reports whose actions changed + if (reportActionsUpdates && currentValue) { + const sortedActions = {...currentValue.sortedActions}; + const lastActions = {...currentValue.lastActions}; + const transactionThreadIDs = {...currentValue.transactionThreadIDs}; + + for (const reportActionsKey of Object.keys(reportActionsUpdates)) { + const reportID = reportActionsKey.replace(ONYXKEYS.COLLECTION.REPORT_ACTIONS, ''); + if (!reportID) { + continue; + } + + const actions = allReportActions[reportActionsKey]; + if (!actions) { + delete sortedActions[reportID]; + delete lastActions[reportID]; + delete transactionThreadIDs[reportID]; + continue; + } + + const result = computeForReport(reportID, actions, allReportActions, allReports); + sortedActions[reportID] = result.sortedReportActions; + transactionThreadIDs[reportID] = result.transactionThreadReportID; + if (result.lastAction) { + lastActions[reportID] = result.lastAction; + } else { + delete lastActions[reportID]; + } + } + + return {sortedActions, lastActions, transactionThreadIDs}; + } + + // Full recompute on first load or when reports change const sortedActions: SortedReportActionsDerivedValue['sortedActions'] = {}; const lastActions: SortedReportActionsDerivedValue['lastActions'] = {}; const transactionThreadIDs: SortedReportActionsDerivedValue['transactionThreadIDs'] = {}; - // Iterate over the report actions to build the sorted report actions objects for (const [key, actions] of Object.entries(allReportActions)) { if (!actions) { continue; @@ -28,27 +91,13 @@ export default createOnyxDerivedValueConfig({ continue; } - const reportActionsArray = Object.values(actions); - let sortedReportActions = getSortedReportActions(withDEWRoutedActionsArray(reportActionsArray), true); - sortedActions[reportID] = sortedReportActions; - - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; - const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`]; - - // If the report is a one-transaction report, we need to return the combined reportActions so that the LHN can display modifications - // to the transaction thread or the report itself. - // Cache the result for O(1) lookup in renderItem. - const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, actions); - transactionThreadIDs[reportID] = transactionThreadReportID; - - if (transactionThreadReportID) { - const transactionThreadReportActionsArray = Object.values(allReportActions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`] ?? {}); - sortedReportActions = getCombinedReportActions(sortedReportActions, transactionThreadReportID, transactionThreadReportActionsArray, reportID); - } - - const firstReportAction = sortedReportActions.at(0); - if (firstReportAction) { - lastActions[reportID] = firstReportAction; + const result = computeForReport(reportID, actions, allReportActions, allReports); + sortedActions[reportID] = result.sortedReportActions; + transactionThreadIDs[reportID] = result.transactionThreadReportID; + if (result.lastAction) { + lastActions[reportID] = result.lastAction; + } else { + delete lastActions[reportID]; } } diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index fabf0d58b3ba..c5011a81c22e 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -46,10 +46,8 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type * as OnyxTypes from '@src/types/onyx'; import type {ReportAttributesDerivedValue} from '@src/types/onyx/DerivedValues'; import type {SelectedParticipant} from '@src/types/onyx/NewGroupChatDraft'; -import {getEmptyObject} from '@src/types/utils/EmptyObject'; import getEmptyArray from '@src/types/utils/getEmptyArray'; import KeyboardUtils from '@src/utils/keyboard'; @@ -77,7 +75,8 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor const allPersonalDetails = usePersonalDetails(); const isScreenFocusedRef = useIsFocusedRef(); const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); - const [{sortedActions} = getEmptyObject()] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); + const [sortedReportActionsData] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); + const sortedActions = sortedReportActionsData?.sortedActions; const { options: listOptions, diff --git a/src/setup/index.ts b/src/setup/index.ts index 6f8aec7bb1e8..af67594d1cdc 100644 --- a/src/setup/index.ts +++ b/src/setup/index.ts @@ -60,6 +60,7 @@ export default function () { }, skippableCollectionMemberIDs: CONST.SKIPPABLE_COLLECTION_MEMBER_IDS, snapshotMergeKeys: ['pendingAction', 'pendingFields'], + ramOnlyKeys: [ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS], }); // Must be imported after Onyx.init() and outside the React lifecycle so that push notification From 5e7646567764ac6f8a8b634bc7174823ea456436 Mon Sep 17 00:00:00 2001 From: truph01 Date: Sun, 22 Mar 2026 19:35:23 +0700 Subject: [PATCH 10/21] fix: lint --- src/components/Search/SearchFiltersParticipantsSelector.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index fa7a5d9e467c..0ecd749797a8 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -69,7 +69,6 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, const currentUserEmail = currentUserPersonalDetails.email ?? ''; const [recentAttendees] = useOnyx(ONYXKEYS.NVP_RECENT_ATTENDEES); const [sortedReportActionsData] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); - const sortedActions = sortedReportActionsData?.sortedActions; // Transform raw recentAttendees into Option[] format for use with getValidOptions (only for attendee filter) const recentAttendeeLists = useMemo( From 0559dc87ac7b301dc84cf01981d7e5de8121c359 Mon Sep 17 00:00:00 2001 From: truph01 Date: Sun, 22 Mar 2026 19:38:39 +0700 Subject: [PATCH 11/21] fix: lint --- src/components/Search/SearchFiltersParticipantsSelector.tsx | 1 - src/pages/NewChatPage.tsx | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/src/components/Search/SearchFiltersParticipantsSelector.tsx b/src/components/Search/SearchFiltersParticipantsSelector.tsx index 0ecd749797a8..ffee0069987d 100644 --- a/src/components/Search/SearchFiltersParticipantsSelector.tsx +++ b/src/components/Search/SearchFiltersParticipantsSelector.tsx @@ -68,7 +68,6 @@ function SearchFiltersParticipantsSelector({initialAccountIDs, onFiltersUpdate, const currentUserAccountID = currentUserPersonalDetails.accountID; const currentUserEmail = currentUserPersonalDetails.email ?? ''; const [recentAttendees] = useOnyx(ONYXKEYS.NVP_RECENT_ATTENDEES); - const [sortedReportActionsData] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); // Transform raw recentAttendees into Option[] format for use with getValidOptions (only for attendee filter) const recentAttendeeLists = useMemo( diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index c5011a81c22e..e8d49f5b2215 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -118,7 +118,7 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor allPolicyTags, countryCode, reportAttributesDerived, - sortedActions, + sortedActions: {}, }, ); From fbee322cb9d7934a167d2abbbd53d9e91a6f2486 Mon Sep 17 00:00:00 2001 From: truph01 Date: Sun, 22 Mar 2026 19:39:11 +0700 Subject: [PATCH 12/21] fix: lint --- src/pages/NewChatPage.tsx | 2 +- tests/unit/OptionsListUtilsTest.tsx | 290 ++++++++++++++++++++++++++++ 2 files changed, 291 insertions(+), 1 deletion(-) diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index e8d49f5b2215..c5011a81c22e 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -118,7 +118,7 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor allPolicyTags, countryCode, reportAttributesDerived, - sortedActions: {}, + sortedActions, }, ); diff --git a/tests/unit/OptionsListUtilsTest.tsx b/tests/unit/OptionsListUtilsTest.tsx index e5575d142373..13e3b4ec9a64 100644 --- a/tests/unit/OptionsListUtilsTest.tsx +++ b/tests/unit/OptionsListUtilsTest.tsx @@ -7094,5 +7094,295 @@ describe('OptionsListUtils', () => { const resultOption = results.recentReports.at(0); expect(resultOption?.lastIOUCreationDate).toBeUndefined(); }); + + it('should not have lastIOUCreationDate when sortedActions is undefined', async () => { + const reportID = 'sorted-test-3'; + + const report: Report = { + ...createRegularChat(Number(reportID), [1]), + reportID, + reportName: 'Test Report 3', + lastVisibleActionCreated: '2025-06-15 10:00:00.000', + lastActorAccountID: 1, + lastMessageText: 'Test', + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await waitForBatchedUpdates(); + + const inputOption: SearchOption = { + item: report, + reportID, + text: 'Test Report 3', + isUnread: false, + participantsList: [], + keyForList: reportID, + isChatRoom: true, + policyID: '123', + lastMessageText: 'Test', + lastVisibleActionCreated: report.lastVisibleActionCreated, + notificationPreference: 'always', + accountID: 0, + login: '', + alternateText: '', + subtitle: '', + firstName: '', + lastName: '', + icons: [], + isSelected: false, + isDisabled: false, + brickRoadIndicator: null, + isBold: false, + }; + + const results = getValidOptions( + {reports: [inputOption], personalDetails: []}, + allPolicies, + {}, + nvpDismissedProductTraining, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + { + includeRecentReports: true, + action: CONST.IOU.ACTION.CREATE, + sortedActions: undefined, + }, + ); + + const resultOption = results.recentReports.at(0); + expect(resultOption?.lastIOUCreationDate).toBeUndefined(); + }); + + it('should pick the correct lastIOUCreationDate from multiple IOU actions', async () => { + const reportID = 'sorted-test-4'; + const iouReportID = 'sorted-iou-4'; + const olderDate = '2025-06-10 08:00:00.000'; + const newerDate = '2025-06-15 10:30:00.000'; + + const report: Report = { + ...createRegularChat(Number(reportID), [1]), + reportID, + reportName: 'Test Report 4', + lastVisibleActionCreated: '2025-06-15 10:00:00.000', + lastActorAccountID: 1, + lastMessageText: 'Test', + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await waitForBatchedUpdates(); + + const reportPreviewAction: ReportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + originalMessage: {linkedReportID: iouReportID}, + } as ReportAction; + + const newerIOUAction: ReportAction = { + ...createRandomReportAction(2), + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + lastModified: newerDate, + } as ReportAction; + + const olderIOUAction: ReportAction = { + ...createRandomReportAction(3), + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + lastModified: olderDate, + } as ReportAction; + + const inputOption: SearchOption = { + item: report, + reportID, + text: 'Test Report 4', + isUnread: false, + participantsList: [], + keyForList: reportID, + isChatRoom: true, + policyID: '123', + lastMessageText: 'Test', + lastVisibleActionCreated: report.lastVisibleActionCreated, + notificationPreference: 'always', + accountID: 0, + login: '', + alternateText: '', + subtitle: '', + firstName: '', + lastName: '', + icons: [], + isSelected: false, + isDisabled: false, + brickRoadIndicator: null, + isBold: false, + }; + + const sortedActions = { + [reportID]: [reportPreviewAction], + [iouReportID]: [newerIOUAction, olderIOUAction], + }; + + const results = getValidOptions( + {reports: [inputOption], personalDetails: []}, + allPolicies, + {}, + nvpDismissedProductTraining, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + { + includeRecentReports: true, + includeMultipleParticipantReports: true, + action: CONST.IOU.ACTION.CREATE, + sortedActions, + }, + ); + + expect(results.recentReports.length).toBe(1); + const resultOption = results.recentReports.at(0); + expect(resultOption?.lastIOUCreationDate).toBe(newerDate); + }); + + it('should not set lastIOUCreationDate when action is not CREATE', async () => { + const reportID = 'sorted-test-5'; + const iouReportID = 'sorted-iou-5'; + + const report: Report = { + ...createRegularChat(Number(reportID), [1]), + reportID, + reportName: 'Test Report 5', + lastVisibleActionCreated: '2025-06-15 10:00:00.000', + lastActorAccountID: 1, + lastMessageText: 'Test', + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await waitForBatchedUpdates(); + + const reportPreviewAction: ReportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + originalMessage: {linkedReportID: iouReportID}, + } as ReportAction; + + const iouAction: ReportAction = { + ...createRandomReportAction(2), + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + lastModified: '2025-06-15 10:30:00.000', + } as ReportAction; + + const inputOption: SearchOption = { + item: report, + reportID, + text: 'Test Report 5', + isUnread: false, + participantsList: [], + keyForList: reportID, + isChatRoom: true, + policyID: '123', + lastMessageText: 'Test', + lastVisibleActionCreated: report.lastVisibleActionCreated, + notificationPreference: 'always', + accountID: 0, + login: '', + alternateText: '', + subtitle: '', + firstName: '', + lastName: '', + icons: [], + isSelected: false, + isDisabled: false, + brickRoadIndicator: null, + isBold: false, + }; + + const sortedActions = { + [reportID]: [reportPreviewAction], + [iouReportID]: [iouAction], + }; + + const results = getValidOptions( + {reports: [inputOption], personalDetails: []}, + allPolicies, + {}, + nvpDismissedProductTraining, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + { + includeRecentReports: true, + sortedActions, + }, + ); + + const resultOption = results.recentReports.at(0); + expect(resultOption?.lastIOUCreationDate).toBeUndefined(); + }); + + it('should not set lastIOUCreationDate when report has no REPORT_PREVIEW action in sortedActions', async () => { + const reportID = 'sorted-test-6'; + + const report: Report = { + ...createRegularChat(Number(reportID), [1]), + reportID, + reportName: 'Test Report 6', + lastVisibleActionCreated: '2025-06-15 10:00:00.000', + lastActorAccountID: 1, + lastMessageText: 'Test', + }; + + await Onyx.merge(`${ONYXKEYS.COLLECTION.REPORT}${reportID}`, report); + await waitForBatchedUpdates(); + + const nonPreviewAction: ReportAction = { + ...createRandomReportAction(1), + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + } as ReportAction; + + const inputOption: SearchOption = { + item: report, + reportID, + text: 'Test Report 6', + isUnread: false, + participantsList: [], + keyForList: reportID, + isChatRoom: true, + policyID: '123', + lastMessageText: 'Test', + lastVisibleActionCreated: report.lastVisibleActionCreated, + notificationPreference: 'always', + accountID: 0, + login: '', + alternateText: '', + subtitle: '', + firstName: '', + lastName: '', + icons: [], + isSelected: false, + isDisabled: false, + brickRoadIndicator: null, + isBold: false, + }; + + const sortedActions = { + [reportID]: [nonPreviewAction], + }; + + const results = getValidOptions( + {reports: [inputOption], personalDetails: []}, + allPolicies, + {}, + nvpDismissedProductTraining, + loginList, + CURRENT_USER_ACCOUNT_ID, + CURRENT_USER_EMAIL, + { + includeRecentReports: true, + action: CONST.IOU.ACTION.CREATE, + sortedActions, + }, + ); + + const resultOption = results.recentReports.at(0); + expect(resultOption?.lastIOUCreationDate).toBeUndefined(); + }); }); }); From d14a8287fb525bf553b6276b6b32839aad8d3d96 Mon Sep 17 00:00:00 2001 From: truph01 Date: Sun, 22 Mar 2026 20:57:19 +0700 Subject: [PATCH 13/21] fix: add test --- tests/unit/useSearchSelectorTest.tsx | 244 +++++++++++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 tests/unit/useSearchSelectorTest.tsx diff --git a/tests/unit/useSearchSelectorTest.tsx b/tests/unit/useSearchSelectorTest.tsx new file mode 100644 index 000000000000..0e7efce12182 --- /dev/null +++ b/tests/unit/useSearchSelectorTest.tsx @@ -0,0 +1,244 @@ +import {act, renderHook} from '@testing-library/react-native'; +import type {OnyxMultiSetInput} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import * as OptionsListUtils from '@libs/OptionsListUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ReportAction} from '@src/types/onyx'; +import type {SortedReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +jest.mock('@components/ConfirmedRoute.tsx'); + +const MOCK_ACCOUNT_ID = 12345; +const MOCK_EMAIL = 'test@expensify.com'; + +const mockGetValidOptions = jest.spyOn(OptionsListUtils, 'getValidOptions'); +const mockGetSearchOptions = jest.spyOn(OptionsListUtils, 'getSearchOptions'); + +jest.mock('@components/OptionListContextProvider', () => { + const actual = jest.requireActual('@components/OptionListContextProvider'); + return { + ...actual, + useOptionsList: () => ({ + options: {reports: [], personalDetails: []}, + areOptionsInitialized: true, + initializeOptions: jest.fn(), + resetOptions: jest.fn(), + }), + }; +}); + +jest.mock('@components/OnyxListItemProvider', () => ({ + usePersonalDetails: () => ({}), +})); + +jest.mock('@hooks/useCurrentUserPersonalDetails', () => () => ({ + accountID: MOCK_ACCOUNT_ID, + email: MOCK_EMAIL, +})); + +// eslint-disable-next-line @typescript-eslint/no-require-imports +const useSearchSelectorBase = require('@hooks/useSearchSelector.base').default; + +function buildMockSortedActions(reportIDs: string[]): SortedReportActionsDerivedValue { + const sortedActions: Record = {}; + const lastActions: Record = {}; + const transactionThreadIDs: Record = {}; + + for (const id of reportIDs) { + const action = { + reportActionID: `action_${id}`, + created: '2025-01-01 10:00:00.000', + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + } as ReportAction; + sortedActions[id] = [action]; + lastActions[id] = action; + transactionThreadIDs[id] = undefined; + } + + return {sortedActions, lastActions, transactionThreadIDs}; +} + +describe('useSearchSelector sortedActions integration', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(async () => { + jest.clearAllMocks(); + await act(async () => { + await Onyx.clear(); + await Onyx.multiSet({ + [ONYXKEYS.SESSION]: { + accountID: MOCK_ACCOUNT_ID, + email: MOCK_EMAIL, + authTokenType: CONST.AUTH_TOKEN_TYPES.ANONYMOUS, + }, + [ONYXKEYS.BETAS]: [], + [ONYXKEYS.COUNTRY_CODE]: CONST.DEFAULT_COUNTRY_CODE, + } as unknown as OnyxMultiSetInput); + }); + await waitForBatchedUpdatesWithAct(); + }); + + afterAll(async () => { + await act(async () => { + await Onyx.clear(); + }); + }); + + it('passes undefined sortedActions to getValidOptions when SORTED_REPORT_ACTIONS is not set', async () => { + renderHook(() => + useSearchSelectorBase({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, + searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL, + }), + ); + await waitForBatchedUpdatesWithAct(); + + expect(mockGetValidOptions).toHaveBeenCalled(); + const lastCall = mockGetValidOptions.mock.calls.at(-1); + const config = lastCall?.[7]; + expect(config?.sortedActions).toBeUndefined(); + }); + + it('passes sortedActions from SORTED_REPORT_ACTIONS to getValidOptions for GENERAL context', async () => { + const mockData = buildMockSortedActions(['1', '2']); + + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS, mockData); + }); + await waitForBatchedUpdatesWithAct(); + + renderHook(() => + useSearchSelectorBase({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, + searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL, + }), + ); + await waitForBatchedUpdatesWithAct(); + + expect(mockGetValidOptions).toHaveBeenCalled(); + const lastCall = mockGetValidOptions.mock.calls.at(-1); + const config = lastCall?.[7]; + expect(config?.sortedActions).toEqual(mockData.sortedActions); + }); + + it('passes sortedActions to getValidOptions for MEMBER_INVITE context', async () => { + const mockData = buildMockSortedActions(['10']); + + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS, mockData); + }); + await waitForBatchedUpdatesWithAct(); + + renderHook(() => + useSearchSelectorBase({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, + searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_MEMBER_INVITE, + }), + ); + await waitForBatchedUpdatesWithAct(); + + expect(mockGetValidOptions).toHaveBeenCalled(); + const lastCall = mockGetValidOptions.mock.calls.at(-1); + const config = lastCall?.[7]; + expect(config?.sortedActions).toEqual(mockData.sortedActions); + }); + + it('passes sortedActions to getValidOptions for SHARE_DESTINATION context', async () => { + const mockData = buildMockSortedActions(['20', '21']); + + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS, mockData); + }); + await waitForBatchedUpdatesWithAct(); + + renderHook(() => + useSearchSelectorBase({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, + searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_SHARE_DESTINATION, + }), + ); + await waitForBatchedUpdatesWithAct(); + + expect(mockGetValidOptions).toHaveBeenCalled(); + const lastCall = mockGetValidOptions.mock.calls.at(-1); + const config = lastCall?.[7]; + expect(config?.sortedActions).toEqual(mockData.sortedActions); + }); + + it('passes sortedActions to getValidOptions for ATTENDEES context', async () => { + const mockData = buildMockSortedActions(['30']); + + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS, mockData); + }); + await waitForBatchedUpdatesWithAct(); + + renderHook(() => + useSearchSelectorBase({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, + searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_ATTENDEES, + }), + ); + await waitForBatchedUpdatesWithAct(); + + expect(mockGetValidOptions).toHaveBeenCalled(); + const lastCall = mockGetValidOptions.mock.calls.at(-1); + const config = lastCall?.[7]; + expect(config?.sortedActions).toEqual(mockData.sortedActions); + }); + + it('updates sortedActions when SORTED_REPORT_ACTIONS changes in Onyx', async () => { + const initialData = buildMockSortedActions(['1']); + + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS, initialData); + }); + await waitForBatchedUpdatesWithAct(); + + renderHook(() => + useSearchSelectorBase({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, + searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_GENERAL, + }), + ); + await waitForBatchedUpdatesWithAct(); + + const firstCallConfig = mockGetValidOptions.mock.calls.at(-1)?.[7]; + expect(firstCallConfig?.sortedActions).toEqual(initialData.sortedActions); + + const updatedData = buildMockSortedActions(['1', '2', '3']); + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS, updatedData); + }); + await waitForBatchedUpdatesWithAct(); + + const latestCallConfig = mockGetValidOptions.mock.calls.at(-1)?.[7]; + expect(latestCallConfig?.sortedActions).toEqual(updatedData.sortedActions); + }); + + it('does not pass sortedActions to getSearchOptions for SEARCH context', async () => { + const mockData = buildMockSortedActions(['1']); + + await act(async () => { + await Onyx.set(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS, mockData); + }); + await waitForBatchedUpdatesWithAct(); + + renderHook(() => + useSearchSelectorBase({ + selectionMode: CONST.SEARCH_SELECTOR.SELECTION_MODE_SINGLE, + searchContext: CONST.SEARCH_SELECTOR.SEARCH_CONTEXT_SEARCH, + }), + ); + await waitForBatchedUpdatesWithAct(); + + expect(mockGetSearchOptions).toHaveBeenCalled(); + const lastSearchCall = mockGetSearchOptions.mock.calls.at(-1); + const searchConfig = lastSearchCall?.[0]; + expect(searchConfig).not.toHaveProperty('sortedActions'); + }); +}); From f54e6272311bfbc1a4801c75c632fb43224a5806 Mon Sep 17 00:00:00 2001 From: truph01 Date: Mon, 23 Mar 2026 00:23:32 +0700 Subject: [PATCH 14/21] fix: lint --- tests/unit/useSearchSelectorTest.tsx | 43 ++++++++++++++++------------ 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/tests/unit/useSearchSelectorTest.tsx b/tests/unit/useSearchSelectorTest.tsx index 0e7efce12182..bcd9635fe76d 100644 --- a/tests/unit/useSearchSelectorTest.tsx +++ b/tests/unit/useSearchSelectorTest.tsx @@ -1,7 +1,8 @@ import {act, renderHook} from '@testing-library/react-native'; import type {OnyxMultiSetInput} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import * as OptionsListUtils from '@libs/OptionsListUtils'; +import useSearchSelectorBase from '@hooks/useSearchSelector.base'; +import {getSearchOptions, getValidOptions} from '@libs/OptionsListUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {ReportAction} from '@src/types/onyx'; @@ -10,24 +11,31 @@ import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct' jest.mock('@components/ConfirmedRoute.tsx'); +// eslint-disable-next-line @typescript-eslint/no-unsafe-return +jest.mock('@libs/OptionsListUtils', () => ({ + // eslint-disable-next-line @typescript-eslint/naming-convention + __esModule: true, + ...jest.requireActual('@libs/OptionsListUtils'), + getValidOptions: jest.fn(), + getSearchOptions: jest.fn(), +})); + const MOCK_ACCOUNT_ID = 12345; const MOCK_EMAIL = 'test@expensify.com'; -const mockGetValidOptions = jest.spyOn(OptionsListUtils, 'getValidOptions'); -const mockGetSearchOptions = jest.spyOn(OptionsListUtils, 'getSearchOptions'); - -jest.mock('@components/OptionListContextProvider', () => { - const actual = jest.requireActual('@components/OptionListContextProvider'); - return { - ...actual, - useOptionsList: () => ({ - options: {reports: [], personalDetails: []}, - areOptionsInitialized: true, - initializeOptions: jest.fn(), - resetOptions: jest.fn(), - }), - }; -}); +const mockGetValidOptions = jest.mocked(getValidOptions); +const mockGetSearchOptions = jest.mocked(getSearchOptions); + +// eslint-disable-next-line @typescript-eslint/no-unsafe-return +jest.mock('@components/OptionListContextProvider', () => ({ + ...jest.requireActual('@components/OptionListContextProvider'), + useOptionsList: () => ({ + options: {reports: [], personalDetails: []}, + areOptionsInitialized: true, + initializeOptions: jest.fn(), + resetOptions: jest.fn(), + }), +})); jest.mock('@components/OnyxListItemProvider', () => ({ usePersonalDetails: () => ({}), @@ -38,9 +46,6 @@ jest.mock('@hooks/useCurrentUserPersonalDetails', () => () => ({ email: MOCK_EMAIL, })); -// eslint-disable-next-line @typescript-eslint/no-require-imports -const useSearchSelectorBase = require('@hooks/useSearchSelector.base').default; - function buildMockSortedActions(reportIDs: string[]): SortedReportActionsDerivedValue { const sortedActions: Record = {}; const lastActions: Record = {}; From e454d24ed2f5c8b5c21da4133225bfd0a1597c68 Mon Sep 17 00:00:00 2001 From: truph01 Date: Mon, 23 Mar 2026 00:33:44 +0700 Subject: [PATCH 15/21] fix: create selector --- src/hooks/useSearchSelector.base.ts | 4 ++-- src/pages/NewChatPage.tsx | 4 ++-- src/selectors/SortedReportActions.ts | 7 +++++++ 3 files changed, 11 insertions(+), 4 deletions(-) create mode 100644 src/selectors/SortedReportActions.ts diff --git a/src/hooks/useSearchSelector.base.ts b/src/hooks/useSearchSelector.base.ts index f682bbe03168..759497302aa5 100644 --- a/src/hooks/useSearchSelector.base.ts +++ b/src/hooks/useSearchSelector.base.ts @@ -1,3 +1,4 @@ +import {sortedActionsSelector} from '@selectors/SortedReportActions'; import {useCallback, useMemo, useState} from 'react'; import type {PermissionStatus} from 'react-native-permissions'; import {usePersonalDetails} from '@components/OnyxListItemProvider'; @@ -193,8 +194,7 @@ function useSearchSelectorBase({ const [draftComments] = useOnyx(ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT); const [nvpDismissedProductTraining] = useOnyx(ONYXKEYS.NVP_DISMISSED_PRODUCT_TRAINING); const [visibleReportActionsData] = useOnyx(ONYXKEYS.DERIVED.VISIBLE_REPORT_ACTIONS); - const [sortedReportActionsData] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); - const sortedActions = sortedReportActionsData?.sortedActions; + const [sortedActions] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS, {selector: sortedActionsSelector}); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const currentUserAccountID = currentUserPersonalDetails.accountID; const currentUserEmail = currentUserPersonalDetails.email ?? ''; diff --git a/src/pages/NewChatPage.tsx b/src/pages/NewChatPage.tsx index c5011a81c22e..cc789f98bb9c 100755 --- a/src/pages/NewChatPage.tsx +++ b/src/pages/NewChatPage.tsx @@ -1,5 +1,6 @@ import {useFocusEffect} from '@react-navigation/native'; import {hasSeenTourSelector} from '@selectors/Onboarding'; +import {sortedActionsSelector} from '@selectors/SortedReportActions'; import reject from 'lodash/reject'; import type {Ref} from 'react'; import React, {useEffect, useImperativeHandle, useRef, useState} from 'react'; @@ -75,8 +76,7 @@ function useOptions(reportAttributesDerived: ReportAttributesDerivedValue['repor const allPersonalDetails = usePersonalDetails(); const isScreenFocusedRef = useIsFocusedRef(); const [allPolicyTags] = useOnyx(ONYXKEYS.COLLECTION.POLICY_TAGS); - const [sortedReportActionsData] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS); - const sortedActions = sortedReportActionsData?.sortedActions; + const [sortedActions] = useOnyx(ONYXKEYS.DERIVED.SORTED_REPORT_ACTIONS, {selector: sortedActionsSelector}); const { options: listOptions, diff --git a/src/selectors/SortedReportActions.ts b/src/selectors/SortedReportActions.ts new file mode 100644 index 000000000000..cc5ae122f16c --- /dev/null +++ b/src/selectors/SortedReportActions.ts @@ -0,0 +1,7 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import type {SortedReportActionsDerivedValue} from '@src/types/onyx/DerivedValues'; + +const sortedActionsSelector = (value: OnyxEntry) => value?.sortedActions; +const lastActionsSelector = (value: OnyxEntry) => value?.lastActions; + +export {sortedActionsSelector, lastActionsSelector}; From 26b32abf30ef26fef8b27ec9eac9c132aaf2fd9d Mon Sep 17 00:00:00 2001 From: truph01 Date: Mon, 23 Mar 2026 00:39:02 +0700 Subject: [PATCH 16/21] fix: unit test --- tests/unit/useSearchSelectorTest.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/unit/useSearchSelectorTest.tsx b/tests/unit/useSearchSelectorTest.tsx index bcd9635fe76d..5d45fa237f93 100644 --- a/tests/unit/useSearchSelectorTest.tsx +++ b/tests/unit/useSearchSelectorTest.tsx @@ -11,13 +11,15 @@ import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct' jest.mock('@components/ConfirmedRoute.tsx'); +const EMPTY_OPTIONS = {recentReports: [], personalDetails: [], userToInvite: null, currentUserOption: null}; + // eslint-disable-next-line @typescript-eslint/no-unsafe-return jest.mock('@libs/OptionsListUtils', () => ({ // eslint-disable-next-line @typescript-eslint/naming-convention __esModule: true, ...jest.requireActual('@libs/OptionsListUtils'), - getValidOptions: jest.fn(), - getSearchOptions: jest.fn(), + getValidOptions: jest.fn(() => EMPTY_OPTIONS), + getSearchOptions: jest.fn(() => EMPTY_OPTIONS), })); const MOCK_ACCOUNT_ID = 12345; From e1258f79f553124505f6e4771a73db22c261baac Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 24 Mar 2026 15:46:30 +0700 Subject: [PATCH 17/21] fix: remove eslint disable and add todo comment --- src/libs/OptionsListUtils/index.ts | 1 - src/libs/OptionsListUtils/types.ts | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 25f752ade6a9..0f0ab09c652c 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -2304,7 +2304,6 @@ function prepareReportOptionsForDisplay( let lastIOUCreationDate; // Add a field to sort the recent reports by the time of last IOU request for create actions if (preferRecentExpenseReports) { - // eslint-disable-next-line @typescript-eslint/no-deprecated const reportPreviewAction = sortedActions[option.reportID]?.find((reportAction) => isActionOfType(reportAction, CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW)); if (reportPreviewAction) { diff --git a/src/libs/OptionsListUtils/types.ts b/src/libs/OptionsListUtils/types.ts index 38b4a839ec50..40f4f24117ee 100644 --- a/src/libs/OptionsListUtils/types.ts +++ b/src/libs/OptionsListUtils/types.ts @@ -222,6 +222,7 @@ type GetOptionsConfig = { countryCode?: number; visibleReportActionsData?: VisibleReportActionsDerivedValue; reportAttributesDerived?: ReportAttributesDerivedValue['reports']; + // TODO: Remove the optional operator once all call sites pass sortedActions (https://github.com/Expensify/App/issues/66381) sortedActions?: Record; } & GetValidReportsConfig; From 6f2d42f0e6ee918e0644e1535a91295a268cff72 Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 24 Mar 2026 15:54:51 +0700 Subject: [PATCH 18/21] fix: remove redundant eslint disable --- src/libs/OptionsListUtils/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 0f0ab09c652c..f739ede0ab15 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -2291,7 +2291,6 @@ function prepareReportOptionsForDisplay( let isOptionUnread = option.isUnread; if (shouldUnreadBeBold) { const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report.chatReportID}`]; - // eslint-disable-next-line @typescript-eslint/no-deprecated const oneTransactionThreadReportID = report.type === CONST.REPORT.TYPE.IOU || report.type === CONST.REPORT.TYPE.EXPENSE || report.type === CONST.REPORT.TYPE.INVOICE ? getOneTransactionThreadReportID(report, chatReport, sortedActions[report.reportID]) From d74f27fa0d53f40ddb573b061cd41991e667a447 Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 24 Mar 2026 16:32:54 +0700 Subject: [PATCH 19/21] fix: add tests --- .../configs/sortedReportActions.ts | 2 + .../unit/OnyxDerived/computeForReportTest.ts | 181 ++++++++++++++++++ 2 files changed, 183 insertions(+) create mode 100644 tests/unit/OnyxDerived/computeForReportTest.ts diff --git a/src/libs/actions/OnyxDerived/configs/sortedReportActions.ts b/src/libs/actions/OnyxDerived/configs/sortedReportActions.ts index bc5da9d09d21..bcf5d22a8ce5 100644 --- a/src/libs/actions/OnyxDerived/configs/sortedReportActions.ts +++ b/src/libs/actions/OnyxDerived/configs/sortedReportActions.ts @@ -104,3 +104,5 @@ export default createOnyxDerivedValueConfig({ return {sortedActions, lastActions, transactionThreadIDs}; }, }); + +export {computeForReport}; diff --git a/tests/unit/OnyxDerived/computeForReportTest.ts b/tests/unit/OnyxDerived/computeForReportTest.ts new file mode 100644 index 000000000000..0f644924b7b4 --- /dev/null +++ b/tests/unit/OnyxDerived/computeForReportTest.ts @@ -0,0 +1,181 @@ +import type {OnyxCollection} from 'react-native-onyx'; +import {computeForReport} from '@libs/actions/OnyxDerived/configs/sortedReportActions'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAction, ReportActions} from '@src/types/onyx'; + +function createAction(id: string, created: string, overrides: Partial = {}): ReportAction { + return { + reportActionID: id, + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + actorAccountID: 1, + created, + message: [{type: 'COMMENT', html: 'test', text: 'test'}], + originalMessage: {html: 'test', lastModified: created}, + avatar: '', + automatic: false, + shouldShow: true, + lastModified: created, + person: [{type: 'TEXT', style: 'strong', text: 'User'}], + ...overrides, + } as ReportAction; +} + +function createReport(reportID: string, overrides: Partial = {}): Report { + return { + reportID, + reportName: `Report ${reportID}`, + type: CONST.REPORT.TYPE.CHAT, + chatType: undefined, + ownerAccountID: 1, + isPinned: false, + ...overrides, + } as Report; +} + +describe('computeForReport', () => { + const reportID = '1'; + const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`; + const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${reportID}`; + + it('sorts actions in descending order (newest first)', () => { + const actions: ReportActions = { + '1': createAction('1', '2024-01-01 10:00:00.000'), + '2': createAction('2', '2024-01-02 10:00:00.000'), + '3': createAction('3', '2024-01-03 10:00:00.000'), + }; + const allReportActions: OnyxCollection = {[reportActionsKey]: actions}; + const allReports: OnyxCollection = {[reportKey]: createReport(reportID)}; + + const result = computeForReport(reportID, actions, allReportActions, allReports); + + expect(result.sortedReportActions.map((a) => a.reportActionID)).toEqual(['3', '2', '1']); + }); + + it('returns the newest action as lastAction', () => { + const actions: ReportActions = { + '1': createAction('1', '2024-01-01 10:00:00.000'), + '2': createAction('2', '2024-01-03 10:00:00.000'), + '3': createAction('3', '2024-01-02 10:00:00.000'), + }; + const allReportActions: OnyxCollection = {[reportActionsKey]: actions}; + const allReports: OnyxCollection = {[reportKey]: createReport(reportID)}; + + const result = computeForReport(reportID, actions, allReportActions, allReports); + + expect(result.lastAction?.reportActionID).toBe('2'); + }); + + it('returns undefined lastAction for an empty actions object', () => { + const actions: ReportActions = {}; + const allReportActions: OnyxCollection = {[reportActionsKey]: actions}; + const allReports: OnyxCollection = {[reportKey]: createReport(reportID)}; + + const result = computeForReport(reportID, actions, allReportActions, allReports); + + expect(result.sortedReportActions).toEqual([]); + expect(result.lastAction).toBeUndefined(); + }); + + it('returns undefined transactionThreadReportID for a non-expense report', () => { + const actions: ReportActions = { + '1': createAction('1', '2024-01-01 10:00:00.000'), + }; + const allReportActions: OnyxCollection = {[reportActionsKey]: actions}; + const allReports: OnyxCollection = {[reportKey]: createReport(reportID, {type: CONST.REPORT.TYPE.CHAT})}; + + const result = computeForReport(reportID, actions, allReportActions, allReports); + + expect(result.transactionThreadReportID).toBeUndefined(); + }); + + it('merges transaction thread actions for a one-transaction expense report', () => { + const transactionThreadReportID = '2'; + const transactionThreadActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`; + + const iouAction = createAction('100', '2024-01-01 10:00:00.000', { + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + childReportID: transactionThreadReportID, + originalMessage: { + IOUTransactionID: 'txn1', + IOUReportID: reportID, + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + amount: 100, + currency: 'USD', + }, + } as Partial); + + const parentActions: ReportActions = { + '100': iouAction, + }; + const threadActions: ReportActions = { + '200': createAction('200', '2024-01-01 11:00:00.000'), + '201': createAction('201', '2024-01-01 12:00:00.000'), + }; + const chatReport = createReport('3', {type: CONST.REPORT.TYPE.CHAT}); + const expenseReport = createReport(reportID, {type: CONST.REPORT.TYPE.EXPENSE, chatReportID: '3'}); + + const allReportActions: OnyxCollection = { + [reportActionsKey]: parentActions, + [transactionThreadActionsKey]: threadActions, + }; + const allReports: OnyxCollection = { + [reportKey]: expenseReport, + [`${ONYXKEYS.COLLECTION.REPORT}3`]: chatReport, + }; + + const result = computeForReport(reportID, parentActions, allReportActions, allReports); + + if (result.transactionThreadReportID) { + expect(result.sortedReportActions.length).toBeGreaterThan(Object.keys(parentActions).length); + + const threadActionIDs = result.sortedReportActions.map((a) => a.reportActionID); + expect(threadActionIDs).toContain('200'); + expect(threadActionIDs).toContain('201'); + } + }); + + it('handles single action correctly', () => { + const actions: ReportActions = { + '1': createAction('1', '2024-06-15 08:30:00.000'), + }; + const allReportActions: OnyxCollection = {[reportActionsKey]: actions}; + const allReports: OnyxCollection = {[reportKey]: createReport(reportID)}; + + const result = computeForReport(reportID, actions, allReportActions, allReports); + + expect(result.sortedReportActions).toHaveLength(1); + expect(result.sortedReportActions.at(0)?.reportActionID).toBe('1'); + expect(result.lastAction?.reportActionID).toBe('1'); + }); + + it('handles null allReportActions gracefully for transaction thread merging', () => { + const actions: ReportActions = { + '1': createAction('1', '2024-01-01 10:00:00.000'), + }; + const expenseReport = createReport(reportID, {type: CONST.REPORT.TYPE.EXPENSE, chatReportID: '3'}); + const chatReport = createReport('3', {type: CONST.REPORT.TYPE.CHAT}); + const allReports: OnyxCollection = { + [reportKey]: expenseReport, + [`${ONYXKEYS.COLLECTION.REPORT}3`]: chatReport, + }; + + const result = computeForReport(reportID, actions, undefined, allReports); + + expect(result.sortedReportActions).toHaveLength(1); + expect(result.lastAction?.reportActionID).toBe('1'); + }); + + it('handles null allReports gracefully', () => { + const actions: ReportActions = { + '1': createAction('1', '2024-01-01 10:00:00.000'), + '2': createAction('2', '2024-01-02 10:00:00.000'), + }; + const allReportActions: OnyxCollection = {[reportActionsKey]: actions}; + + const result = computeForReport(reportID, actions, allReportActions, undefined); + + expect(result.sortedReportActions.map((a) => a.reportActionID)).toEqual(['2', '1']); + expect(result.transactionThreadReportID).toBeUndefined(); + }); +}); From 06ed595d3c8418c67392e4684ef1ea80539a763d Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 24 Mar 2026 16:38:25 +0700 Subject: [PATCH 20/21] fix: lint --- .../unit/OnyxDerived/computeForReportTest.ts | 63 +++++++++---------- 1 file changed, 31 insertions(+), 32 deletions(-) diff --git a/tests/unit/OnyxDerived/computeForReportTest.ts b/tests/unit/OnyxDerived/computeForReportTest.ts index 0f644924b7b4..54701756ba05 100644 --- a/tests/unit/OnyxDerived/computeForReportTest.ts +++ b/tests/unit/OnyxDerived/computeForReportTest.ts @@ -33,17 +33,24 @@ function createReport(reportID: string, overrides: Partial = {}): Report } as Report; } +function toReportActions(...actions: ReportAction[]): ReportActions { + const result: ReportActions = {}; + for (const action of actions) { + result[action.reportActionID] = action; + } + return result; +} + describe('computeForReport', () => { const reportID = '1'; const reportActionsKey = `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`; const reportKey = `${ONYXKEYS.COLLECTION.REPORT}${reportID}`; it('sorts actions in descending order (newest first)', () => { - const actions: ReportActions = { - '1': createAction('1', '2024-01-01 10:00:00.000'), - '2': createAction('2', '2024-01-02 10:00:00.000'), - '3': createAction('3', '2024-01-03 10:00:00.000'), - }; + const action1 = createAction('1', '2024-01-01 10:00:00.000'); + const action2 = createAction('2', '2024-01-02 10:00:00.000'); + const action3 = createAction('3', '2024-01-03 10:00:00.000'); + const actions = toReportActions(action1, action2, action3); const allReportActions: OnyxCollection = {[reportActionsKey]: actions}; const allReports: OnyxCollection = {[reportKey]: createReport(reportID)}; @@ -53,11 +60,10 @@ describe('computeForReport', () => { }); it('returns the newest action as lastAction', () => { - const actions: ReportActions = { - '1': createAction('1', '2024-01-01 10:00:00.000'), - '2': createAction('2', '2024-01-03 10:00:00.000'), - '3': createAction('3', '2024-01-02 10:00:00.000'), - }; + const action1 = createAction('1', '2024-01-01 10:00:00.000'); + const action2 = createAction('2', '2024-01-03 10:00:00.000'); + const action3 = createAction('3', '2024-01-02 10:00:00.000'); + const actions = toReportActions(action1, action2, action3); const allReportActions: OnyxCollection = {[reportActionsKey]: actions}; const allReports: OnyxCollection = {[reportKey]: createReport(reportID)}; @@ -78,9 +84,8 @@ describe('computeForReport', () => { }); it('returns undefined transactionThreadReportID for a non-expense report', () => { - const actions: ReportActions = { - '1': createAction('1', '2024-01-01 10:00:00.000'), - }; + const action1 = createAction('1', '2024-01-01 10:00:00.000'); + const actions = toReportActions(action1); const allReportActions: OnyxCollection = {[reportActionsKey]: actions}; const allReports: OnyxCollection = {[reportKey]: createReport(reportID, {type: CONST.REPORT.TYPE.CHAT})}; @@ -105,13 +110,10 @@ describe('computeForReport', () => { }, } as Partial); - const parentActions: ReportActions = { - '100': iouAction, - }; - const threadActions: ReportActions = { - '200': createAction('200', '2024-01-01 11:00:00.000'), - '201': createAction('201', '2024-01-01 12:00:00.000'), - }; + const parentActions = toReportActions(iouAction); + const threadAction200 = createAction('200', '2024-01-01 11:00:00.000'); + const threadAction201 = createAction('201', '2024-01-01 12:00:00.000'); + const threadActions = toReportActions(threadAction200, threadAction201); const chatReport = createReport('3', {type: CONST.REPORT.TYPE.CHAT}); const expenseReport = createReport(reportID, {type: CONST.REPORT.TYPE.EXPENSE, chatReportID: '3'}); @@ -136,9 +138,8 @@ describe('computeForReport', () => { }); it('handles single action correctly', () => { - const actions: ReportActions = { - '1': createAction('1', '2024-06-15 08:30:00.000'), - }; + const action1 = createAction('1', '2024-06-15 08:30:00.000'); + const actions = toReportActions(action1); const allReportActions: OnyxCollection = {[reportActionsKey]: actions}; const allReports: OnyxCollection = {[reportKey]: createReport(reportID)}; @@ -149,10 +150,9 @@ describe('computeForReport', () => { expect(result.lastAction?.reportActionID).toBe('1'); }); - it('handles null allReportActions gracefully for transaction thread merging', () => { - const actions: ReportActions = { - '1': createAction('1', '2024-01-01 10:00:00.000'), - }; + it('handles undefined allReportActions gracefully for transaction thread merging', () => { + const action1 = createAction('1', '2024-01-01 10:00:00.000'); + const actions = toReportActions(action1); const expenseReport = createReport(reportID, {type: CONST.REPORT.TYPE.EXPENSE, chatReportID: '3'}); const chatReport = createReport('3', {type: CONST.REPORT.TYPE.CHAT}); const allReports: OnyxCollection = { @@ -166,11 +166,10 @@ describe('computeForReport', () => { expect(result.lastAction?.reportActionID).toBe('1'); }); - it('handles null allReports gracefully', () => { - const actions: ReportActions = { - '1': createAction('1', '2024-01-01 10:00:00.000'), - '2': createAction('2', '2024-01-02 10:00:00.000'), - }; + it('handles undefined allReports gracefully', () => { + const action1 = createAction('1', '2024-01-01 10:00:00.000'); + const action2 = createAction('2', '2024-01-02 10:00:00.000'); + const actions = toReportActions(action1, action2); const allReportActions: OnyxCollection = {[reportActionsKey]: actions}; const result = computeForReport(reportID, actions, allReportActions, undefined); From 5a10d7c1950b436f7426c97da0b4f191c06e2a2f Mon Sep 17 00:00:00 2001 From: truph01 Date: Tue, 24 Mar 2026 22:58:53 +0700 Subject: [PATCH 21/21] fix: conflicts --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index de8080ec4eb7..55a46afd67eb 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit de8080ec4eb7635fd7a8aaf1ef6bb05ad5dc6049 +Subproject commit 55a46afd67ebd107a1d6b757390d88c683bc1d4e