Skip to content

Commit 3ef40f2

Browse files
authored
Merge pull request #87955 from marufsharifi/fix/handle-undefined-attendee-email
Fix crash when attendee email is undefined in getPersonalDetailByEmail
2 parents 8d953c1 + 5c91b6e commit 3ef40f2

16 files changed

Lines changed: 245 additions & 12 deletions

src/libs/AttendeeUtils.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,33 @@ import type {PolicyCategories, PolicyCategory} from '@src/types/onyx';
44
import type {Attendee} from '@src/types/onyx/IOU';
55
import type {CurrentUserPersonalDetails} from '@src/types/onyx/PersonalDetails';
66

7+
function getNormalizedString(value?: string): string | undefined {
8+
const normalizedValue = value?.trim();
9+
if (normalizedValue) {
10+
return normalizedValue;
11+
}
12+
13+
return undefined;
14+
}
15+
16+
function normalizeAttendee(attendee: Attendee): Attendee {
17+
const {email, displayName: attendeeDisplayName, login: attendeeLogin, ...rest} = attendee;
18+
const normalizedEmail = getNormalizedString(email);
19+
const normalizedLogin = getNormalizedString(attendeeLogin);
20+
const displayName = getNormalizedString(attendeeDisplayName) ?? normalizedEmail ?? normalizedLogin ?? '';
21+
22+
return {
23+
...rest,
24+
displayName,
25+
login: normalizedLogin,
26+
...(normalizedEmail ? {email: normalizedEmail} : {}),
27+
};
28+
}
29+
30+
function normalizeAttendees(attendees: Attendee[] | undefined): Attendee[] {
31+
return (attendees ?? []).map(normalizeAttendee);
32+
}
33+
734
/** Formats the title for requiredFields menu item based on which fields are enabled in the policy category */
835
function formatRequiredFieldsTitle(translate: LocaleContextProps['translate'], policyCategory: PolicyCategory, isAttendeeTrackingEnabled = false): string {
936
const enabledFields: string[] = [];
@@ -106,4 +133,4 @@ function syncMissingAttendeesViolation<T extends {name: string}>(
106133
return violations;
107134
}
108135

109-
export {formatRequiredFieldsTitle, getIsMissingAttendeesViolation, syncMissingAttendeesViolation};
136+
export {formatRequiredFieldsTitle, getIsMissingAttendeesViolation, normalizeAttendee, normalizeAttendees, syncMissingAttendeesViolation};

src/libs/MergeTransactionUtils.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -286,8 +286,12 @@ function getMergeableDataAndConflictFields(
286286
}
287287

288288
if (field === 'attendees') {
289-
const targetAttendeeLogins = ((targetValue as Attendee[] | undefined)?.map((attendee) => attendee.login ?? attendee.email) ?? []).sort(localeCompare);
290-
const sourceAttendeeLogins = ((sourceValue as Attendee[] | undefined)?.map((attendee) => attendee.login ?? attendee.email) ?? []).sort(localeCompare);
289+
const targetAttendeeLogins = ((targetValue as Attendee[] | undefined)?.map((attendee) => attendee.login ?? attendee.email) ?? [])
290+
.filter((login): login is string => !!login)
291+
.sort(localeCompare);
292+
const sourceAttendeeLogins = ((sourceValue as Attendee[] | undefined)?.map((attendee) => attendee.login ?? attendee.email) ?? [])
293+
.filter((login): login is string => !!login)
294+
.sort(localeCompare);
291295

292296
if (isTargetValueEmpty || isSourceValueEmpty || deepEqual(targetAttendeeLogins, sourceAttendeeLogins)) {
293297
mergeableData[field] = isTargetValueEmpty ? sourceValue : targetValue;

src/libs/OptionsListUtils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2865,6 +2865,7 @@ function getFilteredRecentAttendees(
28652865
// Deduplicate recentAttendees: use email for regular users, displayName for name-only attendees
28662866
const seenAttendees = new Set<string>();
28672867
const deduplicatedRecentAttendees = recentAttendees.filter((attendee) => {
2868+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
28682869
const key = attendee.email || attendee.displayName || '';
28692870
if (seenAttendees.has(key)) {
28702871
return false;

src/libs/PersonalDetailsUtils.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,10 @@ function getPersonalDetailsByIDs({
140140
return result;
141141
}
142142

143-
function getPersonalDetailByEmail(email: string): PersonalDetails | undefined {
143+
function getPersonalDetailByEmail(email: string | undefined): PersonalDetails | undefined {
144+
if (!email) {
145+
return undefined;
146+
}
144147
return emailToPersonalDetailsCache[email.toLowerCase()];
145148
}
146149

src/libs/TransactionUtils/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import utils from '@components/MapView/utils';
1010
import type {UnreportedExpenseListItemType} from '@components/Search/SearchList/ListItem/types';
1111
import type {TransactionWithOptionalSearchFields} from '@components/TransactionItemRow';
1212
import type {MergeDuplicatesParams} from '@libs/API/parameters';
13+
import {normalizeAttendees} from '@libs/AttendeeUtils';
1314
import {getCategoryDefaultTaxRate, isCategoryMissing} from '@libs/CategoryUtils';
1415
import {convertToBackendAmount, getCurrencyDecimals, getCurrencySymbol} from '@libs/CurrencyUtils';
1516
import DateUtils from '@libs/DateUtils';
@@ -1187,7 +1188,7 @@ function getReportOwnerAsAttendee(transaction: OnyxInputOrEntry<Transaction>, cu
11871188
*/
11881189
function getOriginalAttendees(transaction: OnyxInputOrEntry<Transaction>, currentUserPersonalDetails: CurrentUserPersonalDetails | undefined): Attendee[] {
11891190
const rawAttendees = transaction?.comment?.attendees;
1190-
const attendees = Array.isArray(rawAttendees) ? rawAttendees : [];
1191+
const attendees = normalizeAttendees(Array.isArray(rawAttendees) ? rawAttendees : []);
11911192
const reportOwnerAsAttendee = getReportOwnerAsAttendee(transaction, currentUserPersonalDetails);
11921193
if (attendees.length === 0 && reportOwnerAsAttendee !== undefined) {
11931194
attendees.push(reportOwnerAsAttendee);
@@ -1202,7 +1203,7 @@ function getOriginalAttendees(transaction: OnyxInputOrEntry<Transaction>, curren
12021203
*/
12031204
function getAttendees(transaction: OnyxInputOrEntry<Transaction>, currentUserPersonalDetails: CurrentUserPersonalDetails | undefined): Attendee[] {
12041205
const rawAttendees = transaction?.modifiedAttendees ?? transaction?.comment?.attendees;
1205-
const attendees = Array.isArray(rawAttendees) ? rawAttendees : [];
1206+
const attendees = normalizeAttendees(Array.isArray(rawAttendees) ? rawAttendees : []);
12061207
const reportOwnerAsAttendee = getReportOwnerAsAttendee(transaction, currentUserPersonalDetails);
12071208

12081209
if (attendees.length === 0 && reportOwnerAsAttendee !== undefined) {

src/libs/actions/IOU/UpdateMoneyRequest.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1223,8 +1223,9 @@ function getUpdateMoneyRequestParams(params: GetUpdateMoneyRequestParamsType): U
12231223
onyxMethod: Onyx.METHOD.MERGE,
12241224
key: ONYXKEYS.NVP_RECENT_ATTENDEES,
12251225
value: lodashUnionBy(
1226-
transactionChanges.attendees?.map(({avatarUrl, displayName, email}) => ({avatarUrl, displayName, email})),
1226+
transactionChanges.attendees?.map(({avatarUrl, displayName, email}) => ({avatarUrl, displayName, ...(email ? {email} : {})})) ?? [],
12271227
getRecentAttendees(),
1228+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
12281229
(attendee) => attendee.email || attendee.displayName,
12291230
).slice(0, CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW),
12301231
});

src/pages/iou/request/MoneyRequestAttendeeSelector.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,12 +83,13 @@ function MoneyRequestAttendeeSelector({attendees = [], onFinish, onAttendeesAdde
8383
const initialSelectedOptions = attendees.map((attendee) => ({
8484
...attendee,
8585
reportID: CONST.DEFAULT_NUMBER_ID.toString(),
86-
keyForList: String(attendee.accountID) ?? (attendee.email || attendee.displayName),
86+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
87+
keyForList: String(attendee.accountID) || attendee.email || attendee.displayName,
8788
selected: true,
8889
// Use || to fall back to displayName for name-only attendees (empty email)
8990
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
9091
login: attendee.email || attendee.displayName,
91-
...getPersonalDetailByEmail(attendee.email),
92+
...getPersonalDetailByEmail(attendee?.email),
9293
}));
9394

9495
const {searchTerm, debouncedSearchTerm, setSearchTerm, availableOptions, selectedOptions, toggleSelection, areOptionsInitialized, onListEndReached} = useSearchSelector({

src/types/onyx/IOU.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -230,7 +230,7 @@ type IOU = {
230230
/** Model of IOU attendee */
231231
type Attendee = {
232232
/** IOU attendee email */
233-
email: string;
233+
email?: string;
234234

235235
/** IOU attendee display name */
236236
displayName: string;

tests/actions/IOUTest/UpdateMoneyRequestTest.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -634,6 +634,39 @@ describe('actions/IOU/UpdateMoneyRequest', () => {
634634
});
635635
expect(recentAttendees?.length).toBe(CONST.IOU.MAX_RECENT_REPORTS_TO_SHOW);
636636
});
637+
638+
it('should keep displayName-only attendees in recent attendees', async () => {
639+
const transaction = createRandomTransaction(1);
640+
await Onyx.merge(`${ONYXKEYS.COLLECTION.TRANSACTION}${transaction.transactionID}`, transaction);
641+
642+
updateMoneyRequestAttendees({
643+
transactionID: transaction.transactionID,
644+
transactionThreadReport: createRandomReport(2, 'policyExpenseChat'),
645+
parentReport: undefined,
646+
attendees: [{avatarUrl: '', displayName: 'Display Name Only'}],
647+
policy: undefined,
648+
policyTagList: undefined,
649+
policyCategories: undefined,
650+
violations: undefined,
651+
currentUserAccountIDParam: 123,
652+
currentUserEmailParam: '',
653+
isASAPSubmitBetaEnabled: false,
654+
parentReportNextStep: undefined,
655+
});
656+
await waitForBatchedUpdates();
657+
658+
const recentAttendees = await new Promise<OnyxEntry<Attendee[]>>((resolve) => {
659+
const connection = Onyx.connectWithoutView({
660+
key: ONYXKEYS.NVP_RECENT_ATTENDEES,
661+
callback: (attendees) => {
662+
Onyx.disconnect(connection);
663+
resolve(attendees);
664+
},
665+
});
666+
});
667+
668+
expect(recentAttendees).toContainEqual({avatarUrl: '', displayName: 'Display Name Only'});
669+
});
637670
});
638671

639672
describe('updateMoneyRequestTag', () => {

tests/unit/AttendeeUtilsTest.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import {normalizeAttendee, normalizeAttendees} from '@libs/AttendeeUtils';
2+
import type {Attendee} from '@src/types/onyx/IOU';
3+
4+
describe('AttendeeUtils', () => {
5+
describe('normalizeAttendee', () => {
6+
it('should trim email and omit it when blank', () => {
7+
const attendee: Attendee = {
8+
email: ' ',
9+
displayName: ' John Smith ',
10+
avatarUrl: '',
11+
login: ' john@example.com ',
12+
};
13+
14+
const result = normalizeAttendee(attendee);
15+
16+
expect(result).toEqual({
17+
displayName: 'John Smith',
18+
avatarUrl: '',
19+
login: 'john@example.com',
20+
});
21+
});
22+
23+
it('should fall back to login when displayName and email are missing', () => {
24+
const attendee: Attendee = {
25+
displayName: ' ',
26+
avatarUrl: '',
27+
login: ' login-only@example.com ',
28+
};
29+
30+
const result = normalizeAttendee(attendee);
31+
32+
expect(result).toEqual({
33+
displayName: 'login-only@example.com',
34+
avatarUrl: '',
35+
login: 'login-only@example.com',
36+
});
37+
});
38+
39+
it('should use normalized email as the display name when displayName is missing', () => {
40+
const attendee: Attendee = {
41+
email: ' attendee@example.com ',
42+
displayName: ' ',
43+
avatarUrl: '',
44+
};
45+
46+
const result = normalizeAttendee(attendee);
47+
48+
expect(result).toEqual({
49+
email: 'attendee@example.com',
50+
displayName: 'attendee@example.com',
51+
avatarUrl: '',
52+
login: undefined,
53+
});
54+
});
55+
});
56+
57+
describe('normalizeAttendees', () => {
58+
it('should return an empty array when attendees are undefined', () => {
59+
expect(normalizeAttendees(undefined)).toEqual([]);
60+
});
61+
62+
it('should normalize each attendee in the list', () => {
63+
const attendees: Attendee[] = [
64+
{email: ' one@example.com ', displayName: ' One ', avatarUrl: '', login: ' one@example.com '},
65+
{displayName: ' ', avatarUrl: '', login: ' two@example.com '},
66+
];
67+
68+
expect(normalizeAttendees(attendees)).toEqual([
69+
{email: 'one@example.com', displayName: 'One', avatarUrl: '', login: 'one@example.com'},
70+
{displayName: 'two@example.com', avatarUrl: '', login: 'two@example.com'},
71+
]);
72+
});
73+
});
74+
});

0 commit comments

Comments
 (0)