From 6241cd3130491a0b05498db2d4b20f3e68ada907 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 18 Jun 2025 16:02:02 +0200 Subject: [PATCH 01/18] Change getOneTransactionThreadReportID return value --- src/libs/ReportUtils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index a8067590646f9..72c51cf1059e4 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2315,7 +2315,7 @@ function hasOnlyNonReimbursableTransactions(iouReportID: string | undefined): bo function isOneTransactionReport(report: OnyxEntry): boolean { const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`] ?? ([] as ReportAction[]); const chatReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`]; - return getOneTransactionThreadReportID(report, chatReport, reportActions) !== null; + return !!getOneTransactionThreadReportID(report, chatReport, reportActions); } /* From a7eada74fbc528e2c41a580c20877df2e7a9ee92 Mon Sep 17 00:00:00 2001 From: sumo_slonik Date: Wed, 18 Jun 2025 16:24:16 +0200 Subject: [PATCH 02/18] Bring back getIcons test --- tests/unit/ReportUtilsGetIconsTest.ts | 531 ++++++++++++++++++++++++++ 1 file changed, 531 insertions(+) create mode 100644 tests/unit/ReportUtilsGetIconsTest.ts diff --git a/tests/unit/ReportUtilsGetIconsTest.ts b/tests/unit/ReportUtilsGetIconsTest.ts new file mode 100644 index 0000000000000..8625944f0f2c4 --- /dev/null +++ b/tests/unit/ReportUtilsGetIconsTest.ts @@ -0,0 +1,531 @@ +import Onyx from 'react-native-onyx'; +import { + getIcons, + isAdminRoom, + isAnnounceRoom, + isChatReport, + isChatRoom, + isChatThread, + isExpenseReport, + isExpenseRequest, + isGroupChat, + isIndividualInvoiceRoom, + isInvoiceReport, + isInvoiceRoom, + isIOUReport, + isMoneyRequestReport, + isOneTransactionReport, + isPolicyExpenseChat, + isSelfDM, + isTaskReport, + isThread, + isWorkspaceTaskReport, + isWorkspaceThread, +} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report} from '@src/types/onyx'; +import * as LHNTestUtils from '../utils/LHNTestUtils'; + +const FAKE_PERSONAL_DETAILS = LHNTestUtils.fakePersonalDetails; +/* eslint-disable @typescript-eslint/naming-convention */ +const FAKE_REPORT_ACTIONS = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: { + '1': {actorAccountID: 2, actionName: CONST.REPORT.ACTIONS.TYPE.IOU}, + }, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2`]: { + '2': {actorAccountID: 1, actionName: CONST.REPORT.ACTIONS.TYPE.IOU}, + }, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}3`]: { + '2': { + actorAccountID: 1, + actionName: CONST.REPORT.ACTIONS.TYPE.IOU, + originalMessage: { + type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, + }, + }, + }, + // For workspace thread test - parent report actions + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}workspaceParent`]: { + '1': {actorAccountID: 2, actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}, + }, + // For multi-transaction IOU test - multiple transactions + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}multiTxn`]: { + '1': {actorAccountID: 1, actionName: CONST.REPORT.ACTIONS.TYPE.IOU}, + '2': {actorAccountID: 1, actionName: CONST.REPORT.ACTIONS.TYPE.IOU}, + }, +}; +/* eslint-enable @typescript-eslint/naming-convention */ +const FAKE_REPORTS = { + [`${ONYXKEYS.COLLECTION.REPORT}1`]: { + ...LHNTestUtils.getFakeReport([1, 2], 0, true), + invoiceReceiver: { + type: CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL, + accountID: 3, + }, + }, + // This is the parent report for the expense request test. + // It MUST have type: 'expense' for the isExpenseRequest() check to pass. + [`${ONYXKEYS.COLLECTION.REPORT}2`]: { + ...LHNTestUtils.getFakeReport([1], 0, true), + reportID: '2', + type: CONST.REPORT.TYPE.EXPENSE, + }, + + // This is the parent report for the expense request test. + // It MUST have type: 'expense' for the isExpenseRequest() check to pass. + [`${ONYXKEYS.COLLECTION.REPORT}3`]: { + ...LHNTestUtils.getFakeReport([1], 0, true), + reportID: '3', + parentReportID: '2', + parentReportActionID: '2', + type: CONST.REPORT.TYPE.EXPENSE, + }, + // Parent workspace chat for workspace thread test + [`${ONYXKEYS.COLLECTION.REPORT}workspaceParent`]: { + ...LHNTestUtils.getFakeReport([1], 0, true), + reportID: 'workspaceParent', + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + policyID: '1', + }, + // Parent policy expense chat for workspace task test + [`${ONYXKEYS.COLLECTION.REPORT}taskParent`]: { + ...LHNTestUtils.getFakeReport([1], 0, true), + reportID: 'taskParent', + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + policyID: '1', + }, + // Chat report for multi-transaction IOU test + [`${ONYXKEYS.COLLECTION.REPORT}chatReport`]: { + ...LHNTestUtils.getFakeReport([1, 2], 0, true), + reportID: 'chatReport', + }, +}; +const FAKE_POLICIES = { + [`${ONYXKEYS.COLLECTION.POLICY}1`]: LHNTestUtils.getFakePolicy('1'), +}; + +const currentUserAccountID = 5; + +beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + initialKeyStates: { + [ONYXKEYS.SESSION]: {email: FAKE_PERSONAL_DETAILS[currentUserAccountID]?.login, accountID: currentUserAccountID}, + [ONYXKEYS.PERSONAL_DETAILS_LIST]: FAKE_PERSONAL_DETAILS, + ...FAKE_REPORT_ACTIONS, + ...FAKE_REPORTS, + ...FAKE_POLICIES, + }, + }); + // @ts-expect-error Until we add NVP_PRIVATE_DOMAINS to ONYXKEYS, we need to mock it here + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + Onyx.connect({key: ONYXKEYS.NVP_PRIVATE_DOMAINS, callback: () => {}}); +}); + +describe('getIcons', () => { + it('should return a fallback icon if the report is empty', () => { + const report = {} as Report; + const icons = getIcons(report, FAKE_PERSONAL_DETAILS); + expect(icons).toHaveLength(1); + }); + + it('should return the correct icons for an expense request', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([1], 0, true), + parentReportID: '3', + parentReportActionID: '2', + type: CONST.REPORT.TYPE.IOU, + }; + const policy = LHNTestUtils.getFakePolicy('1'); + + // Verify report type conditions + expect(isExpenseRequest(report)).toBe(true); + expect(isThread(report)).toBe(true); + + const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy); + expect(icons).toHaveLength(2); + expect(icons.at(0)?.name).toBe('Email One'); + }); + + it('should return the correct icons for a chat thread', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([1], 0, true), + parentReportID: '1', + parentReportActionID: '1', + }; + + // Verify report type conditions + expect(isChatThread(report)).toBe(true); + expect(isThread(report)).toBe(true); + + const icons = getIcons(report, FAKE_PERSONAL_DETAILS); + expect(icons).toHaveLength(1); + expect(icons.at(0)?.name).toBe('Email\u00A0Two'); + }); + + it('should return the correct icons for a task report', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([1], 0, true), + type: CONST.REPORT.TYPE.TASK, + ownerAccountID: 1, + }; + + // Verify report type conditions + expect(isTaskReport(report)).toBe(true); + + const icons = getIcons(report, FAKE_PERSONAL_DETAILS); + expect(icons).toHaveLength(1); + expect(icons.at(0)?.name).toBe('Email One'); + }); + + it('should return the correct icons for a domain room', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([], 0, true), + chatType: CONST.REPORT.CHAT_TYPE.DOMAIN_ALL, + reportName: '#domain-test', + }; + const icons = getIcons(report, FAKE_PERSONAL_DETAILS); + expect(icons).toHaveLength(1); + expect(icons.at(0)?.name).toBe('domain-test'); + }); + + it('should return the correct icons for a policy room', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([], 0, true), + chatType: CONST.REPORT.CHAT_TYPE.POLICY_ROOM, + policyID: '1', + }; + const policy = LHNTestUtils.getFakePolicy('1'); + const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy); + expect(icons).toHaveLength(1); + expect(icons.at(0)?.name).toBe('Workspace-Test-001'); + }); + + it('should return the correct icons for a policy expense chat', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([1], 0, true), + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + policyID: '1', + }; + const policy = LHNTestUtils.getFakePolicy('1'); + + // Verify report type conditions + expect(isPolicyExpenseChat(report)).toBe(true); + expect(isChatReport(report)).toBe(true); + + const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy); + expect(icons).toHaveLength(2); + expect(icons.at(0)?.type).toBe(CONST.ICON_TYPE_WORKSPACE); + expect(icons.at(1)?.type).toBe(CONST.ICON_TYPE_AVATAR); + }); + + it('should return the correct icons for an expense report', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([1], 0, true), + type: CONST.REPORT.TYPE.EXPENSE, + policyID: '1', + }; + const policy = LHNTestUtils.getFakePolicy('1'); + + // Verify report type conditions + expect(isExpenseReport(report)).toBe(true); + + const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy); + expect(icons).toHaveLength(2); + expect(icons.at(0)?.type).toBe(CONST.ICON_TYPE_AVATAR); + expect(icons.at(1)?.type).toBe(CONST.ICON_TYPE_WORKSPACE); + }); + + it('should return the correct icons for an IOU report with one transaction', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([1], 0, true), + reportID: '1', + type: CONST.REPORT.TYPE.IOU, + ownerAccountID: 1, + managerID: 2, + }; + + // Verify report type conditions + expect(isIOUReport(report)).toBe(true); + expect(isMoneyRequestReport(report)).toBe(true); + + const icons = getIcons(report, FAKE_PERSONAL_DETAILS); + expect(icons).toHaveLength(1); + expect(icons.at(0)?.name).toBe('Email One'); + }); + + it('should return the correct icons for a Self DM', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([currentUserAccountID], 0, true), + chatType: CONST.REPORT.CHAT_TYPE.SELF_DM, + }; + + // Verify report type conditions + expect(isSelfDM(report)).toBe(true); + expect(isChatReport(report)).toBe(true); + + const icons = getIcons(report, FAKE_PERSONAL_DETAILS); + expect(icons).toHaveLength(1); + expect(icons.at(0)?.name).toBe('Email Five'); + }); + + it('should return the correct icons for a system chat', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([], 0, true), + chatType: CONST.REPORT.CHAT_TYPE.SYSTEM, + }; + const icons = getIcons(report, FAKE_PERSONAL_DETAILS); + expect(icons).toHaveLength(1); + expect(icons.at(0)?.id).toBe(CONST.ACCOUNT_ID.NOTIFICATIONS); + }); + + it('should return the correct icons for a group chat', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([1, 2, 3], 0, true), + chatType: CONST.REPORT.CHAT_TYPE.GROUP, + }; + + // Verify report type conditions + expect(isGroupChat(report)).toBe(true); + expect(isChatReport(report)).toBe(true); + + const icons = getIcons(report, FAKE_PERSONAL_DETAILS); + expect(icons).toHaveLength(1); + }); + + it('should return the correct icons for a group chat without an avatar', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([1, 2, 3], 0, true), + chatType: CONST.REPORT.CHAT_TYPE.GROUP, + }; + const icons = getIcons(report, FAKE_PERSONAL_DETAILS); + expect(icons).toHaveLength(1); + }); + + it('should return the correct icons for a group chat with an avatar', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([1, 2, 3], 0, true), + chatType: CONST.REPORT.CHAT_TYPE.GROUP, + avatarUrl: 'https://example.com/avatar.png', + }; + const icons = getIcons(report, FAKE_PERSONAL_DETAILS); + expect(icons).toHaveLength(1); + }); + + it('should return the correct icons for an invoice report', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([], 0, true), + type: CONST.REPORT.TYPE.INVOICE, + chatReportID: '1', + policyID: '1', + }; + const policy = LHNTestUtils.getFakePolicy('1'); + + // Verify report type conditions + expect(isInvoiceReport(report)).toBe(true); + + const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy); + expect(icons).toHaveLength(2); + expect(icons.at(0)?.type).toBe(CONST.ICON_TYPE_WORKSPACE); + expect(icons.at(1)?.name).toBe('Email Three'); + }); + + it('should return all participant icons for a one-on-one chat', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([1], 0, true), + }; + const icons = getIcons(report, FAKE_PERSONAL_DETAILS); + expect(icons).toHaveLength(1); + expect(icons.at(0)?.name).toBe('Email One'); + }); + + it('should return all participant icons as a fallback', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([1, 2, 3, 4], 0, true), + type: 'some_random_type', + }; + const icons = getIcons(report, FAKE_PERSONAL_DETAILS); + expect(icons).toHaveLength(4); + }); + + it('should return the correct icons for a workspace thread', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([1], 0, true), + parentReportID: 'workspaceParent', + parentReportActionID: '1', + policyID: '1', + type: CONST.REPORT.TYPE.CHAT, + chatType: CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT, + }; + const policy = LHNTestUtils.getFakePolicy('1'); + + // Verify report type conditions + expect(isChatThread(report)).toBe(true); + expect(isWorkspaceThread(report)).toBe(true); + + const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy); + + expect(icons).toHaveLength(2); // Actor + workspace icon + expect(icons.at(0)?.type).toBe(CONST.ICON_TYPE_AVATAR); + expect(icons.at(1)?.type).toBe(CONST.ICON_TYPE_WORKSPACE); + }); + + it('should return the correct icons for a workspace task report', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([1], 0, true), + type: CONST.REPORT.TYPE.TASK, + ownerAccountID: 1, + parentReportID: 'taskParent', + policyID: '1', + }; + const policy = LHNTestUtils.getFakePolicy('1'); + + // Verify report type conditions + expect(isTaskReport(report)).toBe(true); + expect(isWorkspaceTaskReport(report)).toBe(true); + + const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy); + + expect(icons).toHaveLength(2); // Owner + workspace icon + expect(icons.at(0)?.type).toBe(CONST.ICON_TYPE_AVATAR); + expect(icons.at(1)?.type).toBe(CONST.ICON_TYPE_WORKSPACE); + }); + + it('should return the correct icons for an admin room', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([], 0, true), + chatType: CONST.REPORT.CHAT_TYPE.POLICY_ADMINS, + policyID: '1', + }; + const policy = LHNTestUtils.getFakePolicy('1'); + + // Verify report type conditions + expect(isAdminRoom(report)).toBe(true); + expect(isChatRoom(report)).toBe(true); + + const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy); + expect(icons).toHaveLength(1); + expect(icons.at(0)?.type).toBe(CONST.ICON_TYPE_WORKSPACE); + }); + + it('should return the correct icons for an announce room', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([], 0, true), + chatType: CONST.REPORT.CHAT_TYPE.POLICY_ANNOUNCE, + policyID: '1', + }; + const policy = LHNTestUtils.getFakePolicy('1'); + + // Verify report type conditions + expect(isAnnounceRoom(report)).toBe(true); + expect(isChatRoom(report)).toBe(true); + + const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy); + expect(icons).toHaveLength(1); + expect(icons.at(0)?.type).toBe(CONST.ICON_TYPE_WORKSPACE); + }); + + it('should return the correct icons for an invoice room with individual receiver', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([], 0, true), + chatType: CONST.REPORT.CHAT_TYPE.INVOICE, + policyID: '1', + invoiceReceiver: { + type: CONST.REPORT.INVOICE_RECEIVER_TYPE.INDIVIDUAL, + accountID: 2, + }, + }; + const policy = LHNTestUtils.getFakePolicy('1'); + + // Verify report type conditions + expect(isInvoiceRoom(report)).toBe(true); + expect(isIndividualInvoiceRoom(report)).toBe(true); + + const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy); + expect(icons).toHaveLength(2); // Workspace + individual receiver + expect(icons.at(0)?.type).toBe(CONST.ICON_TYPE_WORKSPACE); + expect(icons.at(1)?.type).toBe(CONST.ICON_TYPE_AVATAR); + }); + + it('should return the correct icons for an invoice room with business receiver', () => { + const receiverPolicy = LHNTestUtils.getFakePolicy('2', 'Receiver-Policy'); + const report: Report = { + ...LHNTestUtils.getFakeReport([], 0, true), + chatType: CONST.REPORT.CHAT_TYPE.INVOICE, + policyID: '1', + invoiceReceiver: { + type: CONST.REPORT.INVOICE_RECEIVER_TYPE.BUSINESS, + policyID: '2', + }, + }; + const policy = LHNTestUtils.getFakePolicy('1'); + const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy, receiverPolicy); + expect(icons).toHaveLength(2); // Workspace + receiver workspace + expect(icons.at(0)?.type).toBe(CONST.ICON_TYPE_WORKSPACE); + expect(icons.at(1)?.type).toBe(CONST.ICON_TYPE_WORKSPACE); + expect(icons.at(1)?.name).toBe('Receiver-Policy'); + }); + + it('should return the correct icons for a multi-transaction IOU report where current user is not manager', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([1, 2], 0, true), + reportID: 'multiTxn', + chatReportID: 'chatReport', + type: CONST.REPORT.TYPE.IOU, + ownerAccountID: 1, + managerID: 2, // Different from current user (5) + // eslint-disable-next-line @typescript-eslint/naming-convention + participants: { + // eslint-disable-next-line @typescript-eslint/naming-convention + '1': {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + // eslint-disable-next-line @typescript-eslint/naming-convention + '2': {notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS}, + }, + }; + + // Verify report type conditions + expect(isIOUReport(report)).toBe(true); + expect(isMoneyRequestReport(report)).toBe(true); + expect(isOneTransactionReport(report)).toBe(false); + + const icons = getIcons(report, FAKE_PERSONAL_DETAILS); + + // https://github.com/Expensify/App/issues/64333 + expect(icons).toHaveLength(2); + expect(icons.at(0)?.name).toBe('Email\u0020One'); + expect(icons.at(1)?.name).toBe('Email\u0020Two'); + }); + + it('should return the correct icons for a concierge chat', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([CONST.ACCOUNT_ID.CONCIERGE], 0, true), + participants: { + [CONST.ACCOUNT_ID.CONCIERGE]: { + notificationPreference: CONST.REPORT.NOTIFICATION_PREFERENCE.ALWAYS, + }, + }, + }; + const icons = getIcons(report, FAKE_PERSONAL_DETAILS); + expect(icons).toHaveLength(1); + expect(icons.at(0)?.id).toBe(CONST.ACCOUNT_ID.CONCIERGE); + }); + + it('should return the correct icons for an invoice report with individual receiver', () => { + const report: Report = { + ...LHNTestUtils.getFakeReport([], 0, true), + type: CONST.REPORT.TYPE.INVOICE, + chatReportID: '1', + policyID: '1', + }; + + // Verify report type conditions + expect(isInvoiceReport(report)).toBe(true); + + const policy = LHNTestUtils.getFakePolicy('1'); + const icons = getIcons(report, FAKE_PERSONAL_DETAILS, null, '', -1, policy); + expect(icons).toHaveLength(2); + expect(icons.at(0)?.type).toBe(CONST.ICON_TYPE_WORKSPACE); + expect(icons.at(1)?.name).toBe('Email Three'); + }); +}); From 51e0bf6ee12c0ca6b4d7d74850b211f9d454f39b Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 24 Jun 2025 09:11:57 +0200 Subject: [PATCH 03/18] Fix two tests & remove isOneTransactionReport export --- src/libs/ReportUtils.ts | 1 - .../EnforceActionExportRestrictions.ts | 9 +++--- tests/unit/ReportUtilsGetIconsTest.ts | 32 +++++++++---------- 3 files changed, 19 insertions(+), 23 deletions(-) diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 72c51cf1059e4..daa29e13134d5 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -11462,7 +11462,6 @@ export { hasReportBeenReopened, getMoneyReportPreviewName, getNextApproverAccountID, - isOneTransactionReport, isWorkspaceTaskReport, isWorkspaceThread, }; diff --git a/tests/actions/EnforceActionExportRestrictions.ts b/tests/actions/EnforceActionExportRestrictions.ts index c015aba6e91bd..52760e9048c04 100644 --- a/tests/actions/EnforceActionExportRestrictions.ts +++ b/tests/actions/EnforceActionExportRestrictions.ts @@ -17,11 +17,10 @@ describe('ReportUtils', () => { expect(ReportUtils.getReport).toBeUndefined(); }); - // TODO: Re-enable this test when isOneTransactionReport is fixed https://github.com/Expensify/App/issues/64333 - // it('does not export isOneTransactionReport', () => { - // // @ts-expect-error the test is asserting that it's undefined, so the TS error is normal - // expect(ReportUtils.isOneTransactionReport).toBeUndefined(); - // }); + it('does not export isOneTransactionReport', () => { + // @ts-expect-error the test is asserting that it's undefined, so the TS error is normal + expect(ReportUtils.isOneTransactionReport).toBeUndefined(); + }); it('does not export getPolicy', () => { // @ts-expect-error the test is asserting that it's undefined, so the TS error is normal diff --git a/tests/unit/ReportUtilsGetIconsTest.ts b/tests/unit/ReportUtilsGetIconsTest.ts index 8625944f0f2c4..7bb9e86ba8130 100644 --- a/tests/unit/ReportUtilsGetIconsTest.ts +++ b/tests/unit/ReportUtilsGetIconsTest.ts @@ -1,4 +1,6 @@ +import type {OnyxCollection} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import {getOneTransactionThreadReportID} from '@libs/ReportActionsUtils'; import { getIcons, isAdminRoom, @@ -14,7 +16,6 @@ import { isInvoiceRoom, isIOUReport, isMoneyRequestReport, - isOneTransactionReport, isPolicyExpenseChat, isSelfDM, isTaskReport, @@ -24,35 +25,30 @@ import { } from '@libs/ReportUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report} from '@src/types/onyx'; +import type {Report, ReportAction} from '@src/types/onyx'; +import {actionR14932, actionR98765} from '../../__mocks__/reportData/actions'; import * as LHNTestUtils from '../utils/LHNTestUtils'; const FAKE_PERSONAL_DETAILS = LHNTestUtils.fakePersonalDetails; /* eslint-disable @typescript-eslint/naming-convention */ -const FAKE_REPORT_ACTIONS = { +const FAKE_REPORT_ACTIONS: OnyxCollection> = { [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: { - '1': {actorAccountID: 2, actionName: CONST.REPORT.ACTIONS.TYPE.IOU}, + '1': {...actionR14932, actorAccountID: 2}, }, [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2`]: { - '2': {actorAccountID: 1, actionName: CONST.REPORT.ACTIONS.TYPE.IOU}, + '2': {...actionR98765, actorAccountID: 1}, }, [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}3`]: { - '2': { - actorAccountID: 1, - actionName: CONST.REPORT.ACTIONS.TYPE.IOU, - originalMessage: { - type: CONST.IOU.REPORT_ACTION_TYPE.CREATE, - }, - }, + '2': {...actionR98765, actorAccountID: 1}, }, // For workspace thread test - parent report actions [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}workspaceParent`]: { - '1': {actorAccountID: 2, actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}, + '1': {...actionR14932, actorAccountID: 2, actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}, }, // For multi-transaction IOU test - multiple transactions [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}multiTxn`]: { - '1': {actorAccountID: 1, actionName: CONST.REPORT.ACTIONS.TYPE.IOU}, - '2': {actorAccountID: 1, actionName: CONST.REPORT.ACTIONS.TYPE.IOU}, + '1': {...actionR14932, actorAccountID: 1}, + '2': {...actionR98765, actorAccountID: 1}, }, }; /* eslint-enable @typescript-eslint/naming-convention */ @@ -484,14 +480,16 @@ describe('getIcons', () => { }, }; + const reportActions = FAKE_REPORT_ACTIONS?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`]; + const chatReport = FAKE_REPORTS?.[`${ONYXKEYS.COLLECTION.REPORT}${report?.chatReportID}`]; + // Verify report type conditions expect(isIOUReport(report)).toBe(true); expect(isMoneyRequestReport(report)).toBe(true); - expect(isOneTransactionReport(report)).toBe(false); + expect(getOneTransactionThreadReportID(report, chatReport, reportActions)).toBeFalsy(); const icons = getIcons(report, FAKE_PERSONAL_DETAILS); - // https://github.com/Expensify/App/issues/64333 expect(icons).toHaveLength(2); expect(icons.at(0)?.name).toBe('Email\u0020One'); expect(icons.at(1)?.name).toBe('Email\u0020Two'); From d0054c7e939258ab154bc437fd63805d4c155e92 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 24 Jun 2025 14:34:54 +0200 Subject: [PATCH 04/18] Display sender avatar next to report preview with only one person expenses --- .../home/report/ReportActionItemSingle.tsx | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index 1abbe2ac0ff4d..6b655d844e890 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -21,7 +21,7 @@ import ControlSelection from '@libs/ControlSelection'; import DateUtils from '@libs/DateUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; -import {getManagerOnVacation, getReportActionMessage, getSubmittedTo, getVacationer} from '@libs/ReportActionsUtils'; +import {getManagerOnVacation, getReportActionMessage, getSendMoneyFlowOneTransactionThreadID, getSubmittedTo, getVacationer, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import { getDefaultWorkspaceAvatar, getDisplayNameForParticipant, @@ -29,6 +29,7 @@ import { getPolicyName, getReportActionActorAccountID, getWorkspaceIcon, + isActionCreator, isIndividualInvoiceRoom, isInvoiceReport as isInvoiceReportUtils, isInvoiceRoom, @@ -111,17 +112,31 @@ function ReportActionItemSingle({ const [innerPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { canBeMissing: true, }); + const [iouActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, { + canBeMissing: true, + selector: (actions) => Object.values(actions ?? {}).filter(isMoneyRequestAction), + }); + const activePolicies = policies ?? innerPolicies; const policy = usePolicy(report?.policyID); const delegatePersonalDetails = action?.delegateAccountID ? personalDetails?.[action?.delegateAccountID] : undefined; const ownerAccountID = iouReport?.ownerAccountID ?? action?.childOwnerAccountID; const isReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; const actorAccountID = getReportActionActorAccountID(action, iouReport, report, delegatePersonalDetails); + + // We want to display only the sender's avatar next to the report preview if it only contains one person's expenses. + const areIOUActionsOnlyFromOnePerson = isReportPreviewAction && iouActions?.length && (iouActions.every(isActionCreator) || !iouActions.some(isActionCreator)); + + const isSendMoneyFlow = !!getSendMoneyFlowOneTransactionThreadID(iouActions, report); + + // We need to change the account ID to get the avatar & name of the report owner. The only exception is the 'Send Money' flow, where the avatar is retrieved correctly. We just need to hide the second one. + const accountID = areIOUActionsOnlyFromOnePerson && !isSendMoneyFlow ? ownerAccountID : actorAccountID; + const invoiceReceiverPolicy = report?.invoiceReceiver && 'policyID' in report.invoiceReceiver ? activePolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.invoiceReceiver.policyID}`] : undefined; - let displayName = getDisplayNameForParticipant({accountID: actorAccountID, personalDetailsData: personalDetails}); - const {avatar, login, pendingFields, status, fallbackIcon} = personalDetails?.[actorAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? {}; + let displayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails}); + const {avatar, login, pendingFields, status, fallbackIcon} = personalDetails?.[accountID ?? CONST.DEFAULT_NUMBER_ID] ?? {}; const accountOwnerDetails = getPersonalDetailByEmail(login ?? ''); // Vacation delegate details for submitted action @@ -137,7 +152,7 @@ function ReportActionItemSingle({ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing let actorHint = (login || (displayName ?? '')).replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, ''); const isTripRoom = isTripRoomReportUtils(report); - const displayAllActors = isReportPreviewAction && !isTripRoom && !isPolicyExpenseChat(report); + const displayAllActors = isReportPreviewAction && !isTripRoom && !isPolicyExpenseChat(report) && !areIOUActionsOnlyFromOnePerson; const isInvoiceReport = isInvoiceReportUtils(iouReport ?? null); const isWorkspaceActor = isInvoiceReport || (isPolicyExpenseChat(report) && (!actorAccountID || displayAllActors)); From ff0979b417f8972c0d0a33779bd6c46ba735ab81 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Wed, 25 Jun 2025 07:58:18 +0200 Subject: [PATCH 05/18] Fix ReportUtilsGetIconsTest test case --- tests/unit/ReportUtilsGetIconsTest.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/ReportUtilsGetIconsTest.ts b/tests/unit/ReportUtilsGetIconsTest.ts index 7bb9e86ba8130..05c35d3177335 100644 --- a/tests/unit/ReportUtilsGetIconsTest.ts +++ b/tests/unit/ReportUtilsGetIconsTest.ts @@ -339,7 +339,7 @@ describe('getIcons', () => { it('should return all participant icons as a fallback', () => { const report: Report = { ...LHNTestUtils.getFakeReport([1, 2, 3, 4], 0, true), - type: 'some_random_type', + type: undefined, }; const icons = getIcons(report, FAKE_PERSONAL_DETAILS); expect(icons).toHaveLength(4); From 78292a29b0f659e81f6970a086aba7e3546085e5 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Thu, 26 Jun 2025 13:39:53 +0200 Subject: [PATCH 06/18] Add c3024 suggestion --- src/pages/home/report/ReportActionItemSingle.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index 6b655d844e890..15f9039361260 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -125,7 +125,7 @@ function ReportActionItemSingle({ const actorAccountID = getReportActionActorAccountID(action, iouReport, report, delegatePersonalDetails); // We want to display only the sender's avatar next to the report preview if it only contains one person's expenses. - const areIOUActionsOnlyFromOnePerson = isReportPreviewAction && iouActions?.length && (iouActions.every(isActionCreator) || !iouActions.some(isActionCreator)); + const areIOUActionsOnlyFromOnePerson = isReportPreviewAction && !!iouActions?.length && (iouActions.every(isActionCreator) || !iouActions.some(isActionCreator)); const isSendMoneyFlow = !!getSendMoneyFlowOneTransactionThreadID(iouActions, report); From b16e39a2e37e331c81d7f28528f2863b38b53717 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Fri, 27 Jun 2025 16:27:23 +0200 Subject: [PATCH 07/18] Add useIDOfReportPreviewSender & tests for it --- .../home/report/ReportActionItemSingle.tsx | 78 +++++++++-- tests/unit/ReportActionItemSingleTest.ts | 122 ++++++++++++++++-- 2 files changed, 175 insertions(+), 25 deletions(-) diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index 5015d534a3604..1939397f42696 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -19,9 +19,10 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import ControlSelection from '@libs/ControlSelection'; import DateUtils from '@libs/DateUtils'; +import {selectAllTransactionsForReport} from '@libs/MoneyRequestReportUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; -import {getReportActionMessage, getSendMoneyFlowOneTransactionThreadID, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getOriginalMessage, getReportActionMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; import { getDefaultWorkspaceAvatar, getDisplayNameForParticipant, @@ -89,6 +90,62 @@ const showWorkspaceDetails = (reportID: string | undefined) => { Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(reportID, Navigation.getReportRHPActiveRoute())); }; +/** + * This hook is used to determine the ID of the sender for the report preview action. + * There are few fallbacks to determine the sender ID. + * + * It should not be used outside of this component. + * The only reason it is exported to a hook is to make it easier to remove it in the future. + * + * For a reason why it is here, see https://github.com/Expensify/App/pull/64802 discussion + */ +function useIDOfReportPreviewSender({action, iouReport}: {action: OnyxEntry; iouReport?: OnyxEntry}) { + const [iouActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, { + canBeMissing: true, + selector: (actions) => Object.values(actions ?? {}).filter(isMoneyRequestAction), + }); + + const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { + canBeMissing: true, + selector: (allTransactions) => selectAllTransactionsForReport(allTransactions, action?.childReportID, iouActions ?? []), + }); + + if (action?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { + return; + } + + /* We need to perform the following checks to determine if the report preview is a single avatar: */ + + // 1. If all actions are created by one person - either all actions are created by the current user or all actions are created by the user we have opened chat with. + const areActionsCreatedByOnePerson = iouActions?.every(isActionCreator) || !iouActions?.some(isActionCreator); + + // 2. If all amounts have the same sign - either all amounts are positive or all amounts are negative. + // We have to do this because there can be a case when actions are not available + // See: https://github.com/Expensify/App/pull/64802#issuecomment-3008944401 + const areAmountsSignsTheSame = new Set(transactions?.map((tr) => Math.sign(tr.amount))).size < 2; + + // 3. If there is only one attendee - we check that by counting unique emails in the attendees list. + // This has to be done as there are cases when transaction amounts signs are the same until report is opened. + // See: https://github.com/Expensify/App/pull/64802#issuecomment-3007906310 + const isThereOnlyOneAttendee = + new Set( + transactions + // If the transaction is a split, then it had to be created by someone even though the attendees are not present. + ?.flatMap((tr) => tr.comment?.attendees?.map((att) => att.email) ?? tr.comment?.source === 'split') + // We filter out the concierge email and empty emails. + .filter((email) => !!email && email !== CONST.EMAIL.CONCIERGE), + ).size <= 1; + + // If the action is a 'Send Money' flow, it will only have one transaction, but the person who sent the money is the child manager account, not the child owner account. + const isSendMoneyFlow = action?.childMoneyRequestCount === 0 && transactions?.length === 1; + const singleAvatarAccountID = isSendMoneyFlow ? action.childManagerAccountID : action?.childOwnerAccountID; + + // If we have more than one IOU action, but they are 'Pay' actions, we consider it as a single avatar. The only place where 'Pay' actions are considered valid is in the 'Send Money' flow. + const areThereValidIOUActions = !!iouActions?.length && (isSendMoneyFlow || iouActions.some((iouAction) => getOriginalMessage(iouAction)?.type !== CONST.IOU.REPORT_ACTION_TYPE.PAY)); + + return (!areThereValidIOUActions || areActionsCreatedByOnePerson) && areAmountsSignsTheSame && isThereOnlyOneAttendee ? singleAvatarAccountID : undefined; +} + function ReportActionItemSingle({ action, children, @@ -112,10 +169,6 @@ function ReportActionItemSingle({ const [innerPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { canBeMissing: true, }); - const [iouActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, { - canBeMissing: true, - selector: (actions) => Object.values(actions ?? {}).filter(isMoneyRequestAction), - }); const activePolicies = policies ?? innerPolicies; const policy = usePolicy(report?.policyID); @@ -123,14 +176,8 @@ function ReportActionItemSingle({ const ownerAccountID = iouReport?.ownerAccountID ?? action?.childOwnerAccountID; const isReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; const actorAccountID = getReportActionActorAccountID(action, iouReport, report, delegatePersonalDetails); - - // We want to display only the sender's avatar next to the report preview if it only contains one person's expenses. - const areIOUActionsOnlyFromOnePerson = isReportPreviewAction && !!iouActions?.length && (iouActions.every(isActionCreator) || !iouActions.some(isActionCreator)); - - const isSendMoneyFlow = !!getSendMoneyFlowOneTransactionThreadID(iouActions, report); - - // We need to change the account ID to get the avatar & name of the report owner. The only exception is the 'Send Money' flow, where the avatar is retrieved correctly. We just need to hide the second one. - const accountID = areIOUActionsOnlyFromOnePerson && !isSendMoneyFlow ? ownerAccountID : actorAccountID; + const reportPreviewSenderID = useIDOfReportPreviewSender({action, iouReport}); + const accountID = reportPreviewSenderID ?? actorAccountID ?? CONST.DEFAULT_NUMBER_ID; const invoiceReceiverPolicy = report?.invoiceReceiver && 'policyID' in report.invoiceReceiver ? activePolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.invoiceReceiver.policyID}`] : undefined; @@ -142,7 +189,9 @@ function ReportActionItemSingle({ // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing let actorHint = (login || (displayName ?? '')).replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, ''); const isTripRoom = isTripRoomReportUtils(report); - const displayAllActors = isReportPreviewAction && !isTripRoom && !isPolicyExpenseChat(report) && !areIOUActionsOnlyFromOnePerson; + + // We want to display only the sender's avatar next to the report preview if it only contains one person's expenses. + const displayAllActors = isReportPreviewAction && !isTripRoom && !isPolicyExpenseChat(report) && !reportPreviewSenderID; const isInvoiceReport = isInvoiceReportUtils(iouReport ?? null); const isWorkspaceActor = isInvoiceReport || (isPolicyExpenseChat(report) && (!actorAccountID || displayAllActors)); @@ -372,3 +421,4 @@ function ReportActionItemSingle({ ReportActionItemSingle.displayName = 'ReportActionItemSingle'; export default ReportActionItemSingle; +export {useIDOfReportPreviewSender as DO_NOT_USE__EXPORT_FOR_TESTS__useIDOfReportPreviewSender}; diff --git a/tests/unit/ReportActionItemSingleTest.ts b/tests/unit/ReportActionItemSingleTest.ts index b02f56b38d51f..37e5f9fa4897c 100644 --- a/tests/unit/ReportActionItemSingleTest.ts +++ b/tests/unit/ReportActionItemSingleTest.ts @@ -1,22 +1,17 @@ -import {screen, waitFor} from '@testing-library/react-native'; +import {renderHook, screen, waitFor} from '@testing-library/react-native'; import Onyx from 'react-native-onyx'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {DO_NOT_USE__EXPORT_FOR_TESTS__useIDOfReportPreviewSender as useIDOfReportPreviewSender} from '@src/pages/home/report/ReportActionItemSingle'; import type {PersonalDetailsList} from '@src/types/onyx'; import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; +import {actionR14932, actionR98765} from '../../__mocks__/reportData/actions'; +import {chatReportR14932, iouReportR14932} from '../../__mocks__/reportData/reports'; +import {transactionR14932} from '../../__mocks__/reportData/transactions'; import * as LHNTestUtils from '../utils/LHNTestUtils'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; -const ONYXKEYS = { - PERSONAL_DETAILS_LIST: 'personalDetailsList', - IS_LOADING_REPORT_DATA: 'isLoadingReportData', - COLLECTION: { - REPORT_ACTIONS: 'reportActions_', - POLICY: 'policy_', - }, - NETWORK: 'network', -} as const; - describe('ReportActionItemSingle', () => { beforeAll(() => Onyx.init({ @@ -89,3 +84,108 @@ describe('ReportActionItemSingle', () => { }); }); }); + +const reportActions = [{[actionR14932.reportActionID]: actionR14932}]; +const transactions = [transactionR14932]; + +const transactionCollectionDataSet = toCollectionDataSet(ONYXKEYS.COLLECTION.TRANSACTION, transactions, (transaction) => transaction.transactionID); +const reportActionCollectionDataSet = toCollectionDataSet(ONYXKEYS.COLLECTION.REPORT_ACTIONS, reportActions, (actions) => Object.values(actions).at(0)?.childReportID); + +const validAction = { + ...actionR98765, + childReportID: iouReportR14932.reportID, + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + childOwnerAccountID: iouReportR14932.ownerAccountID, + childManagerAccountID: iouReportR14932.managerID, +}; + +describe('useIDOfReportPreviewSender', () => { + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + }); + + beforeEach(() => { + Onyx.multiSet({ + ...reportActionCollectionDataSet, + ...transactionCollectionDataSet, + }); + return waitForBatchedUpdates(); + }); + + afterEach(() => { + Onyx.clear(); + return waitForBatchedUpdates(); + }); + + it('returns undefined when action is not a report preview', () => { + const {result} = renderHook(() => + useIDOfReportPreviewSender({ + action: actionR14932, + iouReport: iouReportR14932, + }), + ); + expect(result.current).toBeUndefined(); + }); + + it('returns childManagerAccountID when all conditions are met for Send Money flow', async () => { + const {result} = renderHook(() => + useIDOfReportPreviewSender({ + action: {...validAction, childMoneyRequestCount: 0}, + iouReport: iouReportR14932, + }), + ); + expect(result.current).toBe(iouReportR14932.managerID); + }); + + it('returns undefined when there are multiple attendees', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionR14932.transactionID}`, { + ...transactionR14932, + comment: { + attendees: [{email: 'test@test.com', displayName: 'Test One', avatarUrl: 'https://none.com/none'}], + }, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionR14932.transactionID}2`, { + ...transactionR14932, + comment: { + attendees: [{email: 'test2@test.com', displayName: 'Test Two', avatarUrl: 'https://none.com/none2'}], + }, + }); + const {result} = renderHook(() => + useIDOfReportPreviewSender({ + action: validAction, + iouReport: iouReportR14932, + }), + ); + expect(result.current).toBeUndefined(); + }); + + it('returns undefined when amounts have different signs', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionR14932.transactionID}`, { + ...transactionR14932, + amount: 100, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionR14932.transactionID}2`, { + ...transactionR14932, + amount: -100, + }); + const {result} = renderHook(() => + useIDOfReportPreviewSender({ + action: validAction, + iouReport: iouReportR14932, + }), + ); + expect(result.current).toBeUndefined(); + }); + + it('returns childOwnerAccountID when all conditions are met', () => { + const {result} = renderHook(() => + useIDOfReportPreviewSender({ + action: validAction, + iouReport: iouReportR14932, + }), + ); + expect(result.current).toBe(iouReportR14932.ownerAccountID); + }); +}); From 78220d81c43a1329c20254feb872f41775c6ed91 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Fri, 27 Jun 2025 16:33:37 +0200 Subject: [PATCH 08/18] Fix ESLint checks --- src/pages/home/report/ReportActionItemSingle.tsx | 1 + tests/unit/ReportActionItemSingleTest.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index 1939397f42696..66049afd576d5 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -117,6 +117,7 @@ function useIDOfReportPreviewSender({action, iouReport}: {action: OnyxEntry { expect(result.current).toBeUndefined(); }); - it('returns childManagerAccountID when all conditions are met for Send Money flow', async () => { + it('returns childManagerAccountID when all conditions are met for Send Money flow', () => { const {result} = renderHook(() => useIDOfReportPreviewSender({ action: {...validAction, childMoneyRequestCount: 0}, From 389f7f933ad9f5d7150ea685a12d79deb998a622 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 30 Jun 2025 08:42:49 +0200 Subject: [PATCH 09/18] Correct comment in TransactionPreview/types --- src/components/ReportActionItem/TransactionPreview/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/ReportActionItem/TransactionPreview/types.ts b/src/components/ReportActionItem/TransactionPreview/types.ts index 4a73fcd3a154b..e485d9fc80bfd 100644 --- a/src/components/ReportActionItem/TransactionPreview/types.ts +++ b/src/components/ReportActionItem/TransactionPreview/types.ts @@ -102,7 +102,7 @@ type TransactionPreviewContentProps = { /** Holds the transaction data entry from Onyx */ transaction: OnyxEntry; - /** The original amount value on the transaction. This is used to deduce who is the sender and who is the receiver of the money request + /** The amount of the transaction saved in the database. This is used to deduce who is the sender and who is the receiver of the money request * In case of Splits the property `transaction` is actually an original transaction (for the whole split) and it does not have the data required to deduce who is the sender */ transactionRawAmount: number; From e338d966848ad498d49ebcfb46ceba325181b869 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 1 Jul 2025 11:22:13 +0200 Subject: [PATCH 10/18] Fix avatar for splits --- .../home/report/ReportActionItemSingle.tsx | 48 ++++++++++++++----- 1 file changed, 36 insertions(+), 12 deletions(-) diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index 66049afd576d5..6ea0fc4be319e 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -41,7 +41,7 @@ import { import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Policy, Report, ReportAction} from '@src/types/onyx'; +import type {Policy, Report, ReportAction, Transaction} from '@src/types/onyx'; import type {Icon} from '@src/types/onyx/OnyxCommon'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import ReportActionItemDate from './ReportActionItemDate'; @@ -90,6 +90,22 @@ const showWorkspaceDetails = (reportID: string | undefined) => { Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(reportID, Navigation.getReportRHPActiveRoute())); }; +function getSplitAuthor(transaction: Transaction, splits?: Array>) { + const {originalTransactionID, source} = transaction.comment ?? {}; + + if (source !== CONST.IOU.TYPE.SPLIT || originalTransactionID === undefined) { + return undefined; + } + + const splitAction = splits?.find((split) => getOriginalMessage(split)?.IOUTransactionID === originalTransactionID); + + if (!splitAction) { + return undefined; + } + + return splitAction.actorAccountID; +} + /** * This hook is used to determine the ID of the sender for the report preview action. * There are few fallbacks to determine the sender ID. @@ -99,7 +115,7 @@ const showWorkspaceDetails = (reportID: string | undefined) => { * * For a reason why it is here, see https://github.com/Expensify/App/pull/64802 discussion */ -function useIDOfReportPreviewSender({action, iouReport}: {action: OnyxEntry; iouReport?: OnyxEntry}) { +function useIDOfReportPreviewSender({action, iouReport, report}: {action: OnyxEntry; iouReport: OnyxEntry; report: OnyxEntry}) { const [iouActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, { canBeMissing: true, selector: (actions) => Object.values(actions ?? {}).filter(isMoneyRequestAction), @@ -110,6 +126,14 @@ function useIDOfReportPreviewSender({action, iouReport}: {action: OnyxEntry selectAllTransactionsForReport(allTransactions, action?.childReportID, iouActions ?? []), }); + const [splits] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`, { + canBeMissing: true, + selector: (actions) => + Object.values(actions ?? {}) + .filter(isMoneyRequestAction) + .filter((act) => getOriginalMessage(act)?.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT), + }); + if (action?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { return; } @@ -125,17 +149,17 @@ function useIDOfReportPreviewSender({action, iouReport}: {action: OnyxEntry Math.sign(tr.amount))).size < 2; - // 3. If there is only one attendee - we check that by counting unique emails in the attendees list. + // 3. If there is only one attendee - we check that by counting unique emails converted to account IDs in the attendees list. // This has to be done as there are cases when transaction amounts signs are the same until report is opened. // See: https://github.com/Expensify/App/pull/64802#issuecomment-3007906310 - const isThereOnlyOneAttendee = - new Set( - transactions - // If the transaction is a split, then it had to be created by someone even though the attendees are not present. - ?.flatMap((tr) => tr.comment?.attendees?.map((att) => att.email) ?? tr.comment?.source === 'split') - // We filter out the concierge email and empty emails. - .filter((email) => !!email && email !== CONST.EMAIL.CONCIERGE), - ).size <= 1; + + const attendeesIDs = transactions + // If the transaction is a split, then attendees are not present so we need to use a helper function. + ?.flatMap((tr) => tr.comment?.attendees?.map((att) => getPersonalDetailByEmail(att.email)?.accountID) ?? getSplitAuthor(tr, splits)) + // We filter out empty ID's + .filter((accountID) => !!accountID); + + const isThereOnlyOneAttendee = new Set(attendeesIDs).size <= 1; // If the action is a 'Send Money' flow, it will only have one transaction, but the person who sent the money is the child manager account, not the child owner account. const isSendMoneyFlow = action?.childMoneyRequestCount === 0 && transactions?.length === 1; @@ -177,7 +201,7 @@ function ReportActionItemSingle({ const ownerAccountID = iouReport?.ownerAccountID ?? action?.childOwnerAccountID; const isReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; const actorAccountID = getReportActionActorAccountID(action, iouReport, report, delegatePersonalDetails); - const reportPreviewSenderID = useIDOfReportPreviewSender({action, iouReport}); + const reportPreviewSenderID = useIDOfReportPreviewSender({action, iouReport, report}); const accountID = reportPreviewSenderID ?? actorAccountID ?? CONST.DEFAULT_NUMBER_ID; const invoiceReceiverPolicy = From 3bd76cb111140f3d0ce355da3a55ad4e9390f582 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 1 Jul 2025 11:37:11 +0200 Subject: [PATCH 11/18] Remove avatar type check based on actions --- src/pages/home/report/ReportActionItemSingle.tsx | 15 ++++----------- 1 file changed, 4 insertions(+), 11 deletions(-) diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index 6ea0fc4be319e..8bffcc50c9a9c 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -30,7 +30,6 @@ import { getPolicyName, getReportActionActorAccountID, getWorkspaceIcon, - isActionCreator, isIndividualInvoiceRoom, isInvoiceReport as isInvoiceReportUtils, isInvoiceRoom, @@ -140,16 +139,13 @@ function useIDOfReportPreviewSender({action, iouReport, report}: {action: OnyxEn /* We need to perform the following checks to determine if the report preview is a single avatar: */ - // 1. If all actions are created by one person - either all actions are created by the current user or all actions are created by the user we have opened chat with. - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const areActionsCreatedByOnePerson = iouActions?.every(isActionCreator) || !iouActions?.some(isActionCreator); - - // 2. If all amounts have the same sign - either all amounts are positive or all amounts are negative. + // 1. If all amounts have the same sign - either all amounts are positive or all amounts are negative. // We have to do this because there can be a case when actions are not available // See: https://github.com/Expensify/App/pull/64802#issuecomment-3008944401 + const areAmountsSignsTheSame = new Set(transactions?.map((tr) => Math.sign(tr.amount))).size < 2; - // 3. If there is only one attendee - we check that by counting unique emails converted to account IDs in the attendees list. + // 2. If there is only one attendee - we check that by counting unique emails converted to account IDs in the attendees list. // This has to be done as there are cases when transaction amounts signs are the same until report is opened. // See: https://github.com/Expensify/App/pull/64802#issuecomment-3007906310 @@ -165,10 +161,7 @@ function useIDOfReportPreviewSender({action, iouReport, report}: {action: OnyxEn const isSendMoneyFlow = action?.childMoneyRequestCount === 0 && transactions?.length === 1; const singleAvatarAccountID = isSendMoneyFlow ? action.childManagerAccountID : action?.childOwnerAccountID; - // If we have more than one IOU action, but they are 'Pay' actions, we consider it as a single avatar. The only place where 'Pay' actions are considered valid is in the 'Send Money' flow. - const areThereValidIOUActions = !!iouActions?.length && (isSendMoneyFlow || iouActions.some((iouAction) => getOriginalMessage(iouAction)?.type !== CONST.IOU.REPORT_ACTION_TYPE.PAY)); - - return (!areThereValidIOUActions || areActionsCreatedByOnePerson) && areAmountsSignsTheSame && isThereOnlyOneAttendee ? singleAvatarAccountID : undefined; + return areAmountsSignsTheSame && isThereOnlyOneAttendee ? singleAvatarAccountID : undefined; } function ReportActionItemSingle({ From 110d83d74a6df42a842c7126c6966483e801c07e Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 1 Jul 2025 12:31:44 +0200 Subject: [PATCH 12/18] Fix Shawn issue & failing test --- __mocks__/reportData/personalDetails.ts | 4 ++-- src/libs/ReportUtils.ts | 2 +- .../home/report/ReportActionItemSingle.tsx | 6 ++++-- tests/unit/ReportActionItemSingleTest.ts | 21 ++++++++++++++++--- 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/__mocks__/reportData/personalDetails.ts b/__mocks__/reportData/personalDetails.ts index b3c6179497f6c..3d22e14b23b4c 100644 --- a/__mocks__/reportData/personalDetails.ts +++ b/__mocks__/reportData/personalDetails.ts @@ -2,7 +2,7 @@ import type {PersonalDetailsList} from '@src/types/onyx'; const usersIDs = [15593135, 51760358, 26502375] as const; -const personalDetails: PersonalDetailsList = { +const personalDetails = { [usersIDs[0]]: { accountID: usersIDs[0], avatar: '@assets/images/avatars/user/default-avatar_1.svg', @@ -63,6 +63,6 @@ const personalDetails: PersonalDetailsList = { phoneNumber: '33333333', validated: true, }, -}; +} satisfies PersonalDetailsList; export default personalDetails; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index ecf4c9e50936d..f9a4b8643de7e 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -8809,7 +8809,7 @@ function shouldReportShowSubscript(report: OnyxEntry): boolean { return true; } - if (isExpenseReport(report) && isOneTransactionReport(report)) { + if (isExpenseReport(report)) { return true; } diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index 8bffcc50c9a9c..31681e6a152a3 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -151,7 +151,9 @@ function useIDOfReportPreviewSender({action, iouReport, report}: {action: OnyxEn const attendeesIDs = transactions // If the transaction is a split, then attendees are not present so we need to use a helper function. - ?.flatMap((tr) => tr.comment?.attendees?.map((att) => getPersonalDetailByEmail(att.email)?.accountID) ?? getSplitAuthor(tr, splits)) + ?.flatMap((tr) => + tr.comment?.attendees?.map((att) => (tr.comment?.source === CONST.IOU.TYPE.SPLIT ? getSplitAuthor(tr, splits) : getPersonalDetailByEmail(att.email)?.accountID)), + ) // We filter out empty ID's .filter((accountID) => !!accountID); @@ -201,7 +203,7 @@ function ReportActionItemSingle({ report?.invoiceReceiver && 'policyID' in report.invoiceReceiver ? activePolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.invoiceReceiver.policyID}`] : undefined; let displayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails}); - const {avatar, login, pendingFields, status, fallbackIcon} = personalDetails?.[accountID ?? CONST.DEFAULT_NUMBER_ID] ?? {}; + const {avatar, login, pendingFields, status, fallbackIcon} = personalDetails?.[accountID] ?? {}; const accountOwnerDetails = getPersonalDetailByEmail(login ?? ''); // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing diff --git a/tests/unit/ReportActionItemSingleTest.ts b/tests/unit/ReportActionItemSingleTest.ts index cfe3b893b9a98..ad1f93e1aede0 100644 --- a/tests/unit/ReportActionItemSingleTest.ts +++ b/tests/unit/ReportActionItemSingleTest.ts @@ -1,17 +1,21 @@ import {renderHook, screen, waitFor} from '@testing-library/react-native'; import Onyx from 'react-native-onyx'; import CONST from '@src/CONST'; +import * as PersonalDetailsUtils from '@src/libs/PersonalDetailsUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import {DO_NOT_USE__EXPORT_FOR_TESTS__useIDOfReportPreviewSender as useIDOfReportPreviewSender} from '@src/pages/home/report/ReportActionItemSingle'; import type {PersonalDetailsList} from '@src/types/onyx'; import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; import {actionR14932, actionR98765} from '../../__mocks__/reportData/actions'; -import {iouReportR14932} from '../../__mocks__/reportData/reports'; +import personalDetails from '../../__mocks__/reportData/personalDetails'; +import {chatReportR14932, iouReportR14932} from '../../__mocks__/reportData/reports'; import {transactionR14932} from '../../__mocks__/reportData/transactions'; import * as LHNTestUtils from '../utils/LHNTestUtils'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; +import PropertyKeysOf = jest.PropertyKeysOf; + describe('ReportActionItemSingle', () => { beforeAll(() => Onyx.init({ @@ -100,10 +104,16 @@ const validAction = { }; describe('useIDOfReportPreviewSender', () => { + const mockedEmailToID: Record> = { + [personalDetails[15593135].login]: 15593135, + [personalDetails[51760358].login]: 51760358, + }; + beforeAll(() => { Onyx.init({ keys: ONYXKEYS, }); + jest.spyOn(PersonalDetailsUtils, 'getPersonalDetailByEmail').mockImplementation((email) => personalDetails[mockedEmailToID[email]]); }); beforeEach(() => { @@ -124,6 +134,7 @@ describe('useIDOfReportPreviewSender', () => { useIDOfReportPreviewSender({ action: actionR14932, iouReport: iouReportR14932, + report: chatReportR14932, }), ); expect(result.current).toBeUndefined(); @@ -134,6 +145,7 @@ describe('useIDOfReportPreviewSender', () => { useIDOfReportPreviewSender({ action: {...validAction, childMoneyRequestCount: 0}, iouReport: iouReportR14932, + report: chatReportR14932, }), ); expect(result.current).toBe(iouReportR14932.managerID); @@ -143,19 +155,20 @@ describe('useIDOfReportPreviewSender', () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionR14932.transactionID}`, { ...transactionR14932, comment: { - attendees: [{email: 'test@test.com', displayName: 'Test One', avatarUrl: 'https://none.com/none'}], + attendees: [{email: personalDetails[15593135].login, displayName: 'Test One', avatarUrl: 'https://none.com/none'}], }, }); await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionR14932.transactionID}2`, { ...transactionR14932, comment: { - attendees: [{email: 'test2@test.com', displayName: 'Test Two', avatarUrl: 'https://none.com/none2'}], + attendees: [{email: personalDetails[51760358].login, displayName: 'Test Two', avatarUrl: 'https://none.com/none2'}], }, }); const {result} = renderHook(() => useIDOfReportPreviewSender({ action: validAction, iouReport: iouReportR14932, + report: chatReportR14932, }), ); expect(result.current).toBeUndefined(); @@ -174,6 +187,7 @@ describe('useIDOfReportPreviewSender', () => { useIDOfReportPreviewSender({ action: validAction, iouReport: iouReportR14932, + report: chatReportR14932, }), ); expect(result.current).toBeUndefined(); @@ -184,6 +198,7 @@ describe('useIDOfReportPreviewSender', () => { useIDOfReportPreviewSender({ action: validAction, iouReport: iouReportR14932, + report: chatReportR14932, }), ); expect(result.current).toBe(iouReportR14932.ownerAccountID); From 2df473d463d4823514635df8608eeff852f7daf3 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 7 Jul 2025 08:17:35 +0200 Subject: [PATCH 13/18] Extract useReportPreviewSenderID --- .../home/report/ReportActionItemSingle.tsx | 86 +----------------- .../home/report/useReportPreviewSenderID.tsx | 87 +++++++++++++++++++ tests/unit/ReportActionItemSingleTest.ts | 14 +-- 3 files changed, 98 insertions(+), 89 deletions(-) create mode 100644 src/pages/home/report/useReportPreviewSenderID.tsx diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index 31681e6a152a3..06db2a541f64d 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -19,10 +19,9 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import ControlSelection from '@libs/ControlSelection'; import DateUtils from '@libs/DateUtils'; -import {selectAllTransactionsForReport} from '@libs/MoneyRequestReportUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; -import {getOriginalMessage, getReportActionMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import {getReportActionMessage} from '@libs/ReportActionsUtils'; import { getDefaultWorkspaceAvatar, getDisplayNameForParticipant, @@ -40,11 +39,12 @@ import { import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Policy, Report, ReportAction, Transaction} from '@src/types/onyx'; +import type {Policy, Report, ReportAction} from '@src/types/onyx'; import type {Icon} from '@src/types/onyx/OnyxCommon'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import ReportActionItemDate from './ReportActionItemDate'; import ReportActionItemFragment from './ReportActionItemFragment'; +import useReportPreviewSenderID from './useReportPreviewSenderID'; type ReportActionItemSingleProps = Partial & { /** All the data of the action */ @@ -89,83 +89,6 @@ const showWorkspaceDetails = (reportID: string | undefined) => { Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(reportID, Navigation.getReportRHPActiveRoute())); }; -function getSplitAuthor(transaction: Transaction, splits?: Array>) { - const {originalTransactionID, source} = transaction.comment ?? {}; - - if (source !== CONST.IOU.TYPE.SPLIT || originalTransactionID === undefined) { - return undefined; - } - - const splitAction = splits?.find((split) => getOriginalMessage(split)?.IOUTransactionID === originalTransactionID); - - if (!splitAction) { - return undefined; - } - - return splitAction.actorAccountID; -} - -/** - * This hook is used to determine the ID of the sender for the report preview action. - * There are few fallbacks to determine the sender ID. - * - * It should not be used outside of this component. - * The only reason it is exported to a hook is to make it easier to remove it in the future. - * - * For a reason why it is here, see https://github.com/Expensify/App/pull/64802 discussion - */ -function useIDOfReportPreviewSender({action, iouReport, report}: {action: OnyxEntry; iouReport: OnyxEntry; report: OnyxEntry}) { - const [iouActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, { - canBeMissing: true, - selector: (actions) => Object.values(actions ?? {}).filter(isMoneyRequestAction), - }); - - const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { - canBeMissing: true, - selector: (allTransactions) => selectAllTransactionsForReport(allTransactions, action?.childReportID, iouActions ?? []), - }); - - const [splits] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`, { - canBeMissing: true, - selector: (actions) => - Object.values(actions ?? {}) - .filter(isMoneyRequestAction) - .filter((act) => getOriginalMessage(act)?.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT), - }); - - if (action?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { - return; - } - - /* We need to perform the following checks to determine if the report preview is a single avatar: */ - - // 1. If all amounts have the same sign - either all amounts are positive or all amounts are negative. - // We have to do this because there can be a case when actions are not available - // See: https://github.com/Expensify/App/pull/64802#issuecomment-3008944401 - - const areAmountsSignsTheSame = new Set(transactions?.map((tr) => Math.sign(tr.amount))).size < 2; - - // 2. If there is only one attendee - we check that by counting unique emails converted to account IDs in the attendees list. - // This has to be done as there are cases when transaction amounts signs are the same until report is opened. - // See: https://github.com/Expensify/App/pull/64802#issuecomment-3007906310 - - const attendeesIDs = transactions - // If the transaction is a split, then attendees are not present so we need to use a helper function. - ?.flatMap((tr) => - tr.comment?.attendees?.map((att) => (tr.comment?.source === CONST.IOU.TYPE.SPLIT ? getSplitAuthor(tr, splits) : getPersonalDetailByEmail(att.email)?.accountID)), - ) - // We filter out empty ID's - .filter((accountID) => !!accountID); - - const isThereOnlyOneAttendee = new Set(attendeesIDs).size <= 1; - - // If the action is a 'Send Money' flow, it will only have one transaction, but the person who sent the money is the child manager account, not the child owner account. - const isSendMoneyFlow = action?.childMoneyRequestCount === 0 && transactions?.length === 1; - const singleAvatarAccountID = isSendMoneyFlow ? action.childManagerAccountID : action?.childOwnerAccountID; - - return areAmountsSignsTheSame && isThereOnlyOneAttendee ? singleAvatarAccountID : undefined; -} - function ReportActionItemSingle({ action, children, @@ -196,7 +119,7 @@ function ReportActionItemSingle({ const ownerAccountID = iouReport?.ownerAccountID ?? action?.childOwnerAccountID; const isReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; const actorAccountID = getReportActionActorAccountID(action, iouReport, report, delegatePersonalDetails); - const reportPreviewSenderID = useIDOfReportPreviewSender({action, iouReport, report}); + const reportPreviewSenderID = useReportPreviewSenderID({action, iouReport, report}); const accountID = reportPreviewSenderID ?? actorAccountID ?? CONST.DEFAULT_NUMBER_ID; const invoiceReceiverPolicy = @@ -441,4 +364,3 @@ function ReportActionItemSingle({ ReportActionItemSingle.displayName = 'ReportActionItemSingle'; export default ReportActionItemSingle; -export {useIDOfReportPreviewSender as DO_NOT_USE__EXPORT_FOR_TESTS__useIDOfReportPreviewSender}; diff --git a/src/pages/home/report/useReportPreviewSenderID.tsx b/src/pages/home/report/useReportPreviewSenderID.tsx new file mode 100644 index 0000000000000..09778daa329ee --- /dev/null +++ b/src/pages/home/report/useReportPreviewSenderID.tsx @@ -0,0 +1,87 @@ +import type {OnyxEntry} from 'react-native-onyx'; +import useOnyx from '@hooks/useOnyx'; +import {selectAllTransactionsForReport} from '@libs/MoneyRequestReportUtils'; +import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; +import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {Report, ReportAction, Transaction} from '@src/types/onyx'; + +function getSplitAuthor(transaction: Transaction, splits?: Array>) { + const {originalTransactionID, source} = transaction.comment ?? {}; + + if (source !== CONST.IOU.TYPE.SPLIT || originalTransactionID === undefined) { + return undefined; + } + + const splitAction = splits?.find((split) => getOriginalMessage(split)?.IOUTransactionID === originalTransactionID); + + if (!splitAction) { + return undefined; + } + + return splitAction.actorAccountID; +} + +/** + * This hook is used to determine the ID of the sender for the report preview action. + * There are few fallbacks to determine the sender ID. + * + * It should not be used outside of this component. + * The only reason it is exported to a hook is to make it easier to remove it in the future. + * + * For a reason why it is here, see https://github.com/Expensify/App/pull/64802 discussion + */ +function useReportPreviewSenderID({action, iouReport, report}: {action: OnyxEntry; iouReport: OnyxEntry; report: OnyxEntry}) { + const [iouActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, { + canBeMissing: true, + selector: (actions) => Object.values(actions ?? {}).filter(isMoneyRequestAction), + }); + + const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { + canBeMissing: true, + selector: (allTransactions) => selectAllTransactionsForReport(allTransactions, action?.childReportID, iouActions ?? []), + }); + + const [splits] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`, { + canBeMissing: true, + selector: (actions) => + Object.values(actions ?? {}) + .filter(isMoneyRequestAction) + .filter((act) => getOriginalMessage(act)?.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT), + }); + + if (action?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { + return; + } + + /* We need to perform the following checks to determine if the report preview is a single avatar: */ + + // 1. If all amounts have the same sign - either all amounts are positive or all amounts are negative. + // We have to do this because there can be a case when actions are not available + // See: https://github.com/Expensify/App/pull/64802#issuecomment-3008944401 + + const areAmountsSignsTheSame = new Set(transactions?.map((tr) => Math.sign(tr.amount))).size < 2; + + // 2. If there is only one attendee - we check that by counting unique emails converted to account IDs in the attendees list. + // This has to be done as there are cases when transaction amounts signs are the same until report is opened. + // See: https://github.com/Expensify/App/pull/64802#issuecomment-3007906310 + + const attendeesIDs = transactions + // If the transaction is a split, then attendees are not present so we need to use a helper function. + ?.flatMap((tr) => + tr.comment?.attendees?.map((att) => (tr.comment?.source === CONST.IOU.TYPE.SPLIT ? getSplitAuthor(tr, splits) : getPersonalDetailByEmail(att.email)?.accountID)), + ) + // We filter out empty ID's + .filter((accountID) => !!accountID); + + const isThereOnlyOneAttendee = new Set(attendeesIDs).size <= 1; + + // If the action is a 'Send Money' flow, it will only have one transaction, but the person who sent the money is the child manager account, not the child owner account. + const isSendMoneyFlow = action?.childMoneyRequestCount === 0 && transactions?.length === 1; + const singleAvatarAccountID = isSendMoneyFlow ? action.childManagerAccountID : action?.childOwnerAccountID; + + return areAmountsSignsTheSame && isThereOnlyOneAttendee ? singleAvatarAccountID : undefined; +} + +export default useReportPreviewSenderID; diff --git a/tests/unit/ReportActionItemSingleTest.ts b/tests/unit/ReportActionItemSingleTest.ts index ad1f93e1aede0..0eb98e4b740f7 100644 --- a/tests/unit/ReportActionItemSingleTest.ts +++ b/tests/unit/ReportActionItemSingleTest.ts @@ -1,9 +1,9 @@ import {renderHook, screen, waitFor} from '@testing-library/react-native'; import Onyx from 'react-native-onyx'; +import useReportPreviewSenderID from '@pages/home/report/useReportPreviewSenderID'; import CONST from '@src/CONST'; import * as PersonalDetailsUtils from '@src/libs/PersonalDetailsUtils'; import ONYXKEYS from '@src/ONYXKEYS'; -import {DO_NOT_USE__EXPORT_FOR_TESTS__useIDOfReportPreviewSender as useIDOfReportPreviewSender} from '@src/pages/home/report/ReportActionItemSingle'; import type {PersonalDetailsList} from '@src/types/onyx'; import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; import {actionR14932, actionR98765} from '../../__mocks__/reportData/actions'; @@ -103,7 +103,7 @@ const validAction = { childManagerAccountID: iouReportR14932.managerID, }; -describe('useIDOfReportPreviewSender', () => { +describe('useReportPreviewSenderID', () => { const mockedEmailToID: Record> = { [personalDetails[15593135].login]: 15593135, [personalDetails[51760358].login]: 51760358, @@ -131,7 +131,7 @@ describe('useIDOfReportPreviewSender', () => { it('returns undefined when action is not a report preview', () => { const {result} = renderHook(() => - useIDOfReportPreviewSender({ + useReportPreviewSenderID({ action: actionR14932, iouReport: iouReportR14932, report: chatReportR14932, @@ -142,7 +142,7 @@ describe('useIDOfReportPreviewSender', () => { it('returns childManagerAccountID when all conditions are met for Send Money flow', () => { const {result} = renderHook(() => - useIDOfReportPreviewSender({ + useReportPreviewSenderID({ action: {...validAction, childMoneyRequestCount: 0}, iouReport: iouReportR14932, report: chatReportR14932, @@ -165,7 +165,7 @@ describe('useIDOfReportPreviewSender', () => { }, }); const {result} = renderHook(() => - useIDOfReportPreviewSender({ + useReportPreviewSenderID({ action: validAction, iouReport: iouReportR14932, report: chatReportR14932, @@ -184,7 +184,7 @@ describe('useIDOfReportPreviewSender', () => { amount: -100, }); const {result} = renderHook(() => - useIDOfReportPreviewSender({ + useReportPreviewSenderID({ action: validAction, iouReport: iouReportR14932, report: chatReportR14932, @@ -195,7 +195,7 @@ describe('useIDOfReportPreviewSender', () => { it('returns childOwnerAccountID when all conditions are met', () => { const {result} = renderHook(() => - useIDOfReportPreviewSender({ + useReportPreviewSenderID({ action: validAction, iouReport: iouReportR14932, report: chatReportR14932, From 805494b1c29dd0b292fa53c696b44201d202e7b0 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Mon, 7 Jul 2025 13:15:30 +0200 Subject: [PATCH 14/18] Refractor, extract & adapt the logic for MoneyReportHeaders --- src/components/AvatarWithDisplayName.tsx | 36 +- src/components/HeaderWithBackButton/index.tsx | 2 + src/components/HeaderWithBackButton/types.ts | 4 + src/components/MoneyReportHeader.tsx | 13 + .../home/report/ReportActionItemSingle.tsx | 173 ++------- .../home/report/useReportPreviewDetails.tsx | 327 ++++++++++++++++++ .../home/report/useReportPreviewSenderID.tsx | 87 ----- tests/unit/ReportActionItemSingleTest.ts | 41 ++- 8 files changed, 440 insertions(+), 243 deletions(-) create mode 100644 src/pages/home/report/useReportPreviewDetails.tsx delete mode 100644 src/pages/home/report/useReportPreviewSenderID.tsx diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index a0fb57fff240d..1790af9d5d6af 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -27,6 +27,8 @@ import { navigateToDetailsPage, shouldReportShowSubscript, } from '@libs/ReportUtils'; +import type {AvatarDetails} from '@pages/home/report/useReportPreviewDetails'; +import {getReportPreviewSenderAvatar} from '@pages/home/report/useReportPreviewDetails'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -73,6 +75,9 @@ type AvatarWithDisplayNameProps = { /** Color of the secondary avatar border, usually should match the container background */ avatarBorderColor?: ColorValue; + + /** If we want to override the default avatar behavior and set a single avatar, we should pass this prop. */ + singleAvatarDetails?: AvatarDetails; }; const fallbackIcon: Icon = { @@ -167,6 +172,7 @@ function AvatarWithDisplayName({ shouldEnableAvatarNavigation = true, shouldUseCustomSearchTitleName = false, transactions = [], + singleAvatarDetails, openParentReportInCurrentTab = false, avatarBorderColor: avatarBorderColorProp, }: AvatarWithDisplayNameProps) { @@ -236,25 +242,41 @@ function AvatarWithDisplayName({ Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID)); } }, [report, shouldEnableDetailPageNavigation, goToDetailsPage]); + const shouldUseFullTitle = isMoneyRequestOrReport || isAnonymous; - const avatar = ( - - {shouldShowSubscriptAvatar ? ( + + const getAvatar = () => { + if (shouldShowSubscriptAvatar) { + return ( - ) : ( + ); + } + + if (!singleAvatarDetails || singleAvatarDetails.shouldDisplayAllActors || !singleAvatarDetails.reportPreviewSenderID) { + return ( - )} - - ); + ); + } + + return getReportPreviewSenderAvatar({ + reportPreviewDetails: singleAvatarDetails, + personalDetails, + containerStyles: [styles.actionAvatar, styles.mr3], + actorAccountID: actorAccountID?.current, + }); + }; + + const avatar = {getAvatar()}; + const headerView = ( {!!report && !!title && ( diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 7bf69ec39b39d..1d358f9ebddda 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -37,6 +37,7 @@ function HeaderWithBackButton({ report, policy, policyAvatar, + singleAvatarDetails, shouldShowReportAvatarWithDisplay = false, shouldShowBackButton = true, shouldShowBorderBottom = false, @@ -103,6 +104,7 @@ function HeaderWithBackButton({ diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index 19135d36ace58..f742884baf4fe 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -3,6 +3,7 @@ import type {StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {PopoverMenuItem} from '@components/PopoverMenu'; import type {Action} from '@hooks/useSingleExecution'; +import type {AvatarDetails} from '@pages/home/report/useReportPreviewDetails'; import type {StepCounterParams} from '@src/languages/params'; import type {TranslationPaths} from '@src/languages/types'; import type {AnchorPosition} from '@src/styles'; @@ -161,6 +162,9 @@ type HeaderWithBackButtonProps = Partial & { shouldMinimizeMenuButton?: boolean; /** Whether to open the parent report link in the current tab if possible */ openParentReportInCurrentTab?: boolean; + + /** If we want to override the default avatar behavior and set a single avatar, we should pass this prop. */ + singleAvatarDetails?: AvatarDetails; }; export type {ThreeDotsMenuItem}; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 35f1fc35434bf..261dcf3143ff7 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -60,6 +60,7 @@ import { shouldShowBrokenConnectionViolationForMultipleTransactions, } from '@libs/TransactionUtils'; import type {ExportType} from '@pages/home/report/ReportDetailsExportPage'; +import useReportPreviewDetails from '@pages/home/report/useReportPreviewDetails'; import variables from '@styles/variables'; import { approveMoneyRequest, @@ -185,6 +186,15 @@ function MoneyReportHeader({ const isExported = isExportedUtils(reportActions); const integrationNameFromExportMessage = isExported ? getIntegrationNameFromExportMessageUtils(reportActions) : null; + const [reportPreviewAction] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${chatReport?.reportID}`, { + canBeMissing: true, + selector: (actions) => Object.entries(actions ?? {}).find(([id]) => id === moneyRequestReport?.parentReportActionID)?.[1], + }); + + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { + canBeMissing: true, + }); + const [downloadErrorModalVisible, setDownloadErrorModalVisible] = useState(false); const [isCancelPaymentModalVisible, setIsCancelPaymentModalVisible] = useState(false); const [isDeleteExpenseModalVisible, setIsDeleteExpenseModalVisible] = useState(false); @@ -220,6 +230,8 @@ function MoneyReportHeader({ [allViolations, transactionIDs], ); + const details = useReportPreviewDetails({report: chatReport, iouReport: moneyRequestReport, action: reportPreviewAction, policy, innerPolicies: policies, personalDetails}); + const messagePDF = useMemo(() => { if (!reportPDFFilename) { return translate('reportDetailsPage.waitForPDF'); @@ -944,6 +956,7 @@ function MoneyReportHeader({ & { /** All the data of the action */ @@ -109,108 +92,34 @@ function ReportActionItemSingle({ const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, { canBeMissing: true, }); + const [innerPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, { canBeMissing: true, }); - const activePolicies = policies ?? innerPolicies; const policy = usePolicy(report?.policyID); + const delegatePersonalDetails = action?.delegateAccountID ? personalDetails?.[action?.delegateAccountID] : undefined; - const ownerAccountID = iouReport?.ownerAccountID ?? action?.childOwnerAccountID; - const isReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; const actorAccountID = getReportActionActorAccountID(action, iouReport, report, delegatePersonalDetails); - const reportPreviewSenderID = useReportPreviewSenderID({action, iouReport, report}); - const accountID = reportPreviewSenderID ?? actorAccountID ?? CONST.DEFAULT_NUMBER_ID; - - const invoiceReceiverPolicy = - report?.invoiceReceiver && 'policyID' in report.invoiceReceiver ? activePolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.invoiceReceiver.policyID}`] : undefined; - - let displayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails}); - const {avatar, login, pendingFields, status, fallbackIcon} = personalDetails?.[accountID] ?? {}; - const accountOwnerDetails = getPersonalDetailByEmail(login ?? ''); - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - let actorHint = (login || (displayName ?? '')).replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, ''); - const isTripRoom = isTripRoomReportUtils(report); - - // We want to display only the sender's avatar next to the report preview if it only contains one person's expenses. - const displayAllActors = isReportPreviewAction && !isTripRoom && !isPolicyExpenseChat(report) && !reportPreviewSenderID; - const isInvoiceReport = isInvoiceReportUtils(iouReport ?? null); - const isWorkspaceActor = isInvoiceReport || (isPolicyExpenseChat(report) && (!actorAccountID || displayAllActors)); - - let avatarSource = avatar; - let avatarId: number | string | undefined = actorAccountID; - if (isWorkspaceActor) { - displayName = getPolicyName({report, policy}); - actorHint = displayName; - avatarSource = getWorkspaceIcon(report, policy).source; - avatarId = report?.policyID; - } else if (delegatePersonalDetails) { - displayName = delegatePersonalDetails?.displayName ?? ''; - avatarSource = delegatePersonalDetails?.avatar; - avatarId = delegatePersonalDetails?.accountID; - } else if (isReportPreviewAction && isTripRoom) { - displayName = report?.reportName ?? ''; - avatarSource = personalDetails?.[ownerAccountID ?? CONST.DEFAULT_NUMBER_ID]?.avatar; - avatarId = ownerAccountID; - } - - // If this is a report preview, display names and avatars of both people involved - let secondaryAvatar: Icon; - const primaryDisplayName = displayName; - if (displayAllActors) { - if (isInvoiceRoom(report) && !isIndividualInvoiceRoom(report)) { - const secondaryPolicyAvatar = invoiceReceiverPolicy?.avatarURL ?? getDefaultWorkspaceAvatar(invoiceReceiverPolicy?.name); - - secondaryAvatar = { - source: secondaryPolicyAvatar, - type: CONST.ICON_TYPE_WORKSPACE, - name: invoiceReceiverPolicy?.name, - id: invoiceReceiverPolicy?.id, - }; - } else { - // The ownerAccountID and actorAccountID can be the same if a user submits an expense back from the IOU's original creator, in that case we need to use managerID to avoid displaying the same user twice - const secondaryAccountId = ownerAccountID === actorAccountID || isInvoiceReport ? actorAccountID : ownerAccountID; - const secondaryUserAvatar = personalDetails?.[secondaryAccountId ?? -1]?.avatar ?? FallbackAvatar; - const secondaryDisplayName = getDisplayNameForParticipant({accountID: secondaryAccountId}); - - secondaryAvatar = { - source: secondaryUserAvatar, - type: CONST.ICON_TYPE_AVATAR, - name: secondaryDisplayName ?? '', - id: secondaryAccountId, - }; - } - } else if (!isWorkspaceActor) { - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - const avatarIconIndex = report?.isOwnPolicyExpenseChat || isPolicyExpenseChat(report) ? 0 : 1; - const reportIcons = getIcons(report, personalDetails, undefined, undefined, undefined, policy); - - secondaryAvatar = reportIcons.at(avatarIconIndex) ?? {name: '', source: '', type: CONST.ICON_TYPE_AVATAR}; - } else if (isInvoiceReportUtils(iouReport)) { - const secondaryAccountId = iouReport?.managerID ?? CONST.DEFAULT_NUMBER_ID; - const secondaryUserAvatar = personalDetails?.[secondaryAccountId ?? -1]?.avatar ?? FallbackAvatar; - const secondaryDisplayName = getDisplayNameForParticipant({accountID: secondaryAccountId}); + const reportPreviewDetails = useReportPreviewDetails({ + action, + report, + iouReport, + policies, + personalDetails, + innerPolicies, + policy, + }); - secondaryAvatar = { - source: secondaryUserAvatar, - type: CONST.ICON_TYPE_AVATAR, - name: secondaryDisplayName, - id: secondaryAccountId, - }; - } else { - secondaryAvatar = {name: '', source: '', type: 'avatar'}; - } - const icon = { - source: avatarSource ?? FallbackAvatar, - type: isWorkspaceActor ? CONST.ICON_TYPE_WORKSPACE : CONST.ICON_TYPE_AVATAR, - name: primaryDisplayName ?? '', - id: avatarId, - }; + const {primaryAvatar, secondaryAvatar, displayName, shouldDisplayAllActors, isWorkspaceActor, reportPreviewSenderID, actorHint} = reportPreviewDetails; + const accountID = reportPreviewSenderID ?? actorAccountID ?? CONST.DEFAULT_NUMBER_ID; - const showMultipleUserAvatarPattern = displayAllActors && !shouldShowSubscriptAvatar; + const {login, pendingFields, status} = personalDetails?.[accountID] ?? {}; + const accountOwnerDetails = getPersonalDetailByEmail(login ?? ''); - const headingText = showMultipleUserAvatarPattern ? `${icon.name} & ${secondaryAvatar.name}` : displayName; + const showMultipleUserAvatarPattern = shouldDisplayAllActors && !shouldShowSubscriptAvatar; + const headingText = showMultipleUserAvatarPattern ? `${primaryAvatar.name} & ${secondaryAvatar.name}` : displayName; // Since the display name for a report action message is delivered with the report history as an array of fragments // we'll need to take the displayName from personal details and have it be in the same format for now. Eventually, @@ -232,13 +141,13 @@ function ReportActionItemSingle({ showWorkspaceDetails(reportID); } else { // Show participants page IOU report preview - if (iouReportID && displayAllActors) { + if (iouReportID && shouldDisplayAllActors) { Navigation.navigate(ROUTES.REPORT_PARTICIPANTS.getRoute(iouReportID, Navigation.getReportRHPActiveRoute())); return; } - showUserDetails(Number(icon.id)); + showUserDetails(Number(primaryAvatar.id)); } - }, [isWorkspaceActor, reportID, iouReportID, displayAllActors, icon.id]); + }, [isWorkspaceActor, reportID, iouReportID, shouldDisplayAllActors, primaryAvatar.id]); const shouldDisableDetailPage = useMemo( () => @@ -256,48 +165,38 @@ function ReportActionItemSingle({ } return theme.sidebar; }; + const getAvatar = () => { if (shouldShowSubscriptAvatar) { return ( ); } - if (displayAllActors) { + if (shouldDisplayAllActors) { return ( ); } - return ( - - - - - - ); + + return getReportPreviewSenderAvatar({ + reportPreviewDetails, + personalDetails, + containerStyles: [styles.actionAvatar], + actorAccountID, + }); }; - const hasEmojiStatus = !displayAllActors && status?.emojiCode; + const hasEmojiStatus = !shouldDisplayAllActors && status?.emojiCode; const formattedDate = DateUtils.getStatusUntilDate(status?.clearAfter ?? ''); const statusText = status?.text ?? ''; const statusTooltipText = formattedDate ? `${statusText ? `${statusText} ` : ''}(${formattedDate})` : statusText; @@ -331,11 +230,11 @@ function ReportActionItemSingle({ diff --git a/src/pages/home/report/useReportPreviewDetails.tsx b/src/pages/home/report/useReportPreviewDetails.tsx new file mode 100644 index 0000000000000..f2e816bc38968 --- /dev/null +++ b/src/pages/home/report/useReportPreviewDetails.tsx @@ -0,0 +1,327 @@ +import React from 'react'; +import type {ViewStyle} from 'react-native'; +import {View} from 'react-native'; +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import Avatar from '@components/Avatar'; +import {FallbackAvatar} from '@components/Icon/Expensicons'; +import UserDetailsTooltip from '@components/UserDetailsTooltip'; +import useOnyx from '@hooks/useOnyx'; +import {selectAllTransactionsForReport} from '@libs/MoneyRequestReportUtils'; +import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; +import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; +import { + getDefaultWorkspaceAvatar, + getDisplayNameForParticipant, + getIcons, + getPolicyName, + getReportActionActorAccountID, + getWorkspaceIcon, + isIndividualInvoiceRoom, + isInvoiceReport as isInvoiceReportUtils, + isInvoiceRoom, + isPolicyExpenseChat, + isTripRoom as isTripRoomReportUtils, +} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList, Policy, Report, ReportAction, Transaction} from '@src/types/onyx'; +import type {Icon} from '@src/types/onyx/OnyxCommon'; + +type AvatarDetails = { + reportPreviewSenderID: number | undefined; + reportPreviewAction: OnyxEntry; + primaryAvatar: Icon; + secondaryAvatar: Icon; + shouldDisplayAllActors: boolean; + displayName: string; + isWorkspaceActor: boolean; + actorHint: string; + fallbackIcon: string | undefined; +}; + +type AvatarDetailsProps = { + personalDetails: OnyxEntry; + innerPolicies: OnyxCollection; + policy: OnyxEntry; + action: OnyxEntry; + report: OnyxEntry; + iouReport?: OnyxEntry; + policies?: OnyxCollection; +}; + +function getSplitAuthor(transaction: Transaction, splits?: Array>) { + const {originalTransactionID, source} = transaction.comment ?? {}; + + if (source !== CONST.IOU.TYPE.SPLIT || originalTransactionID === undefined) { + return undefined; + } + + const splitAction = splits?.find((split) => getOriginalMessage(split)?.IOUTransactionID === originalTransactionID); + + if (!splitAction) { + return undefined; + } + + return splitAction.actorAccountID; +} + +function getIconDetails({ + action, + report, + iouReport, + policies, + personalDetails, + reportPreviewSenderID, + innerPolicies, + policy, +}: AvatarDetailsProps & {reportPreviewSenderID: number | undefined}) { + const delegatePersonalDetails = action?.delegateAccountID ? personalDetails?.[action?.delegateAccountID] : undefined; + const actorAccountID = getReportActionActorAccountID(action, iouReport, report, delegatePersonalDetails); + const accountID = reportPreviewSenderID ?? actorAccountID ?? CONST.DEFAULT_NUMBER_ID; + + const activePolicies = policies ?? innerPolicies; + + const ownerAccountID = iouReport?.ownerAccountID ?? action?.childOwnerAccountID; + const isReportPreviewAction = action?.actionName === CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW; + + const invoiceReceiverPolicy = + report?.invoiceReceiver && 'policyID' in report.invoiceReceiver ? activePolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.invoiceReceiver.policyID}`] : undefined; + + const {avatar, login, fallbackIcon} = personalDetails?.[accountID] ?? {}; + + const isTripRoom = isTripRoomReportUtils(report); + // We want to display only the sender's avatar next to the report preview if it only contains one person's expenses. + const displayAllActors = isReportPreviewAction && !isTripRoom && !isPolicyExpenseChat(report) && !reportPreviewSenderID; + const isInvoiceReport = isInvoiceReportUtils(iouReport ?? null); + const isWorkspaceActor = isInvoiceReport || (isPolicyExpenseChat(report) && (!actorAccountID || displayAllActors)); + + const getPrimaryAvatar = () => { + const defaultDisplayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails}); + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const actorHint = isWorkspaceActor ? getPolicyName({report, policy}) : (login || (defaultDisplayName ?? '')).replace(CONST.REGEX.MERGED_ACCOUNT_PREFIX, ''); + + const defaultAvatar = { + source: avatar ?? FallbackAvatar, + id: actorAccountID, + name: defaultDisplayName, + type: CONST.ICON_TYPE_AVATAR, + }; + + if (isWorkspaceActor) { + return { + avatar: { + ...defaultAvatar, + name: getPolicyName({report, policy}), + type: CONST.ICON_TYPE_WORKSPACE, + source: getWorkspaceIcon(report, policy).source, + id: report?.policyID, + }, + actorHint, + }; + } + + if (delegatePersonalDetails) { + return { + avatar: { + ...defaultAvatar, + name: delegatePersonalDetails?.displayName ?? '', + source: delegatePersonalDetails?.avatar ?? FallbackAvatar, + id: delegatePersonalDetails?.accountID, + }, + actorHint, + }; + } + + if (isReportPreviewAction && isTripRoom) { + return { + avatar: { + ...defaultAvatar, + name: report?.reportName ?? '', + source: personalDetails?.[ownerAccountID ?? CONST.DEFAULT_NUMBER_ID]?.avatar ?? FallbackAvatar, + id: ownerAccountID, + }, + actorHint, + }; + } + + return { + avatar: defaultAvatar, + actorHint, + }; + }; + + const getSecondaryAvatar = () => { + const defaultAvatar = {name: '', source: '', type: CONST.ICON_TYPE_AVATAR}; + + // If this is a report preview, display names and avatars of both people involved + if (displayAllActors) { + const secondaryAccountId = ownerAccountID === actorAccountID || isInvoiceReport ? actorAccountID : ownerAccountID; + const secondaryUserAvatar = personalDetails?.[secondaryAccountId ?? -1]?.avatar ?? FallbackAvatar; + const secondaryDisplayName = getDisplayNameForParticipant({accountID: secondaryAccountId}); + const secondaryPolicyAvatar = invoiceReceiverPolicy?.avatarURL ?? getDefaultWorkspaceAvatar(invoiceReceiverPolicy?.name); + const isWorkspaceInvoice = isInvoiceRoom(report) && !isIndividualInvoiceRoom(report); + + return isWorkspaceInvoice + ? { + source: secondaryPolicyAvatar, + type: CONST.ICON_TYPE_WORKSPACE, + name: invoiceReceiverPolicy?.name, + id: invoiceReceiverPolicy?.id, + } + : { + source: secondaryUserAvatar, + type: CONST.ICON_TYPE_AVATAR, + name: secondaryDisplayName ?? '', + id: secondaryAccountId, + }; + } + + if (!isWorkspaceActor) { + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + const avatarIconIndex = report?.isOwnPolicyExpenseChat || isPolicyExpenseChat(report) ? 0 : 1; + const reportIcons = getIcons(report, personalDetails, undefined, undefined, undefined, policy); + + return reportIcons.at(avatarIconIndex) ?? defaultAvatar; + } + + if (isInvoiceReportUtils(iouReport)) { + const secondaryAccountId = iouReport?.managerID ?? CONST.DEFAULT_NUMBER_ID; + const secondaryUserAvatar = personalDetails?.[secondaryAccountId ?? -1]?.avatar ?? FallbackAvatar; + const secondaryDisplayName = getDisplayNameForParticipant({accountID: secondaryAccountId}); + + return { + source: secondaryUserAvatar, + type: CONST.ICON_TYPE_AVATAR, + name: secondaryDisplayName, + id: secondaryAccountId, + }; + } + + return defaultAvatar; + }; + + const {avatar: primaryAvatar, actorHint} = getPrimaryAvatar(); + + return { + primaryAvatar, + secondaryAvatar: getSecondaryAvatar(), + shouldDisplayAllActors: displayAllActors, + displayName: primaryAvatar.name, + isWorkspaceActor, + actorHint, + fallbackIcon, + }; +} + +/** + * This hook is used to determine the ID of the sender, as well as the avatars of the actors and some additional data, for the report preview action. + * It was originally based on actions; now, it uses transactions and unique emails as a fallback. + * For a reason why, see https://github.com/Expensify/App/pull/64802 discussion. + */ +function useReportPreviewDetails({iouReport, report, action, ...rest}: AvatarDetailsProps): AvatarDetails { + const [iouActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, { + canBeMissing: true, + selector: (actions) => Object.values(actions ?? {}).filter(isMoneyRequestAction), + }); + + const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { + canBeMissing: true, + selector: (allTransactions) => selectAllTransactionsForReport(allTransactions, action?.childReportID, iouActions ?? []), + }); + + const [splits] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`, { + canBeMissing: true, + selector: (actions) => + Object.values(actions ?? {}) + .filter(isMoneyRequestAction) + .filter((act) => getOriginalMessage(act)?.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT), + }); + + if (action?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { + return { + reportPreviewSenderID: undefined, + reportPreviewAction: undefined, + ...getIconDetails({ + ...rest, + action, + report, + iouReport, + reportPreviewSenderID: undefined, + }), + }; + } + + // 1. If all amounts have the same sign - either all amounts are positive or all amounts are negative. + // We have to do it this way because there can be a case when actions are not available + // See: https://github.com/Expensify/App/pull/64802#issuecomment-3008944401 + + const areAmountsSignsTheSame = new Set(transactions?.map((tr) => Math.sign(tr.amount))).size < 2; + + // 2. If there is only one attendee - we check that by counting unique emails converted to account IDs in the attendees list. + // This is a fallback added because: https://github.com/Expensify/App/pull/64802#issuecomment-3007906310 + + const attendeesIDs = transactions + // If the transaction is a split, then attendees are not present as a property so we need to use a helper function. + ?.flatMap((tr) => + tr.comment?.attendees?.map((att) => (tr.comment?.source === CONST.IOU.TYPE.SPLIT ? getSplitAuthor(tr, splits) : getPersonalDetailByEmail(att.email)?.accountID)), + ) + .filter((accountID) => !!accountID); + + const isThereOnlyOneAttendee = new Set(attendeesIDs).size <= 1; + + // If the action is a 'Send Money' flow, it will only have one transaction, but the person who sent the money is the child manager account, not the child owner account. + const isSendMoneyFlow = action?.childMoneyRequestCount === 0 && transactions?.length === 1; + const singleAvatarAccountID = isSendMoneyFlow ? action.childManagerAccountID : action?.childOwnerAccountID; + + const reportPreviewSenderID = areAmountsSignsTheSame && isThereOnlyOneAttendee ? singleAvatarAccountID : undefined; + + return { + reportPreviewSenderID, + reportPreviewAction: action, + ...getIconDetails({ + ...rest, + action, + report, + iouReport, + reportPreviewSenderID, + }), + }; +} + +function getReportPreviewSenderAvatar({ + reportPreviewDetails, + personalDetails, + containerStyles, + actorAccountID, +}: { + reportPreviewDetails: AvatarDetails; + personalDetails: PersonalDetailsList | undefined; + containerStyles: ViewStyle[]; + actorAccountID: number | null | undefined; +}) { + const {primaryAvatar, isWorkspaceActor, fallbackIcon: reportFallbackIcon, reportPreviewAction} = reportPreviewDetails; + const delegatePersonalDetails = reportPreviewAction?.delegateAccountID ? personalDetails?.[reportPreviewAction?.delegateAccountID] : undefined; + + return ( + + + + + + ); +} + +export default useReportPreviewDetails; +export {getReportPreviewSenderAvatar}; +export type {AvatarDetails}; diff --git a/src/pages/home/report/useReportPreviewSenderID.tsx b/src/pages/home/report/useReportPreviewSenderID.tsx deleted file mode 100644 index 09778daa329ee..0000000000000 --- a/src/pages/home/report/useReportPreviewSenderID.tsx +++ /dev/null @@ -1,87 +0,0 @@ -import type {OnyxEntry} from 'react-native-onyx'; -import useOnyx from '@hooks/useOnyx'; -import {selectAllTransactionsForReport} from '@libs/MoneyRequestReportUtils'; -import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; -import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; -import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, ReportAction, Transaction} from '@src/types/onyx'; - -function getSplitAuthor(transaction: Transaction, splits?: Array>) { - const {originalTransactionID, source} = transaction.comment ?? {}; - - if (source !== CONST.IOU.TYPE.SPLIT || originalTransactionID === undefined) { - return undefined; - } - - const splitAction = splits?.find((split) => getOriginalMessage(split)?.IOUTransactionID === originalTransactionID); - - if (!splitAction) { - return undefined; - } - - return splitAction.actorAccountID; -} - -/** - * This hook is used to determine the ID of the sender for the report preview action. - * There are few fallbacks to determine the sender ID. - * - * It should not be used outside of this component. - * The only reason it is exported to a hook is to make it easier to remove it in the future. - * - * For a reason why it is here, see https://github.com/Expensify/App/pull/64802 discussion - */ -function useReportPreviewSenderID({action, iouReport, report}: {action: OnyxEntry; iouReport: OnyxEntry; report: OnyxEntry}) { - const [iouActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, { - canBeMissing: true, - selector: (actions) => Object.values(actions ?? {}).filter(isMoneyRequestAction), - }); - - const [transactions] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION, { - canBeMissing: true, - selector: (allTransactions) => selectAllTransactionsForReport(allTransactions, action?.childReportID, iouActions ?? []), - }); - - const [splits] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report?.reportID}`, { - canBeMissing: true, - selector: (actions) => - Object.values(actions ?? {}) - .filter(isMoneyRequestAction) - .filter((act) => getOriginalMessage(act)?.type === CONST.IOU.REPORT_ACTION_TYPE.SPLIT), - }); - - if (action?.actionName !== CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW) { - return; - } - - /* We need to perform the following checks to determine if the report preview is a single avatar: */ - - // 1. If all amounts have the same sign - either all amounts are positive or all amounts are negative. - // We have to do this because there can be a case when actions are not available - // See: https://github.com/Expensify/App/pull/64802#issuecomment-3008944401 - - const areAmountsSignsTheSame = new Set(transactions?.map((tr) => Math.sign(tr.amount))).size < 2; - - // 2. If there is only one attendee - we check that by counting unique emails converted to account IDs in the attendees list. - // This has to be done as there are cases when transaction amounts signs are the same until report is opened. - // See: https://github.com/Expensify/App/pull/64802#issuecomment-3007906310 - - const attendeesIDs = transactions - // If the transaction is a split, then attendees are not present so we need to use a helper function. - ?.flatMap((tr) => - tr.comment?.attendees?.map((att) => (tr.comment?.source === CONST.IOU.TYPE.SPLIT ? getSplitAuthor(tr, splits) : getPersonalDetailByEmail(att.email)?.accountID)), - ) - // We filter out empty ID's - .filter((accountID) => !!accountID); - - const isThereOnlyOneAttendee = new Set(attendeesIDs).size <= 1; - - // If the action is a 'Send Money' flow, it will only have one transaction, but the person who sent the money is the child manager account, not the child owner account. - const isSendMoneyFlow = action?.childMoneyRequestCount === 0 && transactions?.length === 1; - const singleAvatarAccountID = isSendMoneyFlow ? action.childManagerAccountID : action?.childOwnerAccountID; - - return areAmountsSignsTheSame && isThereOnlyOneAttendee ? singleAvatarAccountID : undefined; -} - -export default useReportPreviewSenderID; diff --git a/tests/unit/ReportActionItemSingleTest.ts b/tests/unit/ReportActionItemSingleTest.ts index 0eb98e4b740f7..3a8815cfd2498 100644 --- a/tests/unit/ReportActionItemSingleTest.ts +++ b/tests/unit/ReportActionItemSingleTest.ts @@ -1,6 +1,6 @@ import {renderHook, screen, waitFor} from '@testing-library/react-native'; import Onyx from 'react-native-onyx'; -import useReportPreviewSenderID from '@pages/home/report/useReportPreviewSenderID'; +import useReportPreviewDetails from '@pages/home/report/useReportPreviewDetails'; import CONST from '@src/CONST'; import * as PersonalDetailsUtils from '@src/libs/PersonalDetailsUtils'; import ONYXKEYS from '@src/ONYXKEYS'; @@ -8,6 +8,7 @@ import type {PersonalDetailsList} from '@src/types/onyx'; import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; import {actionR14932, actionR98765} from '../../__mocks__/reportData/actions'; import personalDetails from '../../__mocks__/reportData/personalDetails'; +import {policy420A} from '../../__mocks__/reportData/policies'; import {chatReportR14932, iouReportR14932} from '../../__mocks__/reportData/reports'; import {transactionR14932} from '../../__mocks__/reportData/transactions'; import * as LHNTestUtils from '../utils/LHNTestUtils'; @@ -103,7 +104,18 @@ const validAction = { childManagerAccountID: iouReportR14932.managerID, }; -describe('useReportPreviewSenderID', () => { +describe('useAvatarDetails', () => { + const policiesMock = { + personalDetails, + policies: { + [`${ONYXKEYS.COLLECTION.POLICY}420A`]: policy420A, + }, + innerPolicies: { + [`${ONYXKEYS.COLLECTION.POLICY}420A`]: policy420A, + }, + policy: policy420A, + }; + const mockedEmailToID: Record> = { [personalDetails[15593135].login]: 15593135, [personalDetails[51760358].login]: 51760358, @@ -131,24 +143,26 @@ describe('useReportPreviewSenderID', () => { it('returns undefined when action is not a report preview', () => { const {result} = renderHook(() => - useReportPreviewSenderID({ + useReportPreviewDetails({ action: actionR14932, iouReport: iouReportR14932, report: chatReportR14932, + ...policiesMock, }), ); - expect(result.current).toBeUndefined(); + expect(result.current.reportPreviewSenderID).toBeUndefined(); }); it('returns childManagerAccountID when all conditions are met for Send Money flow', () => { const {result} = renderHook(() => - useReportPreviewSenderID({ + useReportPreviewDetails({ action: {...validAction, childMoneyRequestCount: 0}, iouReport: iouReportR14932, report: chatReportR14932, + ...policiesMock, }), ); - expect(result.current).toBe(iouReportR14932.managerID); + expect(result.current.reportPreviewSenderID).toBe(iouReportR14932.managerID); }); it('returns undefined when there are multiple attendees', async () => { @@ -165,13 +179,14 @@ describe('useReportPreviewSenderID', () => { }, }); const {result} = renderHook(() => - useReportPreviewSenderID({ + useReportPreviewDetails({ action: validAction, iouReport: iouReportR14932, report: chatReportR14932, + ...policiesMock, }), ); - expect(result.current).toBeUndefined(); + expect(result.current.reportPreviewSenderID).toBeUndefined(); }); it('returns undefined when amounts have different signs', async () => { @@ -184,23 +199,25 @@ describe('useReportPreviewSenderID', () => { amount: -100, }); const {result} = renderHook(() => - useReportPreviewSenderID({ + useReportPreviewDetails({ action: validAction, iouReport: iouReportR14932, report: chatReportR14932, + ...policiesMock, }), ); - expect(result.current).toBeUndefined(); + expect(result.current.reportPreviewSenderID).toBeUndefined(); }); it('returns childOwnerAccountID when all conditions are met', () => { const {result} = renderHook(() => - useReportPreviewSenderID({ + useReportPreviewDetails({ action: validAction, iouReport: iouReportR14932, report: chatReportR14932, + ...policiesMock, }), ); - expect(result.current).toBe(iouReportR14932.ownerAccountID); + expect(result.current.reportPreviewSenderID).toBe(iouReportR14932.ownerAccountID); }); }); From 47b251a09174c7ab8e4046442117d46600bd8876 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 8 Jul 2025 11:27:33 +0200 Subject: [PATCH 15/18] Update useAvatarDetails test & small fixes --- src/components/AvatarWithDisplayName.tsx | 4 +- src/components/HeaderWithBackButton/index.tsx | 1 + tests/unit/ReportActionItemSingleTest.ts | 42 ++++++++++++++----- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index 1790af9d5d6af..cc57b530c185e 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -245,7 +245,7 @@ function AvatarWithDisplayName({ const shouldUseFullTitle = isMoneyRequestOrReport || isAnonymous; - const getAvatar = () => { + const getAvatar = useCallback(() => { if (shouldShowSubscriptAvatar) { return ( {getAvatar()}; diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 1d358f9ebddda..8d55acf70f703 100755 --- a/src/components/HeaderWithBackButton/index.tsx +++ b/src/components/HeaderWithBackButton/index.tsx @@ -140,6 +140,7 @@ function HeaderWithBackButton({ titleColor, translate, openParentReportInCurrentTab, + singleAvatarDetails, ]); const ThreeDotMenuButton = useMemo(() => { if (shouldShowThreeDotsButton) { diff --git a/tests/unit/ReportActionItemSingleTest.ts b/tests/unit/ReportActionItemSingleTest.ts index 3a8815cfd2498..b120ced0a85f4 100644 --- a/tests/unit/ReportActionItemSingleTest.ts +++ b/tests/unit/ReportActionItemSingleTest.ts @@ -105,6 +105,13 @@ const validAction = { }; describe('useAvatarDetails', () => { + const mockedOwnerAccountID = 15593135; + const mockedOwnerAccountAvatar = personalDetails[mockedOwnerAccountID].avatar; + + const mockedManagerAccountID = 51760358; + const mockedManagerAccountAvatar = personalDetails[mockedManagerAccountID].avatar; + const mockedDMChatRoom = {...chatReportR14932, chatType: undefined}; + const policiesMock = { personalDetails, policies: { @@ -141,31 +148,37 @@ describe('useAvatarDetails', () => { return waitForBatchedUpdates(); }); - it('returns undefined when action is not a report preview', () => { + it('returns avatar with no reportPreviewSenderID when action is not a report preview', () => { const {result} = renderHook(() => useReportPreviewDetails({ action: actionR14932, iouReport: iouReportR14932, - report: chatReportR14932, + report: mockedDMChatRoom, ...policiesMock, }), ); + + expect(result.current.primaryAvatar.source).toBe(mockedOwnerAccountAvatar); + expect(result.current.secondaryAvatar.source).toBeFalsy(); expect(result.current.reportPreviewSenderID).toBeUndefined(); }); - it('returns childManagerAccountID when all conditions are met for Send Money flow', () => { + it('returns childManagerAccountID and his avatar when all conditions are met for Send Money flow', () => { const {result} = renderHook(() => useReportPreviewDetails({ action: {...validAction, childMoneyRequestCount: 0}, iouReport: iouReportR14932, - report: chatReportR14932, + report: mockedDMChatRoom, ...policiesMock, }), ); + + expect(result.current.primaryAvatar.source).toBe(mockedManagerAccountAvatar); + expect(result.current.secondaryAvatar.source).toBeFalsy(); expect(result.current.reportPreviewSenderID).toBe(iouReportR14932.managerID); }); - it('returns undefined when there are multiple attendees', async () => { + it('returns both avatars & no reportPreviewSenderID when there are multiple attendees', async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionR14932.transactionID}`, { ...transactionR14932, comment: { @@ -182,14 +195,17 @@ describe('useAvatarDetails', () => { useReportPreviewDetails({ action: validAction, iouReport: iouReportR14932, - report: chatReportR14932, + report: mockedDMChatRoom, ...policiesMock, }), ); + + expect(result.current.primaryAvatar.source).toBe(mockedManagerAccountAvatar); + expect(result.current.secondaryAvatar.source).toBe(mockedOwnerAccountAvatar); expect(result.current.reportPreviewSenderID).toBeUndefined(); }); - it('returns undefined when amounts have different signs', async () => { + it('returns both avatars & no reportPreviewSenderID when amounts have different signs', async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionR14932.transactionID}`, { ...transactionR14932, amount: 100, @@ -202,22 +218,28 @@ describe('useAvatarDetails', () => { useReportPreviewDetails({ action: validAction, iouReport: iouReportR14932, - report: chatReportR14932, + report: mockedDMChatRoom, ...policiesMock, }), ); + + expect(result.current.primaryAvatar.source).toBe(mockedManagerAccountAvatar); + expect(result.current.secondaryAvatar.source).toBe(mockedOwnerAccountAvatar); expect(result.current.reportPreviewSenderID).toBeUndefined(); }); - it('returns childOwnerAccountID when all conditions are met', () => { + it('returns childOwnerAccountID as reportPreviewSenderID and a single avatar when all conditions are met', () => { const {result} = renderHook(() => useReportPreviewDetails({ action: validAction, iouReport: iouReportR14932, - report: chatReportR14932, + report: mockedDMChatRoom, ...policiesMock, }), ); + + expect(result.current.primaryAvatar.source).toBe(mockedOwnerAccountAvatar); + expect(result.current.secondaryAvatar.source).toBeFalsy(); expect(result.current.reportPreviewSenderID).toBe(iouReportR14932.ownerAccountID); }); }); From 470d3895aa3a73c692586a084b424863f22cfb46 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 8 Jul 2025 12:00:05 +0200 Subject: [PATCH 16/18] Correct names & extract related methods & test --- src/components/AvatarWithDisplayName.tsx | 8 +- src/components/HeaderWithBackButton/types.ts | 4 +- src/components/MoneyReportHeader.tsx | 4 +- .../useReportAvatarDetails.ts} | 50 +----- src/libs/getReportSingleAvatar.tsx | 44 +++++ .../home/report/ReportActionItemSingle.tsx | 7 +- tests/unit/ReportActionItemSingleTest.ts | 165 +---------------- tests/unit/useReportAvatarDetailsTest.ts | 169 ++++++++++++++++++ 8 files changed, 231 insertions(+), 220 deletions(-) rename src/{pages/home/report/useReportPreviewDetails.tsx => hooks/useReportAvatarDetails.ts} (86%) create mode 100644 src/libs/getReportSingleAvatar.tsx create mode 100644 tests/unit/useReportAvatarDetailsTest.ts diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index cc57b530c185e..794c5e7c6a62c 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -4,10 +4,12 @@ import type {ColorValue, TextStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import useOnyx from '@hooks/useOnyx'; +import type {ReportAvatarDetails} from '@hooks/useReportAvatarDetails'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import getReportSingleAvatar from '@libs/getReportSingleAvatar'; import Navigation from '@libs/Navigation/Navigation'; import {getPersonalDetailsForAccountIDs} from '@libs/OptionsListUtils'; import type {DisplayNameWithTooltips} from '@libs/ReportUtils'; @@ -27,8 +29,6 @@ import { navigateToDetailsPage, shouldReportShowSubscript, } from '@libs/ReportUtils'; -import type {AvatarDetails} from '@pages/home/report/useReportPreviewDetails'; -import {getReportPreviewSenderAvatar} from '@pages/home/report/useReportPreviewDetails'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -77,7 +77,7 @@ type AvatarWithDisplayNameProps = { avatarBorderColor?: ColorValue; /** If we want to override the default avatar behavior and set a single avatar, we should pass this prop. */ - singleAvatarDetails?: AvatarDetails; + singleAvatarDetails?: ReportAvatarDetails; }; const fallbackIcon: Icon = { @@ -267,7 +267,7 @@ function AvatarWithDisplayName({ ); } - return getReportPreviewSenderAvatar({ + return getReportSingleAvatar({ reportPreviewDetails: singleAvatarDetails, personalDetails, containerStyles: [styles.actionAvatar, styles.mr3], diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index f742884baf4fe..db23738a9952b 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -2,8 +2,8 @@ import type {ReactNode} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {PopoverMenuItem} from '@components/PopoverMenu'; +import type {ReportAvatarDetails} from '@hooks/useReportAvatarDetails'; import type {Action} from '@hooks/useSingleExecution'; -import type {AvatarDetails} from '@pages/home/report/useReportPreviewDetails'; import type {StepCounterParams} from '@src/languages/params'; import type {TranslationPaths} from '@src/languages/types'; import type {AnchorPosition} from '@src/styles'; @@ -164,7 +164,7 @@ type HeaderWithBackButtonProps = Partial & { openParentReportInCurrentTab?: boolean; /** If we want to override the default avatar behavior and set a single avatar, we should pass this prop. */ - singleAvatarDetails?: AvatarDetails; + singleAvatarDetails?: ReportAvatarDetails; }; export type {ThreeDotsMenuItem}; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 261dcf3143ff7..4c652cb77b4cd 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -10,6 +10,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePaymentAnimations from '@hooks/usePaymentAnimations'; import usePaymentOptions from '@hooks/usePaymentOptions'; +import useReportAvatarDetails from '@hooks/useReportAvatarDetails'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSelectedTransactionsActions from '@hooks/useSelectedTransactionsActions'; @@ -60,7 +61,6 @@ import { shouldShowBrokenConnectionViolationForMultipleTransactions, } from '@libs/TransactionUtils'; import type {ExportType} from '@pages/home/report/ReportDetailsExportPage'; -import useReportPreviewDetails from '@pages/home/report/useReportPreviewDetails'; import variables from '@styles/variables'; import { approveMoneyRequest, @@ -230,7 +230,7 @@ function MoneyReportHeader({ [allViolations, transactionIDs], ); - const details = useReportPreviewDetails({report: chatReport, iouReport: moneyRequestReport, action: reportPreviewAction, policy, innerPolicies: policies, personalDetails}); + const details = useReportAvatarDetails({report: chatReport, iouReport: moneyRequestReport, action: reportPreviewAction, policy, innerPolicies: policies, personalDetails}); const messagePDF = useMemo(() => { if (!reportPDFFilename) { diff --git a/src/pages/home/report/useReportPreviewDetails.tsx b/src/hooks/useReportAvatarDetails.ts similarity index 86% rename from src/pages/home/report/useReportPreviewDetails.tsx rename to src/hooks/useReportAvatarDetails.ts index f2e816bc38968..8815696094dad 100644 --- a/src/pages/home/report/useReportPreviewDetails.tsx +++ b/src/hooks/useReportAvatarDetails.ts @@ -1,11 +1,5 @@ -import React from 'react'; -import type {ViewStyle} from 'react-native'; -import {View} from 'react-native'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; -import Avatar from '@components/Avatar'; import {FallbackAvatar} from '@components/Icon/Expensicons'; -import UserDetailsTooltip from '@components/UserDetailsTooltip'; -import useOnyx from '@hooks/useOnyx'; import {selectAllTransactionsForReport} from '@libs/MoneyRequestReportUtils'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {getOriginalMessage, isMoneyRequestAction} from '@libs/ReportActionsUtils'; @@ -26,8 +20,9 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetailsList, Policy, Report, ReportAction, Transaction} from '@src/types/onyx'; import type {Icon} from '@src/types/onyx/OnyxCommon'; +import useOnyx from './useOnyx'; -type AvatarDetails = { +type ReportAvatarDetails = { reportPreviewSenderID: number | undefined; reportPreviewAction: OnyxEntry; primaryAvatar: Icon; @@ -218,7 +213,7 @@ function getIconDetails({ * It was originally based on actions; now, it uses transactions and unique emails as a fallback. * For a reason why, see https://github.com/Expensify/App/pull/64802 discussion. */ -function useReportPreviewDetails({iouReport, report, action, ...rest}: AvatarDetailsProps): AvatarDetails { +function useReportAvatarDetails({iouReport, report, action, ...rest}: AvatarDetailsProps): ReportAvatarDetails { const [iouActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${iouReport?.reportID}`, { canBeMissing: true, selector: (actions) => Object.values(actions ?? {}).filter(isMoneyRequestAction), @@ -288,40 +283,5 @@ function useReportPreviewDetails({iouReport, report, action, ...rest}: AvatarDet }; } -function getReportPreviewSenderAvatar({ - reportPreviewDetails, - personalDetails, - containerStyles, - actorAccountID, -}: { - reportPreviewDetails: AvatarDetails; - personalDetails: PersonalDetailsList | undefined; - containerStyles: ViewStyle[]; - actorAccountID: number | null | undefined; -}) { - const {primaryAvatar, isWorkspaceActor, fallbackIcon: reportFallbackIcon, reportPreviewAction} = reportPreviewDetails; - const delegatePersonalDetails = reportPreviewAction?.delegateAccountID ? personalDetails?.[reportPreviewAction?.delegateAccountID] : undefined; - - return ( - - - - - - ); -} - -export default useReportPreviewDetails; -export {getReportPreviewSenderAvatar}; -export type {AvatarDetails}; +export default useReportAvatarDetails; +export type {ReportAvatarDetails}; diff --git a/src/libs/getReportSingleAvatar.tsx b/src/libs/getReportSingleAvatar.tsx new file mode 100644 index 0000000000000..22b926ed0a0bb --- /dev/null +++ b/src/libs/getReportSingleAvatar.tsx @@ -0,0 +1,44 @@ +import React from 'react'; +import type {ViewStyle} from 'react-native'; +import {View} from 'react-native'; +import Avatar from '@components/Avatar'; +import UserDetailsTooltip from '@components/UserDetailsTooltip'; +import type {ReportAvatarDetails} from '@hooks/useReportAvatarDetails'; +import CONST from '@src/CONST'; +import type {PersonalDetailsList} from '@src/types/onyx'; + +function getReportSingleAvatar({ + reportPreviewDetails, + personalDetails, + containerStyles, + actorAccountID, +}: { + reportPreviewDetails: ReportAvatarDetails; + personalDetails: PersonalDetailsList | undefined; + containerStyles: ViewStyle[]; + actorAccountID: number | null | undefined; +}) { + const {primaryAvatar, isWorkspaceActor, fallbackIcon: reportFallbackIcon, reportPreviewAction} = reportPreviewDetails; + const delegatePersonalDetails = reportPreviewAction?.delegateAccountID ? personalDetails?.[reportPreviewAction?.delegateAccountID] : undefined; + + return ( + + + + + + ); +} + +export default getReportSingleAvatar; diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index f1551770c46cf..21196eb16ba18 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -11,11 +11,13 @@ import Tooltip from '@components/Tooltip'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; +import useReportAvatarDetails from '@hooks/useReportAvatarDetails'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import ControlSelection from '@libs/ControlSelection'; import DateUtils from '@libs/DateUtils'; +import getReportSingleAvatar from '@libs/getReportSingleAvatar'; import Navigation from '@libs/Navigation/Navigation'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {getReportActionMessage} from '@libs/ReportActionsUtils'; @@ -27,7 +29,6 @@ import type {Policy, Report, ReportAction} from '@src/types/onyx'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import ReportActionItemDate from './ReportActionItemDate'; import ReportActionItemFragment from './ReportActionItemFragment'; -import useReportPreviewDetails, {getReportPreviewSenderAvatar} from './useReportPreviewDetails'; type ReportActionItemSingleProps = Partial & { /** All the data of the action */ @@ -102,7 +103,7 @@ function ReportActionItemSingle({ const delegatePersonalDetails = action?.delegateAccountID ? personalDetails?.[action?.delegateAccountID] : undefined; const actorAccountID = getReportActionActorAccountID(action, iouReport, report, delegatePersonalDetails); - const reportPreviewDetails = useReportPreviewDetails({ + const reportPreviewDetails = useReportAvatarDetails({ action, report, iouReport, @@ -188,7 +189,7 @@ function ReportActionItemSingle({ ); } - return getReportPreviewSenderAvatar({ + return getReportSingleAvatar({ reportPreviewDetails, personalDetails, containerStyles: [styles.actionAvatar], diff --git a/tests/unit/ReportActionItemSingleTest.ts b/tests/unit/ReportActionItemSingleTest.ts index b120ced0a85f4..9b32ec46634c1 100644 --- a/tests/unit/ReportActionItemSingleTest.ts +++ b/tests/unit/ReportActionItemSingleTest.ts @@ -1,22 +1,13 @@ -import {renderHook, screen, waitFor} from '@testing-library/react-native'; +import {screen, waitFor} from '@testing-library/react-native'; import Onyx from 'react-native-onyx'; -import useReportPreviewDetails from '@pages/home/report/useReportPreviewDetails'; import CONST from '@src/CONST'; -import * as PersonalDetailsUtils from '@src/libs/PersonalDetailsUtils'; import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetailsList} from '@src/types/onyx'; import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; -import {actionR14932, actionR98765} from '../../__mocks__/reportData/actions'; -import personalDetails from '../../__mocks__/reportData/personalDetails'; -import {policy420A} from '../../__mocks__/reportData/policies'; -import {chatReportR14932, iouReportR14932} from '../../__mocks__/reportData/reports'; -import {transactionR14932} from '../../__mocks__/reportData/transactions'; import * as LHNTestUtils from '../utils/LHNTestUtils'; import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; import wrapOnyxWithWaitForBatchedUpdates from '../utils/wrapOnyxWithWaitForBatchedUpdates'; -import PropertyKeysOf = jest.PropertyKeysOf; - describe('ReportActionItemSingle', () => { beforeAll(() => Onyx.init({ @@ -89,157 +80,3 @@ describe('ReportActionItemSingle', () => { }); }); }); - -const reportActions = [{[actionR14932.reportActionID]: actionR14932}]; -const transactions = [transactionR14932]; - -const transactionCollectionDataSet = toCollectionDataSet(ONYXKEYS.COLLECTION.TRANSACTION, transactions, (transaction) => transaction.transactionID); -const reportActionCollectionDataSet = toCollectionDataSet(ONYXKEYS.COLLECTION.REPORT_ACTIONS, reportActions, (actions) => Object.values(actions).at(0)?.childReportID); - -const validAction = { - ...actionR98765, - childReportID: iouReportR14932.reportID, - actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, - childOwnerAccountID: iouReportR14932.ownerAccountID, - childManagerAccountID: iouReportR14932.managerID, -}; - -describe('useAvatarDetails', () => { - const mockedOwnerAccountID = 15593135; - const mockedOwnerAccountAvatar = personalDetails[mockedOwnerAccountID].avatar; - - const mockedManagerAccountID = 51760358; - const mockedManagerAccountAvatar = personalDetails[mockedManagerAccountID].avatar; - const mockedDMChatRoom = {...chatReportR14932, chatType: undefined}; - - const policiesMock = { - personalDetails, - policies: { - [`${ONYXKEYS.COLLECTION.POLICY}420A`]: policy420A, - }, - innerPolicies: { - [`${ONYXKEYS.COLLECTION.POLICY}420A`]: policy420A, - }, - policy: policy420A, - }; - - const mockedEmailToID: Record> = { - [personalDetails[15593135].login]: 15593135, - [personalDetails[51760358].login]: 51760358, - }; - - beforeAll(() => { - Onyx.init({ - keys: ONYXKEYS, - }); - jest.spyOn(PersonalDetailsUtils, 'getPersonalDetailByEmail').mockImplementation((email) => personalDetails[mockedEmailToID[email]]); - }); - - beforeEach(() => { - Onyx.multiSet({ - ...reportActionCollectionDataSet, - ...transactionCollectionDataSet, - }); - return waitForBatchedUpdates(); - }); - - afterEach(() => { - Onyx.clear(); - return waitForBatchedUpdates(); - }); - - it('returns avatar with no reportPreviewSenderID when action is not a report preview', () => { - const {result} = renderHook(() => - useReportPreviewDetails({ - action: actionR14932, - iouReport: iouReportR14932, - report: mockedDMChatRoom, - ...policiesMock, - }), - ); - - expect(result.current.primaryAvatar.source).toBe(mockedOwnerAccountAvatar); - expect(result.current.secondaryAvatar.source).toBeFalsy(); - expect(result.current.reportPreviewSenderID).toBeUndefined(); - }); - - it('returns childManagerAccountID and his avatar when all conditions are met for Send Money flow', () => { - const {result} = renderHook(() => - useReportPreviewDetails({ - action: {...validAction, childMoneyRequestCount: 0}, - iouReport: iouReportR14932, - report: mockedDMChatRoom, - ...policiesMock, - }), - ); - - expect(result.current.primaryAvatar.source).toBe(mockedManagerAccountAvatar); - expect(result.current.secondaryAvatar.source).toBeFalsy(); - expect(result.current.reportPreviewSenderID).toBe(iouReportR14932.managerID); - }); - - it('returns both avatars & no reportPreviewSenderID when there are multiple attendees', async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionR14932.transactionID}`, { - ...transactionR14932, - comment: { - attendees: [{email: personalDetails[15593135].login, displayName: 'Test One', avatarUrl: 'https://none.com/none'}], - }, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionR14932.transactionID}2`, { - ...transactionR14932, - comment: { - attendees: [{email: personalDetails[51760358].login, displayName: 'Test Two', avatarUrl: 'https://none.com/none2'}], - }, - }); - const {result} = renderHook(() => - useReportPreviewDetails({ - action: validAction, - iouReport: iouReportR14932, - report: mockedDMChatRoom, - ...policiesMock, - }), - ); - - expect(result.current.primaryAvatar.source).toBe(mockedManagerAccountAvatar); - expect(result.current.secondaryAvatar.source).toBe(mockedOwnerAccountAvatar); - expect(result.current.reportPreviewSenderID).toBeUndefined(); - }); - - it('returns both avatars & no reportPreviewSenderID when amounts have different signs', async () => { - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionR14932.transactionID}`, { - ...transactionR14932, - amount: 100, - }); - await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionR14932.transactionID}2`, { - ...transactionR14932, - amount: -100, - }); - const {result} = renderHook(() => - useReportPreviewDetails({ - action: validAction, - iouReport: iouReportR14932, - report: mockedDMChatRoom, - ...policiesMock, - }), - ); - - expect(result.current.primaryAvatar.source).toBe(mockedManagerAccountAvatar); - expect(result.current.secondaryAvatar.source).toBe(mockedOwnerAccountAvatar); - expect(result.current.reportPreviewSenderID).toBeUndefined(); - }); - - it('returns childOwnerAccountID as reportPreviewSenderID and a single avatar when all conditions are met', () => { - const {result} = renderHook(() => - useReportPreviewDetails({ - action: validAction, - iouReport: iouReportR14932, - report: mockedDMChatRoom, - ...policiesMock, - }), - ); - - expect(result.current.primaryAvatar.source).toBe(mockedOwnerAccountAvatar); - expect(result.current.secondaryAvatar.source).toBeFalsy(); - expect(result.current.reportPreviewSenderID).toBe(iouReportR14932.ownerAccountID); - }); -}); diff --git a/tests/unit/useReportAvatarDetailsTest.ts b/tests/unit/useReportAvatarDetailsTest.ts new file mode 100644 index 0000000000000..4aa68fb0bfafa --- /dev/null +++ b/tests/unit/useReportAvatarDetailsTest.ts @@ -0,0 +1,169 @@ +import {renderHook} from '@testing-library/react-native'; +import Onyx from 'react-native-onyx'; +import useReportAvatarDetails from '@hooks/useReportAvatarDetails'; +import CONST from '@src/CONST'; +import * as PersonalDetailsUtils from '@src/libs/PersonalDetailsUtils'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; +import {actionR14932, actionR98765} from '../../__mocks__/reportData/actions'; +import personalDetails from '../../__mocks__/reportData/personalDetails'; +import {policy420A} from '../../__mocks__/reportData/policies'; +import {chatReportR14932, iouReportR14932} from '../../__mocks__/reportData/reports'; +import {transactionR14932} from '../../__mocks__/reportData/transactions'; +import waitForBatchedUpdates from '../utils/waitForBatchedUpdates'; + +import PropertyKeysOf = jest.PropertyKeysOf; + +const reportActions = [{[actionR14932.reportActionID]: actionR14932}]; +const transactions = [transactionR14932]; + +const transactionCollectionDataSet = toCollectionDataSet(ONYXKEYS.COLLECTION.TRANSACTION, transactions, (transaction) => transaction.transactionID); +const reportActionCollectionDataSet = toCollectionDataSet(ONYXKEYS.COLLECTION.REPORT_ACTIONS, reportActions, (actions) => Object.values(actions).at(0)?.childReportID); + +const validAction = { + ...actionR98765, + childReportID: iouReportR14932.reportID, + actionName: CONST.REPORT.ACTIONS.TYPE.REPORT_PREVIEW, + childOwnerAccountID: iouReportR14932.ownerAccountID, + childManagerAccountID: iouReportR14932.managerID, +}; + +describe('useReportAvatarDetails', () => { + const mockedOwnerAccountID = 15593135; + const mockedOwnerAccountAvatar = personalDetails[mockedOwnerAccountID].avatar; + + const mockedManagerAccountID = 51760358; + const mockedManagerAccountAvatar = personalDetails[mockedManagerAccountID].avatar; + const mockedDMChatRoom = {...chatReportR14932, chatType: undefined}; + + const policiesMock = { + personalDetails, + policies: { + [`${ONYXKEYS.COLLECTION.POLICY}420A`]: policy420A, + }, + innerPolicies: { + [`${ONYXKEYS.COLLECTION.POLICY}420A`]: policy420A, + }, + policy: policy420A, + }; + + const mockedEmailToID: Record> = { + [personalDetails[15593135].login]: 15593135, + [personalDetails[51760358].login]: 51760358, + }; + + beforeAll(() => { + Onyx.init({ + keys: ONYXKEYS, + }); + jest.spyOn(PersonalDetailsUtils, 'getPersonalDetailByEmail').mockImplementation((email) => personalDetails[mockedEmailToID[email]]); + }); + + beforeEach(() => { + Onyx.multiSet({ + ...reportActionCollectionDataSet, + ...transactionCollectionDataSet, + }); + return waitForBatchedUpdates(); + }); + + afterEach(() => { + Onyx.clear(); + return waitForBatchedUpdates(); + }); + + it('returns avatar with no reportPreviewSenderID when action is not a report preview', () => { + const {result} = renderHook(() => + useReportAvatarDetails({ + action: actionR14932, + iouReport: iouReportR14932, + report: mockedDMChatRoom, + ...policiesMock, + }), + ); + + expect(result.current.primaryAvatar.source).toBe(mockedOwnerAccountAvatar); + expect(result.current.secondaryAvatar.source).toBeFalsy(); + expect(result.current.reportPreviewSenderID).toBeUndefined(); + }); + + it('returns childManagerAccountID and his avatar when all conditions are met for Send Money flow', () => { + const {result} = renderHook(() => + useReportAvatarDetails({ + action: {...validAction, childMoneyRequestCount: 0}, + iouReport: iouReportR14932, + report: mockedDMChatRoom, + ...policiesMock, + }), + ); + + expect(result.current.primaryAvatar.source).toBe(mockedManagerAccountAvatar); + expect(result.current.secondaryAvatar.source).toBeFalsy(); + expect(result.current.reportPreviewSenderID).toBe(iouReportR14932.managerID); + }); + + it('returns both avatars & no reportPreviewSenderID when there are multiple attendees', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionR14932.transactionID}`, { + ...transactionR14932, + comment: { + attendees: [{email: personalDetails[15593135].login, displayName: 'Test One', avatarUrl: 'https://none.com/none'}], + }, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionR14932.transactionID}2`, { + ...transactionR14932, + comment: { + attendees: [{email: personalDetails[51760358].login, displayName: 'Test Two', avatarUrl: 'https://none.com/none2'}], + }, + }); + const {result} = renderHook(() => + useReportAvatarDetails({ + action: validAction, + iouReport: iouReportR14932, + report: mockedDMChatRoom, + ...policiesMock, + }), + ); + + expect(result.current.primaryAvatar.source).toBe(mockedManagerAccountAvatar); + expect(result.current.secondaryAvatar.source).toBe(mockedOwnerAccountAvatar); + expect(result.current.reportPreviewSenderID).toBeUndefined(); + }); + + it('returns both avatars & no reportPreviewSenderID when amounts have different signs', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionR14932.transactionID}`, { + ...transactionR14932, + amount: 100, + }); + await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transactionR14932.transactionID}2`, { + ...transactionR14932, + amount: -100, + }); + const {result} = renderHook(() => + useReportAvatarDetails({ + action: validAction, + iouReport: iouReportR14932, + report: mockedDMChatRoom, + ...policiesMock, + }), + ); + + expect(result.current.primaryAvatar.source).toBe(mockedManagerAccountAvatar); + expect(result.current.secondaryAvatar.source).toBe(mockedOwnerAccountAvatar); + expect(result.current.reportPreviewSenderID).toBeUndefined(); + }); + + it('returns childOwnerAccountID as reportPreviewSenderID and a single avatar when all conditions are met', () => { + const {result} = renderHook(() => + useReportAvatarDetails({ + action: validAction, + iouReport: iouReportR14932, + report: mockedDMChatRoom, + ...policiesMock, + }), + ); + + expect(result.current.primaryAvatar.source).toBe(mockedOwnerAccountAvatar); + expect(result.current.secondaryAvatar.source).toBeFalsy(); + expect(result.current.reportPreviewSenderID).toBe(iouReportR14932.ownerAccountID); + }); +}); From 0c438fcf5f0f8007e0c3c8a8b5558df0976f21a5 Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 8 Jul 2025 12:10:58 +0200 Subject: [PATCH 17/18] Change getSingleReportAvatar to the component --- src/components/AvatarWithDisplayName.tsx | 16 +++++++++------- .../ReportActionItem/SingleReportAvatar.tsx} | 4 ++-- src/pages/home/report/ReportActionItemSingle.tsx | 16 +++++++++------- 3 files changed, 20 insertions(+), 16 deletions(-) rename src/{libs/getReportSingleAvatar.tsx => components/ReportActionItem/SingleReportAvatar.tsx} (95%) diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index 794c5e7c6a62c..b28e57ce60204 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -3,13 +3,13 @@ import {View} from 'react-native'; import type {ColorValue, TextStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import SingleReportAvatar from '@components/ReportActionItem/SingleReportAvatar'; import useOnyx from '@hooks/useOnyx'; import type {ReportAvatarDetails} from '@hooks/useReportAvatarDetails'; import useReportIsArchived from '@hooks/useReportIsArchived'; import useStyleUtils from '@hooks/useStyleUtils'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; -import getReportSingleAvatar from '@libs/getReportSingleAvatar'; import Navigation from '@libs/Navigation/Navigation'; import {getPersonalDetailsForAccountIDs} from '@libs/OptionsListUtils'; import type {DisplayNameWithTooltips} from '@libs/ReportUtils'; @@ -267,12 +267,14 @@ function AvatarWithDisplayName({ ); } - return getReportSingleAvatar({ - reportPreviewDetails: singleAvatarDetails, - personalDetails, - containerStyles: [styles.actionAvatar, styles.mr3], - actorAccountID: actorAccountID?.current, - }); + return ( + + ); }, [StyleUtils, avatarBorderColor, icons, personalDetails, shouldShowSubscriptAvatar, singleAvatarDetails, size, styles]); const avatar = {getAvatar()}; diff --git a/src/libs/getReportSingleAvatar.tsx b/src/components/ReportActionItem/SingleReportAvatar.tsx similarity index 95% rename from src/libs/getReportSingleAvatar.tsx rename to src/components/ReportActionItem/SingleReportAvatar.tsx index 22b926ed0a0bb..9a7c25d6808b3 100644 --- a/src/libs/getReportSingleAvatar.tsx +++ b/src/components/ReportActionItem/SingleReportAvatar.tsx @@ -7,7 +7,7 @@ import type {ReportAvatarDetails} from '@hooks/useReportAvatarDetails'; import CONST from '@src/CONST'; import type {PersonalDetailsList} from '@src/types/onyx'; -function getReportSingleAvatar({ +function SingleReportAvatar({ reportPreviewDetails, personalDetails, containerStyles, @@ -41,4 +41,4 @@ function getReportSingleAvatar({ ); } -export default getReportSingleAvatar; +export default SingleReportAvatar; diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index 21196eb16ba18..0a79a2e77e70e 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -5,6 +5,7 @@ import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import MultipleAvatars from '@components/MultipleAvatars'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeedback'; +import SingleReportAvatar from '@components/ReportActionItem/SingleReportAvatar'; import SubscriptAvatar from '@components/SubscriptAvatar'; import Text from '@components/Text'; import Tooltip from '@components/Tooltip'; @@ -17,7 +18,6 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import ControlSelection from '@libs/ControlSelection'; import DateUtils from '@libs/DateUtils'; -import getReportSingleAvatar from '@libs/getReportSingleAvatar'; import Navigation from '@libs/Navigation/Navigation'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {getReportActionMessage} from '@libs/ReportActionsUtils'; @@ -189,12 +189,14 @@ function ReportActionItemSingle({ ); } - return getReportSingleAvatar({ - reportPreviewDetails, - personalDetails, - containerStyles: [styles.actionAvatar], - actorAccountID, - }); + return ( + + ); }; const hasEmojiStatus = !shouldDisplayAllActors && status?.emojiCode; From 84b21baf468553b79164f9943854791a1b63f99f Mon Sep 17 00:00:00 2001 From: Jakub Korytko Date: Tue, 8 Jul 2025 12:31:03 +0200 Subject: [PATCH 18/18] Fix ESLint check --- src/components/AvatarWithDisplayName.tsx | 92 ++++++++++++++---------- 1 file changed, 54 insertions(+), 38 deletions(-) diff --git a/src/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index b28e57ce60204..688d4b9bdaf5b 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -3,7 +3,6 @@ import {View} from 'react-native'; import type {ColorValue, TextStyle} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; -import SingleReportAvatar from '@components/ReportActionItem/SingleReportAvatar'; import useOnyx from '@hooks/useOnyx'; import type {ReportAvatarDetails} from '@hooks/useReportAvatarDetails'; import useReportIsArchived from '@hooks/useReportIsArchived'; @@ -41,6 +40,7 @@ import {FallbackAvatar} from './Icon/Expensicons'; import MultipleAvatars from './MultipleAvatars'; import ParentNavigationSubtitle from './ParentNavigationSubtitle'; import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback'; +import SingleReportAvatar from './ReportActionItem/SingleReportAvatar'; import type {TransactionListItemType} from './SelectionList/types'; import SubscriptAvatar from './SubscriptAvatar'; import Text from './Text'; @@ -245,55 +245,71 @@ function AvatarWithDisplayName({ const shouldUseFullTitle = isMoneyRequestOrReport || isAnonymous; - const getAvatar = useCallback(() => { - if (shouldShowSubscriptAvatar) { + const getAvatar = useCallback( + (accountID: number) => { + if (shouldShowSubscriptAvatar) { + return ( + + ); + } + + if (!singleAvatarDetails || singleAvatarDetails.shouldDisplayAllActors || !singleAvatarDetails.reportPreviewSenderID) { + return ( + + ); + } + return ( - ); - } + }, + [StyleUtils, avatarBorderColor, icons, personalDetails, shouldShowSubscriptAvatar, singleAvatarDetails, size, styles], + ); + + const getWrappedAvatar = useCallback( + (accountID: number) => { + const avatar = getAvatar(accountID); + + if (!shouldEnableAvatarNavigation) { + return {avatar}; + } - if (!singleAvatarDetails || singleAvatarDetails.shouldDisplayAllActors || !singleAvatarDetails.reportPreviewSenderID) { return ( - + + + {avatar} + + ); - } - - return ( - - ); - }, [StyleUtils, avatarBorderColor, icons, personalDetails, shouldShowSubscriptAvatar, singleAvatarDetails, size, styles]); + }, + [getAvatar, shouldEnableAvatarNavigation, showActorDetails, title], + ); - const avatar = {getAvatar()}; + const WrappedAvatar = getWrappedAvatar(actorAccountID?.current ?? CONST.DEFAULT_NUMBER_ID); const headerView = ( {!!report && !!title && ( - {shouldEnableAvatarNavigation ? ( - - {avatar} - - ) : ( - avatar - )} + {WrappedAvatar} {getCustomDisplayName( shouldUseCustomSearchTitleName,