Skip to content

Commit e6811d9

Browse files
authored
Merge pull request Expensify#87705 from TaduJR/feat-user-pill-workflow-attendees
feat: user pill workflow attendees
2 parents e331f34 + b10d793 commit e6811d9

19 files changed

Lines changed: 330 additions & 29 deletions

File tree

src/components/ApprovalWorkflowSection.tsx

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import Icon from './Icon';
1515
import MenuItem from './MenuItem';
1616
import PressableWithoutFeedback from './Pressable/PressableWithoutFeedback';
1717
import Text from './Text';
18+
import UserPill from './UserPill';
19+
import UserPills from './UserPills';
1820

1921
type ApprovalWorkflowSectionProps = {
2022
/** Single workflow displayed in this component */
@@ -38,11 +40,15 @@ function ApprovalWorkflowSection({approvalWorkflow, onPress, currency = CONST.CU
3840
const approverTitle = (index: number) =>
3941
approvalWorkflow.approvers.length > 1 ? `${toLocaleOrdinal(index + 1, true)} ${translate('workflowsPage.approver').toLowerCase()}` : `${translate('workflowsPage.approver')}`;
4042

41-
const members = approvalWorkflow.isDefault
42-
? translate('workspace.common.everyone')
43-
: sortAlphabetically(approvalWorkflow.members, 'displayName', localeCompare)
44-
.map((m) => Str.removeSMSDomain(m.displayName))
45-
.join(', ');
43+
const sortedMembers = approvalWorkflow.isDefault ? [] : sortAlphabetically(approvalWorkflow.members, 'displayName', localeCompare);
44+
45+
const members = approvalWorkflow.isDefault ? translate('workspace.common.everyone') : sortedMembers.map((m) => Str.removeSMSDomain(m.displayName)).join(', ');
46+
47+
const memberPills = sortedMembers.map((m) => ({
48+
avatar: m.avatar,
49+
displayName: m.displayName,
50+
email: m.email,
51+
}));
4652
return (
4753
<PressableWithoutFeedback
4854
accessibilityRole="button"
@@ -76,7 +82,7 @@ function ApprovalWorkflowSection({approvalWorkflow, onPress, currency = CONST.CU
7682
style={styles.p0}
7783
titleStyle={styles.textLabelSupportingNormal}
7884
descriptionTextStyle={[styles.textNormalThemeText, styles.lineHeightXLarge]}
79-
description={members}
85+
description={approvalWorkflow.isDefault ? members : undefined}
8086
numberOfLinesDescription={4}
8187
shouldBeAccessible={false}
8288
tabIndex={-1}
@@ -86,6 +92,13 @@ function ApprovalWorkflowSection({approvalWorkflow, onPress, currency = CONST.CU
8692
iconFill={theme.icon}
8793
onPress={onPress}
8894
shouldRemoveBackground
95+
titleComponent={
96+
!approvalWorkflow.isDefault ? (
97+
<View style={styles.ml3}>
98+
<UserPills users={memberPills} />
99+
</View>
100+
) : undefined
101+
}
89102
sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.WORKFLOWS.APPROVAL_SECTION_EXPENSES_FROM}
90103
/>
91104

@@ -98,7 +111,6 @@ function ApprovalWorkflowSection({approvalWorkflow, onPress, currency = CONST.CU
98111
style={styles.p0}
99112
titleStyle={styles.textLabelSupportingNormal}
100113
descriptionTextStyle={[styles.textNormalThemeText, styles.lineHeightXLarge]}
101-
description={Str.removeSMSDomain(approver.displayName)}
102114
icon={icons.UserCheck}
103115
shouldBeAccessible={false}
104116
tabIndex={-1}
@@ -108,6 +120,16 @@ function ApprovalWorkflowSection({approvalWorkflow, onPress, currency = CONST.CU
108120
iconFill={theme.icon}
109121
onPress={onPress}
110122
shouldRemoveBackground
123+
titleComponent={
124+
<View style={[styles.ml3, styles.pr3]}>
125+
<UserPill
126+
avatar={approver.avatar}
127+
displayName={approver.displayName}
128+
email={approver.email}
129+
style={styles.userPillStandalone}
130+
/>
131+
</View>
132+
}
111133
helperText={getApprovalLimitDescription({approver, currency, translate, personalDetailsByEmail})}
112134
helperTextStyle={styles.workflowApprovalLimitText}
113135
sentryLabel={CONST.SENTRY_LABEL.WORKSPACE.WORKFLOWS.APPROVAL_SECTION_APPROVER}

src/components/MoneyRequestConfirmationList/sections/AttendeeField.tsx

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import React from 'react';
22
import type {OnyxEntry} from 'react-native-onyx';
33
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
4+
import {usePersonalDetails} from '@components/OnyxListItemProvider';
5+
import UserPills from '@components/UserPills';
46
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
57
import useLocalize from '@hooks/useLocalize';
68
import useThemeStyles from '@hooks/useThemeStyles';
79
import Navigation from '@libs/Navigation/Navigation';
10+
import {sortAlphabetically} from '@libs/OptionsListUtils';
811
import {getAttendees} from '@libs/TransactionUtils';
912
import CONST from '@src/CONST';
1013
import type {IOUAction, IOUType} from '@src/CONST';
@@ -25,8 +28,9 @@ type AttendeeFieldProps = {
2528

2629
function AttendeeField({formattedAmountPerAttendee, isReadOnly, transactionID, action, iouType, reportID, formError, transaction}: AttendeeFieldProps) {
2730
const styles = useThemeStyles();
28-
const {translate} = useLocalize();
31+
const {translate, localeCompare} = useLocalize();
2932
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
33+
const personalDetailsList = usePersonalDetails();
3034
const shouldDisplayAttendeesError = formError === 'violations.missingAttendees';
3135

3236
const iouAttendees = getAttendees(transaction, currentUserPersonalDetails);
@@ -35,10 +39,38 @@ function AttendeeField({formattedAmountPerAttendee, isReadOnly, transactionID, a
3539
<MenuItemWithTopDescription
3640
key="attendees"
3741
shouldShowRightIcon={!isReadOnly}
38-
title={iouAttendees?.map((item) => item?.displayName ?? item?.login).join(', ')}
42+
accessibilityLabel={`${translate('iou.attendees')}, ${iouAttendees?.map((a) => a?.displayName ?? a?.login).join(', ')}`}
3943
description={`${translate('iou.attendees')} ${
4044
iouAttendees?.length && iouAttendees.length > 1 && formattedAmountPerAttendee ? `\u00B7 ${formattedAmountPerAttendee} ${translate('common.perPerson')}` : ''
4145
}`}
46+
descriptionTextStyle={styles.textLabelSupportingNormal}
47+
titleComponent={
48+
Array.isArray(iouAttendees) ? (
49+
<UserPills
50+
users={sortAlphabetically(
51+
iouAttendees.map((a) => {
52+
const pd = a?.accountID ? personalDetailsList?.[a.accountID] : undefined;
53+
const freshAvatar = typeof pd?.avatar === 'string' ? pd.avatar : undefined;
54+
return {
55+
...a,
56+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
57+
displayName: pd?.displayName || a?.displayName,
58+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
59+
avatarUrl: freshAvatar || a?.avatarUrl,
60+
};
61+
}),
62+
'displayName',
63+
localeCompare,
64+
).map((a) => ({
65+
avatar: a?.avatarUrl,
66+
displayName: a?.displayName ?? a?.login ?? a?.email ?? '',
67+
accountID: a?.accountID,
68+
email: a?.email ?? a?.login,
69+
}))}
70+
maxVisible={isReadOnly ? iouAttendees.length : undefined}
71+
/>
72+
) : undefined
73+
}
4274
style={[styles.moneyRequestMenuItem]}
4375
titleStyle={styles.flex1}
4476
onPress={() => {
@@ -49,7 +81,6 @@ function AttendeeField({formattedAmountPerAttendee, isReadOnly, transactionID, a
4981
Navigation.navigate(ROUTES.MONEY_REQUEST_ATTENDEE.getRoute(action, iouType, transactionID, reportID, Navigation.getActiveRoute()));
5082
}}
5183
interactive={!isReadOnly}
52-
shouldRenderAsHTML
5384
brickRoadIndicator={shouldDisplayAttendeesError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
5485
errorText={shouldDisplayAttendeesError ? translate(formError as TranslationPaths) : ''}
5586
sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.ATTENDEES_FIELD}

src/components/ReportActionItem/MoneyRequestView.tsx

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@ import MenuItem from '@components/MenuItem';
99
import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
1010
import {ModalActions} from '@components/Modal/Global/ModalContext';
1111
import OfflineWithFeedback from '@components/OfflineWithFeedback';
12-
import {usePolicyCategories, usePolicyTags} from '@components/OnyxListItemProvider';
12+
import {usePersonalDetails, usePolicyCategories, usePolicyTags} from '@components/OnyxListItemProvider';
1313
import ReportActionsSkeletonView from '@components/ReportActionsSkeletonView';
1414
import {useSearchStateContext} from '@components/Search/SearchContext';
1515
import Switch from '@components/Switch';
1616
import Text from '@components/Text';
17+
import UserPills from '@components/UserPills';
1718
import ViolationMessages from '@components/ViolationMessages';
1819
import {useWideRHPState} from '@components/WideRHPContextProvider';
1920
import useActiveRoute from '@hooks/useActiveRoute';
@@ -45,7 +46,7 @@ import {getDecodedCategoryName, isCategoryMissing} from '@libs/CategoryUtils';
4546
import DistanceRequestUtils from '@libs/DistanceRequestUtils';
4647
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
4748
import {getRateFromMerchant} from '@libs/MergeTransactionUtils';
48-
import {hasEnabledOptions} from '@libs/OptionsListUtils';
49+
import {hasEnabledOptions, sortAlphabetically} from '@libs/OptionsListUtils';
4950
import Parser from '@libs/Parser';
5051
import {
5152
canSubmitPerDiemExpenseFromWorkspace,
@@ -176,7 +177,7 @@ function MoneyRequestView({
176177
const StyleUtils = useStyleUtils();
177178
const {isOffline} = useNetwork();
178179
const {environmentURL} = useEnvironment();
179-
const {translate, toLocaleDigit} = useLocalize();
180+
const {translate, toLocaleDigit, localeCompare} = useLocalize();
180181
const {convertToDisplayString, getCurrencySymbol} = useCurrencyListActions();
181182
const {getReportRHPActiveRoute} = useActiveRoute();
182183
const {showConfirmModal} = useConfirmModal();
@@ -236,6 +237,7 @@ function MoneyRequestView({
236237
const transactionViolations = useTransactionViolations(transaction?.transactionID);
237238
const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID);
238239
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
240+
const personalDetailsList = usePersonalDetails();
239241
const currentUserAccountIDParam = currentUserPersonalDetails.accountID;
240242
const currentUserEmailParam = currentUserPersonalDetails.login ?? '';
241243
const {isBetaEnabled} = usePermissions();
@@ -1166,12 +1168,40 @@ function MoneyRequestView({
11661168
<OfflineWithFeedback pendingAction={getPendingFieldAction('attendees')}>
11671169
<MenuItemWithTopDescription
11681170
key="attendees"
1169-
title={getAttendeesTitle}
1171+
accessibilityLabel={`${translate('iou.attendees')}, ${getAttendeesTitle}`}
11701172
description={`${translate('iou.attendees')} ${
11711173
Array.isArray(actualAttendees) && actualAttendees.length > 1 && formattedPerAttendeeAmount
11721174
? `${CONST.DOT_SEPARATOR} ${formattedPerAttendeeAmount} ${translate('common.perPerson')}`
11731175
: ''
11741176
}`}
1177+
descriptionTextStyle={styles.textLabelSupportingNormal}
1178+
titleComponent={
1179+
Array.isArray(actualAttendees) ? (
1180+
<UserPills
1181+
users={sortAlphabetically(
1182+
actualAttendees.map((a) => {
1183+
const pd = a?.accountID ? personalDetailsList?.[a.accountID] : undefined;
1184+
const freshAvatar = typeof pd?.avatar === 'string' ? pd.avatar : undefined;
1185+
return {
1186+
...a,
1187+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
1188+
displayName: pd?.displayName || a?.displayName,
1189+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
1190+
avatarUrl: freshAvatar || a?.avatarUrl,
1191+
};
1192+
}),
1193+
'displayName',
1194+
localeCompare,
1195+
).map((a) => ({
1196+
avatar: a?.avatarUrl,
1197+
displayName: a?.displayName ?? a?.login ?? a?.email ?? '',
1198+
accountID: a?.accountID,
1199+
email: a?.email ?? a?.login,
1200+
}))}
1201+
maxVisible={canEdit ? undefined : actualAttendees.length}
1202+
/>
1203+
) : undefined
1204+
}
11751205
style={[styles.moneyRequestMenuItem]}
11761206
titleStyle={styles.flex1}
11771207
onPress={() => {
@@ -1181,7 +1211,6 @@ function MoneyRequestView({
11811211
errorText={getErrorForField('attendees')}
11821212
interactive={canEdit}
11831213
shouldShowRightIcon={canEdit}
1184-
shouldRenderAsHTML
11851214
copyValue={attendeesCopyValue}
11861215
copyable={!!attendeesCopyValue}
11871216
/>

src/components/UserPill.tsx

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import {Str} from 'expensify-common';
2+
import React from 'react';
3+
import {View} from 'react-native';
4+
import type {StyleProp, ViewStyle} from 'react-native';
5+
import useResponsiveLayout from '@hooks/useResponsiveLayout';
6+
import useThemeStyles from '@hooks/useThemeStyles';
7+
import type {AvatarSource} from '@libs/UserAvatarUtils';
8+
import CONST from '@src/CONST';
9+
import Avatar from './Avatar';
10+
import Text from './Text';
11+
import UserDetailsTooltip from './UserDetailsTooltip';
12+
13+
type UserPillProps = {
14+
avatar?: AvatarSource;
15+
displayName: string;
16+
accountID?: number;
17+
email?: string;
18+
style?: StyleProp<ViewStyle>;
19+
};
20+
21+
function UserPill({avatar, displayName, accountID, email, style}: UserPillProps) {
22+
const styles = useThemeStyles();
23+
const {shouldUseNarrowLayout} = useResponsiveLayout();
24+
25+
return (
26+
<UserDetailsTooltip
27+
accountID={accountID ?? CONST.DEFAULT_NUMBER_ID}
28+
fallbackUserDetails={{
29+
avatar,
30+
displayName: Str.removeSMSDomain(displayName),
31+
login: email ?? displayName,
32+
}}
33+
>
34+
<View style={[styles.flexRow, styles.alignItemsCenter, styles.alignSelfStart, styles.userPill, shouldUseNarrowLayout && styles.mw100, style]}>
35+
<Avatar
36+
source={avatar}
37+
size={CONST.AVATAR_SIZE.MENTION_ICON}
38+
type={CONST.ICON_TYPE_AVATAR}
39+
avatarID={accountID}
40+
name={displayName}
41+
/>
42+
<Text
43+
style={styles.userPillText}
44+
numberOfLines={1}
45+
>
46+
{Str.removeSMSDomain(displayName)}
47+
</Text>
48+
</View>
49+
</UserDetailsTooltip>
50+
);
51+
}
52+
53+
UserPill.displayName = 'UserPill';
54+
55+
export default UserPill;
56+
export type {UserPillProps};

src/components/UserPills.tsx

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import {Str} from 'expensify-common';
2+
import React from 'react';
3+
import {View} from 'react-native';
4+
import useLocalize from '@hooks/useLocalize';
5+
import useThemeStyles from '@hooks/useThemeStyles';
6+
import type {AvatarSource} from '@libs/UserAvatarUtils';
7+
import CONST from '@src/CONST';
8+
import Text from './Text';
9+
import Tooltip from './Tooltip';
10+
import UserPill from './UserPill';
11+
12+
type UserPillData = {
13+
avatar?: AvatarSource;
14+
displayName: string;
15+
accountID?: number;
16+
email?: string;
17+
};
18+
19+
type UserPillsProps = {
20+
users: UserPillData[];
21+
maxVisible?: number;
22+
};
23+
24+
const DEFAULT_MAX_VISIBLE = 6;
25+
26+
function UserPills({users, maxVisible = DEFAULT_MAX_VISIBLE}: UserPillsProps) {
27+
const styles = useThemeStyles();
28+
const {translate} = useLocalize();
29+
30+
// Show the extra pill instead of "+1 more" when only 1 would be hidden.
31+
const visibleUsers = users.length <= maxVisible + 1 ? users : users.slice(0, maxVisible);
32+
const hiddenCount = users.length - visibleUsers.length;
33+
const hiddenNames =
34+
hiddenCount > 0
35+
? users
36+
.slice(visibleUsers.length)
37+
.map((u) => Str.removeSMSDomain(u.displayName))
38+
.join(', ')
39+
: '';
40+
41+
return (
42+
<View style={[styles.flexRow, styles.flexWrap, styles.userPillsContainer]}>
43+
{visibleUsers.map((user) => {
44+
const hasRealAccountID = user.accountID !== undefined && user.accountID !== CONST.DEFAULT_NUMBER_ID;
45+
return (
46+
<UserPill
47+
key={hasRealAccountID ? user.accountID : (user.email ?? user.displayName)}
48+
avatar={user.avatar}
49+
displayName={user.displayName}
50+
accountID={user.accountID}
51+
email={user.email}
52+
/>
53+
);
54+
})}
55+
{hiddenCount > 0 && (
56+
<Tooltip text={hiddenNames}>
57+
<View style={[styles.flexRow, styles.alignItemsCenter]}>
58+
<Text style={styles.userPillMoreText}>{translate('common.plusMore', {count: hiddenCount})}</Text>
59+
</View>
60+
</Tooltip>
61+
)}
62+
</View>
63+
);
64+
}
65+
66+
UserPills.displayName = 'UserPills';
67+
68+
export default UserPills;
69+
export type {UserPillData, UserPillsProps};

src/languages/de.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,7 @@ const translations: TranslationDeepObject<typeof en> = {
311311
letsStart: `Lass uns anfangen`,
312312
showMore: 'Mehr anzeigen',
313313
showLess: 'Weniger anzeigen',
314+
plusMore: ({count}: {count: number}) => `+${count} weitere`,
314315
merchant: 'Händler',
315316
change: 'Ändern',
316317
category: 'Kategorie',

src/languages/en.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -325,6 +325,7 @@ const translations = {
325325
letsStart: `Let's start`,
326326
showMore: 'Show more',
327327
showLess: 'Show less',
328+
plusMore: ({count}: {count: number}) => `+${count} more`,
328329
merchant: 'Merchant',
329330
change: 'Change',
330331
category: 'Category',

src/languages/es.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,7 @@ const translations: TranslationDeepObject<typeof en> = {
251251
letsStart: 'Empecemos',
252252
showMore: 'Mostrar más',
253253
showLess: 'Mostrar menos',
254+
plusMore: ({count}: {count: number}) => `+${count} más`,
254255
merchant: 'Comerciante',
255256
change: 'Cambio',
256257
category: 'Categoría',

0 commit comments

Comments
 (0)