Skip to content

Commit 0a057f4

Browse files
authored
Merge pull request #86848 from callstack-internal/new-feed-for-expensify
Release 3: UI Updates for Expensify Cards
2 parents 1914b7f + 2fb47c4 commit 0a057f4

22 files changed

Lines changed: 1175 additions & 117 deletions

src/ROUTES.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2751,10 +2751,17 @@ const ROUTES = {
27512751
},
27522752
WORKSPACE_EXPENSIFY_CARD_SELECT_FEED: {
27532753
route: 'workspaces/:policyID/expensify-card/select-feed',
2754-
27552754
// eslint-disable-next-line no-restricted-syntax -- Legacy route generation
27562755
getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`workspaces/${policyID}/expensify-card/select-feed`, backTo),
27572756
},
2757+
WORKSPACE_EXPENSIFY_CARD_ADD_WORK_EMAIL: {
2758+
route: 'workspaces/:policyID/expensify-card/:fundID/work-email',
2759+
getRoute: (policyID: string, fundID: number) => `workspaces/${policyID}/expensify-card/${encodeURIComponent(fundID)}/work-email` as const,
2760+
},
2761+
WORKSPACE_EXPENSIFY_CARD_VERIFY_WORK_EMAIL: {
2762+
route: 'workspaces/:policyID/expensify-card/:fundID/verify-work-email',
2763+
getRoute: (policyID: string, fundID: number) => `workspaces/${policyID}/expensify-card/${encodeURIComponent(fundID)}/verify-work-email` as const,
2764+
},
27582765
WORKSPACE_EXPENSIFY_CARD_SETTINGS_FREQUENCY: {
27592766
route: 'workspaces/:policyID/expensify-card/settings/frequency',
27602767
getRoute: (policyID: string) => `workspaces/${policyID}/expensify-card/settings/frequency` as const,

src/SCREENS.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -699,6 +699,8 @@ const SCREENS = {
699699
EXPENSIFY_CARD_ISSUE_NEW_CONFIRM_MAGIC_CODE: 'Workspace_ExpensifyCard_New_Confirm_Magic_Code',
700700
EXPENSIFY_CARD_NAME: 'Workspace_ExpensifyCard_Name',
701701
EXPENSIFY_CARD_SELECT_FEED: 'Workspace_ExpensifyCard_Select_Feed',
702+
EXPENSIFY_CARD_ADD_WORK_EMAIL: 'Workspace_ExpensifyCard_Add_Work_Email',
703+
EXPENSIFY_CARD_VERIFY_WORK_EMAIL: 'Workspace_ExpensifyCard_Verify_Work_Email',
702704
EXPENSIFY_CARD_LIMIT_TYPE: 'Workspace_ExpensifyCard_LimitType',
703705
EXPENSIFY_CARD_BANK_ACCOUNT: 'Workspace_ExpensifyCard_BankAccount',
704706
EXPENSIFY_CARD_SETTINGS: 'Workspace_ExpensifyCard_Settings',

src/hooks/useDefaultFundID.ts

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
11
import {useCallback} from 'react';
22
import type {OnyxCollection} from 'react-native-onyx';
3-
import {getCardSettings, getFundIdFromSettingsKey} from '@libs/CardUtils';
3+
import {
4+
getCardSettings,
5+
getFundIdFromSettingsKey,
6+
getLinkedPolicyIDsFromExpensifyCardSettings,
7+
getPreferredPolicyFromExpensifyCardSettings,
8+
isPolicyIDInLinkedExpensifyCardPolicyList,
9+
} from '@libs/CardUtils';
410
import CONST from '@src/CONST';
511
import ONYXKEYS from '@src/ONYXKEYS';
612
import type {ExpensifyCardSettings} from '@src/types/onyx';
@@ -19,12 +25,21 @@ function useDefaultFundID(policyID: string | undefined) {
1925

2026
const getDomainFundID = useCallback(
2127
(cardSettings: OnyxCollection<ExpensifyCardSettings>) => {
22-
const matchingKey = Object.entries(cardSettings ?? {}).find(
23-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
24-
([key, settings]) => settings?.preferredPolicy && settings.preferredPolicy === policyID && !key.includes(workspaceAccountID.toString()),
25-
);
28+
const eligibleEntries = Object.entries(cardSettings ?? {}).filter(([key, settings]) => !!settings && !key.includes(workspaceAccountID.toString()));
2629

27-
return getFundIdFromSettingsKey(matchingKey?.[0] ?? '');
30+
if (policyID) {
31+
const preferredMatch = eligibleEntries.find(([, settings]) => getPreferredPolicyFromExpensifyCardSettings(settings)?.toUpperCase() === policyID.toUpperCase());
32+
if (preferredMatch) {
33+
return getFundIdFromSettingsKey(preferredMatch[0]);
34+
}
35+
36+
const linkedMatch = eligibleEntries.find(([, settings]) => isPolicyIDInLinkedExpensifyCardPolicyList(getLinkedPolicyIDsFromExpensifyCardSettings(settings), policyID));
37+
if (linkedMatch) {
38+
return getFundIdFromSettingsKey(linkedMatch[0]);
39+
}
40+
}
41+
42+
return getFundIdFromSettingsKey('');
2843
},
2944
[policyID, workspaceAccountID],
3045
);

src/hooks/useExpensifyCardFeeds.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import {useCallback} from 'react';
22
import type {OnyxCollection} from 'react-native-onyx';
3+
import {getLinkedPolicyIDsFromExpensifyCardSettings, getPreferredPolicyFromExpensifyCardSettings, isPolicyIDInLinkedExpensifyCardPolicyList} from '@libs/CardUtils';
34
import ONYXKEYS from '@src/ONYXKEYS';
45
import type {ExpensifyCardSettings} from '@src/types/onyx';
56
import useOnyx from './useOnyx';
@@ -11,7 +12,9 @@ function useExpensifyCardFeeds(policyID: string | undefined) {
1112
const getAllExpensifyCardFeeds = useCallback(
1213
(cardSettings: OnyxCollection<ExpensifyCardSettings>) => {
1314
const matchingEntries = Object.entries(cardSettings ?? {}).filter(([key, settings]) => {
14-
const isDomainFeed = !!(settings?.preferredPolicy && settings.preferredPolicy === policyID);
15+
const isDomainFeed =
16+
!!(policyID && isPolicyIDInLinkedExpensifyCardPolicyList(getLinkedPolicyIDsFromExpensifyCardSettings(settings), policyID)) ||
17+
(!!policyID && getPreferredPolicyFromExpensifyCardSettings(settings)?.toUpperCase() === policyID.toUpperCase());
1518
const isWorkspaceFeed = key.includes(workspaceAccountID.toString()) && settings && Object.keys(settings).length > 1;
1619
return isDomainFeed || isWorkspaceFeed;
1720
});
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import {getAdminExpensifyCardFeedEntries, partitionExpensifyCardFeedsForSelector} from '@libs/ExpensifyCardFeedSelectorUtils';
2+
import type {ExpensifyCardFeedEntry} from '@libs/ExpensifyCardFeedSelectorUtils';
3+
import ONYXKEYS from '@src/ONYXKEYS';
4+
import useOnyx from './useOnyx';
5+
6+
function useExpensifyCardFeedsForFeedSelector(policyID: string | undefined): {
7+
primaryFeeds: ExpensifyCardFeedEntry[];
8+
otherFeeds: ExpensifyCardFeedEntry[];
9+
allFeeds: ExpensifyCardFeedEntry[];
10+
} {
11+
const [cardSettingsCollection] = useOnyx(ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS);
12+
const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
13+
14+
if (!policyID) {
15+
return {primaryFeeds: [], otherFeeds: [], allFeeds: []};
16+
}
17+
const allFeeds = getAdminExpensifyCardFeedEntries(cardSettingsCollection, policies);
18+
const {primary, other} = partitionExpensifyCardFeedsForSelector(allFeeds, policyID);
19+
return {primaryFeeds: primary, otherFeeds: other, allFeeds};
20+
}
21+
22+
export default useExpensifyCardFeedsForFeedSelector;
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import {getAdminExpensifyCardFeedEntries} from '@libs/ExpensifyCardFeedSelectorUtils';
2+
import ONYXKEYS from '@src/ONYXKEYS';
3+
import useOnyx from './useOnyx';
4+
5+
/** True when the user is an admin of at least one workspace with loaded Expensify Card program settings. */
6+
function useHasAnyAdminExpensifyCardFeed(): boolean {
7+
const [cardSettingsCollection] = useOnyx(ONYXKEYS.COLLECTION.PRIVATE_EXPENSIFY_CARD_SETTINGS);
8+
const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
9+
10+
return getAdminExpensifyCardFeedEntries(cardSettingsCollection, policies).length > 0;
11+
}
12+
13+
export default useHasAnyAdminExpensifyCardFeed;

src/libs/CardUtils.ts

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1202,6 +1202,77 @@ function getCardSettings(cardSettings: OnyxEntry<ExpensifyCardSettings>, program
12021202
);
12031203
}
12041204

1205+
/** Backend may nest linkedPolicyIDs under each program block (not only on the settings root). */
1206+
const NESTED_EXPENSIFY_CARD_PROGRAM_KEYS: readonly CardProgramKey[] = [CONST.COUNTRY.US, CONST.EXPENSIFY_CARD.CARD_PROGRAM.CURRENT, CONST.COUNTRY.GB, CONST.TRAVEL.PROGRAM_TRAVEL_US];
1207+
1208+
function getNestedExpensifyCardProgramSettings(settings: ExpensifyCardSettings, key: CardProgramKey): ExpensifyCardSettingsBase | undefined {
1209+
const nested = settings[key];
1210+
if (nested && typeof nested === 'object' && !Array.isArray(nested)) {
1211+
return nested;
1212+
}
1213+
return undefined;
1214+
}
1215+
1216+
function collectLinkedPolicyIDsFromBase(base: ExpensifyCardSettingsBase | undefined): string[] {
1217+
if (!base) {
1218+
return [];
1219+
}
1220+
return base.linkedPolicyIDs ?? [];
1221+
}
1222+
1223+
function dedupePolicyIDsCaseInsensitive(ids: string[]): string[] {
1224+
const seen = new Set<string>();
1225+
const result: string[] = [];
1226+
for (const id of ids) {
1227+
const key = id.toUpperCase();
1228+
if (!seen.has(key)) {
1229+
seen.add(key);
1230+
result.push(id);
1231+
}
1232+
}
1233+
return result;
1234+
}
1235+
1236+
/**
1237+
* Linked workspace IDs from the settings root and US / CURRENT / GB / TRAVEL_US nests.
1238+
* Deduplicates case-insensitively; keeps API casing for Onyx lookups.
1239+
*/
1240+
function getLinkedPolicyIDsFromExpensifyCardSettings(settings: ExpensifyCardSettings | OnyxEntry<ExpensifyCardSettings>): string[] | undefined {
1241+
if (!settings) {
1242+
return undefined;
1243+
}
1244+
const ids: string[] = [...collectLinkedPolicyIDsFromBase(settings as ExpensifyCardSettingsBase)];
1245+
for (const key of NESTED_EXPENSIFY_CARD_PROGRAM_KEYS) {
1246+
ids.push(...collectLinkedPolicyIDsFromBase(getNestedExpensifyCardProgramSettings(settings, key)));
1247+
}
1248+
if (ids.length === 0) {
1249+
return undefined;
1250+
}
1251+
return dedupePolicyIDsCaseInsensitive(ids);
1252+
}
1253+
1254+
/** True if `policyID` is in the linked list (case-insensitive). */
1255+
function isPolicyIDInLinkedExpensifyCardPolicyList(linkedPolicyIDs: string[] | undefined, policyID: string): boolean {
1256+
return !!linkedPolicyIDs?.some((id) => id.toUpperCase() === policyID.toUpperCase());
1257+
}
1258+
1259+
/** Resolves preferredPolicy from the settings root or the first nested program block that defines it. */
1260+
function getPreferredPolicyFromExpensifyCardSettings(settings: ExpensifyCardSettings | OnyxEntry<ExpensifyCardSettings>): string | undefined {
1261+
if (!settings) {
1262+
return undefined;
1263+
}
1264+
if (settings.preferredPolicy) {
1265+
return settings.preferredPolicy;
1266+
}
1267+
for (const key of NESTED_EXPENSIFY_CARD_PROGRAM_KEYS) {
1268+
const preferred = getNestedExpensifyCardProgramSettings(settings, key)?.preferredPolicy;
1269+
if (preferred) {
1270+
return preferred;
1271+
}
1272+
}
1273+
return undefined;
1274+
}
1275+
12051276
function isCardPendingIssue(card?: Card) {
12061277
return card?.state === CONST.EXPENSIFY_CARD.STATE.STATE_NOT_ISSUED;
12071278
}
@@ -1684,6 +1755,9 @@ export {
16841755
isExpensifyCardFullySetUp,
16851756
getCardSettings,
16861757
getCardProgramKey,
1758+
getLinkedPolicyIDsFromExpensifyCardSettings,
1759+
getPreferredPolicyFromExpensifyCardSettings,
1760+
isPolicyIDInLinkedExpensifyCardPolicyList,
16871761
filterAllInactiveCards,
16881762
filterInactiveCards,
16891763
getPersonalBankCardDetailsImage,
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
2+
import ONYXKEYS from '@src/ONYXKEYS';
3+
import type {ExpensifyCardSettings, Policy} from '@src/types/onyx';
4+
import {
5+
getCardSettings,
6+
getFundIdFromSettingsKey,
7+
getLinkedPolicyIDsFromExpensifyCardSettings,
8+
getPreferredPolicyFromExpensifyCardSettings,
9+
isPolicyIDInLinkedExpensifyCardPolicyList,
10+
} from './CardUtils';
11+
import {getDescriptionForPolicyDomainCard, isPolicyAdmin} from './PolicyUtils';
12+
13+
type ExpensifyCardFeedEntry = {
14+
settingsKey: string;
15+
fundID: number;
16+
settings: ExpensifyCardSettings;
17+
};
18+
19+
function hasLoadedExpensifyCardSettings(settings: ExpensifyCardSettings | undefined): boolean {
20+
return !!settings && Object.keys(settings).length > 1;
21+
}
22+
23+
function isExpensifyCardFeedVisibleToAdmin(settings: ExpensifyCardSettings, policies: OnyxCollection<Policy> | undefined): boolean {
24+
if (!hasLoadedExpensifyCardSettings(settings)) {
25+
return false;
26+
}
27+
const linkedPolicyIDs = getLinkedPolicyIDsFromExpensifyCardSettings(settings);
28+
if (linkedPolicyIDs?.length) {
29+
return linkedPolicyIDs.some((linkedPolicyID) => isPolicyAdmin(policies?.[`${ONYXKEYS.COLLECTION.POLICY}${linkedPolicyID.toUpperCase()}`]));
30+
}
31+
const preferredPolicy = getPreferredPolicyFromExpensifyCardSettings(settings);
32+
if (!preferredPolicy) {
33+
return false;
34+
}
35+
const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${preferredPolicy.toUpperCase()}`];
36+
return isPolicyAdmin(policy);
37+
}
38+
39+
function isFeedLinkedToPolicy(entry: ExpensifyCardFeedEntry, policyID: string): boolean {
40+
return isPolicyIDInLinkedExpensifyCardPolicyList(getLinkedPolicyIDsFromExpensifyCardSettings(entry.settings), policyID);
41+
}
42+
43+
function isFeedForCurrentWorkspace(entry: ExpensifyCardFeedEntry, policyID: string): boolean {
44+
const preferred = getPreferredPolicyFromExpensifyCardSettings(entry.settings);
45+
return preferred?.toUpperCase() === policyID.toUpperCase();
46+
}
47+
48+
/** Primary vs other: use linkedPolicyIDs when present; otherwise preferredPolicy (legacy). */
49+
function isFeedPrimaryForPolicy(entry: ExpensifyCardFeedEntry, policyID: string): boolean {
50+
const linked = getLinkedPolicyIDsFromExpensifyCardSettings(entry.settings);
51+
if (linked?.length) {
52+
return isFeedLinkedToPolicy(entry, policyID);
53+
}
54+
return isFeedForCurrentWorkspace(entry, policyID);
55+
}
56+
57+
function getAdminExpensifyCardFeedEntries(cardSettingsCollection: OnyxCollection<ExpensifyCardSettings> | undefined, policies: OnyxCollection<Policy> | undefined): ExpensifyCardFeedEntry[] {
58+
return Object.entries(cardSettingsCollection ?? {}).flatMap(([settingsKey, settings]) => {
59+
if (!settings) {
60+
return [];
61+
}
62+
const fundID = getFundIdFromSettingsKey(settingsKey);
63+
if (!isExpensifyCardFeedVisibleToAdmin(settings, policies)) {
64+
return [];
65+
}
66+
return [{settingsKey, fundID, settings}];
67+
});
68+
}
69+
70+
function partitionExpensifyCardFeedsForSelector(entries: ExpensifyCardFeedEntry[], policyID: string): {primary: ExpensifyCardFeedEntry[]; other: ExpensifyCardFeedEntry[]} {
71+
if (entries.length === 0) {
72+
return {primary: [], other: []};
73+
}
74+
const primary = entries.filter((e) => isFeedPrimaryForPolicy(e, policyID));
75+
const other = entries.filter((e) => !isFeedPrimaryForPolicy(e, policyID));
76+
return {primary, other};
77+
}
78+
79+
function getExpensifyCardFeedDescription(cardSettings: OnyxEntry<ExpensifyCardSettings>, policies: OnyxCollection<Policy>): string {
80+
const domainName = getCardSettings(cardSettings)?.domainName ?? '';
81+
if (domainName) {
82+
return getDescriptionForPolicyDomainCard(domainName, policies);
83+
}
84+
const linkedPolicyIDs = getLinkedPolicyIDsFromExpensifyCardSettings(cardSettings);
85+
const preferredPolicyID = getPreferredPolicyFromExpensifyCardSettings(cardSettings);
86+
const policyIDForName = linkedPolicyIDs?.length ? linkedPolicyIDs.at(0) : preferredPolicyID;
87+
return (policyIDForName && policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyIDForName.toUpperCase()}`]?.name) ?? '';
88+
}
89+
90+
export {getAdminExpensifyCardFeedEntries, getExpensifyCardFeedDescription, partitionExpensifyCardFeedsForSelector, type ExpensifyCardFeedEntry};

src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -835,7 +835,10 @@ const SettingsModalStackNavigator = createModalStackNavigator<SettingsNavigatorP
835835
[SCREENS.WORKSPACE.TRAVEL_SETTINGS_FREQUENCY]: () => require<ReactComponentModule>('../../../../pages/workspace/travel/WorkspaceTravelInvoicingSettlementFrequencyPage').default,
836836
[SCREENS.WORKSPACE.TRAVEL_EXPORT]: () => require<ReactComponentModule>('../../../../pages/workspace/travel/WorkspaceTravelInvoicingExportPage').default,
837837
[SCREENS.WORKSPACE.TRAVEL_MISSING_PERSONAL_DETAILS]: () => require<ReactComponentModule>('../../../../pages/Travel/TravelLegalNamePage').default,
838-
[SCREENS.WORKSPACE.EXPENSIFY_CARD_SELECT_FEED]: () => require<ReactComponentModule>('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardSelectorPage').default,
838+
[SCREENS.WORKSPACE.EXPENSIFY_CARD_SELECT_FEED]: () => require<ReactComponentModule>('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardFeedSelectorPage').default,
839+
[SCREENS.WORKSPACE.EXPENSIFY_CARD_ADD_WORK_EMAIL]: () => require<ReactComponentModule>('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardAddWorkEmailPage').default,
840+
[SCREENS.WORKSPACE.EXPENSIFY_CARD_VERIFY_WORK_EMAIL]: () =>
841+
require<ReactComponentModule>('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardVerifyWorkAccountPage').default,
839842
[SCREENS.WORKSPACE.EXPENSIFY_CARD_BANK_ACCOUNT]: () => require<ReactComponentModule>('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardBankAccounts').default,
840843
[SCREENS.WORKSPACE.EXPENSIFY_CARD_DETAILS]: () => require<ReactComponentModule>('../../../../pages/workspace/expensifyCard/WorkspaceExpensifyCardDetailsPage').default,
841844
[SCREENS.WORKSPACE.EXPENSIFY_CARD_NAME]: () => require<ReactComponentModule>('../../../../pages/workspace/expensifyCard/WorkspaceEditCardNamePage').default,

src/libs/Navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,8 @@ const WORKSPACE_TO_RHP: Partial<Record<keyof WorkspaceSplitNavigatorParamList, s
285285
SCREENS.WORKSPACE.EXPENSIFY_CARD_LIMIT,
286286
SCREENS.WORKSPACE.EXPENSIFY_CARD_LIMIT_TYPE,
287287
SCREENS.WORKSPACE.EXPENSIFY_CARD_SELECT_FEED,
288+
SCREENS.WORKSPACE.EXPENSIFY_CARD_ADD_WORK_EMAIL,
289+
SCREENS.WORKSPACE.EXPENSIFY_CARD_VERIFY_WORK_EMAIL,
288290
],
289291
[SCREENS.WORKSPACE.RULES]: [
290292
SCREENS.WORKSPACE.RULES_RECEIPT_REQUIRED_AMOUNT,

0 commit comments

Comments
 (0)