diff --git a/assets/images/companyCards/pending-bank.svg b/assets/images/companyCards/pending-bank.svg
new file mode 100644
index 000000000000..dc265466d53f
--- /dev/null
+++ b/assets/images/companyCards/pending-bank.svg
@@ -0,0 +1,263 @@
+
+
diff --git a/assets/images/gallery-not-found.svg b/assets/images/gallery-not-found.svg
new file mode 100644
index 000000000000..25da973ce9cb
--- /dev/null
+++ b/assets/images/gallery-not-found.svg
@@ -0,0 +1,18 @@
+
+
\ No newline at end of file
diff --git a/src/CONST.ts b/src/CONST.ts
index 84003710938a..8134acabc0d7 100755
--- a/src/CONST.ts
+++ b/src/CONST.ts
@@ -471,6 +471,19 @@ const CONST = {
PERSONAL: 'PERSONAL',
},
},
+ NON_USD_BANK_ACCOUNT: {
+ STEP: {
+ COUNTRY: 'CountryStep',
+ BANK_INFO: 'BankInfoStep',
+ BUSINESS_INFO: 'BusinessInfoStep',
+ BENEFICIAL_OWNER_INFO: 'BeneficialOwnerInfoStep',
+ SIGNER_INFO: 'SignerInfoStep',
+ AGREEMENTS: 'AgreementsStep',
+ FINISH: 'FinishStep',
+ },
+ STEP_NAMES: ['1', '2', '3', '4', '5', '6'],
+ STEP_HEADER_HEIGHT: 40,
+ },
INCORPORATION_TYPES: {
LLC: 'LLC',
CORPORATION: 'Corp',
@@ -1130,9 +1143,6 @@ const CONST = {
SEARCH_OPTION_LIST_DEBOUNCE_TIME: 300,
RESIZE_DEBOUNCE_TIME: 100,
UNREAD_UPDATE_DEBOUNCE_TIME: 300,
- SEARCH_CONVERT_SEARCH_VALUES: 'search_convert_search_values',
- SEARCH_MAKE_TREE: 'search_make_tree',
- SEARCH_BUILD_TREE: 'search_build_tree',
SEARCH_FILTER_OPTIONS: 'search_filter_options',
USE_DEBOUNCED_STATE_DELAY: 300,
},
@@ -2529,6 +2539,13 @@ const CONST = {
VISA: 'vcf',
AMEX: 'gl1025',
STRIPE: 'stripe',
+ CITIBANK: 'oauth.citibank.com',
+ CAPITAL_ONE: 'oauth.capitalone.com',
+ BANK_OF_AMERICA: 'oauth.bankofamerica.com',
+ CHASE: 'oauth.chase.com',
+ BREX: 'oauth.brex.com',
+ WELLS_FARGO: 'oauth.wellsfargo.com',
+ AMEX_DIRECT: 'oauth.americanexpressfdx.com',
},
STEP_NAMES: ['1', '2', '3', '4'],
STEP: {
@@ -2616,6 +2633,14 @@ const CONST = {
WELLS_FARGO: 'Wells Fargo',
OTHER: 'Other',
},
+ BANK_CONNECTIONS: {
+ WELLS_FARGO: 'wellsfargo',
+ CHASE: 'chase',
+ BREX: 'brex',
+ CAPITAL_ONE: 'capitalone',
+ CITI_BANK: 'citibank',
+ AMEX: 'americanexpressfdx',
+ },
AMEX_CUSTOM_FEED: {
CORPORATE: 'American Express Corporate Cards',
BUSINESS: 'American Express Business Cards',
@@ -5905,6 +5930,21 @@ const CONST = {
// The timeout duration (1 minute) (in milliseconds) before the window reloads due to an error.
ERROR_WINDOW_RELOAD_TIMEOUT: 60000,
+ INDICATOR_STATUS: {
+ HAS_USER_WALLET_ERRORS: 'hasUserWalletErrors',
+ HAS_PAYMENT_METHOD_ERROR: 'hasPaymentMethodError',
+ HAS_POLICY_ERRORS: 'hasPolicyError',
+ HAS_CUSTOM_UNITS_ERROR: 'hasCustomUnitsError',
+ HAS_EMPLOYEE_LIST_ERROR: 'hasEmployeeListError',
+ HAS_SYNC_ERRORS: 'hasSyncError',
+ HAS_SUBSCRIPTION_ERRORS: 'hasSubscriptionError',
+ HAS_REIMBURSEMENT_ACCOUNT_ERRORS: 'hasReimbursementAccountErrors',
+ HAS_LOGIN_LIST_ERROR: 'hasLoginListError',
+ HAS_WALLET_TERMS_ERRORS: 'hasWalletTermsErrors',
+ HAS_LOGIN_LIST_INFO: 'hasLoginListInfo',
+ HAS_SUBSCRIPTION_INFO: 'hasSubscriptionInfo',
+ },
+
DEBUG: {
DETAILS: 'details',
JSON: 'json',
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 30c7196f19b4..cf15013fed9b 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -21,6 +21,7 @@ const PUBLIC_SCREENS_ROUTES = {
ROOT: '',
TRANSITION_BETWEEN_APPS: 'transition',
CONNECTION_COMPLETE: 'connection-complete',
+ BANK_CONNECTION_COMPLETE: 'bank-connection-complete',
VALIDATE_LOGIN: 'v/:accountID/:validateCode',
UNLINK_LOGIN: 'u/:accountID/:validateCode',
APPLE_SIGN_IN: 'sign-in-with-apple',
diff --git a/src/components/AttachmentOfflineIndicator.tsx b/src/components/AttachmentOfflineIndicator.tsx
index d425e6f18e0e..4ff1940ba004 100644
--- a/src/components/AttachmentOfflineIndicator.tsx
+++ b/src/components/AttachmentOfflineIndicator.tsx
@@ -37,7 +37,7 @@ function AttachmentOfflineIndicator({isPreview = false}: AttachmentOfflineIndica
return (
setHasLoadFailed(true)}
+ onMeasure={() => setHasLoadFailed(false)}
+ fallbackIconBackground={theme.highlightBG}
+ fallbackIconColor={theme.border}
/>
);
@@ -102,6 +110,7 @@ function ImageRenderer({tnode}: ImageRendererProps) {
shouldUseHapticsOnLongPress
accessibilityRole={CONST.ROLE.BUTTON}
accessibilityLabel={translate('accessibilityHints.viewAttachment')}
+ disabled={hasLoadFailed}
>
{thumbnailImageComponent}
diff --git a/src/components/Icon/Expensicons.ts b/src/components/Icon/Expensicons.ts
index cd9c97105ff0..90f0e0d8a151 100644
--- a/src/components/Icon/Expensicons.ts
+++ b/src/components/Icon/Expensicons.ts
@@ -93,6 +93,7 @@ import FlagLevelTwo from '@assets/images/flag_level_02.svg';
import FlagLevelThree from '@assets/images/flag_level_03.svg';
import Folder from '@assets/images/folder.svg';
import Fullscreen from '@assets/images/fullscreen.svg';
+import GalleryNotFound from '@assets/images/gallery-not-found.svg';
import Gallery from '@assets/images/gallery.svg';
import Gear from '@assets/images/gear.svg';
import Globe from '@assets/images/globe.svg';
@@ -404,4 +405,5 @@ export {
Bookmark,
Star,
QBDSquare,
+ GalleryNotFound,
};
diff --git a/src/components/Icon/Illustrations.ts b/src/components/Icon/Illustrations.ts
index bae8f6af1ab2..18ae1792686f 100644
--- a/src/components/Icon/Illustrations.ts
+++ b/src/components/Icon/Illustrations.ts
@@ -12,6 +12,7 @@ import WellsFargoCompanyCardDetail from '@assets/images/companyCards/card-wellsf
import OtherCompanyCardDetail from '@assets/images/companyCards/card=-generic.svg';
import CompanyCardsEmptyState from '@assets/images/companyCards/emptystate__card-pos.svg';
import MasterCardCompanyCards from '@assets/images/companyCards/mastercard.svg';
+import PendingBank from '@assets/images/companyCards/pending-bank.svg';
import CompanyCardsPendingState from '@assets/images/companyCards/pendingstate_laptop-with-hourglass-and-cards.svg';
import VisaCompanyCards from '@assets/images/companyCards/visa.svg';
import EmptyCardState from '@assets/images/emptystate__expensifycard.svg';
@@ -207,6 +208,7 @@ export {
Approval,
WalletAlt,
Workflows,
+ PendingBank,
ThreeLeggedLaptopWoman,
House,
Alert,
diff --git a/src/components/Indicator.tsx b/src/components/Indicator.tsx
index 4d352b6a6cde..105399936b43 100644
--- a/src/components/Indicator.tsx
+++ b/src/components/Indicator.tsx
@@ -1,109 +1,17 @@
import React from 'react';
import {StyleSheet, View} from 'react-native';
-import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
-import {useOnyx, withOnyx} from 'react-native-onyx';
-import useTheme from '@hooks/useTheme';
+import useIndicatorStatus from '@hooks/useIndicatorStatus';
import useThemeStyles from '@hooks/useThemeStyles';
-import {isConnectionInProgress} from '@libs/actions/connections';
-import * as PolicyUtils from '@libs/PolicyUtils';
-import * as SubscriptionUtils from '@libs/SubscriptionUtils';
-import * as UserUtils from '@libs/UserUtils';
-import * as PaymentMethods from '@userActions/PaymentMethods';
-import ONYXKEYS from '@src/ONYXKEYS';
-import type {BankAccountList, FundList, LoginList, Policy, ReimbursementAccount, UserWallet, WalletTerms} from '@src/types/onyx';
-type CheckingMethod = () => boolean;
-
-type IndicatorOnyxProps = {
- /** All the user's policies (from Onyx via withFullPolicy) */
- policies: OnyxCollection;
-
- /** List of bank accounts */
- bankAccountList: OnyxEntry;
-
- /** List of user cards */
- fundList: OnyxEntry;
-
- /** The user's wallet (coming from Onyx) */
- userWallet: OnyxEntry;
-
- /** Bank account attached to free plan */
- reimbursementAccount: OnyxEntry;
-
- /** Information about the user accepting the terms for payments */
- walletTerms: OnyxEntry;
-
- /** Login list for the user that is signed in */
- loginList: OnyxEntry;
-};
-
-type IndicatorProps = IndicatorOnyxProps;
-
-function Indicator({reimbursementAccount, policies, bankAccountList, fundList, userWallet, walletTerms, loginList}: IndicatorOnyxProps) {
- const theme = useTheme();
+function Indicator() {
const styles = useThemeStyles();
- const [allConnectionSyncProgresses] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}`);
-
- // If a policy was just deleted from Onyx, then Onyx will pass a null value to the props, and
- // those should be cleaned out before doing any error checking
- const cleanPolicies = Object.fromEntries(Object.entries(policies ?? {}).filter(([, policy]) => policy?.id));
-
- // All of the error & info-checking methods are put into an array. This is so that using _.some() will return
- // early as soon as the first error / info condition is returned. This makes the checks very efficient since
- // we only care if a single error / info condition exists anywhere.
- const errorCheckingMethods: CheckingMethod[] = [
- () => Object.keys(userWallet?.errors ?? {}).length > 0,
- () => PaymentMethods.hasPaymentMethodError(bankAccountList, fundList),
- () => Object.values(cleanPolicies).some(PolicyUtils.hasPolicyError),
- () => Object.values(cleanPolicies).some(PolicyUtils.hasCustomUnitsError),
- () => Object.values(cleanPolicies).some(PolicyUtils.hasEmployeeListError),
- () =>
- Object.values(cleanPolicies).some((cleanPolicy) =>
- PolicyUtils.hasSyncError(
- cleanPolicy,
- isConnectionInProgress(allConnectionSyncProgresses?.[`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${cleanPolicy?.id}`], cleanPolicy),
- ),
- ),
- () => SubscriptionUtils.hasSubscriptionRedDotError(),
- () => Object.keys(reimbursementAccount?.errors ?? {}).length > 0,
- () => !!loginList && UserUtils.hasLoginListError(loginList),
-
- // Wallet term errors that are not caused by an IOU (we show the red brick indicator for those in the LHN instead)
- () => Object.keys(walletTerms?.errors ?? {}).length > 0 && !walletTerms?.chatReportID,
- ];
- const infoCheckingMethods: CheckingMethod[] = [() => !!loginList && UserUtils.hasLoginListInfo(loginList), () => SubscriptionUtils.hasSubscriptionGreenDotInfo()];
- const shouldShowErrorIndicator = errorCheckingMethods.some((errorCheckingMethod) => errorCheckingMethod());
- const shouldShowInfoIndicator = !shouldShowErrorIndicator && infoCheckingMethods.some((infoCheckingMethod) => infoCheckingMethod());
+ const {indicatorColor, status} = useIndicatorStatus();
- const indicatorColor = shouldShowErrorIndicator ? theme.danger : theme.success;
const indicatorStyles = [styles.alignItemsCenter, styles.justifyContentCenter, styles.statusIndicator(indicatorColor)];
- return (shouldShowErrorIndicator || shouldShowInfoIndicator) && ;
+ return !!status && ;
}
Indicator.displayName = 'Indicator';
-export default withOnyx({
- policies: {
- key: ONYXKEYS.COLLECTION.POLICY,
- },
- bankAccountList: {
- key: ONYXKEYS.BANK_ACCOUNT_LIST,
- },
- // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM
- reimbursementAccount: {
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- },
- fundList: {
- key: ONYXKEYS.FUND_LIST,
- },
- userWallet: {
- key: ONYXKEYS.USER_WALLET,
- },
- walletTerms: {
- key: ONYXKEYS.WALLET_TERMS,
- },
- loginList: {
- key: ONYXKEYS.LOGIN_LIST,
- },
-})(Indicator);
+export default Indicator;
diff --git a/src/components/KYCWall/BaseKYCWall.tsx b/src/components/KYCWall/BaseKYCWall.tsx
index fd681546c470..7d1e6614c716 100644
--- a/src/components/KYCWall/BaseKYCWall.tsx
+++ b/src/components/KYCWall/BaseKYCWall.tsx
@@ -1,8 +1,7 @@
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {Dimensions} from 'react-native';
import type {EmitterSubscription, GestureResponderEvent, View} from 'react-native';
-import {withOnyx} from 'react-native-onyx';
-import type {OnyxEntry} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
import AddPaymentMethodMenu from '@components/AddPaymentMethodMenu';
import * as BankAccounts from '@libs/actions/BankAccounts';
import getClickedTargetLocation from '@libs/getClickedTargetLocation';
@@ -16,7 +15,6 @@ import * as Wallet from '@userActions/Wallet';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
-import type {BankAccountList, FundList, ReimbursementAccount, UserWallet, WalletTerms} from '@src/types/onyx';
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
import viewRef from '@src/types/utils/viewRef';
import type {AnchorPosition, DomRect, KYCWallProps, PaymentMethod} from './types';
@@ -24,25 +22,6 @@ import type {AnchorPosition, DomRect, KYCWallProps, PaymentMethod} from './types
// This sets the Horizontal anchor position offset for POPOVER MENU.
const POPOVER_MENU_ANCHOR_POSITION_HORIZONTAL_OFFSET = 20;
-type BaseKYCWallOnyxProps = {
- /** The user's wallet */
- userWallet: OnyxEntry;
-
- /** Information related to the last step of the wallet activation flow */
- walletTerms: OnyxEntry;
-
- /** List of user's cards */
- fundList: OnyxEntry;
-
- /** List of bank accounts */
- bankAccountList: OnyxEntry;
-
- /** The reimbursement account linked to the Workspace */
- reimbursementAccount: OnyxEntry;
-};
-
-type BaseKYCWallProps = KYCWallProps & BaseKYCWallOnyxProps;
-
// This component allows us to block various actions by forcing the user to first add a default payment method and successfully make it through our Know Your Customer flow
// before continuing to take whatever action they originally intended to take. It requires a button as a child and a native event so we can get the coordinates and use it
// to render the AddPaymentMethodMenu in the correct location.
@@ -53,22 +32,23 @@ function KYCWall({
horizontal: CONST.MODAL.ANCHOR_ORIGIN_HORIZONTAL.LEFT,
vertical: CONST.MODAL.ANCHOR_ORIGIN_VERTICAL.BOTTOM,
},
- bankAccountList = {},
chatReportID = '',
children,
enablePaymentsRoute,
- fundList,
iouReport,
onSelectPaymentMethod = () => {},
onSuccessfulKYC,
- reimbursementAccount,
shouldIncludeDebitCard = true,
shouldListenForResize = false,
source,
- userWallet,
- walletTerms,
shouldShowPersonalBankAccountOption = false,
-}: BaseKYCWallProps) {
+}: KYCWallProps) {
+ const [userWallet] = useOnyx(ONYXKEYS.USER_WALLET);
+ const [walletTerms] = useOnyx(ONYXKEYS.WALLET_TERMS);
+ const [fundList] = useOnyx(ONYXKEYS.FUND_LIST);
+ const [bankAccountList = {}] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST);
+ const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
+
const anchorRef = useRef(null);
const transferBalanceButtonRef = useRef(null);
@@ -270,21 +250,4 @@ function KYCWall({
KYCWall.displayName = 'BaseKYCWall';
-export default withOnyx({
- userWallet: {
- key: ONYXKEYS.USER_WALLET,
- },
- walletTerms: {
- key: ONYXKEYS.WALLET_TERMS,
- },
- fundList: {
- key: ONYXKEYS.FUND_LIST,
- },
- bankAccountList: {
- key: ONYXKEYS.BANK_ACCOUNT_LIST,
- },
- // @ts-expect-error: ONYXKEYS.REIMBURSEMENT_ACCOUNT is conflicting with ONYXKEYS.FORMS.REIMBURSEMENT_ACCOUNT_FORM
- reimbursementAccount: {
- key: ONYXKEYS.REIMBURSEMENT_ACCOUNT,
- },
-})(KYCWall);
+export default KYCWall;
diff --git a/src/components/Search/SearchRouter/SearchRouter.tsx b/src/components/Search/SearchRouter/SearchRouter.tsx
index fb7aac45043e..1f6a3824d751 100644
--- a/src/components/Search/SearchRouter/SearchRouter.tsx
+++ b/src/components/Search/SearchRouter/SearchRouter.tsx
@@ -12,7 +12,6 @@ import useKeyboardShortcut from '@hooks/useKeyboardShortcut';
import useLocalize from '@hooks/useLocalize';
import useResponsiveLayout from '@hooks/useResponsiveLayout';
import useThemeStyles from '@hooks/useThemeStyles';
-import FastSearch from '@libs/FastSearch';
import Log from '@libs/Log';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import {getAllTaxRates} from '@libs/PolicyUtils';
@@ -64,49 +63,6 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
return OptionsListUtils.getSearchOptions(options, '', betas ?? []);
}, [areOptionsInitialized, betas, options]);
- /**
- * Builds a suffix tree and returns a function to search in it.
- */
- const findInSearchTree = useMemo(() => {
- const fastSearch = FastSearch.createFastSearch([
- {
- data: searchOptions.personalDetails,
- toSearchableString: (option) => {
- const displayName = option.participantsList?.[0]?.displayName ?? '';
- return [option.login ?? '', option.login !== displayName ? displayName : ''].join();
- },
- },
- {
- data: searchOptions.recentReports,
- toSearchableString: (option) => {
- const searchStringForTree = [option.text ?? '', option.login ?? ''];
-
- if (option.isThread) {
- if (option.alternateText) {
- searchStringForTree.push(option.alternateText);
- }
- } else if (!!option.isChatRoom || !!option.isPolicyExpenseChat) {
- if (option.subtitle) {
- searchStringForTree.push(option.subtitle);
- }
- }
-
- return searchStringForTree.join();
- },
- },
- ]);
- function search(searchInput: string) {
- const [personalDetails, recentReports] = fastSearch.search(searchInput);
-
- return {
- personalDetails,
- recentReports,
- };
- }
-
- return search;
- }, [searchOptions.personalDetails, searchOptions.recentReports]);
-
const filteredOptions = useMemo(() => {
if (debouncedInputValue.trim() === '') {
return {
@@ -117,25 +73,15 @@ function SearchRouter({onRouterClose}: SearchRouterProps) {
}
Timing.start(CONST.TIMING.SEARCH_FILTER_OPTIONS);
- const newOptions = findInSearchTree(debouncedInputValue);
+ const newOptions = OptionsListUtils.filterOptions(searchOptions, debouncedInputValue, {sortByReportTypeInSearch: true, preferChatroomsOverThreads: true});
Timing.end(CONST.TIMING.SEARCH_FILTER_OPTIONS);
- const recentReports = newOptions.recentReports.concat(newOptions.personalDetails);
-
- const userToInvite = OptionsListUtils.pickUserToInvite({
- canInviteUser: true,
+ return {
recentReports: newOptions.recentReports,
personalDetails: newOptions.personalDetails,
- searchValue: debouncedInputValue,
- optionsToExclude: [{login: CONST.EMAIL.NOTIFICATIONS}],
- });
-
- return {
- recentReports,
- personalDetails: [],
- userToInvite,
+ userToInvite: newOptions.userToInvite,
};
- }, [debouncedInputValue, findInSearchTree]);
+ }, [debouncedInputValue, searchOptions]);
const recentReports: OptionData[] = useMemo(() => {
if (debouncedInputValue === '') {
diff --git a/src/components/ThumbnailImage.tsx b/src/components/ThumbnailImage.tsx
index cea528e4537c..f283058042eb 100644
--- a/src/components/ThumbnailImage.tsx
+++ b/src/components/ThumbnailImage.tsx
@@ -55,6 +55,12 @@ type ThumbnailImageProps = {
/** The object position of image */
objectPosition?: ImageObjectPosition;
+
+ /** Callback fired when the image fails to load */
+ onLoadFailure?: () => void;
+
+ /** Callback fired when the image has been measured */
+ onMeasure?: () => void;
};
type UpdateImageSizeParams = {
@@ -75,6 +81,8 @@ function ThumbnailImage({
fallbackIconColor,
fallbackIconBackground,
objectPosition = CONST.IMAGE_OBJECT_POSITION.INITIAL,
+ onLoadFailure,
+ onMeasure,
}: ThumbnailImageProps) {
const styles = useThemeStyles();
const theme = useTheme();
@@ -137,8 +145,14 @@ function ThumbnailImage({
setFailedToLoad(true)}
+ onMeasure={(args) => {
+ updateImageSize(args);
+ onMeasure?.();
+ }}
+ onLoadFailure={() => {
+ setFailedToLoad(true);
+ onLoadFailure?.();
+ }}
isAuthTokenRequired={isAuthTokenRequired}
objectPosition={objectPosition}
/>
diff --git a/src/hooks/useIndicatorStatus.ts b/src/hooks/useIndicatorStatus.ts
new file mode 100644
index 000000000000..b026bc52fd7b
--- /dev/null
+++ b/src/hooks/useIndicatorStatus.ts
@@ -0,0 +1,79 @@
+import {useMemo} from 'react';
+import {useOnyx} from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
+import {isConnectionInProgress} from '@libs/actions/connections';
+import * as PolicyUtils from '@libs/PolicyUtils';
+import * as SubscriptionUtils from '@libs/SubscriptionUtils';
+import * as UserUtils from '@libs/UserUtils';
+import * as PaymentMethods from '@userActions/PaymentMethods';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import useTheme from './useTheme';
+
+type IndicatorStatus = ValueOf;
+
+type IndicatorStatusResult = {
+ indicatorColor: string;
+ status: ValueOf | undefined;
+ policyIDWithErrors: string | undefined;
+};
+
+function useIndicatorStatus(): IndicatorStatusResult {
+ const theme = useTheme();
+ const [allConnectionSyncProgresses] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS);
+ const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY);
+ const [bankAccountList] = useOnyx(ONYXKEYS.BANK_ACCOUNT_LIST);
+ const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
+ const [fundList] = useOnyx(ONYXKEYS.FUND_LIST);
+ const [userWallet] = useOnyx(ONYXKEYS.USER_WALLET);
+ const [walletTerms] = useOnyx(ONYXKEYS.WALLET_TERMS);
+ const [loginList] = useOnyx(ONYXKEYS.LOGIN_LIST);
+
+ // If a policy was just deleted from Onyx, then Onyx will pass a null value to the props, and
+ // those should be cleaned out before doing any error checking
+ const cleanPolicies = useMemo(() => Object.fromEntries(Object.entries(policies ?? {}).filter(([, policy]) => policy?.id)), [policies]);
+
+ const policyErrors = {
+ [CONST.INDICATOR_STATUS.HAS_POLICY_ERRORS]: Object.values(cleanPolicies).find(PolicyUtils.hasPolicyError),
+ [CONST.INDICATOR_STATUS.HAS_CUSTOM_UNITS_ERROR]: Object.values(cleanPolicies).find(PolicyUtils.hasCustomUnitsError),
+ [CONST.INDICATOR_STATUS.HAS_EMPLOYEE_LIST_ERROR]: Object.values(cleanPolicies).find(PolicyUtils.hasEmployeeListError),
+ [CONST.INDICATOR_STATUS.HAS_SYNC_ERRORS]: Object.values(cleanPolicies).find((cleanPolicy) =>
+ PolicyUtils.hasSyncError(
+ cleanPolicy,
+ isConnectionInProgress(allConnectionSyncProgresses?.[`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${cleanPolicy?.id}`], cleanPolicy),
+ ),
+ ),
+ };
+
+ // All of the error & info-checking methods are put into an array. This is so that using _.some() will return
+ // early as soon as the first error / info condition is returned. This makes the checks very efficient since
+ // we only care if a single error / info condition exists anywhere.
+ const errorChecking: Partial> = {
+ [CONST.INDICATOR_STATUS.HAS_USER_WALLET_ERRORS]: Object.keys(userWallet?.errors ?? {}).length > 0,
+ [CONST.INDICATOR_STATUS.HAS_PAYMENT_METHOD_ERROR]: PaymentMethods.hasPaymentMethodError(bankAccountList, fundList),
+ ...(Object.fromEntries(Object.entries(policyErrors).map(([error, policy]) => [error, !!policy])) as Record),
+ [CONST.INDICATOR_STATUS.HAS_SUBSCRIPTION_ERRORS]: SubscriptionUtils.hasSubscriptionRedDotError(),
+ [CONST.INDICATOR_STATUS.HAS_REIMBURSEMENT_ACCOUNT_ERRORS]: Object.keys(reimbursementAccount?.errors ?? {}).length > 0,
+ [CONST.INDICATOR_STATUS.HAS_LOGIN_LIST_ERROR]: !!loginList && UserUtils.hasLoginListError(loginList),
+ // Wallet term errors that are not caused by an IOU (we show the red brick indicator for those in the LHN instead)
+ [CONST.INDICATOR_STATUS.HAS_WALLET_TERMS_ERRORS]: Object.keys(walletTerms?.errors ?? {}).length > 0 && !walletTerms?.chatReportID,
+ };
+
+ const infoChecking: Partial> = {
+ [CONST.INDICATOR_STATUS.HAS_LOGIN_LIST_INFO]: !!loginList && UserUtils.hasLoginListInfo(loginList),
+ [CONST.INDICATOR_STATUS.HAS_SUBSCRIPTION_INFO]: SubscriptionUtils.hasSubscriptionGreenDotInfo(),
+ };
+
+ const [error] = Object.entries(errorChecking).find(([, value]) => value) ?? [];
+ const [info] = Object.entries(infoChecking).find(([, value]) => value) ?? [];
+
+ const status = (error ?? info) as IndicatorStatus | undefined;
+ const policyIDWithErrors = Object.values(policyErrors).find(Boolean)?.id;
+ const indicatorColor = error ? theme.danger : theme.success;
+
+ return {indicatorColor, status, policyIDWithErrors};
+}
+
+export default useIndicatorStatus;
+
+export type {IndicatorStatus};
diff --git a/src/languages/en.ts b/src/languages/en.ts
index 5798f7fe48e9..2e543de3ae4b 100755
--- a/src/languages/en.ts
+++ b/src/languages/en.ts
@@ -39,6 +39,7 @@ import type {
ChangeTypeParams,
CharacterLengthLimitParams,
CharacterLimitParams,
+ CompanyCardBankName,
CompanyCardFeedNameParams,
ConfirmThatParams,
ConnectionNameParams,
@@ -2159,6 +2160,7 @@ const translations = {
companyAddress: 'Company address',
listOfRestrictedBusinesses: 'list of restricted businesses',
confirmCompanyIsNot: 'I confirm that this company is not on the',
+ businessInfoTitle: 'Business info',
},
beneficialOwnerInfoStep: {
doYouOwn25percent: 'Do you own 25% or more of',
@@ -2237,6 +2239,21 @@ const translations = {
enable2FAText: 'We take your security seriously. Please set up 2FA now to add an extra layer of protection to your account.',
secureYourAccount: 'Secure your account',
},
+ countryStep: {
+ confirmBusinessBank: 'Confirm business bank account currency and country',
+ confirmCurrency: 'Confirm currency and country',
+ },
+ signerInfoStep: {
+ signerInfo: 'Signer info',
+ },
+ agreementsStep: {
+ agreements: 'Agreements',
+ pleaseConfirm: 'Please confirm the agreements below',
+ accept: 'Accept and add bank account',
+ },
+ finishStep: {
+ connect: 'Connect bank account',
+ },
reimbursementAccountLoadingAnimation: {
oneMoment: 'One moment',
explanationLine: "We’re taking a look at your information. You'll be able to continue with next steps shortly.",
@@ -3314,6 +3331,9 @@ const translations = {
emptyAddedFeedDescription: 'Get started by assigning your first card to a member.',
pendingFeedTitle: `We're reviewing your request...`,
pendingFeedDescription: `We're currently reviewing your feed details. Once that's done we'll reach out to you via`,
+ pendingBankTitle: 'Check your browser window',
+ pendingBankDescription: ({bankName}: CompanyCardBankName) => `Please connect to ${bankName} via your browser window that just opened. If one didn’t open, `,
+ pendingBankLink: 'please click here.',
giveItNameInstruction: 'Give the card a name that sets it apart from the others.',
updating: 'Updating...',
noAccountsFound: 'No accounts found',
@@ -5128,6 +5148,22 @@ const translations = {
hasChildReportAwaitingAction: 'Has child report awaiting action',
hasMissingInvoiceBankAccount: 'Has missing invoice bank account',
},
+ indicatorStatus: {
+ theresAReportAwaitingAction: "There's a report awaiting action",
+ theresAReportWithErrors: "There's a report with errors",
+ theresAWorkspaceWithCustomUnitsErrors: "There's a workspace with custom units errors",
+ theresAProblemWithAWorkspaceMember: "There's a problem with a workspace member",
+ theresAProblemWithAContactMethod: "There's a problem with a contact method",
+ aContactMethodRequiresVerification: 'A contact method requires verification',
+ theresAProblemWithAPaymentMethod: "There's a problem with a payment method",
+ theresAProblemWithAWorkspace: "There's a problem with a workspace",
+ theresAProblemWithYourReimbursementAccount: "There's a problem with your reimbursement account",
+ theresABillingProblemWithYourSubscription: "There's a billing problem with your subscription",
+ yourSubscriptionHasBeenSuccessfullyRenewed: 'Your subscription has been successfully renewed',
+ theresWasAProblemDuringAWorkspaceConnectionSync: 'There was a problem during a workspace connection sync',
+ theresAProblemWithYourWallet: "There's a problem with your wallet",
+ theresAProblemWithYourWalletTerms: "There's a problem with your wallet terms",
+ },
},
emptySearchView: {
takeATour: 'Take a tour',
diff --git a/src/languages/es.ts b/src/languages/es.ts
index 84c03d5d9bf3..d719ef7c2e50 100644
--- a/src/languages/es.ts
+++ b/src/languages/es.ts
@@ -37,6 +37,7 @@ import type {
ChangeTypeParams,
CharacterLengthLimitParams,
CharacterLimitParams,
+ CompanyCardBankName,
CompanyCardFeedNameParams,
ConfirmThatParams,
ConnectionNameParams,
@@ -1546,7 +1547,6 @@ const translations = {
'Has introducido incorrectamente los 4 últimos dígitos de tu tarjeta Expensify demasiadas veces. Si estás seguro de que los números son correctos, ponte en contacto con Conserjería para solucionarlo. De lo contrario, inténtalo de nuevo más tarde.',
},
},
- // TODO: add translation
getPhysicalCard: {
header: 'Obtener tarjeta física',
nameMessage: 'Introduce tu nombre y apellido como aparecerá en tu tarjeta.',
@@ -2183,6 +2183,7 @@ const translations = {
companyAddress: 'Dirección de la empresa',
listOfRestrictedBusinesses: 'lista de negocios restringidos',
confirmCompanyIsNot: 'Confirmo que esta empresa no está en la',
+ businessInfoTitle: 'Información del negocio',
},
beneficialOwnerInfoStep: {
doYouOwn25percent: '¿Posees el 25% o más de',
@@ -2261,6 +2262,21 @@ const translations = {
enable2FAText: 'Tu seguridad es importante para nosotros. Por favor, configura ahora la autenticación de dos factores para añadir una capa adicional de protección a tu cuenta.',
secureYourAccount: 'Asegura tu cuenta',
},
+ countryStep: {
+ confirmBusinessBank: 'Confirmar moneda y país de la cuenta bancaria comercial',
+ confirmCurrency: 'Confirmar moneda y país',
+ },
+ signerInfoStep: {
+ signerInfo: 'Información del firmante',
+ },
+ agreementsStep: {
+ agreements: 'Acuerdos',
+ pleaseConfirm: 'Por favor confirme los acuerdos a continuación',
+ accept: 'Aceptar y añadir cuenta bancaria',
+ },
+ finishStep: {
+ connect: 'Conectar cuenta bancaria',
+ },
reimbursementAccountLoadingAnimation: {
oneMoment: 'Un momento',
explanationLine: 'Estamos verificando tu información y podrás continuar con los siguientes pasos en unos momentos.',
@@ -3359,6 +3375,9 @@ const translations = {
emptyAddedFeedDescription: 'Comienza asignando tu primera tarjeta a un miembro.',
pendingFeedTitle: `Estamos revisando tu solicitud...`,
pendingFeedDescription: `Actualmente estamos revisando los detalles de tu feed. Una vez hecho esto, nos pondremos en contacto contigo a través de`,
+ pendingBankTitle: 'Comprueba la ventana de tu navegador',
+ pendingBankDescription: ({bankName}: CompanyCardBankName) => `Conéctese a ${bankName} a través de la ventana del navegador que acaba de abrir. Si no se abrió, `,
+ pendingBankLink: 'por favor haga clic aquí.',
giveItNameInstruction: 'Nombra la tarjeta para distingirla de las demás.',
updating: 'Actualizando...',
noAccountsFound: 'No se han encontrado cuentas',
@@ -5645,6 +5664,22 @@ const translations = {
hasChildReportAwaitingAction: 'Informe secundario pendiente de acción',
hasMissingInvoiceBankAccount: 'Falta la cuenta bancaria de la factura',
},
+ indicatorStatus: {
+ theresAReportAwaitingAction: 'Hay un informe pendiente de acción',
+ theresAReportWithErrors: 'Hay un informe con errores',
+ theresAWorkspaceWithCustomUnitsErrors: 'Hay un espacio de trabajo con errores en las unidades personalizadas',
+ theresAProblemWithAWorkspaceMember: 'Hay un problema con un miembro del espacio de trabajo',
+ theresAProblemWithAContactMethod: 'Hay un problema con un método de contacto',
+ aContactMethodRequiresVerification: 'Un método de contacto requiere verificación',
+ theresAProblemWithAPaymentMethod: 'Hay un problema con un método de pago',
+ theresAProblemWithAWorkspace: 'Hay un problema con un espacio de trabajo',
+ theresAProblemWithYourReimbursementAccount: 'Hay un problema con tu cuenta de reembolso',
+ theresABillingProblemWithYourSubscription: 'Hay un problema de facturación con tu suscripción',
+ yourSubscriptionHasBeenSuccessfullyRenewed: 'Tu suscripción se ha renovado con éxito',
+ theresWasAProblemDuringAWorkspaceConnectionSync: 'Hubo un problema durante la sincronización de la conexión del espacio de trabajo',
+ theresAProblemWithYourWallet: 'Hay un problema con tu billetera',
+ theresAProblemWithYourWalletTerms: 'Hay un problema con los términos de tu billetera',
+ },
},
emptySearchView: {
takeATour: 'Haz un tour',
diff --git a/src/languages/params.ts b/src/languages/params.ts
index 02dafa76a46d..9341b914d1d0 100644
--- a/src/languages/params.ts
+++ b/src/languages/params.ts
@@ -538,6 +538,10 @@ type ImportedTypesParams = {
importedTypes: string[];
};
+type CompanyCardBankName = {
+ bankName: string;
+};
+
export type {
AuthenticationErrorParams,
ImportMembersSuccessfullDescriptionParams,
@@ -729,6 +733,7 @@ export type {
DateParams,
FiltersAmountBetweenParams,
StatementPageTitleParams,
+ CompanyCardBankName,
DisconnectPromptParams,
DisconnectTitleParams,
CharacterLengthLimitParams,
diff --git a/src/libs/API/index.ts b/src/libs/API/index.ts
index 0d1bab053182..ad0650374011 100644
--- a/src/libs/API/index.ts
+++ b/src/libs/API/index.ts
@@ -208,23 +208,32 @@ function paginate;
-function paginate>(
+function paginate>(
type: TRequestType,
command: TCommand,
apiCommandParameters: ApiRequestCommandParameters[TCommand],
onyxData: OnyxData,
config: PaginationConfig,
): void;
+function paginate>(
+ type: TRequestType,
+ command: TCommand,
+ apiCommandParameters: ApiRequestCommandParameters[TCommand],
+ onyxData: OnyxData,
+ config: PaginationConfig,
+ conflictResolver?: RequestConflictResolver,
+): void;
function paginate>(
type: TRequestType,
command: TCommand,
apiCommandParameters: ApiRequestCommandParameters[TCommand],
onyxData: OnyxData,
config: PaginationConfig,
+ conflictResolver: RequestConflictResolver = {},
): Promise | void {
Log.info('[API] Called API.paginate', false, {command, ...apiCommandParameters});
const request: PaginatedRequest = {
- ...prepareRequest(command, type, apiCommandParameters, onyxData),
+ ...prepareRequest(command, type, apiCommandParameters, onyxData, conflictResolver),
...config,
...{
isPaginated: true,
diff --git a/src/libs/API/parameters/RequestMoneyParams.ts b/src/libs/API/parameters/RequestMoneyParams.ts
index e3e600a4e367..27e1032d82a9 100644
--- a/src/libs/API/parameters/RequestMoneyParams.ts
+++ b/src/libs/API/parameters/RequestMoneyParams.ts
@@ -28,6 +28,7 @@ type RequestMoneyParams = {
transactionThreadReportID: string;
createdReportActionIDForThread: string;
reimbursible?: boolean;
+ policyID?: string;
};
export default RequestMoneyParams;
diff --git a/src/libs/CardUtils.ts b/src/libs/CardUtils.ts
index 7c81c5c224c6..9fda616557a8 100644
--- a/src/libs/CardUtils.ts
+++ b/src/libs/CardUtils.ts
@@ -1,6 +1,6 @@
import groupBy from 'lodash/groupBy';
import Onyx from 'react-native-onyx';
-import type {OnyxCollection, OnyxEntry} from 'react-native-onyx';
+import type {OnyxEntry} from 'react-native-onyx';
import type {ValueOf} from 'type-fest';
import ExpensifyCardImage from '@assets/images/expensify-card.svg';
import * as Illustrations from '@src/components/Icon/Illustrations';
@@ -9,7 +9,6 @@ import type {TranslationPaths} from '@src/languages/types';
import type {OnyxValues} from '@src/ONYXKEYS';
import ONYXKEYS from '@src/ONYXKEYS';
import type {BankAccountList, Card, CardFeeds, CardList, CompanyCardFeed, PersonalDetailsList, WorkspaceCardsList} from '@src/types/onyx';
-import type Policy from '@src/types/onyx/Policy';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import type IconAsset from '@src/types/utils/IconAsset';
import localeCompare from './LocaleCompare';
@@ -193,13 +192,35 @@ function getCompanyCardNumber(cardList: Record, lastFourPAN?: st
return Object.keys(cardList).find((card) => card.endsWith(lastFourPAN)) ?? '';
}
-function getCardFeedIcon(cardFeed: string): IconAsset {
- if (cardFeed.startsWith(CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD)) {
- return Illustrations.MasterCardCompanyCards;
+function getCardFeedIcon(cardFeed: CompanyCardFeed | typeof CONST.EXPENSIFY_CARD.BANK): IconAsset {
+ const feedIcons = {
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.VISA]: Illustrations.VisaCompanyCardDetail,
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX]: Illustrations.AmexCardCompanyCardDetail,
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD]: Illustrations.MasterCardCompanyCardDetail,
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX_DIRECT]: Illustrations.AmexCardCompanyCardDetail,
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.BANK_OF_AMERICA]: Illustrations.BankOfAmericaCompanyCardDetail,
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.CAPITAL_ONE]: Illustrations.CapitalOneCompanyCardDetail,
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.CHASE]: Illustrations.ChaseCompanyCardDetail,
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.CITIBANK]: Illustrations.CitibankCompanyCardDetail,
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.WELLS_FARGO]: Illustrations.WellsFargoCompanyCardDetail,
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.BREX]: Illustrations.BrexCompanyCardDetail,
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.STRIPE]: Illustrations.StripeCompanyCardDetail,
+ [CONST.EXPENSIFY_CARD.BANK]: ExpensifyCardImage,
+ };
+
+ if (cardFeed.startsWith(CONST.EXPENSIFY_CARD.BANK)) {
+ return ExpensifyCardImage;
+ }
+
+ if (feedIcons[cardFeed]) {
+ return feedIcons[cardFeed];
}
- if (cardFeed.startsWith(CONST.COMPANY_CARD.FEED_BANK_NAME.VISA)) {
- return Illustrations.VisaCompanyCards;
+ // In existing OldDot setups other variations of feeds could exist, ex: vcf2, vcf3, cdfbmo
+ const feedKey = (Object.keys(feedIcons) as CompanyCardFeed[]).find((feed) => cardFeed.startsWith(feed));
+
+ if (feedKey) {
+ return feedIcons[feedKey];
}
return Illustrations.AmexCompanyCards;
@@ -211,46 +232,18 @@ function getCardFeedName(feedType: CompanyCardFeed): string {
[CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD]: 'Mastercard',
[CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX]: 'American Express',
[CONST.COMPANY_CARD.FEED_BANK_NAME.STRIPE]: 'Stripe',
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX_DIRECT]: 'American Express',
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.BANK_OF_AMERICA]: 'Bank of America',
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.CAPITAL_ONE]: 'Capital One',
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.CHASE]: 'Chase',
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.CITIBANK]: 'Citibank',
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.WELLS_FARGO]: 'Wells Fargo',
+ [CONST.COMPANY_CARD.FEED_BANK_NAME.BREX]: 'Brex',
};
return feedNamesMapping[feedType];
}
-function getCardDetailsImage(cardFeed: string): IconAsset {
- if (cardFeed.startsWith(CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD)) {
- return Illustrations.MasterCardCompanyCardDetail;
- }
-
- if (cardFeed.startsWith(CONST.COMPANY_CARD.FEED_BANK_NAME.VISA)) {
- return Illustrations.VisaCompanyCardDetail;
- }
-
- if (cardFeed.startsWith(CONST.EXPENSIFY_CARD.BANK)) {
- return ExpensifyCardImage;
- }
-
- return Illustrations.AmexCardCompanyCardDetail;
-}
-
-function getMemberCards(policy: OnyxEntry, allCardsList: OnyxCollection, accountID?: number) {
- const workspaceId = policy?.workspaceAccountID ? policy.workspaceAccountID.toString() : '';
- const cards: WorkspaceCardsList = {};
- Object.keys(allCardsList ?? {})
- .filter((key) => key !== `${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceId}_${CONST.EXPENSIFY_CARD.BANK}` && key.includes(workspaceId))
- .forEach((key) => {
- const feedCards = allCardsList?.[key];
- if (feedCards && Object.keys(feedCards).length > 0) {
- Object.keys(feedCards).forEach((feedCardKey) => {
- if (feedCards?.[feedCardKey].accountID !== accountID) {
- return;
- }
- cards[feedCardKey] = feedCards[feedCardKey];
- });
- }
- });
- return cards;
-}
-
const getBankCardDetailsImage = (bank: ValueOf): IconAsset => {
const iconMap: Record, IconAsset> = {
[CONST.COMPANY_CARDS.BANKS.AMEX]: Illustrations.AmexCardCompanyCardDetail,
@@ -322,8 +315,6 @@ export {
getCompanyCardNumber,
getCardFeedIcon,
getCardFeedName,
- getCardDetailsImage,
- getMemberCards,
getBankCardDetailsImage,
getSelectedFeed,
getCorrectStepForSelectedBank,
diff --git a/src/libs/FastSearch.ts b/src/libs/FastSearch.ts
deleted file mode 100644
index 59d28dedd449..000000000000
--- a/src/libs/FastSearch.ts
+++ /dev/null
@@ -1,140 +0,0 @@
-/* eslint-disable rulesdir/prefer-at */
-import CONST from '@src/CONST';
-import Timing from './actions/Timing';
-import SuffixUkkonenTree from './SuffixUkkonenTree';
-
-type SearchableData = {
- /**
- * The data that should be searchable
- */
- data: T[];
- /**
- * A function that generates a string from a data entry. The string's value is used for searching.
- * If you have multiple fields that should be searchable, simply concat them to the string and return it.
- */
- toSearchableString: (data: T) => string;
-};
-
-// There are certain characters appear very often in our search data (email addresses), which we don't need to search for.
-const charSetToSkip = new Set(['@', '.', '#', '$', '%', '&', '*', '+', '-', '/', ':', ';', '<', '=', '>', '?', '_', '~', '!', ' ']);
-
-/**
- * Creates a new "FastSearch" instance. "FastSearch" uses a suffix tree to search for substrings in a list of strings.
- * You can provide multiple datasets. The search results will be returned for each dataset.
- *
- * Note: Creating a FastSearch instance with a lot of data is computationally expensive. You should create an instance once and reuse it.
- * Searches will be very fast though, even with a lot of data.
- */
-function createFastSearch(dataSets: Array>) {
- Timing.start(CONST.TIMING.SEARCH_CONVERT_SEARCH_VALUES);
- const maxNumericListSize = 400_000;
- // The user might provide multiple data sets, but internally, the search values will be stored in this one list:
- let concatenatedNumericList = new Uint8Array(maxNumericListSize);
- // Here we store the index of the data item in the original data list, so we can map the found occurrences back to the original data:
- const occurrenceToIndex = new Uint32Array(maxNumericListSize * 4);
- // As we are working with ArrayBuffers, we need to keep track of the current offset:
- const offset = {value: 1};
- // We store the last offset for a dataSet, so we can map the found occurrences to the correct dataSet:
- const listOffsets: number[] = [];
-
- for (const {data, toSearchableString} of dataSets) {
- // Performance critical: the array parameters are passed by reference, so we don't have to create new arrays every time:
- dataToNumericRepresentation(concatenatedNumericList, occurrenceToIndex, offset, {data, toSearchableString});
- listOffsets.push(offset.value);
- }
- concatenatedNumericList[offset.value++] = SuffixUkkonenTree.END_CHAR_CODE;
- listOffsets[listOffsets.length - 1] = offset.value;
- Timing.end(CONST.TIMING.SEARCH_CONVERT_SEARCH_VALUES);
-
- // The list might be larger than necessary, so we clamp it to the actual size:
- concatenatedNumericList = concatenatedNumericList.slice(0, offset.value);
-
- // Create & build the suffix tree:
- Timing.start(CONST.TIMING.SEARCH_MAKE_TREE);
- const tree = SuffixUkkonenTree.makeTree(concatenatedNumericList);
- Timing.end(CONST.TIMING.SEARCH_MAKE_TREE);
-
- Timing.start(CONST.TIMING.SEARCH_BUILD_TREE);
- tree.build();
- Timing.end(CONST.TIMING.SEARCH_BUILD_TREE);
-
- /**
- * Searches for the given input and returns results for each dataset.
- */
- function search(searchInput: string): T[][] {
- const cleanedSearchString = cleanString(searchInput);
- const {numeric} = SuffixUkkonenTree.stringToNumeric(cleanedSearchString, {
- charSetToSkip,
- // stringToNumeric might return a list that is larger than necessary, so we clamp it to the actual size
- // (otherwise the search could fail as we include in our search empty array values):
- clamp: true,
- });
- const result = tree.findSubstring(Array.from(numeric));
-
- const resultsByDataSet = Array.from({length: dataSets.length}, () => new Set());
- // eslint-disable-next-line @typescript-eslint/prefer-for-of
- for (let i = 0; i < result.length; i++) {
- const occurrenceIndex = result[i];
- const itemIndexInDataSet = occurrenceToIndex[occurrenceIndex];
- const dataSetIndex = listOffsets.findIndex((listOffset) => occurrenceIndex < listOffset);
-
- if (dataSetIndex === -1) {
- throw new Error(`[FastSearch] The occurrence index ${occurrenceIndex} is not in any dataset`);
- }
- const item = dataSets[dataSetIndex].data[itemIndexInDataSet];
- if (!item) {
- throw new Error(`[FastSearch] The item with index ${itemIndexInDataSet} in dataset ${dataSetIndex} is not defined`);
- }
- resultsByDataSet[dataSetIndex].add(item);
- }
-
- return resultsByDataSet.map((set) => Array.from(set));
- }
-
- return {
- search,
- };
-}
-
-/**
- * The suffix tree can only store string like values, and internally stores those as numbers.
- * This function converts the user data (which are most likely objects) to a numeric representation.
- * Additionally a list of the original data and their index position in the numeric list is created, which is used to map the found occurrences back to the original data.
- */
-function dataToNumericRepresentation(concatenatedNumericList: Uint8Array, occurrenceToIndex: Uint32Array, offset: {value: number}, {data, toSearchableString}: SearchableData): void {
- data.forEach((option, index) => {
- const searchStringForTree = toSearchableString(option);
- const cleanedSearchStringForTree = cleanString(searchStringForTree);
-
- if (cleanedSearchStringForTree.length === 0) {
- return;
- }
-
- SuffixUkkonenTree.stringToNumeric(cleanedSearchStringForTree, {
- charSetToSkip,
- out: {
- outArray: concatenatedNumericList,
- offset,
- outOccurrenceToIndex: occurrenceToIndex,
- index,
- },
- });
- // eslint-disable-next-line no-param-reassign
- occurrenceToIndex[offset.value] = index;
- // eslint-disable-next-line no-param-reassign
- concatenatedNumericList[offset.value++] = SuffixUkkonenTree.DELIMITER_CHAR_CODE;
- });
-}
-
-/**
- * Everything in the tree is treated as lowercase.
- */
-function cleanString(input: string) {
- return input.toLowerCase();
-}
-
-const FastSearch = {
- createFastSearch,
-};
-
-export default FastSearch;
diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx
index da26d093f3ef..5a49d9cff993 100644
--- a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx
+++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/BottomTabBar.tsx
@@ -26,6 +26,7 @@ import ONYXKEYS from '@src/ONYXKEYS';
import type {Route} from '@src/ROUTES';
import ROUTES from '@src/ROUTES';
import SCREENS from '@src/SCREENS';
+import DebugTabView from './DebugTabView';
type BottomTabBarProps = {
selectedTab: string | undefined;
@@ -64,12 +65,15 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const {activeWorkspaceID} = useActiveWorkspace();
- const transactionViolations = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);
+ const [user] = useOnyx(ONYXKEYS.USER);
+ const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
+ const [reportActions] = useOnyx(ONYXKEYS.COLLECTION.REPORT_ACTIONS);
+ const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS);
const [chatTabBrickRoad, setChatTabBrickRoad] = useState(getChatTabBrickRoad(activeWorkspaceID));
useEffect(() => {
setChatTabBrickRoad(getChatTabBrickRoad(activeWorkspaceID));
- }, [activeWorkspaceID, transactionViolations]);
+ }, [activeWorkspaceID, transactionViolations, reports, reportActions]);
const navigateToChats = useCallback(() => {
if (selectedTab === SCREENS.HOME) {
@@ -108,59 +112,72 @@ function BottomTabBar({selectedTab}: BottomTabBarProps) {
}, [activeWorkspaceID, selectedTab]);
return (
-
-
-
-
- {chatTabBrickRoad && }
-
-
- {translate('common.inbox')}
-
-
-
-
-
-
-
+ {user?.isDebugModeEnabled && (
+
+ )}
+
+
- {translate('common.search')}
-
-
-
-
-
+
+
+ {chatTabBrickRoad && (
+
+ )}
+
+
+ {translate('common.inbox')}
+
+
+
+
+
+
+
+ {translate('common.search')}
+
+
+
+
+
+
-
+ >
);
}
diff --git a/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/DebugTabView.tsx b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/DebugTabView.tsx
new file mode 100644
index 000000000000..3e5803b797dc
--- /dev/null
+++ b/src/libs/Navigation/AppNavigator/createCustomBottomTabNavigator/DebugTabView.tsx
@@ -0,0 +1,172 @@
+import React, {useCallback, useMemo} from 'react';
+import {View} from 'react-native';
+import type {OnyxEntry} from 'react-native-onyx';
+import {useOnyx} from 'react-native-onyx';
+import Button from '@components/Button';
+import Icon from '@components/Icon';
+import * as Expensicons from '@components/Icon/Expensicons';
+import Text from '@components/Text';
+import type {IndicatorStatus} from '@hooks/useIndicatorStatus';
+import useIndicatorStatus from '@hooks/useIndicatorStatus';
+import useLocalize from '@hooks/useLocalize';
+import useStyleUtils from '@hooks/useStyleUtils';
+import useTheme from '@hooks/useTheme';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@libs/Navigation/Navigation';
+import type {BrickRoad} from '@libs/WorkspacesSettingsUtils';
+import {getChatTabBrickRoadReport} from '@libs/WorkspacesSettingsUtils';
+import CONST from '@src/CONST';
+import type {TranslationPaths} from '@src/languages/types';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type {Route} from '@src/ROUTES';
+import ROUTES from '@src/ROUTES';
+import SCREENS from '@src/SCREENS';
+import type {ReimbursementAccount} from '@src/types/onyx';
+
+type DebugTabViewProps = {
+ selectedTab?: string;
+ chatTabBrickRoad: BrickRoad;
+ activeWorkspaceID?: string;
+};
+
+function getSettingsMessage(status: IndicatorStatus | undefined): TranslationPaths | undefined {
+ switch (status) {
+ case CONST.INDICATOR_STATUS.HAS_CUSTOM_UNITS_ERROR:
+ return 'debug.indicatorStatus.theresAWorkspaceWithCustomUnitsErrors';
+ case CONST.INDICATOR_STATUS.HAS_EMPLOYEE_LIST_ERROR:
+ return 'debug.indicatorStatus.theresAProblemWithAWorkspaceMember';
+ case CONST.INDICATOR_STATUS.HAS_LOGIN_LIST_ERROR:
+ return 'debug.indicatorStatus.theresAProblemWithAContactMethod';
+ case CONST.INDICATOR_STATUS.HAS_LOGIN_LIST_INFO:
+ return 'debug.indicatorStatus.aContactMethodRequiresVerification';
+ case CONST.INDICATOR_STATUS.HAS_PAYMENT_METHOD_ERROR:
+ return 'debug.indicatorStatus.theresAProblemWithAPaymentMethod';
+ case CONST.INDICATOR_STATUS.HAS_POLICY_ERRORS:
+ return 'debug.indicatorStatus.theresAProblemWithAWorkspace';
+ case CONST.INDICATOR_STATUS.HAS_REIMBURSEMENT_ACCOUNT_ERRORS:
+ return 'debug.indicatorStatus.theresAProblemWithYourReimbursementAccount';
+ case CONST.INDICATOR_STATUS.HAS_SUBSCRIPTION_ERRORS:
+ return 'debug.indicatorStatus.theresABillingProblemWithYourSubscription';
+ case CONST.INDICATOR_STATUS.HAS_SUBSCRIPTION_INFO:
+ return 'debug.indicatorStatus.yourSubscriptionHasBeenSuccessfullyRenewed';
+ case CONST.INDICATOR_STATUS.HAS_SYNC_ERRORS:
+ return 'debug.indicatorStatus.theresWasAProblemDuringAWorkspaceConnectionSync';
+ case CONST.INDICATOR_STATUS.HAS_USER_WALLET_ERRORS:
+ return 'debug.indicatorStatus.theresAProblemWithYourWallet';
+ case CONST.INDICATOR_STATUS.HAS_WALLET_TERMS_ERRORS:
+ return 'debug.indicatorStatus.theresAProblemWithYourWalletTerms';
+ default:
+ return undefined;
+ }
+}
+
+function getSettingsRoute(status: IndicatorStatus | undefined, reimbursementAccount: OnyxEntry, policyIDWithErrors = ''): Route | undefined {
+ switch (status) {
+ case CONST.INDICATOR_STATUS.HAS_CUSTOM_UNITS_ERROR:
+ return ROUTES.WORKSPACE_DISTANCE_RATES.getRoute(policyIDWithErrors);
+ case CONST.INDICATOR_STATUS.HAS_EMPLOYEE_LIST_ERROR:
+ return ROUTES.WORKSPACE_MEMBERS.getRoute(policyIDWithErrors);
+ case CONST.INDICATOR_STATUS.HAS_LOGIN_LIST_ERROR:
+ return ROUTES.SETTINGS_CONTACT_METHODS.route;
+ case CONST.INDICATOR_STATUS.HAS_LOGIN_LIST_INFO:
+ return ROUTES.SETTINGS_CONTACT_METHODS.route;
+ case CONST.INDICATOR_STATUS.HAS_PAYMENT_METHOD_ERROR:
+ return ROUTES.SETTINGS_WALLET;
+ case CONST.INDICATOR_STATUS.HAS_POLICY_ERRORS:
+ return ROUTES.WORKSPACE_INITIAL.getRoute(policyIDWithErrors);
+ case CONST.INDICATOR_STATUS.HAS_REIMBURSEMENT_ACCOUNT_ERRORS:
+ return ROUTES.BANK_ACCOUNT_WITH_STEP_TO_OPEN.getRoute(reimbursementAccount?.achData?.currentStep, reimbursementAccount?.achData?.policyID);
+ case CONST.INDICATOR_STATUS.HAS_SUBSCRIPTION_ERRORS:
+ return ROUTES.SETTINGS_SUBSCRIPTION;
+ case CONST.INDICATOR_STATUS.HAS_SUBSCRIPTION_INFO:
+ return ROUTES.SETTINGS_SUBSCRIPTION;
+ case CONST.INDICATOR_STATUS.HAS_SYNC_ERRORS:
+ return ROUTES.WORKSPACE_ACCOUNTING.getRoute(policyIDWithErrors);
+ case CONST.INDICATOR_STATUS.HAS_USER_WALLET_ERRORS:
+ return ROUTES.SETTINGS_WALLET;
+ case CONST.INDICATOR_STATUS.HAS_WALLET_TERMS_ERRORS:
+ return ROUTES.SETTINGS_WALLET;
+ default:
+ return undefined;
+ }
+}
+
+function DebugTabView({selectedTab = '', chatTabBrickRoad, activeWorkspaceID}: DebugTabViewProps) {
+ const StyleUtils = useStyleUtils();
+ const theme = useTheme();
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT);
+ const {status, indicatorColor, policyIDWithErrors} = useIndicatorStatus();
+
+ const message = useMemo((): TranslationPaths | undefined => {
+ if (selectedTab === SCREENS.HOME) {
+ if (chatTabBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO) {
+ return 'debug.indicatorStatus.theresAReportAwaitingAction';
+ }
+ if (chatTabBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR) {
+ return 'debug.indicatorStatus.theresAReportWithErrors';
+ }
+ }
+ if (selectedTab === SCREENS.SETTINGS.ROOT) {
+ return getSettingsMessage(status);
+ }
+ }, [selectedTab, chatTabBrickRoad, status]);
+
+ const indicator = useMemo(() => {
+ if (selectedTab === SCREENS.HOME) {
+ if (chatTabBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO) {
+ return theme.success;
+ }
+ if (chatTabBrickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR) {
+ return theme.danger;
+ }
+ }
+ if (selectedTab === SCREENS.SETTINGS.ROOT) {
+ if (status) {
+ return indicatorColor;
+ }
+ }
+ }, [selectedTab, chatTabBrickRoad, theme.success, theme.danger, status, indicatorColor]);
+
+ const navigateTo = useCallback(() => {
+ if (selectedTab === SCREENS.HOME && !!chatTabBrickRoad) {
+ const report = getChatTabBrickRoadReport(activeWorkspaceID);
+
+ if (report) {
+ Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(report.reportID));
+ }
+ }
+ if (selectedTab === SCREENS.SETTINGS.ROOT) {
+ const route = getSettingsRoute(status, reimbursementAccount, policyIDWithErrors);
+
+ if (route) {
+ Navigation.navigate(route);
+ }
+ }
+ }, [selectedTab, chatTabBrickRoad, activeWorkspaceID, status, reimbursementAccount, policyIDWithErrors]);
+
+ if (!([SCREENS.HOME, SCREENS.SETTINGS.ROOT] as string[]).includes(selectedTab) || !indicator) {
+ return null;
+ }
+
+ return (
+
+
+
+ {message && {translate(message)}}
+
+
+
+ );
+}
+
+DebugTabView.displayName = 'DebugTabView';
+
+export default DebugTabView;
diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts
index 5ae0fca0a68e..36fd3fc4df20 100644
--- a/src/libs/OptionsListUtils.ts
+++ b/src/libs/OptionsListUtils.ts
@@ -40,6 +40,7 @@ import type * as OnyxCommon from '@src/types/onyx/OnyxCommon';
import type DeepValueOf from '@src/types/utils/DeepValueOf';
import {isEmptyObject} from '@src/types/utils/EmptyObject';
import times from '@src/utils/times';
+import {createDraftReportForPolicyExpenseChat} from './actions/Report';
import Timing from './actions/Timing';
import filterArrayByMatch from './filterArrayByMatch';
import localeCompare from './LocaleCompare';
@@ -61,6 +62,7 @@ import * as UserUtils from './UserUtils';
type SearchOption = ReportUtils.OptionData & {
item: T;
+ isOptimisticReportOption?: boolean;
};
type OptionList = {
@@ -179,6 +181,7 @@ type GetOptionsConfig = {
includeDomainEmail?: boolean;
action?: IOUAction;
shouldBoldTitleByDefault?: boolean;
+ includePoliciesWithoutExpenseChats?: boolean;
};
type GetUserToInviteConfig = {
@@ -240,6 +243,13 @@ Onyx.connect({
},
});
+let allReportsDraft: OnyxCollection;
+Onyx.connect({
+ key: ONYXKEYS.COLLECTION.REPORT_DRAFT,
+ waitForCollectionCallback: true,
+ callback: (value) => (allReportsDraft = value),
+});
+
let loginList: OnyxEntry;
Onyx.connect({
key: ONYXKEYS.LOGIN_LIST,
@@ -1499,6 +1509,7 @@ function isReportSelected(reportOption: ReportUtils.OptionData, selectedOptions:
function createOptionList(personalDetails: OnyxEntry, reports?: OnyxCollection) {
const reportMapForAccountIDs: Record = {};
const allReportOptions: Array> = [];
+ const policyToReportForPolicyExpenseChats: Record = {};
if (reports) {
Object.values(reports).forEach((report) => {
@@ -1514,6 +1525,10 @@ function createOptionList(personalDetails: OnyxEntry, repor
return;
}
+ if (ReportUtils.isPolicyExpenseChat(report) && report.policyID) {
+ policyToReportForPolicyExpenseChats[report.policyID] = report;
+ }
+
// Save the report in the map if this is a single participant so we can associate the reportID with the
// personal detail option later. Individuals should not be associated with single participant
// policyExpenseChats or chatRooms since those are not people.
@@ -1528,6 +1543,46 @@ function createOptionList(personalDetails: OnyxEntry, repor
});
}
+ const policiesWithoutExpenseChats = Object.values(policies ?? {}).filter((policy) => {
+ if (policy?.type === CONST.POLICY.TYPE.PERSONAL) {
+ return false;
+ }
+ return !policyToReportForPolicyExpenseChats[policy?.id ?? ''];
+ });
+
+ // go through each policy and create a optimistic report option for it
+ if (policiesWithoutExpenseChats && policiesWithoutExpenseChats.length > 0) {
+ policiesWithoutExpenseChats.forEach((policy) => {
+ // check for draft report exist in allreportDrafts for the policy
+ let draftReport = Object.values(allReportsDraft ?? {})?.find((reportDraft) => reportDraft?.policyID === policy?.id);
+ if (!draftReport) {
+ draftReport = ReportUtils.buildOptimisticChatReport(
+ [currentUserAccountID ?? -1],
+ '',
+ CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
+ policy?.id,
+ currentUserAccountID,
+ true,
+ policy?.name,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ );
+ createDraftReportForPolicyExpenseChat({...draftReport, isOptimisticReport: true});
+ }
+ const accountIDs = ReportUtils.getParticipantsAccountIDsForDisplay(draftReport);
+ allReportOptions.push({
+ item: draftReport,
+ isOptimisticReportOption: true,
+ ...createOption(accountIDs, personalDetails, draftReport, {}),
+ });
+ });
+ }
const allPersonalDetailsOptions = Object.values(personalDetails ?? {}).map((personalDetail) => ({
item: personalDetail,
...createOption([personalDetail?.accountID ?? -1], personalDetails, reportMapForAccountIDs[personalDetail?.accountID ?? -1], {}, {showPersonalDetails: true}),
@@ -1723,6 +1778,7 @@ function getOptions(
includeDomainEmail = false,
action,
shouldBoldTitleByDefault = true,
+ includePoliciesWithoutExpenseChats = false,
}: GetOptionsConfig,
): Options {
if (includeCategories) {
@@ -1787,6 +1843,9 @@ function getOptions(
// Filter out all the reports that shouldn't be displayed
const filteredReportOptions = options.reports.filter((option) => {
+ if (option.isOptimisticReportOption && !includePoliciesWithoutExpenseChats) {
+ return;
+ }
const report = option.item;
const doesReportHaveViolations = ReportUtils.shouldShowViolations(report, transactionViolations);
@@ -2136,6 +2195,7 @@ type FilteredOptionsParams = {
includeInvoiceRooms?: boolean;
action?: IOUAction;
sortByReportTypeInSearch?: boolean;
+ includePoliciesWithoutExpenseChats?: boolean;
};
// It is not recommended to pass a search value to getFilteredOptions when passing reports and personalDetails.
@@ -2177,6 +2237,7 @@ function getFilteredOptions(params: FilteredOptionsParamsWithDefaultSearchValue
includeInvoiceRooms = false,
action,
sortByReportTypeInSearch = false,
+ includePoliciesWithoutExpenseChats = false,
} = params;
return getOptions(
{reports, personalDetails},
@@ -2206,6 +2267,7 @@ function getFilteredOptions(params: FilteredOptionsParamsWithDefaultSearchValue
includeInvoiceRooms,
action,
sortByReportTypeInSearch,
+ includePoliciesWithoutExpenseChats,
},
);
}
@@ -2419,31 +2481,6 @@ function getPersonalDetailSearchTerms(item: Partial) {
function getCurrentUserSearchTerms(item: ReportUtils.OptionData) {
return [item.text ?? '', item.login ?? '', item.login?.replace(CONST.EMAIL_SEARCH_REGEX, '') ?? ''];
}
-
-type PickUserToInviteParams = {
- canInviteUser: boolean;
- recentReports: ReportUtils.OptionData[];
- personalDetails: ReportUtils.OptionData[];
- searchValue: string;
- config?: FilterOptionsConfig;
- optionsToExclude: Option[];
-};
-
-const pickUserToInvite = ({canInviteUser, recentReports, personalDetails, searchValue, config, optionsToExclude}: PickUserToInviteParams) => {
- let userToInvite = null;
- if (canInviteUser) {
- if (recentReports.length === 0 && personalDetails.length === 0) {
- userToInvite = getUserToInviteOption({
- searchValue,
- selectedOptions: config?.selectedOptions,
- optionsToExclude,
- });
- }
- }
-
- return userToInvite;
-};
-
/**
* Filters options based on the search input value
*/
@@ -2531,7 +2568,16 @@ function filterOptions(options: Options, searchInputValue: string, config?: Filt
recentReports = orderOptions(recentReports, searchValue);
}
- const userToInvite = pickUserToInvite({canInviteUser, recentReports, personalDetails, searchValue, config, optionsToExclude});
+ let userToInvite = null;
+ if (canInviteUser) {
+ if (recentReports.length === 0 && personalDetails.length === 0) {
+ userToInvite = getUserToInviteOption({
+ searchValue,
+ selectedOptions: config?.selectedOptions,
+ optionsToExclude,
+ });
+ }
+ }
if (maxRecentReportsToShow > 0 && recentReports.length > maxRecentReportsToShow) {
recentReports.splice(maxRecentReportsToShow);
@@ -2600,7 +2646,6 @@ export {
formatMemberForList,
formatSectionsFromSearchTerm,
getShareLogOptions,
- orderOptions,
filterOptions,
createOptionList,
createOptionFromReport,
@@ -2614,7 +2659,6 @@ export {
getEmptyOptions,
shouldUseBoldText,
getAlternateText,
- pickUserToInvite,
hasReportErrors,
};
diff --git a/src/libs/ReportActionsUtils.ts b/src/libs/ReportActionsUtils.ts
index beb4e591ae62..552ec88a9cab 100644
--- a/src/libs/ReportActionsUtils.ts
+++ b/src/libs/ReportActionsUtils.ts
@@ -1781,7 +1781,7 @@ function getCardIssuedMessage(reportAction: OnyxEntry, shouldRende
const isAssigneeCurrentUser = currentUserAccountID === assigneeAccountID;
- const shouldShowAddMissingDetailsButton = reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CARD_MISSING_ADDRESS && missingDetails && isAssigneeCurrentUser;
+ const shouldShowAddMissingDetailsMessage = !isAssigneeCurrentUser || (reportAction?.actionName === CONST.REPORT.ACTIONS.TYPE.CARD_MISSING_ADDRESS && missingDetails);
switch (reportAction?.actionName) {
case CONST.REPORT.ACTIONS.TYPE.CARD_ISSUED:
return Localize.translateLocal('workspace.expensifyCard.issuedCard', {assignee});
@@ -1790,7 +1790,7 @@ function getCardIssuedMessage(reportAction: OnyxEntry, shouldRende
case CONST.REPORT.ACTIONS.TYPE.CARD_ASSIGNED:
return Localize.translateLocal('workspace.companyCards.assignedYouCard', {link: companyCardLink});
case CONST.REPORT.ACTIONS.TYPE.CARD_MISSING_ADDRESS:
- return Localize.translateLocal(`workspace.expensifyCard.${shouldShowAddMissingDetailsButton ? 'issuedCardNoShippingDetails' : 'addedShippingDetails'}`, {assignee});
+ return Localize.translateLocal(`workspace.expensifyCard.${shouldShowAddMissingDetailsMessage ? 'issuedCardNoShippingDetails' : 'addedShippingDetails'}`, {assignee});
default:
return '';
}
diff --git a/src/libs/ReportUtils.ts b/src/libs/ReportUtils.ts
index 4bae619d928e..1ef60b626ac7 100644
--- a/src/libs/ReportUtils.ts
+++ b/src/libs/ReportUtils.ts
@@ -300,6 +300,7 @@ type OptimisticChatReport = Pick<
| 'chatReportID'
| 'iouReportID'
| 'isOwnPolicyExpenseChat'
+ | 'isPolicyExpenseChat'
| 'isPinned'
| 'lastActorAccountID'
| 'lastMessageTranslationKey'
@@ -5322,6 +5323,7 @@ function buildOptimisticChatReport(
chatType,
isOwnPolicyExpenseChat,
isPinned: isNewlyCreatedWorkspaceChat,
+ isPolicyExpenseChat: chatType === CONST.REPORT.CHAT_TYPE.POLICY_EXPENSE_CHAT,
lastActorAccountID: 0,
lastMessageTranslationKey: '',
lastMessageHtml: '',
diff --git a/src/libs/SuffixUkkonenTree/index.ts b/src/libs/SuffixUkkonenTree/index.ts
deleted file mode 100644
index bcefd1008493..000000000000
--- a/src/libs/SuffixUkkonenTree/index.ts
+++ /dev/null
@@ -1,211 +0,0 @@
-/* eslint-disable rulesdir/prefer-at */
-// .at() has a performance overhead we explicitly want to avoid here
-
-/* eslint-disable no-continue */
-import {ALPHABET_SIZE, DELIMITER_CHAR_CODE, END_CHAR_CODE, SPECIAL_CHAR_CODE, stringToNumeric} from './utils';
-
-/**
- * This implements a suffix tree using Ukkonen's algorithm.
- * A good visualization to learn about the algorithm can be found here: https://brenden.github.io/ukkonen-animation/
- * A good video explaining Ukkonen's algorithm can be found here: https://www.youtube.com/watch?v=ALEV0Hc5dDk
- * Note: This implementation is optimized for performance, not necessarily for readability.
- *
- * You probably don't want to use this directly, but rather use @libs/FastSearch.ts as a easy to use wrapper around this.
- */
-
-/**
- * Creates a new tree instance that can be used to build a suffix tree and search in it.
- * The input is a numeric representation of the search string, which can be created using {@link stringToNumeric}.
- * Separate search values must be separated by the {@link DELIMITER_CHAR_CODE}. The search string must end with the {@link END_CHAR_CODE}.
- *
- * The tree will be built using the Ukkonen's algorithm: https://www.cs.helsinki.fi/u/ukkonen/SuffixT1withFigs.pdf
- */
-function makeTree(numericSearchValues: Uint8Array) {
- // Every leaf represents a suffix. There can't be more than n suffixes.
- // Every internal node has to have at least 2 children. So the total size of ukkonen tree is not bigger than 2n - 1.
- // + 1 is because an extra character at the beginning to offset the 1-based indexing.
- const maxNodes = 2 * numericSearchValues.length + 1;
- /*
- This array represents all internal nodes in the suffix tree.
- When building this tree, we'll be given a character in the string, and we need to be able to lookup in constant time
- if there's any edge connected to a node starting with that character. For example, given a tree like this:
-
- root
- / | \
- a b c
-
- and the next character in our string is 'd', we need to be able do check if any of the edges from the root node
- start with the letter 'd', without looping through all the edges.
-
- To accomplish this, each node gets an array matching the alphabet size.
- So you can imagine if our alphabet was just [a,b,c,d], then each node would get an array like [0,0,0,0].
- If we add an edge starting with 'a', then the root node would be [1,0,0,0]
- So given an arbitrary letter such as 'd', then we can take the position of that letter in its alphabet (position 3 in our example)
- and check whether that index in the array is 0 or 1. If it's a 1, then there's an edge starting with the letter 'd'.
-
- Note that for efficiency, all nodes are stored in a single flat array. That's how we end up with (maxNodes * alphabet_size).
- In the example of a 4-character alphabet, we'd have an array like this:
-
- root root.left root.right last possible node
- / \ / \ / \ / \
- [0,0,0,0, 0,0,0,0, 0,0,0,0, ................. 0,0,0,0]
- */
- const transitionNodes = new Uint32Array(maxNodes * ALPHABET_SIZE);
-
- // Storing the range of the original string that each node represents:
- const rangeStart = new Uint32Array(maxNodes);
- const rangeEnd = new Uint32Array(maxNodes);
-
- const parent = new Uint32Array(maxNodes);
- const suffixLink = new Uint32Array(maxNodes);
-
- let currentNode = 1;
- let currentPosition = 1;
- let nodeCounter = 3;
- let currentIndex = 1;
-
- function initializeTree() {
- rangeEnd.fill(numericSearchValues.length);
- rangeEnd[1] = 0;
- rangeEnd[2] = 0;
- suffixLink[1] = 2;
- for (let i = 0; i < ALPHABET_SIZE; ++i) {
- transitionNodes[ALPHABET_SIZE * 2 + i] = 1;
- }
- }
-
- function processCharacter(char: number) {
- // eslint-disable-next-line no-constant-condition
- while (true) {
- if (rangeEnd[currentNode] < currentPosition) {
- if (transitionNodes[currentNode * ALPHABET_SIZE + char] === 0) {
- createNewLeaf(char);
- continue;
- }
- currentNode = transitionNodes[currentNode * ALPHABET_SIZE + char];
- currentPosition = rangeStart[currentNode];
- }
- if (currentPosition === 0 || char === numericSearchValues[currentPosition]) {
- currentPosition++;
- } else {
- splitEdge(char);
- continue;
- }
- break;
- }
- }
-
- function createNewLeaf(c: number) {
- transitionNodes[currentNode * ALPHABET_SIZE + c] = nodeCounter;
- rangeStart[nodeCounter] = currentIndex;
- parent[nodeCounter++] = currentNode;
- currentNode = suffixLink[currentNode];
-
- currentPosition = rangeEnd[currentNode] + 1;
- }
-
- function splitEdge(c: number) {
- rangeStart[nodeCounter] = rangeStart[currentNode];
- rangeEnd[nodeCounter] = currentPosition - 1;
- parent[nodeCounter] = parent[currentNode];
-
- transitionNodes[nodeCounter * ALPHABET_SIZE + numericSearchValues[currentPosition]] = currentNode;
- transitionNodes[nodeCounter * ALPHABET_SIZE + c] = nodeCounter + 1;
- rangeStart[nodeCounter + 1] = currentIndex;
- parent[nodeCounter + 1] = nodeCounter;
- rangeStart[currentNode] = currentPosition;
- parent[currentNode] = nodeCounter;
-
- transitionNodes[parent[nodeCounter] * ALPHABET_SIZE + numericSearchValues[rangeStart[nodeCounter]]] = nodeCounter;
- nodeCounter += 2;
- handleDescent(nodeCounter);
- }
-
- function handleDescent(latestNodeIndex: number) {
- currentNode = suffixLink[parent[latestNodeIndex - 2]];
- currentPosition = rangeStart[latestNodeIndex - 2];
- while (currentPosition <= rangeEnd[latestNodeIndex - 2]) {
- currentNode = transitionNodes[currentNode * ALPHABET_SIZE + numericSearchValues[currentPosition]];
- currentPosition += rangeEnd[currentNode] - rangeStart[currentNode] + 1;
- }
- if (currentPosition === rangeEnd[latestNodeIndex - 2] + 1) {
- suffixLink[latestNodeIndex - 2] = currentNode;
- } else {
- suffixLink[latestNodeIndex - 2] = latestNodeIndex;
- }
- currentPosition = rangeEnd[currentNode] - (currentPosition - rangeEnd[latestNodeIndex - 2]) + 2;
- }
-
- function build() {
- initializeTree();
- for (currentIndex = 1; currentIndex < numericSearchValues.length; ++currentIndex) {
- const c = numericSearchValues[currentIndex];
- processCharacter(c);
- }
- }
-
- /**
- * Returns all occurrences of the given (sub)string in the input string.
- *
- * You can think of the tree that we create as a big string that looks like this:
- *
- * "banana$pancake$apple|"
- * The example delimiter character '$' is used to separate the different strings.
- * The end character '|' is used to indicate the end of our search string.
- *
- * This function will return the index(es) of found occurrences within this big string.
- * So, when searching for "an", it would return [1, 3, 8].
- */
- function findSubstring(searchValue: number[]) {
- const occurrences: number[] = [];
-
- function dfs(node: number, depth: number) {
- const leftRange = rangeStart[node];
- const rightRange = rangeEnd[node];
- const rangeLen = node === 1 ? 0 : rightRange - leftRange + 1;
-
- for (let i = 0; i < rangeLen && depth + i < searchValue.length && leftRange + i < numericSearchValues.length; i++) {
- if (searchValue[depth + i] !== numericSearchValues[leftRange + i]) {
- return;
- }
- }
-
- let isLeaf = true;
- for (let i = 0; i < ALPHABET_SIZE; ++i) {
- const tNode = transitionNodes[node * ALPHABET_SIZE + i];
-
- // Search speed optimization: don't go through the edge if it's different than the next char:
- const correctChar = depth + rangeLen >= searchValue.length || i === searchValue[depth + rangeLen];
-
- if (tNode !== 0 && tNode !== 1 && correctChar) {
- isLeaf = false;
- dfs(tNode, depth + rangeLen);
- }
- }
-
- if (isLeaf && depth + rangeLen >= searchValue.length) {
- occurrences.push(numericSearchValues.length - (depth + rangeLen) + 1);
- }
- }
-
- dfs(1, 0);
- return occurrences;
- }
-
- return {
- build,
- findSubstring,
- };
-}
-
-const SuffixUkkonenTree = {
- makeTree,
-
- // Re-exported from utils:
- DELIMITER_CHAR_CODE,
- SPECIAL_CHAR_CODE,
- END_CHAR_CODE,
- stringToNumeric,
-};
-
-export default SuffixUkkonenTree;
diff --git a/src/libs/SuffixUkkonenTree/utils.ts b/src/libs/SuffixUkkonenTree/utils.ts
deleted file mode 100644
index 96ee35b15796..000000000000
--- a/src/libs/SuffixUkkonenTree/utils.ts
+++ /dev/null
@@ -1,115 +0,0 @@
-/* eslint-disable rulesdir/prefer-at */ // .at() has a performance overhead we explicitly want to avoid here
-/* eslint-disable no-continue */
-
-const CHAR_CODE_A = 'a'.charCodeAt(0);
-const ALPHABET = 'abcdefghijklmnopqrstuvwxyz';
-const LETTER_ALPHABET_SIZE = ALPHABET.length;
-const ALPHABET_SIZE = LETTER_ALPHABET_SIZE + 3; // +3: special char, delimiter char, end char
-const SPECIAL_CHAR_CODE = ALPHABET_SIZE - 3;
-const DELIMITER_CHAR_CODE = ALPHABET_SIZE - 2;
-const END_CHAR_CODE = ALPHABET_SIZE - 1;
-
-// Store the results for a char code in a lookup table to avoid recalculating the same values (performance optimization)
-const base26LookupTable = new Array();
-
-/**
- * Converts a number to a base26 representation.
- */
-function convertToBase26(num: number): number[] {
- if (base26LookupTable[num]) {
- return base26LookupTable[num];
- }
- if (num < 0) {
- throw new Error('convertToBase26: Input must be a non-negative integer');
- }
-
- const result: number[] = [];
-
- do {
- // eslint-disable-next-line no-param-reassign
- num--;
- result.unshift(num % 26);
- // eslint-disable-next-line no-bitwise, no-param-reassign
- num >>= 5; // Equivalent to Math.floor(num / 26), but faster
- } while (num > 0);
-
- base26LookupTable[num] = result;
- return result;
-}
-
-/**
- * Converts a string to an array of numbers representing the characters of the string.
- * Every number in the array is in the range [0, ALPHABET_SIZE-1] (0-28).
- *
- * The numbers are offset by the character code of 'a' (97).
- * - This is so that the numbers from a-z are in the range 0-28.
- * - 26 is for encoding special characters. Character numbers that are not within the range of a-z will be encoded as "specialCharacter + base26(charCode)"
- * - 27 is for the delimiter character
- * - 28 is for the end character
- *
- * Note: The string should be converted to lowercase first (otherwise uppercase letters get base26'ed taking more space than necessary).
- */
-function stringToNumeric(
- // The string we want to convert to a numeric representation
- input: string,
- options?: {
- // A set of characters that should be skipped and not included in the numeric representation
- charSetToSkip?: Set;
- // When out is provided, the function will write the result to the provided arrays instead of creating new ones (performance)
- out?: {
- outArray: Uint8Array;
- // As outArray is a ArrayBuffer we need to keep track of the current offset
- offset: {value: number};
- // A map of to map the found occurrences to the correct data set
- // As the search string can be very long for high traffic accounts (500k+), this has to be big enough, thus its a Uint32Array
- outOccurrenceToIndex?: Uint32Array;
- // The index that will be used in the outOccurrenceToIndex array (this is the index of your original data position)
- index?: number;
- };
- // By default false. By default the outArray may be larger than necessary. If clamp is set to true the outArray will be clamped to the actual size.
- clamp?: boolean;
- },
-): {
- numeric: Uint8Array;
- occurrenceToIndex: Uint32Array;
- offset: {value: number};
-} {
- // The out array might be longer than our input string length, because we encode special characters as multiple numbers using the base26 encoding.
- // * 6 is because the upper limit of encoding any char in UTF-8 to base26 is at max 6 numbers.
- const outArray = options?.out?.outArray ?? new Uint8Array(input.length * 6);
- const offset = options?.out?.offset ?? {value: 0};
- const occurrenceToIndex = options?.out?.outOccurrenceToIndex ?? new Uint32Array(input.length * 16 * 4);
- const index = options?.out?.index ?? 0;
-
- for (let i = 0; i < input.length; i++) {
- const char = input[i];
-
- if (options?.charSetToSkip?.has(char)) {
- continue;
- }
-
- if (char >= 'a' && char <= 'z') {
- // char is an alphabet character
- occurrenceToIndex[offset.value] = index;
- outArray[offset.value++] = char.charCodeAt(0) - CHAR_CODE_A;
- } else {
- const charCode = input.charCodeAt(i);
- occurrenceToIndex[offset.value] = index;
- outArray[offset.value++] = SPECIAL_CHAR_CODE;
- const asBase26Numeric = convertToBase26(charCode);
- // eslint-disable-next-line @typescript-eslint/prefer-for-of
- for (let j = 0; j < asBase26Numeric.length; j++) {
- occurrenceToIndex[offset.value] = index;
- outArray[offset.value++] = asBase26Numeric[j];
- }
- }
- }
-
- return {
- numeric: options?.clamp ? outArray.slice(0, offset.value) : outArray,
- occurrenceToIndex,
- offset,
- };
-}
-
-export {stringToNumeric, ALPHABET, ALPHABET_SIZE, SPECIAL_CHAR_CODE, DELIMITER_CHAR_CODE, END_CHAR_CODE};
diff --git a/src/libs/WorkspacesSettingsUtils.ts b/src/libs/WorkspacesSettingsUtils.ts
index 2be641035be7..a27d518fe727 100644
--- a/src/libs/WorkspacesSettingsUtils.ts
+++ b/src/libs/WorkspacesSettingsUtils.ts
@@ -119,7 +119,7 @@ function hasWorkspaceSettingsRBR(policy: Policy) {
return Object.keys(reimbursementAccount?.errors ?? {}).length > 0 || hasPolicyError(policy) || hasCustomUnitsError(policy) || policyMemberError || taxRateError;
}
-function getChatTabBrickRoad(policyID?: string): BrickRoad | undefined {
+function getChatTabBrickRoadReport(policyID?: string): OnyxEntry {
const allReports = ReportConnection.getAllReports();
if (!allReports) {
return undefined;
@@ -128,27 +128,33 @@ function getChatTabBrickRoad(policyID?: string): BrickRoad | undefined {
// If policyID is undefined, then all reports are checked whether they contain any brick road
const policyReports = policyID ? Object.values(allReports).filter((report) => report?.policyID === policyID) : Object.values(allReports);
- let hasChatTabGBR = false;
+ let reportWithGBR: OnyxEntry;
- const hasChatTabRBR = policyReports.some((report) => {
+ const reportWithRBR = policyReports.find((report) => {
const brickRoad = report ? getBrickRoadForPolicy(report) : undefined;
- if (!hasChatTabGBR && brickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO) {
- hasChatTabGBR = true;
+ if (!reportWithGBR && brickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.INFO) {
+ reportWithGBR = report;
+ return false;
}
return brickRoad === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
});
- if (hasChatTabRBR) {
- return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR;
+ if (reportWithRBR) {
+ return reportWithRBR;
}
- if (hasChatTabGBR) {
- return CONST.BRICK_ROAD_INDICATOR_STATUS.INFO;
+ if (reportWithGBR) {
+ return reportWithGBR;
}
return undefined;
}
+function getChatTabBrickRoad(policyID?: string): BrickRoad | undefined {
+ const report = getChatTabBrickRoadReport(policyID);
+ return report ? getBrickRoadForPolicy(report) : undefined;
+}
+
/**
* @returns a map where the keys are policyIDs and the values are BrickRoads for each policy
*/
@@ -296,6 +302,7 @@ function getOwnershipChecksDisplayText(
}
export {
+ getChatTabBrickRoadReport,
getBrickRoadForPolicy,
getWorkspacesBrickRoads,
getWorkspacesUnreadStatuses,
diff --git a/src/libs/actions/App.ts b/src/libs/actions/App.ts
index 71cb5f97e00e..68c255e73be1 100644
--- a/src/libs/actions/App.ts
+++ b/src/libs/actions/App.ts
@@ -257,7 +257,7 @@ function openApp() {
return getPolicyParamsForOpenOrReconnect().then((policyParams: PolicyParamsForOpenOrReconnect) => {
const params: OpenAppParams = {enablePriorityModeFilter: true, ...policyParams};
return API.write(WRITE_COMMANDS.OPEN_APP, params, getOnyxDataForOpenOrReconnect(true), {
- checkAndFixConflictingRequest: (persistedRequests) => resolveDuplicationConflictAction(persistedRequests, WRITE_COMMANDS.OPEN_APP),
+ checkAndFixConflictingRequest: (persistedRequests) => resolveDuplicationConflictAction(persistedRequests, (request) => request.command === WRITE_COMMANDS.OPEN_APP),
});
});
}
@@ -287,7 +287,7 @@ function reconnectApp(updateIDFrom: OnyxEntry = 0) {
}
API.write(WRITE_COMMANDS.RECONNECT_APP, params, getOnyxDataForOpenOrReconnect(), {
- checkAndFixConflictingRequest: (persistedRequests) => resolveDuplicationConflictAction(persistedRequests, WRITE_COMMANDS.RECONNECT_APP),
+ checkAndFixConflictingRequest: (persistedRequests) => resolveDuplicationConflictAction(persistedRequests, (request) => request.command === WRITE_COMMANDS.RECONNECT_APP),
});
});
}
diff --git a/src/libs/actions/IOU.ts b/src/libs/actions/IOU.ts
index fb8cd014ec7b..0d9a4887bd1d 100644
--- a/src/libs/actions/IOU.ts
+++ b/src/libs/actions/IOU.ts
@@ -3640,6 +3640,7 @@ function requestMoney(
transactionThreadReportID,
createdReportActionIDForThread,
reimbursible,
+ policyID: policy?.id,
};
// eslint-disable-next-line rulesdir/no-multiple-api-calls
@@ -8410,9 +8411,30 @@ function resolveDuplicates(params: TransactionMergeParams) {
const optimisticHoldActions: OnyxUpdate[] = [];
const failureHoldActions: OnyxUpdate[] = [];
const reportActionIDList: string[] = [];
+ const optimisticHoldTransactionActions: OnyxUpdate[] = [];
+ const failureHoldTransactionActions: OnyxUpdate[] = [];
transactionThreadReportIDList.forEach((transactionThreadReportID) => {
const createdReportAction = ReportUtils.buildOptimisticHoldReportAction();
reportActionIDList.push(createdReportAction.reportActionID);
+ const transactionID = TransactionUtils.getTransactionID(transactionThreadReportID ?? '-1');
+ optimisticHoldTransactionActions.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {
+ comment: {
+ hold: createdReportAction.reportActionID,
+ },
+ },
+ });
+ failureHoldTransactionActions.push({
+ onyxMethod: Onyx.METHOD.MERGE,
+ key: `${ONYXKEYS.COLLECTION.TRANSACTION}${transactionID}`,
+ value: {
+ comment: {
+ hold: null,
+ },
+ },
+ });
optimisticHoldActions.push({
onyxMethod: Onyx.METHOD.MERGE,
key: `${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${transactionThreadReportID}`,
@@ -8456,8 +8478,8 @@ function resolveDuplicates(params: TransactionMergeParams) {
const optimisticData: OnyxUpdate[] = [];
const failureData: OnyxUpdate[] = [];
- optimisticData.push(optimisticTransactionData, ...optimisticTransactionViolations, ...optimisticHoldActions, optimisticReportActionData);
- failureData.push(failureTransactionData, ...failureTransactionViolations, ...failureHoldActions, failureReportActionData);
+ optimisticData.push(optimisticTransactionData, ...optimisticTransactionViolations, ...optimisticHoldActions, ...optimisticHoldTransactionActions, optimisticReportActionData);
+ failureData.push(failureTransactionData, ...failureTransactionViolations, ...failureHoldActions, ...failureHoldTransactionActions, failureReportActionData);
const {reportID, transactionIDList, receiptID, ...otherParams} = params;
const parameters: ResolveDuplicatesParams = {
diff --git a/src/libs/actions/Report.ts b/src/libs/actions/Report.ts
index 7071c96f8612..3384f41f27a6 100644
--- a/src/libs/actions/Report.ts
+++ b/src/libs/actions/Report.ts
@@ -79,7 +79,7 @@ import processReportIDDeeplink from '@libs/processReportIDDeeplink';
import * as Pusher from '@libs/Pusher/pusher';
import * as ReportActionsUtils from '@libs/ReportActionsUtils';
import * as ReportConnection from '@libs/ReportConnection';
-import type {OptimisticAddCommentReportAction} from '@libs/ReportUtils';
+import type {OptimisticAddCommentReportAction, OptimisticChatReport} from '@libs/ReportUtils';
import * as ReportUtils from '@libs/ReportUtils';
import {doesReportBelongToWorkspace} from '@libs/ReportUtils';
import shouldSkipDeepLinkNavigation from '@libs/shouldSkipDeepLinkNavigation';
@@ -111,6 +111,7 @@ import {isEmptyObject} from '@src/types/utils/EmptyObject';
import * as CachedPDFPaths from './CachedPDFPaths';
import * as Modal from './Modal';
import navigateFromNotification from './navigateFromNotification';
+import resolveDuplicationConflictAction from './RequestConflictUtils';
import * as Session from './Session';
import * as Welcome from './Welcome';
import * as OnboardingFlow from './Welcome/OnboardingFlow';
@@ -977,7 +978,10 @@ function openReport(
});
} else {
// eslint-disable-next-line rulesdir/no-multiple-api-calls
- API.paginate(CONST.API_REQUEST_TYPE.WRITE, WRITE_COMMANDS.OPEN_REPORT, parameters, {optimisticData, successData, failureData}, paginationConfig);
+ API.paginate(CONST.API_REQUEST_TYPE.WRITE, WRITE_COMMANDS.OPEN_REPORT, parameters, {optimisticData, successData, failureData}, paginationConfig, {
+ checkAndFixConflictingRequest: (persistedRequests) =>
+ resolveDuplicationConflictAction(persistedRequests, (request) => request.command === WRITE_COMMANDS.OPEN_REPORT && request.data?.reportID === reportID),
+ });
}
}
@@ -1368,6 +1372,7 @@ function handleReportChanged(report: OnyxEntry) {
if (report?.reportID && report.preexistingReportID) {
let callback = () => {
Onyx.set(`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`, null);
+ Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${report.reportID}`, null);
Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT_COMMENT}${report.reportID}`, null);
};
// Only re-route them if they are still looking at the optimistically created report
@@ -4125,6 +4130,10 @@ function markAsManuallyExported(reportID: string, connectionName: ConnectionName
API.write(WRITE_COMMANDS.MARK_AS_EXPORTED, params, {optimisticData, successData, failureData});
}
+function createDraftReportForPolicyExpenseChat(draftReport: OptimisticChatReport) {
+ Onyx.set(`${ONYXKEYS.COLLECTION.REPORT_DRAFT}${draftReport.reportID}`, draftReport);
+}
+
function exportReportToCSV({reportID, transactionIDList}: ExportReportCSVParams, onDownloadFailed: () => void) {
const finalParameters = enhanceParameters(WRITE_COMMANDS.EXPORT_REPORT_TO_CSV, {
reportID,
@@ -4232,4 +4241,5 @@ export {
updateReportName,
updateRoomVisibility,
updateWriteCapability,
+ createDraftReportForPolicyExpenseChat,
};
diff --git a/src/libs/actions/RequestConflictUtils.ts b/src/libs/actions/RequestConflictUtils.ts
index 68c0860389b9..fcf9ff439b11 100644
--- a/src/libs/actions/RequestConflictUtils.ts
+++ b/src/libs/actions/RequestConflictUtils.ts
@@ -1,16 +1,17 @@
-import type {WriteCommand} from '@libs/API/types';
import type OnyxRequest from '@src/types/onyx/Request';
import type {ConflictActionData} from '@src/types/onyx/Request';
+type RequestMatcher = (request: OnyxRequest) => boolean;
+
/**
- * Resolves duplication conflicts between persisted requests and a given command.
+ * Determines the appropriate action for handling duplication conflicts in persisted requests.
*
- * This method checks if a specific command exists within a list of persisted requests.
- * - If the command is not found, it suggests adding the command to the list, indicating a 'push' action.
- * - If the command is found, it suggests updating the existing entry, indicating a 'replace' action at the found index.
+ * This method checks if any request in the list of persisted requests matches the criteria defined by the request matcher function.
+ * - If no match is found, it suggests adding the request to the list, indicating a 'push' action.
+ * - If a match is found, it suggests updating the existing entry, indicating a 'replace' action at the found index.
*/
-function resolveDuplicationConflictAction(persistedRequests: OnyxRequest[], commandToFind: WriteCommand): ConflictActionData {
- const index = persistedRequests.findIndex((request) => request.command === commandToFind);
+function resolveDuplicationConflictAction(persistedRequests: OnyxRequest[], requestMatcher: RequestMatcher): ConflictActionData {
+ const index = persistedRequests.findIndex(requestMatcher);
if (index === -1) {
return {
conflictAction: {
diff --git a/src/libs/actions/getCompanyCardBankConnection/index.tsx b/src/libs/actions/getCompanyCardBankConnection/index.tsx
new file mode 100644
index 000000000000..935c5d297cb0
--- /dev/null
+++ b/src/libs/actions/getCompanyCardBankConnection/index.tsx
@@ -0,0 +1,34 @@
+import {getApiRoot} from '@libs/ApiUtils';
+import * as NetworkStore from '@libs/Network/NetworkStore';
+import CONST from '@src/CONST';
+
+type CompanyCardBankConnection = {
+ authToken: string;
+ domainName: string;
+ scrapeMinDate: string;
+ isCorporate: string;
+};
+
+// TODO remove this when BE will support bank UI callbacks
+const bankUrl = 'https://secure.chase.com/web/auth/#/logon/logon/chaseOnline?redirect_url=';
+
+export default function getCompanyCardBankConnection(bankName?: string, domainName?: string, scrapeMinDate?: string) {
+ const bankConnection = Object.keys(CONST.COMPANY_CARDS.BANKS).find((key) => CONST.COMPANY_CARDS.BANKS[key as keyof typeof CONST.COMPANY_CARDS.BANKS] === bankName);
+
+ // TODO remove this when BE will support bank UI callbacks
+ if (!domainName) {
+ return bankUrl;
+ }
+
+ if (!bankName || !bankConnection) {
+ return null;
+ }
+ const authToken = NetworkStore.getAuthToken();
+ const params: CompanyCardBankConnection = {authToken: authToken ?? '', domainName: domainName ?? '', isCorporate: 'true', scrapeMinDate: scrapeMinDate ?? ''};
+ const commandURL = getApiRoot({
+ shouldSkipWebProxy: true,
+ command: '',
+ });
+ const bank = CONST.COMPANY_CARDS.BANK_CONNECTIONS[bankConnection as keyof typeof CONST.COMPANY_CARDS.BANK_CONNECTIONS];
+ return `${commandURL}partners/banks/${bank}/oauth_callback.php?${new URLSearchParams(params).toString()}`;
+}
diff --git a/src/pages/ReimbursementAccount/NonUSD/Agreements/Agreements.tsx b/src/pages/ReimbursementAccount/NonUSD/Agreements/Agreements.tsx
new file mode 100644
index 000000000000..605157e2fe33
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/Agreements/Agreements.tsx
@@ -0,0 +1,61 @@
+import type {ComponentType} from 'react';
+import React from 'react';
+import InteractiveStepWrapper from '@components/InteractiveStepWrapper';
+import useLocalize from '@hooks/useLocalize';
+import useSubStep from '@hooks/useSubStep';
+import type {SubStepProps} from '@hooks/useSubStep/types';
+import CONST from '@src/CONST';
+import Confirmation from './substeps/Confirmation';
+
+type AgreementsProps = {
+ /** Handles back button press */
+ onBackButtonPress: () => void;
+
+ /** Handles submit button press */
+ onSubmit: () => void;
+};
+
+const bodyContent: Array> = [Confirmation];
+
+function Agreements({onBackButtonPress, onSubmit}: AgreementsProps) {
+ const {translate} = useLocalize();
+
+ const submit = () => {
+ onSubmit();
+ };
+
+ const {componentToRender: SubStep, isEditing, screenIndex, nextScreen, prevScreen, moveTo, goToTheLastStep} = useSubStep({bodyContent, startFrom: 0, onFinished: submit});
+
+ const handleBackButtonPress = () => {
+ if (isEditing) {
+ goToTheLastStep();
+ return;
+ }
+
+ if (screenIndex === 0) {
+ onBackButtonPress();
+ } else {
+ prevScreen();
+ }
+ };
+
+ return (
+
+
+
+ );
+}
+
+Agreements.displayName = 'Agreements';
+
+export default Agreements;
diff --git a/src/pages/ReimbursementAccount/NonUSD/Agreements/substeps/Confirmation.tsx b/src/pages/ReimbursementAccount/NonUSD/Agreements/substeps/Confirmation.tsx
new file mode 100644
index 000000000000..da828929b0da
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/Agreements/substeps/Confirmation.tsx
@@ -0,0 +1,28 @@
+import React from 'react';
+import FormProvider from '@components/Form/FormProvider';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import type {SubStepProps} from '@hooks/useSubStep/types';
+import useThemeStyles from '@hooks/useThemeStyles';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+function Confirmation({onNext}: SubStepProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ return (
+
+ {translate('agreementsStep.pleaseConfirm')}
+
+ );
+}
+
+Confirmation.displayName = 'Confirmation';
+
+export default Confirmation;
diff --git a/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx b/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx
new file mode 100644
index 000000000000..d6a9267b4f94
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/BankInfo/BankInfo.tsx
@@ -0,0 +1,61 @@
+import type {ComponentType} from 'react';
+import React from 'react';
+import InteractiveStepWrapper from '@components/InteractiveStepWrapper';
+import useLocalize from '@hooks/useLocalize';
+import useSubStep from '@hooks/useSubStep';
+import type {SubStepProps} from '@hooks/useSubStep/types';
+import CONST from '@src/CONST';
+import Confirmation from './substeps/Confirmation';
+
+type BankInfoProps = {
+ /** Handles back button press */
+ onBackButtonPress: () => void;
+
+ /** Handles submit button press */
+ onSubmit: () => void;
+};
+
+const bodyContent: Array> = [Confirmation];
+
+function BankInfo({onBackButtonPress, onSubmit}: BankInfoProps) {
+ const {translate} = useLocalize();
+
+ const submit = () => {
+ onSubmit();
+ };
+
+ const {componentToRender: SubStep, isEditing, screenIndex, nextScreen, prevScreen, moveTo, goToTheLastStep} = useSubStep({bodyContent, startFrom: 0, onFinished: submit});
+
+ const handleBackButtonPress = () => {
+ if (isEditing) {
+ goToTheLastStep();
+ return;
+ }
+
+ if (screenIndex === 0) {
+ onBackButtonPress();
+ } else {
+ prevScreen();
+ }
+ };
+
+ return (
+
+
+
+ );
+}
+
+BankInfo.displayName = 'BankInfo';
+
+export default BankInfo;
diff --git a/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/Confirmation.tsx b/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/Confirmation.tsx
new file mode 100644
index 000000000000..9ff2b0e57de9
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/BankInfo/substeps/Confirmation.tsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import {View} from 'react-native';
+import Button from '@components/Button';
+import SafeAreaConsumer from '@components/SafeAreaConsumer';
+import ScrollView from '@components/ScrollView';
+import useLocalize from '@hooks/useLocalize';
+import type {SubStepProps} from '@hooks/useSubStep/types';
+import useThemeStyles from '@hooks/useThemeStyles';
+
+function Confirmation({onNext}: SubStepProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ return (
+
+ {({safeAreaPaddingBottomStyle}) => (
+
+
+
+
+
+ )}
+
+ );
+}
+
+Confirmation.displayName = 'Confirmation';
+
+export default Confirmation;
diff --git a/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerInfo.tsx b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerInfo.tsx
new file mode 100644
index 000000000000..477bab90af45
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/BeneficialOwnerInfo/BeneficialOwnerInfo.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import {View} from 'react-native';
+import Button from '@components/Button';
+import InteractiveStepWrapper from '@components/InteractiveStepWrapper';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import CONST from '@src/CONST';
+
+type BeneficialOwnerInfoProps = {
+ /** Handles back button press */
+ onBackButtonPress: () => void;
+
+ /** Handles submit button press */
+ onSubmit: () => void;
+};
+
+function BeneficialOwnerInfo({onBackButtonPress, onSubmit}: BeneficialOwnerInfoProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ return (
+
+
+
+
+
+ );
+}
+
+BeneficialOwnerInfo.displayName = 'BeneficialOwnerInfo';
+
+export default BeneficialOwnerInfo;
diff --git a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/BusinessInfo.tsx b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/BusinessInfo.tsx
new file mode 100644
index 000000000000..8d1781edefbd
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/BusinessInfo.tsx
@@ -0,0 +1,61 @@
+import type {ComponentType} from 'react';
+import React from 'react';
+import InteractiveStepWrapper from '@components/InteractiveStepWrapper';
+import useLocalize from '@hooks/useLocalize';
+import useSubStep from '@hooks/useSubStep';
+import type {SubStepProps} from '@hooks/useSubStep/types';
+import CONST from '@src/CONST';
+import Confirmation from './substeps/Confirmation';
+
+type BusinessInfoProps = {
+ /** Handles back button press */
+ onBackButtonPress: () => void;
+
+ /** Handles submit button press */
+ onSubmit: () => void;
+};
+
+const bodyContent: Array> = [Confirmation];
+
+function BusinessInfo({onBackButtonPress, onSubmit}: BusinessInfoProps) {
+ const {translate} = useLocalize();
+
+ const submit = () => {
+ onSubmit();
+ };
+
+ const {componentToRender: SubStep, isEditing, screenIndex, nextScreen, prevScreen, moveTo, goToTheLastStep} = useSubStep({bodyContent, startFrom: 0, onFinished: submit});
+
+ const handleBackButtonPress = () => {
+ if (isEditing) {
+ goToTheLastStep();
+ return;
+ }
+
+ if (screenIndex === 0) {
+ onBackButtonPress();
+ } else {
+ prevScreen();
+ }
+ };
+
+ return (
+
+
+
+ );
+}
+
+BusinessInfo.displayName = 'BusinessInfo';
+
+export default BusinessInfo;
diff --git a/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/Confirmation.tsx b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/Confirmation.tsx
new file mode 100644
index 000000000000..9ff2b0e57de9
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/BusinessInfo/substeps/Confirmation.tsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import {View} from 'react-native';
+import Button from '@components/Button';
+import SafeAreaConsumer from '@components/SafeAreaConsumer';
+import ScrollView from '@components/ScrollView';
+import useLocalize from '@hooks/useLocalize';
+import type {SubStepProps} from '@hooks/useSubStep/types';
+import useThemeStyles from '@hooks/useThemeStyles';
+
+function Confirmation({onNext}: SubStepProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ return (
+
+ {({safeAreaPaddingBottomStyle}) => (
+
+
+
+
+
+ )}
+
+ );
+}
+
+Confirmation.displayName = 'Confirmation';
+
+export default Confirmation;
diff --git a/src/pages/ReimbursementAccount/NonUSD/Country/Country.tsx b/src/pages/ReimbursementAccount/NonUSD/Country/Country.tsx
new file mode 100644
index 000000000000..2faf8ac082c4
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/Country/Country.tsx
@@ -0,0 +1,61 @@
+import type {ComponentType} from 'react';
+import React from 'react';
+import InteractiveStepWrapper from '@components/InteractiveStepWrapper';
+import useLocalize from '@hooks/useLocalize';
+import useSubStep from '@hooks/useSubStep';
+import type {SubStepProps} from '@hooks/useSubStep/types';
+import CONST from '@src/CONST';
+import Confirmation from './substeps/Confirmation';
+
+type CountryProps = {
+ /** Handles back button press */
+ onBackButtonPress: () => void;
+
+ /** Handles submit button press */
+ onSubmit: () => void;
+};
+
+const bodyContent: Array> = [Confirmation];
+
+function Country({onBackButtonPress, onSubmit}: CountryProps) {
+ const {translate} = useLocalize();
+
+ const submit = () => {
+ onSubmit();
+ };
+
+ const {componentToRender: SubStep, isEditing, screenIndex, nextScreen, prevScreen, moveTo, goToTheLastStep} = useSubStep({bodyContent, startFrom: 0, onFinished: submit});
+
+ const handleBackButtonPress = () => {
+ if (isEditing) {
+ goToTheLastStep();
+ return;
+ }
+
+ if (screenIndex === 0) {
+ onBackButtonPress();
+ } else {
+ prevScreen();
+ }
+ };
+
+ return (
+
+
+
+ );
+}
+
+Country.displayName = 'Country';
+
+export default Country;
diff --git a/src/pages/ReimbursementAccount/NonUSD/Country/substeps/Confirmation.tsx b/src/pages/ReimbursementAccount/NonUSD/Country/substeps/Confirmation.tsx
new file mode 100644
index 000000000000..d35a6f4b124f
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/Country/substeps/Confirmation.tsx
@@ -0,0 +1,39 @@
+import React from 'react';
+import FormProvider from '@components/Form/FormProvider';
+import SafeAreaConsumer from '@components/SafeAreaConsumer';
+import ScrollView from '@components/ScrollView';
+import Text from '@components/Text';
+import useLocalize from '@hooks/useLocalize';
+import type {SubStepProps} from '@hooks/useSubStep/types';
+import useThemeStyles from '@hooks/useThemeStyles';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+function Confirmation({onNext}: SubStepProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ return (
+
+ {({safeAreaPaddingBottomStyle}) => (
+
+
+ {translate('countryStep.confirmBusinessBank')}
+
+
+ )}
+
+ );
+}
+
+Confirmation.displayName = 'Confirmation';
+
+export default Confirmation;
diff --git a/src/pages/ReimbursementAccount/NonUSD/Finish/Finish.tsx b/src/pages/ReimbursementAccount/NonUSD/Finish/Finish.tsx
new file mode 100644
index 000000000000..69c0e9e77a45
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/Finish/Finish.tsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import ScreenWrapper from '@components/ScreenWrapper';
+import ScrollView from '@components/ScrollView';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import Navigation from '@navigation/Navigation';
+
+function Finish() {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+
+ const handleBackButtonPress = () => {
+ Navigation.goBack();
+ };
+
+ return (
+
+
+
+
+ );
+}
+
+Finish.displayName = 'Finish';
+
+export default Finish;
diff --git a/src/pages/ReimbursementAccount/NonUSD/SignerInfo/SignerInfo.tsx b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/SignerInfo.tsx
new file mode 100644
index 000000000000..8e794f1f2f38
--- /dev/null
+++ b/src/pages/ReimbursementAccount/NonUSD/SignerInfo/SignerInfo.tsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import {View} from 'react-native';
+import Button from '@components/Button';
+import InteractiveStepWrapper from '@components/InteractiveStepWrapper';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import CONST from '@src/CONST';
+
+type SignerInfoProps = {
+ /** Handles back button press */
+ onBackButtonPress: () => void;
+
+ /** Handles submit button press */
+ onSubmit: () => void;
+};
+
+function SignerInfo({onBackButtonPress, onSubmit}: SignerInfoProps) {
+ const {translate} = useLocalize();
+ const styles = useThemeStyles();
+
+ return (
+
+
+
+
+
+ );
+}
+
+SignerInfo.displayName = 'SignerInfo';
+
+export default SignerInfo;
diff --git a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx
index d28980626d4f..47c1aadf493a 100644
--- a/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx
+++ b/src/pages/ReimbursementAccount/ReimbursementAccountPage.tsx
@@ -40,6 +40,13 @@ import CompanyStep from './CompanyStep';
import ConnectBankAccount from './ConnectBankAccount/ConnectBankAccount';
import ContinueBankAccountSetup from './ContinueBankAccountSetup';
import EnableBankAccount from './EnableBankAccount/EnableBankAccount';
+import Agreements from './NonUSD/Agreements/Agreements';
+import BankInfo from './NonUSD/BankInfo/BankInfo';
+import BeneficialOwnerInfo from './NonUSD/BeneficialOwnerInfo/BeneficialOwnerInfo';
+import BusinessInfo from './NonUSD/BusinessInfo/BusinessInfo';
+import Country from './NonUSD/Country/Country';
+import Finish from './NonUSD/Finish/Finish';
+import SignerInfo from './NonUSD/SignerInfo/SignerInfo';
import RequestorStep from './RequestorStep';
type ReimbursementAccountPageProps = WithPolicyOnyxProps & StackScreenProps;
@@ -159,6 +166,7 @@ function ReimbursementAccountPage({route, policy}: ReimbursementAccountPageProps
const isPreviousPolicy = policyIDParam === achData?.policyID;
// eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing
const currentStep = !isPreviousPolicy ? CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT : achData?.currentStep || CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT;
+ const [nonUSDBankAccountStep, setNonUSDBankAccountStep] = useState(CONST.NON_USD_BANK_ACCOUNT.STEP.COUNTRY);
/**
When this page is first opened, `reimbursementAccount` prop might not yet be fully loaded from Onyx.
@@ -195,6 +203,56 @@ function ReimbursementAccountPage({route, policy}: ReimbursementAccountPageProps
return achData?.state === BankAccount.STATE.PENDING || [CONST.BANK_ACCOUNT.STEP.BANK_ACCOUNT, ''].includes(getStepToOpenFromRouteParams(route));
}
+ const handleNextNonUSDBankAccountStep = () => {
+ switch (nonUSDBankAccountStep) {
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.COUNTRY:
+ setNonUSDBankAccountStep(CONST.NON_USD_BANK_ACCOUNT.STEP.BANK_INFO);
+ break;
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.BANK_INFO:
+ setNonUSDBankAccountStep(CONST.NON_USD_BANK_ACCOUNT.STEP.BUSINESS_INFO);
+ break;
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.BUSINESS_INFO:
+ setNonUSDBankAccountStep(CONST.NON_USD_BANK_ACCOUNT.STEP.BENEFICIAL_OWNER_INFO);
+ break;
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.BENEFICIAL_OWNER_INFO:
+ setNonUSDBankAccountStep(CONST.NON_USD_BANK_ACCOUNT.STEP.SIGNER_INFO);
+ break;
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.SIGNER_INFO:
+ setNonUSDBankAccountStep(CONST.NON_USD_BANK_ACCOUNT.STEP.AGREEMENTS);
+ break;
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.AGREEMENTS:
+ setNonUSDBankAccountStep(CONST.NON_USD_BANK_ACCOUNT.STEP.FINISH);
+ break;
+ default:
+ return null;
+ }
+ };
+
+ const nonUSDBankAccountsGoBack = () => {
+ switch (nonUSDBankAccountStep) {
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.COUNTRY:
+ Navigation.goBack();
+ break;
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.BANK_INFO:
+ setNonUSDBankAccountStep(CONST.NON_USD_BANK_ACCOUNT.STEP.COUNTRY);
+ break;
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.BUSINESS_INFO:
+ setNonUSDBankAccountStep(CONST.NON_USD_BANK_ACCOUNT.STEP.BANK_INFO);
+ break;
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.BENEFICIAL_OWNER_INFO:
+ setNonUSDBankAccountStep(CONST.NON_USD_BANK_ACCOUNT.STEP.BUSINESS_INFO);
+ break;
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.SIGNER_INFO:
+ setNonUSDBankAccountStep(CONST.NON_USD_BANK_ACCOUNT.STEP.BENEFICIAL_OWNER_INFO);
+ break;
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.AGREEMENTS:
+ setNonUSDBankAccountStep(CONST.NON_USD_BANK_ACCOUNT.STEP.SIGNER_INFO);
+ break;
+ default:
+ return null;
+ }
+ };
+
/**
* Retrieve verified business bank account currently being set up.
*/
@@ -389,19 +447,54 @@ function ReimbursementAccountPage({route, policy}: ReimbursementAccountPageProps
errorText = translate('bankAccount.hasBeenThrottledError');
} else if (hasUnsupportedCurrency) {
if (hasForeignCurrency) {
- // TODO This will be replaced with proper component in next issue - https://github.com/Expensify/App/issues/50893
- return (
-
- Navigation.goBack()}
- />
-
- Non USD flow
-
-
- );
+ switch (nonUSDBankAccountStep) {
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.COUNTRY:
+ return (
+
+ );
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.BANK_INFO:
+ return (
+
+ );
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.BUSINESS_INFO:
+ return (
+
+ );
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.BENEFICIAL_OWNER_INFO:
+ return (
+
+ );
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.SIGNER_INFO:
+ return (
+
+ );
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.AGREEMENTS:
+ return (
+
+ );
+ case CONST.NON_USD_BANK_ACCOUNT.STEP.FINISH:
+ return ;
+ default:
+ return null;
+ }
}
errorText = translate('bankAccount.hasCurrencyError');
}
diff --git a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx
index 5dcf4dbd2ea6..b500574288e0 100644
--- a/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx
+++ b/src/pages/iou/request/MoneyRequestParticipantsSelector.tsx
@@ -134,6 +134,7 @@ function MoneyRequestParticipantsSelector({
sortByReportTypeInSearch: isPaidGroupPolicy,
searchValue: '',
maxRecentReportsToShow: 0,
+ includePoliciesWithoutExpenseChats: true,
});
return optionList;
diff --git a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
index 835e37ef877e..e688c4a5825e 100644
--- a/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
+++ b/src/pages/iou/request/step/IOURequestStepConfirmation.tsx
@@ -55,13 +55,17 @@ function IOURequestStepConfirmation({
const currentUserPersonalDetails = useCurrentUserPersonalDetails();
const personalDetails = usePersonalDetails() || CONST.EMPTY_OBJECT;
- const [policyDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${IOU.getIOURequestPolicyID(transaction, reportDraft)}`);
- const [policyReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${IOU.getIOURequestPolicyID(transaction, reportReal)}`);
- const [policyCategoriesReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${IOU.getIOURequestPolicyID(transaction, reportReal)}`);
- const [policyCategoriesDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${IOU.getIOURequestPolicyID(transaction, reportDraft)}`);
- const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${IOU.getIOURequestPolicyID(transaction, reportReal)}`);
+ const policyIDForReal = IOU.getIOURequestPolicyID(transaction, reportReal ?? reportDraft);
+ const policyIDForDraft = IOU.getIOURequestPolicyID(transaction, reportDraft);
+
+ const [policyReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyIDForReal}`);
+ const [policyDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_DRAFTS}${policyIDForDraft}`);
+ const [policyCategoriesReal] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyIDForReal}`);
+ const [policyCategoriesDraft] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CATEGORIES}${policyIDForDraft}`);
+ const [policyTags] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_TAGS}${policyIDForReal}`);
const report = reportReal ?? reportDraft;
+ // Check if the real policy exists for either reportReal or reportDraft
const policy = policyReal ?? policyDraft;
const policyCategories = policyCategoriesReal ?? policyCategoriesDraft;
diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardDetailsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardDetailsPage.tsx
index 726001e80146..e6036da6dc20 100644
--- a/src/pages/workspace/companyCards/WorkspaceCompanyCardDetailsPage.tsx
+++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardDetailsPage.tsx
@@ -25,12 +25,15 @@ import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
import {getConnectedIntegration} from '@libs/PolicyUtils';
import Navigation from '@navigation/Navigation';
+import NotFoundPage from '@pages/ErrorPage/NotFoundPage';
import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
import variables from '@styles/variables';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
+import type {CompanyCardFeed} from '@src/types/onyx';
+import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue';
import {getExportMenuItem} from './utils';
type WorkspaceCompanyCardDetailsPageProps = StackScreenProps;
@@ -49,9 +52,10 @@ function WorkspaceCompanyCardDetailsPage({route}: WorkspaceCompanyCardDetailsPag
const connectedIntegration = getConnectedIntegration(policy, accountingIntegrations) ?? connectionSyncProgress?.connectionName;
const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
- const [allBankCards] = useOnyx(`${ONYXKEYS.CARD_LIST}`);
+ const [allBankCards, allBankCardsMetadata] = useOnyx(`${ONYXKEYS.CARD_LIST}`);
const card = allBankCards?.[cardID];
+ const cardBank = card?.bank ?? '';
const cardholder = personalDetails?.[card?.accountID ?? -1];
const displayName = PersonalDetailsUtils.getDisplayNameOrDefault(cardholder);
const exportMenuItem = getExportMenuItem(connectedIntegration, policyID, translate, policy, card);
@@ -66,6 +70,10 @@ function WorkspaceCompanyCardDetailsPage({route}: WorkspaceCompanyCardDetailsPag
Policy.updateWorkspaceCompanyCard(workspaceAccountID, cardID, bank);
};
+ if (!card && !isLoadingOnyxValue(allBankCardsMetadata)) {
+ return ;
+ }
+
return (
({
- value: feed as CompanyCardFeed,
- text: cardFeeds?.settings?.companyCardNicknames?.[feed] ?? translate(`workspace.companyCards.addNewCard.cardProviders.${feed as CompanyCardFeed}`),
+ const feeds: CardFeedListItem[] = (Object.keys(cardFeeds?.settings?.companyCards ?? {}) as CompanyCardFeed[]).map((feed) => ({
+ value: feed,
+ text: cardFeeds?.settings?.companyCardNicknames?.[feed] ?? CardUtils.getCardFeedName(feed),
keyForList: feed,
isSelected: feed === selectedFeed,
brickRoadIndicator: cardFeeds?.settings?.companyCards?.[feed]?.errors ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined,
diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsListHeaderButtons.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsListHeaderButtons.tsx
index 74e6593d6986..efd95ecb8980 100644
--- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsListHeaderButtons.tsx
+++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsListHeaderButtons.tsx
@@ -16,6 +16,7 @@ import * as CardUtils from '@libs/CardUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
import Navigation from '@navigation/Navigation';
import variables from '@styles/variables';
+import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {CompanyCardFeed} from '@src/types/onyx';
@@ -36,7 +37,9 @@ function WorkspaceCompanyCardsListHeaderButtons({policyID, selectedFeed}: Worksp
const workspaceAccountID = PolicyUtils.getWorkspaceAccountID(policyID);
const [cardFeeds] = useOnyx(`${ONYXKEYS.COLLECTION.SHARED_NVP_PRIVATE_DOMAIN_MEMBER}${workspaceAccountID}`);
const shouldChangeLayout = isMediumScreenWidth || shouldUseNarrowLayout;
- const feedName = cardFeeds?.settings?.companyCardNicknames?.[selectedFeed] ?? translate(`workspace.companyCards.addNewCard.cardProviders.${selectedFeed}`);
+ const feedName = cardFeeds?.settings?.companyCardNicknames?.[selectedFeed] ?? CardUtils.getCardFeedName(selectedFeed);
+ const isCustomFeed =
+ CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD === selectedFeed || CONST.COMPANY_CARD.FEED_BANK_NAME.VISA === selectedFeed || CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX === selectedFeed;
return (
)}
- {translate('workspace.companyCards.customFeed')}
+ {translate(isCustomFeed ? 'workspace.companyCards.customFeed' : 'workspace.companyCards.directFeed')}
diff --git a/src/pages/workspace/companyCards/addNew/AddNewCardPage.tsx b/src/pages/workspace/companyCards/addNew/AddNewCardPage.tsx
index 480bd5d538fe..105c7107548d 100644
--- a/src/pages/workspace/companyCards/addNew/AddNewCardPage.tsx
+++ b/src/pages/workspace/companyCards/addNew/AddNewCardPage.tsx
@@ -6,6 +6,7 @@ import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPol
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import AmexCustomFeed from './AmexCustomFeed';
+import BankConnection from './BankConnection';
import CardInstructionsStep from './CardInstructionsStep';
import CardNameStep from './CardNameStep';
import CardTypeStep from './CardTypeStep';
@@ -28,6 +29,8 @@ function AddNewCardPage({policy}: WithPolicyAndFullscreenLoadingProps) {
return ;
case CONST.COMPANY_CARDS.STEP.CARD_TYPE:
return ;
+ case CONST.COMPANY_CARDS.STEP.BANK_CONNECTION:
+ return ;
case CONST.COMPANY_CARDS.STEP.CARD_INSTRUCTIONS:
return ;
case CONST.COMPANY_CARDS.STEP.CARD_NAME:
diff --git a/src/pages/workspace/companyCards/addNew/BankConnection/index.native.tsx b/src/pages/workspace/companyCards/addNew/BankConnection/index.native.tsx
new file mode 100644
index 000000000000..8de2b9baba9a
--- /dev/null
+++ b/src/pages/workspace/companyCards/addNew/BankConnection/index.native.tsx
@@ -0,0 +1,79 @@
+import React, {useEffect, useRef, useState} from 'react';
+import {useOnyx} from 'react-native-onyx';
+import {WebView} from 'react-native-webview';
+import type {ValueOf} from 'type-fest';
+import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView';
+import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import Modal from '@components/Modal';
+import useLocalize from '@hooks/useLocalize';
+import getUAForWebView from '@libs/getUAForWebView';
+import * as CompanyCards from '@userActions/CompanyCards';
+import getCompanyCardBankConnection from '@userActions/getCompanyCardBankConnection';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+
+function BankConnection() {
+ const {translate} = useLocalize();
+ const webViewRef = useRef(null);
+ const [isWebViewOpen, setWebViewOpen] = useState(false);
+ const [session] = useOnyx(ONYXKEYS.SESSION);
+ const authToken = session?.authToken ?? null;
+ const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD);
+ const bankName: ValueOf | undefined = addNewCard?.data?.selectedBank;
+ const url = getCompanyCardBankConnection(bankName);
+
+ const renderLoading = () => ;
+
+ const handleBackButtonPress = () => {
+ setWebViewOpen(false);
+ if (bankName === CONST.COMPANY_CARDS.BANKS.BREX) {
+ CompanyCards.setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.SELECT_BANK});
+ return;
+ }
+ if (bankName === CONST.COMPANY_CARDS.BANKS.AMEX) {
+ CompanyCards.setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.AMEX_CUSTOM_FEED});
+ return;
+ }
+ CompanyCards.setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.SELECT_FEED_TYPE});
+ };
+
+ useEffect(() => {
+ setWebViewOpen(true);
+ }, []);
+
+ return (
+
+
+
+ {url && (
+
+ )}
+
+
+ );
+}
+
+BankConnection.displayName = 'BankConnection';
+
+export default BankConnection;
diff --git a/src/pages/workspace/companyCards/addNew/BankConnection/index.tsx b/src/pages/workspace/companyCards/addNew/BankConnection/index.tsx
new file mode 100644
index 000000000000..2b4d86f2e43b
--- /dev/null
+++ b/src/pages/workspace/companyCards/addNew/BankConnection/index.tsx
@@ -0,0 +1,91 @@
+import React, {useCallback, useEffect} from 'react';
+import {useOnyx} from 'react-native-onyx';
+import type {ValueOf} from 'type-fest';
+import BlockingView from '@components/BlockingViews/BlockingView';
+import HeaderWithBackButton from '@components/HeaderWithBackButton';
+import * as Illustrations from '@components/Icon/Illustrations';
+import ScreenWrapper from '@components/ScreenWrapper';
+import Text from '@components/Text';
+import TextLink from '@components/TextLink';
+import useLocalize from '@hooks/useLocalize';
+import useThemeStyles from '@hooks/useThemeStyles';
+import getCurrentUrl from '@navigation/currentUrl';
+import * as CompanyCards from '@userActions/CompanyCards';
+import getCompanyCardBankConnection from '@userActions/getCompanyCardBankConnection';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import ROUTES from '@src/ROUTES';
+import openBankConnection from './openBankConnection';
+
+let customWindow: Window | null = null;
+
+function BankConnection() {
+ const styles = useThemeStyles();
+ const {translate} = useLocalize();
+ const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD);
+ const bankName: ValueOf | undefined = addNewCard?.data?.selectedBank;
+ const currentUrl = getCurrentUrl();
+ const isBankConnectionCompleteRoute = currentUrl.includes(ROUTES.BANK_CONNECTION_COMPLETE);
+ const url = getCompanyCardBankConnection(bankName);
+
+ const onOpenBankConnectionFlow = useCallback(() => {
+ if (!url) {
+ return;
+ }
+ customWindow = openBankConnection(url);
+ }, [url]);
+
+ const handleBackButtonPress = () => {
+ customWindow?.close();
+ if (bankName === CONST.COMPANY_CARDS.BANKS.BREX) {
+ CompanyCards.setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.SELECT_BANK});
+ return;
+ }
+ if (bankName === CONST.COMPANY_CARDS.BANKS.AMEX) {
+ CompanyCards.setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.AMEX_CUSTOM_FEED});
+ return;
+ }
+ CompanyCards.setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.SELECT_FEED_TYPE});
+ };
+
+ const CustomSubtitle = (
+
+ {bankName && translate(`workspace.moreFeatures.companyCards.pendingBankDescription`, {bankName})}
+ {translate('workspace.moreFeatures.companyCards.pendingBankLink')}
+
+ );
+
+ useEffect(() => {
+ if (!url) {
+ return;
+ }
+ if (isBankConnectionCompleteRoute) {
+ customWindow?.close();
+ return;
+ }
+ customWindow = openBankConnection(url);
+ }, [isBankConnectionCompleteRoute, url]);
+
+ return (
+
+
+
+
+ );
+}
+
+BankConnection.displayName = 'BankConnection';
+
+export default BankConnection;
diff --git a/src/pages/workspace/companyCards/addNew/BankConnection/openBankConnection/index.tsx b/src/pages/workspace/companyCards/addNew/BankConnection/openBankConnection/index.tsx
new file mode 100644
index 000000000000..91a81bdbd6c6
--- /dev/null
+++ b/src/pages/workspace/companyCards/addNew/BankConnection/openBankConnection/index.tsx
@@ -0,0 +1,5 @@
+const handleOpenBankConnectionFlow = (url: string) => {
+ return window.open(url, '_blank');
+};
+
+export default handleOpenBankConnectionFlow;
diff --git a/src/pages/workspace/companyCards/addNew/BankConnection/openBankConnection/index.website.tsx b/src/pages/workspace/companyCards/addNew/BankConnection/openBankConnection/index.website.tsx
new file mode 100644
index 000000000000..220404cee0e7
--- /dev/null
+++ b/src/pages/workspace/companyCards/addNew/BankConnection/openBankConnection/index.website.tsx
@@ -0,0 +1,14 @@
+const WINDOW_WIDTH = 700;
+const WINDOW_HEIGHT = 600;
+
+const handleOpenBankConnectionFlow = (url: string) => {
+ const screenWidth = window.screen.width;
+ const screenHeight = window.screen.height;
+ const left = (screenWidth - WINDOW_WIDTH) / 2;
+ const top = (screenHeight - WINDOW_HEIGHT) / 2;
+ const popupFeatures = `width=${WINDOW_WIDTH},height=${WINDOW_HEIGHT},left=${left},top=${top},scrollbars=yes,resizable=yes`;
+
+ return window.open(url, 'popupWindow', popupFeatures);
+};
+
+export default handleOpenBankConnectionFlow;
diff --git a/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx b/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx
index b2f5f3f0c321..03dd2e9e63cd 100644
--- a/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx
+++ b/src/pages/workspace/companyCards/addNew/CardTypeStep.tsx
@@ -19,12 +19,12 @@ import variables from '@styles/variables';
import * as CompanyCards from '@userActions/CompanyCards';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import type {CompanyCardFeed} from '@src/types/onyx';
+import type {CardFeedProvider} from '@src/types/onyx/CardFeeds';
type AvailableCompanyCardTypes = {
isAmexAvailable?: boolean;
translate: LocaleContextProps['translate'];
- typeSelected?: CompanyCardFeed;
+ typeSelected?: CardFeedProvider;
styles: StyleProp;
};
@@ -87,7 +87,7 @@ function CardTypeStep() {
const {translate} = useLocalize();
const styles = useThemeStyles();
const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD);
- const [typeSelected, setTypeSelected] = useState();
+ const [typeSelected, setTypeSelected] = useState();
const {canUseDirectFeeds} = usePermissions();
const [isError, setIsError] = useState(false);
const data = getAvailableCompanyCardTypes({isAmexAvailable: !canUseDirectFeeds, translate, typeSelected, styles: styles.mr3});
diff --git a/src/pages/workspace/companyCards/addNew/SelectFeedType.tsx b/src/pages/workspace/companyCards/addNew/SelectFeedType.tsx
index fd4fe021185c..959e7c10f3aa 100644
--- a/src/pages/workspace/companyCards/addNew/SelectFeedType.tsx
+++ b/src/pages/workspace/companyCards/addNew/SelectFeedType.tsx
@@ -28,7 +28,7 @@ function SelectFeedType() {
return;
}
CompanyCards.setAddNewCompanyCardStepAndData({
- step: typeSelected === CONST.COMPANY_CARDS.FEED_TYPE.DIRECT ? CONST.COMPANY_CARDS.STEP.SELECT_BANK : CONST.COMPANY_CARDS.STEP.CARD_TYPE,
+ step: typeSelected === CONST.COMPANY_CARDS.FEED_TYPE.DIRECT ? CONST.COMPANY_CARDS.STEP.BANK_CONNECTION : CONST.COMPANY_CARDS.STEP.CARD_TYPE,
data: {selectedFeedType: typeSelected},
});
};
diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx
index 1ff46ddd8d38..822bef628799 100644
--- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx
+++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx
@@ -39,7 +39,7 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
-import type {Card as MemberCard, PersonalDetails, PersonalDetailsList} from '@src/types/onyx';
+import type {CompanyCardFeed, Card as MemberCard, PersonalDetails, PersonalDetailsList} from '@src/types/onyx';
import type {ListItemType} from './WorkspaceMemberDetailsRoleSelectionModal';
import WorkspaceMemberDetailsRoleSelectionModal from './WorkspaceMemberDetailsRoleSelectionModal';
@@ -312,7 +312,7 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM
? CurrencyUtils.convertToDisplayString(memberCard.nameValuePairs?.unapprovedExpenseLimit)
: ''
}
- icon={CardUtils.getCardDetailsImage(memberCard?.bank ?? '')}
+ icon={CardUtils.getCardFeedIcon(memberCard.bank as CompanyCardFeed)}
displayInDefaultIconColor
iconStyles={styles.cardIcon}
contentFit="contain"
diff --git a/src/pages/workspace/members/WorkspaceMemberNewCardPage.tsx b/src/pages/workspace/members/WorkspaceMemberNewCardPage.tsx
index 390449cd8896..24a2b12cb4d0 100644
--- a/src/pages/workspace/members/WorkspaceMemberNewCardPage.tsx
+++ b/src/pages/workspace/members/WorkspaceMemberNewCardPage.tsx
@@ -78,9 +78,9 @@ function WorkspaceMemberNewCardPage({route, personalDetails}: WorkspaceMemberNew
setShouldShowError(false);
};
- const companyCardFeeds: CardFeedListItem[] = Object.keys(cardFeeds?.settings?.companyCards ?? {}).map((key) => ({
+ const companyCardFeeds: CardFeedListItem[] = (Object.keys(cardFeeds?.settings?.companyCards ?? {}) as CompanyCardFeed[]).map((key) => ({
value: key,
- text: cardFeeds?.settings?.companyCardNicknames?.[key] ?? translate(`workspace.companyCards.addNewCard.cardProviders.${key as CompanyCardFeed}`),
+ text: cardFeeds?.settings?.companyCardNicknames?.[key] ?? CardUtils.getCardFeedName(key),
keyForList: key,
isSelected: selectedFeed === key,
leftElement: (
diff --git a/src/styles/index.ts b/src/styles/index.ts
index 4c03f8e6f2d6..d60c333ba3d8 100644
--- a/src/styles/index.ts
+++ b/src/styles/index.ts
@@ -5157,6 +5157,11 @@ const styles = (theme: ThemeColors) =>
height: 188,
},
+ pendingBankCardIllustration: {
+ width: 217,
+ height: 150,
+ },
+
cardIcon: {
overflow: 'hidden',
borderRadius: variables.cardBorderRadius,
diff --git a/src/types/onyx/CardFeeds.ts b/src/types/onyx/CardFeeds.ts
index 4a3eb5632d83..10938f710b5c 100644
--- a/src/types/onyx/CardFeeds.ts
+++ b/src/types/onyx/CardFeeds.ts
@@ -2,9 +2,16 @@ import type {ValueOf} from 'type-fest';
import type CONST from '@src/CONST';
import type * as OnyxCommon from './OnyxCommon';
-/** Card feed provider */
+/** Card feed */
type CompanyCardFeed = ValueOf;
+/** Card feed provider */
+type CardFeedProvider =
+ | typeof CONST.COMPANY_CARD.FEED_BANK_NAME.MASTER_CARD
+ | typeof CONST.COMPANY_CARD.FEED_BANK_NAME.VISA
+ | typeof CONST.COMPANY_CARD.FEED_BANK_NAME.AMEX
+ | typeof CONST.COMPANY_CARD.FEED_BANK_NAME.STRIPE;
+
/** Card feed data */
type CardFeedData = {
/** Whether any actions are pending */
@@ -50,7 +57,7 @@ type CardFeeds = {
/** Data required to be sent to add a new card */
type AddNewCardFeedData = {
/** Card feed provider */
- feedType: CompanyCardFeed;
+ feedType: CardFeedProvider;
/** Name of the card */
cardTitle: string;
@@ -84,4 +91,4 @@ type AddNewCompanyCardFeed = {
};
export default CardFeeds;
-export type {AddNewCardFeedStep, AddNewCompanyCardFeed, AddNewCardFeedData, CardFeedData, CompanyCardFeed};
+export type {AddNewCardFeedStep, AddNewCompanyCardFeed, AddNewCardFeedData, CardFeedData, CompanyCardFeed, CardFeedProvider};
diff --git a/tests/actions/ReportTest.ts b/tests/actions/ReportTest.ts
index 0ffb0ee9bc08..5ac32866c77f 100644
--- a/tests/actions/ReportTest.ts
+++ b/tests/actions/ReportTest.ts
@@ -3,6 +3,7 @@ import {afterEach, beforeAll, beforeEach, describe, expect, it} from '@jest/glob
import {toZonedTime} from 'date-fns-tz';
import Onyx from 'react-native-onyx';
import type {OnyxCollection, OnyxEntry, OnyxUpdate} from 'react-native-onyx';
+import {WRITE_COMMANDS} from '@libs/API/types';
import CONST from '@src/CONST';
import OnyxUpdateManager from '@src/libs/actions/OnyxUpdateManager';
import * as PersistedRequests from '@src/libs/actions/PersistedRequests';
@@ -757,4 +758,54 @@ describe('actions/Report', () => {
expect(reportActionReaction?.[EMOJI.name].users[TEST_USER_ACCOUNT_ID]).toBeUndefined();
});
});
+
+ it.only('should send only one OpenReport, replacing any extra ones with same reportIDs', async () => {
+ global.fetch = TestHelper.getGlobalFetchMock();
+
+ const REPORT_ID = '1';
+
+ await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true});
+ await waitForBatchedUpdates();
+
+ for (let i = 0; i < 5; i++) {
+ Report.openReport(REPORT_ID, undefined, ['test@user.com'], {
+ isOptimisticReport: true,
+ reportID: REPORT_ID,
+ });
+ }
+
+ expect(PersistedRequests.getAll().length).toBe(1);
+
+ await Onyx.set(ONYXKEYS.NETWORK, {isOffline: false});
+ await waitForBatchedUpdates();
+
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 1);
+ });
+
+ it.only('should replace duplicate OpenReport commands with the same reportID', async () => {
+ global.fetch = TestHelper.getGlobalFetchMock();
+
+ const REPORT_ID = '1';
+
+ await Onyx.set(ONYXKEYS.NETWORK, {isOffline: true});
+ await waitForBatchedUpdates();
+
+ for (let i = 0; i < 8; i++) {
+ let reportID = REPORT_ID;
+ if (i > 4) {
+ reportID = `${i}`;
+ }
+ Report.openReport(reportID, undefined, ['test@user.com'], {
+ isOptimisticReport: true,
+ reportID: REPORT_ID,
+ });
+ }
+
+ expect(PersistedRequests.getAll().length).toBe(4);
+
+ await Onyx.set(ONYXKEYS.NETWORK, {isOffline: false});
+ await waitForBatchedUpdates();
+
+ TestHelper.expectAPICommandToHaveBeenCalled(WRITE_COMMANDS.OPEN_REPORT, 4);
+ });
});
diff --git a/tests/unit/FastSearchTest.ts b/tests/unit/FastSearchTest.ts
deleted file mode 100644
index 029e05e15b1f..000000000000
--- a/tests/unit/FastSearchTest.ts
+++ /dev/null
@@ -1,118 +0,0 @@
-import FastSearch from '../../src/libs/FastSearch';
-
-describe('FastSearch', () => {
- it('should insert, and find the word', () => {
- const {search} = FastSearch.createFastSearch([
- {
- data: ['banana'],
- toSearchableString: (data) => data,
- },
- ]);
- expect(search('an')).toEqual([['banana']]);
- });
-
- it('should work with multiple words', () => {
- const {search} = FastSearch.createFastSearch([
- {
- data: ['banana', 'test'],
- toSearchableString: (data) => data,
- },
- ]);
-
- expect(search('es')).toEqual([['test']]);
- });
-
- it('should work when providing two data sets', () => {
- const {search} = FastSearch.createFastSearch([
- {
- data: ['erica', 'banana'],
- toSearchableString: (data) => data,
- },
- {
- data: ['banana', 'test'],
- toSearchableString: (data) => data,
- },
- ]);
-
- expect(search('es')).toEqual([[], ['test']]);
- });
-
- it('should work with numbers', () => {
- const {search} = FastSearch.createFastSearch([
- {
- data: [1, 2, 3, 4, 5],
- toSearchableString: (data) => String(data),
- },
- ]);
-
- expect(search('2')).toEqual([[2]]);
- });
-
- it('should work with unicodes', () => {
- const {search} = FastSearch.createFastSearch([
- {
- data: ['banana', 'ñèşťǒř', 'test'],
- toSearchableString: (data) => data,
- },
- ]);
-
- expect(search('èşť')).toEqual([['ñèşťǒř']]);
- });
-
- it('should work with words containing "reserved special characters"', () => {
- const {search} = FastSearch.createFastSearch([
- {
- data: ['ba|nana', 'te{st', 'he}llo'],
- toSearchableString: (data) => data,
- },
- ]);
-
- expect(search('st')).toEqual([['te{st']]);
- expect(search('llo')).toEqual([['he}llo']]);
- expect(search('nana')).toEqual([['ba|nana']]);
- });
-
- it('should be case insensitive', () => {
- const {search} = FastSearch.createFastSearch([
- {
- data: ['banana', 'TeSt', 'TEST', 'X'],
- toSearchableString: (data) => data,
- },
- ]);
-
- expect(search('test')).toEqual([['TeSt', 'TEST']]);
- });
-
- it('should work with large random data sets', () => {
- const data = Array.from({length: 1000}, () => {
- return Array.from({length: Math.floor(Math.random() * 22 + 9)}, () => {
- const alphabet = 'abcdefghijklmnopqrstuvwxyz0123456789@-_.';
- return alphabet.charAt(Math.floor(Math.random() * alphabet.length));
- }).join('');
- });
-
- const {search} = FastSearch.createFastSearch([
- {
- data,
- toSearchableString: (x) => x,
- },
- ]);
-
- data.forEach((word) => {
- expect(search(word)).toEqual([expect.arrayContaining([word])]);
- });
- });
-
- it('should find email addresses without dots', () => {
- const {search} = FastSearch.createFastSearch([
- {
- data: ['test.user@example.com', 'unrelated'],
- toSearchableString: (data) => data,
- },
- ]);
-
- expect(search('testuser')).toEqual([['test.user@example.com']]);
- expect(search('test.user')).toEqual([['test.user@example.com']]);
- expect(search('examplecom')).toEqual([['test.user@example.com']]);
- });
-});
diff --git a/tests/unit/OptionsListUtilsTest.ts b/tests/unit/OptionsListUtilsTest.ts
index 5a0cd6638a07..b74779e5046c 100644
--- a/tests/unit/OptionsListUtilsTest.ts
+++ b/tests/unit/OptionsListUtilsTest.ts
@@ -411,7 +411,8 @@ describe('OptionsListUtils', () => {
expect(results.personalDetails.length).toBe(9);
// Then all of the reports should be shown including the archived rooms.
- expect(results.recentReports.length).toBe(Object.values(OPTIONS.reports).length);
+ // - 1 because when we create the option list we also create a workspace chat optimistic options for the old oldot policies or polices with isPolicyExpenseChatEnabled: false, in this case we have a policy "Hero Policy" with isPolicyExpenseChatEnabled: false, More info: https://github.com/Expensify/App/issues/49344
+ expect(results.recentReports.length).toBe(Object.values(OPTIONS.reports).length - 1);
});
it('getFilteredOptions()', () => {
@@ -608,8 +609,8 @@ describe('OptionsListUtils', () => {
// When we pass an empty search value
let results = OptionsListUtils.getShareDestinationOptions(filteredReports, OPTIONS.personalDetails, [], '');
- // Then we should expect all the recent reports to show but exclude the archived rooms
- expect(results.recentReports.length).toBe(Object.values(OPTIONS.reports).length - 1);
+ // Then we should expect all the recent reports to show but exclude the archived rooms and workspace policy chat report
+ expect(results.recentReports.length).toBe(Object.values(OPTIONS.reports).length - 2);
// Filter current REPORTS_WITH_WORKSPACE_ROOMS as we do in the component, before getting share destination options
const filteredReportsWithWorkspaceRooms = Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).reduce((filtered, option) => {
@@ -624,8 +625,8 @@ describe('OptionsListUtils', () => {
// When we also have a policy to return rooms in the results
results = OptionsListUtils.getShareDestinationOptions(filteredReportsWithWorkspaceRooms, OPTIONS.personalDetails, [], '');
// Then we should expect the DMS, the group chats and the workspace room to show
- // We should expect all the recent reports to show, excluding the archived rooms
- expect(results.recentReports.length).toBe(Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).length - 1);
+ // We should expect all the recent reports to show, excluding the archived rooms and workspace policy chat report
+ expect(results.recentReports.length).toBe(Object.values(OPTIONS_WITH_WORKSPACE_ROOM.reports).length - 2);
});
it('getMemberInviteOptions()', () => {
diff --git a/tests/unit/RequestConflictUtilsTest.ts b/tests/unit/RequestConflictUtilsTest.ts
index d2d003192456..103834ce52d2 100644
--- a/tests/unit/RequestConflictUtilsTest.ts
+++ b/tests/unit/RequestConflictUtilsTest.ts
@@ -5,7 +5,7 @@ describe('RequestConflictUtils', () => {
it.each([['OpenApp'], ['ReconnectApp']])('resolveDuplicationConflictAction when %s do not exist in the queue should push %i', (command) => {
const persistedRequests = [{command: 'OpenReport'}, {command: 'AddComment'}, {command: 'CloseAccount'}];
const commandToFind = command as WriteCommand;
- const result = resolveDuplicationConflictAction(persistedRequests, commandToFind);
+ const result = resolveDuplicationConflictAction(persistedRequests, (request) => request.command === commandToFind);
expect(result).toEqual({conflictAction: {type: 'push'}});
});
@@ -15,7 +15,21 @@ describe('RequestConflictUtils', () => {
])('resolveDuplicationConflictAction when %s exist in the queue should replace at index %i', (command, index) => {
const persistedRequests = [{command: 'OpenApp'}, {command: 'AddComment'}, {command: 'ReconnectApp'}];
const commandToFind = command as WriteCommand;
- const result = resolveDuplicationConflictAction(persistedRequests, commandToFind);
+ const result = resolveDuplicationConflictAction(persistedRequests, (request) => request.command === commandToFind);
expect(result).toEqual({conflictAction: {type: 'replace', index}});
});
+
+ it('replaces the first OpenReport command with reportID 1 in case of duplication conflict', () => {
+ const persistedRequests = [
+ {command: 'OpenApp'},
+ {command: 'AddComment'},
+ {command: 'OpenReport', data: {reportID: 1}},
+ {command: 'OpenReport', data: {reportID: 2}},
+ {command: 'OpenReport', data: {reportID: 3}},
+ {command: 'ReconnectApp'},
+ ];
+ const reportID = 1;
+ const result = resolveDuplicationConflictAction(persistedRequests, (request) => request.command === 'OpenReport' && request.data?.reportID === reportID);
+ expect(result).toEqual({conflictAction: {type: 'replace', index: 2}});
+ });
});
diff --git a/tests/unit/SuffixUkkonenTreeTest.ts b/tests/unit/SuffixUkkonenTreeTest.ts
deleted file mode 100644
index c0c556c16e14..000000000000
--- a/tests/unit/SuffixUkkonenTreeTest.ts
+++ /dev/null
@@ -1,63 +0,0 @@
-import SuffixUkkonenTree from '@libs/SuffixUkkonenTree/index';
-
-describe('SuffixUkkonenTree', () => {
- // The suffix tree doesn't take strings, but expects an array buffer, where strings have been separated by a delimiter.
- function helperStringsToNumericForTree(strings: string[]) {
- const numericLists = strings.map((s) => SuffixUkkonenTree.stringToNumeric(s, {clamp: true}));
- const numericList = numericLists.reduce(
- (acc, {numeric}) => {
- acc.push(...numeric, SuffixUkkonenTree.DELIMITER_CHAR_CODE);
- return acc;
- },
- // The value we pass to makeTree needs to be offset by one
- [0],
- );
- numericList.push(SuffixUkkonenTree.END_CHAR_CODE);
- return Uint8Array.from(numericList);
- }
-
- it('should insert, build, and find all occurrences', () => {
- const strings = ['banana', 'pancake'];
- const numericIntArray = helperStringsToNumericForTree(strings);
-
- const tree = SuffixUkkonenTree.makeTree(numericIntArray);
- tree.build();
- const searchValue = SuffixUkkonenTree.stringToNumeric('an', {clamp: true}).numeric;
- expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([2, 4, 9]));
- });
-
- it('should find by first character', () => {
- const strings = ['pancake', 'banana'];
- const numericIntArray = helperStringsToNumericForTree(strings);
- const tree = SuffixUkkonenTree.makeTree(numericIntArray);
- tree.build();
- const searchValue = SuffixUkkonenTree.stringToNumeric('p', {clamp: true}).numeric;
- expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([1]));
- });
-
- it('should handle identical words', () => {
- const strings = ['banana', 'banana', 'x'];
- const numericIntArray = helperStringsToNumericForTree(strings);
- const tree = SuffixUkkonenTree.makeTree(numericIntArray);
- tree.build();
- const searchValue = SuffixUkkonenTree.stringToNumeric('an', {clamp: true}).numeric;
- expect(tree.findSubstring(Array.from(searchValue))).toEqual(expect.arrayContaining([2, 4, 9, 11]));
- });
-
- it('should convert string to numeric with a list of chars to skip', () => {
- const {numeric} = SuffixUkkonenTree.stringToNumeric('abcabc', {
- charSetToSkip: new Set(['b']),
- clamp: true,
- });
- expect(Array.from(numeric)).toEqual([0, 2, 0, 2]);
- });
-
- it('should convert string outside of a-z to numeric with clamping', () => {
- const {numeric} = SuffixUkkonenTree.stringToNumeric('2', {
- clamp: true,
- });
-
- // "2" in ASCII is 50, so base26(50) = [0, 23]
- expect(Array.from(numeric)).toEqual([SuffixUkkonenTree.SPECIAL_CHAR_CODE, 0, 23]);
- });
-});
diff --git a/tests/unit/useIndicatorStatusTest.tsx b/tests/unit/useIndicatorStatusTest.tsx
new file mode 100644
index 000000000000..fad619419227
--- /dev/null
+++ b/tests/unit/useIndicatorStatusTest.tsx
@@ -0,0 +1,239 @@
+import {renderHook} from '@testing-library/react-native';
+import type {OnyxMultiSetInput} from 'react-native-onyx';
+import Onyx from 'react-native-onyx';
+import type {IndicatorStatus} from '@hooks/useIndicatorStatus';
+import useIndicatorStatus from '@hooks/useIndicatorStatus';
+// eslint-disable-next-line no-restricted-imports
+import {defaultTheme} from '@styles/theme';
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import waitForBatchedUpdates from '../utils/waitForBatchedUpdates';
+
+const getMockForStatus = (status: IndicatorStatus) =>
+ ({
+ [`${ONYXKEYS.COLLECTION.POLICY}1` as const]: {
+ id: '1',
+ name: 'Workspace 1',
+ owner: 'johndoe12@expensify.com',
+ customUnits:
+ status === CONST.INDICATOR_STATUS.HAS_CUSTOM_UNITS_ERROR
+ ? {
+ errors: {
+ error: 'Something went wrong',
+ },
+ }
+ : undefined,
+ },
+ [`${ONYXKEYS.COLLECTION.POLICY}2` as const]: {
+ id: '2',
+ name: 'Workspace 2',
+ owner: 'johndoe12@expensify.com',
+ errors:
+ status === CONST.INDICATOR_STATUS.HAS_POLICY_ERRORS
+ ? {
+ error: 'Something went wrong',
+ }
+ : undefined,
+ },
+ [`${ONYXKEYS.COLLECTION.POLICY}3` as const]: {
+ id: '3',
+ name: 'Workspace 3',
+ owner: 'johndoe12@expensify.com',
+ employeeList: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ 'johndoe12@expensify.com': {
+ email: 'johndoe12@expensify.com',
+ errors:
+ status === CONST.INDICATOR_STATUS.HAS_EMPLOYEE_LIST_ERROR
+ ? {
+ error: 'Something went wrong',
+ }
+ : undefined,
+ },
+ },
+ },
+ [`${ONYXKEYS.COLLECTION.POLICY}4` as const]: {
+ id: '4',
+ name: 'Workspace 4',
+ owner: 'johndoe12@expensify.com',
+ connections:
+ status === CONST.INDICATOR_STATUS.HAS_SYNC_ERRORS
+ ? {
+ xero: {
+ lastSync: {
+ isSuccessful: false,
+ errorDate: new Date().toISOString(),
+ },
+ },
+ }
+ : undefined,
+ },
+ [ONYXKEYS.BANK_ACCOUNT_LIST]: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ 12345: {
+ methodID: 12345,
+ errors:
+ status === CONST.INDICATOR_STATUS.HAS_PAYMENT_METHOD_ERROR
+ ? {
+ error: 'Something went wrong',
+ }
+ : undefined,
+ },
+ },
+ [ONYXKEYS.USER_WALLET]: {
+ bankAccountID: 12345,
+ errors:
+ status === CONST.INDICATOR_STATUS.HAS_USER_WALLET_ERRORS
+ ? {
+ error: 'Something went wrong',
+ }
+ : undefined,
+ },
+ [ONYXKEYS.WALLET_TERMS]: {
+ errors:
+ status === CONST.INDICATOR_STATUS.HAS_WALLET_TERMS_ERRORS
+ ? {
+ error: 'Something went wrong',
+ }
+ : undefined,
+ },
+ [ONYXKEYS.LOGIN_LIST]: {
+ // eslint-disable-next-line @typescript-eslint/naming-convention
+ 'johndoe12@expensify.com': {
+ partnerName: 'John Doe',
+ partnerUserID: 'johndoe12@expensify.com',
+ validatedDate: status !== CONST.INDICATOR_STATUS.HAS_LOGIN_LIST_INFO ? new Date().toISOString() : undefined,
+ errorFields:
+ status === CONST.INDICATOR_STATUS.HAS_LOGIN_LIST_ERROR
+ ? {
+ field: {
+ error: 'Something went wrong',
+ },
+ }
+ : undefined,
+ },
+ },
+ [ONYXKEYS.REIMBURSEMENT_ACCOUNT]: {
+ achData: {
+ bankAccountID: 12345,
+ },
+ errors:
+ status === CONST.INDICATOR_STATUS.HAS_REIMBURSEMENT_ACCOUNT_ERRORS
+ ? {
+ error: 'Something went wrong',
+ }
+ : undefined,
+ },
+ [ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_SUCCESSFUL]: status === CONST.INDICATOR_STATUS.HAS_SUBSCRIPTION_INFO,
+ [ONYXKEYS.SUBSCRIPTION_RETRY_BILLING_STATUS_FAILED]: status === CONST.INDICATOR_STATUS.HAS_SUBSCRIPTION_ERRORS,
+ } as OnyxMultiSetInput);
+
+type TestCase = {
+ name: string;
+ indicatorColor: string;
+ status: IndicatorStatus;
+ policyIDWithErrors: string | undefined;
+};
+
+const TEST_CASES: TestCase[] = [
+ {
+ name: 'has policy errors',
+ indicatorColor: defaultTheme.danger,
+ status: CONST.INDICATOR_STATUS.HAS_POLICY_ERRORS,
+ policyIDWithErrors: '2',
+ },
+ {
+ name: 'has custom units error',
+ indicatorColor: defaultTheme.danger,
+ status: CONST.INDICATOR_STATUS.HAS_CUSTOM_UNITS_ERROR,
+ policyIDWithErrors: '1',
+ },
+ {
+ name: 'has employee list error',
+ indicatorColor: defaultTheme.danger,
+ status: CONST.INDICATOR_STATUS.HAS_EMPLOYEE_LIST_ERROR,
+ policyIDWithErrors: '3',
+ },
+ {
+ name: 'has sync errors',
+ indicatorColor: defaultTheme.danger,
+ status: CONST.INDICATOR_STATUS.HAS_SYNC_ERRORS,
+ policyIDWithErrors: '4',
+ },
+ {
+ name: 'has user wallet errors',
+ indicatorColor: defaultTheme.danger,
+ status: CONST.INDICATOR_STATUS.HAS_USER_WALLET_ERRORS,
+ policyIDWithErrors: undefined,
+ },
+ {
+ name: 'has payment method error',
+ indicatorColor: defaultTheme.danger,
+ status: CONST.INDICATOR_STATUS.HAS_PAYMENT_METHOD_ERROR,
+ policyIDWithErrors: undefined,
+ },
+ {
+ name: 'has subscription errors',
+ indicatorColor: defaultTheme.danger,
+ status: CONST.INDICATOR_STATUS.HAS_SUBSCRIPTION_ERRORS,
+ policyIDWithErrors: undefined,
+ },
+ {
+ name: 'has reimbursement account errors',
+ indicatorColor: defaultTheme.danger,
+ status: CONST.INDICATOR_STATUS.HAS_REIMBURSEMENT_ACCOUNT_ERRORS,
+ policyIDWithErrors: undefined,
+ },
+ {
+ name: 'has login list error',
+ indicatorColor: defaultTheme.danger,
+ status: CONST.INDICATOR_STATUS.HAS_LOGIN_LIST_ERROR,
+ policyIDWithErrors: undefined,
+ },
+ {
+ name: 'has wallet terms errors',
+ indicatorColor: defaultTheme.danger,
+ status: CONST.INDICATOR_STATUS.HAS_WALLET_TERMS_ERRORS,
+ policyIDWithErrors: undefined,
+ },
+ {
+ name: 'has login list info',
+ indicatorColor: defaultTheme.success,
+ status: CONST.INDICATOR_STATUS.HAS_LOGIN_LIST_INFO,
+ policyIDWithErrors: undefined,
+ },
+ {
+ name: 'has subscription info',
+ indicatorColor: defaultTheme.success,
+ status: CONST.INDICATOR_STATUS.HAS_SUBSCRIPTION_INFO,
+ policyIDWithErrors: undefined,
+ },
+];
+
+describe('useIndicatorStatusTest', () => {
+ beforeAll(() => {
+ Onyx.init({
+ keys: ONYXKEYS,
+ });
+ });
+ describe.each(TEST_CASES)('$name', (testCase) => {
+ beforeAll(() => {
+ return Onyx.multiSet(getMockForStatus(testCase.status)).then(waitForBatchedUpdates);
+ });
+ it('returns correct indicatorColor', () => {
+ const {result} = renderHook(() => useIndicatorStatus());
+ const {indicatorColor} = result.current;
+ expect(indicatorColor).toBe(testCase.indicatorColor);
+ });
+ it('returns correct status', () => {
+ const {result} = renderHook(() => useIndicatorStatus());
+ const {status} = result.current;
+ expect(status).toBe(testCase.status);
+ });
+ it('returns correct policyIDWithErrors', () => {
+ const {result} = renderHook(() => useIndicatorStatus());
+ const {policyIDWithErrors} = result.current;
+ expect(policyIDWithErrors).toBe(testCase.policyIDWithErrors);
+ });
+ });
+});