Skip to content

Commit 1780b45

Browse files
authored
Merge pull request Expensify#72463 from software-mansion-labs/jnowakow/gbr-validate-contact-method
Add tests for GBR to validate unvalidated contact method
2 parents 02b4b14 + 60eef9a commit 1780b45

File tree

3 files changed

+212
-57
lines changed

3 files changed

+212
-57
lines changed

src/libs/UserUtils.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import type Login from '@src/types/onyx/Login';
99
import {isEmptyObject} from '@src/types/utils/EmptyObject';
1010
import type IconAsset from '@src/types/utils/IconAsset';
1111
import hashCode from './hashCode';
12+
import {formatPhoneNumber} from './LocalePhoneNumber';
13+
import {translateLocal} from './Localize';
1214

1315
type AvatarRange = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24;
1416

@@ -252,6 +254,58 @@ function getContactMethod(primaryLogin: string | undefined, email: string | unde
252254
return primaryLogin ?? email ?? '';
253255
}
254256

257+
/**
258+
* Gets details about contact methods to be displayed as MenuItems
259+
*/
260+
function getContactMethodsOptions(loginList?: LoginList, defaultEmail?: string) {
261+
if (!loginList) {
262+
return [];
263+
}
264+
265+
// Sort the login list by placing the one corresponding to the default contact method as the first item.
266+
// The default contact method is determined by checking against the session email (the current login).
267+
const sortedLoginList = Object.entries(loginList).sort(([, loginData]) => (loginData.partnerUserID === defaultEmail ? -1 : 1));
268+
269+
return sortedLoginList.map(([loginName, login]) => {
270+
const isDefaultContactMethod = defaultEmail === login?.partnerUserID;
271+
const pendingAction = login?.pendingFields?.deletedLogin ?? login?.pendingFields?.addedLogin ?? undefined;
272+
if (!login?.partnerUserID && !pendingAction) {
273+
return null;
274+
}
275+
276+
let description = '';
277+
if (defaultEmail === login?.partnerUserID) {
278+
description = translateLocal('contacts.getInTouch');
279+
} else if (login?.errorFields?.addedLogin) {
280+
description = translateLocal('contacts.failedNewContact');
281+
} else if (!login?.validatedDate) {
282+
description = translateLocal('contacts.pleaseVerify');
283+
}
284+
let indicator;
285+
if (Object.values(login?.errorFields ?? {}).some((errorField) => !isEmptyObject(errorField))) {
286+
indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
287+
} else if (!login?.validatedDate && !isDefaultContactMethod) {
288+
indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.INFO;
289+
} else if (!login?.validatedDate && isDefaultContactMethod && sortedLoginList.length > 1) {
290+
indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.INFO;
291+
}
292+
293+
// Default to using login key if we deleted login.partnerUserID optimistically
294+
// but still need to show the pending login being deleted while offline.
295+
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
296+
const partnerUserID = login?.partnerUserID || loginName;
297+
const menuItemTitle = Str.isSMSLogin(partnerUserID) ? formatPhoneNumber(partnerUserID) : partnerUserID;
298+
299+
return {
300+
partnerUserID,
301+
menuItemTitle,
302+
description,
303+
indicator,
304+
pendingAction,
305+
};
306+
});
307+
}
308+
255309
export {
256310
generateAccountID,
257311
getAvatar,
@@ -268,5 +322,6 @@ export {
268322
isDefaultAvatar,
269323
getContactMethod,
270324
isCurrentUserValidated,
325+
getContactMethodsOptions,
271326
};
272327
export type {AvatarSource};

src/pages/settings/Profile/Contacts/ContactMethodsPage.tsx

Lines changed: 23 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import {isUserValidatedSelector} from '@selectors/Account';
2-
import {Str} from 'expensify-common';
3-
import React, {useCallback, useContext} from 'react';
2+
import React, {useCallback, useContext, useMemo} from 'react';
43
import {View} from 'react-native';
54
import Button from '@components/Button';
65
import CopyTextToClipboard from '@components/CopyTextToClipboard';
@@ -19,77 +18,26 @@ import useThemeStyles from '@hooks/useThemeStyles';
1918
import Navigation from '@libs/Navigation/Navigation';
2019
import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types';
2120
import type {SettingsNavigatorParamList} from '@libs/Navigation/types';
21+
import {getContactMethodsOptions} from '@libs/UserUtils';
2222
import CONST from '@src/CONST';
2323
import ONYXKEYS from '@src/ONYXKEYS';
2424
import ROUTES from '@src/ROUTES';
2525
import type SCREENS from '@src/SCREENS';
26-
import {isEmptyObject} from '@src/types/utils/EmptyObject';
2726

2827
type ContactMethodsPageProps = PlatformStackScreenProps<SettingsNavigatorParamList, typeof SCREENS.SETTINGS.PROFILE.CONTACT_METHODS>;
2928

3029
function ContactMethodsPage({route}: ContactMethodsPageProps) {
3130
const styles = useThemeStyles();
32-
const {formatPhoneNumber, translate} = useLocalize();
31+
const {translate} = useLocalize();
3332
const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST, {canBeMissing: false});
3433
const [session] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: false});
35-
const loginNames = Object.keys(loginList ?? {});
3634
const navigateBackTo = route?.params?.backTo;
3735

