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)}} + +