From 562ac608a03e2855d4e4d39204dbda34c68f2aca Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 12 Jun 2025 03:44:57 +0700 Subject: [PATCH 01/23] feat: update error pattern for delete workspace while on annual subscription --- src/CONST.ts | 1 + src/ONYXKEYS.ts | 4 +++ src/languages/en.ts | 1 + src/languages/es.ts | 1 + src/libs/API/types.ts | 4 +-- src/libs/actions/Policy/Policy.ts | 30 ++++++++++++++++++---- src/pages/workspace/WorkspacesListPage.tsx | 25 +++++++++++++++++- 7 files changed, 58 insertions(+), 8 deletions(-) diff --git a/src/CONST.ts b/src/CONST.ts index 5f95e32d4e0d..0f5ad004c1db 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -1800,6 +1800,7 @@ const CONST = { ERROR_TITLE: { SOCKET: 'Issue connecting to database', DUPLICATE_RECORD: '400 Unique Constraints Violation', + CANNOT_DELETE_WORKSPACE_ANNUAL_SUBSCRIPTION: "You can't delete the workspace until the end of the annual subscription term.", }, NETWORK: { METHOD: { diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index e8829e56f6d1..d78a0799c5ce 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -535,6 +535,9 @@ const ONYXKEYS = { NVP_LAST_ECASH_ANDROID_LOGIN: 'nvp_lastECashAndroidLogin', NVP_LAST_ANDROID_LOGIN: 'nvp_lastAndroidLogin', + /** Set when the delete workspace annual subscription error modal is open */ + IS_DELETE_WORKSPACE_ANNUAL_SUBSCRIPTION_ERROR_MODAL_OPEN: 'isDeleteWorkspaceAnnualSubscriptionErrorModalOpen', + /** Collection Keys */ COLLECTION: { DOWNLOAD: 'download_', @@ -1174,6 +1177,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_LAST_ECASH_ANDROID_LOGIN]: string; [ONYXKEYS.NVP_LAST_IPHONE_LOGIN]: string; [ONYXKEYS.NVP_LAST_ANDROID_LOGIN]: string; + [ONYXKEYS.IS_DELETE_WORKSPACE_ANNUAL_SUBSCRIPTION_ERROR_MODAL_OPEN]: boolean; }; type OnyxDerivedValuesMapping = { diff --git a/src/languages/en.ts b/src/languages/en.ts index 2d501086d026..9223b65e8a65 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3117,6 +3117,7 @@ const translations = { defaultCategory: 'Default category', viewTransactions: 'View transactions', policyExpenseChatName: ({displayName}: PolicyExpenseChatNameParams) => `${displayName}'s expenses`, + cannotDeleteWorkspaceAnnualSubscriptionError: "You can't delete the workspace until the end of the annual subscription term.", }, perDiem: { subtitle: 'Set per diem rates to control daily employee spend. ', diff --git a/src/languages/es.ts b/src/languages/es.ts index 4130c41689a4..20e0b2d88fc9 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3141,6 +3141,7 @@ const translations = { defaultCategory: 'Categoría predeterminada', viewTransactions: 'Ver transacciones', policyExpenseChatName: ({displayName}: PolicyExpenseChatNameParams) => `${displayName}'s gastos`, + cannotDeleteWorkspaceAnnualSubscriptionError: 'No puedes eliminar el espacio de trabajo hasta el final del período de suscripción anual.', }, perDiem: { subtitle: 'Establece las tasas per diem para controlar los gastos diarios de los empleados. ', diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index aab6464fd315..753fc1053ce5 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -136,7 +136,6 @@ const WRITE_COMMANDS = { UPDATE_REPORT_PRIVATE_NOTE: 'UpdateReportPrivateNote', RESOLVE_ACTIONABLE_MENTION_WHISPER: 'ResolveActionableMentionWhisper', RESOLVE_ACTIONABLE_REPORT_MENTION_WHISPER: 'ResolveActionableReportMentionWhisper', - DELETE_WORKSPACE: 'DeleteWorkspace', DELETE_MEMBERS_FROM_WORKSPACE: 'DeleteMembersFromWorkspace', ADD_MEMBERS_TO_WORKSPACE: 'AddMembersToWorkspace', UPDATE_WORKSPACE_AVATAR: 'UpdateWorkspaceAvatar', @@ -610,7 +609,6 @@ type WriteCommandParameters = { [WRITE_COMMANDS.RESOLVE_ACTIONABLE_REPORT_MENTION_WHISPER]: Parameters.ResolveActionableReportMentionWhisperParams; [WRITE_COMMANDS.CHRONOS_REMOVE_OOO_EVENT]: Parameters.ChronosRemoveOOOEventParams; [WRITE_COMMANDS.TRANSFER_WALLET_BALANCE]: Parameters.TransferWalletBalanceParams; - [WRITE_COMMANDS.DELETE_WORKSPACE]: Parameters.DeleteWorkspaceParams; [WRITE_COMMANDS.DELETE_MEMBERS_FROM_WORKSPACE]: Parameters.DeleteMembersFromWorkspaceParams; [WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE]: Parameters.AddMembersToWorkspaceParams; [WRITE_COMMANDS.UPDATE_WORKSPACE_AVATAR]: Parameters.UpdateWorkspaceAvatarParams; @@ -1159,6 +1157,7 @@ const SIDE_EFFECT_REQUEST_COMMANDS = { CREATE_DIGITAL_WALLET: 'CreateDigitalWallet', VERIFY_TEST_DRIVE_RECIPIENT: 'VerifyTestDriveRecipient', LOCK_ACCOUNT: 'LockAccount', + DELETE_WORKSPACE: 'DeleteWorkspace', } as const; type SideEffectRequestCommand = ValueOf; @@ -1182,6 +1181,7 @@ type SideEffectRequestCommandParameters = { [SIDE_EFFECT_REQUEST_COMMANDS.CREATE_DIGITAL_WALLET]: Parameters.CreateDigitalWalletParams; [SIDE_EFFECT_REQUEST_COMMANDS.VERIFY_TEST_DRIVE_RECIPIENT]: Parameters.VerifyTestDriveRecipientParams; [SIDE_EFFECT_REQUEST_COMMANDS.LOCK_ACCOUNT]: Parameters.LockAccountParams; + [SIDE_EFFECT_REQUEST_COMMANDS.DELETE_WORKSPACE]: Parameters.DeleteWorkspaceParams; }; type ApiRequestCommandParameters = WriteCommandParameters & ReadCommandParameters & SideEffectRequestCommandParameters; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 19a439ed3c1f..f71b3c65592b 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -61,7 +61,7 @@ import type { UpgradeToCorporateParams, } from '@libs/API/parameters'; import type UpdatePolicyMembersCustomFieldsParams from '@libs/API/parameters/UpdatePolicyMembersCustomFieldsParams'; -import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; @@ -448,6 +448,14 @@ function deleteWorkspace(policyID: string, policyName: string) { }, }); + failureData.push({ + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + errors: null, + }, + }); + if (report?.iouReportID) { const reportTransactions = ReportUtils.getReportTransactions(report.iouReportID); for (const transaction of reportTransactions) { @@ -466,14 +474,21 @@ function deleteWorkspace(policyID: string, policyName: string) { } }); - const params: DeleteWorkspaceParams = {policyID}; - - API.write(WRITE_COMMANDS.DELETE_WORKSPACE, params, {optimisticData, finallyData, failureData}); - // Reset the lastAccessedWorkspacePolicyID if (policyID === lastAccessedWorkspacePolicyID) { updateLastAccessedWorkspace(undefined); } + + const params: DeleteWorkspaceParams = {policyID}; + + // eslint-disable-next-line rulesdir/no-api-side-effects-method + API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.DELETE_WORKSPACE, params, {optimisticData, finallyData, failureData}).then((response) => { + if (!response || response.jsonCode !== CONST.JSON_CODE.EXP_ERROR || response.message !== CONST.ERROR_TITLE.CANNOT_DELETE_WORKSPACE_ANNUAL_SUBSCRIPTION) { + return; + } + setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen(true); + clearDeleteWorkspaceError(policyID); + }); } function setWorkspaceAutoReportingFrequency(policyID: string, frequency: ValueOf) { @@ -5324,6 +5339,10 @@ function setIsComingFromGlobalReimbursementsFlow(value: boolean) { Onyx.set(ONYXKEYS.IS_COMING_FROM_GLOBAL_REIMBURSEMENTS_FLOW, value); } +function setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen(isOpen: boolean) { + Onyx.merge(ONYXKEYS.IS_DELETE_WORKSPACE_ANNUAL_SUBSCRIPTION_ERROR_MODAL_OPEN, isOpen); +} + export { leaveWorkspace, addBillingCardAndRequestPolicyOwnerChange, @@ -5422,6 +5441,7 @@ export { updateInvoiceCompanyName, updateInvoiceCompanyWebsite, updateDefaultPolicy, + setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen, getAssignedSupportData, downgradeToTeam, getAccessiblePolicies, diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 61cf021e7b52..581ca4326990 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -35,7 +35,16 @@ import useSearchResults from '@hooks/useSearchResults'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {isConnectionInProgress} from '@libs/actions/connections'; -import {calculateBillNewDot, clearDeleteWorkspaceError, clearErrors, deleteWorkspace, leaveWorkspace, removeWorkspace, updateDefaultPolicy} from '@libs/actions/Policy/Policy'; +import { + calculateBillNewDot, + clearDeleteWorkspaceError, + clearErrors, + deleteWorkspace, + leaveWorkspace, + removeWorkspace, + setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen, + updateDefaultPolicy, +} from '@libs/actions/Policy/Policy'; import {callFunctionIfActionIsAllowed, isSupportAuthToken} from '@libs/actions/Session'; import {filterInactiveCards} from '@libs/CardUtils'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; @@ -127,6 +136,7 @@ function WorkspacesListPage() { const [session] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: true}); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); + const [isDeleteWorkspaceAnnualSubscriptionErrorModalOpen] = useOnyx(ONYXKEYS.IS_DELETE_WORKSPACE_ANNUAL_SUBSCRIPTION_ERROR_MODAL_OPEN, {canBeMissing: true}); const shouldShowLoadingIndicator = isLoadingApp && !isOffline; const route = useRoute>(); @@ -163,6 +173,9 @@ function WorkspacesListPage() { const hideSupportalModal = () => { setIsSupportalActionRestrictedModalOpen(false); }; + const hideDeleteWorkspaceAnnualSubscriptionErrorModal = () => { + setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen(false); + }; const confirmDeleteAndHideModal = () => { if (!policyIDToDelete || !policyNameToDelete) { return; @@ -559,6 +572,16 @@ function WorkspacesListPage() { isModalOpen={isSupportalActionRestrictedModalOpen} hideSupportalModal={hideSupportalModal} /> + {shouldDisplayLHB && } ); From 55d9c47dabcfa1a01bec5d539cabdca8522f2c74 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 12 Jun 2025 04:07:59 +0700 Subject: [PATCH 02/23] implement offline case --- src/libs/API/types.ts | 2 ++ src/libs/PolicyUtils.ts | 5 +++++ src/libs/actions/Policy/Policy.ts | 17 +++++++---------- src/pages/workspace/WorkspaceInitialPage.tsx | 2 ++ 4 files changed, 16 insertions(+), 10 deletions(-) diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 753fc1053ce5..0b9d60d5b721 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -136,6 +136,7 @@ const WRITE_COMMANDS = { UPDATE_REPORT_PRIVATE_NOTE: 'UpdateReportPrivateNote', RESOLVE_ACTIONABLE_MENTION_WHISPER: 'ResolveActionableMentionWhisper', RESOLVE_ACTIONABLE_REPORT_MENTION_WHISPER: 'ResolveActionableReportMentionWhisper', + DELETE_WORKSPACE: 'DeleteWorkspace', DELETE_MEMBERS_FROM_WORKSPACE: 'DeleteMembersFromWorkspace', ADD_MEMBERS_TO_WORKSPACE: 'AddMembersToWorkspace', UPDATE_WORKSPACE_AVATAR: 'UpdateWorkspaceAvatar', @@ -609,6 +610,7 @@ type WriteCommandParameters = { [WRITE_COMMANDS.RESOLVE_ACTIONABLE_REPORT_MENTION_WHISPER]: Parameters.ResolveActionableReportMentionWhisperParams; [WRITE_COMMANDS.CHRONOS_REMOVE_OOO_EVENT]: Parameters.ChronosRemoveOOOEventParams; [WRITE_COMMANDS.TRANSFER_WALLET_BALANCE]: Parameters.TransferWalletBalanceParams; + [WRITE_COMMANDS.DELETE_WORKSPACE]: Parameters.DeleteWorkspaceParams; [WRITE_COMMANDS.DELETE_MEMBERS_FROM_WORKSPACE]: Parameters.DeleteMembersFromWorkspaceParams; [WRITE_COMMANDS.ADD_MEMBERS_TO_WORKSPACE]: Parameters.AddMembersToWorkspaceParams; [WRITE_COMMANDS.UPDATE_WORKSPACE_AVATAR]: Parameters.UpdateWorkspaceAvatarParams; diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 53f019e5e41b..d0de29705583 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -147,6 +147,10 @@ function shouldShowPolicyError(policy: OnyxEntry): boolean { return Object.keys(policy?.errors ?? {}).length > 0 ? isPolicyAdmin(policy) : shouldShowPolicyErrorFields(policy); } +function isDeleteWorkspaceAnnualSubscriptionError(policy: OnyxEntry): boolean { + return Object.values(policy?.errors ?? {}).some((error) => error === CONST.ERROR_TITLE.CANNOT_DELETE_WORKSPACE_ANNUAL_SUBSCRIPTION); +} + /** * Checks if we have any errors stored within the policy custom units. */ @@ -1455,6 +1459,7 @@ export { hasPolicyCategoriesError, shouldShowPolicyError, shouldShowPolicyErrorFields, + isDeleteWorkspaceAnnualSubscriptionError, shouldShowTaxRateError, isControlOnAdvancedApprovalMode, isExpensifyTeam, diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index f71b3c65592b..9a31074dab00 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -448,14 +448,6 @@ function deleteWorkspace(policyID: string, policyName: string) { }, }); - failureData.push({ - onyxMethod: Onyx.METHOD.MERGE, - key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: { - errors: null, - }, - }); - if (report?.iouReportID) { const reportTransactions = ReportUtils.getReportTransactions(report.iouReportID); for (const transaction of reportTransactions) { @@ -481,13 +473,18 @@ function deleteWorkspace(policyID: string, policyName: string) { const params: DeleteWorkspaceParams = {policyID}; - // eslint-disable-next-line rulesdir/no-api-side-effects-method + if (NetworkStore.isOffline()) { + API.write(WRITE_COMMANDS.DELETE_WORKSPACE, params, {optimisticData, finallyData, failureData}); + return; + } + + // eslint-disable-next-line rulesdir/no-api-side-effects-method, rulesdir/no-multiple-api-calls API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.DELETE_WORKSPACE, params, {optimisticData, finallyData, failureData}).then((response) => { if (!response || response.jsonCode !== CONST.JSON_CODE.EXP_ERROR || response.message !== CONST.ERROR_TITLE.CANNOT_DELETE_WORKSPACE_ANNUAL_SUBSCRIPTION) { return; } - setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen(true); clearDeleteWorkspaceError(policyID); + setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen(true); }); } diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 91deef0f5b42..4ba3c3893f3d 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -52,6 +52,7 @@ import { shouldShowPolicy as checkIfShouldShowPolicy, goBackFromInvalidPolicy, hasPolicyCategoriesError, + isDeleteWorkspaceAnnualSubscriptionError, isPaidGroupPolicy, isPendingDeletePolicy, isPolicyAdmin, @@ -460,6 +461,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac pendingAction={policy?.pendingAction} onClose={() => dismissError(policyID, policy?.pendingAction)} errors={policy?.errors} + shouldShowErrorMessages={!isDeleteWorkspaceAnnualSubscriptionError(policy)} errorRowStyles={[styles.ph5, styles.pv2]} shouldDisableStrikeThrough={false} shouldHideOnDelete={false} From 752569ad00f9808bede3dea9befc45f6f7e814e1 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 26 Jun 2025 21:47:16 +0700 Subject: [PATCH 03/23] implement API.write approach --- src/ONYXKEYS.ts | 4 --- src/libs/API/types.ts | 2 -- src/libs/actions/Policy/Policy.ts | 27 ++++--------------- src/pages/workspace/WorkspacesListPage.tsx | 30 ++++++++++++---------- 4 files changed, 22 insertions(+), 41 deletions(-) diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 3472f99030e1..1a6520254aba 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -555,9 +555,6 @@ const ONYXKEYS = { NVP_LAST_ECASH_ANDROID_LOGIN: 'nvp_lastECashAndroidLogin', NVP_LAST_ANDROID_LOGIN: 'nvp_lastAndroidLogin', - /** Set when the delete workspace annual subscription error modal is open */ - IS_DELETE_WORKSPACE_ANNUAL_SUBSCRIPTION_ERROR_MODAL_OPEN: 'isDeleteWorkspaceAnnualSubscriptionErrorModalOpen', - /** Collection Keys */ COLLECTION: { DOWNLOAD: 'download_', @@ -1206,7 +1203,6 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_LAST_IPHONE_LOGIN]: string; [ONYXKEYS.NVP_LAST_ANDROID_LOGIN]: string; [ONYXKEYS.TRANSACTION_THREAD_NAVIGATION_REPORT_IDS]: string[]; - [ONYXKEYS.IS_DELETE_WORKSPACE_ANNUAL_SUBSCRIPTION_ERROR_MODAL_OPEN]: boolean; }; type OnyxDerivedValuesMapping = { diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 5f4558279346..3fd1cf99a017 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -1167,7 +1167,6 @@ const SIDE_EFFECT_REQUEST_COMMANDS = { VERIFY_TEST_DRIVE_RECIPIENT: 'VerifyTestDriveRecipient', LOCK_ACCOUNT: 'LockAccount', SET_VACATION_DELEGATE: 'SetVacationDelegate', - DELETE_WORKSPACE: 'DeleteWorkspace', } as const; type SideEffectRequestCommand = ValueOf; @@ -1192,7 +1191,6 @@ type SideEffectRequestCommandParameters = { [SIDE_EFFECT_REQUEST_COMMANDS.VERIFY_TEST_DRIVE_RECIPIENT]: Parameters.VerifyTestDriveRecipientParams; [SIDE_EFFECT_REQUEST_COMMANDS.LOCK_ACCOUNT]: Parameters.LockAccountParams; [SIDE_EFFECT_REQUEST_COMMANDS.SET_VACATION_DELEGATE]: Parameters.SetVacationDelegateParams; - [SIDE_EFFECT_REQUEST_COMMANDS.DELETE_WORKSPACE]: Parameters.DeleteWorkspaceParams; }; type ApiRequestCommandParameters = WriteCommandParameters & ReadCommandParameters & SideEffectRequestCommandParameters; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index fd7b800a96f7..de9189315b23 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -61,7 +61,7 @@ import type { UpgradeToCorporateParams, } from '@libs/API/parameters'; import type UpdatePolicyMembersCustomFieldsParams from '@libs/API/parameters/UpdatePolicyMembersCustomFieldsParams'; -import {READ_COMMANDS, SIDE_EFFECT_REQUEST_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; +import {READ_COMMANDS, WRITE_COMMANDS} from '@libs/API/types'; import * as CurrencyUtils from '@libs/CurrencyUtils'; import DateUtils from '@libs/DateUtils'; import * as ErrorUtils from '@libs/ErrorUtils'; @@ -496,26 +496,14 @@ function deleteWorkspace(policyID: string, policyName: string) { } }); + const params: DeleteWorkspaceParams = {policyID}; + + API.write(WRITE_COMMANDS.DELETE_WORKSPACE, params, {optimisticData, finallyData, failureData}); + // Reset the lastAccessedWorkspacePolicyID if (policyID === lastAccessedWorkspacePolicyID) { updateLastAccessedWorkspace(undefined); } - - const params: DeleteWorkspaceParams = {policyID}; - - if (NetworkStore.isOffline()) { - API.write(WRITE_COMMANDS.DELETE_WORKSPACE, params, {optimisticData, finallyData, failureData}); - return; - } - - // eslint-disable-next-line rulesdir/no-api-side-effects-method, rulesdir/no-multiple-api-calls - API.makeRequestWithSideEffects(SIDE_EFFECT_REQUEST_COMMANDS.DELETE_WORKSPACE, params, {optimisticData, finallyData, failureData}).then((response) => { - if (!response || response.jsonCode !== CONST.JSON_CODE.EXP_ERROR || response.message !== CONST.ERROR_TITLE.CANNOT_DELETE_WORKSPACE_ANNUAL_SUBSCRIPTION) { - return; - } - clearDeleteWorkspaceError(policyID); - setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen(true); - }); } function setWorkspaceAutoReportingFrequency(policyID: string, frequency: ValueOf) { @@ -5444,10 +5432,6 @@ function setIsComingFromGlobalReimbursementsFlow(value: boolean) { Onyx.set(ONYXKEYS.IS_COMING_FROM_GLOBAL_REIMBURSEMENTS_FLOW, value); } -function setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen(isOpen: boolean) { - Onyx.merge(ONYXKEYS.IS_DELETE_WORKSPACE_ANNUAL_SUBSCRIPTION_ERROR_MODAL_OPEN, isOpen); -} - export { leaveWorkspace, addBillingCardAndRequestPolicyOwnerChange, @@ -5546,7 +5530,6 @@ export { updateInvoiceCompanyName, updateInvoiceCompanyWebsite, updateDefaultPolicy, - setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen, getAssignedSupportData, downgradeToTeam, getAccessiblePolicies, diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 1c331cc2dde9..ea4f7b81e9ad 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -1,5 +1,5 @@ import {useRoute} from '@react-navigation/native'; -import React, {useCallback, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {FlatList, View} from 'react-native'; import {useOnyx} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; @@ -35,16 +35,7 @@ import useSearchResults from '@hooks/useSearchResults'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {isConnectionInProgress} from '@libs/actions/connections'; -import { - calculateBillNewDot, - clearDeleteWorkspaceError, - clearErrors, - deleteWorkspace, - leaveWorkspace, - removeWorkspace, - setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen, - updateDefaultPolicy, -} from '@libs/actions/Policy/Policy'; +import {calculateBillNewDot, clearDeleteWorkspaceError, clearErrors, deleteWorkspace, leaveWorkspace, removeWorkspace, updateDefaultPolicy} from '@libs/actions/Policy/Policy'; import {callFunctionIfActionIsAllowed, isSupportAuthToken} from '@libs/actions/Session'; import {filterInactiveCards} from '@libs/CardUtils'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; @@ -52,7 +43,7 @@ import localeCompare from '@libs/LocaleCompare'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {AuthScreensParamList} from '@libs/Navigation/types'; -import {getPolicy, getPolicyBrickRoadIndicatorStatus, isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils'; +import {getPolicy, getPolicyBrickRoadIndicatorStatus, isDeleteWorkspaceAnnualSubscriptionError, isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import {shouldCalculateBillNewDot as shouldCalculateBillNewDotFn} from '@libs/SubscriptionUtils'; import type {AvatarSource} from '@libs/UserUtils'; @@ -126,13 +117,13 @@ function WorkspacesListPage() { const [session] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: true}); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); - const [isDeleteWorkspaceAnnualSubscriptionErrorModalOpen] = useOnyx(ONYXKEYS.IS_DELETE_WORKSPACE_ANNUAL_SUBSCRIPTION_ERROR_MODAL_OPEN, {canBeMissing: true}); const shouldShowLoadingIndicator = isLoadingApp && !isOffline; const route = useRoute>(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [policyIDToDelete, setPolicyIDToDelete] = useState(); const [policyNameToDelete, setPolicyNameToDelete] = useState(); + const [isDeleteWorkspaceAnnualSubscriptionErrorModalOpen, setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen] = useState(false); const {setIsDeletingPaidWorkspace, isLoadingBill}: {setIsDeletingPaidWorkspace: (value: boolean) => void; isLoadingBill: boolean | undefined} = usePayAndDowngrade(setIsDeleteModalOpen); const [loadingSpinnerIconIndex, setLoadingSpinnerIconIndex] = useState(null); @@ -165,6 +156,11 @@ function WorkspacesListPage() { }; const hideDeleteWorkspaceAnnualSubscriptionErrorModal = () => { setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen(false); + + if (!policyIDToDelete) { + return; + } + clearDeleteWorkspaceError(policyIDToDelete); }; const confirmDeleteAndHideModal = () => { if (!policyIDToDelete || !policyNameToDelete) { @@ -181,6 +177,14 @@ function WorkspacesListPage() { setLoadingSpinnerIconIndex(null); }, []); + useEffect(() => { + if (isDeleteWorkspaceAnnualSubscriptionErrorModalOpen || !Object.values(policies ?? {}).some(isDeleteWorkspaceAnnualSubscriptionError)) { + return; + } + + setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen(true); + }, [isDeleteWorkspaceAnnualSubscriptionErrorModalOpen, policies]); + /** * Gets the menu item for each workspace */ From 3ac72866214856aaed1dd42a02270f312eb11147 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 26 Jun 2025 21:52:54 +0700 Subject: [PATCH 04/23] update languages --- src/languages/de.ts | 1 + src/languages/fr.ts | 1 + src/languages/it.ts | 1 + src/languages/ja.ts | 1 + src/languages/nl.ts | 1 + src/languages/pl.ts | 1 + src/languages/pt-BR.ts | 1 + src/languages/zh-hans.ts | 1 + 8 files changed, 8 insertions(+) diff --git a/src/languages/de.ts b/src/languages/de.ts index ad1da7e5173b..fa51ac0e92ee 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -3271,6 +3271,7 @@ const translations = { defaultCategory: 'Standardkategorie', viewTransactions: 'Transaktionen anzeigen', policyExpenseChatName: ({displayName}: PolicyExpenseChatNameParams) => `Ausgaben von ${displayName}`, + cannotDeleteWorkspaceAnnualSubscriptionError: 'Sie können den Arbeitsbereich erst am Ende der jährlichen Abonnementlaufzeit löschen.', }, perDiem: { subtitle: 'Setzen Sie Tagespauschalen, um die täglichen Ausgaben der Mitarbeiter zu kontrollieren.', diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 69d3451c85f9..eccfe8e60326 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -3276,6 +3276,7 @@ const translations = { defaultCategory: 'Catégorie par défaut', viewTransactions: 'Voir les transactions', policyExpenseChatName: ({displayName}: PolicyExpenseChatNameParams) => `Les dépenses de ${displayName}`, + cannotDeleteWorkspaceAnnualSubscriptionError: "Vous ne pouvez pas supprimer l'espace de travail avant la fin de la période d'abonnement annuel.", }, perDiem: { subtitle: 'Définissez des taux de per diem pour contrôler les dépenses quotidiennes des employés.', diff --git a/src/languages/it.ts b/src/languages/it.ts index 58af587e5b02..dba0f5e5b7db 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -3255,6 +3255,7 @@ const translations = { defaultCategory: 'Categoria predefinita', viewTransactions: 'Visualizza transazioni', policyExpenseChatName: ({displayName}: PolicyExpenseChatNameParams) => `Spese di ${displayName}`, + cannotDeleteWorkspaceAnnualSubscriptionError: "Non puoi eliminare lo spazio di lavoro fino alla fine del termine dell'abbonamento annuale.", }, perDiem: { subtitle: 'Imposta le tariffe di diaria per controllare la spesa giornaliera dei dipendenti.', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index a7fe9c587095..ffc44cc0114f 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -3241,6 +3241,7 @@ const translations = { defaultCategory: 'デフォルトカテゴリ', viewTransactions: '取引を表示', policyExpenseChatName: ({displayName}: PolicyExpenseChatNameParams) => `${displayName}の経費`, + cannotDeleteWorkspaceAnnualSubscriptionError: '年次サブスクリプション期間が終了するまで、ワークスペースを削除することはできません。', }, perDiem: { subtitle: '日当料金を設定して、従業員の1日の支出を管理します。', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index dc748627733a..5610e44166a2 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -3257,6 +3257,7 @@ const translations = { defaultCategory: 'Standaardcategorie', viewTransactions: 'Transacties bekijken', policyExpenseChatName: ({displayName}: PolicyExpenseChatNameParams) => `Uitgaven van ${displayName}`, + cannotDeleteWorkspaceAnnualSubscriptionError: 'U kunt de werkruimte niet verwijderen tot het einde van de jaarlijkse abonnementsperiode.', }, perDiem: { subtitle: 'Stel dagvergoedingen in om de dagelijkse uitgaven van werknemers te beheersen.', diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 0d961f83f1ef..682e25a8766a 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -3249,6 +3249,7 @@ const translations = { defaultCategory: 'Domyślna kategoria', viewTransactions: 'Wyświetl transakcje', policyExpenseChatName: ({displayName}: PolicyExpenseChatNameParams) => `Wydatki ${displayName}`, + cannotDeleteWorkspaceAnnualSubscriptionError: 'Nie możesz usunąć przestrzeni roboczej do końca rocznego okresu subskrypcji.', }, perDiem: { subtitle: 'Ustaw stawki diety, aby kontrolować dzienne wydatki pracowników.', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 361c4babe993..549461021e8e 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -3253,6 +3253,7 @@ const translations = { defaultCategory: 'Categoria padrão', viewTransactions: 'Ver transações', policyExpenseChatName: ({displayName}: PolicyExpenseChatNameParams) => `Despesas de ${displayName}`, + cannotDeleteWorkspaceAnnualSubscriptionError: 'Você não pode excluir o espaço de trabalho até o final do período de assinatura anual.', }, perDiem: { subtitle: 'Defina taxas de diárias para controlar os gastos diários dos funcionários.', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 90534f741d15..9cbf73ad9974 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -3214,6 +3214,7 @@ const translations = { defaultCategory: '默认类别', viewTransactions: '查看交易记录', policyExpenseChatName: ({displayName}: PolicyExpenseChatNameParams) => `${displayName}的费用`, + cannotDeleteWorkspaceAnnualSubscriptionError: '在年度订阅期结束之前,您无法删除工作区。', }, perDiem: { subtitle: '设置每日津贴标准以控制员工的日常支出。', From ad1534bd1ea7b8e82600546f5fe82903879c7651 Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 8 Jul 2025 07:43:05 +0700 Subject: [PATCH 05/23] handle delete in offline --- src/libs/PolicyUtils.ts | 30 +++++++++++++++++++++- src/libs/actions/Policy/Policy.ts | 9 +++++-- src/pages/workspace/WorkspacesListPage.tsx | 27 +++++++++++-------- 3 files changed, 53 insertions(+), 13 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 4fe5d57c8820..a1d7220fabc8 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -8,7 +8,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/NetSuiteCustomFieldForm'; -import type {OnyxInputOrEntry, Policy, PolicyCategories, PolicyEmployeeList, PolicyTagLists, PolicyTags, Report, TaxRate} from '@src/types/onyx'; +import type {Locale, OnyxInputOrEntry, Policy, PolicyCategories, PolicyEmployeeList, PolicyTagLists, PolicyTags, Report, TaxRate} from '@src/types/onyx'; import type {ErrorFields, PendingAction, PendingFields} from '@src/types/onyx/OnyxCommon'; import type { ConnectionLastSync, @@ -36,6 +36,7 @@ import {hasSynchronizationErrorMessage, isAuthenticationError} from './actions/c import {shouldShowQBOReimbursableExportDestinationAccountError} from './actions/connections/QuickbooksOnline'; import {getCurrentUserAccountID, getCurrentUserEmail} from './actions/Report'; import {getCategoryApproverRule} from './CategoryUtils'; +import DateUtils from './DateUtils'; import {translateLocal} from './Localize'; import Navigation from './Navigation/Navigation'; import {isOffline as isOfflineNetworkStore} from './Network/NetworkStore'; @@ -1445,6 +1446,32 @@ function isUserInvitedToWorkspace(): boolean { ); } +/** + * Checks if the policy was last modified while the user was offline. + */ +function wasPolicyLastModifiedWhileOffline( + policy: OnyxEntry, + isOffline: boolean, + lastOfflineAt: Date | undefined, + lastOnlineAt: Date | undefined, + locale: Locale = CONST.LOCALES.DEFAULT, +): boolean { + if (!lastOfflineAt || !lastOnlineAt || isEmptyObject(policy)) { + return false; + } + + const policyLastModifiedAt = DateUtils.getLocalDateFromDatetime(locale, policy.lastModified); + if (policyLastModifiedAt <= lastOfflineAt) { + return false; + } + + if (isOffline || policyLastModifiedAt < lastOnlineAt) { + return true; + } + + return false; +} + export { canEditTaxRate, escapeTagName, @@ -1593,6 +1620,7 @@ export { isUserInvitedToWorkspace, getPolicyRole, hasIndependentTags, + wasPolicyLastModifiedWhileOffline, }; export type {MemberEmailsToAccountIDs}; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 021e3c4d284f..a4c87c455f6a 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -327,6 +327,7 @@ function deleteWorkspace(policyID: string, policyName: string) { } const filteredPolicies = Object.values(allPolicies).filter((policy): policy is Policy => policy?.id !== policyID); + const currentTime = DateUtils.getDBTime(); const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -334,6 +335,7 @@ function deleteWorkspace(policyID: string, policyName: string) { value: { avatarURL: '', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + lastModified: currentTime, errors: null, }, }, @@ -376,7 +378,6 @@ function deleteWorkspace(policyID: string, policyName: string) { (report) => ReportUtils.isPolicyRelatedReport(report, policyID) && (ReportUtils.isChatRoom(report) || ReportUtils.isPolicyExpenseChat(report) || ReportUtils.isTaskReport(report)), ); const finallyData: OnyxUpdate[] = []; - const currentTime = DateUtils.getDBTime(); reportsToArchive.forEach((report) => { const {reportID, ownerAccountID, oldPolicyName} = report ?? {}; const isInvoiceReceiverReport = report?.invoiceReceiver && 'policyID' in report.invoiceReceiver && report.invoiceReceiver.policyID === policyID; @@ -1646,7 +1647,11 @@ function updateAddress(policyID: string, newAddress: CompanyAddress) { /** * Removes an error after trying to delete a workspace */ -function clearDeleteWorkspaceError(policyID: string) { +function clearDeleteWorkspaceError(policyID: string | undefined) { + if (!policyID) { + return; + } + Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, { pendingAction: null, errors: null, diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index b58f1d89d566..3f655aba84f2 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -1,4 +1,4 @@ -import {useRoute} from '@react-navigation/native'; +import {useIsFocused, useRoute} from '@react-navigation/native'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import {FlatList, View} from 'react-native'; import type {ValueOf} from 'type-fest'; @@ -28,6 +28,7 @@ import useCardFeeds from '@hooks/useCardFeeds'; import useHandleBackButton from '@hooks/useHandleBackButton'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; +import useNetworkWithOfflineStatus from '@hooks/useNetworkWithOfflineStatus'; import useOnyx from '@hooks/useOnyx'; import usePayAndDowngrade from '@hooks/usePayAndDowngrade'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; @@ -43,7 +44,7 @@ import localeCompare from '@libs/LocaleCompare'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {AuthScreensParamList} from '@libs/Navigation/types'; -import {getPolicy, getPolicyBrickRoadIndicatorStatus, isDeleteWorkspaceAnnualSubscriptionError, isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils'; +import {getPolicy, getPolicyBrickRoadIndicatorStatus, isDeleteWorkspaceAnnualSubscriptionError, isPolicyAdmin, shouldShowPolicy, wasPolicyLastModifiedWhileOffline} from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import {shouldCalculateBillNewDot as shouldCalculateBillNewDotFn} from '@libs/SubscriptionUtils'; import type {AvatarSource} from '@libs/UserUtils'; @@ -108,7 +109,7 @@ function dismissWorkspaceError(policyID: string, pendingAction: OnyxCommon.Pendi function WorkspacesListPage() { const theme = useTheme(); const styles = useThemeStyles(); - const {translate} = useLocalize(); + const {translate, preferredLocale} = useLocalize(); const {isOffline} = useNetwork(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); const [allConnectionSyncProgresses] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS, {canBeMissing: true}); @@ -122,9 +123,12 @@ function WorkspacesListPage() { const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [policyIDToDelete, setPolicyIDToDelete] = useState(); + // The workspace was deleted in this page const [policyNameToDelete, setPolicyNameToDelete] = useState(); const [isDeleteWorkspaceAnnualSubscriptionErrorModalOpen, setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen] = useState(false); const {setIsDeletingPaidWorkspace, isLoadingBill}: {setIsDeletingPaidWorkspace: (value: boolean) => void; isLoadingBill: boolean | undefined} = usePayAndDowngrade(setIsDeleteModalOpen); + const isFocused = useIsFocused(); + const {lastOfflineAt, lastOnlineAt} = useNetworkWithOfflineStatus(); const [loadingSpinnerIconIndex, setLoadingSpinnerIconIndex] = useState(null); @@ -156,11 +160,6 @@ function WorkspacesListPage() { }; const hideDeleteWorkspaceAnnualSubscriptionErrorModal = () => { setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen(false); - - if (!policyIDToDelete) { - return; - } - clearDeleteWorkspaceError(policyIDToDelete); }; const confirmDeleteAndHideModal = () => { if (!policyIDToDelete || !policyNameToDelete) { @@ -178,12 +177,20 @@ function WorkspacesListPage() { }, []); useEffect(() => { - if (isDeleteWorkspaceAnnualSubscriptionErrorModalOpen || !Object.values(policies ?? {}).some(isDeleteWorkspaceAnnualSubscriptionError)) { + if (!isFocused || isDeleteWorkspaceAnnualSubscriptionErrorModalOpen) { return; } + const policyIDWithError = Object.values(policies ?? {}).find(isDeleteWorkspaceAnnualSubscriptionError)?.id; + if ( + !policyIDWithError || + wasPolicyLastModifiedWhileOffline(policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyIDWithError}`], isOffline, lastOfflineAt.current, lastOnlineAt.current, preferredLocale) + ) { + return; + } + clearDeleteWorkspaceError(policyIDWithError); setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen(true); - }, [isDeleteWorkspaceAnnualSubscriptionErrorModalOpen, policies]); + }, [isDeleteWorkspaceAnnualSubscriptionErrorModalOpen, policies, isFocused, isOffline, lastOfflineAt, lastOnlineAt, preferredLocale]); /** * Gets the menu item for each workspace From 61b55a427332b79fdf55b5deb7447cb583aa2d92 Mon Sep 17 00:00:00 2001 From: dominictb Date: Wed, 23 Jul 2025 16:11:21 +0700 Subject: [PATCH 06/23] show delete annual subscription workspace error in overview page --- src/Expensify.tsx | 3 ++ src/ONYXKEYS.ts | 4 +++ .../AnnualSubscriptionErrorConfirmModal.tsx | 28 +++++++++++++++++++ src/libs/actions/Policy/Policy.ts | 5 ++++ src/pages/workspace/WorkspaceInitialPage.tsx | 22 +++++++++++---- src/pages/workspace/WorkspaceOverviewPage.tsx | 8 ++---- src/pages/workspace/WorkspacesListPage.tsx | 26 ++++++++--------- 7 files changed, 70 insertions(+), 26 deletions(-) create mode 100644 src/components/AnnualSubscriptionErrorConfirmModal.tsx diff --git a/src/Expensify.tsx b/src/Expensify.tsx index 252578d01ad5..c1d1d6c1a02a 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -5,6 +5,7 @@ import type {NativeEventSubscription} from 'react-native'; import {AppState, Linking, Platform} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; +import AnnualSubscriptionErrorConfirmModal from './components/AnnualSubscriptionErrorConfirmModal'; import ConfirmModal from './components/ConfirmModal'; import DeeplinkWrapper from './components/DeeplinkWrapper'; import EmojiPicker from './components/EmojiPicker/EmojiPicker'; @@ -101,6 +102,7 @@ function Expensify() { const [isSidebarLoaded] = useOnyx(ONYXKEYS.IS_SIDEBAR_LOADED, {canBeMissing: true}); const [screenShareRequest] = useOnyx(ONYXKEYS.SCREEN_SHARE_REQUEST, {canBeMissing: true}); const [lastVisitedPath] = useOnyx(ONYXKEYS.LAST_VISITED_PATH, {canBeMissing: true}); + const [isDeleteWorkspaceAnnualSubscriptionErrorModalOpen] = useOnyx(ONYXKEYS.IS_DELETE_WORKSPACE_ANNUAL_SUBSCRIPTION_ERROR_MODAL_OPEN, {canBeMissing: true}); useDebugShortcut(); @@ -278,6 +280,7 @@ function Expensify() { {/* We include the modal for showing a new update at the top level so the option is always present. */} {updateAvailable && !updateRequired ? : null} + {isDeleteWorkspaceAnnualSubscriptionErrorModalOpen ? : null} {screenShareRequest ? ( { + setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen(false); + }; + + return ( + + ); +} + +AnnualSubscriptionErrorConfirmModal.displayName = 'AnnualSubscriptionErrorConfirmModal'; +export default AnnualSubscriptionErrorConfirmModal; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 55a6e042bf2b..360ce736b872 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -5527,6 +5527,10 @@ function clearPolicyTitleFieldError(policyID: string) { }); } +function setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen(value: boolean) { + Onyx.set(ONYXKEYS.IS_DELETE_WORKSPACE_ANNUAL_SUBSCRIPTION_ERROR_MODAL_OPEN, value); +} + export { leaveWorkspace, addBillingCardAndRequestPolicyOwnerChange, @@ -5637,4 +5641,5 @@ export { setPolicyAttendeeTrackingEnabled, updateInterestedFeatures, clearPolicyTitleFieldError, + setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen, }; diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 5c3ccca1ce52..d2a86b20705b 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -43,7 +43,7 @@ import useWaitForNavigation from '@hooks/useWaitForNavigation'; import {confirmReadyToOpenApp} from '@libs/actions/App'; import {isConnectionInProgress} from '@libs/actions/connections'; import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; -import {clearErrors, openPolicyInitialPage, removeWorkspace} from '@libs/actions/Policy/Policy'; +import {clearDeleteWorkspaceError, clearErrors, openPolicyInitialPage, removeWorkspace, setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen} from '@libs/actions/Policy/Policy'; import {checkIfFeedConnectionIsBroken, flatAllCardsList, getCompanyFeeds} from '@libs/CardUtils'; import {convertToDisplayString} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; @@ -390,16 +390,28 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const shouldShowPolicy = useMemo(() => checkIfShouldShowPolicy(policy, false, currentUserLogin), [policy, currentUserLogin]); const isPendingDelete = isPendingDeletePolicy(policy); const prevIsPendingDelete = isPendingDeletePolicy(prevPolicy); + const hasDeleteWorkspaceAnnualSubscriptionError = isDeleteWorkspaceAnnualSubscriptionError(policy); // We check isPendingDelete and prevIsPendingDelete to prevent the NotFound view from showing right after we delete the workspace // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundPage = !shouldShowPolicy && (!isPendingDelete || prevIsPendingDelete); useEffect(() => { - if (isEmptyObject(prevPolicy) || prevIsPendingDelete || !isPendingDelete) { + if (isEmptyObject(prevPolicy)) { return; } - goBackFromInvalidPolicy(); - }, [isPendingDelete, policy, prevIsPendingDelete, prevPolicy]); + if (isOffline && !prevIsPendingDelete && isPendingDelete) { + goBackFromInvalidPolicy(); + return; + } + if (prevIsPendingDelete && !isPendingDelete) { + if (hasDeleteWorkspaceAnnualSubscriptionError) { + clearDeleteWorkspaceError(policyID); + setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen(true); + return; + } + goBackFromInvalidPolicy(); + } + }, [isPendingDelete, prevIsPendingDelete, prevPolicy, isOffline, hasDeleteWorkspaceAnnualSubscriptionError, policyID]); // We are checking if the user can access the route. // If user can't access the route, we are dismissing any modals that are open when the NotFound view is shown @@ -462,7 +474,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac pendingAction={policy?.pendingAction} onClose={() => dismissError(policyID, policy?.pendingAction)} errors={policy?.errors} - shouldShowErrorMessages={!isDeleteWorkspaceAnnualSubscriptionError(policy)} + shouldShowErrorMessages={!hasDeleteWorkspaceAnnualSubscriptionError} errorRowStyles={[styles.ph5, styles.pv2]} shouldDisableStrikeThrough={false} shouldHideOnDelete={false} diff --git a/src/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index 10b4713c3297..e39aebc287a8 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -37,7 +37,7 @@ import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; -import {getUserFriendlyWorkspaceType, goBackFromInvalidPolicy, isPolicyAdmin as isPolicyAdminPolicyUtils, isPolicyOwner} from '@libs/PolicyUtils'; +import {getUserFriendlyWorkspaceType, isPolicyAdmin as isPolicyAdminPolicyUtils, isPolicyOwner} from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import StringUtils from '@libs/StringUtils'; import {shouldCalculateBillNewDot} from '@libs/SubscriptionUtils'; @@ -184,11 +184,7 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa deleteWorkspace(policy.id, policyName); setIsDeleteModalOpen(false); - - if (!shouldUseNarrowLayout) { - goBackFromInvalidPolicy(); - } - }, [policy?.id, policyName, shouldUseNarrowLayout]); + }, [policy?.id, policyName]); useEffect(() => { if (isLoadingBill) { diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index f94fddeeb6d9..385e74ba5699 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -36,7 +36,16 @@ import useSearchResults from '@hooks/useSearchResults'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {isConnectionInProgress} from '@libs/actions/connections'; -import {calculateBillNewDot, clearDeleteWorkspaceError, clearErrors, deleteWorkspace, leaveWorkspace, removeWorkspace, updateDefaultPolicy} from '@libs/actions/Policy/Policy'; +import { + calculateBillNewDot, + clearDeleteWorkspaceError, + clearErrors, + deleteWorkspace, + leaveWorkspace, + removeWorkspace, + setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen, + updateDefaultPolicy, +} from '@libs/actions/Policy/Policy'; import {callFunctionIfActionIsAllowed, isSupportAuthToken} from '@libs/actions/Session'; import {filterInactiveCards} from '@libs/CardUtils'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; @@ -116,6 +125,7 @@ function WorkspacesListPage() { const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {canBeMissing: true}); const [session] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: true}); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); + const [isDeleteWorkspaceAnnualSubscriptionErrorModalOpen] = useOnyx(ONYXKEYS.IS_DELETE_WORKSPACE_ANNUAL_SUBSCRIPTION_ERROR_MODAL_OPEN, {canBeMissing: true}); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); const shouldShowLoadingIndicator = isLoadingApp && !isOffline; const route = useRoute>(); @@ -124,7 +134,6 @@ function WorkspacesListPage() { const [policyIDToDelete, setPolicyIDToDelete] = useState(); // The workspace was deleted in this page const [policyNameToDelete, setPolicyNameToDelete] = useState(); - const [isDeleteWorkspaceAnnualSubscriptionErrorModalOpen, setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen] = useState(false); const {setIsDeletingPaidWorkspace, isLoadingBill}: {setIsDeletingPaidWorkspace: (value: boolean) => void; isLoadingBill: boolean | undefined} = usePayAndDowngrade(setIsDeleteModalOpen); const isFocused = useIsFocused(); const {lastOfflineAt, lastOnlineAt} = useNetworkWithOfflineStatus(); @@ -157,9 +166,6 @@ function WorkspacesListPage() { const hideSupportalModal = () => { setIsSupportalActionRestrictedModalOpen(false); }; - const hideDeleteWorkspaceAnnualSubscriptionErrorModal = () => { - setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen(false); - }; const confirmDeleteAndHideModal = () => { if (!policyIDToDelete || !policyNameToDelete) { return; @@ -524,16 +530,6 @@ function WorkspacesListPage() { isModalOpen={isSupportalActionRestrictedModalOpen} hideSupportalModal={hideSupportalModal} /> - {shouldDisplayLHB && } ); From a2d1fb94854151bc2407003142276ec3b6ec3f70 Mon Sep 17 00:00:00 2001 From: dominictb Date: Fri, 25 Jul 2025 04:30:06 +0700 Subject: [PATCH 07/23] show loader while pending delete api --- src/pages/workspace/WorkspaceInitialPage.tsx | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index d2a86b20705b..11d2eac14567 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -3,6 +3,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import {useFullScreenLoader} from '@components/FullScreenLoaderContext'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import HighlightableMenuItem from '@components/HighlightableMenuItem'; import { @@ -118,6 +119,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac .map((data) => data.domainID) .filter((domainID): domainID is number => !!domainID); const {login, accountID} = useCurrentUserPersonalDetails(); + const {setIsLoaderVisible} = useFullScreenLoader(); const hasSyncError = shouldShowSyncError(policy, isConnectionInProgress(connectionSyncProgress, policy)); const waitForNavigate = useWaitForNavigation(); const {singleExecution, isExecuting} = useSingleExecution(); @@ -391,19 +393,23 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const isPendingDelete = isPendingDeletePolicy(policy); const prevIsPendingDelete = isPendingDeletePolicy(prevPolicy); const hasDeleteWorkspaceAnnualSubscriptionError = isDeleteWorkspaceAnnualSubscriptionError(policy); - // We check isPendingDelete and prevIsPendingDelete to prevent the NotFound view from showing right after we delete the workspace // eslint-disable-next-line rulesdir/no-negated-variables - const shouldShowNotFoundPage = !shouldShowPolicy && (!isPendingDelete || prevIsPendingDelete); + const shouldShowNotFoundPage = !shouldShowPolicy && !isPendingDelete; useEffect(() => { if (isEmptyObject(prevPolicy)) { return; } - if (isOffline && !prevIsPendingDelete && isPendingDelete) { + if (!prevIsPendingDelete && isPendingDelete) { + if (!isOffline) { + setIsLoaderVisible(true); + return; + } goBackFromInvalidPolicy(); return; } if (prevIsPendingDelete && !isPendingDelete) { + setIsLoaderVisible(false); if (hasDeleteWorkspaceAnnualSubscriptionError) { clearDeleteWorkspaceError(policyID); setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen(true); @@ -411,7 +417,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac } goBackFromInvalidPolicy(); } - }, [isPendingDelete, prevIsPendingDelete, prevPolicy, isOffline, hasDeleteWorkspaceAnnualSubscriptionError, policyID]); + }, [isPendingDelete, prevIsPendingDelete, prevPolicy, isOffline, hasDeleteWorkspaceAnnualSubscriptionError, policyID, setIsLoaderVisible]); // We are checking if the user can access the route. // If user can't access the route, we are dismissing any modals that are open when the NotFound view is shown From 7f81bb08d3400a7cb323ece323f5529328434ca6 Mon Sep 17 00:00:00 2001 From: dominictb Date: Thu, 7 Aug 2025 09:32:01 +0700 Subject: [PATCH 08/23] do not always go back to workspaces list page --- src/pages/workspace/WorkspaceOverviewPage.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index e8dba74d3bbd..4c682627f4ad 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -37,7 +37,7 @@ import {getLatestErrorField} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; -import {getUserFriendlyWorkspaceType, goBackFromInvalidPolicy, isPolicyAdmin as isPolicyAdminPolicyUtils, isPolicyOwner} from '@libs/PolicyUtils'; +import {getUserFriendlyWorkspaceType, isPolicyAdmin as isPolicyAdminPolicyUtils, isPolicyOwner} from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import StringUtils from '@libs/StringUtils'; import {shouldCalculateBillNewDot} from '@libs/SubscriptionUtils'; @@ -186,7 +186,6 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa deleteWorkspace(policy.id, policyName, lastPaymentMethod); setIsDeleteModalOpen(false); - goBackFromInvalidPolicy(); }, [policy?.id, policyName, lastPaymentMethod]); useEffect(() => { From e58a0c333202ef11f1aa2fc61aa41a11ea5a7240 Mon Sep 17 00:00:00 2001 From: dominictb Date: Mon, 18 Aug 2025 22:22:46 +0700 Subject: [PATCH 09/23] show all BE workspace error in modal --- src/Expensify.tsx | 6 ++--- src/ONYXKEYS.ts | 6 ++--- ...x => DeleteWorkspaceErrorConfirmModal.tsx} | 16 +++++++----- src/libs/actions/Policy/Policy.ts | 7 +++--- src/pages/workspace/WorkspaceInitialPage.tsx | 16 +++++++----- src/pages/workspace/WorkspacesListPage.tsx | 25 +++++++++++-------- src/types/onyx/DeleteWorkspaceErrorModal.ts | 12 +++++++++ src/types/onyx/index.ts | 2 ++ 8 files changed, 58 insertions(+), 32 deletions(-) rename src/components/{AnnualSubscriptionErrorConfirmModal.tsx => DeleteWorkspaceErrorConfirmModal.tsx} (56%) create mode 100644 src/types/onyx/DeleteWorkspaceErrorModal.ts diff --git a/src/Expensify.tsx b/src/Expensify.tsx index f2da4c1bb816..06cc661bde9d 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -5,9 +5,9 @@ import type {NativeEventSubscription} from 'react-native'; import {AppState, Linking, Platform} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; -import AnnualSubscriptionErrorConfirmModal from './components/AnnualSubscriptionErrorConfirmModal'; import ConfirmModal from './components/ConfirmModal'; import DeeplinkWrapper from './components/DeeplinkWrapper'; +import DeleteWorkspaceErrorConfirmModal from './components/DeleteWorkspaceErrorConfirmModal'; import EmojiPicker from './components/EmojiPicker/EmojiPicker'; import GrowlNotification from './components/GrowlNotification'; import {InitialURLContext} from './components/InitialURLContextProvider'; @@ -107,7 +107,7 @@ function Expensify() { const [currentOnboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, {canBeMissing: true}); const [currentOnboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE, {canBeMissing: true}); const [onboardingInitialPath] = useOnyx(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH, {canBeMissing: true}); - const [isDeleteWorkspaceAnnualSubscriptionErrorModalOpen] = useOnyx(ONYXKEYS.IS_DELETE_WORKSPACE_ANNUAL_SUBSCRIPTION_ERROR_MODAL_OPEN, {canBeMissing: true}); + const [deleteWorkspaceErrorModal] = useOnyx(ONYXKEYS.DELETE_WORKSPACE_ERROR_MODAL, {canBeMissing: true}); useDebugShortcut(); usePriorityMode(); @@ -287,7 +287,7 @@ function Expensify() { {/* We include the modal for showing a new update at the top level so the option is always present. */} {updateAvailable && !updateRequired ? : null} - {isDeleteWorkspaceAnnualSubscriptionErrorModalOpen ? : null} + {deleteWorkspaceErrorModal?.isVisible ? : null} {screenShareRequest ? ( ; - [ONYXKEYS.IS_DELETE_WORKSPACE_ANNUAL_SUBSCRIPTION_ERROR_MODAL_OPEN]: boolean; + [ONYXKEYS.DELETE_WORKSPACE_ERROR_MODAL]: OnyxTypes.DeleteWorkspaceErrorModal; }; type OnyxDerivedValuesMapping = { diff --git a/src/components/AnnualSubscriptionErrorConfirmModal.tsx b/src/components/DeleteWorkspaceErrorConfirmModal.tsx similarity index 56% rename from src/components/AnnualSubscriptionErrorConfirmModal.tsx rename to src/components/DeleteWorkspaceErrorConfirmModal.tsx index 3a7f90e3dab5..464bb1f3a95d 100644 --- a/src/components/AnnualSubscriptionErrorConfirmModal.tsx +++ b/src/components/DeleteWorkspaceErrorConfirmModal.tsx @@ -1,13 +1,17 @@ import React from 'react'; import useLocalize from '@hooks/useLocalize'; -import {setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen} from '@libs/actions/Policy/Policy'; +import {setDeleteWorkspaceErrorModalData} from '@libs/actions/Policy/Policy'; import ConfirmModal from './ConfirmModal'; -function AnnualSubscriptionErrorConfirmModal() { +type DeleteWorkspaceErrorConfirmModalProps = { + errorMessage?: string; +}; + +function DeleteWorkspaceErrorConfirmModal({errorMessage}: DeleteWorkspaceErrorConfirmModalProps) { const {translate} = useLocalize(); const hideAnnualSubscriptionErrorModal = () => { - setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen(false); + setDeleteWorkspaceErrorModalData(null); }; return ( @@ -17,12 +21,12 @@ function AnnualSubscriptionErrorConfirmModal() { onConfirm={hideAnnualSubscriptionErrorModal} onCancel={hideAnnualSubscriptionErrorModal} confirmText={translate('common.buttonConfirm')} - prompt={translate('workspace.common.cannotDeleteWorkspaceAnnualSubscriptionError')} + prompt={errorMessage} shouldShowCancelButton={false} success={false} /> ); } -AnnualSubscriptionErrorConfirmModal.displayName = 'AnnualSubscriptionErrorConfirmModal'; -export default AnnualSubscriptionErrorConfirmModal; +DeleteWorkspaceErrorConfirmModal.displayName = 'DeleteWorkspaceErrorConfirmModal'; +export default DeleteWorkspaceErrorConfirmModal; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index ddd208df99f2..3a89eb94ff7b 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -91,6 +91,7 @@ import CONST from '@src/CONST'; import type {OnboardingAccounting} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type { + DeleteWorkspaceErrorModal, IntroSelected, InvitedEmailsToAccountIDs, LastPaymentMethod, @@ -5646,8 +5647,8 @@ function clearPolicyTitleFieldError(policyID: string) { }); } -function setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen(value: boolean) { - Onyx.set(ONYXKEYS.IS_DELETE_WORKSPACE_ANNUAL_SUBSCRIPTION_ERROR_MODAL_OPEN, value); +function setDeleteWorkspaceErrorModalData(value: DeleteWorkspaceErrorModal | null) { + Onyx.set(ONYXKEYS.DELETE_WORKSPACE_ERROR_MODAL, value); } export { @@ -5762,5 +5763,5 @@ export { setPolicyAttendeeTrackingEnabled, updateInterestedFeatures, clearPolicyTitleFieldError, - setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen, + setDeleteWorkspaceErrorModalData, }; diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 305c984781ac..7c48121b8b9f 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -46,9 +46,10 @@ import useWaitForNavigation from '@hooks/useWaitForNavigation'; import {confirmReadyToOpenApp} from '@libs/actions/App'; import {isConnectionInProgress} from '@libs/actions/connections'; import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; -import {clearDeleteWorkspaceError, clearErrors, openPolicyInitialPage, removeWorkspace, setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen} from '@libs/actions/Policy/Policy'; +import {clearDeleteWorkspaceError, clearErrors, openPolicyInitialPage, removeWorkspace, setDeleteWorkspaceErrorModalData} from '@libs/actions/Policy/Policy'; import {checkIfFeedConnectionIsBroken, flatAllCardsList, getCompanyFeeds} from '@libs/CardUtils'; import {convertToDisplayString} from '@libs/CurrencyUtils'; +import {getLatestErrorMessage} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import { @@ -410,7 +411,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const shouldShowPolicy = useMemo(() => checkIfShouldShowPolicy(policy, false, currentUserLogin), [policy, currentUserLogin]); const isPendingDelete = isPendingDeletePolicy(policy); const prevIsPendingDelete = isPendingDeletePolicy(prevPolicy); - const hasDeleteWorkspaceAnnualSubscriptionError = isDeleteWorkspaceAnnualSubscriptionError(policy); + const policyErrorMessage = getLatestErrorMessage(policy); // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundPage = !shouldShowPolicy && !isPendingDelete; @@ -428,14 +429,17 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac } if (prevIsPendingDelete && !isPendingDelete) { setIsLoaderVisible(false); - if (hasDeleteWorkspaceAnnualSubscriptionError) { + if (policyErrorMessage) { clearDeleteWorkspaceError(policyID); - setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen(true); + setDeleteWorkspaceErrorModalData({ + isVisible: true, + errorMessage: policyErrorMessage, + }); return; } goBackFromInvalidPolicy(); } - }, [isPendingDelete, prevIsPendingDelete, prevPolicy, isOffline, hasDeleteWorkspaceAnnualSubscriptionError, policyID, setIsLoaderVisible]); + }, [isPendingDelete, prevIsPendingDelete, prevPolicy, isOffline, policyErrorMessage, policyID, setIsLoaderVisible]); // We are checking if the user can access the route. // If user can't access the route, we are dismissing any modals that are open when the NotFound view is shown @@ -498,7 +502,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac pendingAction={policy?.pendingAction} onClose={() => dismissError(policyID, policy?.pendingAction)} errors={policy?.errors} - shouldShowErrorMessages={!hasDeleteWorkspaceAnnualSubscriptionError} + shouldShowErrorMessages={!isDeleteWorkspaceAnnualSubscriptionError(policy)} errorRowStyles={[styles.ph5, styles.pv2]} shouldDisableStrikeThrough={false} shouldHideOnDelete={false} diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 95a15d42f91b..9cb7db8e22dd 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -43,16 +43,17 @@ import { deleteWorkspace, leaveWorkspace, removeWorkspace, - setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen, + setDeleteWorkspaceErrorModalData, updateDefaultPolicy, } from '@libs/actions/Policy/Policy'; import {callFunctionIfActionIsAllowed, isSupportAuthToken} from '@libs/actions/Session'; import {filterInactiveCards} from '@libs/CardUtils'; +import {getLatestErrorMessage} from '@libs/ErrorUtils'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {AuthScreensParamList} from '@libs/Navigation/types'; -import {getPolicy, getPolicyBrickRoadIndicatorStatus, isDeleteWorkspaceAnnualSubscriptionError, isPolicyAdmin, shouldShowPolicy, wasPolicyLastModifiedWhileOffline} from '@libs/PolicyUtils'; +import {getPolicy, getPolicyBrickRoadIndicatorStatus, isPolicyAdmin, shouldShowPolicy, wasPolicyLastModifiedWhileOffline} from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import {shouldCalculateBillNewDot as shouldCalculateBillNewDotFn} from '@libs/SubscriptionUtils'; import type {AvatarSource} from '@libs/UserUtils'; @@ -125,7 +126,7 @@ function WorkspacesListPage() { const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {canBeMissing: true}); const [session] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: true}); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); - const [isDeleteWorkspaceAnnualSubscriptionErrorModalOpen] = useOnyx(ONYXKEYS.IS_DELETE_WORKSPACE_ANNUAL_SUBSCRIPTION_ERROR_MODAL_OPEN, {canBeMissing: true}); + const [deleteWorkspaceErrorModal] = useOnyx(ONYXKEYS.DELETE_WORKSPACE_ERROR_MODAL, {canBeMissing: true}); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); const [lastPaymentMethod] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD, {canBeMissing: true}); const shouldShowLoadingIndicator = isLoadingApp && !isOffline; @@ -185,20 +186,22 @@ function WorkspacesListPage() { }, []); useEffect(() => { - if (!isFocused || isDeleteWorkspaceAnnualSubscriptionErrorModalOpen) { + if (!isFocused || deleteWorkspaceErrorModal?.isVisible) { return; } - const policyIDWithError = Object.values(policies ?? {}).find(isDeleteWorkspaceAnnualSubscriptionError)?.id; - if ( - !policyIDWithError || - wasPolicyLastModifiedWhileOffline(policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyIDWithError}`], isOffline, lastOfflineAt.current, lastOnlineAt.current, preferredLocale) - ) { + const policyWithError = Object.values(policies ?? {}).find(getLatestErrorMessage); + const policyIDWithError = policyWithError?.id; + if (!policyWithError || wasPolicyLastModifiedWhileOffline(policyWithError, isOffline, lastOfflineAt.current, lastOnlineAt.current, preferredLocale)) { return; } + const policyErrorMessage = getLatestErrorMessage(policyWithError); clearDeleteWorkspaceError(policyIDWithError); - setIsDeleteWorkspaceAnnualSubscriptionErrorModalOpen(true); - }, [isDeleteWorkspaceAnnualSubscriptionErrorModalOpen, policies, isFocused, isOffline, lastOfflineAt, lastOnlineAt, preferredLocale]); + setDeleteWorkspaceErrorModalData({ + isVisible: true, + errorMessage: policyErrorMessage, + }); + }, [deleteWorkspaceErrorModal?.isVisible, policies, isFocused, isOffline, lastOfflineAt, lastOnlineAt, preferredLocale]); /** * Gets the menu item for each workspace diff --git a/src/types/onyx/DeleteWorkspaceErrorModal.ts b/src/types/onyx/DeleteWorkspaceErrorModal.ts new file mode 100644 index 000000000000..02aa639740f5 --- /dev/null +++ b/src/types/onyx/DeleteWorkspaceErrorModal.ts @@ -0,0 +1,12 @@ +/** + * If DeleteWorkspace endpoint fails, we show a modal with the error message that BE responds with. + */ +type DeleteWorkspaceErrorModal = { + /** Whether the modal should be visible */ + isVisible: boolean; + + /** The error message to display in the modal */ + errorMessage: string; +}; + +export default DeleteWorkspaceErrorModal; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index fbf89efc001c..857eb8d88cc3 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -23,6 +23,7 @@ import type Credentials from './Credentials'; import type Currency from './Currency'; import type {CurrencyList} from './Currency'; import type CustomStatusDraft from './CustomStatusDraft'; +import type DeleteWorkspaceErrorModal from './DeleteWorkspaceErrorModal'; import type {OutstandingReportsByPolicyIDDerivedValue, ReportAttributesDerivedValue, ReportTransactionsAndViolationsDerivedValue} from './DerivedValues'; import type DismissedProductTraining from './DismissedProductTraining'; import type DismissedReferralBanners from './DismissedReferralBanners'; @@ -271,4 +272,5 @@ export type { BillingReceiptDetails, ExportTemplate, HybridApp, + DeleteWorkspaceErrorModal, }; From 9dabcd8b2a121c49e04f80c7cca3f98b343bfc30 Mon Sep 17 00:00:00 2001 From: dominictb Date: Mon, 18 Aug 2025 22:44:25 +0700 Subject: [PATCH 10/23] remove delete_workspace_modal Onyx key and use local modal --- src/Expensify.tsx | 3 - src/ONYXKEYS.ts | 4 - .../DeleteWorkspaceErrorConfirmModal.tsx | 15 +- src/libs/PolicyUtils.ts | 15 +- src/libs/actions/Policy/Policy.ts | 6 - src/pages/workspace/WorkspaceInitialPage.tsx | 153 ++++++++++-------- src/pages/workspace/WorkspacesListPage.tsx | 89 +++++----- src/types/onyx/DeleteWorkspaceErrorModal.ts | 12 -- src/types/onyx/index.ts | 2 - 9 files changed, 150 insertions(+), 149 deletions(-) delete mode 100644 src/types/onyx/DeleteWorkspaceErrorModal.ts diff --git a/src/Expensify.tsx b/src/Expensify.tsx index 06cc661bde9d..de21defd5a2b 100644 --- a/src/Expensify.tsx +++ b/src/Expensify.tsx @@ -7,7 +7,6 @@ import type {OnyxEntry} from 'react-native-onyx'; import Onyx from 'react-native-onyx'; import ConfirmModal from './components/ConfirmModal'; import DeeplinkWrapper from './components/DeeplinkWrapper'; -import DeleteWorkspaceErrorConfirmModal from './components/DeleteWorkspaceErrorConfirmModal'; import EmojiPicker from './components/EmojiPicker/EmojiPicker'; import GrowlNotification from './components/GrowlNotification'; import {InitialURLContext} from './components/InitialURLContextProvider'; @@ -107,7 +106,6 @@ function Expensify() { const [currentOnboardingPurposeSelected] = useOnyx(ONYXKEYS.ONBOARDING_PURPOSE_SELECTED, {canBeMissing: true}); const [currentOnboardingCompanySize] = useOnyx(ONYXKEYS.ONBOARDING_COMPANY_SIZE, {canBeMissing: true}); const [onboardingInitialPath] = useOnyx(ONYXKEYS.ONBOARDING_LAST_VISITED_PATH, {canBeMissing: true}); - const [deleteWorkspaceErrorModal] = useOnyx(ONYXKEYS.DELETE_WORKSPACE_ERROR_MODAL, {canBeMissing: true}); useDebugShortcut(); usePriorityMode(); @@ -287,7 +285,6 @@ function Expensify() { {/* We include the modal for showing a new update at the top level so the option is always present. */} {updateAvailable && !updateRequired ? : null} - {deleteWorkspaceErrorModal?.isVisible ? : null} {screenShareRequest ? ( ; - [ONYXKEYS.DELETE_WORKSPACE_ERROR_MODAL]: OnyxTypes.DeleteWorkspaceErrorModal; }; type OnyxDerivedValuesMapping = { diff --git a/src/components/DeleteWorkspaceErrorConfirmModal.tsx b/src/components/DeleteWorkspaceErrorConfirmModal.tsx index 464bb1f3a95d..b31b1c84196b 100644 --- a/src/components/DeleteWorkspaceErrorConfirmModal.tsx +++ b/src/components/DeleteWorkspaceErrorConfirmModal.tsx @@ -1,25 +1,30 @@ -import React from 'react'; +import React, {useState} from 'react'; import useLocalize from '@hooks/useLocalize'; -import {setDeleteWorkspaceErrorModalData} from '@libs/actions/Policy/Policy'; import ConfirmModal from './ConfirmModal'; type DeleteWorkspaceErrorConfirmModalProps = { + /** The error message to display in the modal */ errorMessage?: string; + + /** Callback when the modal is completely hidden */ + onModalHide?: () => void; }; -function DeleteWorkspaceErrorConfirmModal({errorMessage}: DeleteWorkspaceErrorConfirmModalProps) { +function DeleteWorkspaceErrorConfirmModal({errorMessage, onModalHide}: DeleteWorkspaceErrorConfirmModalProps) { const {translate} = useLocalize(); + const [isVisible, setIsVisible] = useState(true); const hideAnnualSubscriptionErrorModal = () => { - setDeleteWorkspaceErrorModalData(null); + setIsVisible(false); }; return ( ; type WorkspaceDetails = { @@ -1527,7 +1538,7 @@ function wasPolicyLastModifiedWhileOffline( isOffline: boolean, lastOfflineAt: Date | undefined, lastOnlineAt: Date | undefined, - locale: Locale = CONST.LOCALES.DEFAULT, + locale: Locale | undefined, ): boolean { if (!lastOfflineAt || !lastOnlineAt || isEmptyObject(policy)) { return false; @@ -1700,4 +1711,4 @@ export { wasPolicyLastModifiedWhileOffline, }; -export type {MemberEmailsToAccountIDs}; +export type {DeleteWorkspaceErrorModal, MemberEmailsToAccountIDs}; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 3a89eb94ff7b..952e07afd469 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -91,7 +91,6 @@ import CONST from '@src/CONST'; import type {OnboardingAccounting} from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type { - DeleteWorkspaceErrorModal, IntroSelected, InvitedEmailsToAccountIDs, LastPaymentMethod, @@ -5647,10 +5646,6 @@ function clearPolicyTitleFieldError(policyID: string) { }); } -function setDeleteWorkspaceErrorModalData(value: DeleteWorkspaceErrorModal | null) { - Onyx.set(ONYXKEYS.DELETE_WORKSPACE_ERROR_MODAL, value); -} - export { leaveWorkspace, addBillingCardAndRequestPolicyOwnerChange, @@ -5763,5 +5758,4 @@ export { setPolicyAttendeeTrackingEnabled, updateInterestedFeatures, clearPolicyTitleFieldError, - setDeleteWorkspaceErrorModalData, }; diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 7c48121b8b9f..2c2588d3be5d 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -3,6 +3,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import DeleteWorkspaceErrorConfirmModal from '@components/DeleteWorkspaceErrorConfirmModal'; import {useFullScreenLoader} from '@components/FullScreenLoaderContext'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import HighlightableMenuItem from '@components/HighlightableMenuItem'; @@ -46,7 +47,7 @@ import useWaitForNavigation from '@hooks/useWaitForNavigation'; import {confirmReadyToOpenApp} from '@libs/actions/App'; import {isConnectionInProgress} from '@libs/actions/connections'; import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; -import {clearDeleteWorkspaceError, clearErrors, openPolicyInitialPage, removeWorkspace, setDeleteWorkspaceErrorModalData} from '@libs/actions/Policy/Policy'; +import {clearDeleteWorkspaceError, clearErrors, openPolicyInitialPage, removeWorkspace} from '@libs/actions/Policy/Policy'; import {checkIfFeedConnectionIsBroken, flatAllCardsList, getCompanyFeeds} from '@libs/CardUtils'; import {convertToDisplayString} from '@libs/CurrencyUtils'; import {getLatestErrorMessage} from '@libs/ErrorUtils'; @@ -65,6 +66,7 @@ import { shouldShowSyncError, shouldShowTaxRateError, } from '@libs/PolicyUtils'; +import type {DeleteWorkspaceErrorModal} from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar, getPolicyExpenseChat, getReportName, getReportOfflinePendingActionAndErrors} from '@libs/ReportUtils'; import type WORKSPACE_TO_RHP from '@navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP'; import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; @@ -157,6 +159,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac }), [policy, isUberForBusinessEnabled], ) as PolicyFeatureStates; + const [deleteWorkspaceErrorModal, setDeleteWorkspaceErrorModal] = useState(null); const fetchPolicyData = useCallback(() => { if (policyDraft?.id) { @@ -431,7 +434,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac setIsLoaderVisible(false); if (policyErrorMessage) { clearDeleteWorkspaceError(policyID); - setDeleteWorkspaceErrorModalData({ + setDeleteWorkspaceErrorModal({ isVisible: true, errorMessage: policyErrorMessage, }); @@ -476,79 +479,87 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const shouldShowNavigationTabBar = !shouldShowNotFoundPage; return ( - } - > - + } > - Navigation.goBack(route.params?.backTo ?? ROUTES.WORKSPACES_LIST.route)} - policyAvatar={policyAvatar} - shouldDisplayHelpButton={shouldUseNarrowLayout} - /> - - - dismissError(policyID, policy?.pendingAction)} - errors={policy?.errors} - shouldShowErrorMessages={!isDeleteWorkspaceAnnualSubscriptionError(policy)} - errorRowStyles={[styles.ph5, styles.pv2]} - shouldDisableStrikeThrough={false} - shouldHideOnDelete={false} - > - - {/* + + Navigation.goBack(route.params?.backTo ?? ROUTES.WORKSPACES_LIST.route)} + policyAvatar={policyAvatar} + shouldDisplayHelpButton={shouldUseNarrowLayout} + /> + + + dismissError(policyID, policy?.pendingAction)} + errors={policy?.errors} + shouldShowErrorMessages={!isDeleteWorkspaceAnnualSubscriptionError(policy)} + errorRowStyles={[styles.ph5, styles.pv2]} + shouldDisableStrikeThrough={false} + shouldHideOnDelete={false} + > + + {/* Ideally we should use MenuList component for MenuItems with singleExecution/Navigation actions. In this case where user can click on workspace avatar or menu items, we need to have a check for `isExecuting`. So, we are directly mapping menuItems. */} - {workspaceMenuItems.map((item) => ( - - ))} - - - {isPolicyExpenseChatEnabled && !!currentUserPolicyExpenseChatReportID && ( - - {translate('workspace.common.submitExpense')} - - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(currentUserPolicyExpenseChat?.reportID))} - shouldShowRightIcon - wrapperStyle={[styles.br2, styles.pl2, styles.pr0, styles.pv3, styles.mt1, styles.alignItemsCenter]} - iconReportID={currentUserPolicyExpenseChatReportID} - /> - - - )} - - {shouldShowNavigationTabBar && shouldDisplayLHB && } - - + {workspaceMenuItems.map((item) => ( + + ))} + + + {isPolicyExpenseChatEnabled && !!currentUserPolicyExpenseChatReportID && ( + + {translate('workspace.common.submitExpense')} + + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(currentUserPolicyExpenseChat?.reportID))} + shouldShowRightIcon + wrapperStyle={[styles.br2, styles.pl2, styles.pr0, styles.pv3, styles.mt1, styles.alignItemsCenter]} + iconReportID={currentUserPolicyExpenseChatReportID} + /> + + + )} + + {shouldShowNavigationTabBar && shouldDisplayLHB && } + + + {deleteWorkspaceErrorModal?.isVisible ? ( + setDeleteWorkspaceErrorModal(null)} + errorMessage={deleteWorkspaceErrorModal?.errorMessage} + /> + ) : null} + ); } diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 9cb7db8e22dd..d14f2be3d51c 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -4,6 +4,7 @@ import {FlatList, View} from 'react-native'; import type {ValueOf} from 'type-fest'; import Button from '@components/Button'; import ConfirmModal from '@components/ConfirmModal'; +import DeleteWorkspaceErrorConfirmModal from '@components/DeleteWorkspaceErrorConfirmModal'; import type {FeatureListItem} from '@components/FeatureList'; import FeatureList from '@components/FeatureList'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; @@ -36,16 +37,7 @@ import useSearchResults from '@hooks/useSearchResults'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {isConnectionInProgress} from '@libs/actions/connections'; -import { - calculateBillNewDot, - clearDeleteWorkspaceError, - clearErrors, - deleteWorkspace, - leaveWorkspace, - removeWorkspace, - setDeleteWorkspaceErrorModalData, - updateDefaultPolicy, -} from '@libs/actions/Policy/Policy'; +import {calculateBillNewDot, clearDeleteWorkspaceError, clearErrors, deleteWorkspace, leaveWorkspace, removeWorkspace, updateDefaultPolicy} from '@libs/actions/Policy/Policy'; import {callFunctionIfActionIsAllowed, isSupportAuthToken} from '@libs/actions/Session'; import {filterInactiveCards} from '@libs/CardUtils'; import {getLatestErrorMessage} from '@libs/ErrorUtils'; @@ -54,6 +46,7 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {AuthScreensParamList} from '@libs/Navigation/types'; import {getPolicy, getPolicyBrickRoadIndicatorStatus, isPolicyAdmin, shouldShowPolicy, wasPolicyLastModifiedWhileOffline} from '@libs/PolicyUtils'; +import type {DeleteWorkspaceErrorModal} from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import {shouldCalculateBillNewDot as shouldCalculateBillNewDotFn} from '@libs/SubscriptionUtils'; import type {AvatarSource} from '@libs/UserUtils'; @@ -126,7 +119,7 @@ function WorkspacesListPage() { const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {canBeMissing: true}); const [session] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: true}); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); - const [deleteWorkspaceErrorModal] = useOnyx(ONYXKEYS.DELETE_WORKSPACE_ERROR_MODAL, {canBeMissing: true}); + const [deleteWorkspaceErrorModal, setDeleteWorkspaceErrorModal] = useState(null); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); const [lastPaymentMethod] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD, {canBeMissing: true}); const shouldShowLoadingIndicator = isLoadingApp && !isOffline; @@ -197,7 +190,7 @@ function WorkspacesListPage() { } const policyErrorMessage = getLatestErrorMessage(policyWithError); clearDeleteWorkspaceError(policyIDWithError); - setDeleteWorkspaceErrorModalData({ + setDeleteWorkspaceErrorModal({ isVisible: true, errorMessage: policyErrorMessage, }); @@ -505,39 +498,47 @@ function WorkspacesListPage() { } return ( - } - > - - {!shouldUseNarrowLayout && {getHeaderButton()}} - {shouldUseNarrowLayout && {getHeaderButton()}} - + } + > + + {!shouldUseNarrowLayout && {getHeaderButton()}} + {shouldUseNarrowLayout && {getHeaderButton()}} + + + setIsDeleteModalOpen(false)} + prompt={hasCardFeedOrExpensifyCard ? translate('workspace.common.deleteWithCardsConfirmation') : translate('workspace.common.deleteConfirmation')} + confirmText={translate('common.delete')} + cancelText={translate('common.cancel')} + danger /> - - setIsDeleteModalOpen(false)} - prompt={hasCardFeedOrExpensifyCard ? translate('workspace.common.deleteWithCardsConfirmation') : translate('workspace.common.deleteConfirmation')} - confirmText={translate('common.delete')} - cancelText={translate('common.cancel')} - danger - /> - - {shouldDisplayLHB && } - + + {shouldDisplayLHB && } + + {deleteWorkspaceErrorModal?.isVisible ? ( + setDeleteWorkspaceErrorModal(null)} + errorMessage={deleteWorkspaceErrorModal?.errorMessage} + /> + ) : null} + ); } diff --git a/src/types/onyx/DeleteWorkspaceErrorModal.ts b/src/types/onyx/DeleteWorkspaceErrorModal.ts deleted file mode 100644 index 02aa639740f5..000000000000 --- a/src/types/onyx/DeleteWorkspaceErrorModal.ts +++ /dev/null @@ -1,12 +0,0 @@ -/** - * If DeleteWorkspace endpoint fails, we show a modal with the error message that BE responds with. - */ -type DeleteWorkspaceErrorModal = { - /** Whether the modal should be visible */ - isVisible: boolean; - - /** The error message to display in the modal */ - errorMessage: string; -}; - -export default DeleteWorkspaceErrorModal; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 857eb8d88cc3..fbf89efc001c 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -23,7 +23,6 @@ import type Credentials from './Credentials'; import type Currency from './Currency'; import type {CurrencyList} from './Currency'; import type CustomStatusDraft from './CustomStatusDraft'; -import type DeleteWorkspaceErrorModal from './DeleteWorkspaceErrorModal'; import type {OutstandingReportsByPolicyIDDerivedValue, ReportAttributesDerivedValue, ReportTransactionsAndViolationsDerivedValue} from './DerivedValues'; import type DismissedProductTraining from './DismissedProductTraining'; import type DismissedReferralBanners from './DismissedReferralBanners'; @@ -272,5 +271,4 @@ export type { BillingReceiptDetails, ExportTemplate, HybridApp, - DeleteWorkspaceErrorModal, }; From 526acd8fcd3da9af9532ac098001c74813d0d5e8 Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 19 Aug 2025 11:53:24 +0700 Subject: [PATCH 11/23] remove redundant copy --- Mobile-Expensify | 2 +- src/languages/de.ts | 1 - src/languages/en.ts | 1 - src/languages/es.ts | 1 - src/languages/fr.ts | 1 - src/languages/it.ts | 1 - src/languages/ja.ts | 1 - src/languages/nl.ts | 1 - src/languages/pl.ts | 1 - src/languages/pt-BR.ts | 1 - src/languages/zh-hans.ts | 1 - 11 files changed, 1 insertion(+), 11 deletions(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 6db359196b62..722f993cb8a4 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 6db359196b62d470925cfd8a70e41820a67e9179 +Subproject commit 722f993cb8a4e51743881f40e5bc2de64a94c2b8 diff --git a/src/languages/de.ts b/src/languages/de.ts index cffab65e7cab..752170ba678a 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -3532,7 +3532,6 @@ const translations = { viewTransactions: 'Transaktionen anzeigen', policyExpenseChatName: ({displayName}: PolicyExpenseChatNameParams) => `Ausgaben von ${displayName}`, deepDiveExpensifyCard: `Expensify Card-Transaktionen werden automatisch in ein mit unserer Integration erstelltes „Expensify Card Liability Account“ exportiert.`, - cannotDeleteWorkspaceAnnualSubscriptionError: 'Sie können den Arbeitsbereich erst am Ende der jährlichen Abonnementlaufzeit löschen.', }, receiptPartners: { uber: { diff --git a/src/languages/en.ts b/src/languages/en.ts index fe46ecc7775f..917877ab5191 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -3529,7 +3529,6 @@ const translations = { viewTransactions: 'View transactions', policyExpenseChatName: ({displayName}: PolicyExpenseChatNameParams) => `${displayName}'s expenses`, deepDiveExpensifyCard: `Expensify Card transactions will automatically export to an "Expensify Card Liability Account" created with our integration.`, - cannotDeleteWorkspaceAnnualSubscriptionError: "You can't delete the workspace until the end of the annual subscription term.", }, receiptPartners: { uber: { diff --git a/src/languages/es.ts b/src/languages/es.ts index aa0051d33d19..ea35a8ecb40d 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -3517,7 +3517,6 @@ const translations = { viewTransactions: 'Ver transacciones', policyExpenseChatName: ({displayName}: PolicyExpenseChatNameParams) => `${displayName}'s gastos`, deepDiveExpensifyCard: `Las transacciones de la Tarjeta Expensify se exportan automáticamente a una "Cuenta de Responsabilidad de la Tarjeta Expensify" creada con nuestra integración.`, - cannotDeleteWorkspaceAnnualSubscriptionError: 'No puedes eliminar el espacio de trabajo hasta el final del período de suscripción anual.', }, receiptPartners: { uber: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index cb501a799638..e7044633bb12 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -3540,7 +3540,6 @@ const translations = { viewTransactions: 'Voir les transactions', policyExpenseChatName: ({displayName}: PolicyExpenseChatNameParams) => `Les dépenses de ${displayName}`, deepDiveExpensifyCard: `Les transactions Expensify Card seront automatiquement exportées vers un « Expensify Card Liability Account » créé avec notre intégration.`, - cannotDeleteWorkspaceAnnualSubscriptionError: "Vous ne pouvez pas supprimer l'espace de travail avant la fin de la période d'abonnement annuel.", }, receiptPartners: { uber: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 0970bdbab0a6..659febb37c78 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -3545,7 +3545,6 @@ const translations = { viewTransactions: 'Visualizza transazioni', policyExpenseChatName: ({displayName}: PolicyExpenseChatNameParams) => `Spese di ${displayName}`, deepDiveExpensifyCard: `Le transazioni della carta Expensify verranno esportate automaticamente in un “Conto di responsabilità della carta Expensify” creato con la nostra integrazione.`, - cannotDeleteWorkspaceAnnualSubscriptionError: "Non puoi eliminare lo spazio di lavoro fino alla fine del termine dell'abbonamento annuale.", }, receiptPartners: { uber: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 968a75f82e14..eb6dc42bbb24 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -3545,7 +3545,6 @@ const translations = { viewTransactions: '取引を表示', policyExpenseChatName: ({displayName}: PolicyExpenseChatNameParams) => `${displayName}の経費`, deepDiveExpensifyCard: `Expensify Cardの取引は、弊社の統合で作成された 「Expensify Card Liability Account 」に自動的にエクスポートされます。`, - cannotDeleteWorkspaceAnnualSubscriptionError: '年次サブスクリプション期間が終了するまで、ワークスペースを削除することはできません。', }, receiptPartners: { uber: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index ac58bbbdf06d..d0f3d810d259 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -3552,7 +3552,6 @@ const translations = { viewTransactions: 'Transacties bekijken', policyExpenseChatName: ({displayName}: PolicyExpenseChatNameParams) => `Uitgaven van ${displayName}`, deepDiveExpensifyCard: `Expensify Card transacties worden automatisch geëxporteerd naar een “Expensify Card Liability Account” die is aangemaakt met onze integratie.`, - cannotDeleteWorkspaceAnnualSubscriptionError: 'U kunt de werkruimte niet verwijderen tot het einde van de jaarlijkse abonnementsperiode.', }, receiptPartners: { uber: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 1839d7283e24..74ced0c3316c 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -3545,7 +3545,6 @@ const translations = { viewTransactions: 'Wyświetl transakcje', policyExpenseChatName: ({displayName}: PolicyExpenseChatNameParams) => `Wydatki ${displayName}`, deepDiveExpensifyCard: `Transakcje kartą Expensify będą automatycznie eksportowane na „Konto odpowiedzialności karty Expensify” utworzone za pomocą naszej integracji.`, - cannotDeleteWorkspaceAnnualSubscriptionError: 'Nie możesz usunąć przestrzeni roboczej do końca rocznego okresu subskrypcji.', }, receiptPartners: { uber: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 8f782e5541e4..1c273852c533 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -3550,7 +3550,6 @@ const translations = { viewTransactions: 'Ver transações', policyExpenseChatName: ({displayName}: PolicyExpenseChatNameParams) => `Despesas de ${displayName}`, deepDiveExpensifyCard: `As transações do cartão Expensify serão exportadas automaticamente para uma “Conta de responsabilidade do cartão Expensify” criada com nossa integração.`, - cannotDeleteWorkspaceAnnualSubscriptionError: 'Você não pode excluir o espaço de trabalho até o final do período de assinatura anual.', }, receiptPartners: { uber: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 2c749847b524..80e852e878fc 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -3503,7 +3503,6 @@ const translations = { viewTransactions: '查看交易记录', policyExpenseChatName: ({displayName}: PolicyExpenseChatNameParams) => `${displayName}的费用`, deepDiveExpensifyCard: `Expensify 卡交易将自动导出到与我们集成创建的 “Expensify 卡责任账户”。`, - cannotDeleteWorkspaceAnnualSubscriptionError: '在年度订阅期结束之前,您无法删除工作区。', }, receiptPartners: { uber: { From 293475325894926a123f3f3bb43358744e356f56 Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 19 Aug 2025 11:55:06 +0700 Subject: [PATCH 12/23] Merge Mobile-Expensify --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 722f993cb8a4..6db359196b62 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 722f993cb8a4e51743881f40e5bc2de64a94c2b8 +Subproject commit 6db359196b62d470925cfd8a70e41820a67e9179 From 9c80e91f03eb4e86673678a0d07c98ee627171a2 Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 9 Sep 2025 07:04:35 +0700 Subject: [PATCH 13/23] fix lint --- Mobile-Expensify | 2 +- src/pages/workspace/WorkspacesListPage.tsx | 20 +++++++++----------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index d118a93b8330..6db359196b62 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit d118a93b8330ba33233dfb5d53a730d2114a97a5 +Subproject commit 6db359196b62d470925cfd8a70e41820a67e9179 diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index dff927b3adbb..592bd7ab1405 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -1,6 +1,6 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; -import {FlatList, View} from 'react-native'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; +import {FlatList, InteractionManager, View} from 'react-native'; import type {ValueOf} from 'type-fest'; import Button from '@components/Button'; import ConfirmModal from '@components/ConfirmModal'; @@ -131,7 +131,6 @@ function WorkspacesListPage() { // The workspace was deleted in this page const [policyNameToDelete, setPolicyNameToDelete] = useState(); const {setIsDeletingPaidWorkspace, isLoadingBill}: {setIsDeletingPaidWorkspace: (value: boolean) => void; isLoadingBill: boolean | undefined} = usePayAndDowngrade(setIsDeleteModalOpen); - const isFocused = useIsFocused(); const {lastOfflineAt, lastOnlineAt} = useNetworkWithOfflineStatus(); const [loadingSpinnerIconIndex, setLoadingSpinnerIconIndex] = useState(null); @@ -355,7 +354,6 @@ function WorkspacesListPage() { styles.ph5, styles.mb2, styles.mh5, - styles.ph5, duplicateWorkspace?.policyID, styles.hoveredComponentBG, styles.offlineFeedback.deleted, @@ -576,14 +574,14 @@ function WorkspacesListPage() { {!shouldUseNarrowLayout && {getHeaderButton()}} {shouldUseNarrowLayout && {getHeaderButton()}} { - flatlistRef.current?.scrollToOffset({ - offset: info.averageItemLength * info.index, - animated: true, - }); - }} + onScrollToIndexFailed={(info) => { + flatlistRef.current?.scrollToOffset({ + offset: info.averageItemLength * info.index, + animated: true, + }); + }} renderItem={getMenuItem} ListHeaderComponent={listHeaderComponent} keyboardShouldPersistTaps="handled" From 5a1900da7582d01e2cd224140713d6de249dc4e1 Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 9 Sep 2025 07:47:41 +0700 Subject: [PATCH 14/23] simplify modal logic to apply modal pattern to all delete workspace errors --- src/libs/PolicyUtils.ts | 30 +----------------- src/libs/actions/Policy/Policy.ts | 3 +- src/pages/workspace/WorkspaceInitialPage.tsx | 6 ++-- src/pages/workspace/WorkspacesListPage.tsx | 32 +++++++++++--------- 4 files changed, 24 insertions(+), 47 deletions(-) diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index c3539036185e..cd9d2552cf12 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -8,7 +8,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import INPUT_IDS from '@src/types/form/NetSuiteCustomFieldForm'; -import type {Locale, OnyxInputOrEntry, Policy, PolicyCategories, PolicyEmployeeList, PolicyTagLists, PolicyTags, Report, TaxRate} from '@src/types/onyx'; +import type {OnyxInputOrEntry, Policy, PolicyCategories, PolicyEmployeeList, PolicyTagLists, PolicyTags, Report, TaxRate} from '@src/types/onyx'; import type {ErrorFields, PendingAction, PendingFields} from '@src/types/onyx/OnyxCommon'; import type { ConnectionLastSync, @@ -36,7 +36,6 @@ import {hasSynchronizationErrorMessage, isAuthenticationError} from './actions/c import {shouldShowQBOReimbursableExportDestinationAccountError} from './actions/connections/QuickbooksOnline'; import {getCurrentUserAccountID, getCurrentUserEmail} from './actions/Report'; import {getCategoryApproverRule} from './CategoryUtils'; -import DateUtils from './DateUtils'; import {translateLocal} from './Localize'; import Navigation from './Navigation/Navigation'; import {isOffline as isOfflineNetworkStore} from './Network/NetworkStore'; @@ -1533,32 +1532,6 @@ function isMemberPolicyAdmin(policy: OnyxEntry, memberEmail: string | un return admins.some((admin) => admin.email === memberEmail); } -/** - * Checks if the policy was last modified while the user was offline. - */ -function wasPolicyLastModifiedWhileOffline( - policy: OnyxEntry, - isOffline: boolean, - lastOfflineAt: Date | undefined, - lastOnlineAt: Date | undefined, - locale: Locale | undefined, -): boolean { - if (!lastOfflineAt || !lastOnlineAt || isEmptyObject(policy)) { - return false; - } - - const policyLastModifiedAt = DateUtils.getLocalDateFromDatetime(locale, policy.lastModified); - if (policyLastModifiedAt <= lastOfflineAt) { - return false; - } - - if (isOffline || policyLastModifiedAt < lastOnlineAt) { - return true; - } - - return false; -} - export { canEditTaxRate, escapeTagName, @@ -1713,7 +1686,6 @@ export { isPolicyMemberWithoutPendingDelete, getPolicyEmployeeAccountIDs, isMemberPolicyAdmin, - wasPolicyLastModifiedWhileOffline, }; export type {DeleteWorkspaceErrorModal, MemberEmailsToAccountIDs}; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 0772fcd112d1..88dd13ae4736 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -369,7 +369,6 @@ function deleteWorkspace(policyID: string, policyName: string, lastAccessedWorks } const filteredPolicies = Object.values(allPolicies).filter((policy): policy is Policy => policy?.id !== policyID); - const currentTime = DateUtils.getDBTime(); const optimisticData: OnyxUpdate[] = [ { onyxMethod: Onyx.METHOD.MERGE, @@ -377,7 +376,6 @@ function deleteWorkspace(policyID: string, policyName: string, lastAccessedWorks value: { avatarURL: '', pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - lastModified: currentTime, errors: null, }, }, @@ -435,6 +433,7 @@ function deleteWorkspace(policyID: string, policyName: string, lastAccessedWorks (report) => ReportUtils.isPolicyRelatedReport(report, policyID) && (ReportUtils.isChatRoom(report) || ReportUtils.isPolicyExpenseChat(report) || ReportUtils.isTaskReport(report)), ); const finallyData: OnyxUpdate[] = []; + const currentTime = DateUtils.getDBTime(); reportsToArchive.forEach((report) => { const {reportID, ownerAccountID, oldPolicyName} = report ?? {}; const isInvoiceReceiverReport = report?.invoiceReceiver && 'policyID' in report.invoiceReceiver && report.invoiceReceiver.policyID === policyID; diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 2c2588d3be5d..35d2a9b0f711 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -433,7 +433,6 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac if (prevIsPendingDelete && !isPendingDelete) { setIsLoaderVisible(false); if (policyErrorMessage) { - clearDeleteWorkspaceError(policyID); setDeleteWorkspaceErrorModal({ isVisible: true, errorMessage: policyErrorMessage, @@ -555,7 +554,10 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac {deleteWorkspaceErrorModal?.isVisible ? ( setDeleteWorkspaceErrorModal(null)} + onModalHide={() => { + setDeleteWorkspaceErrorModal(null); + clearDeleteWorkspaceError(policyID); + }} errorMessage={deleteWorkspaceErrorModal?.errorMessage} /> ) : null} diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 592bd7ab1405..8904b6fad105 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -28,10 +28,10 @@ import useCardFeeds from '@hooks/useCardFeeds'; import useHandleBackButton from '@hooks/useHandleBackButton'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; -import useNetworkWithOfflineStatus from '@hooks/useNetworkWithOfflineStatus'; import useOnyx from '@hooks/useOnyx'; import usePayAndDowngrade from '@hooks/usePayAndDowngrade'; import usePermissions from '@hooks/usePermissions'; +import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchResults from '@hooks/useSearchResults'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -48,7 +48,7 @@ import usePreloadFullScreenNavigators from '@libs/Navigation/AppNavigator/usePre import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {AuthScreensParamList} from '@libs/Navigation/types'; -import {getDefaultApprover, getPolicy, getPolicyBrickRoadIndicatorStatus, isPolicyAdmin, shouldShowPolicy, wasPolicyLastModifiedWhileOffline} from '@libs/PolicyUtils'; +import {getDefaultApprover, getPolicy, getPolicyBrickRoadIndicatorStatus, isPendingDeletePolicy, isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils'; import type {DeleteWorkspaceErrorModal} from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButton'; @@ -104,7 +104,7 @@ function WorkspacesListPage() { const theme = useTheme(); const styles = useThemeStyles(); const StyleUtils = useStyleUtils(); - const {translate, localeCompare, preferredLocale} = useLocalize(); + const {translate, localeCompare} = useLocalize(); const {isOffline} = useNetwork(); const isFocused = useIsFocused(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); @@ -130,8 +130,8 @@ function WorkspacesListPage() { const [policyIDToDelete, setPolicyIDToDelete] = useState(); // The workspace was deleted in this page const [policyNameToDelete, setPolicyNameToDelete] = useState(); + const [wasPolicyDeletedWhileOffline, setWasPolicyDeletedWhileOffline] = useState(false); const {setIsDeletingPaidWorkspace, isLoadingBill}: {setIsDeletingPaidWorkspace: (value: boolean) => void; isLoadingBill: boolean | undefined} = usePayAndDowngrade(setIsDeleteModalOpen); - const {lastOfflineAt, lastOnlineAt} = useNetworkWithOfflineStatus(); const [loadingSpinnerIconIndex, setLoadingSpinnerIconIndex] = useState(null); @@ -152,11 +152,14 @@ function WorkspacesListPage() { // This will be fixed as part of https://github.com/Expensify/Expensify/issues/507850 // eslint-disable-next-line deprecation/deprecation const policyToDelete = getPolicy(policyIDToDelete); + const prevPolicyToDelete = usePrevious(policyToDelete); const hasCardFeedOrExpensifyCard = !isEmptyObject(cardFeeds) || !isEmptyObject(cardsList) || // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing ((policyToDelete?.areExpensifyCardsEnabled || policyToDelete?.areCompanyCardsEnabled) && policyToDelete?.workspaceAccountID); + const isPendingDelete = isPendingDeletePolicy(policyToDelete); + const prevIsPendingDelete = isPendingDeletePolicy(prevPolicyToDelete); const isSupportalAction = isSupportAuthToken(); @@ -171,6 +174,7 @@ function WorkspacesListPage() { deleteWorkspace(policyIDToDelete, policyNameToDelete, lastAccessedWorkspacePolicyID, lastPaymentMethod); setIsDeleteModalOpen(false); + setWasPolicyDeletedWhileOffline(isOffline); }; const shouldCalculateBillNewDot: boolean = shouldCalculateBillNewDotFn(); @@ -200,22 +204,16 @@ function WorkspacesListPage() { ); useEffect(() => { - if (!isFocused || deleteWorkspaceErrorModal?.isVisible) { + if (!isFocused || wasPolicyDeletedWhileOffline || !prevIsPendingDelete || isPendingDelete || deleteWorkspaceErrorModal?.isVisible) { return; } - const policyWithError = Object.values(policies ?? {}).find(getLatestErrorMessage); - const policyIDWithError = policyWithError?.id; - if (!policyWithError || wasPolicyLastModifiedWhileOffline(policyWithError, isOffline, lastOfflineAt.current, lastOnlineAt.current, preferredLocale)) { - return; - } - const policyErrorMessage = getLatestErrorMessage(policyWithError); - clearDeleteWorkspaceError(policyIDWithError); + const policyErrorMessage = getLatestErrorMessage(policyToDelete); setDeleteWorkspaceErrorModal({ isVisible: true, errorMessage: policyErrorMessage, }); - }, [deleteWorkspaceErrorModal?.isVisible, policies, isFocused, isOffline, lastOfflineAt, lastOnlineAt, preferredLocale]); + }, [deleteWorkspaceErrorModal?.isVisible, isFocused, policyIDToDelete, policyToDelete, wasPolicyDeletedWhileOffline, prevIsPendingDelete, isPendingDelete]); /** * Gets the menu item for each workspace @@ -308,6 +306,7 @@ function WorkspacesListPage() { {deleteWorkspaceErrorModal?.isVisible ? ( setDeleteWorkspaceErrorModal(null)} + onModalHide={() => { + setDeleteWorkspaceErrorModal(null); + setWasPolicyDeletedWhileOffline(false); + clearDeleteWorkspaceError(policyIDToDelete); + }} errorMessage={deleteWorkspaceErrorModal?.errorMessage} /> ) : null} From 508b84a689e4d9ec6ae5071b6ae4f8c3992e8e0c Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 9 Sep 2025 07:49:39 +0700 Subject: [PATCH 15/23] Merge Mobile-Expensify --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index 6db359196b62..d486dd7de04d 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit 6db359196b62d470925cfd8a70e41820a67e9179 +Subproject commit d486dd7de04de08ff9597e4cd34120929a9f64ba From 056f562444689e9bc1c21058a5c4ab5f50c61991 Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 9 Sep 2025 07:50:51 +0700 Subject: [PATCH 16/23] Merge Mobile-Expensify --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index d486dd7de04d..d118a93b8330 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit d486dd7de04de08ff9597e4cd34120929a9f64ba +Subproject commit d118a93b8330ba33233dfb5d53a730d2114a97a5 From a2f0eb87c030599e80a4da8254c7ce421c846036 Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 9 Sep 2025 07:53:58 +0700 Subject: [PATCH 17/23] Merge --- Mobile-Expensify | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Mobile-Expensify b/Mobile-Expensify index d118a93b8330..d486dd7de04d 160000 --- a/Mobile-Expensify +++ b/Mobile-Expensify @@ -1 +1 @@ -Subproject commit d118a93b8330ba33233dfb5d53a730d2114a97a5 +Subproject commit d486dd7de04de08ff9597e4cd34120929a9f64ba From 00847001436443b1db24be7fe7d5945c7ce9a5f2 Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 30 Sep 2025 18:04:34 +0700 Subject: [PATCH 18/23] show all workspace errors in modal pattern --- .../DeleteWorkspaceErrorConfirmModal.tsx | 37 ----- src/pages/workspace/WorkspaceInitialPage.tsx | 63 +++----- src/pages/workspace/WorkspacesListPage.tsx | 147 +++++++++--------- 3 files changed, 99 insertions(+), 148 deletions(-) delete mode 100644 src/components/DeleteWorkspaceErrorConfirmModal.tsx diff --git a/src/components/DeleteWorkspaceErrorConfirmModal.tsx b/src/components/DeleteWorkspaceErrorConfirmModal.tsx deleted file mode 100644 index b31b1c84196b..000000000000 --- a/src/components/DeleteWorkspaceErrorConfirmModal.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React, {useState} from 'react'; -import useLocalize from '@hooks/useLocalize'; -import ConfirmModal from './ConfirmModal'; - -type DeleteWorkspaceErrorConfirmModalProps = { - /** The error message to display in the modal */ - errorMessage?: string; - - /** Callback when the modal is completely hidden */ - onModalHide?: () => void; -}; - -function DeleteWorkspaceErrorConfirmModal({errorMessage, onModalHide}: DeleteWorkspaceErrorConfirmModalProps) { - const {translate} = useLocalize(); - const [isVisible, setIsVisible] = useState(true); - - const hideAnnualSubscriptionErrorModal = () => { - setIsVisible(false); - }; - - return ( - - ); -} - -DeleteWorkspaceErrorConfirmModal.displayName = 'DeleteWorkspaceErrorConfirmModal'; -export default DeleteWorkspaceErrorConfirmModal; diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 1640237ca00e..1342f0643b23 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -1,11 +1,10 @@ -import {findFocusedRoute, useFocusEffect, useNavigationState} from '@react-navigation/native'; +import {findFocusedRoute, useFocusEffect, useIsFocused, useNavigationState} from '@react-navigation/native'; import {emailSelector} from '@selectors/Session'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import DeleteWorkspaceErrorConfirmModal from '@components/DeleteWorkspaceErrorConfirmModal'; -import {useFullScreenLoader} from '@components/FullScreenLoaderContext'; +import ConfirmModal from '@components/ConfirmModal'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import HighlightableMenuItem from '@components/HighlightableMenuItem'; import { @@ -49,7 +48,7 @@ import useWaitForNavigation from '@hooks/useWaitForNavigation'; import {confirmReadyToOpenApp} from '@libs/actions/App'; import {isConnectionInProgress} from '@libs/actions/connections'; import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/actions/connections/QuickbooksOnline'; -import {clearDeleteWorkspaceError, clearErrors, openPolicyInitialPage, removeWorkspace} from '@libs/actions/Policy/Policy'; +import {clearErrors, openPolicyInitialPage, removeWorkspace} from '@libs/actions/Policy/Policy'; import {checkIfFeedConnectionIsBroken, flatAllCardsList, getCompanyFeeds} from '@libs/CardUtils'; import {convertToDisplayString} from '@libs/CurrencyUtils'; import {getLatestErrorMessage} from '@libs/ErrorUtils'; @@ -68,7 +67,6 @@ import { shouldShowSyncError, shouldShowTaxRateError, } from '@libs/PolicyUtils'; -import type {DeleteWorkspaceErrorModal} from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar, getPolicyExpenseChat, getReportName, getReportOfflinePendingActionAndErrors} from '@libs/ReportUtils'; import type WORKSPACE_TO_RHP from '@navigation/linkingConfig/RELATIONS/WORKSPACE_TO_RHP'; import type {WorkspaceSplitNavigatorParamList} from '@navigation/types'; @@ -125,7 +123,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac .map((data) => data.domainID) .filter((domainID): domainID is number => !!domainID); const {login, accountID} = useCurrentUserPersonalDetails(); - const {setIsLoaderVisible} = useFullScreenLoader(); + const isFocused = useIsFocused(); const hasSyncError = shouldShowSyncError(policy, isConnectionInProgress(connectionSyncProgress, policy)); const {shouldShowEnterCredentialsError} = useGetReceiptPartnersIntegrationData({policyID: policy?.id}); const waitForNavigate = useWaitForNavigation(); @@ -162,7 +160,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac }), [policy, isUberForBusinessEnabled], ) as PolicyFeatureStates; - const [deleteWorkspaceErrorModal, setDeleteWorkspaceErrorModal] = useState(null); + const [isPolicyErrorModalOpen, setIsPolicyErrorModalOpen] = useState(false); const fetchPolicyData = useCallback(() => { if (policyDraft?.id) { @@ -414,39 +412,18 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac confirmReadyToOpenApp(); }, []); - const prevPolicy = usePrevious(policy); - const shouldShowPolicy = useMemo(() => checkIfShouldShowPolicy(policy, false, currentUserLogin), [policy, currentUserLogin]); const isPendingDelete = isPendingDeletePolicy(policy); - const prevIsPendingDelete = isPendingDeletePolicy(prevPolicy); const policyErrorMessage = getLatestErrorMessage(policy); // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundPage = !shouldShowPolicy && !isPendingDelete; useEffect(() => { - if (isEmptyObject(prevPolicy)) { - return; - } - if (!prevIsPendingDelete && isPendingDelete) { - if (!isOffline) { - setIsLoaderVisible(true); - return; - } - goBackFromInvalidPolicy(); + if (!isFocused || isPolicyErrorModalOpen || !policyErrorMessage) { return; } - if (prevIsPendingDelete && !isPendingDelete) { - setIsLoaderVisible(false); - if (policyErrorMessage) { - setDeleteWorkspaceErrorModal({ - isVisible: true, - errorMessage: policyErrorMessage, - }); - return; - } - goBackFromInvalidPolicy(); - } - }, [isPendingDelete, prevIsPendingDelete, prevPolicy, isOffline, policyErrorMessage, policyID, setIsLoaderVisible]); + setIsPolicyErrorModalOpen(true); + }, [policyErrorMessage, isFocused, isPolicyErrorModalOpen]); // We are checking if the user can access the route. // If user can't access the route, we are dismissing any modals that are open when the NotFound view is shown @@ -480,6 +457,11 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac }; }, [policy]); + const hideWorkspaceErrorModal = () => { + setIsPolicyErrorModalOpen(false); + dismissError(policy?.id, policy?.pendingAction); + }; + const shouldShowNavigationTabBar = !shouldShowNotFoundPage; return ( @@ -557,15 +539,16 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac {shouldShowNavigationTabBar && shouldDisplayLHB && } - {deleteWorkspaceErrorModal?.isVisible ? ( - { - setDeleteWorkspaceErrorModal(null); - clearDeleteWorkspaceError(policyID); - }} - errorMessage={deleteWorkspaceErrorModal?.errorMessage} - /> - ) : null} + ); } diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 3e4b1ead4972..0818e9e035e2 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -2,10 +2,10 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import {Str} from 'expensify-common'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {FlatList, InteractionManager, View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import Button from '@components/Button'; import ConfirmModal from '@components/ConfirmModal'; -import DeleteWorkspaceErrorConfirmModal from '@components/DeleteWorkspaceErrorConfirmModal'; import EmptyStateComponent from '@components/EmptyStateComponent'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import * as Expensicons from '@components/Icon/Expensicons'; @@ -31,7 +31,6 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePayAndDowngrade from '@hooks/usePayAndDowngrade'; -import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchResults from '@hooks/useSearchResults'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -48,8 +47,7 @@ import usePreloadFullScreenNavigators from '@libs/Navigation/AppNavigator/usePre import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import type {AuthScreensParamList} from '@libs/Navigation/types'; -import {getDefaultApprover, getPolicy, getPolicyBrickRoadIndicatorStatus, isPendingDeletePolicy, isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils'; -import type {DeleteWorkspaceErrorModal} from '@libs/PolicyUtils'; +import {getDefaultApprover, getPolicy, getPolicyBrickRoadIndicatorStatus, isPolicyAdmin, shouldShowPolicy, shouldShowPolicyError} from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButton'; import {shouldCalculateBillNewDot as shouldCalculateBillNewDotFn} from '@libs/SubscriptionUtils'; @@ -61,7 +59,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {Policy as PolicyType} from '@src/types/onyx'; +import type {Policy, Policy as PolicyType} from '@src/types/onyx'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type {PolicyDetailsForNonMembers} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -113,7 +111,6 @@ function WorkspacesListPage() { const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {canBeMissing: true}); const [session] = useOnyx(ONYXKEYS.SESSION, {canBeMissing: true}); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID, {canBeMissing: true}); - const [deleteWorkspaceErrorModal, setDeleteWorkspaceErrorModal] = useState(null); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP, {canBeMissing: true}); const [lastPaymentMethod] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD, {canBeMissing: true}); const shouldShowLoadingIndicator = isLoadingApp && !isOffline; @@ -130,8 +127,10 @@ function WorkspacesListPage() { const [policyIDToDelete, setPolicyIDToDelete] = useState(); // The workspace was deleted in this page const [policyNameToDelete, setPolicyNameToDelete] = useState(); - const [wasPolicyDeletedWhileOffline, setWasPolicyDeletedWhileOffline] = useState(false); const {setIsDeletingPaidWorkspace, isLoadingBill}: {setIsDeletingPaidWorkspace: (value: boolean) => void; isLoadingBill: boolean | undefined} = usePayAndDowngrade(setIsDeleteModalOpen); + const [policyErrorMessage, setPolicyErrorMessage] = useState(''); + const [policyWithError, setPolicyWithError] = useState>(); + const [isPolicyErrorModalOpen, setIsPolicyErrorModalOpen] = useState(false); const [loadingSpinnerIconIndex, setLoadingSpinnerIconIndex] = useState(null); @@ -152,14 +151,11 @@ function WorkspacesListPage() { // This will be fixed as part of https://github.com/Expensify/Expensify/issues/507850 // eslint-disable-next-line deprecation/deprecation const policyToDelete = getPolicy(policyIDToDelete); - const prevPolicyToDelete = usePrevious(policyToDelete); const hasCardFeedOrExpensifyCard = !isEmptyObject(cardFeeds) || !isEmptyObject(cardsList) || // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing ((policyToDelete?.areExpensifyCardsEnabled || policyToDelete?.areCompanyCardsEnabled) && policyToDelete?.workspaceAccountID); - const isPendingDelete = isPendingDeletePolicy(policyToDelete); - const prevIsPendingDelete = isPendingDeletePolicy(prevPolicyToDelete); const isSupportalAction = isSupportAuthToken(); @@ -174,7 +170,15 @@ function WorkspacesListPage() { deleteWorkspace(policyIDToDelete, policyNameToDelete, lastAccessedWorkspacePolicyID, defaultCardFeeds, lastPaymentMethod); setIsDeleteModalOpen(false); - setWasPolicyDeletedWhileOffline(isOffline); + }; + + const hideWorkspaceErrorModal = () => { + setIsPolicyErrorModalOpen(false); + if (!policyWithError) { + return; + } + setPolicyErrorMessage(''); + dismissWorkspaceError(policyWithError.id, policyWithError.pendingAction); }; const shouldCalculateBillNewDot: boolean = shouldCalculateBillNewDotFn(); @@ -222,16 +226,20 @@ function WorkspacesListPage() { }, [session?.email, myDomainSecurityGroups, securityGroups]); useEffect(() => { - if (!isFocused || wasPolicyDeletedWhileOffline || !prevIsPendingDelete || isPendingDelete || deleteWorkspaceErrorModal?.isVisible) { + if (!isFocused || !!policyErrorMessage || isPolicyErrorModalOpen) { return; } - const policyErrorMessage = getLatestErrorMessage(policyToDelete); - setDeleteWorkspaceErrorModal({ - isVisible: true, - errorMessage: policyErrorMessage, - }); - }, [deleteWorkspaceErrorModal?.isVisible, isFocused, policyIDToDelete, policyToDelete, wasPolicyDeletedWhileOffline, prevIsPendingDelete, isPendingDelete]); + const workspaceWithError = Object.values(policies ?? {}).find(shouldShowPolicyError); + const workspaceErrorMessage = getLatestErrorMessage(workspaceWithError); + if (!workspaceErrorMessage) { + return; + } + + setIsPolicyErrorModalOpen(true); + setPolicyErrorMessage(workspaceErrorMessage); + setPolicyWithError(workspaceWithError); + }, [policyErrorMessage, isFocused, isPolicyErrorModalOpen, policies]); /** * Gets the menu item for each workspace @@ -324,7 +332,7 @@ function WorkspacesListPage() { - } - > - - {!shouldUseNarrowLayout && {getHeaderButton()}} - {shouldUseNarrowLayout && {getHeaderButton()}} - { - flatlistRef.current?.scrollToOffset({ - offset: info.averageItemLength * info.index, - animated: true, - }); - }} - renderItem={getMenuItem} - ListHeaderComponent={listHeaderComponent} - keyboardShouldPersistTaps="handled" - /> - - setIsDeleteModalOpen(false)} - prompt={hasCardFeedOrExpensifyCard ? translate('workspace.common.deleteWithCardsConfirmation') : translate('workspace.common.deleteConfirmation')} - confirmText={translate('common.delete')} - cancelText={translate('common.cancel')} - danger - /> - - {shouldDisplayLHB && } - - {deleteWorkspaceErrorModal?.isVisible ? ( - { - setDeleteWorkspaceErrorModal(null); - setWasPolicyDeletedWhileOffline(false); - clearDeleteWorkspaceError(policyIDToDelete); + } + > + + {!shouldUseNarrowLayout && {getHeaderButton()}} + {shouldUseNarrowLayout && {getHeaderButton()}} + { + flatlistRef.current?.scrollToOffset({ + offset: info.averageItemLength * info.index, + animated: true, + }); }} - errorMessage={deleteWorkspaceErrorModal?.errorMessage} + renderItem={getMenuItem} + ListHeaderComponent={listHeaderComponent} + keyboardShouldPersistTaps="handled" /> - ) : null} - + + setIsDeleteModalOpen(false)} + prompt={hasCardFeedOrExpensifyCard ? translate('workspace.common.deleteWithCardsConfirmation') : translate('workspace.common.deleteConfirmation')} + confirmText={translate('common.delete')} + cancelText={translate('common.cancel')} + danger + /> + + + {shouldDisplayLHB && } + ); } From dc8233481b607288255bf165435e488eb075c697 Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 30 Sep 2025 18:14:59 +0700 Subject: [PATCH 19/23] remove redundant changes --- src/CONST/index.ts | 1 - src/libs/PolicyUtils.ts | 18 +----------------- src/pages/workspace/WorkspaceInitialPage.tsx | 2 -- 3 files changed, 1 insertion(+), 20 deletions(-) diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 62e7a0316cff..b2d5549be5e0 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -1724,7 +1724,6 @@ const CONST = { ERROR_TITLE: { SOCKET: 'Issue connecting to database', DUPLICATE_RECORD: '400 Unique Constraints Violation', - CANNOT_DELETE_WORKSPACE_ANNUAL_SUBSCRIPTION: "You can't delete the workspace until the end of the annual subscription term.", }, NETWORK: { METHOD: { diff --git a/src/libs/PolicyUtils.ts b/src/libs/PolicyUtils.ts index 8177c172c769..598af1b8d380 100644 --- a/src/libs/PolicyUtils.ts +++ b/src/libs/PolicyUtils.ts @@ -43,17 +43,6 @@ import {getAccountIDsByLogins, getLoginsByAccountIDs, getPersonalDetailByEmail} import {getAllSortedTransactions, getCategory, getTag, getTagArrayFromName} from './TransactionUtils'; import {isPublicDomain} from './ValidationUtils'; -/** - * If DeleteWorkspace endpoint fails, we show a modal with the error message that BE responds with. - */ -type DeleteWorkspaceErrorModal = { - /** Whether the modal should be visible */ - isVisible: boolean; - - /** The error message to display in the modal */ - errorMessage: string; -}; - type MemberEmailsToAccountIDs = Record; type WorkspaceDetails = { @@ -163,10 +152,6 @@ function shouldShowPolicyError(policy: OnyxEntry): boolean { return Object.keys(policy?.errors ?? {}).length > 0 ? isPolicyAdmin(policy) : shouldShowPolicyErrorFields(policy); } -function isDeleteWorkspaceAnnualSubscriptionError(policy: OnyxEntry): boolean { - return Object.values(policy?.errors ?? {}).some((error) => error === CONST.ERROR_TITLE.CANNOT_DELETE_WORKSPACE_ANNUAL_SUBSCRIPTION); -} - /** * Checks if we have any errors stored within the policy custom units. */ @@ -1577,7 +1562,6 @@ export { hasPolicyCategoriesError, shouldShowPolicyError, shouldShowPolicyErrorFields, - isDeleteWorkspaceAnnualSubscriptionError, shouldShowTaxRateError, isControlOnAdvancedApprovalMode, isExpensifyTeam, @@ -1696,4 +1680,4 @@ export { isMemberPolicyAdmin, }; -export type {DeleteWorkspaceErrorModal, MemberEmailsToAccountIDs}; +export type {MemberEmailsToAccountIDs}; diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 1342f0643b23..bd3e8689e304 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -58,7 +58,6 @@ import { shouldShowPolicy as checkIfShouldShowPolicy, goBackFromInvalidPolicy, hasPolicyCategoriesError, - isDeleteWorkspaceAnnualSubscriptionError, isPaidGroupPolicy, isPendingDeletePolicy, isPolicyAdmin, @@ -492,7 +491,6 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac pendingAction={policy?.pendingAction} onClose={() => dismissError(policyID, policy?.pendingAction)} errors={policy?.errors} - shouldShowErrorMessages={!isDeleteWorkspaceAnnualSubscriptionError(policy)} errorRowStyles={[styles.ph5, styles.pv2]} shouldDisableStrikeThrough={false} shouldHideOnDelete={false} From ad0f933b08c60644b199ede9cb136396b95efcb2 Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 30 Sep 2025 18:48:12 +0700 Subject: [PATCH 20/23] fix bugs --- src/pages/workspace/WorkspaceInitialPage.tsx | 38 ++++++++++++++++---- src/pages/workspace/WorkspacesListPage.tsx | 16 +++------ 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index bd3e8689e304..0c22efe30391 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -1,10 +1,11 @@ -import {findFocusedRoute, useFocusEffect, useIsFocused, useNavigationState} from '@react-navigation/native'; +import {findFocusedRoute, useFocusEffect, useNavigationState} from '@react-navigation/native'; import {emailSelector} from '@selectors/Session'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; import ConfirmModal from '@components/ConfirmModal'; +import {useFullScreenLoader} from '@components/FullScreenLoaderContext'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import HighlightableMenuItem from '@components/HighlightableMenuItem'; import { @@ -122,7 +123,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac .map((data) => data.domainID) .filter((domainID): domainID is number => !!domainID); const {login, accountID} = useCurrentUserPersonalDetails(); - const isFocused = useIsFocused(); + const {setIsLoaderVisible} = useFullScreenLoader(); const hasSyncError = shouldShowSyncError(policy, isConnectionInProgress(connectionSyncProgress, policy)); const {shouldShowEnterCredentialsError} = useGetReceiptPartnersIntegrationData({policyID: policy?.id}); const waitForNavigate = useWaitForNavigation(); @@ -160,6 +161,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac [policy, isUberForBusinessEnabled], ) as PolicyFeatureStates; const [isPolicyErrorModalOpen, setIsPolicyErrorModalOpen] = useState(false); + const [policyErrorMessage, setPolicyErrorMessage] = useState(''); const fetchPolicyData = useCallback(() => { if (policyDraft?.id) { @@ -411,18 +413,42 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac confirmReadyToOpenApp(); }, []); + const prevPolicy = usePrevious(policy); + const shouldShowPolicy = useMemo(() => checkIfShouldShowPolicy(policy, false, currentUserLogin), [policy, currentUserLogin]); const isPendingDelete = isPendingDeletePolicy(policy); - const policyErrorMessage = getLatestErrorMessage(policy); + const prevIsPendingDelete = isPendingDeletePolicy(prevPolicy); // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundPage = !shouldShowPolicy && !isPendingDelete; useEffect(() => { - if (!isFocused || isPolicyErrorModalOpen || !policyErrorMessage) { + if (isEmptyObject(prevPolicy)) { + return; + } + // Show fullscreen loading while waiting for BE deletion to complete + // Go back to workspaces list page if offline + if (!prevIsPendingDelete && isPendingDelete) { + if (!isOffline) { + setIsLoaderVisible(true); + return; + } + goBackFromInvalidPolicy(); return; } + if (prevIsPendingDelete && !isPendingDelete) { + setIsLoaderVisible(false); + } + }, [isPendingDelete, policy, prevIsPendingDelete, prevPolicy, isOffline, setIsLoaderVisible]); + + const policyLatestErrorMessage = getLatestErrorMessage(policy); + useEffect(() => { + if (isPolicyErrorModalOpen || !policyLatestErrorMessage) { + return; + } + dismissError(policy?.id, policy?.pendingAction); + setPolicyErrorMessage(policyLatestErrorMessage); setIsPolicyErrorModalOpen(true); - }, [policyErrorMessage, isFocused, isPolicyErrorModalOpen]); + }, [policyLatestErrorMessage, isPolicyErrorModalOpen, policy?.id, policy?.pendingAction]); // We are checking if the user can access the route. // If user can't access the route, we are dismissing any modals that are open when the NotFound view is shown @@ -458,7 +484,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const hideWorkspaceErrorModal = () => { setIsPolicyErrorModalOpen(false); - dismissError(policy?.id, policy?.pendingAction); + setPolicyErrorMessage(''); }; const shouldShowNavigationTabBar = !shouldShowNotFoundPage; diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 0818e9e035e2..153ef7a8a2f3 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -2,7 +2,6 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import {Str} from 'expensify-common'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {FlatList, InteractionManager, View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; import type {ValueOf} from 'type-fest'; import Button from '@components/Button'; import ConfirmModal from '@components/ConfirmModal'; @@ -59,7 +58,7 @@ import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {Policy, Policy as PolicyType} from '@src/types/onyx'; +import type {Policy as PolicyType} from '@src/types/onyx'; import type * as OnyxCommon from '@src/types/onyx/OnyxCommon'; import type {PolicyDetailsForNonMembers} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -129,7 +128,6 @@ function WorkspacesListPage() { const [policyNameToDelete, setPolicyNameToDelete] = useState(); const {setIsDeletingPaidWorkspace, isLoadingBill}: {setIsDeletingPaidWorkspace: (value: boolean) => void; isLoadingBill: boolean | undefined} = usePayAndDowngrade(setIsDeleteModalOpen); const [policyErrorMessage, setPolicyErrorMessage] = useState(''); - const [policyWithError, setPolicyWithError] = useState>(); const [isPolicyErrorModalOpen, setIsPolicyErrorModalOpen] = useState(false); const [loadingSpinnerIconIndex, setLoadingSpinnerIconIndex] = useState(null); @@ -174,11 +172,7 @@ function WorkspacesListPage() { const hideWorkspaceErrorModal = () => { setIsPolicyErrorModalOpen(false); - if (!policyWithError) { - return; - } setPolicyErrorMessage(''); - dismissWorkspaceError(policyWithError.id, policyWithError.pendingAction); }; const shouldCalculateBillNewDot: boolean = shouldCalculateBillNewDotFn(); @@ -229,16 +223,14 @@ function WorkspacesListPage() { if (!isFocused || !!policyErrorMessage || isPolicyErrorModalOpen) { return; } - const workspaceWithError = Object.values(policies ?? {}).find(shouldShowPolicyError); const workspaceErrorMessage = getLatestErrorMessage(workspaceWithError); - if (!workspaceErrorMessage) { + if (!workspaceWithError || !workspaceErrorMessage) { return; } - - setIsPolicyErrorModalOpen(true); setPolicyErrorMessage(workspaceErrorMessage); - setPolicyWithError(workspaceWithError); + dismissWorkspaceError(workspaceWithError.id, workspaceWithError.pendingAction); + setIsPolicyErrorModalOpen(true); }, [policyErrorMessage, isFocused, isPolicyErrorModalOpen, policies]); /** From 92ecf21bf10973174b8d39cfc3ec4a783504b0af Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 14 Oct 2025 01:34:34 +0700 Subject: [PATCH 21/23] feat: show delete workspace errors in modal --- src/pages/workspace/WorkspaceInitialPage.tsx | 209 +++++++----------- src/pages/workspace/WorkspaceOverviewPage.tsx | 47 +++- src/pages/workspace/WorkspacesListPage.tsx | 52 +++-- 3 files changed, 153 insertions(+), 155 deletions(-) diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 5f0f792b53e4..9044193d7fc5 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -1,11 +1,9 @@ -import {findFocusedRoute, useFocusEffect, useNavigationState} from '@react-navigation/native'; +import {findFocusedRoute, useFocusEffect, useIsFocused, useNavigationState} from '@react-navigation/native'; import {emailSelector} from '@selectors/Session'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {ValueOf} from 'type-fest'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; -import ConfirmModal from '@components/ConfirmModal'; -import {useFullScreenLoader} from '@components/FullScreenLoaderContext'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import HighlightableMenuItem from '@components/HighlightableMenuItem'; import MenuItem from '@components/MenuItem'; @@ -34,7 +32,6 @@ import {shouldShowQBOReimbursableExportDestinationAccountError} from '@libs/acti import {clearErrors, openPolicyInitialPage, removeWorkspace} from '@libs/actions/Policy/Policy'; import {checkIfFeedConnectionIsBroken, flatAllCardsList, getCompanyFeeds} from '@libs/CardUtils'; import {convertToDisplayString} from '@libs/CurrencyUtils'; -import {getLatestErrorMessage} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import { @@ -117,6 +114,7 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const policy = policyDraft?.id ? policyDraft : policyProp; const workspaceAccountID = policy?.workspaceAccountID ?? CONST.DEFAULT_NUMBER_ID; const hasPolicyCreationError = policy?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD && !isEmptyObject(policy.errors); + const isFocused = useIsFocused(); const [cardFeeds] = useCardFeeds(policy?.id); const [allFeedsCards] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}`, {canBeMissing: true}); const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policy?.id}`, {canBeMissing: true}); @@ -126,7 +124,6 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac .map((data) => data.domainID) .filter((domainID): domainID is number => !!domainID); const {login, accountID} = useCurrentUserPersonalDetails(); - const {setIsLoaderVisible} = useFullScreenLoader(); const hasSyncError = shouldShowSyncError(policy, isConnectionInProgress(connectionSyncProgress, policy)); const {shouldShowEnterCredentialsError} = useGetReceiptPartnersIntegrationData(policy?.id); const waitForNavigate = useWaitForNavigation(); @@ -163,8 +160,6 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac }), [policy, isUberForBusinessEnabled], ) as PolicyFeatureStates; - const [isPolicyErrorModalOpen, setIsPolicyErrorModalOpen] = useState(false); - const [policyErrorMessage, setPolicyErrorMessage] = useState(''); const fetchPolicyData = useCallback(() => { if (policyDraft?.id) { @@ -419,40 +414,19 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const prevPolicy = usePrevious(policy); - const shouldShowPolicy = useMemo(() => checkIfShouldShowPolicy(policy, false, currentUserLogin), [policy, currentUserLogin]); + const shouldShowPolicy = useMemo(() => checkIfShouldShowPolicy(policy, true, currentUserLogin), [policy, currentUserLogin]); const isPendingDelete = isPendingDeletePolicy(policy); const prevIsPendingDelete = isPendingDeletePolicy(prevPolicy); + // We check isPendingDelete and prevIsPendingDelete to prevent the NotFound view from showing right after we delete the workspace // eslint-disable-next-line rulesdir/no-negated-variables - const shouldShowNotFoundPage = !shouldShowPolicy && !isPendingDelete; + const shouldShowNotFoundPage = !shouldShowPolicy && (!isPendingDelete || prevIsPendingDelete); useEffect(() => { - if (isEmptyObject(prevPolicy)) { + if (!isFocused || !prevIsPendingDelete || isPendingDelete) { return; } - // Show fullscreen loading while waiting for BE deletion to complete - // Go back to workspaces list page if offline - if (!prevIsPendingDelete && isPendingDelete) { - if (!isOffline) { - setIsLoaderVisible(true); - return; - } - goBackFromInvalidPolicy(); - return; - } - if (prevIsPendingDelete && !isPendingDelete) { - setIsLoaderVisible(false); - } - }, [isPendingDelete, policy, prevIsPendingDelete, prevPolicy, isOffline, setIsLoaderVisible]); - - const policyLatestErrorMessage = getLatestErrorMessage(policy); - useEffect(() => { - if (isPolicyErrorModalOpen || !policyLatestErrorMessage) { - return; - } - dismissError(policy?.id, policy?.pendingAction); - setPolicyErrorMessage(policyLatestErrorMessage); - setIsPolicyErrorModalOpen(true); - }, [policyLatestErrorMessage, isPolicyErrorModalOpen, policy?.id, policy?.pendingAction]); + goBackFromInvalidPolicy(); + }, [isFocused, isPendingDelete, prevIsPendingDelete]); // We are checking if the user can access the route. // If user can't access the route, we are dismissing any modals that are open when the NotFound view is shown @@ -486,102 +460,85 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac }; }, [expensifyIcons.ExpensifyAppIcon, policy]); - const hideWorkspaceErrorModal = () => { - setIsPolicyErrorModalOpen(false); - setPolicyErrorMessage(''); - }; - const shouldShowNavigationTabBar = !shouldShowNotFoundPage; return ( - <> - } + } + > + - - Navigation.goBack(route.params?.backTo ?? ROUTES.WORKSPACES_LIST.route)} - policyAvatar={policyAvatar} - shouldDisplayHelpButton={shouldUseNarrowLayout} - /> - - - dismissError(policyID, policy?.pendingAction)} - errors={policy?.errors} - errorRowStyles={[styles.ph5, styles.pv2]} - shouldDisableStrikeThrough={false} - shouldHideOnDelete={false} - shouldShowErrorMessages={false} - > - - {/* - Ideally we should use MenuList component for MenuItems with singleExecution/Navigation actions. - In this case where user can click on workspace avatar or menu items, we need to have a check for `isExecuting`. So, we are directly mapping menuItems. - */} - {workspaceMenuItems.map((item) => ( - - ))} - - - {isPolicyExpenseChatEnabled && !!currentUserPolicyExpenseChatReportID && ( - - {translate('workspace.common.submitExpense')} - - Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(currentUserPolicyExpenseChat?.reportID))} - shouldShowRightIcon - wrapperStyle={[styles.br2, styles.pl2, styles.pr0, styles.pv3, styles.mt1, styles.alignItemsCenter]} - iconReportID={currentUserPolicyExpenseChatReportID} - /> - - - )} - - {shouldShowNavigationTabBar && shouldDisplayLHB && } - - - - + Navigation.goBack(route.params?.backTo ?? ROUTES.WORKSPACES_LIST.route)} + policyAvatar={policyAvatar} + shouldDisplayHelpButton={shouldUseNarrowLayout} + /> + + + dismissError(policyID, policy?.pendingAction)} + errors={policy?.errors} + errorRowStyles={[styles.ph5, styles.pv2]} + shouldDisableStrikeThrough={false} + shouldHideOnDelete={false} + shouldShowErrorMessages={false} + > + + {/* + Ideally we should use MenuList component for MenuItems with singleExecution/Navigation actions. + In this case where user can click on workspace avatar or menu items, we need to have a check for `isExecuting`. So, we are directly mapping menuItems. + */} + {workspaceMenuItems.map((item) => ( + + ))} + + + {isPolicyExpenseChatEnabled && !!currentUserPolicyExpenseChatReportID && ( + + {translate('workspace.common.submitExpense')} + + Navigation.navigate(ROUTES.REPORT_WITH_ID.getRoute(currentUserPolicyExpenseChat?.reportID))} + shouldShowRightIcon + wrapperStyle={[styles.br2, styles.pl2, styles.pr0, styles.pv3, styles.mt1, styles.alignItemsCenter]} + iconReportID={currentUserPolicyExpenseChatReportID} + /> + + + )} + + {shouldShowNavigationTabBar && shouldDisplayLHB && } + + ); } diff --git a/src/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index 9fdf21222d4e..b28506dcda1e 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -1,4 +1,4 @@ -import {useFocusEffect} from '@react-navigation/native'; +import {useFocusEffect, useIsFocused} from '@react-navigation/native'; import {accountIDSelector} from '@selectors/Session'; import React, {useCallback, useContext, useEffect, useRef, useState} from 'react'; import type {ImageStyle, StyleProp} from 'react-native'; @@ -22,6 +22,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePayAndDowngrade from '@hooks/usePayAndDowngrade'; import usePermissions from '@hooks/usePermissions'; +import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeIllustrations from '@hooks/useThemeIllustrations'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -30,6 +31,7 @@ import {clearInviteDraft, clearWorkspaceOwnerChangeFlow, requestWorkspaceOwnerCh import { calculateBillNewDot, clearAvatarErrors, + clearDeleteWorkspaceError, clearPolicyErrorField, deleteWorkspace, deleteWorkspaceAvatar, @@ -38,11 +40,11 @@ import { updateWorkspaceAvatar, } from '@libs/actions/Policy/Policy'; import {filterInactiveCards} from '@libs/CardUtils'; -import {getLatestErrorField} from '@libs/ErrorUtils'; +import {getLatestErrorField, getLatestErrorMessage} from '@libs/ErrorUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; -import {getUserFriendlyWorkspaceType, isPolicyAdmin as isPolicyAdminPolicyUtils, isPolicyOwner} from '@libs/PolicyUtils'; +import {getUserFriendlyWorkspaceType, goBackFromInvalidPolicy, isPendingDeletePolicy, isPolicyAdmin as isPolicyAdminPolicyUtils, isPolicyOwner} from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButton'; import StringUtils from '@libs/StringUtils'; @@ -160,6 +162,12 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const [lastPaymentMethod] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD, {canBeMissing: true}); const {isBetaEnabled} = usePermissions(); + const isFocused = useIsFocused(); + const isPendingDelete = isPendingDeletePolicy(policy); + const prevIsPendingDelete = usePrevious(isPendingDelete); + const [isDeleteWorkspaceErrorModalOpen, setIsDeleteWorkspaceErrorModalOpen] = useState(false); + const policyLastErrorMessage = getLatestErrorMessage(policy); + const fetchPolicyData = useCallback(() => { if (policyDraft?.id) { return; @@ -200,15 +208,19 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const dropdownMenuRef = useRef<{setIsMenuVisible: (visible: boolean) => void} | null>(null); - const confirmDeleteAndHideModal = useCallback(() => { + const confirmDelete = useCallback(() => { if (!policy?.id || !policyName) { return; } deleteWorkspace(policy.id, policyName, lastAccessedWorkspacePolicyID, defaultCardFeeds, reportsToArchive, transactionViolations, reimbursementAccountError, lastPaymentMethod); - setIsDeleteModalOpen(false); }, [policy?.id, policyName, lastAccessedWorkspacePolicyID, defaultCardFeeds, reportsToArchive, transactionViolations, reimbursementAccountError, lastPaymentMethod]); + const hideDeleteWorkspaceErrorModal = () => { + setIsDeleteWorkspaceErrorModalOpen(false); + clearDeleteWorkspaceError(policy?.id); + }; + useEffect(() => { if (isLoadingBill) { return; @@ -216,6 +228,18 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa dropdownMenuRef.current?.setIsMenuVisible(false); }, [isLoadingBill]); + useEffect(() => { + if (!isFocused || !prevIsPendingDelete || isPendingDelete) { + return; + } + setIsDeleteModalOpen(false); + if (!policyLastErrorMessage) { + goBackFromInvalidPolicy(); + return; + } + setIsDeleteWorkspaceErrorModalOpen(true); + }, [isFocused, isPendingDelete, prevIsPendingDelete, policyLastErrorMessage]); + const onDeleteWorkspace = useCallback(() => { if (shouldCalculateBillNewDot()) { setIsDeletingPaidWorkspace(true); @@ -514,13 +538,24 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa setIsDeleteModalOpen(false)} prompt={hasCardFeedOrExpensifyCard ? translate('workspace.common.deleteWithCardsConfirmation') : translate('workspace.common.deleteConfirmation')} confirmText={translate('common.delete')} cancelText={translate('common.cancel')} + isConfirmLoading={isPendingDeletePolicy(policy)} danger /> + )} diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 1d229d64bfb6..5896361e5337 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -30,6 +30,7 @@ import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePayAndDowngrade from '@hooks/usePayAndDowngrade'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; +import usePrevious from '@hooks/usePrevious'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useSearchResults from '@hooks/useSearchResults'; import useStyleUtils from '@hooks/useStyleUtils'; @@ -52,9 +53,9 @@ import { getPolicy, getPolicyBrickRoadIndicatorStatus, getUberConnectionErrorDirectlyFromPolicy, + isPendingDeletePolicy, isPolicyAdmin, shouldShowPolicy, - shouldShowPolicyError, } from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButton'; @@ -138,8 +139,7 @@ function WorkspacesListPage() { const [policyNameToDelete, setPolicyNameToDelete] = useState(); const {reportsToArchive, transactionViolations} = useTransactionViolationOfWorkspace(policyIDToDelete); const {setIsDeletingPaidWorkspace, isLoadingBill}: {setIsDeletingPaidWorkspace: (value: boolean) => void; isLoadingBill: boolean | undefined} = usePayAndDowngrade(setIsDeleteModalOpen); - const [policyErrorMessage, setPolicyErrorMessage] = useState(''); - const [isPolicyErrorModalOpen, setIsPolicyErrorModalOpen] = useState(false); + const [isDeleteWorkspaceErrorModalOpen, setIsDeleteWorkspaceErrorModalOpen] = useState(false); const [loadingSpinnerIconIndex, setLoadingSpinnerIconIndex] = useState(null); @@ -160,13 +160,17 @@ function WorkspacesListPage() { // This will be fixed as part of https://github.com/Expensify/Expensify/issues/507850 // eslint-disable-next-line deprecation/deprecation const policyToDelete = getPolicy(policyIDToDelete); + const prevPolicyToDelete = usePrevious(policyToDelete); const hasCardFeedOrExpensifyCard = !isEmptyObject(cardFeeds) || !isEmptyObject(cardsList) || // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing ((policyToDelete?.areExpensifyCardsEnabled || policyToDelete?.areCompanyCardsEnabled) && policyToDelete?.workspaceAccountID); + const policyToDeleteLatestErrorMessage = getLatestErrorMessage(policyToDelete); + const isPendingDelete = isPendingDeletePolicy(policyToDelete); + const prevIsPendingDelete = isPendingDeletePolicy(prevPolicyToDelete); - const confirmDeleteAndHideModal = () => { + const confirmDelete = () => { if (!policyIDToDelete || !policyNameToDelete) { return; } @@ -181,12 +185,15 @@ function WorkspacesListPage() { reimbursementAccountError, lastPaymentMethod, ); - setIsDeleteModalOpen(false); }; - const hideWorkspaceErrorModal = () => { - setIsPolicyErrorModalOpen(false); - setPolicyErrorMessage(''); + const hideDeleteWorkspaceErrorModal = () => { + setIsDeleteWorkspaceErrorModalOpen(false); + setPolicyIDToDelete(undefined); + if (!policyToDelete) { + return; + } + dismissWorkspaceError(policyToDelete.id, policyToDelete.pendingAction); }; const shouldCalculateBillNewDot: boolean = shouldCalculateBillNewDotFn(); @@ -216,18 +223,15 @@ function WorkspacesListPage() { ); useEffect(() => { - if (!isFocused || !!policyErrorMessage || isPolicyErrorModalOpen) { + if (!prevIsPendingDelete || isPendingDelete) { return; } - const workspaceWithError = Object.values(policies ?? {}).find(shouldShowPolicyError); - const workspaceErrorMessage = getLatestErrorMessage(workspaceWithError); - if (!workspaceWithError || !workspaceErrorMessage) { + setIsDeleteModalOpen(false); + if (!isFocused || !policyToDeleteLatestErrorMessage) { return; } - setPolicyErrorMessage(workspaceErrorMessage); - dismissWorkspaceError(workspaceWithError.id, workspaceWithError.pendingAction); - setIsPolicyErrorModalOpen(true); - }, [policyErrorMessage, isFocused, isPolicyErrorModalOpen, policies]); + setIsDeleteWorkspaceErrorModalOpen(true); + }, [isPendingDelete, prevIsPendingDelete, isFocused, policyToDeleteLatestErrorMessage]); /** * Gets the menu item for each workspace @@ -320,6 +324,7 @@ function WorkspacesListPage() { errors={item.errors} style={styles.mb2} shouldShowErrorMessages={false} + shouldHideOnDelete={false} > shouldShowPolicy(policy, isOffline, session?.email)) + .filter((policy): policy is PolicyType => shouldShowPolicy(policy, true, session?.email)) .map((policy): WorkspaceItem => { const receiptUberBrickRoadIndicator = getUberConnectionErrorDirectlyFromPolicy(policy as OnyxEntry) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined; @@ -449,7 +454,7 @@ function WorkspacesListPage() { employeeList: policy.employeeList, }; }); - }, [reimbursementAccount?.errors, policies, isOffline, session?.email, allConnectionSyncProgresses, theme.textLight, navigateToWorkspace]); + }, [reimbursementAccount?.errors, policies, session?.email, allConnectionSyncProgresses, theme.textLight, navigateToWorkspace]); const filterWorkspace = useCallback((workspace: WorkspaceItem, inputValue: string) => workspace.title.toLowerCase().includes(inputValue), []); const sortWorkspace = useCallback((workspaceItems: WorkspaceItem[]) => workspaceItems.sort((a, b) => localeCompare(a.title, b.title)), [localeCompare]); @@ -601,19 +606,20 @@ function WorkspacesListPage() { setIsDeleteModalOpen(false)} prompt={hasCardFeedOrExpensifyCard ? translate('workspace.common.deleteWithCardsConfirmation') : translate('workspace.common.deleteConfirmation')} confirmText={translate('common.delete')} cancelText={translate('common.cancel')} + isConfirmLoading={isPendingDelete} danger /> Date: Tue, 14 Oct 2025 01:46:27 +0700 Subject: [PATCH 22/23] feat: handle offline case --- src/pages/workspace/WorkspaceOverviewPage.tsx | 19 +++++++++++++++++-- src/pages/workspace/WorkspacesListPage.tsx | 18 ++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index b28506dcda1e..ff71b47af43f 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -167,6 +167,7 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const prevIsPendingDelete = usePrevious(isPendingDelete); const [isDeleteWorkspaceErrorModalOpen, setIsDeleteWorkspaceErrorModalOpen] = useState(false); const policyLastErrorMessage = getLatestErrorMessage(policy); + const [shouldShowOfflineModal, setShouldShowOfflineModal] = useState(false); const fetchPolicyData = useCallback(() => { if (policyDraft?.id) { @@ -175,7 +176,7 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa openPolicyProfilePage(route.params.policyID); }, [policyDraft?.id, route.params.policyID]); - useNetwork({onReconnect: fetchPolicyData}); + const {isOffline} = 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. @@ -247,8 +248,13 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa return; } + if (isOffline) { + setShouldShowOfflineModal(true); + return; + } + setIsDeleteModalOpen(true); - }, [setIsDeletingPaidWorkspace]); + }, [isOffline, setIsDeletingPaidWorkspace]); const handleBackButtonPress = () => { if (isComingFromGlobalReimbursementsFlow) { @@ -556,6 +562,15 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa shouldShowCancelButton={false} success={false} /> + setShouldShowOfflineModal(false)} + onCancel={() => setShouldShowOfflineModal(false)} + confirmText={translate('common.buttonConfirm')} + prompt={translate('common.offlinePrompt')} + shouldShowCancelButton={false} + /> )} diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 5896361e5337..fe1ff602421f 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -140,6 +140,7 @@ function WorkspacesListPage() { const {reportsToArchive, transactionViolations} = useTransactionViolationOfWorkspace(policyIDToDelete); const {setIsDeletingPaidWorkspace, isLoadingBill}: {setIsDeletingPaidWorkspace: (value: boolean) => void; isLoadingBill: boolean | undefined} = usePayAndDowngrade(setIsDeleteModalOpen); const [isDeleteWorkspaceErrorModalOpen, setIsDeleteWorkspaceErrorModalOpen] = useState(false); + const [shouldShowOfflineModal, setShouldShowOfflineModal] = useState(false); const [loadingSpinnerIconIndex, setLoadingSpinnerIconIndex] = useState(null); @@ -300,6 +301,13 @@ function WorkspacesListPage() { return; } + if (isOffline) { + setPolicyIDToDelete(undefined); + setPolicyNameToDelete(undefined); + setShouldShowOfflineModal(true); + return; + } + setIsDeleteModalOpen(true); }, shouldKeepModalOpen: shouldCalculateBillNewDot, @@ -379,6 +387,7 @@ function WorkspacesListPage() { isLoadingBill, resetLoadingSpinnerIconIndex, isRestrictedToPreferredPolicy, + isOffline, ], ); @@ -624,6 +633,15 @@ function WorkspacesListPage() { shouldShowCancelButton={false} success={false} /> + setShouldShowOfflineModal(false)} + onCancel={() => setShouldShowOfflineModal(false)} + confirmText={translate('common.buttonConfirm')} + prompt={translate('common.offlinePrompt')} + shouldShowCancelButton={false} + /> {shouldDisplayLHB && } ); From 66ec4d9e39513576ca84591eb8f6a022087e0374 Mon Sep 17 00:00:00 2001 From: dominictb Date: Tue, 14 Oct 2025 02:18:56 +0700 Subject: [PATCH 23/23] remove redundant changes --- src/pages/workspace/WorkspaceInitialPage.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/pages/workspace/WorkspaceInitialPage.tsx b/src/pages/workspace/WorkspaceInitialPage.tsx index 9044193d7fc5..520e1cd9f34a 100644 --- a/src/pages/workspace/WorkspaceInitialPage.tsx +++ b/src/pages/workspace/WorkspaceInitialPage.tsx @@ -422,11 +422,11 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac const shouldShowNotFoundPage = !shouldShowPolicy && (!isPendingDelete || prevIsPendingDelete); useEffect(() => { - if (!isFocused || !prevIsPendingDelete || isPendingDelete) { + if (!isFocused || isEmptyObject(prevPolicy) || prevIsPendingDelete || !isPendingDelete) { return; } goBackFromInvalidPolicy(); - }, [isFocused, isPendingDelete, prevIsPendingDelete]); + }, [isFocused, isPendingDelete, policy, prevIsPendingDelete, prevPolicy]); // We are checking if the user can access the route. // If user can't access the route, we are dismissing any modals that are open when the NotFound view is shown @@ -496,9 +496,9 @@ function WorkspaceInitialPage({policyDraft, policy: policyProp, route}: Workspac > {/* - Ideally we should use MenuList component for MenuItems with singleExecution/Navigation actions. - In this case where user can click on workspace avatar or menu items, we need to have a check for `isExecuting`. So, we are directly mapping menuItems. - */} + Ideally we should use MenuList component for MenuItems with singleExecution/Navigation actions. + In this case where user can click on workspace avatar or menu items, we need to have a check for `isExecuting`. So, we are directly mapping menuItems. + */} {workspaceMenuItems.map((item) => (