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 [ (→ "[") and ] (→ "]"). Index 7 is the distinguishing digit ('1' vs '3').
const RE_BRACKET_ESCAPE = /	[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;
}