diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx index a25be06bf7ac..d187557e5039 100644 --- a/src/components/LHNOptionsList/LHNOptionsList.tsx +++ b/src/components/LHNOptionsList/LHNOptionsList.tsx @@ -28,21 +28,13 @@ import getPlatform from '@libs/getPlatform'; import Log from '@libs/Log'; import {getMovedReportID} from '@libs/ModifiedExpenseMessage'; import {getIOUReportIDOfLastAction, getLastMessageTextForReport} from '@libs/OptionsListUtils'; -import { - getOneTransactionThreadReportID, - getOriginalMessage, - getSortedReportActions, - getSortedReportActionsForDisplay, - isInviteOrRemovedAction, - isMoneyRequestAction, - shouldReportActionBeVisibleAsLastAction, -} from '@libs/ReportActionsUtils'; +import {findLastReportActions, getOriginalMessage, isInviteOrRemovedAction, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import {canUserPerformWriteAction as canUserPerformWriteActionUtil} from '@libs/ReportUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import NAVIGATORS from '@src/NAVIGATORS'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {PersonalDetails, Report, ReportAction} from '@src/types/onyx'; +import type {PersonalDetails, Report} from '@src/types/onyx'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import OptionRowLHNData from './OptionRowLHNData'; import OptionRowRendererComponent from './OptionRowRendererComponent'; @@ -175,14 +167,13 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio const renderItem = useCallback( ({item, index}: RenderItemProps): ReactElement => { const reportID = item.reportID; + const itemReportAttributes = reportAttributes?.[reportID]; const itemParentReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${item.parentReportID}`]; const itemReportNameValuePairs = reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${reportID}`]; - const chatReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${item.chatReportID}`]; const itemReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`]; - const itemOneTransactionThreadReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${getOneTransactionThreadReportID(item, chatReport, itemReportActions, isOffline)}`]; + const itemOneTransactionThreadReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${itemReportAttributes?.oneTransactionThreadReportID}`]; const itemParentReportActions = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${item?.parentReportID}`]; const itemParentReportAction = item?.parentReportActionID ? itemParentReportActions?.[item?.parentReportActionID] : undefined; - const itemReportAttributes = reportAttributes?.[reportID]; let invoiceReceiverPolicyID = '-1'; if (item?.invoiceReceiver && 'policyID' in item.invoiceReceiver) { @@ -207,8 +198,7 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio const isReportArchived = !!itemReportNameValuePairs?.private_isArchived; const canUserPerformWrite = canUserPerformWriteActionUtil(item, isReportArchived); - const sortedReportActions = getSortedReportActionsForDisplay(itemReportActions, canUserPerformWrite); - const lastReportAction = sortedReportActions.at(0); + const {lastVisibleAction: lastReportAction, lastActionForDisplay: lastAction} = findLastReportActions(itemReportActions, canUserPerformWrite); // Get the transaction for the last report action const lastReportActionTransactionID = isMoneyRequestAction(lastReportAction) @@ -245,18 +235,6 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio const shouldShowRBRorGBRTooltip = firstReportIDWithGBRorRBR === reportID; - let lastAction: ReportAction | undefined; - if (!itemReportActions || !item) { - lastAction = undefined; - } else { - const canUserPerformWriteAction = canUserPerformWriteActionUtil(item, isReportArchived); - const actionsArray = getSortedReportActions(Object.values(itemReportActions)); - const reportActionsForDisplay = actionsArray.filter( - (reportAction) => shouldReportActionBeVisibleAsLastAction(reportAction, canUserPerformWriteAction) && reportAction.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED, - ); - lastAction = reportActionsForDisplay.at(-1); - } - let lastActionReport: OnyxEntry | undefined; if (isInviteOrRemovedAction(lastAction)) { const lastActionOriginalMessage = lastAction?.actionName ? getOriginalMessage(lastAction) : null; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index 4c8d29da5ece..50bc146a016f 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -260,8 +260,8 @@ Onyx.connect({ const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`]; - // If the report is a one-transaction report and has , we need to return the combined reportActions so that the LHN can display modifications - // to the transaction thread or the report itself + // 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. const transactionThreadReportID = getOneTransactionThreadReportID(report, chatReport, actions[reportActions[0]]); if (transactionThreadReportID) { const transactionThreadReportActionsArray = Object.values(actions[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`] ?? {}); @@ -542,9 +542,6 @@ function isSearchStringMatchUserDetails(personalDetail: PersonalDetails, searchV return isSearchStringMatch(searchValue.trim(), memberDetails.toLowerCase()); } -/** - * Get IOU report ID of report last action if the action is report action preview - */ function getIOUReportIDOfLastAction(report: OnyxEntry): string | undefined { if (!report?.reportID) { return; diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts index 7bb9723934e1..310b5bfdcf3d 100644 --- a/src/libs/ReportActionsUtils.ts +++ b/src/libs/ReportActionsUtils.ts @@ -1387,6 +1387,75 @@ function getSortedReportActionsForDisplay( return getSortedReportActions(withDEWRoutedActionsArray(baseURLAdjustedReportActions), true); } +/** + * Returns true if action `a` is newer than action `b`, + * matching getSortedReportActions order (descending by timestamp, with CREATED always oldest). + */ +function isNewerReportAction(a: ReportAction, b: ReportAction): boolean { + if (a.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { + return false; + } + if (b.actionName === CONST.REPORT.ACTIONS.TYPE.CREATED) { + return true; + } + + // Undefined created is treated as oldest (mirrors getSortedReportActions which places undefined first in ascending order) + if (a.created === undefined && b.created !== undefined) { + return false; + } + if (a.created !== undefined && b.created === undefined) { + return true; + } + + if (a.created !== b.created) { + return a.created > b.created; + } + + if (a.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && b.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { + return true; + } + if (b.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW && a.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { + return false; + } + + return a.reportActionID > b.reportActionID; +} + +/** + * Finds the newest report action matching each of two filter criteria in a single pass. + * Returns: + * - lastVisibleAction: newest visible action + * - lastActionForDisplay: newest displayable action (not CREATED) + */ +function findLastReportActions(reportActions: OnyxEntry, canUserPerformWriteAction?: boolean) { + if (!reportActions) { + return {lastVisibleAction: undefined, lastActionForDisplay: undefined}; + } + + let lastVisibleAction: ReportAction | undefined; + let lastActionForDisplay: ReportAction | undefined; + + for (const [key, action] of Object.entries(reportActions)) { + if (!action) { + continue; + } + + if (shouldReportActionBeVisible(action, key, canUserPerformWriteAction)) { + if (!lastVisibleAction || isNewerReportAction(action, lastVisibleAction)) { + lastVisibleAction = action; + } + } + + if (shouldReportActionBeVisibleAsLastAction(action, canUserPerformWriteAction) && action.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED) { + if (!lastActionForDisplay || isNewerReportAction(action, lastActionForDisplay)) { + lastActionForDisplay = action; + } + } + } + + return {lastVisibleAction, lastActionForDisplay}; +} + /** * The first visible action is the second last action in sortedReportActions which satisfy following conditions: * 1. That is not pending deletion as pending deletion actions are kept in sortedReportActions in memory. @@ -4159,6 +4228,7 @@ export { isAddCommentAction, isApprovedOrSubmittedReportAction, isIOURequestReportAction, + isNewerReportAction, isChronosOOOListAction, isClosedAction, isConsecutiveActionMadeByPreviousActor, @@ -4244,6 +4314,7 @@ export { getCardIssuedMessage, getRemovedConnectionMessage, getActionableJoinRequestPendingReportAction, + findLastReportActions, getFilteredReportActionsForReportView, wasMessageReceivedWhileOffline, shouldShowAddMissingDetails, diff --git a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts index e998c18507f5..69be37203a37 100644 --- a/src/libs/actions/OnyxDerived/configs/reportAttributes.ts +++ b/src/libs/actions/OnyxDerived/configs/reportAttributes.ts @@ -201,7 +201,7 @@ export default createOnyxDerivedValueConfig({ const reportNameValuePair = reportNameValuePairs?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${report.reportID}`]; const reportActionsList = reportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`]; const isReportArchived = isArchivedReport(reportNameValuePair); - const {hasAnyViolations, requiresAttention, reportErrors} = generateReportAttributes({ + const {hasAnyViolations, requiresAttention, reportErrors, oneTransactionThreadReportID} = generateReportAttributes({ report, chatReport, reportActions, @@ -227,6 +227,7 @@ export default createOnyxDerivedValueConfig({ brickRoadStatus, requiresAttention, reportErrors, + oneTransactionThreadReportID, }; return acc; diff --git a/src/types/onyx/DerivedValues.ts b/src/types/onyx/DerivedValues.ts index e7d033ccbd47..15b11f237c37 100644 --- a/src/types/onyx/DerivedValues.ts +++ b/src/types/onyx/DerivedValues.ts @@ -33,6 +33,10 @@ type ReportAttributes = { * The errors of the report. */ reportErrors: Errors; + /** + * The reportID of the one-transaction thread report, if applicable. + */ + oneTransactionThreadReportID?: string; }; /** diff --git a/tests/unit/ReportActionsUtilsTest.ts b/tests/unit/ReportActionsUtilsTest.ts index a2732899644e..3a0074d838f8 100644 --- a/tests/unit/ReportActionsUtilsTest.ts +++ b/tests/unit/ReportActionsUtilsTest.ts @@ -11,6 +11,7 @@ import {chatReportR14932 as mockChatReport, iouReportR14932 as mockIOUReport} fr import CONST from '../../src/CONST'; import * as ReportActionsUtils from '../../src/libs/ReportActionsUtils'; import { + findLastReportActions, getAutoPayApprovedReportsEnabledMessage, getAutoReimbursementMessage, getCardIssuedMessage, @@ -25,8 +26,12 @@ import { getPolicyChangeLogMaxExpenseAmountNoReceiptMessage, getReportActionActorAccountID, getSendMoneyFlowAction, + getSortedReportActions, + getSortedReportActionsForDisplay, getUpdateACHAccountMessage, isIOUActionMatchingTransactionList, + isNewerReportAction, + shouldReportActionBeVisibleAsLastAction, } from '../../src/libs/ReportActionsUtils'; import {buildOptimisticCreatedReportForUnapprovedAction} from '../../src/libs/ReportUtils'; import ONYXKEYS from '../../src/ONYXKEYS'; @@ -3429,4 +3434,186 @@ describe('ReportActionsUtils', () => { expect(result).toBe('changed the auto-pay approved reports threshold to "$1,000.00" (previously "$500.00")'); }); }); + + describe('isNewerReportAction', () => { + const makeAction = (overrides: Partial): ReportAction => + ({ + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + reportActionID: '1', + created: '2024-01-01 00:00:00.000', + ...overrides, + }) as ReportAction; + + it('should return true when a has a later timestamp than b', () => { + const a = makeAction({created: '2024-01-02 00:00:00.000', reportActionID: '1'}); + const b = makeAction({created: '2024-01-01 00:00:00.000', reportActionID: '2'}); + expect(isNewerReportAction(a, b)).toBeTruthy(); + }); + + it('should return false when a has an earlier timestamp than b', () => { + const a = makeAction({created: '2024-01-01 00:00:00.000', reportActionID: '1'}); + const b = makeAction({created: '2024-01-02 00:00:00.000', reportActionID: '2'}); + expect(isNewerReportAction(a, b)).toBeFalsy(); + }); + + it('should treat CREATED action as always oldest', () => { + const created = makeAction({actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, created: '2024-12-31 00:00:00.000', reportActionID: '1'}); + const comment = makeAction({created: '2024-01-01 00:00:00.000', reportActionID: '2'}); + + expect(isNewerReportAction(created, comment)).toBeFalsy(); + expect(isNewerReportAction(comment, created)).toBeTruthy(); + }); + + it('should treat undefined created as oldest', () => { + const withUndefined = makeAction({created: undefined, reportActionID: '1'}); + const withDate = makeAction({created: '2024-01-01 00:00:00.000', reportActionID: '2'}); + + expect(isNewerReportAction(withUndefined, withDate)).toBeFalsy(); + expect(isNewerReportAction(withDate, withUndefined)).toBeTruthy(); + }); + + it('should prefer REPORT_PREVIEW over other actions when timestamps match', () => { + const preview = makeAction({actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, created: '2024-01-01 00:00:00.000', reportActionID: '1'}); + const comment = makeAction({actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, created: '2024-01-01 00:00:00.000', reportActionID: '2'}); + + expect(isNewerReportAction(preview, comment)).toBeTruthy(); + expect(isNewerReportAction(comment, preview)).toBeFalsy(); + }); + + it('should fall back to reportActionID comparison when timestamps and action types match', () => { + const higher = makeAction({created: '2024-01-01 00:00:00.000', reportActionID: '200'}); + const lower = makeAction({created: '2024-01-01 00:00:00.000', reportActionID: '100'}); + + expect(isNewerReportAction(higher, lower)).toBeTruthy(); + expect(isNewerReportAction(lower, higher)).toBeFalsy(); + }); + }); + + describe('findLastReportActions', () => { + const makeAction = (overrides: Partial): ReportAction => + ({ + actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT, + reportActionID: '1', + created: '2024-01-01 00:00:00.000', + person: [{type: 'TEXT', style: 'strong', text: 'Actor'}], + message: [{html: 'hello', text: 'hello', type: 'COMMENT'}], + ...overrides, + }) as ReportAction; + + it('returns undefined for both when reportActions is undefined', () => { + const result = findLastReportActions(undefined); + expect(result.lastVisibleAction).toBeUndefined(); + expect(result.lastActionForDisplay).toBeUndefined(); + }); + + it('returns undefined for both when reportActions is empty', () => { + const result = findLastReportActions({}); + expect(result.lastVisibleAction).toBeUndefined(); + expect(result.lastActionForDisplay).toBeUndefined(); + }); + + it('returns the single visible action for both when there is only one ADD_COMMENT action', () => { + const action = makeAction({reportActionID: '1', created: '2024-01-01 00:00:00.000'}); + const result = findLastReportActions({[action.reportActionID]: action}); + expect(result.lastVisibleAction).toBe(action); + expect(result.lastActionForDisplay).toBe(action); + }); + + it('returns undefined for lastActionForDisplay but not lastVisibleAction when only action is CREATED', () => { + const created = makeAction({actionName: CONST.REPORT.ACTIONS.TYPE.CREATED, reportActionID: '1', created: '2024-01-01 00:00:00.000'}); + const result = findLastReportActions({[created.reportActionID]: created}); + expect(result.lastVisibleAction).toBe(created); + expect(result.lastActionForDisplay).toBeUndefined(); + }); + + it('returns the newest of multiple visible actions', () => { + const older = makeAction({reportActionID: '1', created: '2024-01-01 00:00:00.000'}); + const newer = makeAction({reportActionID: '2', created: '2024-01-02 00:00:00.000'}); + const result = findLastReportActions({ + [older.reportActionID]: older, + [newer.reportActionID]: newer, + }); + expect(result.lastVisibleAction).toBe(newer); + expect(result.lastActionForDisplay).toBe(newer); + }); + + it('skips deleted actions (no pendingAction, empty html) for both results', () => { + const visible = makeAction({reportActionID: '1', created: '2024-01-01 00:00:00.000'}); + const deleted = makeAction({ + reportActionID: '2', + created: '2024-01-02 00:00:00.000', + message: [{html: '', text: '', type: 'COMMENT'}], + pendingAction: undefined, + }); + const result = findLastReportActions({ + [visible.reportActionID]: visible, + [deleted.reportActionID]: deleted, + }); + expect(result.lastVisibleAction).toBe(visible); + expect(result.lastActionForDisplay).toBe(visible); + }); + + it('excludes actions with errors from lastActionForDisplay but not from lastVisibleAction', () => { + const clean = makeAction({reportActionID: '1', created: '2024-01-01 00:00:00.000'}); + const withErrors = makeAction({ + reportActionID: '2', + created: '2024-01-02 00:00:00.000', + errors: {someError: 'error message'}, + }); + const result = findLastReportActions({ + [clean.reportActionID]: clean, + [withErrors.reportActionID]: withErrors, + }); + expect(result.lastVisibleAction).toBe(withErrors); + expect(result.lastActionForDisplay).toBe(clean); + }); + + it('agrees with getSortedReportActionsForDisplay for lastVisibleAction across multiple actions', () => { + const actionA = makeAction({reportActionID: 'actionA', created: '2024-01-01 00:00:00.000'}); + const actionB = makeAction({reportActionID: 'actionB', created: '2024-01-03 00:00:00.000'}); + const actionC = makeAction({reportActionID: 'actionC', created: '2024-01-02 00:00:00.000'}); + const actions: ReportActions = { + actionA, + actionB, + actionC, + }; + const {lastVisibleAction} = findLastReportActions(actions); + const fromSort = getSortedReportActionsForDisplay(actions).at(0); + expect(lastVisibleAction?.reportActionID).toBe(fromSort?.reportActionID); + }); + + it('agrees with the old getSortedReportActions+filter approach for lastActionForDisplay', () => { + const actionA = makeAction({reportActionID: 'actionA', created: '2024-01-01 00:00:00.000'}); + const actionB = makeAction({reportActionID: 'actionB', created: '2024-01-03 00:00:00.000'}); + const actionC = makeAction({ + reportActionID: 'actionC', + created: '2024-01-02 00:00:00.000', + errors: {someError: 'error'}, + }); + const actions: ReportActions = {actionA, actionB, actionC}; + const {lastActionForDisplay} = findLastReportActions(actions); + const fromOldApproach = getSortedReportActions(Object.values(actions)).findLast( + (a) => shouldReportActionBeVisibleAsLastAction(a) && a.actionName !== CONST.REPORT.ACTIONS.TYPE.CREATED, + ); + expect(lastActionForDisplay?.reportActionID).toBe(fromOldApproach?.reportActionID); + }); + + it('respects canUserPerformWriteAction when determining visibility', () => { + const normalAction = makeAction({reportActionID: '1', created: '2024-01-01 00:00:00.000'}); + // An actionable mention whisper is hidden when canUserPerformWriteAction is false + const joinRequestAction = makeAction({ + reportActionID: '2', + created: '2024-01-02 00:00:00.000', + actionName: CONST.REPORT.ACTIONS.TYPE.ACTIONABLE_MENTION_WHISPER, + }); + + const withWrite = findLastReportActions({[normalAction.reportActionID]: normalAction, [joinRequestAction.reportActionID]: joinRequestAction}, true); + const withoutWrite = findLastReportActions({[normalAction.reportActionID]: normalAction, [joinRequestAction.reportActionID]: joinRequestAction}, false); + + // With write permission: join request is visible, so it should be selected as newer + expect(withWrite.lastVisibleAction?.reportActionID).toBe(joinRequestAction.reportActionID); + // Without write permission: join request hidden, so only the normal action remains + expect(withoutWrite.lastVisibleAction?.reportActionID).toBe(normalAction.reportActionID); + }); + }); });