Skip to content

Commit b3d80d1

Browse files
committed
refactor: extract enrichAndSortAttendees into AttendeeUtils to remove duplication
1 parent da3a27b commit b3d80d1

4 files changed

Lines changed: 93 additions & 42 deletions

File tree

src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx

Lines changed: 2 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import UserPills from '@components/UserPills';
66
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
77
import useLocalize from '@hooks/useLocalize';
88
import useThemeStyles from '@hooks/useThemeStyles';
9+
import {enrichAndSortAttendees} from '@libs/AttendeeUtils';
910
import Navigation from '@libs/Navigation/Navigation';
10-
import {sortAlphabetically} from '@libs/OptionsListUtils';
1111
import {getAttendees} from '@libs/TransactionUtils';
1212
import CONST from '@src/CONST';
1313
import type {IOUAction, IOUType} from '@src/CONST';
@@ -34,24 +34,7 @@ function AttendeeField({formattedAmountPerAttendee, isReadOnly, transactionID, a
3434
const shouldDisplayAttendeesError = formError === 'violations.missingAttendees';
3535

3636
const rawIouAttendees = getAttendees(transaction, currentUserPersonalDetails);
37-
// Enrich + sort once so pills and accessibility label share one canonical order.
38-
const iouAttendees = Array.isArray(rawIouAttendees)
39-
? sortAlphabetically(
40-
rawIouAttendees.map((a) => {
41-
const pd = a?.accountID ? personalDetailsList?.[a.accountID] : undefined;
42-
const freshAvatar = typeof pd?.avatar === 'string' ? pd.avatar : undefined;
43-
return {
44-
...a,
45-
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional || to fall back when personalDetails has an empty string
46-
displayName: pd?.displayName || a?.displayName,
47-
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional || to fall back when personalDetails has an empty string
48-
avatarUrl: freshAvatar || a?.avatarUrl,
49-
};
50-
}),
51-
'displayName',
52-
localeCompare,
53-
)
54-
: rawIouAttendees;
37+
const iouAttendees = enrichAndSortAttendees(rawIouAttendees, personalDetailsList, localeCompare);
5538

5639
return (
5740
<MenuItemWithTopDescription

src/components/ReportActionItem/MoneyRequestView.tsx

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -40,13 +40,13 @@ import type {ViolationField} from '@hooks/useViolations';
4040
import useViolations from '@hooks/useViolations';
4141
import {updateMoneyRequestBillable, updateMoneyRequestReimbursable, updateMoneyRequestTaxRate} from '@libs/actions/IOU/UpdateMoneyRequest';
4242
import initSplitExpense from '@libs/actions/SplitExpenses';
43-
import {getIsMissingAttendeesViolation} from '@libs/AttendeeUtils';
43+
import {enrichAndSortAttendees, getIsMissingAttendeesViolation} from '@libs/AttendeeUtils';
4444
import {getBrokenConnectionUrlToFixPersonalCard, getCompanyCardDescription} from '@libs/CardUtils';
4545
import {getDecodedCategoryName, isCategoryMissing} from '@libs/CategoryUtils';
4646
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
4747
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
4848
import {getRateFromMerchant} from '@libs/MergeTransactionUtils';
49-
import {hasEnabledOptions, sortAlphabetically} from '@libs/OptionsListUtils';
49+
import {hasEnabledOptions} from '@libs/OptionsListUtils';
5050
import Parser from '@libs/Parser';
5151
import {
5252
canSubmitPerDiemExpenseFromWorkspace,
@@ -285,24 +285,7 @@ function MoneyRequestView({
285285
const hasRoute = hasRouteTransactionUtils(transactionBackup ?? transaction, isDistanceRequest);
286286

287287
const rawActualAttendees = isFromMergeTransaction && updatedTransaction ? updatedTransaction.comment?.attendees : transactionAttendees;
288-
// Enrich + sort once so pills, hover-Copy value, accessibility label, and violation check share one canonical order.
289-
const actualAttendees = Array.isArray(rawActualAttendees)
290-
? sortAlphabetically(
291-
rawActualAttendees.map((a) => {
292-
const pd = a?.accountID ? personalDetailsList?.[a.accountID] : undefined;
293-
const freshAvatar = typeof pd?.avatar === 'string' ? pd.avatar : undefined;
294-
return {
295-
...a,
296-
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional || to fall back when personalDetails has an empty string
297-
displayName: pd?.displayName || a?.displayName,
298-
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional || to fall back when personalDetails has an empty string
299-
avatarUrl: freshAvatar || a?.avatarUrl,
300-
};
301-
}),
302-
'displayName',
303-
localeCompare,
304-
)
305-
: rawActualAttendees;
288+
const actualAttendees = enrichAndSortAttendees(rawActualAttendees, personalDetailsList, localeCompare);
306289

307290
// Use the updated transaction amount in merge flow to have correct positive/negative sign
308291
const actualAmount = isFromMergeTransaction && updatedTransaction ? updatedTransaction.amount : transactionAmount;

src/libs/AttendeeUtils.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import type {OnyxEntry} from 'react-native-onyx';
12
import type {LocaleContextProps} from '@components/LocaleContextProvider';
23
import CONST from '@src/CONST';
3-
import type {PolicyCategories, PolicyCategory} from '@src/types/onyx';
4+
import type {PersonalDetailsList, PolicyCategories, PolicyCategory} from '@src/types/onyx';
45
import type {Attendee} from '@src/types/onyx/IOU';
56
import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails';
7+
import {sortAlphabetically} from './OptionsListUtils';
68

79
function getNormalizedString(value?: string): string | undefined {
810
const normalizedValue = value?.trim();
@@ -133,4 +135,33 @@ function syncMissingAttendeesViolation<T extends {name: string}>(
133135
return violations;
134136
}
135137

136-
export {formatRequiredFieldsTitle, getIsMissingAttendeesViolation, normalizeAttendee, normalizeAttendees, syncMissingAttendeesViolation};
138+
/**
139+
* Enrich each attendee with the live displayName/avatar from `personalDetails` and return them sorted alphabetically.
140+
* Centralised so every attendee renderer (pills, copy value, accessibility label, violation check) shares one canonical order.
141+
*/
142+
function enrichAndSortAttendees(
143+
attendees: Attendee[] | undefined,
144+
personalDetailsList: OnyxEntry<PersonalDetailsList>,
145+
localeCompare: LocaleContextProps['localeCompare'],
146+
): Attendee[] | undefined {
147+
if (!Array.isArray(attendees)) {
148+
return attendees;
149+
}
150+
return sortAlphabetically(
151+
attendees.map((a) => {
152+
const pd = a?.accountID ? personalDetailsList?.[a.accountID] : undefined;
153+
const freshAvatar = typeof pd?.avatar === 'string' ? pd.avatar : undefined;
154+
return {
155+
...a,
156+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional || to fall back when personalDetails has an empty string
157+
displayName: pd?.displayName || a?.displayName,
158+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing -- intentional || to fall back when personalDetails has an empty string
159+
avatarUrl: freshAvatar || a?.avatarUrl,
160+
};
161+
}),
162+
'displayName',
163+
localeCompare,
164+
);
165+
}
166+
167+
export {enrichAndSortAttendees, formatRequiredFieldsTitle, getIsMissingAttendeesViolation, normalizeAttendee, normalizeAttendees, syncMissingAttendeesViolation};

tests/unit/AttendeeUtilsTest.ts

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import {normalizeAttendee, normalizeAttendees} from '@libs/AttendeeUtils';
1+
import {enrichAndSortAttendees, normalizeAttendee, normalizeAttendees} from '@libs/AttendeeUtils';
2+
import type {PersonalDetailsList} from '@src/types/onyx';
23
import type {Attendee} from '@src/types/onyx/IOU';
34

45
describe('AttendeeUtils', () => {
@@ -71,4 +72,57 @@ describe('AttendeeUtils', () => {
7172
]);
7273
});
7374
});
75+
76+
describe('enrichAndSortAttendees', () => {
77+
const localeCompare = (a: string, b: string) => a.localeCompare(b);
78+
79+
it('returns input as-is when it is not an array', () => {
80+
expect(enrichAndSortAttendees(undefined, undefined, localeCompare)).toBeUndefined();
81+
});
82+
83+
it('sorts alphabetically by stored displayName when no personalDetails are available', () => {
84+
const attendees: Attendee[] = [
85+
{email: 'b@x.com', displayName: 'banana', avatarUrl: '', login: 'b@x.com'},
86+
{email: 'a@x.com', displayName: 'apple', avatarUrl: '', login: 'a@x.com'},
87+
];
88+
89+
expect(enrichAndSortAttendees(attendees, undefined, localeCompare)?.map((a) => a.displayName)).toEqual(['apple', 'banana']);
90+
});
91+
92+
it('enriches displayName and avatar from personalDetails when accountID matches', () => {
93+
const attendees: Attendee[] = [{accountID: 1, displayName: 'Old', avatarUrl: 'old.png'} as Attendee];
94+
const personalDetailsList = {1: {accountID: 1, displayName: 'New', avatar: 'new.png'}} as unknown as PersonalDetailsList;
95+
96+
const result = enrichAndSortAttendees(attendees, personalDetailsList, localeCompare);
97+
98+
expect(result?.[0].displayName).toBe('New');
99+
expect(result?.[0].avatarUrl).toBe('new.png');
100+
});
101+
102+
it('falls back to stored value when personalDetails has empty strings', () => {
103+
const attendees: Attendee[] = [{accountID: 1, displayName: 'Stored', avatarUrl: 'stored.png'} as Attendee];
104+
const personalDetailsList = {1: {accountID: 1, displayName: '', avatar: ''}} as unknown as PersonalDetailsList;
105+
106+
const result = enrichAndSortAttendees(attendees, personalDetailsList, localeCompare);
107+
108+
expect(result?.[0].displayName).toBe('Stored');
109+
expect(result?.[0].avatarUrl).toBe('stored.png');
110+
});
111+
112+
it('sorts using enriched displayName so a profile rename moves the pill', () => {
113+
const attendees: Attendee[] = [{accountID: 1, displayName: 'alice', avatarUrl: ''} as Attendee, {accountID: 2, displayName: 'bob', avatarUrl: ''} as Attendee];
114+
const personalDetailsList = {1: {accountID: 1, displayName: 'zoe'}} as unknown as PersonalDetailsList;
115+
116+
expect(enrichAndSortAttendees(attendees, personalDetailsList, localeCompare)?.map((a) => a.displayName)).toEqual(['bob', 'zoe']);
117+
});
118+
119+
it('does not mutate the input array', () => {
120+
const attendees: Attendee[] = [{displayName: 'banana', avatarUrl: ''} as Attendee, {displayName: 'apple', avatarUrl: ''} as Attendee];
121+
const snapshot = [...attendees];
122+
123+
enrichAndSortAttendees(attendees, undefined, localeCompare);
124+
125+
expect(attendees).toEqual(snapshot);
126+
});
127+
});
74128
});

0 commit comments

Comments
 (0)