diff --git a/Mobile-Expensify b/Mobile-Expensify index d7a182689cc6..27a47d6c0407 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit d7a182689cc66f913d85b5fa0142aeb8aeb6a523 +Subproject commit 27a47d6c0407efb43403da4062bc711734fe73fc diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index e46deb6bdb14..80324b59cb90 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -1205,7 +1205,6 @@ const ONYXKEYS = { PERSONAL_AND_WORKSPACE_CARD_LIST: 'personalAndWorkspaceCardList', CARD_FEED_ERRORS: 'cardFeedErrors', RAM_ONLY_SORTED_REPORT_ACTIONS: 'sortedReportActions', - LOGIN_TO_ACCOUNT_ID_MAP: 'loginToAccountIDMap', }, /** Stores HybridApp specific state required to interoperate with OldDot */ @@ -1697,7 +1696,6 @@ type OnyxDerivedValuesMapping = { [ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST]: OnyxTypes.PersonalAndWorkspaceCardListDerivedValue; [ONYXKEYS.DERIVED.CARD_FEED_ERRORS]: OnyxTypes.CardFeedErrorsDerivedValue; [ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: OnyxTypes.SortedReportActionsDerivedValue; - [ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP]: OnyxTypes.LoginToAccountIDMapDerivedValue; }; type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping & OnyxDerivedValuesMapping; diff --git a/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx b/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx index 78d1bfff2139..2252f0df716f 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx @@ -5,7 +5,6 @@ import {usePersonalDetails} from '@components/OnyxListItemProvider'; import UserPills from '@components/UserPills'; import useAttendees from '@hooks/useAttendees'; import useLocalize from '@hooks/useLocalize'; -import useOnyx from '@hooks/useOnyx'; import useThemeStyles from '@hooks/useThemeStyles'; import {enrichAndSortAttendees} from '@libs/AttendeeUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -13,7 +12,6 @@ import {getAttendeesListDisplayString} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import type {IOUAction, IOUType} from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; -import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type * as OnyxTypes from '@src/types/onyx'; import {attendeeSliceSelector} from './selectors'; @@ -33,13 +31,12 @@ function AttendeeField({formattedAmountPerAttendee, isReadOnly, transactionID, a const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); const personalDetailsList = usePersonalDetails(); - const [loginToAccountIDMap] = useOnyx(ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP); const shouldDisplayAttendeesError = formError === 'violations.missingAttendees'; const attendeeSlice = useTransactionSelector(transactionID, attendeeSliceSelector); const rawIouAttendees = useAttendees(attendeeSlice as OnyxEntry); - const iouAttendees = enrichAndSortAttendees(rawIouAttendees, loginToAccountIDMap, personalDetailsList, localeCompare); + const iouAttendees = enrichAndSortAttendees(rawIouAttendees, personalDetailsList, localeCompare); return ( ({ avatar: a?.avatarUrl, - displayName: a?.displayName ?? a?.email ?? '', + displayName: a?.displayName ?? a?.login ?? a?.email ?? '', accountID: a?.accountID, - email: a?.email, + email: a?.email ?? a?.login, }))} maxVisible={isReadOnly ? iouAttendees.length : undefined} /> diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index eb7d76550a94..fcf5e0f99a75 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -203,7 +203,6 @@ function MoneyRequestView({ const {getReportRHPActiveRoute} = useActiveRoute(); const {showConfirmModal} = useConfirmModal(); const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH); - const [loginToAccountIDMap] = useOnyx(ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP); const {currentSearchResults} = useSearchResultsContext(); const reportAttributes = useReportAttributes(); @@ -320,7 +319,7 @@ function MoneyRequestView({ const hasRoute = hasRouteTransactionUtils(transactionBackup ?? transaction, isDistanceRequest); const rawActualAttendees = isFromMergeTransaction && updatedTransaction ? updatedTransaction.comment?.attendees : transactionAttendees; - const actualAttendees = enrichAndSortAttendees(rawActualAttendees, loginToAccountIDMap, personalDetailsList, localeCompare); + const actualAttendees = enrichAndSortAttendees(rawActualAttendees, personalDetailsList, localeCompare); // Use the updated transaction amount in merge flow to have correct positive/negative sign const actualAmount = isFromMergeTransaction && updatedTransaction ? updatedTransaction.amount : transactionAmount; @@ -1423,9 +1422,9 @@ function MoneyRequestView({ ({ avatar: a?.avatarUrl, - displayName: a?.displayName ?? a?.email ?? '', + displayName: a?.displayName ?? a?.login ?? a?.email ?? '', accountID: a?.accountID, - email: a?.email, + email: a?.email ?? a?.login, }))} maxVisible={canEdit ? undefined : actualAttendees.length} /> diff --git a/src/components/Search/SearchList/ListItem/AttendeesCell.tsx b/src/components/Search/SearchList/ListItem/AttendeesCell.tsx index a108fd7772b8..4f66df681c90 100644 --- a/src/components/Search/SearchList/ListItem/AttendeesCell.tsx +++ b/src/components/Search/SearchList/ListItem/AttendeesCell.tsx @@ -27,16 +27,12 @@ type AttendeesCellProps = { function AttendeesCell({attendees, isHovered, isPressed}: AttendeesCellProps) { const defaultAvatars = useDefaultAvatars(); - const [loginToAccountIDMap] = useOnyx(ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP); - const attendeeIcons: IconType[] = attendees.map((attendee) => { - const accountID = loginToAccountIDMap?.[attendee.email ?? ''] ?? CONST.DEFAULT_NUMBER_ID; - return { - id: accountID, - name: attendee.displayName ?? attendee.email, - source: (attendee.avatarUrl || getDefaultAvatar({accountID, accountEmail: attendee.email, defaultAvatars})) ?? '', - type: CONST.ICON_TYPE_AVATAR, - }; - }); + const attendeeIcons: IconType[] = attendees.map((attendee) => ({ + id: attendee.accountID ?? CONST.DEFAULT_NUMBER_ID, + name: attendee.displayName ?? attendee.email, + source: (attendee.avatarUrl || getDefaultAvatar({accountID: attendee.accountID, accountEmail: attendee.email, defaultAvatars})) ?? '', + type: CONST.ICON_TYPE_AVATAR, + })); const theme = useTheme(); const styles = useThemeStyles(); diff --git a/src/libs/AttendeeUtils.ts b/src/libs/AttendeeUtils.ts index 303a314d080a..318d2822bbf5 100644 --- a/src/libs/AttendeeUtils.ts +++ b/src/libs/AttendeeUtils.ts @@ -1,7 +1,7 @@ import type {OnyxEntry} from 'react-native-onyx'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; -import type {LoginToAccountIDMapDerivedValue, PersonalDetailsList, PolicyCategories, PolicyCategory} from '@src/types/onyx'; +import type {PersonalDetailsList, PolicyCategories, PolicyCategory} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails'; import {sortAlphabetically} from './OptionsListUtils'; @@ -16,13 +16,15 @@ function getNormalizedString(value?: string): string | undefined { } function normalizeAttendee(attendee: Attendee): Attendee { - const {email, displayName: attendeeDisplayName, ...rest} = attendee; + const {email, displayName: attendeeDisplayName, login: attendeeLogin, ...rest} = attendee; const normalizedEmail = getNormalizedString(email); - const displayName = getNormalizedString(attendeeDisplayName) ?? normalizedEmail ?? ''; + const normalizedLogin = getNormalizedString(attendeeLogin); + const displayName = getNormalizedString(attendeeDisplayName) ?? normalizedEmail ?? normalizedLogin ?? ''; return { ...rest, displayName, + login: normalizedLogin, ...(normalizedEmail ? {email: normalizedEmail} : {}), }; } @@ -93,7 +95,7 @@ function getIsMissingAttendeesViolation( const attendees = convertAttendeesToArray(iouAttendees); // Check both login and email since attendee objects may have identifier in either property const attendeesMinusCreatorCount = attendees.filter((a) => { - const attendeeIdentifier = a?.email; + const attendeeIdentifier = a?.login ?? a?.email; return attendeeIdentifier !== creatorLogin && attendeeIdentifier !== creatorEmail; }).length; @@ -147,28 +149,29 @@ function syncMissingAttendeesViolation( return violations; } -type AttendeeWithAccountID = Attendee & {accountID?: number}; - /** * Enrich each attendee with live `personalDetails` and return them sorted alphabetically by displayName. */ +function enrichAndSortAttendees(attendees: Attendee[], personalDetailsList: OnyxEntry, localeCompare: LocaleContextProps['localeCompare']): Attendee[]; function enrichAndSortAttendees( - attendees: Attendee[] | undefined, - loginToAccountIDMap: OnyxEntry, + attendees: Attendee[] | string | undefined, + personalDetailsList: OnyxEntry, + localeCompare: LocaleContextProps['localeCompare'], +): Attendee[] | string | undefined; +function enrichAndSortAttendees( + attendees: Attendee[] | string | undefined, personalDetailsList: OnyxEntry, localeCompare: LocaleContextProps['localeCompare'], -): AttendeeWithAccountID[] | undefined { +): Attendee[] | string | undefined { if (!Array.isArray(attendees)) { return attendees; } return sortAlphabetically( attendees.map((a) => { - const accountID = loginToAccountIDMap?.[a.email ?? ''] ?? CONST.DEFAULT_NUMBER_ID; - const pd = personalDetailsList?.[accountID]; + const pd = a?.accountID ? personalDetailsList?.[a.accountID] : undefined; const freshAvatar = typeof pd?.avatar === 'string' ? pd.avatar : undefined; return { ...a, - accountID, // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional || to fall back when personalDetails has an empty string displayName: pd?.displayName || a?.displayName, // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional || to fall back when personalDetails has an empty string diff --git a/src/libs/DebugUtils.ts b/src/libs/DebugUtils.ts index bae429cab5c7..fa342b082295 100644 --- a/src/libs/DebugUtils.ts +++ b/src/libs/DebugUtils.ts @@ -1220,6 +1220,13 @@ function validateTransactionDraftProperty(key: keyof Transaction, value: string) email: 'string', displayName: 'string', avatarUrl: 'string', + accountID: 'number', + text: 'string', + login: 'string', + searchText: 'string', + selected: 'boolean', + iouType: CONST.IOU.TYPE, + reportID: 'string', }); case 'modifiedWaypoints': return validateObject>( diff --git a/src/libs/IOUUtils.ts b/src/libs/IOUUtils.ts index 7af16337d2c4..4e60fdf84fef 100644 --- a/src/libs/IOUUtils.ts +++ b/src/libs/IOUUtils.ts @@ -334,7 +334,7 @@ function shouldUseTransactionDraft(action: IOUAction | undefined, type?: IOUType return action === CONST.IOU.ACTION.CREATE || type === CONST.IOU.TYPE.SPLIT_EXPENSE || isMovingTransactionFromTrackExpense(action); } -function formatCurrentUserToAttendee(currentUser?: CurrentUserPersonalDetails) { +function formatCurrentUserToAttendee(currentUser?: CurrentUserPersonalDetails, reportID?: string) { if (!currentUser) { return; } @@ -347,8 +347,13 @@ function formatCurrentUserToAttendee(currentUser?: CurrentUserPersonalDetails) { const initialAttendee: Attendee = { email: login, + login, displayName, avatarUrl: SafeString(currentUser.avatar), + accountID: currentUser.accountID, + text: login, + selected: true, + reportID, }; return [initialAttendee]; diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index 8033cfd74dc0..36fa229fc148 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -288,8 +288,12 @@ function getMergeableDataAndConflictFields( } if (field === 'attendees') { - const targetAttendeeLogins = ((targetValue as Attendee[] | undefined)?.map((attendee) => attendee.email) ?? []).filter((login): login is string => !!login).sort(localeCompare); - const sourceAttendeeLogins = ((sourceValue as Attendee[] | undefined)?.map((attendee) => attendee.email) ?? []).filter((login): login is string => !!login).sort(localeCompare); + const targetAttendeeLogins = ((targetValue as Attendee[] | undefined)?.map((attendee) => attendee.login ?? attendee.email) ?? []) + .filter((login): login is string => !!login) + .sort(localeCompare); + const sourceAttendeeLogins = ((sourceValue as Attendee[] | undefined)?.map((attendee) => attendee.login ?? attendee.email) ?? []) + .filter((login): login is string => !!login) + .sort(localeCompare); if (deepEqual(targetAttendeeLogins, sourceAttendeeLogins)) { mergeableData[field] = isTargetValueEmpty ? sourceValue : targetValue; diff --git a/src/libs/OptionsListUtils/index.ts b/src/libs/OptionsListUtils/index.ts index a17c6790c5ee..7025f71aa483 100644 --- a/src/libs/OptionsListUtils/index.ts +++ b/src/libs/OptionsListUtils/index.ts @@ -2858,12 +2858,16 @@ function getFilteredRecentAttendees( currentUserEmail: string, currentUserAccountID: number, ): Option[] { - const recentAttendeeHasCurrentUser = recentAttendees.find((attendee) => attendee.email === currentUserEmail); + const recentAttendeeHasCurrentUser = recentAttendees.find((attendee) => attendee.email === currentUserEmail || attendee.login === currentUserEmail); if (!recentAttendeeHasCurrentUser && currentUserEmail) { const details = getPersonalDetailByEmail(currentUserEmail); recentAttendees.push({ email: currentUserEmail, + login: currentUserEmail, displayName: details?.displayName ?? currentUserEmail, + accountID: currentUserAccountID, + text: details?.displayName ?? currentUserEmail, + searchText: details?.displayName ?? currentUserEmail, avatarUrl: details?.avatarThumbnail ?? '', }); } diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index 071d3ba87717..2f9cb1011a4d 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -1146,8 +1146,13 @@ function getReportOwnerAsAttendee(creatorDetails: OnyxEntry): A const creatorDisplayName = creatorDetails?.displayName ?? creatorLogin; return { email: creatorLogin, + login: creatorLogin, displayName: creatorDisplayName, + accountID: creatorDetails?.accountID, + text: creatorDisplayName, + searchText: creatorDisplayName, avatarUrl: (creatorDetails?.avatarThumbnail ?? creatorDetails?.avatar ?? '') as string, + selected: true, }; } @@ -1197,7 +1202,7 @@ function getAttendees(transaction: OnyxInputOrEntry, reportOwnerAsA * Strips the SMS domain so phone-login attendees render the same as in the rendered pills. */ function getAttendeesListDisplayString(attendees: Attendee[], localeCompare?: LocaleContextProps['localeCompare']): string { - const getName = (a: Attendee) => Str.removeSMSDomain(a.displayName ?? a.email ?? ''); + const getName = (a: Attendee) => Str.removeSMSDomain(a.displayName ?? a.login ?? ''); const ordered = localeCompare ? // Lowercase to match sortAlphabetically (the pill sort) so joined string and pill order never disagree on case. [...attendees].sort((a, b) => localeCompare(getName(a).toLowerCase(), getName(b).toLowerCase())) diff --git a/src/libs/Violations/ViolationsUtils.ts b/src/libs/Violations/ViolationsUtils.ts index 7c692b9fbbf8..425a48b9c9e2 100644 --- a/src/libs/Violations/ViolationsUtils.ts +++ b/src/libs/Violations/ViolationsUtils.ts @@ -14,7 +14,6 @@ import {isReceiptError} from '@libs/ErrorUtils'; import {getCurrentUserEmail} from '@libs/Network/NetworkStore'; import Parser from '@libs/Parser'; import Permissions from '@libs/Permissions'; -import {getLoginByAccountID} from '@libs/PersonalDetailsUtils'; import { arePolicyRulesEnabled, getDistanceRateCustomUnitRate, @@ -706,19 +705,28 @@ const ViolationsUtils = { const attendees = convertAttendeesToArray(rawAttendees); const isAttendeeTrackingEnabled = isAttendeeTrackingEnabledForPolicy(policy); // Filter out the owner/creator when checking attendance count - expense is valid if at least one non-owner attendee is present + const ownerAccountID = iouReport?.ownerAccountID; + // Calculate attendees minus owner. When ownerAccountID is known, filter by accountID. + // When ownerAccountID is undefined (offline split where iouReport is unavailable), + // fallback to using login/email to identify the owner (similar to AttendeeUtils approach). let attendeesMinusOwnerCount: number; - // Prefer the actual report owner's login; fall back to the current user when the report is unavailable (e.g. an offline-created expense) - const ownerLogin = getLoginByAccountID(iouReport?.ownerAccountID) ?? getCurrentUserEmail(); - if (ownerLogin) { - // Filter by login or email to identify owner - attendeesMinusOwnerCount = attendees.filter((a) => { - const attendeeIdentifier = a?.email; - return attendeeIdentifier !== ownerLogin; - }).length; + if (ownerAccountID !== undefined) { + // Normal case: filter by accountID + attendeesMinusOwnerCount = attendees.filter((a) => a?.accountID !== ownerAccountID).length; } else { - // Can't identify owner at all - if there are attendees, assume owner is one of them - // This means we need at least 2 attendees to have a non-owner attendee - attendeesMinusOwnerCount = Math.max(0, attendees.length - 1); + // Offline scenario: ownerAccountID unavailable, use login/email as fallback + const currentUserEmail = getCurrentUserEmail(); + if (currentUserEmail) { + // Filter by login or email to identify owner + attendeesMinusOwnerCount = attendees.filter((a) => { + const attendeeIdentifier = a?.login ?? a?.email; + return attendeeIdentifier !== currentUserEmail; + }).length; + } else { + // Can't identify owner at all - if there are attendees, assume owner is one of them + // This means we need at least 2 attendees to have a non-owner attendee + attendeesMinusOwnerCount = Math.max(0, attendees.length - 1); + } } const shouldShowMissingAttendees = diff --git a/src/libs/actions/IOU/MoneyRequest.ts b/src/libs/actions/IOU/MoneyRequest.ts index b7f4387ee602..ce35b7af8cd4 100644 --- a/src/libs/actions/IOU/MoneyRequest.ts +++ b/src/libs/actions/IOU/MoneyRequest.ts @@ -300,7 +300,7 @@ function initMoneyRequest({ } const comment: Comment = { - attendees: formatCurrentUserToAttendee(currentUserPersonalDetails), + attendees: formatCurrentUserToAttendee(currentUserPersonalDetails, reportID), }; let requestCategory: string | null = null; diff --git a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts index b0520472ce21..a77a1557503c 100644 --- a/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts +++ b/src/libs/actions/OnyxDerived/ONYX_DERIVED_VALUES.ts @@ -1,7 +1,6 @@ import type {ValueOf} from 'type-fest'; import ONYXKEYS from '@src/ONYXKEYS'; import cardFeedErrorsConfig from './configs/cardFeedErrors'; -import loginToAccountIDMapConfig from './configs/loginToAccountIDMap'; import nonPersonalAndWorkspaceCardListConfig from './configs/nonPersonalAndWorkspaceCardList'; import outstandingReportsByPolicyIDConfig from './configs/outstandingReportsByPolicyID'; import personalAndWorkspaceCardListConfig from './configs/personalAndWorkspaceCardList'; @@ -24,7 +23,6 @@ const ONYX_DERIVED_VALUES = { [ONYXKEYS.DERIVED.PERSONAL_AND_WORKSPACE_CARD_LIST]: personalAndWorkspaceCardListConfig, [ONYXKEYS.DERIVED.CARD_FEED_ERRORS]: cardFeedErrorsConfig, [ONYXKEYS.DERIVED.RAM_ONLY_SORTED_REPORT_ACTIONS]: sortedReportActionsConfig, - [ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP]: loginToAccountIDMapConfig, } as const satisfies { // eslint-disable-next-line @typescript-eslint/no-explicit-any [Key in ValueOf]: OnyxDerivedValueConfig; diff --git a/src/libs/actions/OnyxDerived/configs/loginToAccountIDMap.ts b/src/libs/actions/OnyxDerived/configs/loginToAccountIDMap.ts deleted file mode 100644 index 2a2610919bc1..000000000000 --- a/src/libs/actions/OnyxDerived/configs/loginToAccountIDMap.ts +++ /dev/null @@ -1,29 +0,0 @@ -import createOnyxDerivedValueConfig from '@userActions/OnyxDerived/createOnyxDerivedValueConfig'; -import ONYXKEYS from '@src/ONYXKEYS'; -import type {LoginToAccountIDMapDerivedValue} from '@src/types/onyx'; - -/** - * Derives a map of login (lowercased) -> accountID from the personal details list. - * - * This is the reactive, persisted replacement for the imperative `emailToPersonalDetailsCache` - * login lookup that was built via `Onyx.connect` in `PersonalDetailsUtils` (see issue #66391). - * Keys are lowercased since logins/emails are case-insensitive, matching the previous cache behavior. - */ -export default createOnyxDerivedValueConfig({ - key: ONYXKEYS.DERIVED.LOGIN_TO_ACCOUNT_ID_MAP, - dependencies: [ONYXKEYS.PERSONAL_DETAILS_LIST], - compute: ([personalDetailsList]) => { - if (!personalDetailsList) { - return {}; - } - - const loginToAccountIDMap: LoginToAccountIDMapDerivedValue = {}; - for (const personalDetails of Object.values(personalDetailsList)) { - if (!personalDetails?.login) { - continue; - } - loginToAccountIDMap[personalDetails.login.toLowerCase()] = personalDetails.accountID; - } - return loginToAccountIDMap; - }, -}); diff --git a/src/libs/actions/TransactionEdit.ts b/src/libs/actions/TransactionEdit.ts index ac3214f90af0..671e0580d387 100644 --- a/src/libs/actions/TransactionEdit.ts +++ b/src/libs/actions/TransactionEdit.ts @@ -177,7 +177,7 @@ function buildOptimisticTransactionAndCreateDraft({initialTransaction, currentUs amount: 0, created: format(new Date(), 'yyyy-MM-dd'), currency, - comment: {attendees: formatCurrentUserToAttendee(currentUserPersonalDetails)}, + comment: {attendees: formatCurrentUserToAttendee(currentUserPersonalDetails, reportID)}, iouRequestType, reportID, transactionID: newTransactionID, diff --git a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx index 6c346fcbf237..a85edf2430cf 100644 --- a/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx +++ b/src/pages/iou/request/MoneyRequestAttendeeSelector.tsx @@ -86,7 +86,7 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde ...attendee, reportID: CONST.DEFAULT_NUMBER_ID.toString(), // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - keyForList: attendee.email || attendee.displayName, + keyForList: String(attendee.accountID) || attendee.email || attendee.displayName, selected: true, // Use || to fall back to displayName for name-only attendees (empty email) // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing diff --git a/src/types/onyx/DerivedValues.ts b/src/types/onyx/DerivedValues.ts index ab611bb0c51f..dcc5cad42b46 100644 --- a/src/types/onyx/DerivedValues.ts +++ b/src/types/onyx/DerivedValues.ts @@ -246,14 +246,6 @@ type SortedReportActionsDerivedValue = { */ type PersonalAndWorkspaceCardListDerivedValue = CardList; -/** - * The derived value mapping each user's login (lowercased) to their accountID. - * - * Replaces the imperative `emailToPersonalDetailsCache` login lookup that was built via `Onyx.connect` - * in `PersonalDetailsUtils` (see issue #66391). Keys are lowercased since logins/emails are case-insensitive. - */ -type LoginToAccountIDMapDerivedValue = Record; - export type { ReportAttributes, ReportAttributesDerivedValue, @@ -265,7 +257,6 @@ export type { NonPersonalAndWorkspaceCardListDerivedValue, PersonalAndWorkspaceCardListDerivedValue, CardFeedErrorsDerivedValue, - LoginToAccountIDMapDerivedValue, CardFeedErrorsObject, CardFeedErrorState, CardFeedErrors, diff --git a/src/types/onyx/IOU.ts b/src/types/onyx/IOU.ts index a54932c5ca52..374b3b2f7d6f 100644 --- a/src/types/onyx/IOU.ts +++ b/src/types/onyx/IOU.ts @@ -192,6 +192,27 @@ type Attendee = { /** IOU attendee avatar url */ avatarUrl: string; + + /** Account ID */ + accountID?: number; + + /** Text to be displayed in lists (participant display name) */ + text?: string; + + /** IOU attendee login */ + login?: string; + + /** Text that IOU attendee display name and login, if available, for searching purposes */ + searchText?: string; + + /** Is IOU attendee selected in list */ + selected?: boolean; + + /** The type of IOU report, i.e. split, request, send, track */ + iouType?: IOUType; + + /** IOU attendee report ID */ + reportID?: string; }; /** Model of IOU accountant */ diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index a32e9bc27e8e..913910699049 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -48,7 +48,6 @@ import type {CurrencyList} from './Currency'; import type CustomStatusDraft from './CustomStatusDraft'; import type { CardFeedErrorsDerivedValue, - LoginToAccountIDMapDerivedValue, NonPersonalAndWorkspaceCardListDerivedValue, OutstandingReportsByPolicyIDDerivedValue, PersonalAndWorkspaceCardListDerivedValue, @@ -397,7 +396,6 @@ export type { NonPersonalAndWorkspaceCardListDerivedValue, PersonalAndWorkspaceCardListDerivedValue, CardFeedErrorsDerivedValue, - LoginToAccountIDMapDerivedValue, ScheduleCallDraft, ValidateUserAndGetAccessiblePolicies, VacationDelegate, diff --git a/tests/actions/IOU/MoneyRequestSettersTest.ts b/tests/actions/IOU/MoneyRequestSettersTest.ts index a24030b293d4..84678b10e905 100644 --- a/tests/actions/IOU/MoneyRequestSettersTest.ts +++ b/tests/actions/IOU/MoneyRequestSettersTest.ts @@ -523,6 +523,11 @@ describe('actions/IOU', () => { attendees: [ { email: currentUserPersonalDetails.email ?? '', + login: currentUserPersonalDetails.login, + accountID: 3, + text: currentUserPersonalDetails.login, + selected: true, + reportID: '0', avatarUrl: SafeString(currentUserPersonalDetails.avatar) ?? '', displayName: currentUserPersonalDetails.displayName ?? '', }, diff --git a/tests/data/Invoice.ts b/tests/data/Invoice.ts index 3c8d021841ec..002c137cb56f 100644 --- a/tests/data/Invoice.ts +++ b/tests/data/Invoice.ts @@ -180,8 +180,13 @@ const transaction: OnyxEntry = { attendees: [ { email: 'a1@53019.com', + login: 'a1@53019.com', displayName: 'a1', avatarUrl: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_9.png', + accountID: 32, + text: 'a1@53019.com', + selected: true, + reportID: '3634215302663162', }, ], }, diff --git a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx index a40ae9130cdb..c1c0f3b8b1d6 100644 --- a/tests/ui/components/IOURequestStepConfirmationPageTest.tsx +++ b/tests/ui/components/IOURequestStepConfirmationPageTest.tsx @@ -250,9 +250,14 @@ const DEFAULT_SPLIT_TRANSACTION: Transaction = { comment: { attendees: [ { + accountID: ACCOUNT_ID, avatarUrl: '', displayName: '', email: ACCOUNT_LOGIN, + login: ACCOUNT_LOGIN, + reportID: REPORT_ID, + selected: true, + text: ACCOUNT_LOGIN, }, ], }, diff --git a/tests/ui/components/IOURequestStepReportTest.tsx b/tests/ui/components/IOURequestStepReportTest.tsx index 36eb7b0e2002..be407b9d47de 100644 --- a/tests/ui/components/IOURequestStepReportTest.tsx +++ b/tests/ui/components/IOURequestStepReportTest.tsx @@ -85,9 +85,14 @@ const DEFAULT_SPLIT_TRANSACTION: Transaction = { comment: { attendees: [ { + accountID: ACCOUNT_ID, avatarUrl: '', displayName: '', email: ACCOUNT_LOGIN, + login: ACCOUNT_LOGIN, + reportID: REPORT_ID_1, + selected: true, + text: ACCOUNT_LOGIN, }, ], }, diff --git a/tests/unit/AttendeeUtilsTest.ts b/tests/unit/AttendeeUtilsTest.ts index 177ee1077c33..93c99b4aa64b 100644 --- a/tests/unit/AttendeeUtilsTest.ts +++ b/tests/unit/AttendeeUtilsTest.ts @@ -61,9 +61,9 @@ describe('AttendeeUtils', () => { }); it('preserves all attendee fields when converting from an object', () => { - const attendee = makeAttendee({email: 'user@test.com', displayName: 'Alice'}); + const attendee = makeAttendee({email: 'user@test.com', displayName: 'Alice', login: 'alice', accountID: 42, selected: true}); const result = convertAttendeesToArray({first: attendee}); - expect(result.at(0)).toMatchObject({email: 'user@test.com', displayName: 'Alice'}); + expect(result.at(0)).toMatchObject({email: 'user@test.com', displayName: 'Alice', login: 'alice', accountID: 42, selected: true}); }); }); @@ -88,6 +88,7 @@ describe('AttendeeUtils', () => { email: ' ', displayName: ' John Smith ', avatarUrl: '', + login: ' john@example.com ', }; const result = normalizeAttendee(attendee); @@ -95,20 +96,23 @@ describe('AttendeeUtils', () => { expect(result).toEqual({ displayName: 'John Smith', avatarUrl: '', + login: 'john@example.com', }); }); - it('should fall back to an empty display name when displayName and email are missing', () => { + it('should fall back to login when displayName and email are missing', () => { const attendee: Attendee = { displayName: ' ', avatarUrl: '', + login: ' login-only@example.com ', }; const result = normalizeAttendee(attendee); expect(result).toEqual({ - displayName: '', + displayName: 'login-only@example.com', avatarUrl: '', + login: 'login-only@example.com', }); }); @@ -125,6 +129,7 @@ describe('AttendeeUtils', () => { email: 'attendee@example.com', displayName: 'attendee@example.com', avatarUrl: '', + login: undefined, }); }); }); @@ -136,13 +141,13 @@ describe('AttendeeUtils', () => { it('should normalize each attendee in the list', () => { const attendees: Attendee[] = [ - {email: ' one@example.com ', displayName: ' One ', avatarUrl: ''}, - {displayName: ' ', avatarUrl: ''}, + {email: ' one@example.com ', displayName: ' One ', avatarUrl: '', login: ' one@example.com '}, + {displayName: ' ', avatarUrl: '', login: ' two@example.com '}, ]; expect(normalizeAttendees(attendees)).toEqual([ - {email: 'one@example.com', displayName: 'One', avatarUrl: ''}, - {displayName: '', avatarUrl: ''}, + {email: 'one@example.com', displayName: 'One', avatarUrl: '', login: 'one@example.com'}, + {displayName: 'two@example.com', avatarUrl: '', login: 'two@example.com'}, ]); }); }); @@ -151,56 +156,49 @@ describe('AttendeeUtils', () => { const localeCompare = (a: string, b: string) => a.localeCompare(b); it('returns input as-is when it is not an array', () => { - expect(enrichAndSortAttendees(undefined, undefined, undefined, localeCompare)).toBeUndefined(); + expect(enrichAndSortAttendees(undefined, undefined, localeCompare)).toBeUndefined(); }); it('sorts alphabetically by stored displayName when no personalDetails are available', () => { const attendees: Attendee[] = [ - {email: 'b@x.com', displayName: 'banana', avatarUrl: ''}, - {email: 'a@x.com', displayName: 'apple', avatarUrl: ''}, + {email: 'b@x.com', displayName: 'banana', avatarUrl: '', login: 'b@x.com'}, + {email: 'a@x.com', displayName: 'apple', avatarUrl: '', login: 'a@x.com'}, ]; - expect((enrichAndSortAttendees(attendees, undefined, undefined, localeCompare) ?? []).map((a) => a.displayName)).toEqual(['apple', 'banana']); + expect(enrichAndSortAttendees(attendees, undefined, localeCompare).map((a) => a.displayName)).toEqual(['apple', 'banana']); }); - it('enriches displayName and avatar from personalDetails when the login maps to an accountID', () => { + it('enriches displayName and avatar from personalDetails when accountID matches', () => { const accountID = 1; - const email = 'user@x.com'; - const attendees: Attendee[] = [{email, displayName: 'Old', avatarUrl: 'old.png'}]; - const loginToAccountIDMap = {[email]: accountID}; + const attendees: Attendee[] = [{accountID, displayName: 'Old', avatarUrl: 'old.png'}]; const personalDetailsList: PersonalDetailsList = {[accountID]: {accountID, displayName: 'New', avatar: 'new.png'}}; - const result = enrichAndSortAttendees(attendees, loginToAccountIDMap, personalDetailsList, localeCompare); + const result = enrichAndSortAttendees(attendees, personalDetailsList, localeCompare); - expect(result?.at(0)?.displayName).toBe('New'); - expect(result?.at(0)?.avatarUrl).toBe('new.png'); + expect(result.at(0)?.displayName).toBe('New'); + expect(result.at(0)?.avatarUrl).toBe('new.png'); }); it('falls back to stored value when personalDetails has empty strings', () => { const accountID = 1; - const email = 'user@x.com'; - const attendees: Attendee[] = [{email, displayName: 'Stored', avatarUrl: 'stored.png'}]; - const loginToAccountIDMap = {[email]: accountID}; + const attendees: Attendee[] = [{accountID, displayName: 'Stored', avatarUrl: 'stored.png'}]; const personalDetailsList: PersonalDetailsList = {[accountID]: {accountID, displayName: '', avatar: ''}}; - const result = enrichAndSortAttendees(attendees, loginToAccountIDMap, personalDetailsList, localeCompare); + const result = enrichAndSortAttendees(attendees, personalDetailsList, localeCompare); - expect(result?.at(0)?.displayName).toBe('Stored'); - expect(result?.at(0)?.avatarUrl).toBe('stored.png'); + expect(result.at(0)?.displayName).toBe('Stored'); + expect(result.at(0)?.avatarUrl).toBe('stored.png'); }); it('sorts using enriched displayName so a profile rename moves the pill', () => { const renamedAccountID = 1; - const aliceEmail = 'alice@x.com'; - const bobEmail = 'bob@x.com'; const attendees: Attendee[] = [ - {email: aliceEmail, displayName: 'alice', avatarUrl: ''}, - {email: bobEmail, displayName: 'bob', avatarUrl: ''}, + {accountID: renamedAccountID, displayName: 'alice', avatarUrl: ''}, + {accountID: 2, displayName: 'bob', avatarUrl: ''}, ]; - const loginToAccountIDMap = {[aliceEmail]: renamedAccountID, [bobEmail]: 2}; const personalDetailsList: PersonalDetailsList = {[renamedAccountID]: {accountID: renamedAccountID, displayName: 'zoe'}}; - expect((enrichAndSortAttendees(attendees, loginToAccountIDMap, personalDetailsList, localeCompare) ?? []).map((a) => a.displayName)).toEqual(['bob', 'zoe']); + expect(enrichAndSortAttendees(attendees, personalDetailsList, localeCompare).map((a) => a.displayName)).toEqual(['bob', 'zoe']); }); it('does not mutate the input array', () => { @@ -210,7 +208,7 @@ describe('AttendeeUtils', () => { ]; const snapshot = [...attendees]; - enrichAndSortAttendees(attendees, undefined, undefined, localeCompare); + enrichAndSortAttendees(attendees, undefined, localeCompare); expect(attendees).toEqual(snapshot); }); diff --git a/tests/unit/IOUUtilsTest.ts b/tests/unit/IOUUtilsTest.ts index 58729cffe17c..d59e499f3876 100644 --- a/tests/unit/IOUUtilsTest.ts +++ b/tests/unit/IOUUtilsTest.ts @@ -1070,8 +1070,13 @@ describe('formatCurrentUserToAttendee', () => { expect(attendees).toEqual([ { email: 'john.smith@example.com', + login: 'john.smith@example.com', displayName: 'John Smith', avatarUrl: '', + accountID: 2840332, + text: 'john.smith@example.com', + selected: true, + reportID: undefined, }, ]); }); @@ -1088,8 +1093,13 @@ describe('formatCurrentUserToAttendee', () => { expect(attendees).toEqual([ { email: 'john.smith@example.com', + login: 'john.smith@example.com', displayName: 'john.smith@example.com', avatarUrl: '', + accountID: 2840332, + text: 'john.smith@example.com', + selected: true, + reportID: undefined, }, ]); }); diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index 7d4e62a3e46f..912967e56b67 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -1335,8 +1335,13 @@ describe('TransactionUtils', () => { expect(result).toBeDefined(); expect(result?.email).toBe(OTHER_USER_EMAIL); + expect(result?.login).toBe(OTHER_USER_EMAIL); expect(result?.displayName).toBe(OTHER_USER_EMAIL); + expect(result?.accountID).toBe(SECOND_USER_ID); + expect(result?.text).toBe(OTHER_USER_EMAIL); + expect(result?.searchText).toBe(OTHER_USER_EMAIL); expect(result?.avatarUrl).toBe(avatar); + expect(result?.selected).toBe(true); }); it('should return current user as attendee for unreported expense', () => { @@ -1344,8 +1349,13 @@ describe('TransactionUtils', () => { expect(result).toBeDefined(); expect(result?.email).toBe(CURRENT_USER_EMAIL); + expect(result?.login).toBe(CURRENT_USER_EMAIL); expect(result?.displayName).toBe(currentUserPersonalDetails.displayName); + expect(result?.accountID).toBe(CURRENT_USER_ID); + expect(result?.text).toBe(currentUserPersonalDetails.displayName); + expect(result?.searchText).toBe(currentUserPersonalDetails.displayName); expect(result?.avatarUrl).toBe(''); + expect(result?.selected).toBe(true); }); }); @@ -1399,13 +1409,19 @@ describe('TransactionUtils', () => { const attendees: Attendee[] = [ { email: 'attendee1@example.com', + login: 'attendee1@example.com', displayName: 'Attendee One', avatarUrl: '', + accountID: 3, + selected: true, }, { email: 'attendee2@example.com', + login: 'attendee2@example.com', displayName: 'Attendee Two', avatarUrl: '', + accountID: 4, + selected: false, }, ]; const transaction = generateTransaction({ @@ -1429,29 +1445,30 @@ describe('TransactionUtils', () => { }, }); - const result = TransactionUtils.getOriginalAttendees(transaction, {email: CURRENT_USER_EMAIL, displayName: '', avatarUrl: ''}); + const result = TransactionUtils.getOriginalAttendees(transaction, {accountID: CURRENT_USER_ID, displayName: '', avatarUrl: '', selected: true}); expect(result.length).toBe(1); - expect(result.at(0)?.email).toBe(CURRENT_USER_EMAIL); + expect(result.at(0)?.accountID).toBe(CURRENT_USER_ID); + expect(result.at(0)?.selected).toBe(true); }); - it('should normalize email-only attendees from comment', () => { + it('should normalize login-only attendees from comment', () => { const transaction = generateTransaction({ reportID: FAKE_OPEN_REPORT_ID, comment: { - attendees: [{displayName: ' ', email: ' email-only@example.com ', avatarUrl: ''}], + attendees: [{displayName: ' ', login: ' login-only@example.com ', avatarUrl: ''}], }, }); const result = TransactionUtils.getOriginalAttendees(transaction, undefined); - expect(result).toEqual([{displayName: 'email-only@example.com', email: 'email-only@example.com', avatarUrl: ''}]); + expect(result).toEqual([{displayName: 'login-only@example.com', login: 'login-only@example.com', avatarUrl: ''}]); }); it('should handle attendees stored as a plain object', () => { const attendeesArray: Attendee[] = [ - {email: 'attendee1@example.com', displayName: 'Attendee One', avatarUrl: ''}, - {email: 'attendee2@example.com', displayName: 'Attendee Two', avatarUrl: ''}, + {email: 'attendee1@example.com', login: 'attendee1@example.com', displayName: 'Attendee One', avatarUrl: '', accountID: 3, selected: true}, + {email: 'attendee2@example.com', login: 'attendee2@example.com', displayName: 'Attendee Two', avatarUrl: '', accountID: 4, selected: false}, ]; const transaction = generateTransaction({ reportID: FAKE_OPEN_REPORT_ID, @@ -1473,15 +1490,21 @@ describe('TransactionUtils', () => { const originalAttendees: Attendee[] = [ { email: 'original@example.com', + login: 'original@example.com', displayName: 'Original Attendee', avatarUrl: '', + accountID: 5, + selected: true, }, ]; const modifiedAttendees: Attendee[] = [ { email: 'modified@example.com', + login: 'modified@example.com', displayName: 'Modified Attendee', avatarUrl: '', + accountID: 6, + selected: true, }, ]; const transaction = generateTransaction({ @@ -1503,8 +1526,11 @@ describe('TransactionUtils', () => { const attendees: Attendee[] = [ { email: 'attendee@example.com', + login: 'attendee@example.com', displayName: 'Attendee', avatarUrl: '', + accountID: 7, + selected: true, }, ]; const transaction = generateTransaction({ @@ -1527,10 +1553,11 @@ describe('TransactionUtils', () => { }, }); - const result = TransactionUtils.getAttendees(transaction, {email: CURRENT_USER_EMAIL, avatarUrl: '', displayName: ''}); + const result = TransactionUtils.getAttendees(transaction, {accountID: CURRENT_USER_ID, avatarUrl: '', displayName: '', selected: true}); expect(result.length).toBe(1); - expect(result.at(0)?.email).toBe(CURRENT_USER_EMAIL); + expect(result.at(0)?.accountID).toBe(CURRENT_USER_ID); + expect(result.at(0)?.selected).toBe(true); }); it('should return empty array when transaction has no reportID and no attendees', () => { @@ -1550,8 +1577,11 @@ describe('TransactionUtils', () => { const originalAttendees: Attendee[] = [ { email: 'original@example.com', + login: 'original@example.com', displayName: 'Original Attendee', avatarUrl: '', + accountID: 8, + selected: true, }, ]; @@ -1563,11 +1593,11 @@ describe('TransactionUtils', () => { modifiedAttendees: [], }); - const result = TransactionUtils.getAttendees(transaction, {email: CURRENT_USER_EMAIL, avatarUrl: '', displayName: ''}); + const result = TransactionUtils.getAttendees(transaction, {accountID: CURRENT_USER_ID, avatarUrl: '', displayName: ''}); // When modifiedAttendees is empty array and no report owner fallback applies expect(result.length).toBe(1); - expect(result.at(0)?.email).toBe(CURRENT_USER_EMAIL); + expect(result.at(0)?.accountID).toBe(CURRENT_USER_ID); }); it('should normalize modified attendees with undefined email', () => { @@ -1576,16 +1606,16 @@ describe('TransactionUtils', () => { comment: { attendees: [], }, - modifiedAttendees: [{displayName: ' ', email: ' edited@example.com ', avatarUrl: ''}], + modifiedAttendees: [{displayName: ' ', login: ' edited@example.com ', avatarUrl: ''}], }); const result = TransactionUtils.getAttendees(transaction); - expect(result).toEqual([{displayName: 'edited@example.com', email: 'edited@example.com', avatarUrl: ''}]); + expect(result).toEqual([{displayName: 'edited@example.com', login: 'edited@example.com', avatarUrl: ''}]); }); it('should handle comment attendees stored as a plain object', () => { - const attendeesArray: Attendee[] = [{email: 'attendee@example.com', displayName: 'Attendee', avatarUrl: ''}]; + const attendeesArray: Attendee[] = [{email: 'attendee@example.com', login: 'attendee@example.com', displayName: 'Attendee', avatarUrl: '', accountID: 7, selected: true}]; const transaction = generateTransaction({ reportID: FAKE_OPEN_REPORT_ID, comment: { @@ -1600,7 +1630,9 @@ describe('TransactionUtils', () => { }); it('should handle modifiedAttendees stored as a plain object', () => { - const modifiedAttendeesArray: Attendee[] = [{email: 'modified@example.com', displayName: 'Modified Attendee', avatarUrl: ''}]; + const modifiedAttendeesArray: Attendee[] = [ + {email: 'modified@example.com', login: 'modified@example.com', displayName: 'Modified Attendee', avatarUrl: '', accountID: 6, selected: true}, + ]; const transaction = generateTransaction({ reportID: FAKE_OPEN_REPORT_ID, comment: { @@ -1623,10 +1655,10 @@ describe('TransactionUtils', () => { }, }); - const result = TransactionUtils.getAttendees(transaction, {email: CURRENT_USER_EMAIL, avatarUrl: '', displayName: ''}); + const result = TransactionUtils.getAttendees(transaction, {accountID: CURRENT_USER_ID, avatarUrl: '', displayName: ''}); expect(result.length).toBe(1); - expect(result.at(0)?.email).toBe(CURRENT_USER_EMAIL); + expect(result.at(0)?.accountID).toBe(CURRENT_USER_ID); }); it('should fall back to report owner when modifiedAttendees is an empty plain object', () => { @@ -1638,10 +1670,10 @@ describe('TransactionUtils', () => { modifiedAttendees: {} as unknown as Attendee[], }); - const result = TransactionUtils.getAttendees(transaction, {email: CURRENT_USER_EMAIL, avatarUrl: '', displayName: ''}); + const result = TransactionUtils.getAttendees(transaction, {accountID: CURRENT_USER_ID, avatarUrl: '', displayName: ''}); expect(result.length).toBe(1); - expect(result.at(0)?.email).toBe(CURRENT_USER_EMAIL); + expect(result.at(0)?.accountID).toBe(CURRENT_USER_ID); }); }); @@ -1650,40 +1682,40 @@ describe('TransactionUtils', () => { it('preserves insertion order when no localeCompare is provided', () => { const attendees: Attendee[] = [ - {email: 'b@x.com', displayName: 'banana', avatarUrl: ''}, - {email: 'a@x.com', displayName: 'apple', avatarUrl: ''}, + {email: 'b@x.com', displayName: 'banana', avatarUrl: '', login: 'b@x.com'}, + {email: 'a@x.com', displayName: 'apple', avatarUrl: '', login: 'a@x.com'}, ]; expect(TransactionUtils.getAttendeesListDisplayString(attendees)).toBe('banana, apple'); }); it('returns attendees alphabetically regardless of insertion order (deploy blocker #89130)', () => { const attendees: Attendee[] = [ - {email: 'b@x.com', displayName: 'banana', avatarUrl: ''}, - {email: 'a@x.com', displayName: 'apple', avatarUrl: ''}, + {email: 'b@x.com', displayName: 'banana', avatarUrl: '', login: 'b@x.com'}, + {email: 'a@x.com', displayName: 'apple', avatarUrl: '', login: 'a@x.com'}, ]; expect(TransactionUtils.getAttendeesListDisplayString(attendees, localeCompare)).toBe('apple, banana'); }); it('uses numeric-aware sort so "User 9" comes before "User 10"', () => { const attendees: Attendee[] = [ - {email: '10@x.com', displayName: 'User 10', avatarUrl: ''}, - {email: '9@x.com', displayName: 'User 9', avatarUrl: ''}, + {email: '10@x.com', displayName: 'User 10', avatarUrl: '', login: '10@x.com'}, + {email: '9@x.com', displayName: 'User 9', avatarUrl: '', login: '9@x.com'}, ]; expect(TransactionUtils.getAttendeesListDisplayString(attendees, localeCompare)).toBe('User 9, User 10'); }); it('compares case-insensitively so the joined string matches pill order', () => { const attendees: Attendee[] = [ - {email: 'b@x.com', displayName: 'Bob', avatarUrl: ''}, - {email: 'a@x.com', displayName: 'alice', avatarUrl: ''}, + {email: 'b@x.com', displayName: 'Bob', avatarUrl: '', login: 'b@x.com'}, + {email: 'a@x.com', displayName: 'alice', avatarUrl: '', login: 'a@x.com'}, ]; expect(TransactionUtils.getAttendeesListDisplayString(attendees, localeCompare)).toBe('alice, Bob'); }); it('strips the @expensify.sms domain so phone-login attendees render the same as in pills', () => { const attendees: Attendee[] = [ - {displayName: '+15551234567@expensify.sms', avatarUrl: ''}, - {displayName: 'Alice', avatarUrl: ''}, + {displayName: '+15551234567@expensify.sms', avatarUrl: '', login: '+15551234567@expensify.sms'}, + {displayName: 'Alice', avatarUrl: '', login: 'alice@x.com'}, ]; expect(TransactionUtils.getAttendeesListDisplayString(attendees, localeCompare)).toBe('+15551234567, Alice'); }); @@ -1694,8 +1726,8 @@ describe('TransactionUtils', () => { it('does not mutate the input array', () => { const attendees: Attendee[] = [ - {email: 'b@x.com', displayName: 'banana', avatarUrl: ''}, - {email: 'a@x.com', displayName: 'apple', avatarUrl: ''}, + {email: 'b@x.com', displayName: 'banana', avatarUrl: '', login: 'b@x.com'}, + {email: 'a@x.com', displayName: 'apple', avatarUrl: '', login: 'a@x.com'}, ]; const snapshot = [...attendees]; TransactionUtils.getAttendeesListDisplayString(attendees, localeCompare); diff --git a/tests/unit/ViolationUtilsTest.ts b/tests/unit/ViolationUtilsTest.ts index d81f0e286222..8089eaeaf183 100644 --- a/tests/unit/ViolationUtilsTest.ts +++ b/tests/unit/ViolationUtilsTest.ts @@ -1890,6 +1890,7 @@ describe('getViolationsOnyxData', () => { }; const ownerAccountID = 123; + const otherAccountID = 456; let iouReport: Report; @@ -1928,7 +1929,7 @@ describe('getViolationsOnyxData', () => { it('should add missingAttendees violation when only owner is an attendee', () => { transaction.comment = { - attendees: [{email: 'owner@example.com', displayName: 'Owner', avatarUrl: ''}], + attendees: [{email: 'owner@example.com', displayName: 'Owner', avatarUrl: '', accountID: ownerAccountID}], }; const result = ViolationsUtils.getViolationsOnyxData({ updatedTransaction: transaction, @@ -1947,8 +1948,8 @@ describe('getViolationsOnyxData', () => { it('should not add missingAttendees violation when there is at least one non-owner attendee', () => { transaction.comment = { attendees: [ - {email: 'owner@example.com', displayName: 'Owner', avatarUrl: ''}, - {email: 'other@example.com', displayName: 'Other', avatarUrl: ''}, + {email: 'owner@example.com', displayName: 'Owner', avatarUrl: '', accountID: ownerAccountID}, + {email: 'other@example.com', displayName: 'Other', avatarUrl: '', accountID: otherAccountID}, ], }; const result = ViolationsUtils.getViolationsOnyxData({ @@ -1969,8 +1970,8 @@ describe('getViolationsOnyxData', () => { transactionViolations = [missingAttendeesViolation]; transaction.comment = { attendees: [ - {email: 'owner@example.com', displayName: 'Owner', avatarUrl: ''}, - {email: 'other@example.com', displayName: 'Other', avatarUrl: ''}, + {email: 'owner@example.com', displayName: 'Owner', avatarUrl: '', accountID: ownerAccountID}, + {email: 'other@example.com', displayName: 'Other', avatarUrl: '', accountID: otherAccountID}, ], }; const result = ViolationsUtils.getViolationsOnyxData({ @@ -2021,57 +2022,6 @@ describe('getViolationsOnyxData', () => { expect(result.value).not.toEqual(expect.arrayContaining([missingAttendeesViolation])); }); - describe('owner identified via report ownerAccountID', () => { - const ownerLogin = 'owner@example.com'; - - beforeEach(async () => { - // Seed personal details so the report owner's accountID resolves to a login via getLoginByAccountID. - // This populates the allPersonalDetails cache that PersonalDetailsUtils reads from. - await Onyx.merge(ONYXKEYS.PERSONAL_DETAILS_LIST, {[ownerAccountID]: {accountID: ownerAccountID, login: ownerLogin}}); - await waitForBatchedUpdates(); - }); - - it('should not add missingAttendees violation for a single non-owner attendee when the owner is resolved from ownerAccountID', () => { - // The report owner (owner@example.com) is NOT in the attendee list, so the single attendee is a genuine - // non-owner. Without owner-aware identification, the count-based fallback would wrongly flag this as missing. - transactionViolations = []; - transaction.comment = { - attendees: [{email: 'other@example.com', displayName: 'Other', avatarUrl: ''}], - }; - const result = ViolationsUtils.getViolationsOnyxData({ - updatedTransaction: transaction, - transactionViolations, - policy, - policyTagList: policyTags, - policyCategories, - hasDependentTags: false, - isInvoiceTransaction: false, - isSelfDM: false, - iouReport, - }); - expect(result.value).not.toEqual(expect.arrayContaining([missingAttendeesViolation])); - }); - - it('should add missingAttendees violation when the only attendee is the report owner resolved from ownerAccountID', () => { - transactionViolations = []; - transaction.comment = { - attendees: [{email: ownerLogin, displayName: 'Owner', avatarUrl: ''}], - }; - const result = ViolationsUtils.getViolationsOnyxData({ - updatedTransaction: transaction, - transactionViolations, - policy, - policyTagList: policyTags, - policyCategories, - hasDependentTags: false, - isInvoiceTransaction: false, - isSelfDM: false, - iouReport, - }); - expect(result.value).toEqual(expect.arrayContaining([missingAttendeesViolation])); - }); - }); - describe('optimistic / offline scenarios (iouReport is undefined)', () => { // In offline scenarios, iouReport is undefined so we can't get ownerAccountID. // The code falls back to using getCurrentUserEmail() to identify the owner by login/email. diff --git a/tests/unit/hooks/useConfirmationAmount.test.tsx b/tests/unit/hooks/useConfirmationAmount.test.tsx index 2e59ea826319..66757bb65ebe 100644 --- a/tests/unit/hooks/useConfirmationAmount.test.tsx +++ b/tests/unit/hooks/useConfirmationAmount.test.tsx @@ -83,7 +83,7 @@ describe('useConfirmationAmount', () => { }); it('divides amount by attendee count for per-attendee total', () => { - const {result} = renderHook(() => useConfirmationAmount({...baseParams, iouAttendees: [{}, {}, {}, {}] as Params['iouAttendees']}), { + const {result} = renderHook(() => useConfirmationAmount({...baseParams, iouAttendees: [{accountID: 1}, {accountID: 2}, {accountID: 3}, {accountID: 4}] as Params['iouAttendees']}), { wrapper: Wrapper, }); // 100 / 4 = 25 diff --git a/tests/unit/useAttendeesTest.tsx b/tests/unit/useAttendeesTest.tsx index f8519193b8d6..725b7b95c3bc 100644 --- a/tests/unit/useAttendeesTest.tsx +++ b/tests/unit/useAttendeesTest.tsx @@ -59,8 +59,13 @@ describe('useAttendees', () => { expect(result.current).toEqual([ { + accountID: ownerDetails.accountID, + login: ownerDetails.login, displayName: ownerDetails.displayName, + selected: true, email: ownerDetails.login, + text: ownerDetails.displayName, + searchText: ownerDetails.displayName, avatarUrl: ownerDetails.avatar, }, ]); @@ -68,9 +73,11 @@ describe('useAttendees', () => { it('should return existing attendees if transaction already has attendees', async () => { const attendee: Attendee = { - email: 'attendee@test.com', + accountID: 1, + login: 'attendee@test.com', displayName: 'Attendee', avatarUrl: '', + selected: true, }; const mockTransaction: Transaction = {