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
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,13 @@ import useSyncFocus from '@hooks/useSyncFocus';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';

import durationHighlightItem from '@libs/Navigation/helpers/getDurationHighlightItem';

import CONST from '@src/CONST';

import type {View} from 'react-native';

import React, {useRef} from 'react';
import React, {useEffect, useRef, useState} from 'react';

import type {TransactionListItemNarrowProps} from './types';

Expand Down Expand Up @@ -76,6 +78,35 @@ function TransactionListItemNarrow<TItem extends ListItem>({
shouldApplyOtherStyles: true,
});

// The highlight animation is applied to the row wrapper, which sits behind this pressable. A focused
// row paints an opaque background on the pressable itself, which would cover the highlight - so after
// splitting an expense the newly-created row that receives focus never appears highlighted. Suppress
// the opaque focus background for the full highlight animation so the highlight shows. shouldAnimateInHighlight
// only stays true for the brief queue window, so latch it for durationHighlightItem.
const shouldAnimateInHighlight = !!item?.shouldAnimateInHighlight;

// Initialize from the prop so a row that mounts already flagged (the split/search highlight case this
// fixes) latches immediately - otherwise the render-time guard below never fires on first mount.
const [isHighlighting, setIsHighlighting] = useState(shouldAnimateInHighlight);
const [wasAnimatingHighlight, setWasAnimatingHighlight] = useState(shouldAnimateInHighlight);

// Start the latch during render (React's "storing information from previous renders" pattern) to avoid
// calling setState synchronously inside an effect. The effect below only clears it via an async timer.
if (shouldAnimateInHighlight !== wasAnimatingHighlight) {
Comment thread
aswin-s marked this conversation as resolved.
setWasAnimatingHighlight(shouldAnimateInHighlight);
if (shouldAnimateInHighlight) {
setIsHighlighting(true);
}
}
useEffect(() => {
if (!isHighlighting) {
return;
}
const timer = setTimeout(() => setIsHighlighting(false), durationHighlightItem);
return () => clearTimeout(timer);
}, [isHighlighting]);
const shouldShowFocusBackground = !!isFocused && !isHighlighting;

