Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion src/hooks/useSearchBulkActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ import {
shouldShowMarkAsDone,
} from '@libs/ReportUtils';
import {buildSearchQueryJSON, buildSearchQueryString, getFilterFromQuery, isDefaultExpensesQuery, serializeQueryJSONForBackend} from '@libs/SearchQueryUtils';
import refreshSearchAfterReportAction from '@libs/SearchRefreshUtils';
import {getColumnsToShow, getSearchColumnTranslationKey, getSelectedGroupFilterEntry, getValidGroupBy, isGroupEntry, navigateToSearchRHP, shouldShowDeleteOption} from '@libs/SearchUIUtils';
import showConfirmModalAfterMoreMenuDismiss from '@libs/showConfirmModalAfterMoreMenuDismiss';
import playSound, {SOUNDS} from '@libs/Sound';
Expand Down Expand Up @@ -129,6 +130,7 @@ import usePermissions from './usePermissions';
import usePersonalPolicy from './usePersonalPolicy';
import usePolicyForMovingExpenses from './usePolicyForMovingExpenses';
import useRestrictedActionPolicyID from './useRestrictedActionPolicyID';
import useSearchShouldCalculateTotals from './useSearchShouldCalculateTotals';
import useSelfDMReport from './useSelfDMReport';
import useSplitEffectivePolicy from './useSplitEffectivePolicy';
import useTheme from './useTheme';
Expand Down Expand Up @@ -424,7 +426,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
const {showDelegateNoAccessModal} = useDelegateNoAccessActions();
const {selectedTransactions, excludedTransactions = getEmptyObject<SelectedTransactions>(), selectedReports, areAllMatchingItemsSelected} = useSearchSelectionContext();
const {currentSearchResults} = useSearchResultsContext();
const {currentSearchKey} = useSearchQueryContext();
const {currentSearchKey, currentSearchQueryJSON} = useSearchQueryContext();
const {clearSelectedTransactions, selectAllMatchingItems} = useSearchSelectionActions();
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
const {accountID, email, login: currentUserLogin, localCurrencyCode} = currentUserPersonalDetails;
Expand Down Expand Up @@ -660,6 +662,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
});

const {hash} = queryJSON ?? {};
const shouldCalculateTotalsOnRefresh = useSearchShouldCalculateTotals(currentSearchKey, hash, true);
const isExpenseType = queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE;
const selectedTransactionsKeys = Object.keys(selectedTransactions ?? {});
// Use currentSearchResults, not the lastNonEmpty fallback: the export scope must reflect the query on screen now,
Expand Down Expand Up @@ -2132,6 +2135,13 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
managerEmail,
managerAccountID,
);
refreshSearchAfterReportAction({
currentSearchQueryJSON,
currentSearchKey,
shouldCalculateTotals: shouldCalculateTotalsOnRefresh,
isOffline,
isLoading: !!currentSearchResults?.search?.isLoading,
});
clearSelectedTransactions();
},
});
Expand All @@ -2145,6 +2155,16 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
submitMoneyRequestOnSearch(hash, [item as Report], [policy], getLoginByAccountID(item.ownerAccountID, personalDetails));
}
}
// Submitting only changes the report, so the rows keep serving the snapshot's pre-submit report
// context (which still offers Submit) until the snapshot is refetched, the same way approving and
// paying from Search already do.
refreshSearchAfterReportAction({
currentSearchQueryJSON,
currentSearchKey,
shouldCalculateTotals: shouldCalculateTotalsOnRefresh,
isOffline,
isLoading: !!currentSearchResults?.search?.isLoading,
});
clearSelectedTransactions();
},
});
Expand Down Expand Up @@ -2544,6 +2564,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) {
noReportsShouldMarkAsDone,
queryJSON?.groupBy,
delegateAccountID,
currentSearchQueryJSON,
currentSearchResults?.search?.isLoading,
shouldCalculateTotalsOnRefresh,
]);