3836
const {isActingAsDelegate, showDelegateNoAccessModal} = useContext(DelegateNoAccessContext);
3937
const [isUserValidated] = useOnyx(ONYXKEYS.ACCOUNT, {selector: isUserValidatedSelector, canBeMissing: false});
4038
const {isAccountLocked, showLockedAccountModal} = useContext(LockedAccountContext);
4139

42-
// Sort the login names by placing the one corresponding to the default contact method as the first item before displaying the contact methods.
43-
// The default contact method is determined by checking against the session email (the current login).
44-
const sortedLoginNames = loginNames.sort((loginName) => (loginList?.[loginName].partnerUserID === session?.email ? -1 : 1));
45-
const loginMenuItems = sortedLoginNames.map((loginName) => {
46-
const login = loginList?.[loginName];
47-
const isDefaultContactMethod = session?.email === login?.partnerUserID;
48-
const pendingAction = login?.pendingFields?.deletedLogin ?? login?.pendingFields?.addedLogin ?? undefined;
49-
if (!login?.partnerUserID && !pendingAction) {
50-
return null;
51-
}
52-
53-
let description = '';
54-
if (session?.email === login?.partnerUserID) {
55-
description = translate('contacts.getInTouch');
56-
} else if (login?.errorFields?.addedLogin) {
57-
description = translate('contacts.failedNewContact');
58-
} else if (!login?.validatedDate) {
59-
description = translate('contacts.pleaseVerify');
60-
}
61-
let indicator;
62-
if (Object.values(login?.errorFields ?? {}).some((errorField) => !isEmptyObject(errorField))) {
63-
indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
64-
} else if (!login?.validatedDate && !isDefaultContactMethod) {
65-
indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.INFO;
66-
} else if (!login?.validatedDate && isDefaultContactMethod && loginNames.length > 1) {
67-
indicator = CONST.BRICK_ROAD_INDICATOR_STATUS.INFO;
68-
}
69-
70-
// Default to using login key if we deleted login.partnerUserID optimistically
71-
// but still need to show the pending login being deleted while offline.
72-
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
73-
const partnerUserID = login?.partnerUserID || loginName;
74-
const menuItemTitle = Str.isSMSLogin(partnerUserID) ? formatPhoneNumber(partnerUserID) : partnerUserID;
75-
76-
return (
77-
<OfflineWithFeedback
78-
pendingAction={pendingAction}
79-
key={partnerUserID}
80-
>
81-
<MenuItem
82-
title={menuItemTitle}
83-
description={description}
84-
onPress={() => Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHOD_DETAILS.getRoute(partnerUserID, navigateBackTo))}
85-
brickRoadIndicator={indicator}
86-
shouldShowBasicTitle
87-
shouldShowRightIcon
88-
disabled={!!pendingAction}
89-
/>
90-
</OfflineWithFeedback>
91-
);
92-
});
40+
const options = useMemo(() => getContactMethodsOptions(loginList, session?.email), [loginList, session?.email]);
9341

