diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 23188706a6e21..aab76f4aab108 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -2455,6 +2455,10 @@ const ROUTES = { route: 'workspaces/:policyID/company-cards/:feed/broken-card-feed-connection', getRoute: (policyID: string, feed: CompanyCardFeedWithDomainID) => `workspaces/${policyID}/company-cards/${encodeURIComponent(feed)}/broken-card-feed-connection` as const, }, + WORKSPACE_COMPANY_CARDS_REFRESH_CARD_FEED_CONNECTION: { + route: 'workspaces/:policyID/company-cards/:feed/refresh-card-feed-connection', + getRoute: (policyID: string, feed: CompanyCardFeedWithDomainID) => `workspaces/${policyID}/company-cards/${encodeURIComponent(feed)}/refresh-card-feed-connection` as const, + }, WORKSPACE_COMPANY_CARDS_ASSIGN_CARD_ASSIGNEE: { route: 'workspaces/:policyID/company-cards/:feed/assign-card/:cardID/assignee', getRoute: (params: WorkspaceCompanyCardsAssignCardParams, backTo?: string) => diff --git a/src/SCREENS.ts b/src/SCREENS.ts index 6c2599a91dc50..6f816b182e459 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -636,6 +636,7 @@ const SCREENS = { PROFILE: 'Workspace_Overview', COMPANY_CARDS: 'Workspace_CompanyCards', COMPANY_CARDS_BROKEN_CARD_FEED_CONNECTION: 'Workspace_CompanyCards_BrokenCardFeedConnection', + COMPANY_CARDS_REFRESH_CARD_FEED_CONNECTION: 'Workspace_CompanyCards_RefreshCardFeedConnection', COMPANY_CARDS_ASSIGN_CARD_ASSIGNEE: 'Workspace_CompanyCards_AssignCard_Assignee', COMPANY_CARDS_ASSIGN_CARD_BANK_CONNECTION: 'Workspace_CompanyCards_AssignCard_Bank_Connection', COMPANY_CARDS_ASSIGN_CARD_PLAID_CONNECTION: 'Workspace_CompanyCards_AssignCard_Plaid_Connection', diff --git a/src/languages/de.ts b/src/languages/de.ts index f84f0da1aba12..721cb1ee27149 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -5412,6 +5412,10 @@ _Für ausführlichere Anweisungen [besuchen Sie unsere Hilfeseite](${CONST.NETSU removeCardFeed: 'Kartenfeed entfernen', removeCardFeedTitle: (feedName: string) => `${feedName}-Feed entfernen`, removeCardFeedDescription: 'Möchtest du diesen Kartenfeed wirklich entfernen? Dadurch werden alle Karten zugewiesen.', + assignNewCards: 'Neue Karten zuweisen', + assignNewCardsDescription: 'Die neuesten Karten von deiner Bank zum Zuweisen abrufen', + refreshConnectionSuccess: 'Verbindung aktualisiert', + refreshConnectionSuccessDescription: 'Deine Bankverbindung wurde erfolgreich erneut authentifiziert. Du kannst jetzt neue Karten zuweisen.', error: { feedNameRequired: 'Name des Kartenfeeds ist erforderlich', statementCloseDateRequired: 'Bitte wählen Sie ein Abrechnungsenddatum aus.', diff --git a/src/languages/en.ts b/src/languages/en.ts index c92d5bd001659..a0aab6d43cfbf 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -5396,6 +5396,10 @@ const translations = { removeCardFeed: 'Remove card feed', removeCardFeedTitle: (feedName: string) => `Remove ${feedName} feed`, removeCardFeedDescription: 'Are you sure you want to remove this card feed? This will unassign all cards.', + assignNewCards: 'Assign new cards', + assignNewCardsDescription: 'Get the latest cards to assign from your bank', + refreshConnectionSuccess: 'Connection refreshed', + refreshConnectionSuccessDescription: 'Your bank connection has been re-authenticated successfully. You can now assign new cards.', error: { feedNameRequired: 'Card feed name is required', statementCloseDateRequired: 'Please select a statement close date.', diff --git a/src/languages/es.ts b/src/languages/es.ts index 1baa6afa156d0..6175b73ce263f 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5250,6 +5250,10 @@ ${amount} para ${merchant} - ${date}`, removeCardFeed: 'Quitar la alimentación de tarjetas', removeCardFeedTitle: (feedName) => `Eliminar el feed de ${feedName}`, removeCardFeedDescription: '¿Estás seguro de que deseas eliminar esta fuente de tarjetas? Esto anulará la asignación de todas las tarjetas.', + assignNewCards: 'Asignar nuevas tarjetas', + assignNewCardsDescription: 'Obtén las últimas tarjetas de tu banco para asignar', + refreshConnectionSuccess: 'Conexión actualizada', + refreshConnectionSuccessDescription: 'La conexión con tu banco ha sido reautenticada exitosamente. Ahora puedes asignar nuevas tarjetas.', error: { feedNameRequired: 'Se requiere el nombre de la fuente de la tarjeta', statementCloseDateRequired: 'Por favor, selecciona una fecha de cierre del estado de cuenta.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index b65c07543413a..4bace7a8436a1 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -5431,6 +5431,10 @@ _Pour des instructions plus détaillées, [visitez notre site d’aide](${CONST. removeCardFeed: 'Supprimer le flux de cartes', removeCardFeedTitle: (feedName: string) => `Supprimer le flux ${feedName}`, removeCardFeedDescription: "Voulez-vous vraiment supprimer ce flux de cartes ? Cela retirera l'assignation de toutes les cartes.", + assignNewCards: 'Attribuer de nouvelles cartes', + assignNewCardsDescription: 'Obtenez les dernières cartes à attribuer depuis votre banque', + refreshConnectionSuccess: 'Connexion actualisée', + refreshConnectionSuccessDescription: 'Votre connexion bancaire a été ré-authentifiée avec succès. Vous pouvez maintenant attribuer de nouvelles cartes.', error: { feedNameRequired: 'Le nom du flux de carte est obligatoire', statementCloseDateRequired: 'Veuillez sélectionner une date de clôture de relevé.', diff --git a/src/languages/it.ts b/src/languages/it.ts index babe2339b9f66..69b90b5068100 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -5401,6 +5401,10 @@ _Per istruzioni più dettagliate, [visita il nostro sito di assistenza](${CONST. removeCardFeed: 'Rimuovi flusso carta', removeCardFeedTitle: (feedName: string) => `Rimuovi feed ${feedName}`, removeCardFeedDescription: 'Sei sicuro di voler rimuovere questo flusso di carte? Questo rimuoverà l’assegnazione di tutte le carte.', + assignNewCards: 'Assegna nuove carte', + assignNewCardsDescription: 'Ottieni le ultime carte da assegnare dalla tua banca', + refreshConnectionSuccess: 'Connessione aggiornata', + refreshConnectionSuccessDescription: 'La connessione bancaria è stata riautenticata con successo. Ora puoi assegnare nuove carte.', error: { feedNameRequired: 'Il nome del feed della carta è obbligatorio', statementCloseDateRequired: 'Seleziona una data di chiusura dell’estratto conto.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index b3a7c6691b6c9..5d6e1dfeb990d 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -5343,6 +5343,10 @@ _詳しい手順については、[ヘルプサイトをご覧ください](${CO removeCardFeed: 'カードフィードを削除', removeCardFeedTitle: (feedName: string) => `${feedName}フィードを削除`, removeCardFeedDescription: 'このカードフィードを削除してもよろしいですか?すべてのカードの割り当てが解除されます。', + assignNewCards: '新しいカードを割り当てる', + assignNewCardsDescription: '銀行から最新のカードを取得して割り当てます', + refreshConnectionSuccess: '接続が更新されました', + refreshConnectionSuccessDescription: '銀行接続の再認証が正常に完了しました。新しいカードを割り当てることができます。', error: { feedNameRequired: 'カードフィード名は必須です', statementCloseDateRequired: '明細書の締め日を選択してください。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index be5b70b72ab6f..eda47ea634f54 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -5383,6 +5383,10 @@ _Voor meer gedetailleerde instructies, [bezoek onze help-site](${CONST.NETSUITE_ removeCardFeed: 'Kaartfeed verwijderen', removeCardFeedTitle: (feedName: string) => `Feed ${feedName} verwijderen`, removeCardFeedDescription: 'Weet je zeker dat je deze kaartfeed wilt verwijderen? Hierdoor worden alle kaarten losgekoppeld.', + assignNewCards: 'Nieuwe kaarten toewijzen', + assignNewCardsDescription: 'Haal de nieuwste kaarten op van je bank om toe te wijzen', + refreshConnectionSuccess: 'Verbinding vernieuwd', + refreshConnectionSuccessDescription: 'Je bankverbinding is succesvol opnieuw geverifieerd. Je kunt nu nieuwe kaarten toewijzen.', error: { feedNameRequired: 'Naam van kaartfeed is vereist', statementCloseDateRequired: 'Selecteer een afsluitdatum voor het afschrift.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 2fb31cbf66bd7..856ee8b78c2e5 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -5378,6 +5378,10 @@ _Aby uzyskać bardziej szczegółowe instrukcje, [odwiedź naszą stronę pomocy removeCardFeed: 'Usuń źródło karty', removeCardFeedTitle: (feedName: string) => `Usuń strumień ${feedName}`, removeCardFeedDescription: 'Na pewno chcesz usunąć ten kanał kart? Spowoduje to odłączenie wszystkich kart.', + assignNewCards: 'Przypisz nowe karty', + assignNewCardsDescription: 'Pobierz najnowsze karty z banku do przypisania', + refreshConnectionSuccess: 'Połączenie odświeżone', + refreshConnectionSuccessDescription: 'Połączenie z bankiem zostało pomyślnie ponownie uwierzytelnione. Możesz teraz przypisać nowe karty.', error: { feedNameRequired: 'Nazwa źródła karty jest wymagana', statementCloseDateRequired: 'Wybierz datę zamknięcia wyciągu.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index eaf08826159ef..620d41b15f384 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -5383,6 +5383,10 @@ _Para instruções mais detalhadas, [visite nossa central de ajuda](${CONST.NETS removeCardFeed: 'Remover feed do cartão', removeCardFeedTitle: (feedName: string) => `Remover feed ${feedName}`, removeCardFeedDescription: 'Tem certeza de que deseja remover este feed de cartão? Isso removerá a atribuição de todos os cartões.', + assignNewCards: 'Atribuir novos cartões', + assignNewCardsDescription: 'Obtenha os cartões mais recentes do seu banco para atribuir', + refreshConnectionSuccess: 'Conexão atualizada', + refreshConnectionSuccessDescription: 'Sua conexão bancária foi reautenticada com sucesso. Agora você pode atribuir novos cartões.', error: { feedNameRequired: 'O nome do feed do cartão é obrigatório', statementCloseDateRequired: 'Selecione uma data de fechamento do extrato.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index b4f23ecbec1c9..f148448f26cfa 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -5260,6 +5260,10 @@ _如需更详细的说明,请[访问我们的帮助网站](${CONST.NETSUITE_IM removeCardFeed: '移除卡片流水', removeCardFeedTitle: (feedName: string) => `移除 ${feedName} 数据源`, removeCardFeedDescription: '确定要移除此卡片数据源吗?这将取消分配所有卡片。', + assignNewCards: '分配新卡片', + assignNewCardsDescription: '从银行获取最新的卡片进行分配', + refreshConnectionSuccess: '连接已刷新', + refreshConnectionSuccessDescription: '您的银行连接已成功重新验证。您现在可以分配新卡片。', error: { feedNameRequired: '必须填写卡片流水名称', statementCloseDateRequired: '请选择账单结算日期。', diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index 30d5ba47887e8..c8804379da110 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -793,6 +793,7 @@ const SettingsModalStackNavigator = createModalStackNavigator require('../../../../pages/workspace/invoices/WorkspaceInvoicingDetailsWebsite').default, [SCREENS.WORKSPACE.INVOICES_VERIFY_ACCOUNT]: () => require('../../../../pages/workspace/invoices/WorkspaceInvoicesVerifyAccountPage').default, [SCREENS.WORKSPACE.COMPANY_CARDS_BROKEN_CARD_FEED_CONNECTION]: () => require('../../../../pages/workspace/companyCards/BrokenCardFeedConnectionPage').default, + [SCREENS.WORKSPACE.COMPANY_CARDS_REFRESH_CARD_FEED_CONNECTION]: () => require('../../../../pages/workspace/companyCards/RefreshCardFeedConnectionPage').default, [SCREENS.WORKSPACE.COMPANY_CARDS_ASSIGN_CARD_ASSIGNEE]: () => require('../../../../pages/workspace/companyCards/assignCard/AssigneeStep').default, [SCREENS.WORKSPACE.COMPANY_CARDS_ASSIGN_CARD_CARD_SELECTION]: () => require('../../../../pages/workspace/companyCards/assignCard/CardSelectionStep').default, [SCREENS.WORKSPACE.COMPANY_CARDS_ASSIGN_CARD_TRANSACTION_START_DATE]: () => diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index b533a716fbef8..df0793f1e6317 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -876,6 +876,9 @@ const config: LinkingOptions['config'] = { [SCREENS.WORKSPACE.COMPANY_CARDS_BROKEN_CARD_FEED_CONNECTION]: { path: ROUTES.WORKSPACE_COMPANY_CARDS_BROKEN_CARD_FEED_CONNECTION.route, }, + [SCREENS.WORKSPACE.COMPANY_CARDS_REFRESH_CARD_FEED_CONNECTION]: { + path: ROUTES.WORKSPACE_COMPANY_CARDS_REFRESH_CARD_FEED_CONNECTION.route, + }, [SCREENS.WORKSPACE.COMPANY_CARDS_ASSIGN_CARD_ASSIGNEE]: { path: ROUTES.WORKSPACE_COMPANY_CARDS_ASSIGN_CARD_ASSIGNEE.route, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 072269fe167f3..b6be7ccd09f68 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1302,6 +1302,10 @@ type SettingsNavigatorParamList = { // eslint-disable-next-line no-restricted-syntax -- `backTo` usages in this file are legacy. Do not add new `backTo` params to screens. See contributingGuides/NAVIGATION.md backTo?: Routes; }; + [SCREENS.WORKSPACE.COMPANY_CARDS_REFRESH_CARD_FEED_CONNECTION]: { + policyID: string; + feed: CompanyCardFeedWithDomainID; + }; [SCREENS.WORKSPACE.COMPANY_CARDS_ASSIGN_CARD_ASSIGNEE]: { policyID: string; feed: CompanyCardFeedWithDomainID; diff --git a/src/pages/workspace/companyCards/BankConnection/index.native.tsx b/src/pages/workspace/companyCards/BankConnection/index.native.tsx index 870bc4928d326..a5aeea42d803b 100644 --- a/src/pages/workspace/companyCards/BankConnection/index.native.tsx +++ b/src/pages/workspace/companyCards/BankConnection/index.native.tsx @@ -1,35 +1,27 @@ -import React, {useEffect, useMemo, useRef, useState} from 'react'; +import React, {useRef, useState} from 'react'; import type {WebViewNavigation} from 'react-native-webview'; import {WebView} from 'react-native-webview'; import ActivityIndicator from '@components/ActivityIndicator'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; +import ConfirmationPage from '@components/ConfirmationPage'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; -import useCardFeeds from '@hooks/useCardFeeds'; -import useImportPlaidAccounts from '@hooks/useImportPlaidAccounts'; -import useIsBlockedToAddFeed from '@hooks/useIsBlockedToAddFeed'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; -import useUpdateFeedBrokenConnection from '@hooks/useUpdateFeedBrokenConnection'; -import {updateSelectedFeed} from '@libs/actions/Card'; -import {setAssignCardStepAndData} from '@libs/actions/CompanyCards'; -import {checkIfNewFeedConnected, getBankName, getCompanyCardFeed, isSelectedFeedExpired} from '@libs/CardUtils'; import getUAForWebView from '@libs/getUAForWebView'; import Navigation from '@libs/Navigation/Navigation'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import type {PlatformStackRouteProp} from '@navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@navigation/types'; import WorkspaceCompanyCardsErrorConfirmation from '@pages/workspace/companyCards/WorkspaceCompanyCardsErrorConfirmation'; -import {setAddNewCompanyCardStepAndData} from '@userActions/CompanyCards'; -import {getCompanyCardBankConnection} from '@userActions/getCompanyCardBankConnection'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {CompanyCardFeedWithDomainID} from '@src/types/onyx'; +import useBankConnection from './useBankConnection'; type BankConnectionProps = { /** ID of the policy */ @@ -40,37 +32,43 @@ type BankConnectionProps = { /** Route params for add new card flow */ route?: PlatformStackRouteProp; + + /** Whether this is a refresh card list flow */ + isRefreshConnectionFlow?: boolean; + + /** Called when the assign flow succeeds */ + onSuccess?: () => void; + + /** Called when the assign flow fails due to broken connection */ + onFailure?: () => void; + + /** Called when the back button is pressed */ + onBackButtonPress?: () => void; }; -function BankConnection({policyID: policyIDFromProps, feed, route}: BankConnectionProps) { +function BankConnection({policyID: policyIDFromProps, feed, route, isRefreshConnectionFlow, onSuccess, onFailure, onBackButtonPress}: BankConnectionProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); const webViewRef = useRef(null); const [session] = useOnyx(ONYXKEYS.SESSION); - const [assignCard] = useOnyx(ONYXKEYS.ASSIGN_CARD); const authToken = session?.authToken ?? null; - const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD); - const selectedBank = addNewCard?.data?.selectedBank; const {feed: bankNameFromRoute, backTo, policyID: policyIDFromRoute} = route?.params ?? {}; const policyID = policyIDFromProps ?? policyIDFromRoute; - const bankName = feed ? getBankName(getCompanyCardFeed(feed)) : (bankNameFromRoute ?? addNewCard?.data?.plaidConnectedFeed ?? selectedBank); - const plaidToken = addNewCard?.data?.publicToken ?? assignCard?.cardToAssign?.plaidAccessToken; - const isPlaid = !!plaidToken; - const url = getCompanyCardBankConnection(policyID, bankName); - const [cardFeeds] = useCardFeeds(policyID); const [isConnectionCompleted, setConnectionCompleted] = useState(false); - const prevFeedsData = usePrevious(cardFeeds); - const isFeedExpired = feed ? isSelectedFeedExpired(cardFeeds?.[feed]) : false; - const {isNewFeedConnected, newFeed} = useMemo( - () => checkIfNewFeedConnected(prevFeedsData ?? {}, cardFeeds ?? {}, addNewCard?.data?.plaidConnectedFeed), - [addNewCard?.data?.plaidConnectedFeed, cardFeeds, prevFeedsData], - ); + + const {handleBackButtonPress, url, isPlaid, isNewFeedHasError, newFeed, isAllFeedsResultLoading, isBlockedToAddNewFeeds, isRefreshComplete} = useBankConnection({ + policyID, + feed, + bankNameFromRoute, + onSuccess, + onFailure, + onBackButtonPress, + isRefreshConnectionFlow, + shouldOpenWindow: false, + }); + const headerTitleAddCards = !backTo ? translate('workspace.companyCards.addCards') : undefined; - const headerTitle = feed ? translate('workspace.companyCards.assignCard') : headerTitleAddCards; - const onImportPlaidAccounts = useImportPlaidAccounts(policyID); - const {updateBrokenConnection, isFeedConnectionBroken} = useUpdateFeedBrokenConnection({policyID, feed}); - const isNewFeedHasError = !!(newFeed && cardFeeds?.[newFeed]?.errors); - const {isBlockedToAddNewFeeds, isAllFeedsResultLoading} = useIsBlockedToAddFeed(policyID); + const headerTitle = feed ? translate(isRefreshConnectionFlow ? 'workspace.moreFeatures.companyCards.assignNewCards' : 'workspace.companyCards.assignCard') : headerTitleAddCards; const fullscreenReasonAttributes: SkeletonSpanReasonAttributes = { context: 'BankConnection', @@ -84,93 +82,63 @@ function BankConnection({policyID: policyIDFromProps, feed, route}: BankConnecti }; const renderLoading = () => ; - useEffect(() => { - if (!policyID || !isBlockedToAddNewFeeds || feed) { - return; - } - Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.UPGRADE_FEATURE_INTRO_MAPPING.companyCards.alias, ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID)), { - forceReplace: true, - }); - }, [isBlockedToAddNewFeeds, policyID, feed]); - - const handleBackButtonPress = () => { - // Handle assign card flow - if (feed) { - Navigation.goBack(); - return; - } - - // Handle add new card flow - if (backTo) { - Navigation.goBack(backTo); + const checkIfConnectionCompleted = (navState: WebViewNavigation) => { + if (!navState.url.includes(ROUTES.BANK_CONNECTION_COMPLETE)) { return; } - setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.SELECT_BANK}); + setConnectionCompleted(true); }; - useEffect(() => { - if ((!url && !isPlaid) || isNewFeedHasError) { - return; + const getContent = () => { + if (isRefreshComplete) { + return ( + Navigation.dismissModal()} + /> + ); } - - // Handle assign card flow - if (feed && !isFeedExpired) { - if (isFeedConnectionBroken) { - updateBrokenConnection(); - Navigation.goBack(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID)); - return; - } - setAssignCardStepAndData({ - currentStep: assignCard?.cardToAssign?.dateOption ? CONST.COMPANY_CARD.STEP.CONFIRMATION : CONST.COMPANY_CARD.STEP.ASSIGNEE, - isEditing: false, - }); - return; + if (isNewFeedHasError) { + return ( + + ); } - - // Handle add new card flow - if (isNewFeedConnected) { - if (newFeed) { - updateSelectedFeed(newFeed, policyID); - } - - // Direct feeds (except those added via Plaid) are created with default statement period end date. - // Redirect the user to set a custom date. - if (policyID && !isPlaid) { - setAddNewCompanyCardStepAndData({ - step: CONST.COMPANY_CARDS.STEP.SELECT_DIRECT_STATEMENT_CLOSE_DATE, - }); - } else { - Navigation.goBack(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID)); - } - } - if (isPlaid) { - onImportPlaidAccounts(); + if (!!url && !isConnectionCompleted && !isPlaid && !isAllFeedsResultLoading && (!isBlockedToAddNewFeeds || !!feed)) { + return ( + + ); } - }, [ - isNewFeedConnected, - newFeed, - policyID, - url, - feed, - isFeedExpired, - assignCard?.cardToAssign?.dateOption, - isPlaid, - onImportPlaidAccounts, - isFeedConnectionBroken, - updateBrokenConnection, - isNewFeedHasError, - ]); - - const checkIfConnectionCompleted = (navState: WebViewNavigation) => { - if (!navState.url.includes(ROUTES.BANK_CONNECTION_COMPLETE)) { - return; - } - setConnectionCompleted(true); + return ( + + ); }; return ( - - {!!url && !isConnectionCompleted && !isPlaid && !isNewFeedHasError && !isAllFeedsResultLoading && (!isBlockedToAddNewFeeds || !!feed) && ( - - )} - {(isAllFeedsResultLoading || (isBlockedToAddNewFeeds && !feed) || isConnectionCompleted || isPlaid) && !isNewFeedHasError && ( - - )} - {isNewFeedHasError && ( - - )} - + {getContent()} ); } diff --git a/src/pages/workspace/companyCards/BankConnection/index.tsx b/src/pages/workspace/companyCards/BankConnection/index.tsx index 79e91521b3ca0..7803c8b0c4234 100644 --- a/src/pages/workspace/companyCards/BankConnection/index.tsx +++ b/src/pages/workspace/companyCards/BankConnection/index.tsx @@ -1,39 +1,24 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React from 'react'; import ActivityIndicator from '@components/ActivityIndicator'; import BlockingView from '@components/BlockingViews/BlockingView'; import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; +import ConfirmationPage from '@components/ConfirmationPage'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import Text from '@components/Text'; import TextLink from '@components/TextLink'; -import useCardFeeds from '@hooks/useCardFeeds'; -import useImportPlaidAccounts from '@hooks/useImportPlaidAccounts'; -import useIsBlockedToAddFeed from '@hooks/useIsBlockedToAddFeed'; import {useMemoizedLazyIllustrations} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; -import useNetwork from '@hooks/useNetwork'; -import useOnyx from '@hooks/useOnyx'; -import usePrevious from '@hooks/usePrevious'; import useThemeStyles from '@hooks/useThemeStyles'; -import useUpdateFeedBrokenConnection from '@hooks/useUpdateFeedBrokenConnection'; -import {setAssignCardStepAndData} from '@libs/actions/CompanyCards'; -import {checkIfNewFeedConnected, getBankName, getCompanyCardFeed, isSelectedFeedExpired} from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import type {PlatformStackRouteProp} from '@navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@navigation/types'; import WorkspaceCompanyCardsErrorConfirmation from '@pages/workspace/companyCards/WorkspaceCompanyCardsErrorConfirmation'; -import {updateSelectedFeed} from '@userActions/Card'; -import {setAddNewCompanyCardStepAndData} from '@userActions/CompanyCards'; -import {getCompanyCardBankConnection} from '@userActions/getCompanyCardBankConnection'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; -import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import type {CompanyCardFeedWithDomainID} from '@src/types/onyx'; -import openBankConnection from './openBankConnection'; - -let customWindow: Window | null = null; +import useBankConnection from './useBankConnection'; type BankConnectionProps = { /** ID of the policy */ @@ -44,156 +29,70 @@ type BankConnectionProps = { /** Route params for add new card flow */ route?: PlatformStackRouteProp; + + /** Whether this is a refresh card list flow */ + isRefreshConnectionFlow?: boolean; + + /** Called when the assign flow succeeds */ + onSuccess?: () => void; + + /** Called when the assign flow fails due to broken connection */ + onFailure?: () => void; + + /** Called when the back button is pressed */ + onBackButtonPress?: () => void; }; -function BankConnection({policyID: policyIDFromProps, feed, route}: BankConnectionProps) { +function BankConnection({policyID: policyIDFromProps, feed, route, isRefreshConnectionFlow, onSuccess, onFailure, onBackButtonPress}: BankConnectionProps) { const styles = useThemeStyles(); const {translate} = useLocalize(); - const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD); - const [assignCard] = useOnyx(ONYXKEYS.ASSIGN_CARD); + const illustrations = useMemoizedLazyIllustrations(['PendingBank']); const {feed: bankNameFromRoute, backTo, policyID: policyIDFromRoute} = route?.params ?? {}; const policyID = policyIDFromProps ?? policyIDFromRoute; - const [cardFeeds] = useCardFeeds(policyID); - const prevFeedsData = usePrevious(cardFeeds); - const illustrations = useMemoizedLazyIllustrations(['PendingBank']); - const [shouldBlockWindowOpen, setShouldBlockWindowOpen] = useState(false); - const selectedBank = addNewCard?.data?.selectedBank; - const bankName = feed ? getBankName(getCompanyCardFeed(feed)) : (bankNameFromRoute ?? addNewCard?.data?.plaidConnectedFeed ?? selectedBank); - const {isNewFeedConnected, newFeed} = useMemo( - () => checkIfNewFeedConnected(prevFeedsData ?? {}, cardFeeds ?? {}, addNewCard?.data?.plaidConnectedFeed), - [addNewCard?.data?.plaidConnectedFeed, cardFeeds, prevFeedsData], - ); - const {isOffline} = useNetwork(); - const plaidToken = addNewCard?.data?.publicToken ?? assignCard?.cardToAssign?.plaidAccessToken; - const {updateBrokenConnection, isFeedConnectionBroken} = useUpdateFeedBrokenConnection({policyID, feed}); - const isPlaid = !!plaidToken; - - const url = getCompanyCardBankConnection(policyID, bankName); - const isFeedExpired = feed ? isSelectedFeedExpired(cardFeeds?.[feed]) : false; - const headerTitleAddCards = !backTo ? translate('workspace.companyCards.addCards') : undefined; - const headerTitle = feed ? translate('workspace.companyCards.assignCard') : headerTitleAddCards; - const isNewFeedHasError = !!(newFeed && cardFeeds?.[newFeed]?.errors); - const onImportPlaidAccounts = useImportPlaidAccounts(policyID); - const {isBlockedToAddNewFeeds, isAllFeedsResultLoading} = useIsBlockedToAddFeed(policyID); - - const onOpenBankConnectionFlow = useCallback(() => { - if (!url) { - return; - } - customWindow = openBankConnection(url); - }, [url]); - useEffect(() => { - if (!policyID || !isBlockedToAddNewFeeds || feed) { - return; - } - Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.UPGRADE_FEATURE_INTRO_MAPPING.companyCards.alias, ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID)), { - forceReplace: true, - }); - }, [isBlockedToAddNewFeeds, policyID, feed]); - - const handleBackButtonPress = () => { - customWindow?.close(); + const { + onOpenBankConnectionFlow, + handleBackButtonPress, + bankName, + bankDisplayName, + isPlaid, + isNewFeedHasError, + newFeed, + isAllFeedsResultLoading, + isBlockedToAddNewFeeds, + isRefreshComplete, + } = useBankConnection({ + policyID, + feed, + bankNameFromRoute, + onSuccess, + onFailure, + onBackButtonPress, + isRefreshConnectionFlow, + }); - // Handle assign card flow - if (feed) { - Navigation.goBack(); - return; - } - - // Handle add new card flow - if (backTo) { - Navigation.goBack(backTo); - return; - } - setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.SELECT_BANK}); - }; + const headerTitleAddCards = !backTo ? translate(isRefreshConnectionFlow ? 'workspace.moreFeatures.companyCards.assignNewCards' : 'workspace.companyCards.addCards') : undefined; + const headerTitle = feed ? translate(isRefreshConnectionFlow ? 'workspace.moreFeatures.companyCards.assignNewCards' : 'workspace.companyCards.assignCard') : headerTitleAddCards; const CustomSubtitle = ( - {bankName && translate(`workspace.moreFeatures.companyCards.pendingBankDescription`, addNewCard?.data?.plaidConnectedFeedName ?? bankName)} + {bankName && translate(`workspace.moreFeatures.companyCards.pendingBankDescription`, bankDisplayName ?? bankName)} {translate('workspace.moreFeatures.companyCards.pendingBankLink')}. ); - useEffect(() => { - if ((!url && !isPlaid) || isOffline || isNewFeedHasError || isAllFeedsResultLoading || (isBlockedToAddNewFeeds && !feed)) { - return; - } - - // Handle assign card flow - if (feed) { - if (!isFeedExpired) { - customWindow?.close(); - if (isFeedConnectionBroken) { - updateBrokenConnection(); - Navigation.closeRHPFlow(); - return; - } - setAssignCardStepAndData({ - currentStep: assignCard?.cardToAssign?.dateOption ? CONST.COMPANY_CARD.STEP.CONFIRMATION : CONST.COMPANY_CARD.STEP.ASSIGNEE, - isEditing: false, - }); - return; - } - if (isPlaid) { - return; - } - if (url) { - customWindow = openBankConnection(url); - return; - } - } - - // Handle add new card flow - if (isNewFeedConnected) { - setShouldBlockWindowOpen(true); - customWindow?.close(); - if (newFeed) { - updateSelectedFeed(newFeed, policyID); - } - - // Direct feeds (except those added via Plaid) are created with default statement period end date. - // Redirect the user to set a custom date. - if (policyID && !isPlaid) { - setAddNewCompanyCardStepAndData({ - step: CONST.COMPANY_CARDS.STEP.SELECT_DIRECT_STATEMENT_CLOSE_DATE, - }); - } else { - Navigation.closeRHPFlow(); - Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID), {forceReplace: true}); - } - return; - } - if (!shouldBlockWindowOpen) { - if (isPlaid) { - onImportPlaidAccounts(); - return; - } - if (url) { - customWindow = openBankConnection(url); - } - } - }, [ - isNewFeedConnected, - isAllFeedsResultLoading, - shouldBlockWindowOpen, - isBlockedToAddNewFeeds, - newFeed, - policyID, - url, - feed, - isFeedExpired, - isOffline, - assignCard?.cardToAssign?.dateOption, - isPlaid, - onImportPlaidAccounts, - isFeedConnectionBroken, - updateBrokenConnection, - isNewFeedHasError, - ]); - const getContent = () => { + if (isRefreshComplete) { + return ( + Navigation.dismissModal()} + /> + ); + } if (isNewFeedHasError) { return ( { + // No-op for native + return null; +}; + +export default handleOpenBankConnectionFlow; diff --git a/src/pages/workspace/companyCards/BankConnection/useBankConnection.ts b/src/pages/workspace/companyCards/BankConnection/useBankConnection.ts new file mode 100644 index 0000000000000..2482a48e04bd7 --- /dev/null +++ b/src/pages/workspace/companyCards/BankConnection/useBankConnection.ts @@ -0,0 +1,209 @@ +import {useCallback, useEffect, useMemo, useRef} from 'react'; +import useCardFeeds from '@hooks/useCardFeeds'; +import useImportPlaidAccounts from '@hooks/useImportPlaidAccounts'; +import useIsBlockedToAddFeed from '@hooks/useIsBlockedToAddFeed'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import usePrevious from '@hooks/usePrevious'; +import useUpdateFeedBrokenConnection from '@hooks/useUpdateFeedBrokenConnection'; +import {checkIfNewFeedConnected, getBankName, getCompanyCardFeed, isSelectedFeedExpired} from '@libs/CardUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {getCompanyCardBankConnection} from '@userActions/getCompanyCardBankConnection'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {CompanyCardFeedWithDomainID} from '@src/types/onyx'; +import openBankConnection from './openBankConnection'; + +type UseBankConnectionProps = { + policyID?: string; + feed?: CompanyCardFeedWithDomainID; + bankNameFromRoute?: string | null; + onSuccess?: (newFeed?: CompanyCardFeedWithDomainID) => void; + onFailure?: () => void; + onBackButtonPress?: () => void; + isRefreshConnectionFlow?: boolean; + shouldOpenWindow?: boolean; +}; + +let customWindow: Window | null = null; + +function closeCustomWindow() { + customWindow?.close(); +} + +export default function useBankConnection({ + policyID, + feed, + bankNameFromRoute, + onSuccess, + onFailure, + onBackButtonPress, + isRefreshConnectionFlow, + shouldOpenWindow = true, +}: UseBankConnectionProps) { + const {isOffline} = useNetwork(); + const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD); + const [assignCard] = useOnyx(ONYXKEYS.ASSIGN_CARD); + const [cardFeeds] = useCardFeeds(policyID); + const prevFeedsData = usePrevious(cardFeeds); + const onImportPlaidAccounts = useImportPlaidAccounts(policyID); + const {isBlockedToAddNewFeeds, isAllFeedsResultLoading} = useIsBlockedToAddFeed(policyID); + const {isFeedConnectionBroken} = useUpdateFeedBrokenConnection({policyID, feed}); + const shouldBlockWindowOpen = useRef(false); + const refreshSuccessHandled = useRef(false); + + const addNewCardData = addNewCard?.data; + const bankName = feed ? getBankName(getCompanyCardFeed(feed)) : (bankNameFromRoute ?? addNewCardData?.plaidConnectedFeed ?? addNewCardData?.selectedBank); + const bankDisplayName = addNewCardData?.plaidConnectedFeedName ?? bankName; + const plaidToken = addNewCardData?.publicToken ?? assignCard?.cardToAssign?.plaidAccessToken; + const isPlaid = !!plaidToken; + const url = getCompanyCardBankConnection(policyID, bankName); + const isFeedExpired = feed ? !!isSelectedFeedExpired(cardFeeds?.[feed]) : false; + const prevIsFeedExpired = usePrevious(isFeedExpired); + const {isNewFeedConnected, newFeed} = useMemo( + () => checkIfNewFeedConnected(prevFeedsData ?? {}, cardFeeds ?? {}, addNewCardData?.plaidConnectedFeed), + [addNewCardData?.plaidConnectedFeed, cardFeeds, prevFeedsData], + ); + const isNewFeedHasError = !!(newFeed && cardFeeds?.[newFeed]?.errors); + const hasConnectionSource = !!url || isPlaid; + const shouldWaitForData = isOffline || isNewFeedHasError || isAllFeedsResultLoading || (isBlockedToAddNewFeeds && !feed); + + const isRefreshComplete = useMemo(() => { + if (!isRefreshConnectionFlow || !feed || !hasConnectionSource || shouldWaitForData) { + return false; + } + return !!prevIsFeedExpired && !isFeedExpired && !isFeedConnectionBroken; + }, [isRefreshConnectionFlow, feed, hasConnectionSource, shouldWaitForData, prevIsFeedExpired, isFeedExpired, isFeedConnectionBroken]); + + const fallbackNavigation = useCallback(() => { + Navigation.goBack(policyID ? ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID) : undefined); + }, [policyID]); + + const handleSuccess = useCallback( + (connectedFeed?: CompanyCardFeedWithDomainID) => { + if (onSuccess) { + onSuccess(connectedFeed); + return; + } + fallbackNavigation(); + }, + [onSuccess, fallbackNavigation], + ); + + const handleFailure = useMemo(() => onFailure ?? fallbackNavigation, [onFailure, fallbackNavigation]); + + const onOpenBankConnectionFlow = useCallback(() => { + if (!url || !shouldOpenWindow) { + return; + } + customWindow = openBankConnection(url); + }, [url, shouldOpenWindow]); + + const handleBackButtonPress = useCallback(() => { + if (shouldOpenWindow) { + closeCustomWindow(); + } + + if (onBackButtonPress) { + onBackButtonPress(); + return; + } + + Navigation.goBack(); + }, [shouldOpenWindow, onBackButtonPress]); + + useEffect(() => { + if (!isRefreshComplete || refreshSuccessHandled.current) { + return; + } + refreshSuccessHandled.current = true; + onSuccess?.(); + }, [isRefreshComplete, onSuccess]); + + useEffect(() => { + if (!policyID || !isBlockedToAddNewFeeds || feed) { + return; + } + Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(policyID, CONST.UPGRADE_FEATURE_INTRO_MAPPING.companyCards.alias, ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID)), { + forceReplace: true, + }); + }, [isBlockedToAddNewFeeds, policyID, feed]); + + useEffect(() => { + if (!hasConnectionSource || shouldWaitForData) { + return; + } + + // Handle existing feed flow + if (feed) { + if (!isFeedExpired) { + if (shouldOpenWindow) { + closeCustomWindow(); + } + if (isFeedConnectionBroken) { + handleFailure(); + return; + } + if (!isRefreshConnectionFlow) { + handleSuccess(); + } + return; + } + if (!isPlaid && url && shouldOpenWindow) { + customWindow = openBankConnection(url); + } + return; + } + + // Handle new feed flow + if (isNewFeedConnected) { + shouldBlockWindowOpen.current = true; + if (shouldOpenWindow) { + closeCustomWindow(); + } + handleSuccess(newFeed); + return; + } + + if (!shouldBlockWindowOpen.current) { + if (isPlaid) { + onImportPlaidAccounts(); + return; + } + if (url && shouldOpenWindow) { + customWindow = openBankConnection(url); + } + } + }, [ + hasConnectionSource, + shouldWaitForData, + isNewFeedConnected, + newFeed, + policyID, + url, + feed, + isFeedExpired, + isPlaid, + onImportPlaidAccounts, + isFeedConnectionBroken, + handleSuccess, + handleFailure, + shouldOpenWindow, + isRefreshConnectionFlow, + ]); + + return { + onOpenBankConnectionFlow, + handleBackButtonPress, + bankName, + bankDisplayName, + url, + isPlaid, + isNewFeedHasError, + newFeed, + isAllFeedsResultLoading, + isBlockedToAddNewFeeds, + isRefreshComplete, + }; +} diff --git a/src/pages/workspace/companyCards/BrokenCardFeedConnectionPage.tsx b/src/pages/workspace/companyCards/BrokenCardFeedConnectionPage.tsx index 9f0521319dbb7..f65402860deae 100644 --- a/src/pages/workspace/companyCards/BrokenCardFeedConnectionPage.tsx +++ b/src/pages/workspace/companyCards/BrokenCardFeedConnectionPage.tsx @@ -1,14 +1,18 @@ -import React, {useEffect} from 'react'; +import React, {useCallback, useEffect} from 'react'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import useResponsiveLayout from '@hooks/useResponsiveLayout'; +import useUpdateFeedBrokenConnection from '@hooks/useUpdateFeedBrokenConnection'; +import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@navigation/types'; import LoadingPage from '@pages/LoadingPage'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; -import {clearAssignCardStepAndData} from '@userActions/CompanyCards'; +import {clearAssignCardStepAndData, setAssignCardStepAndData} from '@userActions/CompanyCards'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import PlaidConnectionStep from './addNew/PlaidConnectionStep'; import BankConnection from './BankConnection'; @@ -21,9 +25,11 @@ function BrokenCardFeedConnectionPage({route, policy}: BrokenCardFeedConnectionP const policyID = policy?.id; const {translate} = useLocalize(); + const {shouldUseNarrowLayout} = useResponsiveLayout(); const [assignCard] = useOnyx(ONYXKEYS.ASSIGN_CARD); const currentStep = assignCard?.currentStep; + const {updateBrokenConnection} = useUpdateFeedBrokenConnection({policyID, feed}); useEffect(() => { return () => { @@ -31,12 +37,39 @@ function BrokenCardFeedConnectionPage({route, policy}: BrokenCardFeedConnectionP }; }, []); + const handleAssignSuccess = useCallback(() => { + setAssignCardStepAndData({ + currentStep: assignCard?.cardToAssign?.dateOption ? CONST.COMPANY_CARD.STEP.CONFIRMATION : CONST.COMPANY_CARD.STEP.ASSIGNEE, + isEditing: false, + }); + }, [assignCard?.cardToAssign?.dateOption]); + + const handleAssignFailure = useCallback(() => { + updateBrokenConnection(); + if (shouldUseNarrowLayout) { + Navigation.goBack(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID)); + return; + } + Navigation.closeRHPFlow(); + }, [policyID, shouldUseNarrowLayout, updateBrokenConnection]); + + const handleBackButtonPress = useCallback(() => { + if (shouldUseNarrowLayout) { + Navigation.goBack(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID)); + return; + } + Navigation.closeRHPFlow(); + }, [policyID, shouldUseNarrowLayout]); + switch (currentStep) { case CONST.COMPANY_CARD.STEP.BANK_CONNECTION: return ( ); case CONST.COMPANY_CARD.STEP.PLAID_CONNECTION: diff --git a/src/pages/workspace/companyCards/RefreshCardFeedConnectionPage.tsx b/src/pages/workspace/companyCards/RefreshCardFeedConnectionPage.tsx new file mode 100644 index 0000000000000..4823433a6e5e1 --- /dev/null +++ b/src/pages/workspace/companyCards/RefreshCardFeedConnectionPage.tsx @@ -0,0 +1,66 @@ +import React, {useCallback, useEffect} from 'react'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import useUpdateFeedBrokenConnection from '@hooks/useUpdateFeedBrokenConnection'; +import Navigation from '@libs/Navigation/Navigation'; +import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import LoadingPage from '@pages/LoadingPage'; +import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; +import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; +import {clearAssignCardStepAndData} from '@userActions/CompanyCards'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type SCREENS from '@src/SCREENS'; +import PlaidConnectionStep from './addNew/PlaidConnectionStep'; +import BankConnection from './BankConnection'; + +type RefreshCardFeedConnectionPageProps = PlatformStackScreenProps & + WithPolicyAndFullscreenLoadingProps; + +function RefreshCardFeedConnectionPage({route, policy}: RefreshCardFeedConnectionPageProps) { + const feed = route.params?.feed; + const policyID = policy?.id; + + const {translate} = useLocalize(); + + const [assignCard] = useOnyx(ONYXKEYS.ASSIGN_CARD); + const currentStep = assignCard?.currentStep; + const {updateBrokenConnection} = useUpdateFeedBrokenConnection({policyID, feed}); + + useEffect(() => { + return () => { + clearAssignCardStepAndData(); + }; + }, []); + + const navigateToFeedSettings = useCallback(() => { + Navigation.goBack(policyID ? ROUTES.WORKSPACE_COMPANY_CARDS_SETTINGS.getRoute(policyID) : undefined); + }, [policyID]); + + switch (currentStep) { + case CONST.COMPANY_CARD.STEP.BANK_CONNECTION: + return ( + + ); + case CONST.COMPANY_CARD.STEP.PLAID_CONNECTION: + return ( + + ); + default: + return ; + } +} + +export default withPolicyAndFullscreenLoading(RefreshCardFeedConnectionPage); diff --git a/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx b/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx index 655090ea74fd9..5a8d42a4ab811 100644 --- a/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx +++ b/src/pages/workspace/companyCards/WorkspaceCompanyCardsSettingsPage.tsx @@ -10,18 +10,29 @@ import ScrollView from '@components/ScrollView'; import Text from '@components/Text'; import useCardFeeds from '@hooks/useCardFeeds'; import useCardsList from '@hooks/useCardsList'; +import {useCurrencyListState} from '@hooks/useCurrencyList'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; -import {deleteWorkspaceCompanyCardFeed, setWorkspaceCompanyCardTransactionLiability} from '@libs/actions/CompanyCards'; -import {getCompanyCardFeed, getCompanyFeeds, getCustomOrFormattedFeedName, getDomainOrWorkspaceAccountID, getSelectedFeed} from '@libs/CardUtils'; +import {deleteWorkspaceCompanyCardFeed, setAssignCardStepAndData, setWorkspaceCompanyCardTransactionLiability} from '@libs/actions/CompanyCards'; +import { + getCompanyCardFeed, + getCompanyFeeds, + getCustomOrFormattedFeedName, + getDomainOrWorkspaceAccountID, + getPlaidCountry, + getPlaidInstitutionId, + getSelectedFeed, + isDirectFeed, +} from '@libs/CardUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SettingsNavigatorParamList} from '@libs/Navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; +import {setAddNewCompanyCardStepAndData} from '@userActions/CompanyCards'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -48,7 +59,7 @@ function WorkspaceCompanyCardsSettingsPage({ const feed = selectedFeed ? getCompanyCardFeed(selectedFeed) : undefined; const [cardsList] = useCardsList(selectedFeed); - const icons = useMemoizedLazyExpensifyIcons(['Trashcan'] as const); + const icons = useMemoizedLazyExpensifyIcons(['Sync', 'Trashcan'] as const); const feedName = selectedFeed ? getCustomOrFormattedFeedName(translate, feed, cardFeeds?.[selectedFeed]?.customFeedName) : undefined; const companyFeeds = getCompanyFeeds(cardFeeds); const selectedFeedData = selectedFeed ? companyFeeds[selectedFeed] : undefined; @@ -56,6 +67,9 @@ function WorkspaceCompanyCardsSettingsPage({ const isPersonal = liabilityType === CONST.COMPANY_CARDS.DELETE_TRANSACTIONS.ALLOW; const domainOrWorkspaceAccountID = getDomainOrWorkspaceAccountID(workspaceAccountID, selectedFeedData); const isPending = !!selectedFeedData?.pending; + const {currencyList} = useCurrencyListState(); + const [countryByIp] = useOnyx(ONYXKEYS.COUNTRY); + const isDirectCardFeed = isDirectFeed(feed); const statementCloseDate = useMemo(() => { if (!selectedFeedData?.statementPeriodEndDay) { return undefined; @@ -92,6 +106,30 @@ function WorkspaceCompanyCardsSettingsPage({ } }; + const openBankConnectionFlow = () => { + if (!selectedFeed) { + return; + } + + const institutionId = getPlaidInstitutionId(selectedFeed); + const initialStep = institutionId ? CONST.COMPANY_CARD.STEP.PLAID_CONNECTION : CONST.COMPANY_CARD.STEP.BANK_CONNECTION; + + if (institutionId) { + const country = getPlaidCountry(policy?.outputCurrency, currencyList, countryByIp); + setAddNewCompanyCardStepAndData({ + data: { + selectedCountry: country, + }, + }); + } + + setAssignCardStepAndData({currentStep: initialStep}); + + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS_REFRESH_CARD_FEED_CONNECTION.getRoute(policyID, selectedFeed)); + }); + }; + const onToggleLiability = (isOn: boolean) => { if (!feed) { return; @@ -149,6 +187,14 @@ function WorkspaceCompanyCardsSettingsPage({ /> {translate('workspace.moreFeatures.companyCards.setTransactionLiabilityDescription')} + {isDirectCardFeed && ( + + )} { + if (newFeed) { + updateSelectedFeed(newFeed, policyID); + } + + const isPlaid = !!addNewCardFeed?.data?.publicToken; + + // Direct feeds (except those added via Plaid) are created with default statement period end date. + // Redirect the user to set a custom date. + if (policyID && !isPlaid) { + setAddNewCompanyCardStepAndData({ + step: CONST.COMPANY_CARDS.STEP.SELECT_DIRECT_STATEMENT_CLOSE_DATE, + }); + } else { + Navigation.closeRHPFlow(); + Navigation.navigate(ROUTES.WORKSPACE_COMPANY_CARDS.getRoute(policyID), {forceReplace: true}); + } + }, + [addNewCardFeed?.data?.publicToken, policyID], + ); + + const handleBackButtonPress = useCallback(() => { + setAddNewCompanyCardStepAndData({step: CONST.COMPANY_CARDS.STEP.SELECT_BANK}); + }, []); + if (isAddCardFeedLoading || isAllFeedsResultLoading || isBlockedToAddNewFeeds) { return ; } @@ -101,7 +129,13 @@ function AddNewCardPage({policy}: WithPolicyAndFullscreenLoadingProps) { CurrentStep = ; break; case CONST.COMPANY_CARDS.STEP.BANK_CONNECTION: - CurrentStep = ; + CurrentStep = ( + + ); break; case CONST.COMPANY_CARDS.STEP.CARD_INSTRUCTIONS: CurrentStep = ; diff --git a/src/pages/workspace/companyCards/addNew/PlaidConnectionStep.tsx b/src/pages/workspace/companyCards/addNew/PlaidConnectionStep.tsx index c6b9762af0879..05c3c8fae6acd 100644 --- a/src/pages/workspace/companyCards/addNew/PlaidConnectionStep.tsx +++ b/src/pages/workspace/companyCards/addNew/PlaidConnectionStep.tsx @@ -27,7 +27,17 @@ import type {CompanyCardFeedWithDomainID} from '@src/types/onyx'; import type {CardFeedWithNumber} from '@src/types/onyx/CardFeeds'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; -function PlaidConnectionStep({feed, policyID, onExit}: {feed?: CompanyCardFeedWithDomainID; policyID?: string; onExit?: () => void}) { +function PlaidConnectionStep({ + feed, + policyID, + onExit, + isRefreshConnectionFlow, +}: { + feed?: CompanyCardFeedWithDomainID; + policyID?: string; + onExit?: () => void; + isRefreshConnectionFlow?: boolean; +}) { const {translate} = useLocalize(); const styles = useThemeStyles(); const [addNewCard] = useOnyx(ONYXKEYS.ADD_NEW_COMPANY_CARD); @@ -218,7 +228,7 @@ function PlaidConnectionStep({feed, policyID, onExit}: {feed?: CompanyCardFeedWi shouldEnableMaxHeight > {isPlaidDisabled ? ( diff --git a/src/pages/workspace/withPolicy.tsx b/src/pages/workspace/withPolicy.tsx index f4ef4b7212a5b..4a2e610b4e1df 100644 --- a/src/pages/workspace/withPolicy.tsx +++ b/src/pages/workspace/withPolicy.tsx @@ -53,6 +53,7 @@ type PolicyRouteName = | typeof SCREENS.WORKSPACE.RULES | typeof SCREENS.WORKSPACE.EXPENSIFY_CARD_ISSUE_NEW | typeof SCREENS.WORKSPACE.COMPANY_CARDS_BROKEN_CARD_FEED_CONNECTION + | typeof SCREENS.WORKSPACE.COMPANY_CARDS_REFRESH_CARD_FEED_CONNECTION | typeof SCREENS.WORKSPACE.ACCOUNTING.CLAIM_OFFER | typeof SCREENS.WORKSPACE.TIME_TRACKING; diff --git a/tests/ui/RefreshCardFeedConnection.tsx b/tests/ui/RefreshCardFeedConnection.tsx new file mode 100644 index 0000000000000..a4fa47484c1fc --- /dev/null +++ b/tests/ui/RefreshCardFeedConnection.tsx @@ -0,0 +1,258 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import {PortalProvider} from '@gorhom/portal'; +import {NavigationContainer} from '@react-navigation/native'; +import {act, render, screen, waitFor} from '@testing-library/react-native'; +import React from 'react'; +import Onyx from 'react-native-onyx'; +import ComposeProviders from '@components/ComposeProviders'; +import {LocaleContextProvider} from '@components/LocaleContextProvider'; +import OnyxListItemProvider from '@components/OnyxListItemProvider'; +import {CurrentReportIDContextProvider} from '@hooks/useCurrentReportID'; +import * as useResponsiveLayoutModule from '@hooks/useResponsiveLayout'; +import type ResponsiveLayoutResult from '@hooks/useResponsiveLayout/types'; +import createPlatformStackNavigator from '@libs/Navigation/PlatformStackNavigation/createPlatformStackNavigator'; +import type {SettingsNavigatorParamList} from '@navigation/types'; +import RefreshCardFeedConnectionPage from '@pages/workspace/companyCards/RefreshCardFeedConnectionPage'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import SCREENS from '@src/SCREENS'; +import type {CompanyCardFeedWithDomainID} from '@src/types/onyx/CardFeeds'; +import * as LHNTestUtils from '../utils/LHNTestUtils'; +import * as TestHelper from '../utils/TestHelper'; +import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct'; + +const WORKSPACE_ACCOUNT_ID = 5678; +const POLICY_ID = LHNTestUtils.getFakePolicy().id; +const DIRECT_FEED = `oauth.chase.com#${WORKSPACE_ACCOUNT_ID}` as CompanyCardFeedWithDomainID; + +TestHelper.setupGlobalFetchMock(); + +jest.mock('@hooks/useNetwork', () => + jest.fn(() => ({ + isOffline: false, + })), +); + +jest.mock('react-native-permissions', () => ({ + RESULTS: {UNAVAILABLE: 'unavailable', BLOCKED: 'blocked', DENIED: 'denied', GRANTED: 'granted', LIMITED: 'limited'}, + check: jest.fn(() => Promise.resolve('granted')), + request: jest.fn(() => Promise.resolve('granted')), + PERMISSIONS: {IOS: {CONTACTS: 'ios.permission.CONTACTS'}, ANDROID: {READ_CONTACTS: 'android.permission.READ_CONTACTS'}}, +})); + +jest.mock('@rnmapbox/maps', () => ({default: jest.fn(), MarkerView: jest.fn(), setAccessToken: jest.fn()})); + +jest.mock('react-native-plaid-link-sdk', () => ({dismissLink: jest.fn(), openLink: jest.fn(), usePlaidEmitter: jest.fn()})); + +jest.mock('@libs/Navigation/Navigation', () => ({ + navigate: jest.fn(), + goBack: jest.fn(), + closeRHPFlow: jest.fn(), + dismissModal: jest.fn(), + setNavigationActionToMicrotaskQueue: jest.fn((callback: () => void) => callback?.()), + getTopmostReportId: jest.fn(), +})); + +const mockClearAssignCardStepAndData = jest.fn(); + +jest.mock('@userActions/CompanyCards', () => ({ + setAssignCardStepAndData: jest.fn(), + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + clearAssignCardStepAndData: (...args: unknown[]) => mockClearAssignCardStepAndData(...args), + setAddNewCompanyCardStepAndData: jest.fn(), +})); + +let capturedProps: {isRefreshConnectionFlow?: boolean; onFailure?: () => void; onBackButtonPress?: () => void} = {}; +jest.mock('@pages/workspace/companyCards/BankConnection', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const {View, Text} = require('react-native'); + return { + __esModule: true, + // eslint-disable-next-line @typescript-eslint/no-unsafe-return + default: (props: {isRefreshConnectionFlow?: boolean; onFailure?: () => void; onBackButtonPress?: () => void}) => { + capturedProps = props; + return ( + + {props.isRefreshConnectionFlow ? 'refresh-flow' : 'normal-flow'} + + ); + }, + }; +}); + +jest.mock('@pages/workspace/companyCards/addNew/PlaidConnectionStep', () => { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const {View} = require('react-native'); + return { + __esModule: true, + default: () => , + }; +}); + +const Stack = createPlatformStackNavigator(); + +const renderRefreshPage = () => { + return render( + + + + + + + + + , + ); +}; + +describe('RefreshCardFeedConnection', () => { + beforeAll(() => { + Onyx.init({keys: ONYXKEYS}); + }); + + beforeEach(() => { + capturedProps = {}; + jest.spyOn(useResponsiveLayoutModule, 'default').mockReturnValue({ + isSmallScreenWidth: false, + shouldUseNarrowLayout: false, + } as ResponsiveLayoutResult); + }); + + afterEach(async () => { + await act(async () => { + await Onyx.clear(); + }); + jest.clearAllMocks(); + }); + + describe('Step rendering', () => { + it('should show loading page when no step is set', async () => { + await TestHelper.signInWithTestUser(); + + const policy = {...LHNTestUtils.getFakePolicy(), role: CONST.POLICY.ROLE.ADMIN, workspaceAccountID: WORKSPACE_ACCOUNT_ID}; + + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await Onyx.merge(ONYXKEYS.NETWORK, {isOffline: false}); + }); + + const {unmount} = renderRefreshPage(); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByText('Assign new cards')).toBeOnTheScreen(); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should render BankConnection when step is BANK_CONNECTION', async () => { + await TestHelper.signInWithTestUser(); + + const policy = {...LHNTestUtils.getFakePolicy(), role: CONST.POLICY.ROLE.ADMIN, workspaceAccountID: WORKSPACE_ACCOUNT_ID}; + + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await Onyx.merge(ONYXKEYS.NETWORK, {isOffline: false}); + await Onyx.merge(ONYXKEYS.ASSIGN_CARD, {currentStep: CONST.COMPANY_CARD.STEP.BANK_CONNECTION}); + }); + + const {unmount} = renderRefreshPage(); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByTestId('BankConnection')).toBeOnTheScreen(); + expect(screen.getByText('refresh-flow')).toBeOnTheScreen(); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + + it('should render PlaidConnectionStep when step is PLAID_CONNECTION', async () => { + await TestHelper.signInWithTestUser(); + + const policy = {...LHNTestUtils.getFakePolicy(), role: CONST.POLICY.ROLE.ADMIN, workspaceAccountID: WORKSPACE_ACCOUNT_ID}; + + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await Onyx.merge(ONYXKEYS.NETWORK, {isOffline: false}); + await Onyx.merge(ONYXKEYS.ASSIGN_CARD, {currentStep: CONST.COMPANY_CARD.STEP.PLAID_CONNECTION}); + }); + + const {unmount} = renderRefreshPage(); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByTestId('PlaidConnectionStep')).toBeOnTheScreen(); + }); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + }); + + describe('BankConnection props', () => { + it('should pass onFailure and onBackButtonPress callbacks', async () => { + await TestHelper.signInWithTestUser(); + + const policy = {...LHNTestUtils.getFakePolicy(), role: CONST.POLICY.ROLE.ADMIN, workspaceAccountID: WORKSPACE_ACCOUNT_ID}; + + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await Onyx.merge(ONYXKEYS.NETWORK, {isOffline: false}); + await Onyx.merge(ONYXKEYS.ASSIGN_CARD, {currentStep: CONST.COMPANY_CARD.STEP.BANK_CONNECTION}); + }); + + const {unmount} = renderRefreshPage(); + await waitForBatchedUpdatesWithAct(); + + await waitFor(() => { + expect(screen.getByTestId('BankConnection')).toBeOnTheScreen(); + }); + + expect(capturedProps.onFailure).toBeDefined(); + expect(capturedProps.onBackButtonPress).toBeDefined(); + + unmount(); + await waitForBatchedUpdatesWithAct(); + }); + }); + + describe('Cleanup', () => { + it('should call clearAssignCardStepAndData on unmount', async () => { + await TestHelper.signInWithTestUser(); + + const policy = {...LHNTestUtils.getFakePolicy(), role: CONST.POLICY.ROLE.ADMIN, workspaceAccountID: WORKSPACE_ACCOUNT_ID}; + + await act(async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policy.id}`, policy); + await Onyx.merge(ONYXKEYS.NETWORK, {isOffline: false}); + }); + + const {unmount} = renderRefreshPage(); + await waitForBatchedUpdatesWithAct(); + + unmount(); + await waitForBatchedUpdatesWithAct(); + + expect(mockClearAssignCardStepAndData).toHaveBeenCalled(); + }); + }); + + describe('Route generation', () => { + it('should generate correct route for refresh card feed connection', () => { + const route = ROUTES.WORKSPACE_COMPANY_CARDS_REFRESH_CARD_FEED_CONNECTION.getRoute(POLICY_ID, DIRECT_FEED); + expect(route).toBe(`workspaces/${POLICY_ID}/company-cards/${encodeURIComponent(DIRECT_FEED)}/refresh-card-feed-connection`); + }); + }); +});