From 283b1a4ca4cd5fe3495aee8d4625e49e2194810e Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Wed, 16 Sep 2026 16:43:31 +0700 Subject: [PATCH 1/2] warn when leaving a workspace with reports to approve --- .../Tables/WorkspaceListTable/LeaveWorkspaceFlow.tsx | 8 +++++++- src/libs/WorkspacesSettingsUtils.ts | 10 ++++++++-- src/pages/workspace/WorkspaceOverviewPage.tsx | 9 ++++++++- tests/unit/WorkspacesSettingsUtilsTest.ts | 5 +++++ 4 files changed, 28 insertions(+), 4 deletions(-) diff --git a/src/components/Tables/WorkspaceListTable/LeaveWorkspaceFlow.tsx b/src/components/Tables/WorkspaceListTable/LeaveWorkspaceFlow.tsx index 6bcfe76e9dbc..305a180dc6fa 100644 --- a/src/components/Tables/WorkspaceListTable/LeaveWorkspaceFlow.tsx +++ b/src/components/Tables/WorkspaceListTable/LeaveWorkspaceFlow.tsx @@ -4,13 +4,16 @@ import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; +import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; import {close} from '@libs/actions/Modal'; import {leaveWorkspace} from '@libs/actions/Policy/Policy'; +import {isApproverOfOutstandingPolicyReports} from '@libs/ReportUtils'; import {getLeaveWorkspaceConfirmationPrompt} from '@libs/WorkspacesSettingsUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; +import {createOutstandingReportsForPolicySelector} from '@src/selectors/Report'; import type {PersonalDetailsList} from '@src/types/onyx'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; @@ -40,6 +43,8 @@ function LeaveWorkspaceFlow({policyID, onDismiss}: LeaveWorkspaceFlowProps) { 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)}); + const [outstandingReportsForPolicy] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID, {selector: createOutstandingReportsForPolicySelector(policyID)}); + const privateIsArchivedMap = usePrivateIsArchivedMap(); const isLoadingData = isLoadingOnyxValue(policyResult); @@ -53,7 +58,8 @@ function LeaveWorkspaceFlow({policyID, onDismiss}: LeaveWorkspaceFlowProps) { close(() => { const userLogin = currentUserPersonalDetails.login ?? ''; - const prompt = getLeaveWorkspaceConfirmationPrompt(policy, userLogin, policyOwnerDisplayName ?? '', translate); + const isApproverOfOutstandingReports = isApproverOfOutstandingPolicyReports(currentUserPersonalDetails.accountID, outstandingReportsForPolicy, privateIsArchivedMap); + const prompt = getLeaveWorkspaceConfirmationPrompt(policy, userLogin, policyOwnerDisplayName ?? '', translate, isApproverOfOutstandingReports); if (policy?.achAccount?.reimburser === userLogin) { showConfirmModal({ title: policy?.name ? translate('common.leaveWorkspaceTitle', policy.name) : translate('common.leaveWorkspace'), diff --git a/src/libs/WorkspacesSettingsUtils.ts b/src/libs/WorkspacesSettingsUtils.ts index 3e45632dedf2..1251494f5065 100644 --- a/src/libs/WorkspacesSettingsUtils.ts +++ b/src/libs/WorkspacesSettingsUtils.ts @@ -140,7 +140,13 @@ function getOwnershipChecksDisplayText( return {title, text, buttonText}; } -function getLeaveWorkspaceConfirmationPrompt(policy: OnyxEntry, userEmail: string, ownerDisplayName: string, translate: LocaleContextProps['translate']): string { +function getLeaveWorkspaceConfirmationPrompt( + policy: OnyxEntry, + userEmail: string, + ownerDisplayName: string, + translate: LocaleContextProps['translate'], + isApproverOfOutstandingReports = false, +): string { const exporters = getConnectionExporters(policy); if (policy?.achAccount?.reimburser === userEmail) { @@ -155,7 +161,7 @@ function getLeaveWorkspaceConfirmationPrompt(policy: OnyxEntry, userEmai return translate('common.leaveWorkspaceConfirmationExporter', ownerDisplayName); } - if (isPolicyApprover(policy, userEmail)) { + if (isPolicyApprover(policy, userEmail) || isApproverOfOutstandingReports) { return translate('common.leaveWorkspaceConfirmationApprover', ownerDisplayName); } diff --git a/src/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index f46b7a07d501..6cdf0a49e96c 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -26,6 +26,7 @@ import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePrevious from '@hooks/usePrevious'; +import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useScreenBoundDynamicRoute from '@hooks/useScreenBoundDynamicRoute'; import useShouldBlockCurrencyChange from '@hooks/useShouldBlockCurrencyChange'; @@ -53,6 +54,7 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import {canEditWorkspaceSettings, getRulesDocumentSourceURL, getUserFriendlyWorkspaceType, goBackFromInvalidPolicy, isPendingDeletePolicy, isPolicyOwner} from '@libs/PolicyUtils'; import {formatAddressToString} from '@libs/ReportActionsUtils'; +import {isApproverOfOutstandingPolicyReports} from '@libs/ReportUtils'; import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButton'; import StringUtils from '@libs/StringUtils'; import {getLeaveWorkspaceConfirmationPrompt} from '@libs/WorkspacesSettingsUtils'; @@ -65,6 +67,7 @@ import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import {canDowngradeSelector} from '@src/selectors/Account'; import {createOwnedPaidPoliciesCountsSelector} from '@src/selectors/Policy'; +import {createOutstandingReportsForPolicySelector} from '@src/selectors/Report'; import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -183,6 +186,9 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const {showLockedAccountModal} = useLockedAccountActions(); const [pendingRulesDocumentFile, setPendingRulesDocumentFile] = useState(); const [session] = useOnyx(ONYXKEYS.SESSION); + const outstandingReportsForPolicySelector = useMemo(() => createOutstandingReportsForPolicySelector(policyID), [policyID]); + const [outstandingReportsForPolicy] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID, {selector: outstandingReportsForPolicySelector}); + const privateIsArchivedMap = usePrivateIsArchivedMap(); const rulesDocumentSourceURL = useMemo( () => getRulesDocumentSourceURL(policy?.rulesDocumentURL, policyID, session?.encryptedAuthToken ?? ''), @@ -313,7 +319,8 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const handleLeave = () => { const userEmail = session?.email ?? ''; const ownerDisplayName = personalDetails?.[policy?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID]?.displayName ?? ''; - const prompt = getLeaveWorkspaceConfirmationPrompt(policy, userEmail, ownerDisplayName, translate); + const isApproverOfOutstandingReports = isApproverOfOutstandingPolicyReports(currentUserPersonalDetails.accountID, outstandingReportsForPolicy, privateIsArchivedMap); + const prompt = getLeaveWorkspaceConfirmationPrompt(policy, userEmail, ownerDisplayName, translate, isApproverOfOutstandingReports); const isReimburser = policy?.achAccount?.reimburser === userEmail; if (isReimburser) { diff --git a/tests/unit/WorkspacesSettingsUtilsTest.ts b/tests/unit/WorkspacesSettingsUtilsTest.ts index a4cd59c681d2..d25fa472ddd9 100644 --- a/tests/unit/WorkspacesSettingsUtilsTest.ts +++ b/tests/unit/WorkspacesSettingsUtilsTest.ts @@ -266,6 +266,11 @@ describe('WorkspacesSettingsUtils', () => { expect(getLeaveWorkspaceConfirmationPrompt(policy, userEmail, ownerDisplayName, translate)).toBe('common.leaveWorkspaceConfirmationApprover'); }); + it('returns approver key when user approves outstanding reports without being in the approval workflow', () => { + const policy = createMock({role: CONST.POLICY.ROLE.ADMIN}); + expect(getLeaveWorkspaceConfirmationPrompt(policy, userEmail, ownerDisplayName, translate, true)).toBe('common.leaveWorkspaceConfirmationApprover'); + }); + it('returns admin key when the policy role is admin', () => { const policy = createMock({role: CONST.POLICY.ROLE.ADMIN}); expect(getLeaveWorkspaceConfirmationPrompt(policy, userEmail, ownerDisplayName, translate)).toBe('common.leaveWorkspaceConfirmationAdmin'); From 51ee501285bfa90d674d98b441039218c6956a22 Mon Sep 17 00:00:00 2001 From: dukenv0307 Date: Wed, 16 Sep 2026 22:36:06 +0700 Subject: [PATCH 2/2] Let React Compiler memoize the selector --- .../WorkspaceListTable/LeaveWorkspaceFlow.tsx | 8 ++----- ...useIsApproverOfOutstandingPolicyReports.ts | 23 +++++++++++++++++++ src/pages/workspace/WorkspaceOverviewPage.tsx | 9 ++------ 3 files changed, 27 insertions(+), 13 deletions(-) create mode 100644 src/hooks/useIsApproverOfOutstandingPolicyReports.ts diff --git a/src/components/Tables/WorkspaceListTable/LeaveWorkspaceFlow.tsx b/src/components/Tables/WorkspaceListTable/LeaveWorkspaceFlow.tsx index 305a180dc6fa..0cd4b6eb5221 100644 --- a/src/components/Tables/WorkspaceListTable/LeaveWorkspaceFlow.tsx +++ b/src/components/Tables/WorkspaceListTable/LeaveWorkspaceFlow.tsx @@ -2,18 +2,16 @@ import {ModalActions} from '@components/Modal/Global/ModalContext'; import useConfirmModal from '@hooks/useConfirmModal'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useIsApproverOfOutstandingPolicyReports from '@hooks/useIsApproverOfOutstandingPolicyReports'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; -import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; import {close} from '@libs/actions/Modal'; import {leaveWorkspace} from '@libs/actions/Policy/Policy'; -import {isApproverOfOutstandingPolicyReports} from '@libs/ReportUtils'; import {getLeaveWorkspaceConfirmationPrompt} from '@libs/WorkspacesSettingsUtils'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import {createOutstandingReportsForPolicySelector} from '@src/selectors/Report'; import type {PersonalDetailsList} from '@src/types/onyx'; import isLoadingOnyxValue from '@src/types/utils/isLoadingOnyxValue'; @@ -43,8 +41,7 @@ function LeaveWorkspaceFlow({policyID, onDismiss}: LeaveWorkspaceFlowProps) { 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)}); - const [outstandingReportsForPolicy] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID, {selector: createOutstandingReportsForPolicySelector(policyID)}); - const privateIsArchivedMap = usePrivateIsArchivedMap(); + const isApproverOfOutstandingReports = useIsApproverOfOutstandingPolicyReports(policyID); const isLoadingData = isLoadingOnyxValue(policyResult); @@ -58,7 +55,6 @@ function LeaveWorkspaceFlow({policyID, onDismiss}: LeaveWorkspaceFlowProps) { close(() => { const userLogin = currentUserPersonalDetails.login ?? ''; - const isApproverOfOutstandingReports = isApproverOfOutstandingPolicyReports(currentUserPersonalDetails.accountID, outstandingReportsForPolicy, privateIsArchivedMap); const prompt = getLeaveWorkspaceConfirmationPrompt(policy, userLogin, policyOwnerDisplayName ?? '', translate, isApproverOfOutstandingReports); if (policy?.achAccount?.reimburser === userLogin) { showConfirmModal({ diff --git a/src/hooks/useIsApproverOfOutstandingPolicyReports.ts b/src/hooks/useIsApproverOfOutstandingPolicyReports.ts new file mode 100644 index 000000000000..b64a41db6974 --- /dev/null +++ b/src/hooks/useIsApproverOfOutstandingPolicyReports.ts @@ -0,0 +1,23 @@ +import {isApproverOfOutstandingPolicyReports} from '@libs/ReportUtils'; + +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +import useCurrentUserPersonalDetails from './useCurrentUserPersonalDetails'; +import useOnyx from './useOnyx'; +import usePrivateIsArchivedMap from './usePrivateIsArchivedMap'; + +/** + * Whether the current user is the approver of any report in the workspace that is waiting for their approval, + * including the ones they were assigned to through "Change approver". + */ +function useIsApproverOfOutstandingPolicyReports(policyID: string | undefined): boolean { + const currentUserPersonalDetails = useCurrentUserPersonalDetails(); + // Subscribing without a selector keeps the change detection a reference compare, and the reports are reduced to a boolean below. + const [outstandingReportsByPolicyID] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID); + const privateIsArchivedMap = usePrivateIsArchivedMap(); + + return isApproverOfOutstandingPolicyReports(currentUserPersonalDetails.accountID, outstandingReportsByPolicyID?.[policyID ?? CONST.DEFAULT_NUMBER_ID], privateIsArchivedMap); +} + +export default useIsApproverOfOutstandingPolicyReports; diff --git a/src/pages/workspace/WorkspaceOverviewPage.tsx b/src/pages/workspace/WorkspaceOverviewPage.tsx index 6cdf0a49e96c..25fb8d60f250 100644 --- a/src/pages/workspace/WorkspaceOverviewPage.tsx +++ b/src/pages/workspace/WorkspaceOverviewPage.tsx @@ -21,12 +21,12 @@ import useConfirmModal from '@hooks/useConfirmModal'; import {useCurrencyListActions} from '@hooks/useCurrencyList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useDefaultFundID from '@hooks/useDefaultFundID'; +import useIsApproverOfOutstandingPolicyReports from '@hooks/useIsApproverOfOutstandingPolicyReports'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePrevious from '@hooks/usePrevious'; -import usePrivateIsArchivedMap from '@hooks/usePrivateIsArchivedMap'; import useResponsiveLayout from '@hooks/useResponsiveLayout'; import useScreenBoundDynamicRoute from '@hooks/useScreenBoundDynamicRoute'; import useShouldBlockCurrencyChange from '@hooks/useShouldBlockCurrencyChange'; @@ -54,7 +54,6 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; import {canEditWorkspaceSettings, getRulesDocumentSourceURL, getUserFriendlyWorkspaceType, goBackFromInvalidPolicy, isPendingDeletePolicy, isPolicyOwner} from '@libs/PolicyUtils'; import {formatAddressToString} from '@libs/ReportActionsUtils'; -import {isApproverOfOutstandingPolicyReports} from '@libs/ReportUtils'; import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButton'; import StringUtils from '@libs/StringUtils'; import {getLeaveWorkspaceConfirmationPrompt} from '@libs/WorkspacesSettingsUtils'; @@ -67,7 +66,6 @@ import ROUTES, {DYNAMIC_ROUTES} from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; import {canDowngradeSelector} from '@src/selectors/Account'; import {createOwnedPaidPoliciesCountsSelector} from '@src/selectors/Policy'; -import {createOutstandingReportsForPolicySelector} from '@src/selectors/Report'; import type {FileObject} from '@src/types/utils/Attachment'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; @@ -186,9 +184,7 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const {showLockedAccountModal} = useLockedAccountActions(); const [pendingRulesDocumentFile, setPendingRulesDocumentFile] = useState(); const [session] = useOnyx(ONYXKEYS.SESSION); - const outstandingReportsForPolicySelector = useMemo(() => createOutstandingReportsForPolicySelector(policyID), [policyID]); - const [outstandingReportsForPolicy] = useOnyx(ONYXKEYS.DERIVED.OUTSTANDING_REPORTS_BY_POLICY_ID, {selector: outstandingReportsForPolicySelector}); - const privateIsArchivedMap = usePrivateIsArchivedMap(); + const isApproverOfOutstandingReports = useIsApproverOfOutstandingPolicyReports(policyID); const rulesDocumentSourceURL = useMemo( () => getRulesDocumentSourceURL(policy?.rulesDocumentURL, policyID, session?.encryptedAuthToken ?? ''), @@ -319,7 +315,6 @@ function WorkspaceOverviewPage({policyDraft, policy: policyProp, route}: Workspa const handleLeave = () => { const userEmail = session?.email ?? ''; const ownerDisplayName = personalDetails?.[policy?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID]?.displayName ?? ''; - const isApproverOfOutstandingReports = isApproverOfOutstandingPolicyReports(currentUserPersonalDetails.accountID, outstandingReportsForPolicy, privateIsArchivedMap); const prompt = getLeaveWorkspaceConfirmationPrompt(policy, userEmail, ownerDisplayName, translate, isApproverOfOutstandingReports); const isReimburser = policy?.achAccount?.reimburser === userEmail;