Skip to content

Commit 9caaac9

Browse files
authored
Merge pull request Expensify#89248 from Eskalifer1/fix/88756
fix: Attendees Not Displayed in Expense View on New Expensify (Visible in Classic)
2 parents d4deeef + 4a83013 commit 9caaac9

7 files changed

Lines changed: 190 additions & 12 deletions

File tree

src/components/ReportActionAvatars/useReportPreviewSenderID.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type {OnyxEntry} from 'react-native-onyx';
33
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
44
import useOnyx from '@hooks/useOnyx';
55
import useTransactionsAndViolationsForReport from '@hooks/useTransactionsAndViolationsForReport';
6+
import {convertAttendeesToArray} from '@libs/AttendeeUtils';
67
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
78
import {getAllNonDeletedTransactions} from '@libs/MoneyRequestReportUtils';
89
import {getPersonalDetailByEmail} from '@libs/PersonalDetailsUtils';
@@ -72,7 +73,9 @@ function getReportPreviewSenderID({iouReport, action, chatReport, iouActions, tr
7273
const attendeesIDs = transactions
7374
// If the transaction is a split, then attendees are not present as a property so we need to use a helper function.
7475
?.flatMap<number | undefined>((tr) =>
75-
tr.comment?.attendees?.map?.((att) => (tr.comment?.source === CONST.IOU.TYPE.SPLIT ? getSplitAuthor(tr, splits) : getPersonalDetailByEmail(att.email)?.accountID)),
76+
convertAttendeesToArray(tr.comment?.attendees).map((att) =>
77+
tr.comment?.source === CONST.IOU.TYPE.SPLIT ? getSplitAuthor(tr, splits) : getPersonalDetailByEmail(att.email)?.accountID,
78+
),
7679
)
7780
.filter((accountID) => !!accountID);
7881

src/libs/AttendeeUtils.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,20 @@ function formatRequiredFieldsTitle(translate: LocaleContextProps['translate'], p
5656
return [capitalizedFirst, ...lowercasedRest].join(', ');
5757
}
5858

59+
/**
60+
* Converts raw attendees value to an array.
61+
* Onyx may deserialize arrays as plain objects, so both shapes are handled.
62+
*/
63+
function convertAttendeesToArray(rawAttendees: unknown): Attendee[] {
64+
if (Array.isArray(rawAttendees)) {
65+
return rawAttendees as Attendee[];
66+
}
67+
if (rawAttendees && typeof rawAttendees === 'object') {
68+
return Object.values(rawAttendees as Record<string, Attendee>);
69+
}
70+
return [];
71+
}
72+
5973
/** Returns whether there are missing attendees for the given category */
6074
function getIsMissingAttendeesViolation(
6175
policyCategories: PolicyCategories | undefined,
@@ -78,7 +92,7 @@ function getIsMissingAttendeesViolation(
7892

7993
const creatorLogin = userPersonalDetails.login ?? '';
8094
const creatorEmail = userPersonalDetails.email ?? '';
81-
const attendees = Array.isArray(iouAttendees) ? iouAttendees : [];
95+
const attendees = convertAttendeesToArray(iouAttendees);
8296
// Check both login and email since attendee objects may have identifier in either property
8397
const attendeesMinusCreatorCount = attendees.filter((a) => {
8498
const attendeeIdentifier = a?.login ?? a?.email;
@@ -169,4 +183,4 @@ function enrichAndSortAttendees(
169183
);
170184
}
171185

172-
export {enrichAndSortAttendees, formatRequiredFieldsTitle, getIsMissingAttendeesViolation, normalizeAttendee, normalizeAttendees, syncMissingAttendeesViolation};
186+
export {enrichAndSortAttendees, formatRequiredFieldsTitle, getIsMissingAttendeesViolation, normalizeAttendee, normalizeAttendees, syncMissingAttendeesViolation, convertAttendeesToArray};

src/libs/SearchUIUtils.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ import {canApproveIOU, canIOUBePaid, canSubmitReport} from './actions/IOU/Report
9494
import {createTransactionThreadReport} from './actions/Report';
9595
import type {TransactionPreviewData} from './actions/Search';
9696
import {setOptimisticDataForTransactionThreadPreview} from './actions/Search';
97+
import {convertAttendeesToArray} from './AttendeeUtils';
9798
import type {CardFeedForDisplay} from './CardFeedUtils';
9899
import {getCardFeedsForDisplay} from './CardFeedUtils';
99100
import {getCardDescriptionForSearchTable, getFeedNameForDisplay} from './CardUtils';
@@ -3711,15 +3712,15 @@ function getSortedTransactionData(
37113712

37123713
if (sortBy === CONST.SEARCH.TABLE_COLUMNS.ATTENDEES) {
37133714
return data.sort((a, b) => {
3714-
const aValue = a.comment?.attendees?.length ?? 0;
3715-
const bValue = b.comment?.attendees?.length ?? 0;
3715+
const aValue = convertAttendeesToArray(a.comment?.attendees).length;
3716+
const bValue = convertAttendeesToArray(b.comment?.attendees).length;
37163717
return compareValues(aValue, bValue, sortOrder, sortBy, localeCompare);
37173718
});
37183719
}
37193720

37203721
if (sortBy === CONST.SEARCH.TABLE_COLUMNS.TOTAL_PER_ATTENDEE) {
37213722
const getTotalPerAttendee = (t: TransactionListItemType) => {
3722-
const attendeesCount = t.comment?.attendees?.length ?? 0;
3723+
const attendeesCount = convertAttendeesToArray(t.comment?.attendees).length;
37233724
if (!attendeesCount) {
37243725
return 0;
37253726
}

src/libs/TransactionUtils/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import utils from '@components/MapView/utils';
1212
import type {UnreportedExpenseListItemType} from '@components/Search/SearchList/ListItem/types';
1313
import type {TransactionWithOptionalSearchFields} from '@components/TransactionItemRow';
1414
import type {MergeDuplicatesParams} from '@libs/API/parameters';
15-
import {normalizeAttendees} from '@libs/AttendeeUtils';
15+
import {convertAttendeesToArray, normalizeAttendees} from '@libs/AttendeeUtils';
1616
import {getCategoryDefaultTaxRate, isCategoryMissing} from '@libs/CategoryUtils';
1717
import {convertToBackendAmount, getCurrencyDecimals, getCurrencySymbol} from '@libs/CurrencyUtils';
1818
import DateUtils from '@libs/DateUtils';
@@ -1177,7 +1177,7 @@ function getReportOwnerAsAttendee(transaction: OnyxInputOrEntry<Transaction>, cu
11771177
*/
11781178
function getOriginalAttendees(transaction: OnyxInputOrEntry<Transaction>, currentUserPersonalDetails: CurrentUserPersonalDetails | undefined): Attendee[] {
11791179
const rawAttendees = transaction?.comment?.attendees;
1180-
const attendees = normalizeAttendees(Array.isArray(rawAttendees) ? rawAttendees : []);
1180+
const attendees = normalizeAttendees(convertAttendeesToArray(rawAttendees));
11811181
const reportOwnerAsAttendee = getReportOwnerAsAttendee(transaction, currentUserPersonalDetails);
11821182
if (attendees.length === 0 && reportOwnerAsAttendee !== undefined) {
11831183
attendees.push(reportOwnerAsAttendee);
@@ -1192,7 +1192,7 @@ function getOriginalAttendees(transaction: OnyxInputOrEntry<Transaction>, curren
11921192
*/
11931193
function getAttendees(transaction: OnyxInputOrEntry<Transaction>, currentUserPersonalDetails: CurrentUserPersonalDetails | undefined): Attendee[] {
11941194
const rawAttendees = transaction?.modifiedAttendees ?? transaction?.comment?.attendees;
1195-
const attendees = normalizeAttendees(Array.isArray(rawAttendees) ? rawAttendees : []);
1195+
const attendees = normalizeAttendees(convertAttendeesToArray(rawAttendees));
11961196
const reportOwnerAsAttendee = getReportOwnerAsAttendee(transaction, currentUserPersonalDetails);
11971197

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

src/libs/Violations/ViolationsUtils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import reject from 'lodash/reject';
44
import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
55
import Onyx from 'react-native-onyx';
66
import type {LocaleContextProps} from '@components/LocaleContextProvider';
7-
import {getIsMissingAttendeesViolation} from '@libs/AttendeeUtils';
7+
import {convertAttendeesToArray, getIsMissingAttendeesViolation} from '@libs/AttendeeUtils';
88
import {isPersonalCard} from '@libs/CardUtils';
99
import {getDecodedCategoryName, isCategoryMissing} from '@libs/CategoryUtils';
1010
import * as CurrencyUtils from '@libs/CurrencyUtils';
@@ -525,7 +525,7 @@ const ViolationsUtils = {
525525
const shouldShowMissingComment =
526526
!isInvoiceTransaction && policyCategories?.[categoryName ?? '']?.areCommentsRequired && !updatedTransaction.comment?.comment && isControlPolicy && policy?.areRulesEnabled;
527527
const rawAttendees = updatedTransaction.modifiedAttendees ?? updatedTransaction.comment?.attendees;
528-
const attendees = Array.isArray(rawAttendees) ? rawAttendees : [];
528+
const attendees = convertAttendeesToArray(rawAttendees);
529529
const isAttendeeTrackingEnabled = isAttendeeTrackingEnabledForPolicy(policy);
530530
// Filter out the owner/creator when checking attendance count - expense is valid if at least one non-owner attendee is present
531531
const ownerAccountID = iouReport?.ownerAccountID;

tests/unit/AttendeeUtilsTest.ts

Lines changed: 80 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,87 @@
1-
import {enrichAndSortAttendees, normalizeAttendee, normalizeAttendees} from '@libs/AttendeeUtils';
1+
import {convertAttendeesToArray, enrichAndSortAttendees, normalizeAttendee, normalizeAttendees} from '@libs/AttendeeUtils';
22
import type {PersonalDetailsList} from '@src/types/onyx';
33
import type {Attendee} from '@src/types/onyx/IOU';
44

5+
const makeAttendee = (overrides: Partial<Attendee> = {}): Attendee => ({
6+
displayName: 'Test User',
7+
avatarUrl: 'https://example.com/avatar.png',
8+
...overrides,
9+
});
10+
511
describe('AttendeeUtils', () => {
12+
describe('convertAttendeesToArray', () => {
13+
describe('falsy / missing values', () => {
14+
it('returns [] for undefined', () => {
15+
expect(convertAttendeesToArray(undefined)).toEqual([]);
16+
});
17+
18+
it('returns [] for null (unexpected runtime value)', () => {
19+
expect(convertAttendeesToArray(null)).toEqual([]);
20+
});
21+
});
22+
23+
describe('array inputs', () => {
24+
it('returns [] for an empty array', () => {
25+
expect(convertAttendeesToArray([])).toEqual([]);
26+
});
27+
28+
it('returns the same array reference when input is already an array', () => {
29+
const attendees: Attendee[] = [makeAttendee({email: 'a@test.com'})];
30+
expect(convertAttendeesToArray(attendees)).toBe(attendees);
31+
});
32+
33+
it('returns all items for an array with multiple attendees', () => {
34+
const attendees: Attendee[] = [makeAttendee({email: 'a@test.com'}), makeAttendee({email: 'b@test.com'}), makeAttendee({email: 'c@test.com'})];
35+
expect(convertAttendeesToArray(attendees)).toHaveLength(3);
36+
expect(convertAttendeesToArray(attendees)).toEqual(attendees);
37+
});
38+
});
39+
40+
describe('plain-object inputs (Onyx-deserialized arrays)', () => {
41+
it('returns [] for an empty object {}', () => {
42+
expect(convertAttendeesToArray({})).toEqual([]);
43+
});
44+
45+
it('converts a single-entry object to a one-element array', () => {
46+
const attendee = makeAttendee({email: 'a@test.com'});
47+
const result = convertAttendeesToArray({first: attendee});
48+
expect(result).toHaveLength(1);
49+
expect(result.at(0)).toBe(attendee);
50+
});
51+
52+
it('converts a multi-entry object to an array containing all values', () => {
53+
const a1 = makeAttendee({email: 'a@test.com'});
54+
const a2 = makeAttendee({email: 'b@test.com'});
55+
const a3 = makeAttendee({email: 'c@test.com'});
56+
const result = convertAttendeesToArray({first: a1, second: a2, third: a3});
57+
expect(result).toHaveLength(3);
58+
expect(result).toContain(a1);
59+
expect(result).toContain(a2);
60+
expect(result).toContain(a3);
61+
});
62+
63+
it('preserves all attendee fields when converting from an object', () => {
64+
const attendee = makeAttendee({email: 'user@test.com', displayName: 'Alice', login: 'alice', accountID: 42, selected: true});
65+
const result = convertAttendeesToArray({first: attendee});
66+
expect(result.at(0)).toMatchObject({email: 'user@test.com', displayName: 'Alice', login: 'alice', accountID: 42, selected: true});
67+
});
68+
});
69+
70+
describe('non-object primitives (unexpected runtime values)', () => {
71+
it('returns [] for a string', () => {
72+
expect(convertAttendeesToArray('invalid')).toEqual([]);
73+
});
74+
75+
it('returns [] for a number', () => {
76+
expect(convertAttendeesToArray(42)).toEqual([]);
77+
});
78+
79+
it('returns [] for a boolean', () => {
80+
expect(convertAttendeesToArray(true)).toEqual([]);
81+
});
82+
});
83+
});
84+
685
describe('normalizeAttendee', () => {
786
it('should trim email and omit it when blank', () => {
887
const attendee: Attendee = {

tests/unit/TransactionUtilsTest.ts

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1250,6 +1250,25 @@ describe('TransactionUtils', () => {
12501250

12511251
expect(result).toEqual([{displayName: 'login-only@example.com', login: 'login-only@example.com', avatarUrl: ''}]);
12521252
});
1253+
1254+
it('should handle attendees stored as a plain object', () => {
1255+
const attendeesArray: Attendee[] = [
1256+
{email: 'attendee1@example.com', login: 'attendee1@example.com', displayName: 'Attendee One', avatarUrl: '', accountID: 3, selected: true},
1257+
{email: 'attendee2@example.com', login: 'attendee2@example.com', displayName: 'Attendee Two', avatarUrl: '', accountID: 4, selected: false},
1258+
];
1259+
const transaction = generateTransaction({
1260+
reportID: FAKE_OPEN_REPORT_ID,
1261+
comment: {
1262+
attendees: Object.fromEntries(attendeesArray.entries()) as unknown as Attendee[],
1263+
},
1264+
});
1265+
1266+
const result = TransactionUtils.getOriginalAttendees(transaction, currentUserPersonalDetails);
1267+
1268+
expect(result.length).toBe(2);
1269+
expect(result.at(0)?.email).toBe('attendee1@example.com');
1270+
expect(result.at(1)?.email).toBe('attendee2@example.com');
1271+
});
12531272
});
12541273

12551274
describe('getAttendees', () => {
@@ -1395,6 +1414,68 @@ describe('TransactionUtils', () => {
13951414

13961415
expect(result).toEqual([{displayName: 'edited@example.com', login: 'edited@example.com', avatarUrl: ''}]);
13971416
});
1417+
1418+
it('should handle comment attendees stored as a plain object', () => {
1419+
const attendeesArray: Attendee[] = [{email: 'attendee@example.com', login: 'attendee@example.com', displayName: 'Attendee', avatarUrl: '', accountID: 7, selected: true}];
1420+
const transaction = generateTransaction({
1421+
reportID: FAKE_OPEN_REPORT_ID,
1422+
comment: {
1423+
attendees: Object.fromEntries(attendeesArray.entries()) as unknown as Attendee[],
1424+
},
1425+
});
1426+
1427+
const result = TransactionUtils.getAttendees(transaction, currentUserPersonalDetails);
1428+
1429+
expect(result.length).toBe(1);
1430+
expect(result.at(0)?.email).toBe('attendee@example.com');
1431+
});
1432+
1433+
it('should handle modifiedAttendees stored as a plain object', () => {
1434+
const modifiedAttendeesArray: Attendee[] = [
1435+
{email: 'modified@example.com', login: 'modified@example.com', displayName: 'Modified Attendee', avatarUrl: '', accountID: 6, selected: true},
1436+
];
1437+
const transaction = generateTransaction({
1438+
reportID: FAKE_OPEN_REPORT_ID,
1439+
comment: {
1440+
attendees: [],
1441+
},
1442+
modifiedAttendees: Object.fromEntries(modifiedAttendeesArray.entries()) as unknown as Attendee[],
1443+
});
1444+
1445+
const result = TransactionUtils.getAttendees(transaction, currentUserPersonalDetails);
1446+
1447+
expect(result.length).toBe(1);
1448+
expect(result.at(0)?.email).toBe('modified@example.com');
1449+
});
1450+
1451+
it('should fall back to report owner when comment attendees is an empty plain object', () => {
1452+
const transaction = generateTransaction({
1453+
reportID: FAKE_OPEN_REPORT_ID,
1454+
comment: {
1455+
attendees: {} as unknown as Attendee[],
1456+
},
1457+
});
1458+
1459+
const result = TransactionUtils.getAttendees(transaction, currentUserPersonalDetails);
1460+
1461+
expect(result.length).toBe(1);
1462+
expect(result.at(0)?.accountID).toBe(CURRENT_USER_ID);
1463+
});
1464+
1465+
it('should fall back to report owner when modifiedAttendees is an empty plain object', () => {
1466+
const transaction = generateTransaction({
1467+
reportID: FAKE_OPEN_REPORT_ID,
1468+
comment: {
1469+
attendees: [],
1470+
},
1471+
modifiedAttendees: {} as unknown as Attendee[],
1472+
});
1473+
1474+
const result = TransactionUtils.getAttendees(transaction, currentUserPersonalDetails);
1475+
1476+
expect(result.length).toBe(1);
1477+
expect(result.at(0)?.accountID).toBe(CURRENT_USER_ID);
1478+
});
13981479
});
13991480

14001481
describe('getAttendeesListDisplayString', () => {

0 commit comments

Comments
 (0)