Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions src/libs/actions/Policy/Policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,7 @@ import type {OnboardingCompanySize, OnboardingPurpose} from '@userActions/Welcom

import CONST from '@src/CONST';
import type {OnboardingAccounting} from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
import ONYXKEYS from '@src/ONYXKEYS';
import type {PolicySelector} from '@src/selectors/Policy';
import type {
Expand Down Expand Up @@ -431,7 +432,8 @@ type DeleteWorkspaceActionParams = {
reimbursementAccountError: Errors | undefined;
lastUsedPaymentMethods?: LastPaymentMethod;
localeCompare: LocaleContextProps['localeCompare'];
hasDeleteWorkspaceExpensifyCardsError?: boolean;
/** Translation key of the blocker error to surface when the delete is already known to be rejected while offline */
deleteWorkspaceBlockedErrorKey?: TranslationPaths;
currentUserAccountID: number;
accountIDToLogin: Record<number, string>;
};
Expand All @@ -455,23 +457,23 @@ function deleteWorkspace(params: DeleteWorkspaceActionParams) {
lastUsedPaymentMethods,
localeCompare,
personalPolicyID,
hasDeleteWorkspaceExpensifyCardsError,
deleteWorkspaceBlockedErrorKey,
currentUserAccountID,
accountIDToLogin,
} = params;

const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`];
const filteredPolicies = Object.values(policies ?? {}).filter((p): p is Policy => p?.id !== policyID);
const workspaceAccountID = policy?.policyAccountID;

// Offline pre-flight guard: we already know locally the workspace has active Expensify Cards, so surface the error instead of queuing a delete that the backend will reject on reconnect.
if (hasDeleteWorkspaceExpensifyCardsError) {
// Offline pre-flight guard: we already know locally the workspace has active Expensify Cards or Consolidated Travel Billing, so surface the error instead of queuing a delete that the backend will reject on reconnect.
if (deleteWorkspaceBlockedErrorKey) {
Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {
errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workspace.common.deleteOpenExpensifyCardsError'),
errors: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey(deleteWorkspaceBlockedErrorKey),
});
return;
}

const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`];
const filteredPolicies = Object.values(policies ?? {}).filter((p): p is Policy => p?.id !== policyID);
const workspaceAccountID = policy?.policyAccountID;

