diff --git a/src/components/DotIndicatorMessage.tsx b/src/components/DotIndicatorMessage.tsx index c9326e086696..bfe8ad93eb2d 100644 --- a/src/components/DotIndicatorMessage.tsx +++ b/src/components/DotIndicatorMessage.tsx @@ -17,8 +17,11 @@ import type {TranslationKeyError} from '@src/types/onyx/OnyxCommon'; import type {ReceiptError} from '@src/types/onyx/Transaction'; import Button from './Button'; import Icon from './Icon'; +import RenderHTML from './RenderHTML'; import Text from './Text'; +const HTML_TAG_PATTERN = /<\/?[a-z][^>]*>/i; + type DotIndicatorMessageProps = { /** * In most cases this should just be errors from onxyData @@ -74,7 +77,31 @@ function DotIndicatorMessage({messages = {}, style, type, textStyles, dismissErr } const displayMessage = isTranslationKeyError(message) ? translate(message.translationKey) : message; - const formattedMessage = typeof displayMessage === 'string' ? Str.htmlDecode(displayMessage) : displayMessage; + + if (typeof displayMessage !== 'string') { + return ( + + {displayMessage} + + ); + } + + if (HTML_TAG_PATTERN.test(displayMessage)) { + const html = isErrorMessage ? `${displayMessage}` : `${displayMessage}`; + + return ( + + ); + } return ( - {formattedMessage} + {Str.htmlDecode(displayMessage)} ); }; diff --git a/src/components/HTMLEngineProvider/HTMLRenderers/ConciergeLinkRenderer.tsx b/src/components/HTMLEngineProvider/HTMLRenderers/ConciergeLinkRenderer.tsx index c010323ea54f..8dfa664618d2 100644 --- a/src/components/HTMLEngineProvider/HTMLRenderers/ConciergeLinkRenderer.tsx +++ b/src/components/HTMLEngineProvider/HTMLRenderers/ConciergeLinkRenderer.tsx @@ -1,8 +1,8 @@ import {hasSeenTourSelector} from '@selectors/Onboarding'; import React from 'react'; import type {StyleProp, TextStyle} from 'react-native'; -import type {CustomRendererProps, TPhrasing, TText} from 'react-native-render-html'; -import {TNodeChildrenRenderer} from 'react-native-render-html'; +import type {CustomRendererProps, RenderersProps, TPhrasing, TText} from 'react-native-render-html'; +import {TNodeChildrenRenderer, useRendererProps} from 'react-native-render-html'; import * as HTMLEngineUtils from '@components/HTMLEngineProvider/htmlEngineUtils'; import Text from '@components/Text'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -15,6 +15,16 @@ import ONYXKEYS from '@src/ONYXKEYS'; type ConciergeLinkRendererProps = CustomRendererProps; +type ConciergeLinkRendererConfig = { + onPress?: () => void; +}; + +type ConciergeLinkRenderersProps = RenderersProps & { + // Custom HTML renderer keys must use hyphenated tag names per react-native-render-html API + /* eslint-disable @typescript-eslint/naming-convention */ + 'concierge-link': ConciergeLinkRendererConfig; +}; + function ConciergeLinkRenderer({tnode, style}: ConciergeLinkRendererProps) { const styles = useThemeStyles(); const [conciergeReportID] = useOnyx(ONYXKEYS.CONCIERGE_REPORT_ID); @@ -22,11 +32,13 @@ function ConciergeLinkRenderer({tnode, style}: ConciergeLinkRendererProps) { const [betas] = useOnyx(ONYXKEYS.BETAS); const [isSelfTourViewed] = useOnyx(ONYXKEYS.NVP_ONBOARDING, {selector: hasSeenTourSelector}); const {accountID: currentUserAccountID} = useCurrentUserPersonalDetails(); + const {onPress: onPressFromProps} = useRendererProps('concierge-link') ?? {}; /** * Simple wrapper to create a stable reference without passing event args to navigation function. */ const navigateToConciergeChat = () => { + onPressFromProps?.(); navigateToConciergeChatAction(conciergeReportID, introSelected, currentUserAccountID, isSelfTourViewed, betas, false); }; diff --git a/src/components/RenderHTML.tsx b/src/components/RenderHTML.tsx index 6d31af35218c..771eb10abec9 100644 --- a/src/components/RenderHTML.tsx +++ b/src/components/RenderHTML.tsx @@ -5,11 +5,13 @@ import useHasTextAncestor from '@hooks/useHasTextAncestor'; import useWindowDimensions from '@hooks/useWindowDimensions'; import Parser from '@libs/Parser'; import BulletItemRenderer from './HTMLEngineProvider/HTMLRenderers/BulletItemRenderer'; +import ConciergeLinkRenderer from './HTMLEngineProvider/HTMLRenderers/ConciergeLinkRenderer'; import OLRenderer from './HTMLEngineProvider/HTMLRenderers/OLRenderer'; import SparklesIconRenderer from './HTMLEngineProvider/HTMLRenderers/SparklesIconRenderer'; import ULRenderer from './HTMLEngineProvider/HTMLRenderers/ULRenderer'; type LinkPressHandler = NonNullable['onPress']; +type ConciergeLinkPressHandler = () => void; // Matches &#91; (→ "[") and &#93; (→ "]"). Index 7 is the distinguishing digit ('1' vs '3'). const RE_BRACKET_ESCAPE = /&#9[13];/g; @@ -25,6 +27,9 @@ type RenderHTMLProps = { /** Callback to handle link press */ onLinkPress?: LinkPressHandler; + /** Callback to handle concierge-link press */ + onConciergeLinkPress?: ConciergeLinkPressHandler; + /** Whether the rendered text should be selectable */ isSelectable?: boolean; }; @@ -33,7 +38,7 @@ type RenderHTMLProps = { // Configuration for RenderHTML is handled in a top-level component providing // context to RenderHTMLSource components. See https://git.io/JRcZb // The provider is available at src/components/HTMLEngineProvider/ -function RenderHTML({html: htmlParam, onLinkPress, isSelectable}: RenderHTMLProps) { +function RenderHTML({html: htmlParam, onLinkPress, onConciergeLinkPress, isSelectable}: RenderHTMLProps) { const hasTextAncestor = useHasTextAncestor(); if (__DEV__ && hasTextAncestor) { throw new Error('RenderHTML must not be rendered inside a component, as it will break the layout on iOS. Render it as a sibling instead.'); @@ -57,12 +62,18 @@ function RenderHTML({html: htmlParam, onLinkPress, isSelectable}: RenderHTMLProp a: { onPress: onLinkPress, }, + // Custom HTML renderer keys must use hyphenated tag names per react-native-render-html API + /* eslint-disable @typescript-eslint/naming-convention */ + 'concierge-link': { + onPress: onConciergeLinkPress, + }, }; - }, [onLinkPress]); + }, [onLinkPress, onConciergeLinkPress]); const renderers = { /* eslint-disable @typescript-eslint/naming-convention */ 'bullet-item': BulletItemRenderer, + 'concierge-link': ConciergeLinkRenderer, 'sparkles-icon': SparklesIconRenderer, ol: OLRenderer, ul: ULRenderer, @@ -75,7 +86,7 @@ function RenderHTML({html: htmlParam, onLinkPress, isSelectable}: RenderHTMLProp /> ); - return onLinkPress ? ( + return onLinkPress || onConciergeLinkPress ? ( wenden Sie sich an Concierge, um sie zu entfernen.', outstandingBalanceWarning: 'Sie haben einen offenen Saldo, der beglichen werden muss, bevor Sie Ihren letzten Workspace löschen können. Bitte gehen Sie zu Ihren Abonnementeinstellungen, um die Zahlung abzuschließen.', settleBalance: 'Zu Abo wechseln', diff --git a/src/languages/en.ts b/src/languages/en.ts index a602e6238653..fd0c1538da84 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -4516,7 +4516,7 @@ const translations = { defaultNote: `Receipts sent to ${CONST.EMAIL.RECEIPTS} will appear in this workspace.`, deleteConfirmation: 'Are you sure you want to delete this workspace?', deleteWithCardsConfirmation: 'Are you sure you want to delete this workspace? This will remove all card feeds and assigned cards.', - deleteOpenExpensifyCardsError: 'Your company still has open Expensify Cards.', + deleteOpenExpensifyCardsError: 'Your company still has Expensify Cards. Please reach out to Concierge to remove them.', outstandingBalanceWarning: 'You have an outstanding balance that must be settled before deleting your last workspace. Please go to your subscription settings to resolve the payment.', settleBalance: 'Go to subscription', diff --git a/src/languages/es.ts b/src/languages/es.ts index 15087b043c81..43dcfa8e4072 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4299,7 +4299,7 @@ ${amount} para ${merchant} - ${date}`, defaultNote: `Los recibos enviados a ${CONST.EMAIL.RECEIPTS} aparecerán en este espacio de trabajo.`, deleteConfirmation: '¿Estás seguro de que quieres eliminar este espacio de trabajo?', deleteWithCardsConfirmation: '¿Estás seguro de que quieres eliminar este espacio de trabajo? Se eliminarán todos los datos de las tarjetas y las tarjetas asignadas.', - deleteOpenExpensifyCardsError: 'Su empresa todavía tiene tarjetas Expensify activas.', + deleteOpenExpensifyCardsError: 'Tu empresa todavía tiene Tarjetas Expensify. Por favor, contacta con Concierge para eliminarlas.', outstandingBalanceWarning: 'Tienes un saldo pendiente que debe liquidarse antes de eliminar tu último espacio de trabajo. Por favor, ve a la configuración de tu suscripción para resolver el pago.', settleBalance: 'Ir a Suscripción', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 8e700e34026c..346c535da283 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -4435,7 +4435,7 @@ ${amount} pour ${merchant} - ${date}`, defaultNote: `Les reçus envoyés à ${CONST.EMAIL.RECEIPTS} apparaîtront dans cet espace de travail.`, deleteConfirmation: 'Voulez-vous vraiment supprimer cet espace de travail ?', deleteWithCardsConfirmation: 'Voulez-vous vraiment supprimer cet espace de travail ? Cela supprimera tous les flux de cartes et les cartes assignées.', - deleteOpenExpensifyCardsError: 'Votre entreprise a encore des cartes Expensify actives.', + deleteOpenExpensifyCardsError: 'Votre entreprise a encore des Cartes Expensify. Veuillez contacter Concierge pour les supprimer.', outstandingBalanceWarning: 'Vous avez un solde impayé qui doit être réglé avant de supprimer votre dernier espace de travail. Veuillez accéder à vos paramètres d’abonnement pour résoudre le paiement.', settleBalance: 'Aller à l’abonnement', diff --git a/src/languages/it.ts b/src/languages/it.ts index fc1658236d3f..6ded20357ff6 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -4410,7 +4410,7 @@ ${amount} per ${merchant} - ${date}`, defaultNote: `Le ricevute inviate a ${CONST.EMAIL.RECEIPTS} verranno visualizzate in questo workspace.`, deleteConfirmation: 'Sei sicuro di voler eliminare questo spazio di lavoro?', deleteWithCardsConfirmation: 'Sei sicuro di voler eliminare questo spazio di lavoro? Questa azione rimuoverà tutti i feed delle carte e le carte assegnate.', - deleteOpenExpensifyCardsError: 'La tua azienda ha ancora carte Expensify attive.', + deleteOpenExpensifyCardsError: 'La tua azienda ha ancora delle Carte Expensify. Per favore, contatta Concierge per rimuoverle.', outstandingBalanceWarning: 'Hai un saldo in sospeso che deve essere saldato prima di eliminare il tuo ultimo workspace. Vai alle impostazioni dell’abbonamento per risolvere il pagamento.', settleBalance: 'Vai all’abbonamento', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 1b06b1fc90eb..de071269ba85 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -4381,7 +4381,7 @@ ${integrationName === CONST.ONBOARDING_ACCOUNTING_MAPPING.other ? 'あなたの' defaultNote: `${CONST.EMAIL.RECEIPTS} に送信されたレシートは、このワークスペースに表示されます。`, deleteConfirmation: 'このワークスペースを削除してもよろしいですか?', deleteWithCardsConfirmation: 'このワークスペースを削除してもよろしいですか? すべてのカードフィードと割り当て済みカードが削除されます。', - deleteOpenExpensifyCardsError: 'あなたの会社にはまだ有効なExpensifyカードがあります。', + deleteOpenExpensifyCardsError: '御社にはまだ Expensify カードが残っています。削除するには、Concierge までお問い合わせください。', outstandingBalanceWarning: '最後のワークスペースを削除する前に精算する必要がある未払残高があります。支払いを解決するには、サブスクリプション設定に移動してください。', settleBalance: 'サブスクリプションに移動', unavailable: '利用できないワークスペース', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 17a2fa7d61d0..d3c82e3c992a 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -4405,7 +4405,7 @@ ${amount} voor ${merchant} - ${date}`, defaultNote: `Bonnetjes die naar ${CONST.EMAIL.RECEIPTS} worden gestuurd, verschijnen in deze workspace.`, deleteConfirmation: 'Weet je zeker dat je deze werkruimte wilt verwijderen?', deleteWithCardsConfirmation: 'Weet je zeker dat je deze werkruimte wilt verwijderen? Hiermee worden alle kaartfeeds en toegewezen kaarten verwijderd.', - deleteOpenExpensifyCardsError: 'Uw bedrijf heeft nog actieve Expensify Cards.', + deleteOpenExpensifyCardsError: 'Je bedrijf heeft nog Expensify Kaarten. Neem contact op met Concierge om ze te verwijderen.', outstandingBalanceWarning: 'Je hebt een openstaand saldo dat moet worden vereffend voordat je je laatste werkruimte kunt verwijderen. Ga naar je abonnementsinstellingen om de betaling af te ronden.', settleBalance: 'Ga naar abonnement', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 81c400582abb..ec94120c1414 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -4398,7 +4398,7 @@ ${amount} dla ${merchant} - ${date}`, defaultNote: `Paragony wysłane na ${CONST.EMAIL.RECEIPTS} pojawią się w tym obszarze roboczym.`, deleteConfirmation: 'Czy na pewno chcesz usunąć tę przestrzeń roboczą?', deleteWithCardsConfirmation: 'Na pewno chcesz usunąć tę przestrzeń roboczą? Spowoduje to usunięcie wszystkich źródeł kart i przypisanych kart.', - deleteOpenExpensifyCardsError: 'Twoja firma nadal ma aktywne karty Expensify.', + deleteOpenExpensifyCardsError: 'Twoja firma wciąż ma Karty Expensify. Prosimy, skontaktuj się z Concierge, aby je usunąć.', outstandingBalanceWarning: 'Masz zaległe saldo, które musi zostać uregulowane przed usunięciem ostatniego miejsca pracy. Przejdź do ustawień subskrypcji, aby uregulować płatność.', settleBalance: 'Przejdź do subskrypcji', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 13ae04229d98..6d59529e6756 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -4395,7 +4395,7 @@ ${amount} para ${merchant} - ${date}`, defaultNote: `Recibos enviados para ${CONST.EMAIL.RECEIPTS} aparecerão neste workspace.`, deleteConfirmation: 'Tem certeza de que deseja excluir este workspace?', deleteWithCardsConfirmation: 'Tem certeza de que deseja excluir este workspace? Isso removerá todos os feeds de cartão e cartões atribuídos.', - deleteOpenExpensifyCardsError: 'Sua empresa ainda possui cartões Expensify ativos.', + deleteOpenExpensifyCardsError: 'Sua empresa ainda tem Cartões Expensify. Por favor, fale com o Concierge para removê-los.', outstandingBalanceWarning: 'Você tem um saldo pendente que precisa ser quitado antes de excluir seu último espaço de trabalho. Acesse as configurações de assinatura para resolver o pagamento.', settleBalance: 'Ir para a assinatura', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 84b6e80ebaa6..cc5eda21f2d1 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -4294,7 +4294,7 @@ ${amount},商户:${merchant} - 日期:${date}`, defaultNote: `发送到 ${CONST.EMAIL.RECEIPTS} 的收据将显示在此工作区中。`, deleteConfirmation: '确定要删除此工作区吗?', deleteWithCardsConfirmation: '确定要删除此工作区吗?这将移除所有卡片数据源和已分配的卡片。', - deleteOpenExpensifyCardsError: '您的公司仍有未关闭的 Expensify 卡。', + deleteOpenExpensifyCardsError: '您的公司仍在使用 Expensify 卡。请联系 Concierge以停用它们。', outstandingBalanceWarning: '您有一笔未结清的余额,必须在删除最后一个工作区之前结清。请前往订阅设置以解决付款问题。', settleBalance: '前往订阅', unavailable: '工作区不可用', diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 78b1eefb665d..565af2b9e285 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -407,6 +407,13 @@ function deleteWorkspace(params: DeleteWorkspaceActionParams) { const filteredPolicies = Object.values(policies ?? {}).filter((p): p is Policy => p?.id !== policyID); const workspaceAccountID = policy?.policyAccountID; + if (hasDeleteWorkspaceExpensifyCardsError) { + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { + errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.common.deleteOpenExpensifyCardsError'), + }); + return; + } + const optimisticData: Array< OnyxUpdate< | typeof ONYXKEYS.COLLECTION.POLICY @@ -430,7 +437,7 @@ function deleteWorkspace(params: DeleteWorkspaceActionParams) { value: { avatarURL: '', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - errors: hasDeleteWorkspaceExpensifyCardsError ? ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.common.deleteOpenExpensifyCardsError') : null, + errors: null, }, }, { @@ -614,16 +621,6 @@ function deleteWorkspace(params: DeleteWorkspaceActionParams) { }, }); - if (hasDeleteWorkspaceExpensifyCardsError) { - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - errors: null, - }, - }); - } - reportIDToOptimisticCloseReportActionID[reportID] = optimisticClosedReportAction.reportActionID; for (const transactionViolationKey of Object.keys(transactionViolations ?? {})) { @@ -7547,7 +7544,6 @@ export { deleteWorkspace, updateAddress, updateLastAccessedWorkspace, - clearDeleteWorkspaceError, dismissWorkspaceError, setWorkspaceDefaultSpendCategory, getDisplayNameForWorkspace, diff --git a/src/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index 62a80f21cf78..a740e656ac83 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -20,7 +20,6 @@ import PressableWithoutFeedback from '@components/Pressable/PressableWithoutFeed import Section from '@components/Section'; import Text from '@components/Text'; import ThreeDotsMenu from '@components/ThreeDotsMenu'; -import useCardFeeds from '@hooks/useCardFeeds'; import useConfirmModal from '@hooks/useConfirmModal'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; @@ -29,25 +28,18 @@ import {useMemoizedLazyExpensifyIcons, useMemoizedLazyIllustrations} from '@hook import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import useOutstandingBalanceGuard from '@hooks/useOutstandingBalanceGuard'; -import usePayAndDowngrade from '@hooks/usePayAndDowngrade'; import usePrevious from '@hooks/usePrevious'; -import usePrivateSubscription from '@hooks/usePrivateSubscription'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useShouldBlockCurrencyChange from '@hooks/useShouldBlockCurrencyChange'; import useShouldDisplayButtonsInSeparateLine from '@hooks/useShouldDisplayButtonsInSeparateLine'; import useThemeStyles from '@hooks/useThemeStyles'; -import useTransactionViolationOfWorkspace from '@hooks/useTransactionViolationOfWorkspace'; import useWorkspaceDocumentTitle from '@hooks/useWorkspaceDocumentTitle'; import {close} from '@libs/actions/Modal'; import {clearInviteDraft, clearWorkspaceOwnerChangeFlow, requestWorkspaceOwnerChange} from '@libs/actions/Policy/Member'; import { - calculateBillNewDot, clearAvatarErrors, - clearDeleteWorkspaceError, clearPolicyErrorField, deletePolicyRulesDocument, - deleteWorkspace, deleteWorkspaceAvatar, leaveWorkspace, openPolicyProfilePage, @@ -55,37 +47,28 @@ import { updatePolicyRulesDocument, updateWorkspaceAvatar, } from '@libs/actions/Policy/Policy'; -import {filterInactiveCards, getCardSettings} from '@libs/CardUtils'; -import {getLatestErrorField, getLatestErrorMessage} from '@libs/ErrorUtils'; +import {getCardSettings} from '@libs/CardUtils'; +import {getLatestErrorField} from '@libs/ErrorUtils'; import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/createDynamicRoute'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; -import { - canEditWorkspaceSettings, - getRulesDocumentSourceURL, - getUserFriendlyWorkspaceType, - goBackFromInvalidPolicy, - isPendingDeletePolicy, - isPolicyOwner, - shouldBlockWorkspaceDeletionForInvoicifyUser, -} from '@libs/PolicyUtils'; +import {canEditWorkspaceSettings, getRulesDocumentSourceURL, getUserFriendlyWorkspaceType, goBackFromInvalidPolicy, isPendingDeletePolicy, isPolicyOwner} from '@libs/PolicyUtils'; import {formatAddressToString} from '@libs/ReportActionsUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButton'; import StringUtils from '@libs/StringUtils'; -import {isSubscriptionTypeOfInvoicing, shouldCalculateBillNewDot} from '@libs/SubscriptionUtils'; import {getLeaveWorkspaceConfirmationPrompt} from '@libs/WorkspacesSettingsUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import {accountIDToLoginSelector} from '@src/selectors/PersonalDetails'; -import {ownerPoliciesSelector} from '@src/selectors/Policy'; -import {reimbursementAccountErrorSelector} from '@src/selectors/ReimbursementAccount'; +import {canDowngradeSelector} from '@src/selectors/Account'; +import {createOwnedPaidPoliciesCountsSelector} from '@src/selectors/Policy'; import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import DeleteWorkspaceFlow from './deleteWorkspace/DeleteWorkspaceFlow'; import type {WithPolicyProps} from './withPolicy'; import withPolicy from './withPolicy'; import WorkspacePageWithSections from './WorkspacePageWithSections'; @@ -97,7 +80,7 @@ const rulesDocumentMenuPositionStyle = {top: variables.spacing2, right: variable function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: WorkspaceOverviewPageProps) { const styles = useThemeStyles(); - const {translate, localeCompare} = useLocalize(); + const {translate} = useLocalize(); const {shouldUseNarrowLayout} = useResponsiveLayout(); const shouldDisplayButtonsInSeparateLine = useShouldDisplayButtonsInSeparateLine(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); @@ -109,11 +92,20 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const routePolicyID = route.params.policyID; const [account] = useOnyx(ONYXKEYS.ACCOUNT); const [fundList] = useOnyx(ONYXKEYS.FUND_LIST); - const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID); const [isComingFromGlobalReimbursementsFlow] = useOnyx(ONYXKEYS.IS_COMING_FROM_GLOBAL_REIMBURSEMENTS_FLOW); - const [lastAccessedWorkspacePolicyID] = useOnyx(ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID); - const [reimbursementAccountError] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {selector: reimbursementAccountErrorSelector}); - const {showConfirmModal, closeModal} = useConfirmModal(); + const {showConfirmModal} = useConfirmModal(); + const [isDeleteWorkspaceFlowVisible, setIsDeleteWorkspaceFlowVisible] = useState(false); + + // Primitive-valued subscriptions configuring the Delete menu item (popover behavior and the loading spinner) + // before a deletion starts. The deletion itself is handled by DeleteWorkspaceFlow, mounted on demand below. + const [canDowngrade] = useOnyx(ONYXKEYS.ACCOUNT, {selector: canDowngradeSelector}); + const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); + const [isLoadingBill] = useOnyx(ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE); + const [ownedPaidPoliciesCounts] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createOwnedPaidPoliciesCountsSelector(currentUserPersonalDetails.accountID)}, [ + currentUserPersonalDetails.accountID, + ]); + const shouldCalculateBillNewDot = !!canDowngrade && ownedPaidPoliciesCounts?.total === 1; + const wouldBlockDeletion = (amountOwed ?? 0) > 0 && ownedPaidPoliciesCounts?.active === 1; // When we create a new workspace, the policy prop will be empty on the first render. Therefore, we have to use policyDraft until policy has been set in Onyx. const policy = policyDraft?.id ? policyDraft : policyProp; @@ -131,24 +123,8 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const currencySymbol = getCurrencySymbol(outputCurrency) ?? ''; const formattedCurrency = !isEmptyObject(policy) ? `${outputCurrency} - ${currencySymbol}` : ''; - // We need this to update translation for deleting a workspace when it has third party card feeds or expensify card assigned. - const workspaceAccountID = policy?.policyAccountID ?? CONST.DEFAULT_NUMBER_ID; - const [cardFeeds, , defaultCardFeeds] = useCardFeeds(policyID); - const [lastSelectedFeed] = useOnyx(`${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`); - const [lastSelectedExpensifyCardFeed] = useOnyx(`${ONYXKEYS.COLLECTION.LAST_SELECTED_EXPENSIFY_CARD_FEED}${policyID}`); - const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`, { - selector: filterInactiveCards, - }); - const hasCardFeedOrExpensifyCard = - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - !isEmptyObject(cardFeeds) || !isEmptyObject(cardsList) || ((policy?.areExpensifyCardsEnabled || policy?.areCompanyCardsEnabled) && policy?.policyAccountID); - - const hasExpensifyCard = !!policy?.areExpensifyCardsEnabled && !isEmptyObject(cardsList); - const formattedAddress = !isEmptyObject(policy) && !isEmptyObject(policy.address) ? formatAddressToString(policy.address) : ''; - const {reportsToArchive, transactionViolations} = useTransactionViolationOfWorkspace(policyID); - const onPressCurrency = () => { if (!policyID) { return; @@ -200,7 +176,6 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const shouldShowAddress = !readOnly || !!formattedAddress; const {isAccountLocked} = useLockedAccountState(); const {showLockedAccountModal} = useLockedAccountActions(); - const [lastPaymentMethod] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD); const [pendingRulesDocumentFile, setPendingRulesDocumentFile] = useState(); const [session] = useOnyx(ONYXKEYS.SESSION); @@ -254,21 +229,10 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const shouldShowRulesDocumentSubSection = isPolicyAdmin || hasRulesDocument; const personalDetails = usePersonalDetails(); - const [accountIDToLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: accountIDToLoginSelector(reportsToArchive)}); - const privateSubscription = usePrivateSubscription(); - const accountID = currentUserPersonalDetails?.accountID; - - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const ownerPolicies = ownerPoliciesSelector(policies, accountID); - const activeOwnerPoliciesCount = ownerPolicies.filter((p) => !isPendingDeletePolicy(p)).length; - const {shouldBlockDeletion, wouldBlockDeletion, outstandingBalanceModal} = useOutstandingBalanceGuard(activeOwnerPoliciesCount); const isFocused = useIsFocused(); const isPendingDelete = isPendingDeletePolicy(policy); const prevIsPendingDelete = usePrevious(isPendingDelete); - const prevIsPendingDeleteRef = useRef(isPendingDelete); - const isErrorModalShowingRef = useRef(false); - const policyLastErrorMessage = getLatestErrorMessage(policy); const mentionReportContextValue = useMemo(() => ({policyID, currentReportID: undefined, exactlyMatch: true}), [policyID]); @@ -279,10 +243,7 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa openPolicyProfilePage(routePolicyID); }, [policyDraftID, isFocused, routePolicyID]); - const {isOffline} = useNetwork({onReconnect: fetchPolicyData}); - - const subscriptionType = privateSubscription?.type; - const canDowngrade = account?.canDowngrade; + useNetwork({onReconnect: fetchPolicyData}); // We have the same focus effect in the WorkspaceInitialPage, this way we can get the policy data in narrow // as well as in the wide layout when looking at policy settings. @@ -309,65 +270,6 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa [expensifyIcons.FallbackWorkspaceAvatar, policy?.avatarURL, policyID, policyName, styles.alignSelfCenter, styles.avatarXLarge], ); - const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); - - const hasDeleteWorkspaceExpensifyCardsError = !!hasExpensifyCard && !!isOffline; - - const confirmDelete = () => { - if (!policyID || !policyName) { - return; - } - - deleteWorkspace({ - policies, - policyID, - activePolicyID, - policyName, - lastAccessedWorkspacePolicyID, - policyCardFeeds: defaultCardFeeds, - lastSelectedFeed, - lastSelectedExpensifyCardFeed, - reportsToArchive, - transactionViolations, - reimbursementAccountError, - lastUsedPaymentMethods: lastPaymentMethod, - localeCompare, - personalPolicyID, - hasDeleteWorkspaceExpensifyCardsError, - currentUserAccountID: accountID, - accountIDToLogin: accountIDToLogin ?? {}, - }); - if (isOffline) { - closeModal(); - - if (hasDeleteWorkspaceExpensifyCardsError) { - return; - } - - goBackFromInvalidPolicy(); - } - }; - - const continueDeleteWorkspace = () => { - showConfirmModal({ - title: translate('workspace.common.delete'), - prompt: hasCardFeedOrExpensifyCard ? translate('workspace.common.deleteWithCardsConfirmation') : translate('workspace.common.deleteConfirmation'), - confirmText: translate('common.delete'), - cancelText: translate('common.cancel'), - danger: true, - isConfirmLoading: isPendingDelete, - }).then((result) => { - if (result.action !== ModalActions.CONFIRM) { - return; - } - - confirmDelete(); - }); - }; - - const {setIsDeletingPaidWorkspace, isLoadingBill}: {setIsDeletingPaidWorkspace: (value: boolean) => void; isLoadingBill: boolean | undefined} = - usePayAndDowngrade(continueDeleteWorkspace); - const dropdownMenuRef = useRef<{setIsMenuVisible: (visible: boolean) => void} | null>(null); const handleLeaveWorkspace = () => { @@ -379,10 +281,6 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa goBackFromInvalidPolicy(); }; - const hideDeleteWorkspaceErrorModal = () => { - clearDeleteWorkspaceError(policyID); - }; - useEffect(() => { if (isLoadingBill) { return; @@ -390,76 +288,6 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa dropdownMenuRef.current?.setIsMenuVisible(false); }, [isLoadingBill]); - useEffect(() => { - const prevIsPendingDeleteValue = prevIsPendingDeleteRef.current; - prevIsPendingDeleteRef.current = isPendingDelete; - - if (isOffline && policyLastErrorMessage && hasExpensifyCard) { - if (isErrorModalShowingRef.current) { - return; - } - isErrorModalShowingRef.current = true; - showConfirmModal({ - title: translate('workspace.common.delete'), - prompt: policyLastErrorMessage, - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - success: false, - }).then(() => { - isErrorModalShowingRef.current = false; - hideDeleteWorkspaceErrorModal(); - }); - return; - } - - if (!prevIsPendingDeleteValue || isPendingDelete || !policyID) { - return; - } - - closeModal(); - - if (!policyLastErrorMessage) { - if (!(isOffline && hasExpensifyCard)) { - goBackFromInvalidPolicy(); - } - return; - } - - if (!isFocused || isErrorModalShowingRef.current) { - return; - } - isErrorModalShowingRef.current = true; - showConfirmModal({ - title: translate('workspace.common.delete'), - prompt: policyLastErrorMessage, - confirmText: translate('common.buttonConfirm'), - shouldShowCancelButton: false, - success: false, - }).then(() => { - isErrorModalShowingRef.current = false; - hideDeleteWorkspaceErrorModal(); - }); - }, [isOffline, hideDeleteWorkspaceErrorModal, showConfirmModal, translate, policyLastErrorMessage, isPendingDelete, isFocused, policyID, closeModal, hasExpensifyCard]); - - const onDeleteWorkspace = () => { - if (shouldBlockWorkspaceDeletionForInvoicifyUser(isSubscriptionTypeOfInvoicing(subscriptionType), ownerPolicies, policyID)) { - Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION_DOWNGRADE_BLOCKED.getRoute(Navigation.getActiveRoute())); - return; - } - - if (shouldBlockDeletion()) { - return; - } - - if (shouldCalculateBillNewDot(currentUserPersonalDetails.accountID, canDowngrade, policies)) { - setIsDeletingPaidWorkspace(true); - calculateBillNewDot(); - return; - } - - continueDeleteWorkspace(); - }; - const handleBackButtonPress = () => { if (isComingFromGlobalReimbursementsFlow) { setIsComingFromGlobalReimbursementsFlow(false); @@ -554,10 +382,17 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa value: 'delete', text: translate('common.delete'), icon: expensifyIcons.Trashcan, - onSelected: onDeleteWorkspace, + onSelected: () => { + if (isLoadingBill) { + return; + } + + // All the pre-deletion checks and the confirmation modal are handled by DeleteWorkspaceFlow, which mounts when this is set. + setIsDeleteWorkspaceFlowVisible(true); + }, disabled: isLoadingBill, shouldShowLoadingSpinnerIcon: isLoadingBill, - shouldCloseModalOnSelect: !shouldCalculateBillNewDot(currentUserPersonalDetails.accountID, account?.canDowngrade, policies) || wouldBlockDeletion, + shouldCloseModalOnSelect: !shouldCalculateBillNewDot || wouldBlockDeletion, }); } const isCurrentUserAdmin = policy?.employeeList?.[currentUserPersonalDetails?.login ?? '']?.role === CONST.POLICY.ROLE.ADMIN; @@ -616,7 +451,14 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const modals = ( <> - {outstandingBalanceModal} + {isDeleteWorkspaceFlowVisible && !!policyID && ( + setIsDeleteWorkspaceFlowVisible(false)} + onDeleteComplete={goBackFromInvalidPolicy} + /> + )} {!!pendingRulesDocumentFile && ( void; + + /** Called when the workspace has been deleted (optimistically while offline, or after a successful online delete) */ + onDeleteComplete?: () => void; }; /** @@ -41,8 +48,9 @@ type DeleteWorkspaceFlowProps = { * On mount (once the data is ready) it runs the pre-deletion checks (Invoicify block, outstanding balance, * bill calculation for the last paid workspace) and then shows the delete confirmation modal. */ -function DeleteWorkspaceFlow({policyID, onDismiss}: DeleteWorkspaceFlowProps) { +function DeleteWorkspaceFlow({policyID, onDismiss, onDeleteComplete}: DeleteWorkspaceFlowProps) { const {translate, localeCompare} = useLocalize(); + const styles = useThemeStyles(); const {isOffline} = useNetwork(); const isFocused = useIsFocused(); const {showConfirmModal, closeModal} = useConfirmModal(); @@ -90,17 +98,74 @@ function DeleteWorkspaceFlow({policyID, onDismiss}: DeleteWorkspaceFlowProps) { !isEmptyObject(cardsList) || // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing ((policy?.areExpensifyCardsEnabled || policy?.areCompanyCardsEnabled) && policy?.policyAccountID); - const hasExpensifyCard = !!policy?.areExpensifyCardsEnabled && !isEmptyObject(cardsList); - const hasDeleteWorkspaceExpensifyCardsError = !!hasExpensifyCard && !!isOffline; + const hasExpensifyCardsEnabledOnWorkspace = !!policy?.areExpensifyCardsEnabled && !!policy?.policyAccountID && !isEmptyObject(cardsList); + const hasDeleteWorkspaceExpensifyCardsError = !!hasExpensifyCardsEnabledOnWorkspace && !!isOffline; const policyLatestErrorMessage = getLatestErrorMessage(policy); const isPendingDelete = isPendingDeletePolicy(policy); - const prevIsPendingDeleteRef = useRef(isPendingDelete); - const isErrorModalShowingRef = useRef(false); + const prevIsPendingDelete = usePrevious(isPendingDelete); const shouldCalculateBillNewDot = !!canDowngrade && ownedPaidPoliciesCounts?.total === 1; const {shouldBlockDeletion, outstandingBalanceModal} = useOutstandingBalanceGuard(ownedPaidPoliciesCounts?.active ?? 0, onDismiss); + const hideDeleteWorkspaceErrorModal = useCallback(() => { + dismissWorkspaceError(policyID, policy?.pendingAction); + }, [policyID, policy?.pendingAction]); + + const dismissDeleteWorkspaceFlow = useCallback(() => { + hideDeleteWorkspaceErrorModal(); + onDismiss(); + }, [hideDeleteWorkspaceErrorModal, onDismiss]); + + const showDeleteWorkspaceErrorModal = useCallback(() => { + if (!isFocused) { + dismissDeleteWorkspaceFlow(); + return; + } + + showConfirmModal({ + title: translate('workspace.common.delete'), + prompt: ( + + { + closeModal(); + dismissDeleteWorkspaceFlow(); + }} + /> + + ), + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + success: false, + shouldHandleNavigationBack: false, + }).then(() => { + dismissDeleteWorkspaceFlow(); + }); + }, [closeModal, dismissDeleteWorkspaceFlow, isFocused, showConfirmModal, styles.flexRow, styles.renderHTML, translate]); + + const showGenericDeleteWorkspaceErrorModal = useCallback( + (errorMessage: string) => { + if (!isFocused) { + dismissDeleteWorkspaceFlow(); + return; + } + + showConfirmModal({ + title: translate('workspace.common.delete'), + prompt: errorMessage, + confirmText: translate('common.buttonConfirm'), + shouldShowCancelButton: false, + success: false, + shouldHandleNavigationBack: false, + }).then(() => { + dismissDeleteWorkspaceFlow(); + }); + }, + [dismissDeleteWorkspaceFlow, isFocused, showConfirmModal, translate], + ); + // Always invoked after a re-render (from the start effect below for normal deletes, or from usePayAndDowngrade for billed deletes), // so the workspace being deleted and its derived data are read from the latest state. const continueDeleteWorkspace = () => { @@ -112,7 +177,7 @@ function DeleteWorkspaceFlow({policyID, onDismiss}: DeleteWorkspaceFlowProps) { confirmText: translate('common.delete'), cancelText: translate('common.cancel'), danger: true, - isConfirmLoading: isPendingDelete, + ...(hasDeleteWorkspaceExpensifyCardsError ? {} : {isConfirmLoading: isPendingDelete}), }).then((result) => { if (!policyName || result.action !== ModalActions.CONFIRM) { onDismiss(); @@ -138,11 +203,13 @@ function DeleteWorkspaceFlow({policyID, onDismiss}: DeleteWorkspaceFlowProps) { currentUserAccountID: session?.accountID ?? CONST.DEFAULT_NUMBER_ID, accountIDToLogin: accountIDToLogin ?? {}, }); - if (isOffline) { + + if (hasDeleteWorkspaceExpensifyCardsError) { + showDeleteWorkspaceErrorModal(); + } else if (isOffline) { closeModal(); - if (!hasDeleteWorkspaceExpensifyCardsError) { - onDismiss(); - } + onDeleteComplete?.(); + onDismiss(); } }); }; @@ -177,55 +244,41 @@ function DeleteWorkspaceFlow({policyID, onDismiss}: DeleteWorkspaceFlowProps) { continueDeleteWorkspace(); }); - const hideDeleteWorkspaceErrorModal = () => { - if (policy) { - dismissWorkspaceError(policy.id, policy.pendingAction); + useEffect(() => { + if (isOffline) { + return; } - onDismiss(); - }; - const showErrorModal = () => { - if (isErrorModalShowingRef.current) { + if (!prevIsPendingDelete || isPendingDelete) { return; } - isErrorModalShowingRef.current = true; - showConfirmModal({ - title: translate('workspace.common.delete'), - prompt: policyLatestErrorMessage, - confirmText: translate('common.buttonConfirm'), - cancelText: translate('common.cancel'), - success: false, - shouldShowCancelButton: false, - }).then(() => { - isErrorModalShowingRef.current = false; - hideDeleteWorkspaceErrorModal(); - }); - }; - useEffect(() => { - const prevIsPendingDelete = prevIsPendingDeleteRef.current; - prevIsPendingDeleteRef.current = isPendingDelete; + closeModal(); - // Handle showing error modal when offline and error occurs - if (isOffline && policyLatestErrorMessage) { - showErrorModal(); + if (policyLatestErrorMessage && hasExpensifyCardsEnabledOnWorkspace) { + showDeleteWorkspaceErrorModal(); return; } - if (!prevIsPendingDelete || isPendingDelete) { - return; - } - closeModal(); - if (!isFocused || !policyLatestErrorMessage) { - // The deletion either succeeded or there is no error modal to show, so the flow is finished. - if (!isErrorModalShowingRef.current) { - onDismiss(); - } + if (policyLatestErrorMessage) { + showGenericDeleteWorkspaceErrorModal(policyLatestErrorMessage); return; } - showErrorModal(); - }, [isOffline, hideDeleteWorkspaceErrorModal, showConfirmModal, translate, policyLatestErrorMessage, isPendingDelete, isFocused, closeModal, onDismiss, showErrorModal]); + onDeleteComplete?.(); + onDismiss(); + }, [ + isOffline, + isPendingDelete, + prevIsPendingDelete, + policyLatestErrorMessage, + hasExpensifyCardsEnabledOnWorkspace, + closeModal, + onDeleteComplete, + onDismiss, + showDeleteWorkspaceErrorModal, + showGenericDeleteWorkspaceErrorModal, + ]); return outstandingBalanceModal; }