diff --git a/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx b/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx index f05c57dd78f0..56c8fe82f667 100644 --- a/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx +++ b/src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx @@ -6,9 +6,9 @@ import UserPills from '@components/UserPills'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useThemeStyles from '@hooks/useThemeStyles'; +import {enrichAndSortAttendees} from '@libs/AttendeeUtils'; import Navigation from '@libs/Navigation/Navigation'; -import {sortAlphabetically} from '@libs/OptionsListUtils'; -import {getAttendees} from '@libs/TransactionUtils'; +import {getAttendees, getAttendeesListDisplayString} from '@libs/TransactionUtils'; import CONST from '@src/CONST'; import type {IOUAction, IOUType} from '@src/CONST'; import type {TranslationPaths} from '@src/languages/types'; @@ -33,13 +33,14 @@ function AttendeeField({formattedAmountPerAttendee, isReadOnly, transactionID, a const personalDetailsList = usePersonalDetails(); const shouldDisplayAttendeesError = formError === 'violations.missingAttendees'; - const iouAttendees = getAttendees(transaction, currentUserPersonalDetails); + const rawIouAttendees = getAttendees(transaction, currentUserPersonalDetails); + const iouAttendees = enrichAndSortAttendees(rawIouAttendees, personalDetailsList, localeCompare); return ( a?.displayName ?? a?.login).join(', ')}`} + accessibilityLabel={`${translate('iou.attendees')}, ${Array.isArray(iouAttendees) ? getAttendeesListDisplayString(iouAttendees) : ''}`} description={`${translate('iou.attendees')} ${ iouAttendees?.length && iouAttendees.length > 1 && formattedAmountPerAttendee ? `\u00B7 ${formattedAmountPerAttendee} ${translate('common.perPerson')}` : '' }`} @@ -47,21 +48,7 @@ function AttendeeField({formattedAmountPerAttendee, isReadOnly, transactionID, a titleComponent={ Array.isArray(iouAttendees) ? ( { - const pd = a?.accountID ? personalDetailsList?.[a.accountID] : undefined; - const freshAvatar = typeof pd?.avatar === 'string' ? pd.avatar : undefined; - return { - ...a, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - displayName: pd?.displayName || a?.displayName, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - avatarUrl: freshAvatar || a?.avatarUrl, - }; - }), - 'displayName', - localeCompare, - ).map((a) => ({ + users={iouAttendees.map((a) => ({ avatar: a?.avatarUrl, displayName: a?.displayName ?? a?.login ?? a?.email ?? '', accountID: a?.accountID, diff --git a/src/components/ReportActionItem/MoneyRequestView.tsx b/src/components/ReportActionItem/MoneyRequestView.tsx index 3df940219f55..2038610f5195 100644 --- a/src/components/ReportActionItem/MoneyRequestView.tsx +++ b/src/components/ReportActionItem/MoneyRequestView.tsx @@ -41,13 +41,13 @@ import type {ViolationField} from '@hooks/useViolations'; import useViolations from '@hooks/useViolations'; import {updateMoneyRequestBillable, updateMoneyRequestReimbursable, updateMoneyRequestTaxRate} from '@libs/actions/IOU/UpdateMoneyRequest'; import initSplitExpense from '@libs/actions/SplitExpenses'; -import {getIsMissingAttendeesViolation} from '@libs/AttendeeUtils'; +import {enrichAndSortAttendees, getIsMissingAttendeesViolation} from '@libs/AttendeeUtils'; import {getBrokenConnectionUrlToFixPersonalCard, getCompanyCardDescription} from '@libs/CardUtils'; import {getDecodedCategoryName, isCategoryMissing} from '@libs/CategoryUtils'; import DistanceRequestUtils from '@libs/DistanceRequestUtils'; import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID'; import {getRateFromMerchant} from '@libs/MergeTransactionUtils'; -import {hasEnabledOptions, sortAlphabetically} from '@libs/OptionsListUtils'; +import {hasEnabledOptions} from '@libs/OptionsListUtils'; import Parser from '@libs/Parser'; import { canSubmitPerDiemExpenseFromWorkspace, @@ -82,6 +82,7 @@ import { } from '@libs/ReportUtils'; import {hasEnabledTags, shouldShowDependentTagList} from '@libs/TagsOptionsListUtils'; import { + getAttendeesListDisplayString, getBillable, getCurrency, getDescription, @@ -283,7 +284,8 @@ function MoneyRequestView({ const isTransactionScanning = isScanning(updatedTransaction ?? transaction); const hasRoute = hasRouteTransactionUtils(transactionBackup ?? transaction, isDistanceRequest); - const actualAttendees = isFromMergeTransaction && updatedTransaction ? updatedTransaction.comment?.attendees : transactionAttendees; + const rawActualAttendees = isFromMergeTransaction && updatedTransaction ? updatedTransaction.comment?.attendees : transactionAttendees; + 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; @@ -802,7 +804,8 @@ function MoneyRequestView({ const previousTagLength = getLengthOfTag(previousTag ?? ''); const currentTagLength = getLengthOfTag(currentTransactionTag ?? ''); - const getAttendeesTitle = Array.isArray(actualAttendees) ? actualAttendees.map((item) => item?.displayName ?? item?.login).join(', ') : ''; + // actualAttendees is already sorted by enrichAndSortAttendees above; pass without localeCompare to preserve that order while stripping the SMS domain. + const getAttendeesTitle = Array.isArray(actualAttendees) ? getAttendeesListDisplayString(actualAttendees) : ''; const attendeesCopyValue = !canEdit ? getAttendeesTitle : undefined; const tagList = policyTagLists.map(({name, orderWeight, tags}, index) => { @@ -1171,21 +1174,7 @@ function MoneyRequestView({ titleComponent={ Array.isArray(actualAttendees) ? ( { - const pd = a?.accountID ? personalDetailsList?.[a.accountID] : undefined; - const freshAvatar = typeof pd?.avatar === 'string' ? pd.avatar : undefined; - return { - ...a, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - displayName: pd?.displayName || a?.displayName, - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - avatarUrl: freshAvatar || a?.avatarUrl, - }; - }), - 'displayName', - localeCompare, - ).map((a) => ({ + users={actualAttendees.map((a) => ({ avatar: a?.avatarUrl, displayName: a?.displayName ?? a?.login ?? a?.email ?? '', accountID: a?.accountID, diff --git a/src/libs/AttendeeUtils.ts b/src/libs/AttendeeUtils.ts index 63b85b991076..f92c621d5a57 100644 --- a/src/libs/AttendeeUtils.ts +++ b/src/libs/AttendeeUtils.ts @@ -1,8 +1,10 @@ +import type {OnyxEntry} from 'react-native-onyx'; import type {LocaleContextProps} from '@components/LocaleContextProvider'; import CONST from '@src/CONST'; -import type {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'; function getNormalizedString(value?: string): string | undefined { const normalizedValue = value?.trim(); @@ -133,4 +135,38 @@ function syncMissingAttendeesViolation( return violations; } -export {formatRequiredFieldsTitle, getIsMissingAttendeesViolation, normalizeAttendee, normalizeAttendees, syncMissingAttendeesViolation}; +/** + * 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[] | string | undefined, + personalDetailsList: OnyxEntry, + localeCompare: LocaleContextProps['localeCompare'], +): Attendee[] | string | undefined; +function enrichAndSortAttendees( + attendees: Attendee[] | string | undefined, + personalDetailsList: OnyxEntry, + localeCompare: LocaleContextProps['localeCompare'], +): Attendee[] | string | undefined { + if (!Array.isArray(attendees)) { + return attendees; + } + return sortAlphabetically( + attendees.map((a) => { + const pd = a?.accountID ? personalDetailsList?.[a.accountID] : undefined; + const freshAvatar = typeof pd?.avatar === 'string' ? pd.avatar : undefined; + return { + ...a, + // 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 + avatarUrl: freshAvatar || a?.avatarUrl, + }; + }), + 'displayName', + localeCompare, + ); +} + +export {enrichAndSortAttendees, formatRequiredFieldsTitle, getIsMissingAttendeesViolation, normalizeAttendee, normalizeAttendees, syncMissingAttendeesViolation}; diff --git a/src/libs/MergeTransactionUtils.ts b/src/libs/MergeTransactionUtils.ts index 5b376d909029..23f2d9a79ef6 100644 --- a/src/libs/MergeTransactionUtils.ts +++ b/src/libs/MergeTransactionUtils.ts @@ -498,7 +498,14 @@ function selectTargetAndSourceTransactionsForMerge( * @param translate - The translation function * @returns The formatted display string for the field value */ -function getDisplayValue(field: MergeFieldKey, transaction: Transaction, policy: Policy | undefined, translate: LocaleContextProps['translate'], reports?: Array>): string { +function getDisplayValue( + field: MergeFieldKey, + transaction: Transaction, + policy: Policy | undefined, + translate: LocaleContextProps['translate'], + localeCompare: LocaleContextProps['localeCompare'], + reports?: Array>, +): string { const fieldValue = getMergeFieldValue(getTransactionDetails(transaction), transaction, field); if (isEmptyMergeValue(fieldValue) || fieldValue === undefined) { @@ -528,7 +535,7 @@ function getDisplayValue(field: MergeFieldKey, transaction: Transaction, policy: return transaction?.reportName ?? getReportName(getReportOrDraftReport(SafeString(fieldValue), reports)); } if (field === 'attendees') { - return Array.isArray(fieldValue) ? getAttendeesListDisplayString(fieldValue) : ''; + return Array.isArray(fieldValue) ? getAttendeesListDisplayString(fieldValue, localeCompare) : ''; } if (field === 'taxValue') { @@ -554,6 +561,7 @@ function buildMergeFieldsData( targetTransactionPolicy: Policy | undefined, sourceTransactionPolicy: Policy | undefined, translate: LocaleContextProps['translate'], + localeCompare: LocaleContextProps['localeCompare'], reports: Array> = [], ): MergeFieldData[] { if (!targetTransaction || !sourceTransaction) { @@ -568,12 +576,12 @@ function buildMergeFieldsData( const options: MergeFieldOption[] = [ { transaction: targetTransaction, - displayValue: getDisplayValue(field, targetTransaction, targetTransactionPolicy, translate, reports), + displayValue: getDisplayValue(field, targetTransaction, targetTransactionPolicy, translate, localeCompare, reports), isSelected: selectedTransactionId === targetTransaction.transactionID, }, { transaction: sourceTransaction, - displayValue: getDisplayValue(field, sourceTransaction, sourceTransactionPolicy, translate, reports), + displayValue: getDisplayValue(field, sourceTransaction, sourceTransactionPolicy, translate, localeCompare, reports), isSelected: selectedTransactionId === sourceTransaction.transactionID, }, ]; diff --git a/src/libs/TransactionUtils/index.ts b/src/libs/TransactionUtils/index.ts index cf2e6791e95b..730cfd74e2a2 100644 --- a/src/libs/TransactionUtils/index.ts +++ b/src/libs/TransactionUtils/index.ts @@ -1,10 +1,12 @@ import {format, isValid, parse} from 'date-fns'; +import {Str} from 'expensify-common'; import {deepEqual} from 'fast-equals'; import lodashDeepClone from 'lodash/cloneDeep'; import lodashSet from 'lodash/set'; import type {NullishDeep, OnyxCollection, OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; +import type {LocaleContextProps} from '@components/LocaleContextProvider'; import type {Coordinate} from '@components/MapView/MapViewTypes'; import utils from '@components/MapView/utils'; import type {UnreportedExpenseListItemType} from '@components/Search/SearchList/ListItem/types'; @@ -1204,19 +1206,26 @@ function getAttendees(transaction: OnyxInputOrEntry, currentUserPer } /** - * Return the list of attendees as a string of display names/logins. + * Returns attendees joined as a display string. Pass `localeCompare` to sort alphabetically (matches the pill sort); + * omit it to keep insertion order — used by non-React callers that don't want to thread the comparator. + * Strips the SMS domain so phone-login attendees render the same as in the rendered pills. */ -function getAttendeesListDisplayString(attendees: Attendee[]): string { - return attendees.map((item) => item.displayName ?? item.login).join(', '); +function getAttendeesListDisplayString(attendees: Attendee[], localeCompare?: LocaleContextProps['localeCompare']): string { + 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())) + : attendees; + return ordered.map(getName).join(', '); } /** * Return the list of attendees as a string and modified list of attendees as a string if present. */ -function getFormattedAttendees(modifiedAttendees?: Attendee[], attendees?: Attendee[]): [string, string] { +function getFormattedAttendees(modifiedAttendees?: Attendee[], attendees?: Attendee[], localeCompare?: LocaleContextProps['localeCompare']): [string, string] { const oldAttendees = modifiedAttendees ?? []; const newAttendees = attendees ?? []; - return [getAttendeesListDisplayString(oldAttendees), getAttendeesListDisplayString(newAttendees)]; + return [getAttendeesListDisplayString(oldAttendees, localeCompare), getAttendeesListDisplayString(newAttendees, localeCompare)]; } /** diff --git a/src/pages/TransactionMerge/DetailsReviewPage.tsx b/src/pages/TransactionMerge/DetailsReviewPage.tsx index a7340f0afc30..dc66ad0713be 100644 --- a/src/pages/TransactionMerge/DetailsReviewPage.tsx +++ b/src/pages/TransactionMerge/DetailsReviewPage.tsx @@ -150,7 +150,7 @@ function DetailsReviewPage({route}: DetailsReviewPageProps) { // Build merge fields array with all necessary information const mergeFields = useMemo( () => - buildMergeFieldsData(conflictFields, targetTransaction, sourceTransaction, mergeTransaction, targetTransactionPolicy, sourceTransactionPolicy, translate, [ + buildMergeFieldsData(conflictFields, targetTransaction, sourceTransaction, mergeTransaction, targetTransactionPolicy, sourceTransactionPolicy, translate, localeCompare, [ targetTransactionReport, sourceTransactionReport, ]), @@ -164,6 +164,7 @@ function DetailsReviewPage({route}: DetailsReviewPageProps) { targetTransactionPolicy, sourceTransactionPolicy, translate, + localeCompare, ], ); diff --git a/tests/perf-test/ModifiedExpenseMessage.perf-test.ts b/tests/perf-test/ModifiedExpenseMessage.perf-test.ts index 738adf26c919..7728fe91187c 100644 --- a/tests/perf-test/ModifiedExpenseMessage.perf-test.ts +++ b/tests/perf-test/ModifiedExpenseMessage.perf-test.ts @@ -65,5 +65,13 @@ test('[ModifiedExpenseMessage] getForReportAction on 1k reports and policies', a }); await waitForBatchedUpdates(); - await measureFunction(() => getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: mockedPolicyTags, currentUserLogin: CURRENT_USER_LOGIN})); + await measureFunction(() => + getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: mockedPolicyTags, + currentUserLogin: CURRENT_USER_LOGIN, + }), + ); }); diff --git a/tests/unit/AttendeeUtilsTest.ts b/tests/unit/AttendeeUtilsTest.ts index 1120a3252299..f234a39e222b 100644 --- a/tests/unit/AttendeeUtilsTest.ts +++ b/tests/unit/AttendeeUtilsTest.ts @@ -1,4 +1,5 @@ -import {normalizeAttendee, normalizeAttendees} from '@libs/AttendeeUtils'; +import {enrichAndSortAttendees, normalizeAttendee, normalizeAttendees} from '@libs/AttendeeUtils'; +import type {PersonalDetailsList} from '@src/types/onyx'; import type {Attendee} from '@src/types/onyx/IOU'; describe('AttendeeUtils', () => { @@ -71,4 +72,66 @@ describe('AttendeeUtils', () => { ]); }); }); + + describe('enrichAndSortAttendees', () => { + const localeCompare = (a: string, b: string) => a.localeCompare(b); + + it('returns input as-is when it is not an array', () => { + 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: '', login: 'b@x.com'}, + {email: 'a@x.com', displayName: 'apple', avatarUrl: '', login: 'a@x.com'}, + ]; + + expect(enrichAndSortAttendees(attendees, undefined, localeCompare).map((a) => a.displayName)).toEqual(['apple', 'banana']); + }); + + it('enriches displayName and avatar from personalDetails when accountID matches', () => { + const accountID = 1; + const attendees: Attendee[] = [{accountID, displayName: 'Old', avatarUrl: 'old.png'}]; + const personalDetailsList: PersonalDetailsList = {[accountID]: {accountID, displayName: 'New', avatar: 'new.png'}}; + + const result = enrichAndSortAttendees(attendees, personalDetailsList, localeCompare); + + 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 attendees: Attendee[] = [{accountID, displayName: 'Stored', avatarUrl: 'stored.png'}]; + const personalDetailsList: PersonalDetailsList = {[accountID]: {accountID, displayName: '', avatar: ''}}; + + const result = enrichAndSortAttendees(attendees, personalDetailsList, localeCompare); + + 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 attendees: Attendee[] = [ + {accountID: renamedAccountID, displayName: 'alice', avatarUrl: ''}, + {accountID: 2, displayName: 'bob', avatarUrl: ''}, + ]; + const personalDetailsList: PersonalDetailsList = {[renamedAccountID]: {accountID: renamedAccountID, displayName: 'zoe'}}; + + expect(enrichAndSortAttendees(attendees, personalDetailsList, localeCompare).map((a) => a.displayName)).toEqual(['bob', 'zoe']); + }); + + it('does not mutate the input array', () => { + const attendees: Attendee[] = [ + {displayName: 'banana', avatarUrl: ''}, + {displayName: 'apple', avatarUrl: ''}, + ]; + const snapshot = [...attendees]; + + enrichAndSortAttendees(attendees, undefined, localeCompare); + + expect(attendees).toEqual(snapshot); + }); + }); }); diff --git a/tests/unit/MergeTransactionUtilsTest.ts b/tests/unit/MergeTransactionUtilsTest.ts index 8d4f4efd977f..d581dcd49ce1 100644 --- a/tests/unit/MergeTransactionUtilsTest.ts +++ b/tests/unit/MergeTransactionUtilsTest.ts @@ -825,7 +825,7 @@ describe('MergeTransactionUtils', () => { }; // When we get display value for merchant - const result = getDisplayValue('merchant', transaction, undefined, translateLocal); + const result = getDisplayValue('merchant', transaction, undefined, translateLocal, mockLocaleCompare); // Then it should return empty string expect(result).toBe(''); @@ -840,8 +840,8 @@ describe('MergeTransactionUtils', () => { }; // When we get display values for boolean fields - const reimbursableResult = getDisplayValue('reimbursable', transaction, undefined, translateLocal); - const billableResult = getDisplayValue('billable', transaction, undefined, translateLocal); + const reimbursableResult = getDisplayValue('reimbursable', transaction, undefined, translateLocal, mockLocaleCompare); + const billableResult = getDisplayValue('billable', transaction, undefined, translateLocal, mockLocaleCompare); // Then it should return translated Yes/No values expect(reimbursableResult).toBe('common.yes'); @@ -857,7 +857,7 @@ describe('MergeTransactionUtils', () => { }; // When we get display value for amount - const result = getDisplayValue('amount', transaction, undefined, translateLocal); + const result = getDisplayValue('amount', transaction, undefined, translateLocal, mockLocaleCompare); // Then it should return formatted currency string expect(result).toBe('$10.00'); @@ -873,7 +873,7 @@ describe('MergeTransactionUtils', () => { }; // When we get display value for description - const result = getDisplayValue('description', transaction, undefined, translateLocal); + const result = getDisplayValue('description', transaction, undefined, translateLocal, mockLocaleCompare); // Then it should return cleaned text without HTML and with spaces instead of line breaks expect(result).toBe('This is a test description with line breaks and more text'); @@ -887,13 +887,13 @@ describe('MergeTransactionUtils', () => { }; // When we get display value for tag - const result = getDisplayValue('tag', transaction, undefined, translateLocal); + const result = getDisplayValue('tag', transaction, undefined, translateLocal, mockLocaleCompare); // Then it should return sanitized tag names separated by commas expect(result).toBe('Department, Engineering, Frontend'); }); - it('should return correct value for attendees field', () => { + it('should return attendees in alphabetical order regardless of insertion order', () => { const transaction = { ...createRandomTransaction(0), comment: { @@ -904,9 +904,9 @@ describe('MergeTransactionUtils', () => { ], }, }; - const result = getDisplayValue('attendees', transaction, undefined, translateLocal); + const result = getDisplayValue('attendees', transaction, undefined, translateLocal, mockLocaleCompare); - expect(result).toBe('Test User 2, Test User 1'); + expect(result).toBe('Test User 1, Test User 2'); }); it('should return string values directly', () => { @@ -919,8 +919,8 @@ describe('MergeTransactionUtils', () => { }; // When we get display values for string fields - const merchantResult = getDisplayValue('merchant', transaction, undefined, translateLocal); - const categoryResult = getDisplayValue('category', transaction, undefined, translateLocal); + const merchantResult = getDisplayValue('merchant', transaction, undefined, translateLocal, mockLocaleCompare); + const categoryResult = getDisplayValue('category', transaction, undefined, translateLocal, mockLocaleCompare); // Then it should return the string values expect(merchantResult).toBe('Starbucks Coffee'); @@ -935,7 +935,7 @@ describe('MergeTransactionUtils', () => { }; // When we get display value for reportID - const result = getDisplayValue('reportID', transaction, undefined, translateLocal); + const result = getDisplayValue('reportID', transaction, undefined, translateLocal, mockLocaleCompare); // Then it should return translated "None" expect(result).toBe('common.none'); @@ -950,7 +950,7 @@ describe('MergeTransactionUtils', () => { }; // When we get display value for reportID - const result = getDisplayValue('reportID', transaction, undefined, translateLocal); + const result = getDisplayValue('reportID', transaction, undefined, translateLocal, mockLocaleCompare); // Then it should return the reportName expect(result).toBe('Test Report Name'); @@ -976,7 +976,7 @@ describe('MergeTransactionUtils', () => { }; // When we get display value for reportID - const result = getDisplayValue('reportID', transaction, undefined, translateLocal); + const result = getDisplayValue('reportID', transaction, undefined, translateLocal, mockLocaleCompare); // Then it should return the report's name from Onyx expect(result).toBe(report.reportName); diff --git a/tests/unit/ModifiedExpenseMessageTest.ts b/tests/unit/ModifiedExpenseMessageTest.ts index b2f2895b0ce5..43e62efb0954 100644 --- a/tests/unit/ModifiedExpenseMessageTest.ts +++ b/tests/unit/ModifiedExpenseMessageTest.ts @@ -281,7 +281,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct text message', () => { const expectedResult = `changed the amount to $18.00 (previously $12.55)`; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -302,7 +308,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct text message', () => { const expectedResult = `changed the amount to $18.00 (previously $0.00)`; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -325,7 +337,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct text message', () => { const expectedResult = 'changed the amount to $18.00 (previously $12.55)\nremoved the description (previously "this is for the shuttle")'; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -350,7 +368,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct text message', () => { const expectedResult = 'changed the amount to $18.00 (previously $12.55)\nset the category to "Benefits"\nremoved the description (previously "this is for the shuttle")'; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -373,7 +397,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct text message', () => { const expectedResult = 'changed the amount to $18.00 (previously $12.55) and the merchant to "Taco Bell" (previously "Big Belly")'; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -401,7 +431,13 @@ describe('ModifiedExpenseMessage', () => { const expectedResult = 'changed the amount to $18.00 (previously $12.55) and the merchant to "Taco Bell" (previously "Big Belly")\nset the category to "Benefits"\nremoved the description (previously "this is for the shuttle")'; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -427,7 +463,13 @@ describe('ModifiedExpenseMessage', () => { const expectedResult = 'changed the amount to $18.00 (previously $12.55), the description to "I bought it on the way" (previously "from the business trip"), and the merchant to "Taco Bell" (previously "Big Belly")'; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -446,7 +488,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct text message', () => { const expectedResult = `removed the merchant (previously "Big Belly")`; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -465,7 +513,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct text message', () => { const expectedResult = `set the merchant to "KFC"`; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -484,7 +538,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct text message', () => { const expectedResult = `set the merchant to "KFC"`; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -503,7 +563,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct "removed" text message', () => { const expectedResult = `removed the merchant (previously "Old Merchant")`; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -522,7 +588,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct "changed" text message', () => { const expectedResult = `changed the merchant to "New Merchant" (previously "Old Merchant")`; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -543,7 +615,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct text message', () => { const expectedResult = `removed the description (previously "mini shore") and the merchant (previously "Big Belly")`; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -566,7 +644,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct text message', () => { const expectedResult = `removed the description (previously "mini shore"), the merchant (previously "Big Belly"), and the category (previously "Benefits")`; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -585,7 +669,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct text message', () => { const expectedResult = `set the merchant to "Big Belly"`; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -606,7 +696,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct text message', () => { const expectedResult = `set the description to "mini shore" and the merchant to "Big Belly"`; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -629,7 +725,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct text message', () => { const expectedResult = `set the description to "mini shore", the merchant to "Big Belly", and the category to "Benefits"`; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -648,7 +750,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct text message', () => { const expectedResult = 'changed the date to 2023-12-27 (previously 2023-12-26)'; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -666,7 +774,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct text message', () => { const expectedResult = 'changed the expense'; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -688,7 +802,13 @@ describe('ModifiedExpenseMessage', () => { it('then the message says the distance is changed and shows the new and old merchant and amount', () => { const expectedResult = `changed the distance to ${reportAction.originalMessage.merchant} (previously ${reportAction.originalMessage.oldMerchant}), which updated the amount to $7.00 (previously $0.70)`; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); }); @@ -709,7 +829,13 @@ describe('ModifiedExpenseMessage', () => { it('then the message says the rate is changed and shows the new and old merchant and amount', () => { const expectedResult = `changed the rate to ${reportAction.originalMessage.merchant} (previously ${reportAction.originalMessage.oldMerchant}), which updated the amount to $55.80 (previously $39.45)`; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); }); @@ -728,7 +854,14 @@ describe('ModifiedExpenseMessage', () => { reportName: '', }; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN, movedFromReport}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + movedFromReport, + }); expect(result).toEqual(expectedResult); }); }); @@ -747,7 +880,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct text message with AI attribution when setting description', () => { const expectedResult = 'set the description based on past activity to "Flight to client meeting"'; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -767,7 +906,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct text message with AI attribution when changing description', () => { const expectedResult = 'changed the description based on past activity to "New description" (previously "Old description")'; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -787,7 +932,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct text message with AI attribution', () => { const expectedResult = `changed the category based on past activity to "Travel" (previously "Food")`; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -807,7 +958,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct text message with MCC attribution for non-admin', () => { const expectedResult = `changed the category based on workspace rule to "Travel" (previously "Food")`; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -829,7 +986,13 @@ describe('ModifiedExpenseMessage', () => { jest.spyOn(PolicyUtils, 'isPolicyAdmin').mockReturnValue(true); - const result = getForReportAction({translate: translateLocal, reportAction, policy: mockPolicy, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: mockPolicy, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); // Verify the policyID in the URL exactly matches the policy.id (case-preserved) expect(result).toContain(`workspaces/${mockPolicy.id}/rules`); @@ -852,7 +1015,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct text message with AI attribution', () => { const expectedResult = `set the category based on past activity to "Travel"`; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -872,7 +1041,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct text message without showing previously uncategorized', () => { const expectedResult = `set the category based on past activity to "6403 Travel - Member Services"`; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -891,7 +1066,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the generic changed expense message since no meaningful change occurred', () => { const expectedResult = `changed the expense`; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -911,7 +1092,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct text message with AI attribution', () => { const expectedResult = `removed the category based on past activity (previously "Travel")`; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -947,7 +1134,13 @@ describe('ModifiedExpenseMessage', () => { } as OriginalMessageModifiedExpense, }; - const result = getForReportAction({translate: translateLocal, reportAction, policy: policyRulesPolicy, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: policyRulesPolicy, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); const expectedResult = `marked the expense as "billable", marked the expense as "reimbursable", set the category to "Travel", and merchant to "McDonald's" via workspace rules`; @@ -973,7 +1166,13 @@ describe('ModifiedExpenseMessage', () => { } as OriginalMessageModifiedExpense, }; - const result = getForReportAction({translate: translateLocal, reportAction, policy: policyRulesPolicy, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: policyRulesPolicy, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); const expectedResult = `set the tax rate to "New Tax Rate" via workspace rules`; @@ -993,7 +1192,13 @@ describe('ModifiedExpenseMessage', () => { } as OriginalMessageModifiedExpense, }; - const result = getForReportAction({translate: translateLocal, reportAction, policy: policyRulesPolicy, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: policyRulesPolicy, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); const expectedResult = `set the category to "Travel" and merchant to "McDonald's" via workspace rules`; @@ -1012,7 +1217,13 @@ describe('ModifiedExpenseMessage', () => { } as OriginalMessageModifiedExpense, }; - const result = getForReportAction({translate: translateLocal, reportAction, policy: policyRulesPolicy, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: policyRulesPolicy, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); const expectedResult = `marked the expense as "billable" via workspace rules`; @@ -1032,7 +1243,13 @@ describe('ModifiedExpenseMessage', () => { } as OriginalMessageModifiedExpense, }; - const result = getForReportAction({translate: translateLocal, reportAction, policy: policyRulesPolicy, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: policyRulesPolicy, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); const expectedResult = `marked the expense as "reimbursable" and marked the expense as "billable" via workspace rules`; @@ -1054,7 +1271,13 @@ describe('ModifiedExpenseMessage', () => { } as OriginalMessageModifiedExpense, }; - const result = getForReportAction({translate: translateLocal, reportAction, policy: policyRulesPolicy, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: policyRulesPolicy, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toContain(CONST.CONFIGURE_EXPENSE_REPORT_RULES_HELP_URL); expect(result).toContain('workspace rules'); @@ -1075,7 +1298,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct text message without attribution', () => { const expectedResult = `changed the category to "Travel" (previously "Food")`; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); @@ -1095,7 +1324,13 @@ describe('ModifiedExpenseMessage', () => { it('returns the correct text message without attribution', () => { const expectedResult = `changed the category to "Travel" (previously "Food")`; - const result = getForReportAction({translate: translateLocal, reportAction, policy: undefined, policyTags: undefined, currentUserLogin: CURRENT_USER_LOGIN}); + const result = getForReportAction({ + translate: translateLocal, + reportAction, + policy: undefined, + policyTags: undefined, + currentUserLogin: CURRENT_USER_LOGIN, + }); expect(result).toEqual(expectedResult); }); diff --git a/tests/unit/TransactionUtilsTest.ts b/tests/unit/TransactionUtilsTest.ts index d360ce6523b0..2681919605f2 100644 --- a/tests/unit/TransactionUtilsTest.ts +++ b/tests/unit/TransactionUtilsTest.ts @@ -1397,6 +1397,64 @@ describe('TransactionUtils', () => { }); }); + describe('getAttendeesListDisplayString', () => { + const localeCompare = (a: string, b: string) => a.localeCompare(b, undefined, {numeric: true, sensitivity: 'variant', caseFirst: 'upper'}); + + it('preserves insertion order when no localeCompare is provided', () => { + const attendees: Attendee[] = [ + {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: '', 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: '', 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: '', 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: '', login: '+15551234567@expensify.sms'}, + {displayName: 'Alice', avatarUrl: '', login: 'alice@x.com'}, + ]; + expect(TransactionUtils.getAttendeesListDisplayString(attendees, localeCompare)).toBe('+15551234567, Alice'); + }); + + it('returns empty string for empty array', () => { + expect(TransactionUtils.getAttendeesListDisplayString([], localeCompare)).toBe(''); + }); + + it('does not mutate the input array', () => { + const attendees: Attendee[] = [ + {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); + expect(attendees).toEqual(snapshot); + }); + }); + describe('isCategoryBeingAnalyzed', () => { it('should return false for undefined transaction', () => { expect(TransactionUtils.isCategoryBeingAnalyzed(undefined)).toBe(false);