const optimisticData: Array<
OnyxUpdate<
| typeof ONYXKEYS.COLLECTION.POLICY
Expand Down
21 changes: 11 additions & 10 deletions src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,11 @@ function DeleteWorkspaceFlow({policyID, onDismiss, onDeleteComplete}: DeleteWork
const hasTravelCards = Object.values(travelCardsList ?? {}).some(isCard);
const isBlockedByExpensifyCards = hasExpensifyCardsEnabledOnWorkspace && hasExpensifyCards;
const isBlockedByTravelBilling = hasTravelBillingEnabledOnWorkspace && hasTravelCards;
// While offline we can't get the real rejection reason from the backend, so if we already know locally that the workspace has active Expensify Cards, block the delete up front instead of queuing one that will fail on reconnect.
const hasDeleteWorkspaceExpensifyCardsError = isBlockedByExpensifyCards && !!isOffline;
const isDeleteBlocked = isBlockedByExpensifyCards || isBlockedByTravelBilling;
// Only used while one of the blockers applies, and Expensify Cards take priority when both do.
const deleteWorkspaceBlockedErrorKey = isBlockedByExpensifyCards ? 'workspace.common.deleteOpenExpensifyCardsError' : 'workspace.common.deleteTravelBillingError';
// While offline we can't get the real rejection reason from the backend, so if we already know locally that the workspace has active Expensify Cards or Consolidated Travel Billing, block the delete up front instead of queuing one that will fail on reconnect.
const hasDeleteWorkspaceBlockedError = isDeleteBlocked && !!isOffline;

const policyLatestErrorMessage = getLatestErrorMessage(policy);
const isPendingDelete = isPendingDeletePolicy(policy);
Expand Down Expand Up @@ -146,8 +149,7 @@ function DeleteWorkspaceFlow({policyID, onDismiss, onDeleteComplete}: DeleteWork
prompt: (
<View style={[styles.renderHTML, styles.flexRow]}>
<RenderHTML
// This modal is only shown when one of the two blockers applies, and Expensify Cards take priority when both do.
html={translate(isBlockedByExpensifyCards ? 'workspace.common.deleteOpenExpensifyCardsError' : 'workspace.common.deleteTravelBillingError')}
html={translate(deleteWorkspaceBlockedErrorKey)}
onConciergeLinkPress={() => {
closeModal();
dismissDeleteWorkspaceFlow();
Expand Down Expand Up @@ -205,7 +207,7 @@ function DeleteWorkspaceFlow({policyID, onDismiss, onDeleteComplete}: DeleteWork
confirmText: translate('common.delete'),
cancelText: translate('common.cancel'),
buttonVariant: CONST.BUTTON_VARIANT.DANGER,
...(hasDeleteWorkspaceExpensifyCardsError ? {} : {isConfirmLoading: isPendingDelete}),
...(hasDeleteWorkspaceBlockedError ? {} : {isConfirmLoading: isPendingDelete}),
}).then((result) => {
if (!policyName || result.action !== ModalActions.CONFIRM) {
onDismiss();
Expand All @@ -227,12 +229,12 @@ function DeleteWorkspaceFlow({policyID, onDismiss, onDeleteComplete}: DeleteWork
lastUsedPaymentMethods: lastPaymentMethod,
localeCompare,
personalPolicyID,
hasDeleteWorkspaceExpensifyCardsError,
deleteWorkspaceBlockedErrorKey: hasDeleteWorkspaceBlockedError ? deleteWorkspaceBlockedErrorKey : undefined,
currentUserAccountID: session?.accountID ?? CONST.DEFAULT_NUMBER_ID,
accountIDToLogin: accountIDToLogin ?? {},
});

if (hasDeleteWorkspaceExpensifyCardsError) {
if (hasDeleteWorkspaceBlockedError) {
showDeleteWorkspaceErrorModal();
} else if (isOffline) {
closeModal();
Expand Down Expand Up @@ -289,7 +291,7 @@ function DeleteWorkspaceFlow({policyID, onDismiss, onDeleteComplete}: DeleteWork
}

closeVisibleModal(() => {
if (isBlockedByExpensifyCards || isBlockedByTravelBilling) {
if (isDeleteBlocked) {
showDeleteWorkspaceErrorModal();
return;
}
Expand All @@ -301,8 +303,7 @@ function DeleteWorkspaceFlow({policyID, onDismiss, onDeleteComplete}: DeleteWork
isPendingDelete,
prevIsPendingDelete,
policyLatestErrorMessage,
isBlockedByExpensifyCards,
isBlockedByTravelBilling,
isDeleteBlocked,
closeModal,
onDeleteComplete,
onDismiss,
Expand Down
39 changes: 39 additions & 0 deletions tests/actions/PolicyTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5354,6 +5354,45 @@ describe('actions/Policy', () => {
const message = closedAction && 'message' in closedAction && Array.isArray(closedAction.message) ? closedAction.message.at(0) : undefined;
expect(message?.text).toBe('');
});

it.each(['workspace.common.deleteOpenExpensifyCardsError', 'workspace.common.deleteTravelBillingError'] as const)(
'should show %s on the workspace without queuing the delete when the delete is known to be blocked',
async (blockedErrorKey) => {
// Given a workspace whose delete the backend is known to reject
const fakePolicy = createRandomPolicy(0);
await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`, fakePolicy);
const apiWriteSpy = jest.spyOn(APIModule, 'write').mockImplementation(() => Promise.resolve());

// When the delete flow passes the blocker's error key, as it does while offline
Policy.deleteWorkspace({
policies: {[`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`]: fakePolicy},
policyID: fakePolicy.id,
personalPolicyID: undefined,
activePolicyID: undefined,
policyName: fakePolicy.name,
lastAccessedWorkspacePolicyID: undefined,
policyCardFeeds: undefined,
lastSelectedFeed: undefined,
lastSelectedExpensifyCardFeed: undefined,
reportsToArchive: [],
transactionViolations: undefined,
reimbursementAccountError: undefined,
lastUsedPaymentMethods: undefined,
localeCompare: TestHelper.localeCompare,
deleteWorkspaceBlockedErrorKey: blockedErrorKey,
currentUserAccountID: ESH_ACCOUNT_ID,
accountIDToLogin: {},
});
await waitForBatchedUpdates();

// Then the workspace shows that blocker's error and stays, since a queued delete would only fail on reconnect
const policy = await getOnyxValue(`${ONYXKEYS.COLLECTION.POLICY}${fakePolicy.id}`);
expect(Object.values(policy?.errors ?? {})).toEqual([TestHelper.translateLocal(blockedErrorKey)]);
expect(policy?.pendingAction).not.toBe(CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
expect(apiWriteSpy).not.toHaveBeenCalled();
apiWriteSpy.mockRestore();
},
);
});

describe('leaveWorkspace', () => {
Expand Down
194 changes: 194 additions & 0 deletions tests/ui/DeleteWorkspaceFlowTest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
import {act, render, waitFor} from '@testing-library/react-native';

import {LocaleContextProvider} from '@components/LocaleContextProvider';
import type {ModalStateChangePayload} from '@components/Modal/Global/ModalContext';
import {ModalActions} from '@components/Modal/Global/ModalContext';

import {getMicroSecondOnyxErrorWithTranslationKey} from '@libs/ErrorUtils';
import {getTravelBillingCardSettingsKey, getTravelBillingFeedID} from '@libs/TravelBillingUtils';

import DeleteWorkspaceFlow from '@pages/workspace/deleteWorkspace/DeleteWorkspaceFlow';

import CONST from '@src/CONST';
import IntlStore from '@src/languages/IntlStore';
import ONYXKEYS from '@src/ONYXKEYS';

import type * as ReactNavigation from '@react-navigation/native';

import React from 'react';
import Onyx from 'react-native-onyx';

import createRandomCard from '../utils/collections/card';
import {translateLocal} from '../utils/TestHelper';
import waitForBatchedUpdatesWithAct from '../utils/waitForBatchedUpdatesWithAct';

type ConfirmModalCall = {
prompt?: React.ReactNode;
shouldShowCancelButton?: boolean;
};

const mockDeleteWorkspace = jest.fn();
const mockShowConfirmModal = jest.fn<Promise<ModalStateChangePayload>, [ConfirmModalCall]>();
const mockUseNetwork = jest.fn<{isOffline: boolean}, []>();

jest.mock('@libs/actions/Policy/Policy', () => ({
calculateBillNewDot: jest.fn(),
deleteWorkspace: (...args: unknown[]) => {
mockDeleteWorkspace(...args);
},
dismissWorkspaceError: jest.fn(),
}));

jest.mock('@hooks/useConfirmModal', () =>
jest.fn().mockImplementation(() => ({
showConfirmModal: mockShowConfirmModal,
closeModal: jest.fn(),
})),
);

jest.mock('@hooks/useNetwork', () => () => mockUseNetwork());

jest.mock('@hooks/useScreenBoundDynamicRoute', () => () => (suffix: string) => suffix);

jest.mock('@libs/actions/Modal', () => ({
close: jest.fn().mockImplementation((callback?: () => void) => callback?.()),
}));

jest.mock('@react-navigation/native', () => ({
...jest.requireActual<typeof ReactNavigation>('@react-navigation/native'),
useIsFocused: () => true,
}));

const POLICY_ID = 'ABCDEF1234567890';
const WORKSPACE_ACCOUNT_ID = 1001;
const DOMAIN_FUND_ID = 2002;
const USER_ACCOUNT_ID = 1;
const USER_EMAIL = 'admin@example.com';

const renderFlow = (onDeleteComplete = jest.fn()) =>
render(
<LocaleContextProvider>
<DeleteWorkspaceFlow
policyID={POLICY_ID}
onDismiss={jest.fn()}
onDeleteComplete={onDeleteComplete}
/>
</LocaleContextProvider>,
);

async function enableTravelBilling(fundID: number, linkedPolicyIDs?: string[]) {
const travelCard = createRandomCard(fundID, {bank: CONST.EXPENSIFY_CARD.BANK, state: CONST.EXPENSIFY_CARD.STATE.OPEN});
await Onyx.set(getTravelBillingCardSettingsKey(fundID), {[CONST.TRAVEL.PROGRAM_TRAVEL_US]: {isEnabled: true, linkedPolicyIDs}});
await Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${getTravelBillingFeedID(fundID)}`, {[travelCard.cardID]: travelCard});
}

async function issueExpensifyCard() {
const expensifyCard = createRandomCard(1, {bank: CONST.EXPENSIFY_CARD.BANK, state: CONST.EXPENSIFY_CARD.STATE.OPEN});
await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {areExpensifyCardsEnabled: true});
await Onyx.set(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${WORKSPACE_ACCOUNT_ID}_${CONST.EXPENSIFY_CARD.BANK}`, {[expensifyCard.cardID]: expensifyCard});
}

function getErrorModalHTML() {
const prompt = mockShowConfirmModal.mock.calls.at(1)?.[0].prompt;
if (!React.isValidElement<{children?: React.ReactNode}>(prompt)) {
return undefined;
}
const renderHTML = prompt.props.children;
return React.isValidElement<{html?: string}>(renderHTML) ? renderHTML.props.html : undefined;
}

describe('DeleteWorkspaceFlow', () => {
beforeAll(() => {
Onyx.init({keys: ONYXKEYS});
return IntlStore.load(CONST.LOCALES.EN);
});

beforeEach(async () => {
mockDeleteWorkspace.mockReset();
mockShowConfirmModal.mockReset();
mockShowConfirmModal.mockResolvedValue({action: ModalActions.CONFIRM});
mockUseNetwork.mockReturnValue({isOffline: true});
await act(async () => {
await Onyx.clear();
await Onyx.set(ONYXKEYS.SESSION, {email: USER_EMAIL, accountID: USER_ACCOUNT_ID});
await Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {id: POLICY_ID, name: 'Test', role: CONST.POLICY.ROLE.ADMIN, policyAccountID: WORKSPACE_ACCOUNT_ID});
});
});

it('shows the Consolidated Travel Billing error instead of deleting the workspace while offline', async () => {
// Given an offline admin whose workspace pays for travel through its own Consolidated Travel Billing, which the backend refuses to delete
await act(async () => {
await enableTravelBilling(WORKSPACE_ACCOUNT_ID);
});

// When the admin confirms the delete
renderFlow();
await waitForBatchedUpdatesWithAct();

// Then the delete is not queued and the travel billing warning is shown, because the real rejection reason only arrives after reconnecting
await waitFor(() => expect(mockShowConfirmModal).toHaveBeenCalledTimes(2));
expect(mockDeleteWorkspace).toHaveBeenCalledWith(expect.objectContaining({deleteWorkspaceBlockedErrorKey: 'workspace.common.deleteTravelBillingError'}));
expect(mockShowConfirmModal).toHaveBeenLastCalledWith(expect.objectContaining({shouldShowCancelButton: false}));
expect(getErrorModalHTML()).toBe(translateLocal('workspace.common.deleteTravelBillingError'));
});

it('shows the Expensify Cards error while offline when the workspace has both blockers', async () => {
// Given an offline admin whose workspace has active Expensify Cards as well as Consolidated Travel Billing
await act(async () => {
await enableTravelBilling(WORKSPACE_ACCOUNT_ID);
await issueExpensifyCard();
});

// When the admin confirms the delete
renderFlow();
await waitForBatchedUpdatesWithAct();

// Then the Expensify Cards warning wins, matching the warning shown when the same delete is rejected online
await waitFor(() => expect(mockShowConfirmModal).toHaveBeenCalledTimes(2));
expect(mockDeleteWorkspace).toHaveBeenCalledWith(expect.objectContaining({deleteWorkspaceBlockedErrorKey: 'workspace.common.deleteOpenExpensifyCardsError'}));
expect(getErrorModalHTML()).toBe(translateLocal('workspace.common.deleteOpenExpensifyCardsError'));
});

it('still deletes the workspace offline when its Consolidated Travel Billing is funded by a domain', async () => {
// Given an offline admin whose workspace is billed for travel by a domain, which the backend lets the workspace be deleted with
const onDeleteComplete = jest.fn();
await act(async () => {
await enableTravelBilling(DOMAIN_FUND_ID, [POLICY_ID]);
});

// When the admin confirms the delete
renderFlow(onDeleteComplete);
await waitForBatchedUpdatesWithAct();

// Then the delete goes ahead optimistically without a warning
await waitFor(() => expect(onDeleteComplete).toHaveBeenCalled());
expect(mockDeleteWorkspace).toHaveBeenCalledWith(expect.objectContaining({deleteWorkspaceBlockedErrorKey: undefined}));
expect(mockShowConfirmModal).toHaveBeenCalledTimes(1);
});

it('sends the delete while online and shows the Consolidated Travel Billing warning when the backend rejects it', async () => {
// Given an online admin whose workspace has Consolidated Travel Billing
mockUseNetwork.mockReturnValue({isOffline: false});
mockDeleteWorkspace.mockImplementation(() => Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {pendingAction: CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE}));
await act(async () => {
await enableTravelBilling(WORKSPACE_ACCOUNT_ID);
});

// When the admin confirms the delete and the backend rejects it with its Expensify Cards wording
renderFlow();
await waitForBatchedUpdatesWithAct();
await waitFor(() => expect(mockDeleteWorkspace).toHaveBeenCalled());
await act(async () => {
await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID}`, {
pendingAction: null,
errors: getMicroSecondOnyxErrorWithTranslationKey('workspace.common.deleteOpenExpensifyCardsError'),
});
});
await waitForBatchedUpdatesWithAct();

// Then the delete is sent without a pre-flight warning, and the rejection shows the travel billing warning in place of the backend's wording
expect(mockDeleteWorkspace).toHaveBeenCalledWith(expect.objectContaining({deleteWorkspaceBlockedErrorKey: undefined}));
await waitFor(() => expect(mockShowConfirmModal).toHaveBeenCalledTimes(2));
expect(getErrorModalHTML()).toBe(translateLocal('workspace.common.deleteTravelBillingError'));
});
});
Loading