diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv index 7285d0f402f8..44174db31757 100644 --- a/config/eslint/eslint.seatbelt.tsv +++ b/config/eslint/eslint.seatbelt.tsv @@ -836,7 +836,7 @@ "../../src/libs/actions/IOU/UpdateMoneyRequest.ts" "no-restricted-syntax" 1 "../../src/libs/actions/IOU/index.ts" "@typescript-eslint/no-deprecated/getMoneyRequestPolicyTags" 1 "../../src/libs/actions/IOU/index.ts" "@typescript-eslint/no-deprecated/getPolicyTagsData" 2 -"../../src/libs/actions/IOU/index.ts" "rulesdir/no-onyx-connect" 10 +"../../src/libs/actions/IOU/index.ts" "rulesdir/no-onyx-connect" 12 "../../src/libs/actions/ImportTransactions.ts" "@typescript-eslint/no-unsafe-type-assertion" 2 "../../src/libs/actions/ImportTransactions.ts" "no-restricted-syntax" 1 "../../src/libs/actions/InputFocus/index.web.ts" "no-restricted-syntax" 1 diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index cbeff2cf3b78..db80cd45b162 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -634,6 +634,9 @@ const ONYXKEYS = { /** Stores the current search page context (e.g., whether to show the search query) */ SEARCH_CONTEXT: 'searchContext', + /** Maps each loaded search snapshot's hash to its original query string, used to fan optimistic IOU updates to every matching snapshot */ + SEARCH_QUERY_BY_HASH: 'searchQueryByHash', + /** Stores recently used currencies */ RECENTLY_USED_CURRENCIES: 'nvp_recentlyUsedCurrencies', @@ -1427,6 +1430,7 @@ type OnyxValuesMapping = { [ONYXKEYS.RECENT_SEARCHES]: Record; [ONYXKEYS.SAVED_SEARCHES]: OnyxTypes.SaveSearch; [ONYXKEYS.SEARCH_CONTEXT]: OnyxTypes.SearchContext; + [ONYXKEYS.SEARCH_QUERY_BY_HASH]: Record; [ONYXKEYS.RECENTLY_USED_CURRENCIES]: string[]; [ONYXKEYS.ACTIVE_CLIENTS]: string[]; [ONYXKEYS.DEVICE_ID]: string; diff --git a/src/libs/actions/IOU/SearchUpdate.ts b/src/libs/actions/IOU/SearchUpdate.ts index cf9d238ba918..70ff5b6e55e5 100644 --- a/src/libs/actions/IOU/SearchUpdate.ts +++ b/src/libs/actions/IOU/SearchUpdate.ts @@ -11,7 +11,7 @@ import type * as OnyxTypes from '@src/types/onyx'; import type {Participant} from '@src/types/onyx/IOU'; import type {OnyxData} from '@src/types/onyx/Request'; import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; -import {getCurrentUserPersonalDetails} from './index'; +import {getAllSnapshots, getCurrentUserPersonalDetails, getSearchQueryByHash} from './index'; type ExpenseReportStatusPredicate = (expenseReport: OnyxEntry, transactionReportID?: string) => boolean; @@ -90,6 +90,11 @@ function shouldOptimisticallyUpdateSearch( const hasNoFlatFilters = currentSearchQueryJSON.flatFilters.length === 0; + const onlyFromFilter = currentSearchQueryJSON.flatFilters.length === 1 ? currentSearchQueryJSON.flatFilters.at(0) : undefined; + const matchesFromQuery: boolean = + onlyFromFilter?.key === CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM && + onlyFromFilter.filters.some((f) => f.operator === CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO && String(f.value) === String(currentUserAccountID)); + const matchesSubmitQuery = submitQueryJSON?.similarSearchHash === currentSearchQueryJSON.similarSearchHash && expenseReportStatusFilterMapping[CONST.SEARCH.STATUS.EXPENSE.DRAFTS](iouReport); @@ -102,7 +107,7 @@ function shouldOptimisticallyUpdateSearch( (expenseReportStatusFilterMapping[CONST.SEARCH.STATUS.EXPENSE.DRAFTS](iouReport) || expenseReportStatusFilterMapping[CONST.SEARCH.STATUS.EXPENSE.OUTSTANDING](iouReport)) && transaction?.reimbursable; - const matchesFilterQuery = hasNoFlatFilters || matchesSubmitQuery || matchesApproveQuery || matchesUnapprovedCashQuery; + const matchesFilterQuery = hasNoFlatFilters || matchesFromQuery || matchesSubmitQuery || matchesApproveQuery || matchesUnapprovedCashQuery; return shouldOptimisticallyUpdateByStatus && validSearchTypes && matchesFilterQuery; } @@ -120,15 +125,83 @@ function getSearchOnyxUpdate({ const toAccountID = participant?.accountID; const deprecatedCurrentUserPersonalDetails = getCurrentUserPersonalDetails(); const fromAccountID = deprecatedCurrentUserPersonalDetails?.accountID; - const currentSearchQueryJSON = getCurrentSearchQueryJSON(); - if (!currentSearchQueryJSON || toAccountID === undefined || fromAccountID === undefined) { + if (toAccountID === undefined || fromAccountID === undefined) { return; } - if (shouldOptimisticallyUpdateSearch(currentSearchQueryJSON, iouReport, isInvoice, fromAccountID, transaction)) { - const isOptimisticToAccountData = isOptimisticPersonalDetail(toAccountID); - const successData = []; + // Common transaction payload merged into every matching snapshot. + const baseSnapshotData: SearchResultDataType = {}; + baseSnapshotData[ONYXKEYS.PERSONAL_DETAILS_LIST] = { + [toAccountID]: { + accountID: toAccountID, + displayName: participant?.displayName, + login: participant?.login, + }, + [fromAccountID]: { + accountID: fromAccountID, + avatar: deprecatedCurrentUserPersonalDetails?.avatar, + displayName: deprecatedCurrentUserPersonalDetails?.displayName, + login: deprecatedCurrentUserPersonalDetails?.login, + }, + }; + baseSnapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = { + ...(transactionThreadReportID && {transactionThreadReportID}), + ...(isFromOneTransactionReport && {isFromOneTransactionReport}), + ...transaction, + }; + if (policy) { + baseSnapshotData[`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`] = policy; + } + if (iouReport) { + baseSnapshotData[`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`] = iouReport; + } + if (iouReport && iouAction) { + baseSnapshotData[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`] = {[iouAction.reportActionID]: iouAction}; + } + + const isOptimisticToAccountData = isOptimisticPersonalDetail(toAccountID); + const optimisticData: Array> = []; + const successData: Array> = []; + const writtenHashes = new Set(); + const allSnapshots = getAllSnapshots() ?? {}; + + const writeForQuery = (queryJSON: Readonly, existingSnapshot: OnyxEntry) => { + if (writtenHashes.has(queryJSON.hash)) { + return; + } + if (!shouldOptimisticallyUpdateSearch(queryJSON, iouReport, isInvoice, fromAccountID, transaction)) { + return; + } + + const snapshotData: SearchResultDataType = {...baseSnapshotData}; + + if (queryJSON.groupBy === CONST.SEARCH.GROUP_BY.FROM) { + const groupKey = `${CONST.SEARCH.GROUP_PREFIX}${fromAccountID}` as const; + const existingGroup = existingSnapshot?.data?.[groupKey]; + snapshotData[groupKey] = { + accountID: fromAccountID, + count: (existingGroup?.count ?? 0) + 1, + total: (existingGroup?.total ?? 0) + (transaction.amount ?? 0), + currency: existingGroup?.currency ?? transaction.currency ?? CONST.CURRENCY.USD, + }; + } + + optimisticData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON.hash}` as const, + value: { + search: { + type: queryJSON.type, + status: queryJSON.status, + hasResults: true, + isLoading: false, + }, + data: snapshotData, + }, + }); + writtenHashes.add(queryJSON.hash); + if (isOptimisticToAccountData) { // The optimistic personal detail is cleared from PERSONAL_DETAILS_LIST on API success, but the snapshot's report still references // that optimistic accountID via report.managerID. Re-merging the personal detail into the snapshot in successData prevents the @@ -136,7 +209,7 @@ function getSearchOnyxUpdate({ // See https://github.com/Expensify/App/issues/61310 for more information. successData.push({ onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${currentSearchQueryJSON.hash}` as const, + key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${queryJSON.hash}` as const, value: { data: { [ONYXKEYS.PERSONAL_DETAILS_LIST]: { @@ -150,59 +223,11 @@ function getSearchOnyxUpdate({ }, }); } - // Building this object sequentially resolves TypeScript type inference issues - const optimisticSnapshotData: SearchResultDataType = {}; - - optimisticSnapshotData[ONYXKEYS.PERSONAL_DETAILS_LIST] = { - [toAccountID]: { - accountID: toAccountID, - displayName: participant?.displayName, - login: participant?.login, - }, - [fromAccountID]: { - accountID: fromAccountID, - avatar: deprecatedCurrentUserPersonalDetails?.avatar, - displayName: deprecatedCurrentUserPersonalDetails?.displayName, - login: deprecatedCurrentUserPersonalDetails?.login, - }, - }; - - optimisticSnapshotData[`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`] = { - ...(transactionThreadReportID && {transactionThreadReportID}), - ...(isFromOneTransactionReport && {isFromOneTransactionReport}), - ...transaction, - }; - if (policy) { - optimisticSnapshotData[`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`] = policy; - } - - if (iouReport) { - optimisticSnapshotData[`${ONYXKEYS.COLLECTION.REPORT}${iouReport.reportID}`] = iouReport; - } - - if (iouReport && iouAction) { - optimisticSnapshotData[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport.reportID}`] = {[iouAction.reportActionID]: iouAction}; - } - - const optimisticData: Array> = [ - { - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${currentSearchQueryJSON.hash}` as const, - value: { - search: { - type: currentSearchQueryJSON.type, - status: currentSearchQueryJSON.status, - hasResults: true, - isLoading: false, - }, - data: optimisticSnapshotData, - }, - }, - ]; - - if (currentSearchQueryJSON.groupBy === CONST.SEARCH.GROUP_BY.FROM) { - const newFlatFilters = currentSearchQueryJSON.flatFilters.filter((filter) => filter.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM); + // For a group-by:from view, also pre-populate the per-member transactions snapshot + // so that opening the group row immediately shows the new transaction. + if (queryJSON.groupBy === CONST.SEARCH.GROUP_BY.FROM) { + const newFlatFilters = queryJSON.flatFilters.filter((filter) => filter.key !== CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM); newFlatFilters.push({ key: CONST.SEARCH.SYNTAX_FILTER_KEYS.FROM, filters: [{operator: CONST.SEARCH.SYNTAX_OPERATORS.EQUAL_TO, value: fromAccountID}], @@ -210,13 +235,13 @@ function getSearchOnyxUpdate({ const groupTransactionsQueryJSON = buildSearchQueryJSON( buildSearchQueryString({ - ...currentSearchQueryJSON, + ...queryJSON, groupBy: undefined, flatFilters: newFlatFilters, }), ); - if (groupTransactionsQueryJSON?.hash) { + if (groupTransactionsQueryJSON?.hash && !writtenHashes.has(groupTransactionsQueryJSON.hash)) { optimisticData.push({ onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.SNAPSHOT}${groupTransactionsQueryJSON.hash}` as const, @@ -229,17 +254,45 @@ function getSearchOnyxUpdate({ hasResults: true, isLoading: false, }, - data: optimisticSnapshotData, + data: baseSnapshotData, }, }); + writtenHashes.add(groupTransactionsQueryJSON.hash); } } + }; + + // 1. Always cover the currently-active search query. Its snapshot may not exist yet + // (e.g. user opened the filter for the first time while offline), but Onyx MERGE will create it. + const currentSearchQueryJSON = getCurrentSearchQueryJSON(); + if (currentSearchQueryJSON) { + writeForQuery(currentSearchQueryJSON, allSnapshots[`${ONYXKEYS.COLLECTION.SNAPSHOT}${currentSearchQueryJSON.hash}`]); + } + + // 2. Fan out to every other loaded snapshot whose recorded query also matches this transaction. + // This catches cases like creating an expense from a chat while a `from:` filter or + // `groupBy:from` view is loaded but not the currently active search. The hash→query map is + // stored in a dedicated Onyx key (not on the snapshot) so SEARCH API responses can't wipe it. + const queryByHash = getSearchQueryByHash(); + for (const [hashString, queryString] of Object.entries(queryByHash)) { + if (!queryString) { + continue; + } + const queryJSON = buildSearchQueryJSON(queryString); + if (!queryJSON) { + continue; + } + writeForQuery(queryJSON, allSnapshots[`${ONYXKEYS.COLLECTION.SNAPSHOT}${hashString}`]); + } - return { - optimisticData, - successData, - }; + if (optimisticData.length === 0) { + return; } + + return { + optimisticData, + successData, + }; } export {getSearchOnyxUpdate, shouldOptimisticallyUpdateSearch}; diff --git a/src/libs/actions/IOU/index.ts b/src/libs/actions/IOU/index.ts index 3105782caffb..a9e41bee8410 100644 --- a/src/libs/actions/IOU/index.ts +++ b/src/libs/actions/IOU/index.ts @@ -117,6 +117,40 @@ Onyx.connectWithoutView({ callback: (value) => (recentAttendees = value), }); +let searchQueryByHash: Record = {}; +Onyx.connect({ + key: ONYXKEYS.SEARCH_QUERY_BY_HASH, + callback: (value) => { + searchQueryByHash = value ?? {}; + }, +}); + +let allSnapshots: OnyxCollection = {}; +let knownSnapshotHashes = new Set(); +Onyx.connect({ + key: ONYXKEYS.COLLECTION.SNAPSHOT, + waitForCollectionCallback: true, + callback: (value) => { + allSnapshots = value ?? {}; + // Keep SEARCH_QUERY_BY_HASH bounded by mirroring the snapshot collection's lifecycle: + // when a snapshot disappears, drop its query entry so the map can never outgrow it. + const snapshotPrefixLength = ONYXKEYS.COLLECTION.SNAPSHOT.length; + const currentHashes = new Set(Object.keys(allSnapshots).map((k) => k.slice(snapshotPrefixLength))); + // Reconcile against persisted SEARCH_QUERY_BY_HASH too, so entries whose snapshots were evicted + // before this JS session get pruned on first sync (not just hashes seen since startup). + const candidates = new Set([...knownSnapshotHashes, ...Object.keys(searchQueryByHash)]); + const removed = [...candidates].filter((h) => !currentHashes.has(h)); + if (removed.length > 0) { + const evictions: Record = {}; + for (const h of removed) { + evictions[h] = null; + } + Onyx.merge(ONYXKEYS.SEARCH_QUERY_BY_HASH, evictions); + } + knownSnapshotHashes = currentHashes; + }, +}); + function getAllPersonalDetails(): OnyxTypes.PersonalDetailsList { return allPersonalDetails; } @@ -157,6 +191,14 @@ function getRecentAttendees(): OnyxEntry { return recentAttendees; } +function getAllSnapshots(): OnyxCollection { + return allSnapshots; +} + +function getSearchQueryByHash(): Record { + return searchQueryByHash; +} + /** * This function uses Onyx.connect and should be replaced with useOnyx for reactive data access. * TODO: remove `getPolicyTagsData` from this file (https://github.com/Expensify/App/issues/72721) @@ -227,6 +269,8 @@ export { getAllTransactionDrafts, getCurrentUserPersonalDetails, getRecentAttendees, + getAllSnapshots, + getSearchQueryByHash, // eslint-disable-next-line @typescript-eslint/no-deprecated buildParticipantsPolicyTags, // TODO: Replace getPolicyTagsData (https://github.com/Expensify/App/issues/72721) and getPolicyRecentlyUsedTagsData (https://github.com/Expensify/App/issues/71491) with useOnyx hook diff --git a/src/libs/actions/Search.ts b/src/libs/actions/Search.ts index 23d4526eb7e2..17d1d1e4f57c 100644 --- a/src/libs/actions/Search.ts +++ b/src/libs/actions/Search.ts @@ -564,6 +564,14 @@ function getOnyxLoadingData( }, ]; + // Side effect: record this query string under SEARCH_QUERY_BY_HASH so IOU optimistic updates + // can later fan to every loaded snapshot whose query matches. Done here (not via optimisticData) + // because this function's return type only allows snapshot keys; the matching eviction lives + // in the SNAPSHOT subscription in IOU/index.ts. + if (queryJSON?.inputQuery) { + Onyx.merge(ONYXKEYS.SEARCH_QUERY_BY_HASH, {[hash]: queryJSON.inputQuery}); + } + const finallyData: Array> = [ { onyxMethod: Onyx.METHOD.MERGE, diff --git a/tests/actions/IOU/SearchUpdateTest.ts b/tests/actions/IOU/SearchUpdateTest.ts index 99a1216878e9..95f73cf2ff86 100644 --- a/tests/actions/IOU/SearchUpdateTest.ts +++ b/tests/actions/IOU/SearchUpdateTest.ts @@ -3,7 +3,7 @@ import Onyx from 'react-native-onyx'; import type {OnyxEntry} from 'react-native-onyx'; import type {SearchQueryJSON, SearchStatus} from '@components/Search/types'; import '@libs/actions/IOU/MoneyRequest'; -import {shouldOptimisticallyUpdateSearch} from '@libs/actions/IOU/SearchUpdate'; +import {getSearchOnyxUpdate, shouldOptimisticallyUpdateSearch} from '@libs/actions/IOU/SearchUpdate'; import initOnyxDerivedValues from '@libs/actions/OnyxDerived'; import type * as PolicyUtils from '@libs/PolicyUtils'; import CONST from '@src/CONST'; @@ -338,4 +338,37 @@ describe('actions/IOU', () => { expect(shouldOptimisticallyUpdateSearch(currentSearchQueryJSON, nonMatchingIOUReport, false, RORY_ACCOUNT_ID, transaction)).toBeFalsy(); }); }); + + describe('getSearchOnyxUpdate', () => { + it('returns undefined when the participant has no accountID', () => { + const result = getSearchOnyxUpdate({ + transaction: {...createRandomTransaction(1)}, + participant: {}, + iouReport: undefined, + iouAction: undefined, + policy: undefined, + transactionThreadReportID: undefined, + isFromOneTransactionReport: false, + isInvoice: false, + }); + expect(result).toBeUndefined(); + }); + + it('returns undefined when there is no current user account', async () => { + await Onyx.set(ONYXKEYS.PERSONAL_DETAILS_LIST, {}); + await Onyx.set(ONYXKEYS.SESSION, {}); + await waitForBatchedUpdates(); + const result = getSearchOnyxUpdate({ + transaction: {...createRandomTransaction(1)}, + participant: {accountID: 42, login: 'test@test.com'}, + iouReport: undefined, + iouAction: undefined, + policy: undefined, + transactionThreadReportID: undefined, + isFromOneTransactionReport: false, + isInvoice: false, + }); + expect(result).toBeUndefined(); + }); + }); });