9442
const onNewContactMethodButtonPress = useCallback(() => {
9543
if (isActingAsDelegate) {
@@ -129,7 +77,25 @@ function ContactMethodsPage({route}: ContactMethodsPageProps) {
12977
<Text>{translate('contacts.helpTextAfterEmail')}</Text>
13078
</Text>
13179
</View>
132-
{loginMenuItems}
80+
{options.map(
81+
(option) =>
82+
!!option && (
83+
<OfflineWithFeedback
84+
pendingAction={option.pendingAction}
85+
key={option.partnerUserID}
86+
>
87+
<MenuItem
88+
title={option.menuItemTitle}
89+
description={option.description}
90+
onPress={() => Navigation.navigate(ROUTES.SETTINGS_CONTACT_METHOD_DETAILS.getRoute(option.partnerUserID, navigateBackTo))}
91+
brickRoadIndicator={option.indicator}
92+
shouldShowBasicTitle
93+
shouldShowRightIcon
94+
disabled={!!option.pendingAction}
95+
/>
96+
</OfflineWithFeedback>
97+
),
98+
)}
13399
<FixedFooter style={[styles.mtAuto, styles.pt5]}>
134100
<Button
135101
large

tests/unit/UserUtilsTest.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import * as defaultAvatars from '@components/Icon/DefaultAvatars';
2+
import CONST from '@src/CONST';
23
import * as UserUtils from '@src/libs/UserUtils';
4+
import type {LoginList} from '@src/types/onyx';
35

46
describe('UserUtils', () => {
57
it('should return default avatar if the url is for default avatar', () => {
@@ -25,4 +27,136 @@ describe('UserUtils', () => {
2527

2628
expect(avatarUrl).toEqual('https://test.com/images/some_avatar.png');
2729
});
30+
31+
describe('getContactMethodsOptions', () => {
32+
type TestCase = {
33+
name: string;
34+
loginList: LoginList;
35+
defaultEmail?: string;
36+
expectedIndicators: Array<undefined | string>;
37+
};
38+
39+
const TEST_CASES: TestCase[] = [
40+
{
41+
name: 'shows error indicator when any errorFields are present',
42+
loginList: {
43+
// eslint-disable-next-line @typescript-eslint/naming-convention
44+
45+
partnerUserID: '[email protected]',
46+
errorFields: {addedLogin: {message: 'err'}},
47+
},
48+
},
49+
defaultEmail: '[email protected]',
50+
expectedIndicators: [CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR],
51+
},
52+
{
53+
name: 'shows info indicator for unvalidated non-default contact method',
54+
loginList: {
55+
// eslint-disable-next-line @typescript-eslint/naming-convention
56+
57+
partnerUserID: '[email protected]',
58+
validatedDate: '2024-01-01',
59+
},
60+
// eslint-disable-next-line @typescript-eslint/naming-convention
61+
62+
partnerUserID: '[email protected]',
63+
// no validatedDate => unvalidated
64+
},
65+
},
66+
defaultEmail: '[email protected]',
67+
// Sorted order puts default first, then secondary
68+
expectedIndicators: [undefined, CONST.BRICK_ROAD_INDICATOR_STATUS.INFO],
69+
},
70+
{
71+
name: 'shows no indicator when validated and no errors',
72+
loginList: {
73+
// eslint-disable-next-line @typescript-eslint/naming-convention
74+
75+
partnerUserID: '[email protected]',
76+
validatedDate: '2024-01-01',
77+
},
78+
// eslint-disable-next-line @typescript-eslint/naming-convention
79+
80+
partnerUserID: '[email protected]',
81+
validatedDate: '2024-03-03',
82+
},
83+
},
84+
defaultEmail: '[email protected]',
85+
expectedIndicators: [undefined],
86+
},
87+
];
88+
89+
describe.each(TEST_CASES)('$name', ({loginList, defaultEmail, expectedIndicators}) => {
90+
test('verifies indicator states', () => {
91+
const options = UserUtils.getContactMethodsOptions(loginList, defaultEmail);
92+
const indicators = options.map((o) => o?.indicator);
93+
expect(indicators).toEqual(expectedIndicators);
94+
});
95+
});
96+
});
97+
98+
describe('getLoginListBrickRoadIndicator', () => {
99+
type TestCase = {
100+
name: string;
101+
loginList: LoginList;
102+
email?: string;
103+
expected: undefined | string;
104+
};
105+
106+
const TEST_CASES: TestCase[] = [
107+
{
108+
name: 'returns ERROR when any login has errorFields',
109+
loginList: {
110+
// eslint-disable-next-line @typescript-eslint/naming-convention
111+
112+
partnerUserID: '[email protected]',
113+
errorFields: {validateCodeSent: {code: 'oops'}},
114+
},
115+
},
116+
117+
expected: CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR,
118+
},
119+
{
120+
name: 'returns INFO when there is unvalidated non-default login and no errors',
121+
loginList: {
122+
// eslint-disable-next-line @typescript-eslint/naming-convention
123+
124+
partnerUserID: '[email protected]',
125+
validatedDate: '2024-01-01',
126+
},
127+
// eslint-disable-next-line @typescript-eslint/naming-convention
128+
129+
partnerUserID: '[email protected]',
130+
// missing validatedDate => unvalidated
131+
},
132+
},
133+
134+
expected: CONST.BRICK_ROAD_INDICATOR_STATUS.INFO,
135+
},
136+
{
137+
name: 'returns undefined when all validated and no errors',
138+
loginList: {
139+
// eslint-disable-next-line @typescript-eslint/naming-convention
140+
141+
partnerUserID: '[email protected]',
142+
validatedDate: '2024-01-01',
143+
},
144+
// eslint-disable-next-line @typescript-eslint/naming-convention
145+
146+
partnerUserID: '[email protected]',
147+
validatedDate: '2024-03-03',
148+
},
149+
},
150+
151+
expected: undefined,
152+
},
153+
];
154+
155+
describe.each(TEST_CASES)('$name', ({loginList, email, expected}) => {
156+
test('verifies brick road indicator', () => {
157+
const result = UserUtils.getLoginListBrickRoadIndicator(loginList, email);
158+
expect(result).toBe(expected);
159+
});
160+
});
161+
});
28162
});

0 commit comments

Comments
 (0)