return (
<OfflineWithFeedback pendingAction={item.pendingAction}>
<PressableWithFeedback
Expand All @@ -92,7 +123,7 @@ function TransactionListItemNarrow<TItem extends ListItem>({
sentryLabel={CONST.SENTRY_LABEL.SEARCH.TRANSACTION_LIST_ITEM}
style={[
pressableStyle,
isFocused && StyleUtils.getItemBackgroundColorStyle(isSelected, !!isFocused, !!item.isDisabled, theme.activeComponentBG, theme.hoverComponentBG),
shouldShowFocusBackground && StyleUtils.getItemBackgroundColorStyle(isSelected, !!isFocused, !!item.isDisabled, theme.activeComponentBG, theme.hoverComponentBG),
isDeletedTransaction && styles.cursorDefault,
]}
onFocus={onFocus}
Expand Down
51 changes: 41 additions & 10 deletions src/libs/actions/IOU/SplitTransactionUpdate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,12 @@ import {
navigateBackOnDeleteTransaction,
updateOptimisticParentReportAction,
} from '@libs/ReportUtils';
import {getCurrentSearchQueryJSON} from '@libs/SearchQueryUtils';
import {isTracking, setPendingSubmitFollowUpAction} from '@libs/telemetry/submitFollowUpAction';
import {getChildTransactions, isDistanceRequest as isDistanceRequestTransactionUtils, isOnHold, isPerDiemRequest as isPerDiemRequestTransactionUtils} from '@libs/TransactionUtils';

import {setDeleteTransactionNavigateBackUrl} from '@userActions/Report';
import {mergeTransactionIdsHighlightOnSearchRoute} from '@userActions/Transaction';
import {removeDraftSplitTransaction} from '@userActions/TransactionEdit';

import CONST from '@src/CONST';
Expand Down Expand Up @@ -1865,6 +1867,12 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac
const hasEditableSplitExpensesLeft = splitExpenses.some((expense) => (expense.statusNum ?? 0) < CONST.REPORT.STATUS_NUM.SUBMITTED);
const isReverseSplitOperation =
splitExpenses.length === 1 && originalChildTransactions.length > 0 && hasEditableSplitExpensesLeft && allChildTransactions.length === originalChildTransactions.length;

// Newly created split transaction IDs, excluding ones already present in allChildTransactions.
function getNewSplitTransactionIDs(): string[] {
const existingChildTransactionIDs = new Set(allChildTransactions.map((tx) => tx?.transactionID).filter(Boolean));
return splitExpenses.map((splitExpense) => splitExpense.transactionID).filter((transactionID) => transactionID && !existingChildTransactionIDs.has(transactionID));
}
const expenseReportID = params.expenseReport?.reportID;

// Detect whether the expense report the user is editing from will be emptied by this save.
Expand Down Expand Up @@ -1929,20 +1937,32 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac

const targetReportID = params.expenseReport?.reportID ?? String(CONST.DEFAULT_NUMBER_ID);

// Register newly created split transaction IDs so they briefly highlight in the expense list.
// We skip existing transactions (already in allChildTransactions), reverse splits (no new transactions are created),
// and the last-transaction case (the report navigates away before the highlight renders).
if (params.expenseReport?.reportID && !isReverseSplitOperation && !isLastTransactionInReport) {
const existingChildTransactionIDs = new Set(allChildTransactions.map((tx) => tx?.transactionID).filter(Boolean));
for (const splitExpense of splitExpenses) {
if (!splitExpense.transactionID || existingChildTransactionIDs.has(splitExpense.transactionID)) {
continue;
}
addPendingNewTransactionIDs(targetReportID, splitExpense.transactionID);
// Register newly created split transaction IDs so they briefly highlight on the Search/Spend page.
// The Search page reads TRANSACTION_IDS_HIGHLIGHT_ON_SEARCH_ROUTE, which highlights matching rows
// optimistically without waiting for a server re-search. Unlike the auto-detect path in
// useSearchHighlightAndScroll (skipped while offline), this makes the highlight work offline too.
// Reverse splits create no new transactions, and existing children are already in the list, so both are skipped.
function registerSearchRouteHighlight() {
if (!isSearchPageTopmostFullScreenRoute || isReverseSplitOperation) {
return;
}
const currentSearchType = getCurrentSearchQueryJSON()?.type;
if (!currentSearchType) {
return;
}
const newTransactionIDsToHighlight: Record<string, boolean> = {};
for (const transactionID of getNewSplitTransactionIDs()) {
newTransactionIDsToHighlight[transactionID] = true;
}
if (isEmptyObject(newTransactionIDsToHighlight)) {
return;
}
mergeTransactionIdsHighlightOnSearchRoute(currentSearchType, newTransactionIDsToHighlight);
}

if (isSearchPageTopmostFullScreenRoute || !params.transactionReport?.parentReportID) {
registerSearchRouteHighlight();

if (!isSelfDMSplit) {
Navigation.navigateBackToLastSuperWideRHPScreen();
}
Expand Down Expand Up @@ -1978,6 +1998,17 @@ function updateSplitTransactionsFromSplitExpensesFlow(params: UpdateSplitTransac
return;
}

// Register newly created split transaction IDs so they briefly highlight in the expense list.
// This only runs on the path that opens the expense report (dismissModalWithReport), so the highlight
// flags are consumed and cleared on mount. We skip existing transactions (already in allChildTransactions)
// and reverse splits (no new transactions are created). The Search/Spend page and last-transaction cases
// return earlier above, so they never pollute REPORT_METADATA with flags that would never be cleared.
if (params.expenseReport?.reportID && !isReverseSplitOperation && !isLastTransactionInReport) {
for (const transactionID of getNewSplitTransactionIDs()) {
addPendingNewTransactionIDs(targetReportID, transactionID);
}
}
Comment thread
mountiny marked this conversation as resolved.

if (isTracking()) {
setPendingSubmitFollowUpAction(CONST.TELEMETRY.SUBMIT_FOLLOW_UP_ACTION.DISMISS_MODAL_AND_OPEN_REPORT, targetReportID);
}
Expand Down
67 changes: 67 additions & 0 deletions tests/actions/IOU/SplitReportTotalsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {createSplitsAndOnyxData} from '@libs/actions/IOU/Split';
import {updateSplitTransactionsFromSplitExpensesFlow} from '@libs/actions/IOU/SplitTransactionUpdate';
import initOnyxDerivedValues from '@libs/actions/OnyxDerived';
import isReportTopmostSplitNavigator from '@libs/Navigation/helpers/isReportTopmostSplitNavigator';
import isSearchTopmostFullScreenRoute from '@libs/Navigation/helpers/isSearchTopmostFullScreenRoute';
import {rand64} from '@libs/NumberUtils';
import type * as PolicyUtils from '@libs/PolicyUtils';

Expand Down Expand Up @@ -883,5 +884,71 @@ describe('actions/IOU', () => {
// Then nothing is registered — the list navigates away before any highlight could render
expect(addPendingNewTransactionIDs).not.toHaveBeenCalled();
});

it('registers the search-route highlight (not report metadata) when splitting from the Search/Spend page', async () => {
// Given the user is on the Search (Spend > Expenses) page, where the expense report is never opened
jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(true);
const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute');
const params = buildBaseParams({
transactionData: {
reportID: EXPENSE_REPORT_ID,
originalTransactionID: ORIGINAL_TX_ID,
splitExpenses: [
{transactionID: 'new-tx-1', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'},
{transactionID: 'new-tx-2', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 500, created: '2024-01-01'},
],
splitExpensesTotal: 1000,
},
});

// When saving the split from the Search page
updateSplitTransactionsFromSplitExpensesFlow(params);
await waitForBatchedUpdates();

// Then the report-metadata highlight is skipped — the report is never mounted, so those flags would
// never be cleared and would incorrectly highlight rows when the report is later opened from the Inbox.
expect(addPendingNewTransactionIDs).not.toHaveBeenCalled();

// And instead the new IDs are registered on the search-route highlight, keyed by the current search type.
// This mechanism highlights optimistically without a server re-search, so it works offline too.
expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).toHaveBeenCalledWith(
'expense',
Object.fromEntries([
['new-tx-1', true],
['new-tx-2', true],
]),
);

spyOnMergeTransactionIdsHighlightOnSearchRoute.mockRestore();
});

it('skips the search-route highlight during a reverse split from the Search/Spend page', async () => {
// Given the user is on the Search page and this is a reverse split (1 expense, existing child present)
jest.mocked(isSearchTopmostFullScreenRoute).mockReturnValue(true);
const spyOnMergeTransactionIdsHighlightOnSearchRoute = jest.spyOn(require('@libs/actions/Transaction'), 'mergeTransactionIdsHighlightOnSearchRoute');
const existingChildTx = {
transactionID: 'child-tx-1',
reportID: EXPENSE_REPORT_ID,
comment: {originalTransactionID: ORIGINAL_TX_ID, source: CONST.IOU.TYPE.SPLIT},
};
const params = buildBaseParams({
allTransactionsList: {[`${ONYXKEYS.COLLECTION.TRANSACTION}child-tx-1`]: existingChildTx},
transactionData: {
reportID: EXPENSE_REPORT_ID,
originalTransactionID: ORIGINAL_TX_ID,
splitExpenses: [{transactionID: 'new-merged-tx', reportID: EXPENSE_REPORT_ID, statusNum: 0, amount: 1000, created: '2024-01-01'}],
splitExpensesTotal: 1000,
},
});

// When saving the reverse split
updateSplitTransactionsFromSplitExpensesFlow(params);
await waitForBatchedUpdates();

// Then nothing is highlighted — reverse splits create no new transactions
expect(spyOnMergeTransactionIdsHighlightOnSearchRoute).not.toHaveBeenCalled();

spyOnMergeTransactionIdsHighlightOnSearchRoute.mockRestore();
});
});
});
Loading