From 6a65f651edb7d9c907def3ef6c2b61f69cfaecbc Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Thu, 11 Jun 2026 07:50:05 +0200 Subject: [PATCH 1/3] extract DeleteWorkspaceFlow component from WorkspacesListPage --- .../WorkspaceListTable/WorkspaceTableRow.tsx | 5 +- .../Tables/WorkspaceListTable/index.tsx | 1 - src/hooks/useOutstandingBalanceGuard.tsx | 11 +- src/libs/actions/Policy/Policy.ts | 18 ++ src/pages/workspace/WorkspacesListPage.tsx | 264 ++---------------- .../deleteWorkspace/DeleteWorkspaceFlow.tsx | 237 ++++++++++++++++ src/selectors/Account.ts | 3 + src/selectors/Policy.ts | 27 +- 8 files changed, 323 insertions(+), 243 deletions(-) create mode 100644 src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx diff --git a/src/components/Tables/WorkspaceListTable/WorkspaceTableRow.tsx b/src/components/Tables/WorkspaceListTable/WorkspaceTableRow.tsx index eb09f3348a2b..ccca940ab267 100644 --- a/src/components/Tables/WorkspaceListTable/WorkspaceTableRow.tsx +++ b/src/components/Tables/WorkspaceListTable/WorkspaceTableRow.tsx @@ -44,7 +44,6 @@ export default function WorkspaceRow({item, shouldUseNarrowTableLayout, rowIndex const formattedWorkspaceType = getUserFriendlyWorkspaceType(item.type, translate); const narrowWorkspaceLabel = `${translate('common.owner')}: ${formattedOwnerName} • ${formattedWorkspaceType}`; const itemDeletedStyles = item.isDeleted ? [styles.offlineFeedbackDeleted] : [{}]; - const resetLoadingSpinnerIconIndex = item.resetLoadingSpinnerIconIndex; const accessibilityLabel = [ `${translate('workspace.common.workspaceName')}: ${item.title}`, @@ -117,13 +116,11 @@ export default function WorkspaceRow({item, shouldUseNarrowTableLayout, rowIndex return; } - resetLoadingSpinnerIconIndex?.(); - if (!threeDotsMenuRef.current?.isPopupMenuVisible) { return; } threeDotsMenuRef?.current?.hidePopoverMenu(); - }, [isLoadingBill, resetLoadingSpinnerIconIndex]); + }, [isLoadingBill]); return ( ; action: (event?: ModifiedMouseEvent) => void; dismissError: () => void; - resetLoadingSpinnerIconIndex?: () => void; }; type WorkspaceListTableProps = { diff --git a/src/hooks/useOutstandingBalanceGuard.tsx b/src/hooks/useOutstandingBalanceGuard.tsx index 6322fa828654..3ca06eac9096 100644 --- a/src/hooks/useOutstandingBalanceGuard.tsx +++ b/src/hooks/useOutstandingBalanceGuard.tsx @@ -12,11 +12,12 @@ import useOnyx from './useOnyx'; * a modal is shown directing them to subscription settings to settle the balance. * * @param ownedPaidPoliciesCount - The number of paid policies the current user owns + * @param onModalDismissed - called when the modal is dismissed (either by settling the balance or cancelling) * @returns shouldBlockDeletion - function that checks and shows the modal if needed (returns true if blocked) * @returns wouldBlockDeletion - pre-computed boolean for popover/menu configuration * @returns outstandingBalanceModal - React element to render in the page */ -function useOutstandingBalanceGuard(ownedPaidPoliciesCount: number) { +function useOutstandingBalanceGuard(ownedPaidPoliciesCount: number, onModalDismissed?: () => void) { const [isModalOpen, setIsModalOpen] = useState(false); const {translate} = useLocalize(); const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); @@ -39,14 +40,18 @@ function useOutstandingBalanceGuard(ownedPaidPoliciesCount: number) { onConfirm={() => { setIsModalOpen(false); Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION.route); + onModalDismissed?.(); + }} + onCancel={() => { + setIsModalOpen(false); + onModalDismissed?.(); }} - onCancel={() => setIsModalOpen(false)} prompt={translate('workspace.common.outstandingBalanceWarning')} confirmText={translate('workspace.common.settleBalance')} cancelText={translate('common.cancel')} /> ), - [isModalOpen, translate], + [isModalOpen, translate, onModalDismissed], ); return {shouldBlockDeletion, wouldBlockDeletion, outstandingBalanceModal}; diff --git a/src/libs/actions/Policy/Policy.ts b/src/libs/actions/Policy/Policy.ts index 887e7f1d1951..5c0509ae2073 100644 --- a/src/libs/actions/Policy/Policy.ts +++ b/src/libs/actions/Policy/Policy.ts @@ -2292,6 +2292,23 @@ function removeWorkspace(policyID: string) { Onyx.set(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, null); } +/** + * Dismisses the errors on a workspace based on its pending action + */ +function dismissWorkspaceError(policyID: string, pendingAction: PendingAction | undefined) { + if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { + clearDeleteWorkspaceError(policyID); + return; + } + + if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { + removeWorkspace(policyID); + return; + } + + clearErrors(policyID); +} + function setDuplicateWorkspaceData(data: Partial) { Onyx.merge(ONYXKEYS.DUPLICATE_WORKSPACE, {...data}); } @@ -7508,6 +7525,7 @@ export { updateAddress, updateLastAccessedWorkspace, clearDeleteWorkspaceError, + dismissWorkspaceError, setWorkspaceDefaultSpendCategory, getDisplayNameForWorkspace, generateDefaultWorkspaceName, diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 0d06bb10dabc..4a217aa6ee0c 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -13,7 +13,6 @@ import type {WorkspaceRowData, WorkspaceTableColumnKey} from '@components/Tables import WorkspaceListTable from '@components/Tables/WorkspaceListTable'; import WorkspaceListLayout from '@components/WorkspaceListLayout'; import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; -import useCardFeeds from '@hooks/useCardFeeds'; import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDocumentTitle from '@hooks/useDocumentTitle'; @@ -21,23 +20,17 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import useOutstandingBalanceGuard from '@hooks/useOutstandingBalanceGuard'; -import usePayAndDowngrade from '@hooks/usePayAndDowngrade'; import usePermissions from '@hooks/usePermissions'; import usePoliciesWithCardFeedErrors from '@hooks/usePoliciesWithCardFeedErrors'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; -import usePrivateSubscription from '@hooks/usePrivateSubscription'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import useTransactionViolationOfWorkspace from '@hooks/useTransactionViolationOfWorkspace'; import {isConnectionInProgress} from '@libs/actions/connections'; import {close} from '@libs/actions/Modal'; import {clearCopyPolicySettings} from '@libs/actions/Policy/CopyPolicySettings'; import {clearWorkspaceOwnerChangeFlow, requestWorkspaceOwnerChange} from '@libs/actions/Policy/Member'; -import {calculateBillNewDot, clearDeleteWorkspaceError, clearDuplicateWorkspace, clearErrors, deleteWorkspace, leaveWorkspace, removeWorkspace} from '@libs/actions/Policy/Policy'; +import {clearDuplicateWorkspace, dismissWorkspaceError, leaveWorkspace} from '@libs/actions/Policy/Policy'; import {callFunctionIfActionIsAllowed} from '@libs/actions/Session'; -import {filterInactiveCards} from '@libs/CardUtils'; -import {getLatestErrorMessage} from '@libs/ErrorUtils'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import openInternalRouteInNewTab from '@libs/Navigation/helpers/openInternalRouteInNewTab'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; @@ -54,46 +47,24 @@ import { isPolicyAdmin, isPolicyApprover, isPolicyAuditor, - shouldBlockWorkspaceDeletionForInvoicifyUser, shouldShowEmployeeListError, shouldShowPolicy, } from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButton'; -import {isSubscriptionTypeOfInvoicing, shouldCalculateBillNewDot as shouldCalculateBillNewDotFn} from '@libs/SubscriptionUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import {setNameValuePair} from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import {accountIDToLoginSelector} from '@src/selectors/PersonalDetails'; -import {ownerPoliciesSelector} from '@src/selectors/Policy'; -import {reimbursementAccountErrorSelector} from '@src/selectors/ReimbursementAccount'; +import {canDowngradeSelector} from '@src/selectors/Account'; +import {createOwnedPaidPoliciesCountsSelector} from '@src/selectors/Policy'; import type {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'; import CopyPolicySettingsProgressModal from './copyPolicySettings/CopyPolicySettingsProgressModal'; - -type GetWorkspaceMenuItem = {item: WorkspaceRowData; index: number}; - -/** - * Dismisses the errors on one item - */ -function dismissWorkspaceError(policyID: string, pendingAction: OnyxCommon.PendingAction | undefined) { - if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) { - clearDeleteWorkspaceError(policyID); - return; - } - - if (pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD) { - removeWorkspace(policyID); - return; - } - - clearErrors(policyID); -} +import DeleteWorkspaceFlow from './deleteWorkspace/DeleteWorkspaceFlow'; function isUserReimburserForPolicy(policies: Record | undefined, policyID: string | undefined, userEmail: string | undefined): boolean { if (!policies || !policyID || !userEmail) { @@ -110,12 +81,11 @@ function WorkspacesListPage() { const tableRef = useRef>(null); const icons = useMemoizedLazyExpensifyIcons(['Building', 'Exit', 'Copy', 'Star', 'Trashcan', 'Transfer', 'FallbackWorkspaceAvatar', 'Plus']); const styles = useThemeStyles(); - const {translate, localeCompare} = useLocalize(); + const {translate} = useLocalize(); useDocumentTitle(translate('common.workspaces')); const {isOffline} = useNetwork(); const isFocused = useIsFocused(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const privateSubscription = usePrivateSubscription(); const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const [allConnectionSyncProgresses] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); @@ -123,124 +93,27 @@ function WorkspacesListPage() { const [session] = useOnyx(ONYXKEYS.SESSION); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); - const [lastPaymentMethod] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD); const shouldShowLoadingIndicator = isLoadingApp && !isOffline; const route = useRoute>(); const [fundList] = useOnyx(ONYXKEYS.FUND_LIST); const {isBetaEnabled} = usePermissions(); const {isRestrictedToPreferredPolicy, preferredPolicyID, isRestrictedPolicyCreation} = usePreferredPolicy(); - const [account] = useOnyx(ONYXKEYS.ACCOUNT); - const [reimbursementAccountError] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {selector: reimbursementAccountErrorSelector}); - const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID); const [duplicateWorkspace] = useOnyx(ONYXKEYS.DUPLICATE_WORKSPACE); - const {showConfirmModal, closeModal} = useConfirmModal(); - - const ownedPaidPolicies = ownerPoliciesSelector(policies, currentUserPersonalDetails?.accountID); - const activeOwnedPaidPoliciesCount = ownedPaidPolicies.filter((p) => !isPendingDeletePolicy(p)).length; - const {shouldBlockDeletion, wouldBlockDeletion, outstandingBalanceModal} = useOutstandingBalanceGuard(activeOwnedPaidPoliciesCount); - - const [policyIDToDelete, setPolicyIDToDelete] = useState(); - // Set when a non-billing delete is initiated. The confirmation modal is opened from an effect (not synchronously) so that - // `policyIDToDelete` and all of its Onyx-derived data (card feeds, reports to archive, transaction violations, etc.) are - // up to date for the selected workspace before the modal and the deleteWorkspace call are built. - const [shouldShowDeleteConfirmModal, setShouldShowDeleteConfirmModal] = useState(false); - const isErrorModalShowingRef = useRef(false); - const {reportsToArchive, transactionViolations} = useTransactionViolationOfWorkspace(policyIDToDelete); - - const [loadingSpinnerIconIndex, setLoadingSpinnerIconIndex] = useState(null); - - const policyToDelete = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyIDToDelete}`]; - - // We need this to update translation for deleting a workspace when it has third party card feeds or expensify card assigned. - const workspaceAccountID = policyToDelete?.policyAccountID ?? CONST.DEFAULT_NUMBER_ID; - const [cardFeeds, , defaultCardFeeds] = useCardFeeds(policyIDToDelete); - const [lastSelectedFeed] = useOnyx(`${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyIDToDelete}`); - const [lastSelectedExpensifyCardFeed] = useOnyx(`${ONYXKEYS.COLLECTION.LAST_SELECTED_EXPENSIFY_CARD_FEED}${policyIDToDelete}`); - const [cardsList] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`, { - selector: filterInactiveCards, - }); - const [lastAccessedWorkspacePolicyID] = useOnyx(ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID); - - const hasCardFeedOrExpensifyCard = - !isEmptyObject(cardFeeds) || - !isEmptyObject(cardsList) || - // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing - ((policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyIDToDelete}`]?.areExpensifyCardsEnabled || - policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyIDToDelete}`]?.areCompanyCardsEnabled) && - policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyIDToDelete}`]?.policyAccountID); - const hasExpensifyCard = !!policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyIDToDelete}`]?.areExpensifyCardsEnabled && !isEmptyObject(cardsList); + const {showConfirmModal} = useConfirmModal(); const personalDetails = usePersonalDetails(); - const [accountIDToLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: accountIDToLoginSelector(reportsToArchive)}); - - const policyToDeleteLatestErrorMessage = getLatestErrorMessage(policyToDelete); - const isPendingDelete = isPendingDeletePolicy(policyToDelete); - const hasDeleteWorkspaceExpensifyCardsError = !!hasExpensifyCard && !!isOffline; - - const prevIsPendingDeleteRef = useRef(isPendingDelete); - // Always invoked after a re-render (from the effect below for normal deletes, or from usePayAndDowngrade for billed deletes), - // so the workspace being deleted and its derived data are read from the latest state. - const continueDeleteWorkspace = () => { - const policyID = policyIDToDelete; - const policyName = policyToDelete?.name; - - showConfirmModal({ - title: translate('workspace.common.delete'), - prompt: hasCardFeedOrExpensifyCard ? translate('workspace.common.deleteWithCardsConfirmation') : translate('workspace.common.deleteConfirmation'), - confirmText: translate('common.delete'), - cancelText: translate('common.cancel'), - danger: true, - isConfirmLoading: isPendingDelete, - }).then((result) => { - if (!policyID || !policyName || result.action !== ModalActions.CONFIRM) { - return; - } - deleteWorkspace({ - policies, - policyID, - activePolicyID, - policyName, - lastAccessedWorkspacePolicyID, - policyCardFeeds: defaultCardFeeds, - lastSelectedFeed, - lastSelectedExpensifyCardFeed, - reportsToArchive, - transactionViolations, - reimbursementAccountError, - lastUsedPaymentMethods: lastPaymentMethod, - localeCompare, - personalPolicyID, - hasDeleteWorkspaceExpensifyCardsError, - currentUserAccountID: currentUserPersonalDetails.accountID, - accountIDToLogin: accountIDToLogin ?? {}, - }); - if (isOffline) { - closeModal(); - if (!hasDeleteWorkspaceExpensifyCardsError) { - setPolicyIDToDelete(undefined); - } - } - }); - }; - - const {setIsDeletingPaidWorkspace, isLoadingBill} = usePayAndDowngrade(continueDeleteWorkspace); - - // Open the delete confirmation modal on the render after `policyIDToDelete` (and its derived data) have updated. - useEffect(() => { - if (!shouldShowDeleteConfirmModal) { - return; - } - setShouldShowDeleteConfirmModal(false); - continueDeleteWorkspace(); - }, [shouldShowDeleteConfirmModal, continueDeleteWorkspace]); + // Primitive-valued subscriptions configuring the Delete menu item (popover behavior and the loading spinner) + // before a deletion starts. The deletion itself is handled by DeleteWorkspaceFlow, mounted on demand below. + const [canDowngrade] = useOnyx(ONYXKEYS.ACCOUNT, {selector: canDowngradeSelector}); + const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); + const [isLoadingBill] = useOnyx(ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE); + const [ownedPaidPoliciesCounts] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createOwnedPaidPoliciesCountsSelector(currentUserPersonalDetails.accountID)}, [ + currentUserPersonalDetails.accountID, + ]); + const shouldCalculateBillNewDot = !!canDowngrade && ownedPaidPoliciesCounts?.total === 1; + const wouldBlockDeletion = (amountOwed ?? 0) > 0 && ownedPaidPoliciesCounts?.active === 1; - const hideDeleteWorkspaceErrorModal = () => { - setPolicyIDToDelete(undefined); - if (!policyToDelete) { - return; - } - dismissWorkspaceError(policyToDelete.id, policyToDelete.pendingAction); - }; + const [policyIDToDelete, setPolicyIDToDelete] = useState(); const confirmLeaveAndHideModal = (policyToLeave: PolicyType | undefined) => { if (!policyToLeave) { @@ -285,57 +158,6 @@ function WorkspacesListPage() { return translate('common.leaveWorkspaceConfirmation'); }; - const shouldCalculateBillNewDot: boolean = shouldCalculateBillNewDotFn(currentUserPersonalDetails.accountID, account?.canDowngrade, policies); - - useEffect(() => { - const prevIsPendingDelete = prevIsPendingDeleteRef.current; - prevIsPendingDeleteRef.current = isPendingDelete; - - // Handle showing error modal when offline and error occurs - if (isOffline && policyToDeleteLatestErrorMessage) { - if (isErrorModalShowingRef.current) { - return; - } - isErrorModalShowingRef.current = true; - showConfirmModal({ - title: translate('workspace.common.delete'), - prompt: policyToDeleteLatestErrorMessage, - confirmText: translate('common.buttonConfirm'), - cancelText: translate('common.cancel'), - success: false, - shouldShowCancelButton: false, - }).then(() => { - isErrorModalShowingRef.current = false; - hideDeleteWorkspaceErrorModal(); - }); - return; - } - - if (!prevIsPendingDelete || isPendingDelete || !policyIDToDelete) { - return; - } - closeModal(); - if (!isFocused || !policyToDeleteLatestErrorMessage) { - return; - } - - if (isErrorModalShowingRef.current) { - return; - } - isErrorModalShowingRef.current = true; - showConfirmModal({ - title: translate('workspace.common.delete'), - prompt: policyToDeleteLatestErrorMessage, - confirmText: translate('common.buttonConfirm'), - cancelText: translate('common.cancel'), - success: false, - shouldShowCancelButton: false, - }).then(() => { - isErrorModalShowingRef.current = false; - hideDeleteWorkspaceErrorModal(); - }); - }, [isOffline, hideDeleteWorkspaceErrorModal, showConfirmModal, translate, policyToDeleteLatestErrorMessage, isPendingDelete, isFocused, policyIDToDelete, closeModal]); - const startChangeOwnershipFlow = (policyID: string | undefined) => { if (!policyID) { return; @@ -374,7 +196,7 @@ function WorkspacesListPage() { /** * Gets the menu item for each workspace */ - const getThreeDotMenuItems = ({item, index}: GetWorkspaceMenuItem) => { + const getThreeDotMenuItems = (item: WorkspaceRowData) => { const isDefault = activePolicyID === item.policyID; const isOwner = item.ownerAccountID === session?.accountID; const isAdmin = isPolicyAdmin(item as unknown as PolicyType, session?.email); @@ -465,38 +287,14 @@ function WorkspacesListPage() { threeDotsMenuItems.push({ icon: icons.Trashcan, text: translate('workspace.common.delete'), - shouldShowLoadingSpinnerIcon: loadingSpinnerIconIndex === index, + shouldShowLoadingSpinnerIcon: !!isLoadingBill && policyIDToDelete === item.policyID, onSelected: () => { - if (loadingSpinnerIconIndex !== null) { - return; - } - - if ( - shouldBlockWorkspaceDeletionForInvoicifyUser( - isSubscriptionTypeOfInvoicing(privateSubscription?.type), - policies, - item?.policyID, - currentUserPersonalDetails?.accountID, - ) - ) { - Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION_DOWNGRADE_BLOCKED.getRoute(Navigation.getActiveRoute())); + if (isLoadingBill) { return; } + // All the pre-deletion checks and the confirmation modal are handled by DeleteWorkspaceFlow, which mounts when this is set. setPolicyIDToDelete(item.policyID); - - if (shouldBlockDeletion()) { - return; - } - - if (shouldCalculateBillNewDot) { - setIsDeletingPaidWorkspace(true); - calculateBillNewDot(); - setLoadingSpinnerIconIndex(index); - return; - } - - setShouldShowDeleteConfirmModal(true); }, shouldKeepModalOpen: shouldCalculateBillNewDot && !wouldBlockDeletion, shouldCallAfterModalHide: !shouldCalculateBillNewDot || wouldBlockDeletion, @@ -522,10 +320,6 @@ function WorkspacesListPage() { Navigation.navigate(workspaceRoute); }; - const resetLoadingSpinnerIconIndex = () => { - setLoadingSpinnerIconIndex(null); - }; - const {policiesWithCardFeedErrors} = usePoliciesWithCardFeedErrors(); /** @@ -536,7 +330,7 @@ function WorkspacesListPage() { if (!isEmptyObject(policies)) { const reimbursementAccountBrickRoadIndicator = !isEmptyObject(reimbursementAccount?.errors) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined; - for (const [index, policy] of Object.values(policies).entries()) { + for (const policy of Object.values(policies)) { if (!policy || !shouldShowPolicy(policy, true, session?.email)) { continue; } @@ -598,10 +392,9 @@ function WorkspacesListPage() { icon: policyInfo?.avatar ? policyInfo.avatar : getDefaultWorkspaceAvatar(policy.name), action: () => null, dismissError: () => null, - resetLoadingSpinnerIconIndex, }; - pendingWorkspaceRow.threeDotMenuItems = getThreeDotMenuItems({item: pendingWorkspaceRow, index}); + pendingWorkspaceRow.threeDotMenuItems = getThreeDotMenuItems(pendingWorkspaceRow); workspaceRows.push(pendingWorkspaceRow); } else { const policyOwnerAccountID = policy.ownerAccountID; @@ -628,12 +421,11 @@ function WorkspacesListPage() { icon: policy.avatarURL ? policy.avatarURL : getDefaultWorkspaceAvatar(policy.name), brickRoadIndicator, pendingAction: policy.pendingAction, - resetLoadingSpinnerIconIndex, action: (event) => navigateToWorkspace(policy.id, event), dismissError: () => dismissWorkspaceError(policy.id, policy.pendingAction), }; - workspaceRow.threeDotMenuItems = getThreeDotMenuItems({item: workspaceRow, index}); + workspaceRow.threeDotMenuItems = getThreeDotMenuItems(workspaceRow); workspaceRows.push(workspaceRow); } } @@ -701,7 +493,13 @@ function WorkspacesListPage() { workspaces={workspaceRows} /> )} - {outstandingBalanceModal} + {!!policyIDToDelete && ( + setPolicyIDToDelete(undefined)} + /> + )} ); diff --git a/src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx b/src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx new file mode 100644 index 000000000000..f15a22ff8c6a --- /dev/null +++ b/src/pages/workspace/deleteWorkspace/DeleteWorkspaceFlow.tsx @@ -0,0 +1,237 @@ +import {useIsFocused} from '@react-navigation/native'; +import {useEffect, useRef} from 'react'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import useCardFeeds from '@hooks/useCardFeeds'; +import useConfirmModal from '@hooks/useConfirmModal'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; +import useOnyx from '@hooks/useOnyx'; +import useOutstandingBalanceGuard from '@hooks/useOutstandingBalanceGuard'; +import usePayAndDowngrade from '@hooks/usePayAndDowngrade'; +import useTransactionViolationOfWorkspace from '@hooks/useTransactionViolationOfWorkspace'; +import {calculateBillNewDot, deleteWorkspace, dismissWorkspaceError} from '@libs/actions/Policy/Policy'; +import {filterInactiveCards} from '@libs/CardUtils'; +import {getLatestErrorMessage} from '@libs/ErrorUtils'; +import Navigation from '@libs/Navigation/Navigation'; +import {isPendingDeletePolicy, shouldBlockWorkspaceDeletionForInvoicifyUser} from '@libs/PolicyUtils'; +import {isSubscriptionTypeOfInvoicing} from '@libs/SubscriptionUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import {canDowngradeSelector} from '@src/selectors/Account'; +import {accountIDToLoginSelector} from '@src/selectors/PersonalDetails'; +import {createOwnedPaidPoliciesCountsSelector} from '@src/selectors/Policy'; +import {reimbursementAccountErrorSelector} from '@src/selectors/ReimbursementAccount'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +type DeleteWorkspaceFlowProps = { + /** ID of the workspace being deleted */ + policyID: string; + + /** Called when the flow is finished or abandoned, so the parent can unmount this component */ + onDismiss: () => void; +}; + +/** + * Self-contained workspace deletion flow. It is mounted only while a deletion is in progress, so all of the + * Onyx data needed to delete a workspace (full policy and report collections, card feeds, violations, etc.) + * is subscribed to only for the lifetime of the flow instead of re-rendering the workspaces list in the background. + * + * On mount (once the data is ready) it runs the pre-deletion checks (Invoicify block, outstanding balance, + * bill calculation for the last paid workspace) and then shows the delete confirmation modal. + */ +function DeleteWorkspaceFlow({policyID, onDismiss}: DeleteWorkspaceFlowProps) { + const {translate, localeCompare} = useLocalize(); + const {isOffline} = useNetwork(); + const isFocused = useIsFocused(); + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + const {showConfirmModal, closeModal} = useConfirmModal(); + + const [policies, policiesResult] = useOnyx(ONYXKEYS.COLLECTION.POLICY); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const [lastPaymentMethod] = useOnyx(ONYXKEYS.NVP_LAST_PAYMENT_METHOD); + const [personalPolicyID] = useOnyx(ONYXKEYS.PERSONAL_POLICY_ID); + const [lastAccessedWorkspacePolicyID] = useOnyx(ONYXKEYS.LAST_ACCESSED_WORKSPACE_POLICY_ID); + const [reimbursementAccountError] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {selector: reimbursementAccountErrorSelector}); + const [privateSubscription, privateSubscriptionResult] = useOnyx(ONYXKEYS.NVP_PRIVATE_SUBSCRIPTION); + const [canDowngrade, accountResult] = useOnyx(ONYXKEYS.ACCOUNT, {selector: canDowngradeSelector}); + const [, amountOwedResult] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); + const [ownedPaidPoliciesCounts, ownedPaidPoliciesCountsResult] = useOnyx( + ONYXKEYS.COLLECTION.POLICY, + {selector: createOwnedPaidPoliciesCountsSelector(currentUserPersonalDetails.accountID)}, + [currentUserPersonalDetails.accountID], + ); + + const policy = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + + // We need this to update translation for deleting a workspace when it has third party card feeds or expensify card assigned. + const workspaceAccountID = policy?.policyAccountID ?? CONST.DEFAULT_NUMBER_ID; + const [cardFeeds, cardFeedsResult, defaultCardFeeds] = useCardFeeds(policyID); + const [lastSelectedFeed] = useOnyx(`${ONYXKEYS.COLLECTION.LAST_SELECTED_FEED}${policyID}`); + const [lastSelectedExpensifyCardFeed] = useOnyx(`${ONYXKEYS.COLLECTION.LAST_SELECTED_EXPENSIFY_CARD_FEED}${policyID}`); + const [cardsList, cardsListResult] = useOnyx(`${ONYXKEYS.COLLECTION.WORKSPACE_CARDS_LIST}${workspaceAccountID}_${CONST.EXPENSIFY_CARD.BANK}`, { + selector: filterInactiveCards, + }); + const {reportsToArchive, transactionViolations} = useTransactionViolationOfWorkspace(policyID); + const [accountIDToLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: accountIDToLoginSelector(reportsToArchive)}); + + const isLoadingData = isLoadingOnyxValue(policiesResult, ownedPaidPoliciesCountsResult, accountResult, amountOwedResult, privateSubscriptionResult, cardFeedsResult, cardsListResult); + + const hasCardFeedOrExpensifyCard = + !isEmptyObject(cardFeeds) || + !isEmptyObject(cardsList) || + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + ((policy?.areExpensifyCardsEnabled || policy?.areCompanyCardsEnabled) && policy?.policyAccountID); + const hasExpensifyCard = !!policy?.areExpensifyCardsEnabled && !isEmptyObject(cardsList); + const hasDeleteWorkspaceExpensifyCardsError = !!hasExpensifyCard && !!isOffline; + + const policyLatestErrorMessage = getLatestErrorMessage(policy); + const isPendingDelete = isPendingDeletePolicy(policy); + const prevIsPendingDeleteRef = useRef(isPendingDelete); + const isErrorModalShowingRef = useRef(false); + + const shouldCalculateBillNewDot = !!canDowngrade && ownedPaidPoliciesCounts?.total === 1; + const {shouldBlockDeletion, outstandingBalanceModal} = useOutstandingBalanceGuard(ownedPaidPoliciesCounts?.active ?? 0, onDismiss); + + // Always invoked after a re-render (from the start effect below for normal deletes, or from usePayAndDowngrade for billed deletes), + // so the workspace being deleted and its derived data are read from the latest state. + const continueDeleteWorkspace = () => { + const policyName = policy?.name; + + showConfirmModal({ + title: translate('workspace.common.delete'), + prompt: hasCardFeedOrExpensifyCard ? translate('workspace.common.deleteWithCardsConfirmation') : translate('workspace.common.deleteConfirmation'), + confirmText: translate('common.delete'), + cancelText: translate('common.cancel'), + danger: true, + isConfirmLoading: isPendingDelete, + }).then((result) => { + if (!policyName || result.action !== ModalActions.CONFIRM) { + onDismiss(); + return; + } + + deleteWorkspace({ + policies, + policyID, + activePolicyID, + policyName, + lastAccessedWorkspacePolicyID, + policyCardFeeds: defaultCardFeeds, + lastSelectedFeed, + lastSelectedExpensifyCardFeed, + reportsToArchive, + transactionViolations, + reimbursementAccountError, + lastUsedPaymentMethods: lastPaymentMethod, + localeCompare, + personalPolicyID, + hasDeleteWorkspaceExpensifyCardsError, + currentUserAccountID: currentUserPersonalDetails.accountID, + accountIDToLogin: accountIDToLogin ?? {}, + }); + if (isOffline) { + closeModal(); + if (!hasDeleteWorkspaceExpensifyCardsError) { + onDismiss(); + } + } + }); + }; + + const {setIsDeletingPaidWorkspace} = usePayAndDowngrade(continueDeleteWorkspace); + + // Runs the pre-deletion checks and opens the confirmation modal once all the Onyx data the flow depends on has loaded. + const hasStartedRef = useRef(false); + useEffect(() => { + if (hasStartedRef.current || isLoadingData) { + return; + } + hasStartedRef.current = true; + + if (shouldBlockWorkspaceDeletionForInvoicifyUser(isSubscriptionTypeOfInvoicing(privateSubscription?.type), policies, policyID, currentUserPersonalDetails?.accountID)) { + Navigation.navigate(ROUTES.SETTINGS_SUBSCRIPTION_DOWNGRADE_BLOCKED.getRoute(Navigation.getActiveRoute())); + onDismiss(); + return; + } + + if (shouldBlockDeletion()) { + // The outstanding balance modal is now visible and will call onDismiss when it is closed. + return; + } + + if (shouldCalculateBillNewDot) { + setIsDeletingPaidWorkspace(true); + calculateBillNewDot(); + return; + } + + continueDeleteWorkspace(); + }); + + const hideDeleteWorkspaceErrorModal = () => { + if (policy) { + dismissWorkspaceError(policy.id, policy.pendingAction); + } + onDismiss(); + }; + + useEffect(() => { + const prevIsPendingDelete = prevIsPendingDeleteRef.current; + prevIsPendingDeleteRef.current = isPendingDelete; + + // Handle showing error modal when offline and error occurs + if (isOffline && policyLatestErrorMessage) { + if (isErrorModalShowingRef.current) { + return; + } + isErrorModalShowingRef.current = true; + showConfirmModal({ + title: translate('workspace.common.delete'), + prompt: policyLatestErrorMessage, + confirmText: translate('common.buttonConfirm'), + cancelText: translate('common.cancel'), + success: false, + shouldShowCancelButton: false, + }).then(() => { + isErrorModalShowingRef.current = false; + hideDeleteWorkspaceErrorModal(); + }); + return; + } + + if (!prevIsPendingDelete || isPendingDelete) { + return; + } + closeModal(); + if (!isFocused || !policyLatestErrorMessage) { + // The deletion either succeeded or there is no error modal to show, so the flow is finished. + if (!isErrorModalShowingRef.current) { + onDismiss(); + } + return; + } + + if (isErrorModalShowingRef.current) { + return; + } + isErrorModalShowingRef.current = true; + showConfirmModal({ + title: translate('workspace.common.delete'), + prompt: policyLatestErrorMessage, + confirmText: translate('common.buttonConfirm'), + cancelText: translate('common.cancel'), + success: false, + shouldShowCancelButton: false, + }).then(() => { + isErrorModalShowingRef.current = false; + hideDeleteWorkspaceErrorModal(); + }); + }, [isOffline, hideDeleteWorkspaceErrorModal, showConfirmModal, translate, policyLatestErrorMessage, isPendingDelete, isFocused, closeModal, onDismiss]); + + return outstandingBalanceModal; +} + +export default DeleteWorkspaceFlow; diff --git a/src/selectors/Account.ts b/src/selectors/Account.ts index add99f027ed8..5d22d1cf5ad4 100644 --- a/src/selectors/Account.ts +++ b/src/selectors/Account.ts @@ -19,6 +19,8 @@ const mfaCredentialIDsSelector = (data: OnyxEntry) => data?.multifactor const isFromInternalDomainSelector = (account: OnyxEntry) => account?.isFromInternalDomain; +const canDowngradeSelector = (account: OnyxEntry) => !!account?.canDowngrade; + export { isActingAsDelegateSelector, delegateEmailSelector, @@ -29,4 +31,5 @@ export { accountGuideDetailsSelector, mfaCredentialIDsSelector, isFromInternalDomainSelector, + canDowngradeSelector, }; diff --git a/src/selectors/Policy.ts b/src/selectors/Policy.ts index cdad2624e3da..1b5020b405fc 100644 --- a/src/selectors/Policy.ts +++ b/src/selectors/Policy.ts @@ -2,7 +2,7 @@ import escapeRegExp from 'lodash/escapeRegExp'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {hasSynchronizationErrorMessage, isConnectionUnverified} from '@libs/actions/connections'; import {getDisplayNameForWorkspace} from '@libs/actions/Policy/Policy'; -import {getActiveAdminWorkspaces, getOwnedPaidPolicies, isPaidGroupPolicy} from '@libs/PolicyUtils'; +import {getActiveAdminWorkspaces, getOwnedPaidPolicies, isPaidGroupPolicy, isPendingDeletePolicy} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyReportField} from '@src/types/onyx'; @@ -17,6 +17,28 @@ const activePolicySelector = (policy: OnyxEntry) => (policy?.type !== CO const ownerPoliciesSelector = (policies: OnyxCollection, currentUserAccountID: number) => getOwnedPaidPolicies(policies, currentUserAccountID); +type OwnedPaidPoliciesCounts = { + /** Number of paid policies owned by the user */ + total: number; + + /** Number of owned paid policies that are not pending deletion */ + active: number; +}; + +/** + * Creates a selector returning only the counts of owned paid policies, so subscribers don't re-render + * when anything else on the policy collection changes. + */ +const createOwnedPaidPoliciesCountsSelector = + (currentUserAccountID: number | undefined) => + (policies: OnyxCollection): OwnedPaidPoliciesCounts => { + const ownedPaidPolicies = getOwnedPaidPolicies(policies, currentUserAccountID); + return { + total: ownedPaidPolicies.length, + active: ownedPaidPolicies.filter((policy) => !isPendingDeletePolicy(policy)).length, + }; + }; + const activeAdminPoliciesSelector = (policies: OnyxCollection, currentUserAccountLogin: string) => getActiveAdminWorkspaces(policies, currentUserAccountLogin); const hasActiveAdminPoliciesSelector = (policies: OnyxCollection, currentUserAccountLogin: string) => !!activeAdminPoliciesSelector(policies, currentUserAccountLogin).length; @@ -246,6 +268,7 @@ export { activePolicySelector, createAllPolicyReportFieldsSelector, ownerPoliciesSelector, + createOwnedPaidPoliciesCountsSelector, activeAdminPoliciesSelector, hasActiveAdminPoliciesSelector, createPoliciesForDomainCardsSelector, @@ -264,4 +287,4 @@ export { createAdminPoliciesSelector, isAdminForPolicyByIDSelector, }; -export type {ReusablePolicyConnectionName}; +export type {ReusablePolicyConnectionName, OwnedPaidPoliciesCounts}; From 9234fca0a5afe92f39e58a13d4793c595407ba5c Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Thu, 11 Jun 2026 14:43:52 +0200 Subject: [PATCH 2/3] extract WorkspaceRowThreeDotsMenu component from WorkspacesListPage --- .../LeaveWorkspaceAction.tsx | 113 +++++++ .../TransferOwnershipAction.tsx | 53 ++++ .../WorkspaceRowThreeDotsMenu.tsx | 195 ++++++++++++ .../WorkspaceListTable/WorkspaceTableRow.tsx | 48 ++- .../Tables/WorkspaceListTable/index.tsx | 18 +- src/pages/workspace/WorkspacesListPage.tsx | 277 ++---------------- src/selectors/PersonalDetails.ts | 22 ++ src/selectors/Policy.ts | 34 ++- 8 files changed, 478 insertions(+), 282 deletions(-) create mode 100644 src/components/Tables/WorkspaceListTable/LeaveWorkspaceAction.tsx create mode 100644 src/components/Tables/WorkspaceListTable/TransferOwnershipAction.tsx create mode 100644 src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx diff --git a/src/components/Tables/WorkspaceListTable/LeaveWorkspaceAction.tsx b/src/components/Tables/WorkspaceListTable/LeaveWorkspaceAction.tsx new file mode 100644 index 000000000000..c4eb441a53fb --- /dev/null +++ b/src/components/Tables/WorkspaceListTable/LeaveWorkspaceAction.tsx @@ -0,0 +1,113 @@ +import {useEffect, useRef} from 'react'; +import type {OnyxEntry} from 'react-native-onyx'; +import {ModalActions} from '@components/Modal/Global/ModalContext'; +import useConfirmModal from '@hooks/useConfirmModal'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import {close} from '@libs/actions/Modal'; +import {leaveWorkspace} from '@libs/actions/Policy/Policy'; +import {getConnectionExporters, isPolicyAdmin, isPolicyApprover, isPolicyAuditor} from '@libs/PolicyUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {PersonalDetailsList, Policy} from '@src/types/onyx'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +type LeaveWorkspaceActionProps = { + /** ID of the workspace being left */ + policyID: string; + + /** Called when the flow is finished or abandoned, so the parent can unmount this component */ + onDismiss: () => void; +}; + +const ownerDisplayNameSelector = (ownerAccountID: number) => (personalDetailsList: OnyxEntry) => personalDetailsList?.[ownerAccountID]?.displayName ?? ''; + +function isUserReimburserForPolicy(policy: OnyxEntry, userEmail: string | undefined): boolean { + return !!userEmail && policy?.achAccount?.reimburser === userEmail; +} + +/** + * Self-contained "leave workspace" flow, mounted only after the user picks Leave in the row menu. + * The full policy entry needed to build the confirmation prompt is subscribed to only for the + * lifetime of the flow, so the workspaces list rows don't re-render on every policy change. + */ +function LeaveWorkspaceAction({policyID, onDismiss}: LeaveWorkspaceActionProps) { + const {translate} = useLocalize(); + const {showConfirmModal} = useConfirmModal(); + const [session, sessionResult] = useOnyx(ONYXKEYS.SESSION); + const [policy, policyResult] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + const ownerAccountID = policy?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID; + const [policyOwnerDisplayName] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: ownerDisplayNameSelector(ownerAccountID)}, [ownerAccountID]); + + const isLoadingData = isLoadingOnyxValue(sessionResult, policyResult); + + const confirmModalPrompt = () => { + const userEmail = session?.email ?? ''; + const exporters = getConnectionExporters(policy); + + if (isUserReimburserForPolicy(policy, userEmail)) { + return translate('common.leaveWorkspaceReimburser'); + } + + if (policy?.technicalContact === userEmail) { + return translate('common.leaveWorkspaceConfirmationTechContact', policyOwnerDisplayName ?? ''); + } + + if (exporters.some((exporter) => exporter === userEmail)) { + return translate('common.leaveWorkspaceConfirmationExporter', policyOwnerDisplayName ?? ''); + } + + if (isPolicyApprover(policy, userEmail)) { + return translate('common.leaveWorkspaceConfirmationApprover', policyOwnerDisplayName ?? ''); + } + + if (isPolicyAdmin(policy)) { + return translate('common.leaveWorkspaceConfirmationAdmin'); + } + + if (isPolicyAuditor(policy)) { + return translate('common.leaveWorkspaceConfirmationAuditor'); + } + + return translate('common.leaveWorkspaceConfirmation'); + }; + + // Closes the row popover (if still open) and shows the confirmation modal once the policy entry has loaded. + const hasStartedRef = useRef(false); + useEffect(() => { + if (hasStartedRef.current || isLoadingData) { + return; + } + hasStartedRef.current = true; + + close(() => { + if (isUserReimburserForPolicy(policy, session?.email)) { + showConfirmModal({ + title: translate('common.leaveWorkspace'), + prompt: confirmModalPrompt(), + confirmText: translate('common.buttonConfirm'), + success: true, + shouldShowCancelButton: false, + }).then(() => onDismiss()); + return; + } + + showConfirmModal({ + title: translate('common.leaveWorkspace'), + prompt: confirmModalPrompt(), + confirmText: translate('common.leaveWorkspace'), + cancelText: translate('common.cancel'), + danger: true, + }).then((result) => { + if (result.action === ModalActions.CONFIRM && policy) { + leaveWorkspace(session?.accountID ?? CONST.DEFAULT_NUMBER_ID, session?.email ?? '', policy); + } + onDismiss(); + }); + }); + }); + + return null; +} + +export default LeaveWorkspaceAction; diff --git a/src/components/Tables/WorkspaceListTable/TransferOwnershipAction.tsx b/src/components/Tables/WorkspaceListTable/TransferOwnershipAction.tsx new file mode 100644 index 000000000000..94b03bba216a --- /dev/null +++ b/src/components/Tables/WorkspaceListTable/TransferOwnershipAction.tsx @@ -0,0 +1,53 @@ +import {useEffect, useRef} from 'react'; +import type {ValueOf} from 'type-fest'; +import useOnyx from '@hooks/useOnyx'; +import {clearWorkspaceOwnerChangeFlow, requestWorkspaceOwnerChange} from '@libs/actions/Policy/Member'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; + +type TransferOwnershipActionProps = { + /** ID of the workspace whose ownership is being transferred */ + policyID: string; + + /** Called once the ownership change flow has started, so the parent can unmount this component */ + onDismiss: () => void; +}; + +/** + * Kicks off the "transfer owner" flow, mounted only after the user picks Transfer owner in the row menu. + * The full policy entry needed by requestWorkspaceOwnerChange is subscribed to only for the moment + * the flow starts, so the workspaces list rows don't re-render on every policy change. + */ +function TransferOwnershipAction({policyID, onDismiss}: TransferOwnershipActionProps) { + const [session, sessionResult] = useOnyx(ONYXKEYS.SESSION); + const [policy, policyResult] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`); + + const isLoadingData = isLoadingOnyxValue(sessionResult, policyResult); + + const hasStartedRef = useRef(false); + useEffect(() => { + if (hasStartedRef.current || isLoadingData) { + return; + } + hasStartedRef.current = true; + + clearWorkspaceOwnerChangeFlow(policyID); + requestWorkspaceOwnerChange(policy, session?.accountID ?? CONST.DEFAULT_NUMBER_ID, session?.email ?? ''); + Navigation.navigate( + ROUTES.WORKSPACE_OWNER_CHANGE_CHECK.getRoute( + policyID, + session?.accountID ?? CONST.DEFAULT_NUMBER_ID, + 'amountOwed' as ValueOf, + Navigation.getActiveRoute(), + ), + ); + onDismiss(); + }); + + return null; +} + +export default TransferOwnershipAction; diff --git a/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx b/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx new file mode 100644 index 000000000000..c217599d5970 --- /dev/null +++ b/src/components/Tables/WorkspaceListTable/WorkspaceRowThreeDotsMenu.tsx @@ -0,0 +1,195 @@ +import {useIsFocused} from '@react-navigation/core'; +import React, {useEffect, useRef, useState} from 'react'; +import type {PopoverMenuItem} from '@components/PopoverMenu'; +import ThreeDotsMenu from '@components/ThreeDotsMenu'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import usePreferredPolicy from '@hooks/usePreferredPolicy'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {clearCopyPolicySettings} from '@libs/actions/Policy/CopyPolicySettings'; +import {callFunctionIfActionIsAllowed} from '@libs/actions/Session'; +import Navigation from '@libs/Navigation/Navigation'; +import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButton'; +import {setNameValuePair} from '@userActions/User'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import {canDowngradeSelector} from '@src/selectors/Account'; +import type {CopySettingsEligibleTargets} from '@src/selectors/Policy'; +import {createOwnedPaidPoliciesCountsSelector} from '@src/selectors/Policy'; +import type {WorkspaceRowData} from '.'; +import LeaveWorkspaceAction from './LeaveWorkspaceAction'; +import TransferOwnershipAction from './TransferOwnershipAction'; + +type ActiveAction = 'leave' | 'transferOwnership'; + +type WorkspaceRowThreeDotsMenuProps = { + /** The workspace row the menu is rendered for */ + item: WorkspaceRowData; + + /** Called when the user picks Delete, so the page can mount the delete flow */ + onDeleteWorkspace: (policyID: string) => void; + + /** ID of the workspace with a deletion in progress, if any */ + pendingDeletePolicyID?: string; + + /** IDs of the policies eligible as copy-settings targets */ + copySettingsEligibleTargets: CopySettingsEligibleTargets; +}; + +/** + * Three-dots menu of a workspaces list row. It builds its menu items locally from cheap, mostly + * primitive-valued subscriptions, and mounts the leave/transfer flows on demand so their heavier + * subscriptions (the full policy entry) exist only while the corresponding action is in progress. + */ +function WorkspaceRowThreeDotsMenu({item, onDeleteWorkspace, pendingDeletePolicyID, copySettingsEligibleTargets}: WorkspaceRowThreeDotsMenuProps) { + const threeDotsMenuRef = useRef<{hidePopoverMenu: () => void; isPopupMenuVisible: boolean}>(null); + const styles = useThemeStyles(); + const isFocused = useIsFocused(); + const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['Building', 'Exit', 'Plus', 'Copy', 'Star', 'Trashcan', 'Transfer']); + const [session] = useOnyx(ONYXKEYS.SESSION); + const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); + const {isBetaEnabled} = usePermissions(); + const {isRestrictedToPreferredPolicy, preferredPolicyID} = usePreferredPolicy(); + const [canRenderTransferOwnerButton] = useOnyx(ONYXKEYS.FUND_LIST, {selector: shouldRenderTransferOwnerButton}); + + // Primitive-valued subscriptions configuring the Delete menu item (popover behavior and the loading spinner) + // before a deletion starts. The deletion itself is handled by DeleteWorkspaceFlow, mounted by the page. + const [canDowngrade] = useOnyx(ONYXKEYS.ACCOUNT, {selector: canDowngradeSelector}); + const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); + const [isLoadingBill] = useOnyx(ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE); + const [ownedPaidPoliciesCounts] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createOwnedPaidPoliciesCountsSelector(session?.accountID)}, [session?.accountID]); + const shouldCalculateBillNewDot = !!canDowngrade && ownedPaidPoliciesCounts?.total === 1; + const wouldBlockDeletion = (amountOwed ?? 0) > 0 && ownedPaidPoliciesCounts?.active === 1; + + const [activeAction, setActiveAction] = useState(); + + useEffect(() => { + if (isLoadingBill) { + return; + } + + if (!threeDotsMenuRef.current?.isPopupMenuVisible) { + return; + } + threeDotsMenuRef?.current?.hidePopoverMenu(); + }, [isLoadingBill]); + + const isDefault = activePolicyID === item.policyID; + const isOwner = item.ownerAccountID === session?.accountID; + const isAdmin = item.role === CONST.POLICY.ROLE.ADMIN; + + const menuItems: PopoverMenuItem[] = [ + { + icon: icons.Building, + text: translate('workspace.common.goToWorkspace'), + onSelected: item.action, + }, + ]; + + if (!isOwner && (item.policyID !== preferredPolicyID || !isRestrictedToPreferredPolicy)) { + menuItems.push({ + icon: icons.Exit, + text: translate('common.leave'), + onSelected: callFunctionIfActionIsAllowed(() => setActiveAction('leave')), + }); + } + + if (isAdmin) { + menuItems.push({ + icon: icons.Plus, + text: translate('workspace.common.duplicateWorkspace'), + onSelected: () => (item.policyID ? Navigation.navigate(ROUTES.WORKSPACE_DUPLICATE.getRoute(item.policyID)) : undefined), + }); + const isSourceCorporate = item.type === CONST.POLICY.TYPE.CORPORATE; + const candidates = isSourceCorporate ? copySettingsEligibleTargets.corporateOnly : copySettingsEligibleTargets.adminNonPersonal; + const hasEligibleCopyTarget = candidates.length > 1 || (candidates.length === 1 && candidates.at(0) !== item.policyID); + + if (hasEligibleCopyTarget && isBetaEnabled(CONST.BETAS.BULK_EDIT_WORKSPACES)) { + menuItems.push({ + icon: icons.Copy, + text: translate('workspace.copyPolicySettings.title'), + onSelected: () => { + if (!item.policyID) { + return; + } + clearCopyPolicySettings(); + Navigation.navigate(ROUTES.POLICY_COPY_SETTINGS.getRoute(item.policyID)); + }, + }); + } + } + + if (!isDefault && !item?.isJoinRequestPending && !isRestrictedToPreferredPolicy) { + menuItems.push({ + icon: icons.Star, + text: translate('workspace.common.setAsDefault'), + onSelected: () => { + if (!item.policyID || !activePolicyID) { + return; + } + setNameValuePair(ONYXKEYS.NVP_ACTIVE_POLICY_ID, item.policyID, activePolicyID); + }, + }); + } + + if (isOwner) { + menuItems.push({ + icon: icons.Trashcan, + text: translate('workspace.common.delete'), + shouldShowLoadingSpinnerIcon: !!isLoadingBill && pendingDeletePolicyID === item.policyID, + onSelected: () => { + if (isLoadingBill) { + return; + } + + // All the pre-deletion checks and the confirmation modal are handled by DeleteWorkspaceFlow, mounted by the page. + onDeleteWorkspace(item.policyID); + }, + shouldKeepModalOpen: shouldCalculateBillNewDot && !wouldBlockDeletion, + shouldCallAfterModalHide: !shouldCalculateBillNewDot || wouldBlockDeletion, + }); + } + + if (isAdmin && !isOwner && canRenderTransferOwnerButton) { + menuItems.push({ + icon: icons.Transfer, + text: translate('workspace.people.transferOwner'), + onSelected: () => setActiveAction('transferOwnership'), + }); + } + + return ( + <> + + {activeAction === 'leave' && ( + setActiveAction(undefined)} + /> + )} + {activeAction === 'transferOwnership' && ( + setActiveAction(undefined)} + /> + )} + + ); +} + +export default WorkspaceRowThreeDotsMenu; diff --git a/src/components/Tables/WorkspaceListTable/WorkspaceTableRow.tsx b/src/components/Tables/WorkspaceListTable/WorkspaceTableRow.tsx index ccca940ab267..152c46768a44 100644 --- a/src/components/Tables/WorkspaceListTable/WorkspaceTableRow.tsx +++ b/src/components/Tables/WorkspaceListTable/WorkspaceTableRow.tsx @@ -1,5 +1,4 @@ -import {useIsFocused} from '@react-navigation/core'; -import React, {useEffect, useRef} from 'react'; +import React from 'react'; import {View} from 'react-native'; import Avatar from '@components/Avatar'; import Badge from '@components/Badge'; @@ -7,7 +6,6 @@ import Icon from '@components/Icon'; import Table from '@components/Table'; import Text from '@components/Text'; import TextWithTooltip from '@components/TextWithTooltip'; -import ThreeDotsMenu from '@components/ThreeDotsMenu'; import Tooltip from '@components/Tooltip'; import WorkspacesListRowDisplayName from '@components/WorkspacesListRowDisplayName'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; @@ -17,7 +15,9 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {getUserFriendlyWorkspaceType} from '@libs/PolicyUtils'; import variables from '@styles/variables'; import CONST from '@src/CONST'; +import type {CopySettingsEligibleTargets} from '@src/selectors/Policy'; import type {WorkspaceRowData} from '.'; +import WorkspaceRowThreeDotsMenu from './WorkspaceRowThreeDotsMenu'; type WorkspaceRowProps = { /** The workspace data */ @@ -28,18 +28,23 @@ type WorkspaceRowProps = { /** Whether to use narrow table row layout */ shouldUseNarrowTableLayout: boolean; -}; -export default function WorkspaceRow({item, shouldUseNarrowTableLayout, rowIndex}: WorkspaceRowProps) { - const threeDotsMenuRef = useRef<{hidePopoverMenu: () => void; isPopupMenuVisible: boolean}>(null); + /** Called when the user picks Delete in the row menu, so the page can mount the delete flow */ + onDeleteWorkspace: (policyID: string) => void; + + /** ID of the workspace with a deletion in progress, if any */ + pendingDeletePolicyID?: string; + + /** IDs of the policies eligible as copy-settings targets, passed down to the row menu */ + copySettingsEligibleTargets: CopySettingsEligibleTargets; +}; +export default function WorkspaceRow({item, shouldUseNarrowTableLayout, rowIndex, onDeleteWorkspace, pendingDeletePolicyID, copySettingsEligibleTargets}: WorkspaceRowProps) { const theme = useTheme(); const styles = useThemeStyles(); - const isFocused = useIsFocused(); const {translate} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'Building', 'FallbackWorkspaceAvatar', 'DotIndicator', 'Hourglass']); - const isLoadingBill = item.isLoadingBill; const formattedOwnerName = item.ownerName ?? ''; const formattedWorkspaceType = getUserFriendlyWorkspaceType(item.type, translate); const narrowWorkspaceLabel = `${translate('common.owner')}: ${formattedOwnerName} • ${formattedWorkspaceType}`; @@ -96,32 +101,15 @@ export default function WorkspaceRow({item, shouldUseNarrowTableLayout, rowIndex const ThreeDotsMenuWithBrickRoadIndicator = ( {item.brickRoadIndicator && BrickRoadIndicator} - ); - useEffect(() => { - if (isLoadingBill) { - return; - } - - if (!threeDotsMenuRef.current?.isPopupMenuVisible) { - return; - } - threeDotsMenuRef?.current?.hidePopoverMenu(); - }, [isLoadingBill]); - return ( ; role: ValueOf; iconType: typeof CONST.ICON_TYPE_AVATAR | typeof CONST.ICON_TYPE_ICON; @@ -45,9 +43,18 @@ type WorkspaceRowData = TableData & { type WorkspaceListTableProps = { ref?: React.Ref> | undefined; workspaces: WorkspaceRowData[]; + + /** Called when the user picks Delete in a row menu, so the page can mount the delete flow */ + onDeleteWorkspace: (policyID: string) => void; + + /** ID of the workspace with a deletion in progress, if any */ + pendingDeletePolicyID?: string; + + /** IDs of the policies eligible as copy-settings targets, passed down to the row menus */ + copySettingsEligibleTargets: CopySettingsEligibleTargets; }; -export default function WorkspaceListTable({ref, workspaces}: WorkspaceListTableProps) { +export default function WorkspaceListTable({ref, workspaces, onDeleteWorkspace, pendingDeletePolicyID, copySettingsEligibleTargets}: WorkspaceListTableProps) { const styles = useThemeStyles(); const {translate, localeCompare} = useLocalize(); const {shouldUseNarrowLayout, isMediumScreenWidth} = useResponsiveLayout(); @@ -106,6 +113,9 @@ export default function WorkspaceListTable({ref, workspaces}: WorkspaceListTable item={item} rowIndex={index} shouldUseNarrowTableLayout={shouldUseNarrowTableLayout} + onDeleteWorkspace={onDeleteWorkspace} + pendingDeletePolicyID={pendingDeletePolicyID} + copySettingsEligibleTargets={copySettingsEligibleTargets} /> ); }; diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 4a217aa6ee0c..6e0721540b8f 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -1,36 +1,25 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; -import React, {useEffect, useMemo, useRef, useState} from 'react'; +import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; import type {OnyxEntry} from 'react-native-onyx'; -import type {ValueOf} from 'type-fest'; import ActivityIndicator from '@components/ActivityIndicator'; import Button from '@components/Button'; -import {ModalActions} from '@components/Modal/Global/ModalContext'; -import {usePersonalDetails} from '@components/OnyxListItemProvider'; -import type {PopoverMenuItem} from '@components/PopoverMenu'; import type {TableHandle} from '@components/Table'; import type {WorkspaceRowData, WorkspaceTableColumnKey} from '@components/Tables/WorkspaceListTable'; import WorkspaceListTable from '@components/Tables/WorkspaceListTable'; import WorkspaceListLayout from '@components/WorkspaceListLayout'; import useAndroidBackButtonHandler from '@hooks/useAndroidBackButtonHandler'; -import useConfirmModal from '@hooks/useConfirmModal'; -import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDocumentTitle from '@hooks/useDocumentTitle'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import usePermissions from '@hooks/usePermissions'; import usePoliciesWithCardFeedErrors from '@hooks/usePoliciesWithCardFeedErrors'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; import {isConnectionInProgress} from '@libs/actions/connections'; -import {close} from '@libs/actions/Modal'; -import {clearCopyPolicySettings} from '@libs/actions/Policy/CopyPolicySettings'; -import {clearWorkspaceOwnerChangeFlow, requestWorkspaceOwnerChange} from '@libs/actions/Policy/Member'; -import {clearDuplicateWorkspace, dismissWorkspaceError, leaveWorkspace} from '@libs/actions/Policy/Policy'; -import {callFunctionIfActionIsAllowed} from '@libs/actions/Session'; +import {clearDuplicateWorkspace, dismissWorkspaceError} from '@libs/actions/Policy/Policy'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import openInternalRouteInNewTab from '@libs/Navigation/helpers/openInternalRouteInNewTab'; import type {ModifiedMouseEvent} from '@libs/Navigation/helpers/openInternalRouteInNewTab'; @@ -38,55 +27,41 @@ import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigation/types'; import TransitionTracker from '@libs/Navigation/TransitionTracker'; import type {WorkspaceNavigatorParamList} from '@libs/Navigation/types'; -import {getDisplayNameOrDefault, getPersonalDetailsByIDs} from '@libs/PersonalDetailsUtils'; +import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import { - getConnectionExporters, getPolicyBrickRoadIndicatorStatus, getUberConnectionErrorDirectlyFromPolicy, isPendingDeletePolicy, isPolicyAdmin, - isPolicyApprover, - isPolicyAuditor, shouldShowEmployeeListError, shouldShowPolicy, } from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; -import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButton'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; -import {setNameValuePair} from '@userActions/User'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import {canDowngradeSelector} from '@src/selectors/Account'; -import {createOwnedPaidPoliciesCountsSelector} from '@src/selectors/Policy'; +import {createDisplayDetailsByAccountIDsSelector} from '@src/selectors/PersonalDetails'; +import type {CopySettingsEligibleTargets} from '@src/selectors/Policy'; +import {createCopySettingsEligibleTargetsSelector} from '@src/selectors/Policy'; import type {Policy as PolicyType} from '@src/types/onyx'; import type {PolicyDetailsForNonMembers} from '@src/types/onyx/Policy'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import CopyPolicySettingsProgressModal from './copyPolicySettings/CopyPolicySettingsProgressModal'; import DeleteWorkspaceFlow from './deleteWorkspace/DeleteWorkspaceFlow'; -function isUserReimburserForPolicy(policies: Record | undefined, policyID: string | undefined, userEmail: string | undefined): boolean { - if (!policies || !policyID || !userEmail) { - return false; - } - const policy = policies[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; - if (!policy) { - return false; - } - return policy.achAccount?.reimburser === userEmail; -} +const EMPTY_COPY_SETTINGS_ELIGIBLE_TARGETS: CopySettingsEligibleTargets = {adminNonPersonal: [], corporateOnly: []}; function WorkspacesListPage() { const tableRef = useRef>(null); - const icons = useMemoizedLazyExpensifyIcons(['Building', 'Exit', 'Copy', 'Star', 'Trashcan', 'Transfer', 'FallbackWorkspaceAvatar', 'Plus']); + const icons = useMemoizedLazyExpensifyIcons(['Plus']); const styles = useThemeStyles(); const {translate} = useLocalize(); useDocumentTitle(translate('common.workspaces')); const {isOffline} = useNetwork(); const isFocused = useIsFocused(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const currentUserPersonalDetails = useCurrentUserPersonalDetails(); const [allConnectionSyncProgresses] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS); const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); @@ -95,222 +70,33 @@ function WorkspacesListPage() { const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); const shouldShowLoadingIndicator = isLoadingApp && !isOffline; const route = useRoute>(); - const [fundList] = useOnyx(ONYXKEYS.FUND_LIST); - const {isBetaEnabled} = usePermissions(); - const {isRestrictedToPreferredPolicy, preferredPolicyID, isRestrictedPolicyCreation} = usePreferredPolicy(); + const {isRestrictedPolicyCreation} = usePreferredPolicy(); const [duplicateWorkspace] = useOnyx(ONYXKEYS.DUPLICATE_WORKSPACE); - const {showConfirmModal} = useConfirmModal(); - const personalDetails = usePersonalDetails(); - - // Primitive-valued subscriptions configuring the Delete menu item (popover behavior and the loading spinner) - // before a deletion starts. The deletion itself is handled by DeleteWorkspaceFlow, mounted on demand below. - const [canDowngrade] = useOnyx(ONYXKEYS.ACCOUNT, {selector: canDowngradeSelector}); - const [amountOwed] = useOnyx(ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED); - const [isLoadingBill] = useOnyx(ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE); - const [ownedPaidPoliciesCounts] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createOwnedPaidPoliciesCountsSelector(currentUserPersonalDetails.accountID)}, [ - currentUserPersonalDetails.accountID, - ]); - const shouldCalculateBillNewDot = !!canDowngrade && ownedPaidPoliciesCounts?.total === 1; - const wouldBlockDeletion = (amountOwed ?? 0) > 0 && ownedPaidPoliciesCounts?.active === 1; - - const [policyIDToDelete, setPolicyIDToDelete] = useState(); - - const confirmLeaveAndHideModal = (policyToLeave: PolicyType | undefined) => { - if (!policyToLeave) { - return; - } - - leaveWorkspace(currentUserPersonalDetails.accountID, currentUserPersonalDetails?.email ?? '', policyToLeave); - }; - - const confirmModalPrompt = (policyToLeave: PolicyType | undefined) => { - const exporters = getConnectionExporters(policyToLeave); - const userEmail = currentUserPersonalDetails?.email ?? ''; - const policyOwnerDisplayName = personalDetails?.[policyToLeave?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID]?.displayName ?? ''; - const technicalContact = policyToLeave?.technicalContact; - const isCurrentUserReimburser = isUserReimburserForPolicy(policies, policyToLeave?.id, userEmail); - const isApprover = isPolicyApprover(policyToLeave, userEmail); - - if (isCurrentUserReimburser) { - return translate('common.leaveWorkspaceReimburser'); - } - - if (technicalContact === userEmail) { - return translate('common.leaveWorkspaceConfirmationTechContact', policyOwnerDisplayName); - } - - if (exporters.some((exporter) => exporter === userEmail)) { - return translate('common.leaveWorkspaceConfirmationExporter', policyOwnerDisplayName); - } - - if (isApprover) { - return translate('common.leaveWorkspaceConfirmationApprover', policyOwnerDisplayName); - } - - if (isPolicyAdmin(policyToLeave)) { - return translate('common.leaveWorkspaceConfirmationAdmin'); - } - - if (isPolicyAuditor(policyToLeave)) { - return translate('common.leaveWorkspaceConfirmationAuditor'); - } - - return translate('common.leaveWorkspaceConfirmation'); - }; - const startChangeOwnershipFlow = (policyID: string | undefined) => { - if (!policyID) { - return; - } + // Light projection of the policy collection passed down to the row menus, which compute their copy-settings + // eligibility from it. Kept at the page level so it is evaluated once per policy write instead of once per row. + const [copySettingsEligibleTargets] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createCopySettingsEligibleTargetsSelector(session?.email)}, [session?.email]); - clearWorkspaceOwnerChangeFlow(policyID); - requestWorkspaceOwnerChange(policies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`], currentUserPersonalDetails.accountID, currentUserPersonalDetails.login ?? ''); - Navigation.navigate( - ROUTES.WORKSPACE_OWNER_CHANGE_CHECK.getRoute( - policyID, - currentUserPersonalDetails.accountID, - 'amountOwed' as ValueOf, - Navigation.getActiveRoute(), - ), - ); - }; + const [policyIDToDelete, setPolicyIDToDelete] = useState(); - const copySettingsEligibleTargets = useMemo(() => { - const adminNonPersonal: string[] = []; - const corporateOnly: string[] = []; - if (!policies) { - return {adminNonPersonal, corporateOnly}; - } + // Narrow subscription keeping the owner name/avatar columns reactive without re-rendering the page + // when anything else in the personal details list changes. + const ownerAccountIDs: number[] = []; + if (!isEmptyObject(policies)) { + const uniqueOwnerAccountIDs = new Set(); for (const policy of Object.values(policies)) { - if (!policy || policy.type === CONST.POLICY.TYPE.PERSONAL || !isPolicyAdmin(policy, session?.email) || isPendingDeletePolicy(policy)) { + if (!policy || !shouldShowPolicy(policy, true, session?.email)) { continue; } - adminNonPersonal.push(policy.id); - if (policy.type === CONST.POLICY.TYPE.CORPORATE) { - corporateOnly.push(policy.id); + const ownerAccountID = + policy.isJoinRequestPending && policy.policyDetailsForNonMembers ? Object.values(policy.policyDetailsForNonMembers).at(0)?.ownerAccountID : policy.ownerAccountID; + if (ownerAccountID) { + uniqueOwnerAccountIDs.add(ownerAccountID); } } - return {adminNonPersonal, corporateOnly}; - }, [policies, session?.email]); - - /** - * Gets the menu item for each workspace - */ - const getThreeDotMenuItems = (item: WorkspaceRowData) => { - const isDefault = activePolicyID === item.policyID; - const isOwner = item.ownerAccountID === session?.accountID; - const isAdmin = isPolicyAdmin(item as unknown as PolicyType, session?.email); - - const threeDotsMenuItems: PopoverMenuItem[] = [ - { - icon: icons.Building, - text: translate('workspace.common.goToWorkspace'), - onSelected: item.action, - }, - ]; - - if (!isOwner && (item.policyID !== preferredPolicyID || !isRestrictedToPreferredPolicy)) { - threeDotsMenuItems.push({ - icon: icons.Exit, - text: translate('common.leave'), - onSelected: callFunctionIfActionIsAllowed(() => { - close(() => { - const policyToLeave = policies?.[`${ONYXKEYS.COLLECTION.POLICY}${item.policyID}`]; - const isReimburser = isUserReimburserForPolicy(policies, item.policyID, session?.email); - - if (isReimburser) { - showConfirmModal({ - title: translate('common.leaveWorkspace'), - prompt: confirmModalPrompt(policyToLeave), - confirmText: translate('common.buttonConfirm'), - success: true, - shouldShowCancelButton: false, - }); - return; - } - - showConfirmModal({ - title: translate('common.leaveWorkspace'), - prompt: confirmModalPrompt(policyToLeave), - confirmText: translate('common.leaveWorkspace'), - cancelText: translate('common.cancel'), - danger: true, - }).then((result) => { - if (result.action !== ModalActions.CONFIRM) { - return; - } - confirmLeaveAndHideModal(policyToLeave); - }); - }); - }), - }); - } - - if (isAdmin) { - threeDotsMenuItems.push({ - icon: icons.Plus, - text: translate('workspace.common.duplicateWorkspace'), - onSelected: () => (item.policyID ? Navigation.navigate(ROUTES.WORKSPACE_DUPLICATE.getRoute(item.policyID)) : undefined), - }); - const isSourceCorporate = item.type === CONST.POLICY.TYPE.CORPORATE; - const candidates = isSourceCorporate ? copySettingsEligibleTargets.corporateOnly : copySettingsEligibleTargets.adminNonPersonal; - const hasEligibleCopyTarget = candidates.length > 1 || (candidates.length === 1 && candidates.at(0) !== item.policyID); - - if (hasEligibleCopyTarget && isBetaEnabled(CONST.BETAS.BULK_EDIT_WORKSPACES)) { - threeDotsMenuItems.push({ - icon: icons.Copy, - text: translate('workspace.copyPolicySettings.title'), - onSelected: () => { - if (!item.policyID) { - return; - } - clearCopyPolicySettings(); - Navigation.navigate(ROUTES.POLICY_COPY_SETTINGS.getRoute(item.policyID)); - }, - }); - } - } - - if (!isDefault && !item?.isJoinRequestPending && !isRestrictedToPreferredPolicy) { - threeDotsMenuItems.push({ - icon: icons.Star, - text: translate('workspace.common.setAsDefault'), - onSelected: () => { - if (!item.policyID || !activePolicyID) { - return; - } - setNameValuePair(ONYXKEYS.NVP_ACTIVE_POLICY_ID, item.policyID, activePolicyID); - }, - }); - } - if (isOwner) { - threeDotsMenuItems.push({ - icon: icons.Trashcan, - text: translate('workspace.common.delete'), - shouldShowLoadingSpinnerIcon: !!isLoadingBill && policyIDToDelete === item.policyID, - onSelected: () => { - if (isLoadingBill) { - return; - } - - // All the pre-deletion checks and the confirmation modal are handled by DeleteWorkspaceFlow, which mounts when this is set. - setPolicyIDToDelete(item.policyID); - }, - shouldKeepModalOpen: shouldCalculateBillNewDot && !wouldBlockDeletion, - shouldCallAfterModalHide: !shouldCalculateBillNewDot || wouldBlockDeletion, - }); - } - - if (isAdmin && !isOwner && shouldRenderTransferOwnerButton(fundList)) { - threeDotsMenuItems.push({ - icon: icons.Transfer, - text: translate('workspace.people.transferOwner'), - onSelected: () => startChangeOwnershipFlow(item.policyID), - }); - } - - return threeDotsMenuItems; - }; + ownerAccountIDs.push(...uniqueOwnerAccountIDs); + } + const [ownerDisplayDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: createDisplayDetailsByAccountIDsSelector(ownerAccountIDs)}, [policies, session?.email]); const navigateToWorkspace = (policyID: string, event?: ModifiedMouseEvent) => { const workspaceRoute = shouldUseNarrowLayout ? ROUTES.WORKSPACE_INITIAL.getRoute(policyID) : ROUTES.WORKSPACE_OVERVIEW.getRoute(policyID); @@ -369,7 +155,7 @@ function WorkspacesListPage() { const policyInfo = Object.values(policy.policyDetailsForNonMembers).at(0) as PolicyDetailsForNonMembers; const policyOwnerAccountID = policyInfo.ownerAccountID; - const ownerDetails = policyOwnerAccountID && getPersonalDetailsByIDs({accountIDs: [policyOwnerAccountID], currentUserAccountID: currentUserPersonalDetails.accountID}).at(0); + const ownerDetails = policyOwnerAccountID ? ownerDisplayDetails?.[policyOwnerAccountID] : undefined; const pendingWorkspaceRow: WorkspaceRowData = { keyForList: policyID, @@ -380,7 +166,6 @@ function WorkspacesListPage() { title: policyInfo.name, role: CONST.POLICY.ROLE.USER, isDeleted: false, - isLoadingBill: !!isLoadingBill, isJoinRequestPending: true, isDefault: activePolicyID === policyID, shouldAnimateInHighlight: duplicateWorkspace?.policyID === policyID, @@ -394,11 +179,10 @@ function WorkspacesListPage() { dismissError: () => null, }; - pendingWorkspaceRow.threeDotMenuItems = getThreeDotMenuItems(pendingWorkspaceRow); workspaceRows.push(pendingWorkspaceRow); } else { const policyOwnerAccountID = policy.ownerAccountID; - const ownerDetails = policyOwnerAccountID && getPersonalDetailsByIDs({accountIDs: [policyOwnerAccountID], currentUserAccountID: currentUserPersonalDetails.accountID}).at(0); + const ownerDetails = policyOwnerAccountID ? ownerDisplayDetails?.[policyOwnerAccountID] : undefined; const workspaceRow: WorkspaceRowData = { keyForList: policy.id, @@ -409,7 +193,6 @@ function WorkspacesListPage() { title: policy.name, role: policy.role, ownerAccountID: policyOwnerAccountID, - isLoadingBill: !!isLoadingBill, isJoinRequestPending: false, shouldAnimateInHighlight: duplicateWorkspace?.policyID === policy.id, isDefault: activePolicyID === policy.id, @@ -425,7 +208,6 @@ function WorkspacesListPage() { dismissError: () => dismissWorkspaceError(policy.id, policy.pendingAction), }; - workspaceRow.threeDotMenuItems = getThreeDotMenuItems(workspaceRow); workspaceRows.push(workspaceRow); } } @@ -491,6 +273,9 @@ function WorkspacesListPage() { )} {!!policyIDToDelete && ( diff --git a/src/selectors/PersonalDetails.ts b/src/selectors/PersonalDetails.ts index 1171756a1606..547960e95b39 100644 --- a/src/selectors/PersonalDetails.ts +++ b/src/selectors/PersonalDetails.ts @@ -22,6 +22,26 @@ const personalDetailByAccountIDSelector = const conciergePersonalDetailSelector = personalDetailByAccountIDSelector(CONST.ACCOUNT_ID.CONCIERGE); +type DisplayDetails = Pick; + +/** + * Creates a selector returning only the display details (name, login, avatar) of the given accounts, + * so subscribers don't re-render when anything else in the personal details list changes. + */ +const createDisplayDetailsByAccountIDsSelector = + (accountIDs: number[]) => + (personalDetailsList: OnyxEntry): Record => { + const result: Record = {}; + for (const accountID of accountIDs) { + const detail = personalDetailsList?.[accountID]; + if (!detail) { + continue; + } + result[accountID] = {accountID: detail.accountID, displayName: detail.displayName, login: detail.login, avatar: detail.avatar}; + } + return result; + }; + const accountIDToLoginSelector = (reportsToArchive: Report[]) => (personalDetailsList: OnyxEntry) => { const map: Record = {}; for (const report of reportsToArchive) { @@ -42,4 +62,6 @@ export { personalDetailByAccountIDSelector, conciergePersonalDetailSelector, accountIDToLoginSelector, + createDisplayDetailsByAccountIDsSelector, }; +export type {DisplayDetails}; diff --git a/src/selectors/Policy.ts b/src/selectors/Policy.ts index 1b5020b405fc..c56b718c7556 100644 --- a/src/selectors/Policy.ts +++ b/src/selectors/Policy.ts @@ -2,7 +2,7 @@ import escapeRegExp from 'lodash/escapeRegExp'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {hasSynchronizationErrorMessage, isConnectionUnverified} from '@libs/actions/connections'; import {getDisplayNameForWorkspace} from '@libs/actions/Policy/Policy'; -import {getActiveAdminWorkspaces, getOwnedPaidPolicies, isPaidGroupPolicy, isPendingDeletePolicy} from '@libs/PolicyUtils'; +import {getActiveAdminWorkspaces, getOwnedPaidPolicies, isPaidGroupPolicy, isPendingDeletePolicy, isPolicyAdmin} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyReportField} from '@src/types/onyx'; @@ -39,6 +39,35 @@ const createOwnedPaidPoliciesCountsSelector = }; }; +type CopySettingsEligibleTargets = { + /** IDs of non-personal policies administered by the user that can be copy-settings targets */ + adminNonPersonal: string[]; + + /** Subset of adminNonPersonal limited to corporate (Control) policies */ + corporateOnly: string[]; +}; + +/** + * Creates a selector returning only the policy IDs eligible as copy-settings targets, + * so subscribers don't re-render when anything else on the policy collection changes. + */ +const createCopySettingsEligibleTargetsSelector = + (currentUserLogin: string | undefined) => + (policies: OnyxCollection): CopySettingsEligibleTargets => { + const adminNonPersonal: string[] = []; + const corporateOnly: string[] = []; + for (const policy of Object.values(policies ?? {})) { + if (!policy || policy.type === CONST.POLICY.TYPE.PERSONAL || !isPolicyAdmin(policy, currentUserLogin) || isPendingDeletePolicy(policy)) { + continue; + } + adminNonPersonal.push(policy.id); + if (policy.type === CONST.POLICY.TYPE.CORPORATE) { + corporateOnly.push(policy.id); + } + } + return {adminNonPersonal, corporateOnly}; + }; + const activeAdminPoliciesSelector = (policies: OnyxCollection, currentUserAccountLogin: string) => getActiveAdminWorkspaces(policies, currentUserAccountLogin); const hasActiveAdminPoliciesSelector = (policies: OnyxCollection, currentUserAccountLogin: string) => !!activeAdminPoliciesSelector(policies, currentUserAccountLogin).length; @@ -269,6 +298,7 @@ export { createAllPolicyReportFieldsSelector, ownerPoliciesSelector, createOwnedPaidPoliciesCountsSelector, + createCopySettingsEligibleTargetsSelector, activeAdminPoliciesSelector, hasActiveAdminPoliciesSelector, createPoliciesForDomainCardsSelector, @@ -287,4 +317,4 @@ export { createAdminPoliciesSelector, isAdminForPolicyByIDSelector, }; -export type {ReusablePolicyConnectionName, OwnedPaidPoliciesCounts}; +export type {ReusablePolicyConnectionName, OwnedPaidPoliciesCounts, CopySettingsEligibleTargets}; From ff9584985e57a5c23d5dde7561be9b7872fcda50 Mon Sep 17 00:00:00 2001 From: Wojciech Boman Date: Thu, 11 Jun 2026 15:23:09 +0200 Subject: [PATCH 3/3] extract WorkspaceRowBrickRoadIndicator component from WorkspacesListPage --- .../WorkspaceRowBrickRoadIndicator.tsx | 64 ++++++ .../WorkspaceListTable/WorkspaceTableRow.tsx | 14 +- .../Tables/WorkspaceListTable/index.tsx | 1 - src/pages/workspace/WorkspacesListPage.tsx | 192 ++++++------------ src/selectors/Policy.ts | 64 +++++- src/selectors/ReimbursementAccount.ts | 6 +- 6 files changed, 199 insertions(+), 142 deletions(-) create mode 100644 src/components/Tables/WorkspaceListTable/WorkspaceRowBrickRoadIndicator.tsx diff --git a/src/components/Tables/WorkspaceListTable/WorkspaceRowBrickRoadIndicator.tsx b/src/components/Tables/WorkspaceListTable/WorkspaceRowBrickRoadIndicator.tsx new file mode 100644 index 000000000000..9eeb9327a44d --- /dev/null +++ b/src/components/Tables/WorkspaceListTable/WorkspaceRowBrickRoadIndicator.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import {View} from 'react-native'; +import type {OnyxEntry} from 'react-native-onyx'; +import Icon from '@components/Icon'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; +import useOnyx from '@hooks/useOnyx'; +import useTheme from '@hooks/useTheme'; +import useThemeStyles from '@hooks/useThemeStyles'; +import useWorkspaceAccountID from '@hooks/useWorkspaceAccountID'; +import {isConnectionInProgress} from '@libs/actions/connections'; +import {getPolicyBrickRoadIndicatorStatus, getUberConnectionErrorDirectlyFromPolicy, shouldShowEmployeeListError} from '@libs/PolicyUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import {hasReimbursementAccountErrorsSelector} from '@src/selectors/ReimbursementAccount'; +import type {Policy, PolicyConnectionSyncProgress} from '@src/types/onyx'; +import type {CardFeedErrors} from '@src/types/onyx/DerivedValues'; + +type WorkspaceRowBrickRoadIndicatorProps = { + /** ID of the policy the row represents */ + policyID: string; +}; + +const createCardFeedErrorsSelector = (workspaceAccountID: number) => (cardFeedErrors: OnyxEntry) => + !!cardFeedErrors?.shouldShowRbrForWorkspaceAccountID?.[workspaceAccountID]; + +const createPolicyErrorsSelector = (connectionSyncProgress: OnyxEntry) => (policy: OnyxEntry) => + getUberConnectionErrorDirectlyFromPolicy(policy) || + shouldShowEmployeeListError(policy) || + getPolicyBrickRoadIndicatorStatus(policy, isConnectionInProgress(connectionSyncProgress, policy)) === CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; + +/** + * Error indicator of a workspaces list row. All of its subscriptions are narrow (booleans or a single + * small entry), so a policy write re-evaluates only this row's selectors and re-renders at most this + * one component instead of committing the whole workspaces list page. + */ +function WorkspaceRowBrickRoadIndicator({policyID}: WorkspaceRowBrickRoadIndicatorProps) { + const theme = useTheme(); + const styles = useThemeStyles(); + const icons = useMemoizedLazyExpensifyIcons(['DotIndicator']); + const workspaceAccountID = useWorkspaceAccountID(policyID); + const [hasReimbursementAccountErrors] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT, {selector: hasReimbursementAccountErrorsSelector}); + const [hasCardFeedErrors] = useOnyx(ONYXKEYS.DERIVED.CARD_FEED_ERRORS, {selector: createCardFeedErrorsSelector(workspaceAccountID)}, [workspaceAccountID]); + const [connectionSyncProgress] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policyID}`); + const [hasPolicyErrors] = useOnyx(`${ONYXKEYS.COLLECTION.POLICY}${policyID}`, {selector: createPolicyErrorsSelector(connectionSyncProgress)}, [connectionSyncProgress]); + + // Every branch of the page-level brick road logic this replaces resolved to ERROR or undefined, + // so a boolean OR preserves its semantics. + const hasError = !!hasReimbursementAccountErrors || !!hasCardFeedErrors || !!hasPolicyErrors; + + if (!hasError) { + return null; + } + + return ( + + + + ); +} + +export default WorkspaceRowBrickRoadIndicator; diff --git a/src/components/Tables/WorkspaceListTable/WorkspaceTableRow.tsx b/src/components/Tables/WorkspaceListTable/WorkspaceTableRow.tsx index 152c46768a44..9aaef3720108 100644 --- a/src/components/Tables/WorkspaceListTable/WorkspaceTableRow.tsx +++ b/src/components/Tables/WorkspaceListTable/WorkspaceTableRow.tsx @@ -17,6 +17,7 @@ import variables from '@styles/variables'; import CONST from '@src/CONST'; import type {CopySettingsEligibleTargets} from '@src/selectors/Policy'; import type {WorkspaceRowData} from '.'; +import WorkspaceRowBrickRoadIndicator from './WorkspaceRowBrickRoadIndicator'; import WorkspaceRowThreeDotsMenu from './WorkspaceRowThreeDotsMenu'; type WorkspaceRowProps = { @@ -43,7 +44,7 @@ export default function WorkspaceRow({item, shouldUseNarrowTableLayout, rowIndex const theme = useTheme(); const styles = useThemeStyles(); const {translate} = useLocalize(); - const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'Building', 'FallbackWorkspaceAvatar', 'DotIndicator', 'Hourglass']); + const icons = useMemoizedLazyExpensifyIcons(['ArrowRight', 'Building', 'FallbackWorkspaceAvatar', 'Hourglass']); const formattedOwnerName = item.ownerName ?? ''; const formattedWorkspaceType = getUserFriendlyWorkspaceType(item.type, translate); @@ -60,15 +61,6 @@ export default function WorkspaceRow({item, shouldUseNarrowTableLayout, rowIndex .filter(Boolean) .join(', '); - const BrickRoadIndicator = !!item.brickRoadIndicator && ( - - - - ); - const JoinRequestPendingBadge = ( - {item.brickRoadIndicator && BrickRoadIndicator} + {item.role === CONST.POLICY.ROLE.ADMIN && } ; action: (event?: ModifiedMouseEvent) => void; dismissError: () => void; }; diff --git a/src/pages/workspace/WorkspacesListPage.tsx b/src/pages/workspace/WorkspacesListPage.tsx index 6e0721540b8f..a6f5010150a4 100755 --- a/src/pages/workspace/WorkspacesListPage.tsx +++ b/src/pages/workspace/WorkspacesListPage.tsx @@ -1,7 +1,6 @@ import {useIsFocused, useRoute} from '@react-navigation/native'; import React, {useEffect, useRef, useState} from 'react'; import {View} from 'react-native'; -import type {OnyxEntry} from 'react-native-onyx'; import ActivityIndicator from '@components/ActivityIndicator'; import Button from '@components/Button'; import type {TableHandle} from '@components/Table'; @@ -14,11 +13,9 @@ import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; -import usePoliciesWithCardFeedErrors from '@hooks/usePoliciesWithCardFeedErrors'; import usePreferredPolicy from '@hooks/usePreferredPolicy'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useThemeStyles from '@hooks/useThemeStyles'; -import {isConnectionInProgress} from '@libs/actions/connections'; import {clearDuplicateWorkspace, dismissWorkspaceError} from '@libs/actions/Policy/Policy'; import interceptAnonymousUser from '@libs/interceptAnonymousUser'; import openInternalRouteInNewTab from '@libs/Navigation/helpers/openInternalRouteInNewTab'; @@ -28,14 +25,6 @@ import type {PlatformStackRouteProp} from '@libs/Navigation/PlatformStackNavigat import TransitionTracker from '@libs/Navigation/TransitionTracker'; import type {WorkspaceNavigatorParamList} from '@libs/Navigation/types'; import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; -import { - getPolicyBrickRoadIndicatorStatus, - getUberConnectionErrorDirectlyFromPolicy, - isPendingDeletePolicy, - isPolicyAdmin, - shouldShowEmployeeListError, - shouldShowPolicy, -} from '@libs/PolicyUtils'; import {getDefaultWorkspaceAvatar} from '@libs/ReportUtils'; import type {SkeletonSpanReasonAttributes} from '@libs/telemetry/useSkeletonSpan'; import CONST from '@src/CONST'; @@ -44,10 +33,7 @@ import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import {createDisplayDetailsByAccountIDsSelector} from '@src/selectors/PersonalDetails'; import type {CopySettingsEligibleTargets} from '@src/selectors/Policy'; -import {createCopySettingsEligibleTargetsSelector} from '@src/selectors/Policy'; -import type {Policy as PolicyType} from '@src/types/onyx'; -import type {PolicyDetailsForNonMembers} from '@src/types/onyx/Policy'; -import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import {createCopySettingsEligibleTargetsSelector, createWorkspaceListPoliciesSelector} from '@src/selectors/Policy'; import CopyPolicySettingsProgressModal from './copyPolicySettings/CopyPolicySettingsProgressModal'; import DeleteWorkspaceFlow from './deleteWorkspace/DeleteWorkspaceFlow'; @@ -62,9 +48,6 @@ function WorkspacesListPage() { const {isOffline} = useNetwork(); const isFocused = useIsFocused(); const {shouldUseNarrowLayout} = useResponsiveLayout(); - const [allConnectionSyncProgresses] = useOnyx(ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS); - const [policies] = useOnyx(ONYXKEYS.COLLECTION.POLICY); - const [reimbursementAccount] = useOnyx(ONYXKEYS.REIMBURSEMENT_ACCOUNT); const [session] = useOnyx(ONYXKEYS.SESSION); const [activePolicyID] = useOnyx(ONYXKEYS.NVP_ACTIVE_POLICY_ID); const [isLoadingApp] = useOnyx(ONYXKEYS.IS_LOADING_APP); @@ -73,7 +56,12 @@ function WorkspacesListPage() { const {isRestrictedPolicyCreation} = usePreferredPolicy(); const [duplicateWorkspace] = useOnyx(ONYXKEYS.DUPLICATE_WORKSPACE); - // Light projection of the policy collection passed down to the row menus, which compute their copy-settings + // Light, flat projection of the policy collection. Deep, frequently mutated policy fields (isLoading* + // flags, employeeList, connections, etc.) are excluded, so background writes to them no longer commit + // this page. Per-row error indicators subscribe to those fields themselves in WorkspaceRowBrickRoadIndicator. + const [workspaceListPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createWorkspaceListPoliciesSelector(session?.email)}, [session?.email]); + + // Projection of the policy collection passed down to the row menus, which compute their copy-settings // eligibility from it. Kept at the page level so it is evaluated once per policy write instead of once per row. const [copySettingsEligibleTargets] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {selector: createCopySettingsEligibleTargetsSelector(session?.email)}, [session?.email]); @@ -82,21 +70,17 @@ function WorkspacesListPage() { // Narrow subscription keeping the owner name/avatar columns reactive without re-rendering the page // when anything else in the personal details list changes. const ownerAccountIDs: number[] = []; - if (!isEmptyObject(policies)) { + { const uniqueOwnerAccountIDs = new Set(); - for (const policy of Object.values(policies)) { - if (!policy || !shouldShowPolicy(policy, true, session?.email)) { - continue; - } - const ownerAccountID = - policy.isJoinRequestPending && policy.policyDetailsForNonMembers ? Object.values(policy.policyDetailsForNonMembers).at(0)?.ownerAccountID : policy.ownerAccountID; + for (const policy of workspaceListPolicies ?? []) { + const ownerAccountID = policy.isJoinRequestPending && policy.nonMemberDetails ? policy.nonMemberDetails.ownerAccountID : policy.ownerAccountID; if (ownerAccountID) { uniqueOwnerAccountIDs.add(ownerAccountID); } } ownerAccountIDs.push(...uniqueOwnerAccountIDs); } - const [ownerDisplayDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: createDisplayDetailsByAccountIDsSelector(ownerAccountIDs)}, [policies, session?.email]); + const [ownerDisplayDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: createDisplayDetailsByAccountIDsSelector(ownerAccountIDs)}, [workspaceListPolicies]); const navigateToWorkspace = (policyID: string, event?: ModifiedMouseEvent) => { const workspaceRoute = shouldUseNarrowLayout ? ROUTES.WORKSPACE_INITIAL.getRoute(policyID) : ROUTES.WORKSPACE_OVERVIEW.getRoute(policyID); @@ -106,110 +90,66 @@ function WorkspacesListPage() { Navigation.navigate(workspaceRoute); }; - const {policiesWithCardFeedErrors} = usePoliciesWithCardFeedErrors(); - /** * Add free policies (workspaces) to the list of menu items and returns the list of menu items */ const workspaceRows: WorkspaceRowData[] = []; - if (!isEmptyObject(policies)) { - const reimbursementAccountBrickRoadIndicator = !isEmptyObject(reimbursementAccount?.errors) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined; - - for (const policy of Object.values(policies)) { - if (!policy || !shouldShowPolicy(policy, true, session?.email)) { - continue; - } - - const brickRoadIndicator = (() => { - if (!isPolicyAdmin(policy, session?.email)) { - return undefined; - } - - if (reimbursementAccountBrickRoadIndicator) { - return reimbursementAccountBrickRoadIndicator; - } - - const receiptUberBrickRoadIndicator = getUberConnectionErrorDirectlyFromPolicy(policy as OnyxEntry) ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined; - - if (receiptUberBrickRoadIndicator) { - return receiptUberBrickRoadIndicator; - } - - if (policiesWithCardFeedErrors.find((p) => p.id === policy.id)) { - return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; - } - - if (shouldShowEmployeeListError(policy)) { - return CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR; - } - - return getPolicyBrickRoadIndicatorStatus( - policy, - isConnectionInProgress(allConnectionSyncProgresses?.[`${ONYXKEYS.COLLECTION.POLICY_CONNECTION_SYNC_PROGRESS}${policy.id}`], policy), - ); - })(); - - if (policy?.isJoinRequestPending && policy?.policyDetailsForNonMembers) { - const policyID = Object.keys(policy.policyDetailsForNonMembers).at(0) ?? ''; - const policyInfo = Object.values(policy.policyDetailsForNonMembers).at(0) as PolicyDetailsForNonMembers; - - const policyOwnerAccountID = policyInfo.ownerAccountID; - const ownerDetails = policyOwnerAccountID ? ownerDisplayDetails?.[policyOwnerAccountID] : undefined; - - const pendingWorkspaceRow: WorkspaceRowData = { - keyForList: policyID, - policyID, - disabled: true, - errors: undefined, - type: policyInfo.type, - title: policyInfo.name, - role: CONST.POLICY.ROLE.USER, - isDeleted: false, - isJoinRequestPending: true, - isDefault: activePolicyID === policyID, - shouldAnimateInHighlight: duplicateWorkspace?.policyID === policyID, - ownerAccountID: policyOwnerAccountID, - ownerLogin: ownerDetails ? ownerDetails.login : undefined, - ownerAvatar: ownerDetails ? ownerDetails.avatar : undefined, - ownerName: ownerDetails ? getDisplayNameOrDefault(ownerDetails) : undefined, - iconType: policyInfo?.avatar ? CONST.ICON_TYPE_AVATAR : CONST.ICON_TYPE_ICON, - icon: policyInfo?.avatar ? policyInfo.avatar : getDefaultWorkspaceAvatar(policy.name), - action: () => null, - dismissError: () => null, - }; - - workspaceRows.push(pendingWorkspaceRow); - } else { - const policyOwnerAccountID = policy.ownerAccountID; - const ownerDetails = policyOwnerAccountID ? ownerDisplayDetails?.[policyOwnerAccountID] : undefined; - - const workspaceRow: WorkspaceRowData = { - keyForList: policy.id, - policyID: policy.id, - disabled: policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, - errors: policy.errors, - type: policy.type, - title: policy.name, - role: policy.role, - ownerAccountID: policyOwnerAccountID, - isJoinRequestPending: false, - shouldAnimateInHighlight: duplicateWorkspace?.policyID === policy.id, - isDefault: activePolicyID === policy.id, - isDeleted: isPendingDeletePolicy(policy), - ownerLogin: ownerDetails ? ownerDetails.login : undefined, - ownerAvatar: ownerDetails ? ownerDetails.avatar : undefined, - ownerName: ownerDetails ? getDisplayNameOrDefault(ownerDetails) : undefined, - iconType: policy.avatarURL ? CONST.ICON_TYPE_AVATAR : CONST.ICON_TYPE_ICON, - icon: policy.avatarURL ? policy.avatarURL : getDefaultWorkspaceAvatar(policy.name), - brickRoadIndicator, - pendingAction: policy.pendingAction, - action: (event) => navigateToWorkspace(policy.id, event), - dismissError: () => dismissWorkspaceError(policy.id, policy.pendingAction), - }; - - workspaceRows.push(workspaceRow); - } + for (const policy of workspaceListPolicies ?? []) { + if (policy.isJoinRequestPending && policy.nonMemberDetails) { + const {policyID, ownerAccountID} = policy.nonMemberDetails; + const ownerDetails = ownerAccountID ? ownerDisplayDetails?.[ownerAccountID] : undefined; + + const pendingWorkspaceRow: WorkspaceRowData = { + keyForList: policyID, + policyID, + disabled: true, + errors: undefined, + type: policy.nonMemberDetails.type, + title: policy.nonMemberDetails.name, + role: CONST.POLICY.ROLE.USER, + isDeleted: false, + isJoinRequestPending: true, + isDefault: activePolicyID === policyID, + shouldAnimateInHighlight: duplicateWorkspace?.policyID === policyID, + ownerAccountID, + ownerLogin: ownerDetails ? ownerDetails.login : undefined, + ownerAvatar: ownerDetails ? ownerDetails.avatar : undefined, + ownerName: ownerDetails ? getDisplayNameOrDefault(ownerDetails) : undefined, + iconType: policy.nonMemberDetails.avatar ? CONST.ICON_TYPE_AVATAR : CONST.ICON_TYPE_ICON, + icon: policy.nonMemberDetails.avatar ? policy.nonMemberDetails.avatar : getDefaultWorkspaceAvatar(policy.name), + action: () => null, + dismissError: () => null, + }; + + workspaceRows.push(pendingWorkspaceRow); + } else { + const ownerDetails = policy.ownerAccountID ? ownerDisplayDetails?.[policy.ownerAccountID] : undefined; + + const workspaceRow: WorkspaceRowData = { + keyForList: policy.id, + policyID: policy.id, + disabled: policy.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE, + errors: policy.errors, + type: policy.type, + title: policy.name, + role: policy.role, + ownerAccountID: policy.ownerAccountID, + isJoinRequestPending: false, + shouldAnimateInHighlight: duplicateWorkspace?.policyID === policy.id, + isDefault: activePolicyID === policy.id, + isDeleted: policy.isPendingDelete, + ownerLogin: ownerDetails ? ownerDetails.login : undefined, + ownerAvatar: ownerDetails ? ownerDetails.avatar : undefined, + ownerName: ownerDetails ? getDisplayNameOrDefault(ownerDetails) : undefined, + iconType: policy.avatarURL ? CONST.ICON_TYPE_AVATAR : CONST.ICON_TYPE_ICON, + icon: policy.avatarURL ? policy.avatarURL : getDefaultWorkspaceAvatar(policy.name), + pendingAction: policy.pendingAction, + action: (event) => navigateToWorkspace(policy.id, event), + dismissError: () => dismissWorkspaceError(policy.id, policy.pendingAction), + }; + + workspaceRows.push(workspaceRow); } } diff --git a/src/selectors/Policy.ts b/src/selectors/Policy.ts index c56b718c7556..c0af3ab56888 100644 --- a/src/selectors/Policy.ts +++ b/src/selectors/Policy.ts @@ -2,10 +2,11 @@ import escapeRegExp from 'lodash/escapeRegExp'; import type {OnyxCollection, OnyxEntry} from 'react-native-onyx'; import {hasSynchronizationErrorMessage, isConnectionUnverified} from '@libs/actions/connections'; import {getDisplayNameForWorkspace} from '@libs/actions/Policy/Policy'; -import {getActiveAdminWorkspaces, getOwnedPaidPolicies, isPaidGroupPolicy, isPendingDeletePolicy, isPolicyAdmin} from '@libs/PolicyUtils'; +import {getActiveAdminWorkspaces, getOwnedPaidPolicies, isPaidGroupPolicy, isPendingDeletePolicy, isPolicyAdmin, shouldShowPolicy} from '@libs/PolicyUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type {Policy, PolicyReportField} from '@src/types/onyx'; +import type {PolicyDetailsForNonMembers} from '@src/types/onyx/Policy'; type ReusablePolicyConnectionName = | typeof CONST.POLICY.CONNECTIONS.NAME.NETSUITE @@ -68,6 +69,64 @@ const createCopySettingsEligibleTargetsSelector = return {adminNonPersonal, corporateOnly}; }; +type WorkspaceListPolicy = Pick & { + /** Whether the policy is optimistically pending deletion */ + isPendingDelete: boolean; + + /** Whether the current user has a pending request to join the policy */ + isJoinRequestPending: boolean; + + /** Projection of policyDetailsForNonMembers for join-request-pending policies */ + nonMemberDetails?: Pick & {policyID: string}; +}; + +/** + * Creates a selector returning a light, flat projection of the policies shown on the workspaces list, + * so the page doesn't re-render when deep, frequently mutated policy fields change (isLoading* flags, + * employeeList, customUnits, connections, etc.). + */ +const createWorkspaceListPoliciesSelector = + (currentUserLogin: string | undefined) => + (policies: OnyxCollection): WorkspaceListPolicy[] => { + const result: WorkspaceListPolicy[] = []; + for (const policy of Object.values(policies ?? {})) { + if (!policy || !shouldShowPolicy(policy, true, currentUserLogin)) { + continue; + } + + const isJoinRequestPending = !!policy.isJoinRequestPending && !!policy.policyDetailsForNonMembers; + let nonMemberDetails: WorkspaceListPolicy['nonMemberDetails']; + if (isJoinRequestPending) { + const nonMemberEntry = Object.entries(policy.policyDetailsForNonMembers ?? {}).at(0); + if (nonMemberEntry) { + const [nonMemberPolicyID, details] = nonMemberEntry; + nonMemberDetails = { + policyID: nonMemberPolicyID, + name: details.name, + type: details.type, + ownerAccountID: details.ownerAccountID, + avatar: details.avatar, + }; + } + } + + result.push({ + id: policy.id, + name: policy.name, + type: policy.type, + role: policy.role, + ownerAccountID: policy.ownerAccountID, + avatarURL: policy.avatarURL, + pendingAction: policy.pendingAction, + errors: policy.errors, + isPendingDelete: isPendingDeletePolicy(policy), + isJoinRequestPending, + nonMemberDetails, + }); + } + return result; + }; + const activeAdminPoliciesSelector = (policies: OnyxCollection, currentUserAccountLogin: string) => getActiveAdminWorkspaces(policies, currentUserAccountLogin); const hasActiveAdminPoliciesSelector = (policies: OnyxCollection, currentUserAccountLogin: string) => !!activeAdminPoliciesSelector(policies, currentUserAccountLogin).length; @@ -299,6 +358,7 @@ export { ownerPoliciesSelector, createOwnedPaidPoliciesCountsSelector, createCopySettingsEligibleTargetsSelector, + createWorkspaceListPoliciesSelector, activeAdminPoliciesSelector, hasActiveAdminPoliciesSelector, createPoliciesForDomainCardsSelector, @@ -317,4 +377,4 @@ export { createAdminPoliciesSelector, isAdminForPolicyByIDSelector, }; -export type {ReusablePolicyConnectionName, OwnedPaidPoliciesCounts, CopySettingsEligibleTargets}; +export type {ReusablePolicyConnectionName, OwnedPaidPoliciesCounts, CopySettingsEligibleTargets, WorkspaceListPolicy}; diff --git a/src/selectors/ReimbursementAccount.ts b/src/selectors/ReimbursementAccount.ts index 17f6acec34da..99aca6e3ce74 100644 --- a/src/selectors/ReimbursementAccount.ts +++ b/src/selectors/ReimbursementAccount.ts @@ -1,7 +1,9 @@ import type {OnyxEntry} from 'react-native-onyx'; import type {ReimbursementAccount} from '@src/types/onyx'; +import {isEmptyObject} from '@src/types/utils/EmptyObject'; const reimbursementAccountErrorSelector = (reimbursementAccount: OnyxEntry) => reimbursementAccount?.errors; -// eslint-disable-next-line import/prefer-default-export -export {reimbursementAccountErrorSelector}; +const hasReimbursementAccountErrorsSelector = (reimbursementAccount: OnyxEntry) => !isEmptyObject(reimbursementAccount?.errors); + +export {reimbursementAccountErrorSelector, hasReimbursementAccountErrorsSelector};