const handleOfflineModalClose = useCallback(() => {
Expand Down
82 changes: 68 additions & 14 deletions src/hooks/useSearchHighlightAndScroll.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,26 +85,30 @@ function useSearchHighlightAndScroll({
const previousTransactionIDsLocal = Object.keys(previousTransactions ?? {});
const transactionsIDs = Object.keys(transactions ?? {});

const reportActionsIDs = Object.values(reportActions ?? {})
.map((actions) => Object.keys(actions ?? {}))
.flat();
const previousReportActionsIDs = Object.values(previousReportActions ?? {})
.map((actions) => Object.keys(actions ?? {}))
.flat();

// Only proceed if we have previous data to compare against
// This prevents triggering on initial data load
if ((previousTransactionIDsLocal.length === 0 && previousReportActionsIDs.length === 0) || searchTriggeredRef.current) {
const hasPreviousReportActions = Object.values(previousReportActions ?? {}).some((actions) => Object.keys(actions ?? {}).length > 0);
if ((previousTransactionIDsLocal.length === 0 && !hasPreviousReportActions) || searchTriggeredRef.current) {
return;
}

// Only chat searches are driven by report actions, so the rest skip walking that collection entirely.
const reportActionsIDs = isChat ? Object.values(reportActions ?? {}).flatMap((actions) => Object.keys(actions ?? {})) : [];
const previousReportActionsIDs = isChat ? Object.values(previousReportActions ?? {}).flatMap((actions) => Object.keys(actions ?? {})) : [];

const previousTransactionsIDsSet = new Set(previousTransactionIDsLocal);
const previousReportActionsIDsSet = new Set(previousReportActionsIDs);
const hasTransactionsIDsChange = transactionsIDs.length !== previousTransactionIDsLocal.length || transactionsIDs.some((id) => !previousTransactionsIDsSet.has(id));
const hasReportActionsIDsChange = reportActionsIDs.some((id) => !previousReportActionsIDsSet.has(id));

// Editing an expense that the results already show changes no transaction ID, so the ID checks above miss it.
// The rows and the footer total are served from the Search snapshot, and the footer total is computed by the
// server, so those edits only become visible after a refetch.
const hasChangedResultTransaction =
!isChat && !hasTransactionsIDsChange && hasChangedTransactionInSearchResults(transactions, previousTransactions, previousTransactionsIDsSet, searchResultsData);

// Check if there is a change in the transactions or report actions list
if ((!isChat && hasTransactionsIDsChange) || hasReportActionsIDsChange || hasPendingSearchRef.current) {
if ((isChat ? hasReportActionsIDsChange : hasTransactionsIDsChange || hasChangedResultTransaction) || hasPendingSearchRef.current) {
// Skip if offline, or if the user has navigated to a different fullscreen page entirely.
// An RHP layered on top of Search makes `isFocused` false but keeps Search as the topmost
// fullscreen route, so we still want to refetch — otherwise the snapshot can't reflect
Expand All @@ -114,21 +118,37 @@ function useSearchHighlightAndScroll({
hasPendingSearchRef.current = true;
return;
}
// A deferred refetch is its own reason to search. `usePrevious` advances while Search is inactive, so by
// the time it becomes active again the change that set the flag has washed out of the comparisons below.
const hadPendingSearch = hasPendingSearchRef.current;
hasPendingSearchRef.current = false;

const newIDs = isChat ? reportActionsIDs : transactionsIDs;
// Transaction Onyx keys are `transactions_<id>` but search results yield bare IDs, so read the ID off the value.
const addedTransactionIDs: string[] = [];
const currentTransactionIDs: string[] = [];
for (const [key, transaction] of Object.entries(transactions ?? {})) {
const transactionID = transaction?.transactionID;
if (!transactionID) {
continue;
}
currentTransactionIDs.push(transactionID);
if (!previousTransactionsIDsSet.has(key)) {
addedTransactionIDs.push(transactionID);
}
}

let currentSearchResultIDs: string[] = [];
if (searchResultsData) {
currentSearchResultIDs = isChat ? extractReportActionIDsFromSearchResults(searchResultsData) : extractTransactionIDsFromSearchResults(searchResultsData);
}
const existingSearchResultIDsSet = new Set(currentSearchResultIDs);
const hasAGenuinelyNewID = newIDs.some((id) => !existingSearchResultIDsSet.has(id));
const hasAGenuinelyNewID = (isChat ? reportActionsIDs : addedTransactionIDs).some((id) => !existingSearchResultIDsSet.has(id));
Comment thread
BartekObudzinski marked this conversation as resolved.

// Only skip search if there are no new items AND search results aren't empty
// This ensures deletions that result in empty data still trigger search
if (!hasAGenuinelyNewID && currentSearchResultIDs.length > 0) {
const newIDsSet = new Set(newIDs);
const hasDeletedID = currentSearchResultIDs.some((id) => !newIDsSet.has(id));
if (!hasAGenuinelyNewID && !hasChangedResultTransaction && !hadPendingSearch && currentSearchResultIDs.length > 0) {
const currentIDsSet = new Set(isChat ? reportActionsIDs : currentTransactionIDs);
const hasDeletedID = currentSearchResultIDs.some((id) => !currentIDsSet.has(id));
if (!hasDeletedID) {
return;
}
Expand Down Expand Up @@ -362,5 +382,39 @@ function extractReportActionIDsFromSearchResults(searchResultsData: Partial<Sear
.flat();
}

/**
* Whether a transaction that the current search results already display has changed.
*
* Onyx keeps one value object per collection member and only replaces the ones it writes, so an identity check is
* enough to spot an edit. A refetch triggered from here can't feed itself, because a Search response only writes
* snapshot keys and never touches the transaction collection.
*/
function hasChangedTransactionInSearchResults(
transactions: OnyxCollection<Transaction>,
previousTransactions: OnyxCollection<Transaction>,
previousTransactionKeys: Set<string>,
searchResultsData: Partial<SearchResults['data']> | undefined,
): boolean {
if (!searchResultsData) {
return false;
}

const changedTransactionIDs: string[] = [];
for (const [key, transaction] of Object.entries(transactions ?? {})) {
if (!transaction?.transactionID || !previousTransactionKeys.has(key) || previousTransactions?.[key] === transaction) {
continue;
}
changedTransactionIDs.push(transaction.transactionID);
}

// Walking the results is the expensive half, so only do it once something actually changed.
if (changedTransactionIDs.length === 0) {
return false;
}

const searchResultIDs = new Set(extractTransactionIDsFromSearchResults(searchResultsData));
return changedTransactionIDs.some((transactionID) => searchResultIDs.has(transactionID));
}

export default useSearchHighlightAndScroll;
export type {UseSearchHighlightAndScroll};
Loading
Loading