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/components/AvatarWithDisplayName.tsx b/src/components/AvatarWithDisplayName.tsx index a0fb57fff240d..688d4b9bdaf5b 100644 --- a/src/components/AvatarWithDisplayName.tsx +++ b/src/components/AvatarWithDisplayName.tsx @@ -4,6 +4,7 @@ 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'; @@ -39,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'; @@ -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?: ReportAvatarDetails; }; const fallbackIcon: Icon = { @@ -167,6 +172,7 @@ function AvatarWithDisplayName({ shouldEnableAvatarNavigation = true, shouldUseCustomSearchTitleName = false, transactions = [], + singleAvatarDetails, openParentReportInCurrentTab = false, avatarBorderColor: avatarBorderColorProp, }: AvatarWithDisplayNameProps) { @@ -236,40 +242,74 @@ function AvatarWithDisplayName({ Navigation.navigate(ROUTES.REPORT_WITH_ID_DETAILS.getRoute(report.reportID)); } }, [report, shouldEnableDetailPageNavigation, goToDetailsPage]); + const shouldUseFullTitle = isMoneyRequestOrReport || isAnonymous; - const avatar = ( - - {shouldShowSubscriptAvatar ? ( - - ) : ( - { + 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}; + } + + return ( + + + {avatar} + + + ); + }, + [getAvatar, shouldEnableAvatarNavigation, showActorDetails, title], + ); + + const WrappedAvatar = getWrappedAvatar(actorAccountID?.current ?? CONST.DEFAULT_NUMBER_ID); + const headerView = ( {!!report && !!title && ( - {shouldEnableAvatarNavigation ? ( - - {avatar} - - ) : ( - avatar - )} + {WrappedAvatar} {getCustomDisplayName( shouldUseCustomSearchTitleName, diff --git a/src/components/HeaderWithBackButton/index.tsx b/src/components/HeaderWithBackButton/index.tsx index 7bf69ec39b39d..8d55acf70f703 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({ @@ -138,6 +140,7 @@ function HeaderWithBackButton({ titleColor, translate, openParentReportInCurrentTab, + singleAvatarDetails, ]); const ThreeDotMenuButton = useMemo(() => { if (shouldShowThreeDotsButton) { diff --git a/src/components/HeaderWithBackButton/types.ts b/src/components/HeaderWithBackButton/types.ts index 19135d36ace58..db23738a9952b 100644 --- a/src/components/HeaderWithBackButton/types.ts +++ b/src/components/HeaderWithBackButton/types.ts @@ -2,6 +2,7 @@ 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 {StepCounterParams} from '@src/languages/params'; import type {TranslationPaths} from '@src/languages/types'; @@ -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?: ReportAvatarDetails; }; export type {ThreeDotsMenuItem}; diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 35f1fc35434bf..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'; @@ -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 = useReportAvatarDetails({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({ + + + + + ); +} + +export default SingleReportAvatar; diff --git a/src/components/ReportActionItem/TransactionPreview/types.ts b/src/components/ReportActionItem/TransactionPreview/types.ts index c75d2ef03ee1d..886dcf25a185e 100644 --- a/src/components/ReportActionItem/TransactionPreview/types.ts +++ b/src/components/ReportActionItem/TransactionPreview/types.ts @@ -105,7 +105,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; diff --git a/src/hooks/useReportAvatarDetails.ts b/src/hooks/useReportAvatarDetails.ts new file mode 100644 index 0000000000000..8815696094dad --- /dev/null +++ b/src/hooks/useReportAvatarDetails.ts @@ -0,0 +1,287 @@ +import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; +import {FallbackAvatar} from '@components/Icon/Expensicons'; +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'; +import useOnyx from './useOnyx'; + +type ReportAvatarDetails = { + 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 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), + }); + + 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, + }), + }; +} + +export default useReportAvatarDetails; +export type {ReportAvatarDetails}; diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts index 3af5e1e8c4e6f..473e412f3201a 100644 --- a/src/libs/ReportUtils.ts +++ b/src/libs/ReportUtils.ts @@ -2310,7 +2310,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); } /* @@ -8776,7 +8776,7 @@ function shouldReportShowSubscript(report: OnyxEntry, isReportArchived = return true; } - if (isExpenseReport(report) && isOneTransactionReport(report)) { + if (isExpenseReport(report)) { return true; } @@ -11469,7 +11469,6 @@ export { hasReportBeenReopened, getMoneyReportPreviewName, getNextApproverAccountID, - isOneTransactionReport, isWorkspaceTaskReport, isWorkspaceThread, }; diff --git a/src/pages/home/report/ReportActionItemSingle.tsx b/src/pages/home/report/ReportActionItemSingle.tsx index eb099cae11e9c..0a79a2e77e70e 100644 --- a/src/pages/home/report/ReportActionItemSingle.tsx +++ b/src/pages/home/report/ReportActionItemSingle.tsx @@ -2,18 +2,17 @@ import React, {useCallback, useMemo} from 'react'; import type {StyleProp, 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 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'; -import UserDetailsTooltip from '@components/UserDetailsTooltip'; 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'; @@ -22,25 +21,11 @@ import DateUtils from '@libs/DateUtils'; import Navigation from '@libs/Navigation/Navigation'; import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils'; import {getReportActionMessage} from '@libs/ReportActionsUtils'; -import { - getDefaultWorkspaceAvatar, - getDisplayNameForParticipant, - getIcons, - getPolicyName, - getReportActionActorAccountID, - getWorkspaceIcon, - isIndividualInvoiceRoom, - isInvoiceReport as isInvoiceReportUtils, - isInvoiceRoom, - isOptimisticPersonalDetail, - isPolicyExpenseChat, - isTripRoom as isTripRoomReportUtils, -} from '@libs/ReportUtils'; +import {getReportActionActorAccountID, isOptimisticPersonalDetail} from '@libs/ReportUtils'; 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 {Icon} from '@src/types/onyx/OnyxCommon'; import type ChildrenProps from '@src/types/utils/ChildrenProps'; import ReportActionItemDate from './ReportActionItemDate'; import ReportActionItemFragment from './ReportActionItemFragment'; @@ -108,102 +93,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 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] ?? {}; - 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); - const displayAllActors = isReportPreviewAction && !isTripRoom && !isPolicyExpenseChat(report); - 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 = useReportAvatarDetails({ + 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, @@ -225,13 +142,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( () => @@ -249,48 +166,40 @@ function ReportActionItemSingle({ } return theme.sidebar; }; + const getAvatar = () => { if (shouldShowSubscriptAvatar) { return ( ); } - if (displayAllActors) { + if (shouldDisplayAllActors) { return ( ); } + return ( - - - - - + ); }; - 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; @@ -324,11 +233,11 @@ function ReportActionItemSingle({ diff --git a/tests/actions/EnforceActionExportRestrictions.ts b/tests/actions/EnforceActionExportRestrictions.ts index d57e76f03ffcc..8f3a2b713641e 100644 --- a/tests/actions/EnforceActionExportRestrictions.ts +++ b/tests/actions/EnforceActionExportRestrictions.ts @@ -18,11 +18,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/ReportActionItemSingleTest.ts b/tests/unit/ReportActionItemSingleTest.ts index b02f56b38d51f..9b32ec46634c1 100644 --- a/tests/unit/ReportActionItemSingleTest.ts +++ b/tests/unit/ReportActionItemSingleTest.ts @@ -1,22 +1,13 @@ import {screen, waitFor} from '@testing-library/react-native'; import Onyx from 'react-native-onyx'; import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; import type {PersonalDetailsList} from '@src/types/onyx'; import {toCollectionDataSet} from '@src/types/utils/CollectionDataSet'; 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({ diff --git a/tests/unit/ReportUtilsGetIconsTest.ts b/tests/unit/ReportUtilsGetIconsTest.ts new file mode 100644 index 0000000000000..05c35d3177335 --- /dev/null +++ b/tests/unit/ReportUtilsGetIconsTest.ts @@ -0,0 +1,529 @@ +import type {OnyxCollection} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import {getOneTransactionThreadReportID} from '@libs/ReportActionsUtils'; +import { + getIcons, + isAdminRoom, + isAnnounceRoom, + isChatReport, + isChatRoom, + isChatThread, + isExpenseReport, + isExpenseRequest, + isGroupChat, + isIndividualInvoiceRoom, + isInvoiceReport, + isInvoiceRoom, + isIOUReport, + isMoneyRequestReport, + isPolicyExpenseChat, + isSelfDM, + isTaskReport, + isThread, + isWorkspaceTaskReport, + isWorkspaceThread, +} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +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: OnyxCollection> = { + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}1`]: { + '1': {...actionR14932, actorAccountID: 2}, + }, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}2`]: { + '2': {...actionR98765, actorAccountID: 1}, + }, + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}3`]: { + '2': {...actionR98765, actorAccountID: 1}, + }, + // For workspace thread test - parent report actions + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}workspaceParent`]: { + '1': {...actionR14932, actorAccountID: 2, actionName: CONST.REPORT.ACTIONS.TYPE.ADD_COMMENT}, + }, + // For multi-transaction IOU test - multiple transactions + [`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}multiTxn`]: { + '1': {...actionR14932, actorAccountID: 1}, + '2': {...actionR98765, actorAccountID: 1}, + }, +}; +/* 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: undefined, + }; + 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}, + }, + }; + + 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(getOneTransactionThreadReportID(report, chatReport, reportActions)).toBeFalsy(); + + const icons = getIcons(report, FAKE_PERSONAL_DETAILS); + + 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'); + }); +}); 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); + }); +});