diff --git a/src/components/MoneyReportHeader.tsx b/src/components/MoneyReportHeader.tsx index 3a373f4f1b4c..8349b37c3576 100644 --- a/src/components/MoneyReportHeader.tsx +++ b/src/components/MoneyReportHeader.tsx @@ -11,6 +11,7 @@ import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; import {convertToDisplayString} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; +import {buildOptimisticNextStepForPreventSelfApprovalsEnabled} from '@libs/NextStepUtils'; import {getConnectedIntegration} from '@libs/PolicyUtils'; import {getOriginalMessage, isDeletedAction, isMoneyRequestAction, isTrackExpenseAction} from '@libs/ReportActionsUtils'; import { @@ -207,7 +208,13 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea const isFromPaidPolicy = policyType === CONST.POLICY.TYPE.TEAM || policyType === CONST.POLICY.TYPE.CORPORATE; const shouldShowStatusBar = hasAllPendingRTERViolations || shouldShowBrokenConnectionViolation || hasOnlyHeldExpenses || hasScanningReceipt || isPayAtEndExpense || hasOnlyPendingTransactions; - const shouldShowNextStep = transactions?.length !== 0 && isFromPaidPolicy && !!nextStep?.message?.length && !shouldShowStatusBar; + + // When prevent self-approval is enabled, we need to show the optimistic next step + // We should always show this optimistic message for policies with preventSelfApproval + // to avoid any flicker during transitions between online/offline states + const optimisticNextStep = policy?.preventSelfApproval ? buildOptimisticNextStepForPreventSelfApprovalsEnabled() : nextStep; + + const shouldShowNextStep = transactions?.length !== 0 && isFromPaidPolicy && !!optimisticNextStep?.message?.length && !shouldShowStatusBar; const shouldShowAnyButton = isDuplicate || shouldShowSettlementButton || @@ -510,7 +517,7 @@ function MoneyReportHeader({policy, report: moneyRequestReport, transactionThrea /> )} - {shouldShowNextStep && } + {shouldShowNextStep && } {!!statusBarProps && ( `and enable workflows, then add ${featureName} to unlock this feature.`, enableFeatureSubtitle: ({featureName}: FeatureNameParams) => `and enable ${featureName} to unlock this feature.`, - preventSelfApprovalsModalText: ({managerEmail}: {managerEmail: string}) => - `Any members currently approving their own expenses will be removed and replaced with the default approver for this workspace (${managerEmail}).`, - preventSelfApprovalsConfirmButton: 'Prevent self-approvals', - preventSelfApprovalsModalTitle: 'Prevent self-approvals?', - preventSelfApprovalsDisabledSubtitle: "Self approvals can't be enabled until this workspace has at least two members.", }, categoryRules: { title: 'Category rules', diff --git a/src/languages/es.ts b/src/languages/es.ts index 6052ce92814a..a851560bdf8a 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -4793,11 +4793,6 @@ const translations = { unlockFeatureGoToSubtitle: 'Ir a', unlockFeatureEnableWorkflowsSubtitle: ({featureName}: FeatureNameParams) => `y habilita flujos de trabajo, luego agrega ${featureName} para desbloquear esta función.`, enableFeatureSubtitle: ({featureName}: FeatureNameParams) => `y habilita ${featureName} para desbloquear esta función.`, - preventSelfApprovalsModalText: ({managerEmail}: {managerEmail: string}) => - `Todos los miembros que actualmente estén aprobando sus propios gastos serán eliminados y reemplazados con el aprobador predeterminado de este espacio de trabajo (${managerEmail}).`, - preventSelfApprovalsConfirmButton: 'Evitar autoaprobaciones', - preventSelfApprovalsModalTitle: '¿Evitar autoaprobaciones?', - preventSelfApprovalsDisabledSubtitle: 'Las aprobaciones propias no pueden habilitarse hasta que este espacio de trabajo tenga al menos dos miembros.', }, categoryRules: { title: 'Reglas de categoría', diff --git a/src/libs/NextStepUtils.ts b/src/libs/NextStepUtils.ts index afb42bad9232..b9f1abf81edc 100644 --- a/src/libs/NextStepUtils.ts +++ b/src/libs/NextStepUtils.ts @@ -82,6 +82,34 @@ function getNextApproverDisplayName(report: OnyxEntry, isUnapprove?: boo return getDisplayNameForParticipant({accountID: approverAccountID}) ?? getPersonalDetailsForAccountID(approverAccountID).login; } +function buildOptimisticNextStepForPreventSelfApprovalsEnabled() { + const optimisticNextStep: ReportNextStep = { + type: 'alert', + icon: CONST.NEXT_STEP.ICONS.HOURGLASS, + message: [ + { + text: "Oops! Looks like you're submitting to ", + }, + { + text: 'yourself', + type: 'strong', + }, + { + text: '. Approving your own reports is ', + }, + { + text: 'forbidden', + type: 'strong', + }, + { + text: ' by your policy. Please submit this report to someone else or contact your admin to change the person you submit to.', + }, + ], + }; + + return optimisticNextStep; +} + /** * Generates an optimistic nextStep based on a current report status and other properties. * @@ -416,4 +444,4 @@ function buildNextStep(report: OnyxEntry, predictedNextStatus: ValueOf { return domainName; }; -/** - * Returns an array of user emails who are currently self-approving: - * i.e. user.submitsTo === their own email. - */ -function getAllSelfApprovers(policy: OnyxEntry): string[] { - const defaultApprover = policy?.approver ?? policy?.owner; - if (!policy?.employeeList || !defaultApprover) { - return []; - } - return Object.keys(policy.employeeList).filter((email) => { - const employee = policy?.employeeList?.[email] ?? {}; - return employee?.submitsTo === email && employee?.email !== defaultApprover; - }); -} - -/** - * Checks if the workspace has only one user and if there no approver for the policy. - * If so, we can't enable the "Prevent Self Approvals" feature. - */ -function canEnablePreventSelfApprovals(policy: OnyxEntry): boolean { - if (!policy?.employeeList || !policy.approver) { - return false; - } - - const employeeEmails = Object.keys(policy.employeeList); - - return employeeEmails.length > 1; -} - function isPrefferedExporter(policy: Policy) { const user = getCurrentUserEmail(); const exporters = [ @@ -1383,12 +1354,10 @@ function isAutoSyncEnabled(policy: Policy) { export { canEditTaxRate, - canEnablePreventSelfApprovals, extractPolicyIDFromPath, escapeTagName, getActivePolicies, getPerDiemCustomUnits, - getAllSelfApprovers, getAdminEmployees, getCleanedTagName, getConnectedIntegration, diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index daf6d6d972ab..8f46146ec9c1 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -55,7 +55,7 @@ import {getMemberAccountIDsForWorkspace, isDeletedPolicyEmployee, isExpensifyTea import {getDisplayNameForParticipant} from '@libs/ReportUtils'; import {convertPolicyEmployeesToApprovalWorkflows, updateWorkflowDataOnApproverRemoval} from '@libs/WorkflowUtils'; import {close} from '@userActions/Modal'; -import {dismissAddedWithPrimaryLoginMessages, setPolicyPreventSelfApproval} from '@userActions/Policy/Policy'; +import {dismissAddedWithPrimaryLoginMessages} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; @@ -239,10 +239,8 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson return; } - const previousEmployeesCount = Object.keys(policy?.employeeList ?? {}).length; // Remove the admin from the list const accountIDsToRemove = session?.accountID ? selectedEmployees.filter((id) => id !== session.accountID) : selectedEmployees; - const newEmployeesCount = previousEmployeesCount - accountIDsToRemove.length; // Check if any of the account IDs are approvers const hasApprovers = accountIDsToRemove.some((accountID) => isApprover(policy, accountID)); @@ -274,10 +272,6 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson setRemoveMembersConfirmModalVisible(false); InteractionManager.runAfterInteractions(() => { removeMembers(accountIDsToRemove, route.params.policyID); - if (newEmployeesCount === 1 && policy?.preventSelfApproval) { - // We can't let the "Prevent Self Approvals" enabled if there's only one workspace user - setPolicyPreventSelfApproval(route.params.policyID, false); - } }); }; diff --git a/src/pages/workspace/rules/ExpenseReportRulesSection.tsx b/src/pages/workspace/rules/ExpenseReportRulesSection.tsx index 1ec0ee65e956..916b9b941c94 100644 --- a/src/pages/workspace/rules/ExpenseReportRulesSection.tsx +++ b/src/pages/workspace/rules/ExpenseReportRulesSection.tsx @@ -1,6 +1,4 @@ -import React, {useMemo, useState} from 'react'; -import {useOnyx} from 'react-native-onyx'; -import ConfirmModal from '@components/ConfirmModal'; +import React from 'react'; import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; import OfflineWithFeedback from '@components/OfflineWithFeedback'; import Section from '@components/Section'; @@ -11,8 +9,7 @@ import usePolicy from '@hooks/usePolicy'; import useThemeStyles from '@hooks/useThemeStyles'; import {convertToDisplayString} from '@libs/CurrencyUtils'; import Navigation from '@libs/Navigation/Navigation'; -import {canEnablePreventSelfApprovals, getAllSelfApprovers, getWorkflowApprovalsUnavailable} from '@libs/PolicyUtils'; -import {convertPolicyEmployeesToApprovalWorkflows} from '@libs/WorkflowUtils'; +import {getWorkflowApprovalsUnavailable} from '@libs/PolicyUtils'; import ToggleSettingOptionRow from '@pages/workspace/workflows/ToggleSettingsOptionRow'; import { enableAutoApprovalOptions, @@ -21,12 +18,8 @@ import { setPolicyPreventMemberCreatedTitle, setPolicyPreventSelfApproval, } from '@userActions/Policy/Policy'; -import {updateApprovalWorkflow} from '@userActions/Workflow'; import CONST from '@src/CONST'; -import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type ApprovalWorkflow from '@src/types/onyx/ApprovalWorkflow'; -import type {Approver, Member} from '@src/types/onyx/ApprovalWorkflow'; type ExpenseReportRulesSectionProps = { policyID: string; @@ -36,48 +29,10 @@ function ExpenseReportRulesSection({policyID}: ExpenseReportRulesSectionProps) { const {translate} = useLocalize(); const styles = useThemeStyles(); const policy = usePolicy(policyID); - const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); - const customReportNamesUnavailable = !policy?.areReportFieldsEnabled; - // Auto-approvals and self-approvals are unavailable due to the policy workflows settings const workflowApprovalsUnavailable = getWorkflowApprovalsUnavailable(policy); + const customReportNamesUnavailable = !policy?.areReportFieldsEnabled; const autoPayApprovedReportsUnavailable = policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO; - const [isPreventSelfApprovalsModalVisible, setIsPreventSelfApprovalsModalVisible] = useState(false); - const isPreventSelfApprovalsDisabled = !canEnablePreventSelfApprovals(policy) && !policy?.preventSelfApproval; - const selfApproversEmails = getAllSelfApprovers(policy); - - function handleTogglePreventSelfApprovals(isEnabled: boolean) { - if (!isEnabled) { - setPolicyPreventSelfApproval(policyID, false); - return; - } - - if (selfApproversEmails.length === 0) { - setPolicyPreventSelfApproval(policyID, true); - } else { - setIsPreventSelfApprovalsModalVisible(true); - } - } - - const {currentApprovalWorkflows, defaultWorkflowMembers, usedApproverEmails} = useMemo(() => { - if (!policy || !personalDetails) { - return {}; - } - - const defaultApprover = policy?.approver ?? policy.owner; - const result = convertPolicyEmployeesToApprovalWorkflows({ - employees: policy.employeeList ?? {}, - defaultApprover, - personalDetails, - }); - - return { - defaultWorkflowMembers: result.availableMembers, - usedApproverEmails: result.usedApproverEmails, - currentApprovalWorkflows: result.approvalWorkflows.filter((workflow) => !workflow.isDefault), - }; - }, [personalDetails, policy]); - const renderFallbackSubtitle = ({featureName, variant = 'unlock'}: {featureName: string; variant?: 'unlock' | 'enable'}) => { return ( @@ -137,21 +92,15 @@ function ExpenseReportRulesSection({policyID}: ExpenseReportRulesSectionProps) { }, { title: translate('workspace.rules.expenseReportRules.preventSelfApprovalsTitle'), - subtitle: (() => { - if (workflowApprovalsUnavailable) { - return renderFallbackSubtitle({featureName: translate('common.approvals').toLowerCase()}); - } - if (isPreventSelfApprovalsDisabled) { - return translate('workspace.rules.expenseReportRules.preventSelfApprovalsDisabledSubtitle'); - } - return translate('workspace.rules.expenseReportRules.preventSelfApprovalsSubtitle'); - })(), + subtitle: workflowApprovalsUnavailable + ? renderFallbackSubtitle({featureName: translate('common.approvals').toLowerCase()}) + : translate('workspace.rules.expenseReportRules.preventSelfApprovalsSubtitle'), switchAccessibilityLabel: translate('workspace.rules.expenseReportRules.preventSelfApprovalsTitle'), isActive: policy?.preventSelfApproval && !workflowApprovalsUnavailable, - disabled: workflowApprovalsUnavailable || isPreventSelfApprovalsDisabled, - showLockIcon: workflowApprovalsUnavailable || isPreventSelfApprovalsDisabled, + disabled: workflowApprovalsUnavailable, + showLockIcon: workflowApprovalsUnavailable, pendingAction: policy?.pendingFields?.preventSelfApproval, - onToggle: (isEnabled: boolean) => handleTogglePreventSelfApprovals(isEnabled), + onToggle: (isEnabled: boolean) => setPolicyPreventSelfApproval(policyID, isEnabled), }, { title: translate('workspace.rules.expenseReportRules.autoApproveCompliantReportsTitle'), @@ -230,92 +179,36 @@ function ExpenseReportRulesSection({policyID}: ExpenseReportRulesSectionProps) { ]; return ( - <> -
- {optionItems.map(({title, subtitle, isActive, subMenuItems, showLockIcon, disabled, onToggle, pendingAction}, index) => { - const showBorderBottom = index !== optionItems.length - 1; - - return ( - - ); - })} -
- { - setPolicyPreventSelfApproval(policyID, true); - - const defaultApprover = policy?.approver ?? policy?.owner; - if (!defaultApprover) { - setIsPreventSelfApprovalsModalVisible(false); - return; - } - - currentApprovalWorkflows?.forEach((workflow: ApprovalWorkflow) => { - const oldApprovers = workflow.approvers ?? []; - const approversToRemove = oldApprovers.filter((approver: Approver) => selfApproversEmails.includes(approver?.email)); - const newApprovers = oldApprovers.filter((approver: Approver) => !selfApproversEmails.includes(approver?.email)); - - if (!newApprovers.some((a) => a.email === defaultApprover)) { - newApprovers.unshift({ - email: defaultApprover, - displayName: defaultApprover, - }); - } - - const oldMembers = workflow.members ?? []; - const newMembers = oldMembers.map((member: Member) => { - const isSelfApprover = selfApproversEmails.includes(member.email); - return isSelfApprover ? {...member, submitsTo: defaultApprover} : member; - }); - - const newWorkflow = { - ...workflow, - approvers: newApprovers, - availableMembers: [...workflow.members, ...defaultWorkflowMembers], - members: newMembers, - usedApproverEmails, - isDefault: workflow.isDefault ?? false, - action: CONST.APPROVAL_WORKFLOW.ACTION.EDIT, - errors: null, - }; - - const membersToRemove: Member[] = []; - - updateApprovalWorkflow(policyID, newWorkflow, membersToRemove, approversToRemove); - }); - setIsPreventSelfApprovalsModalVisible(false); - }} - onCancel={() => setIsPreventSelfApprovalsModalVisible(false)} - /> - +
+ {optionItems.map(({title, subtitle, isActive, subMenuItems, showLockIcon, disabled, onToggle, pendingAction}, index) => { + const showBorderBottom = index !== optionItems.length - 1; + + return ( + + ); + })} +
); } diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx index 348fc1b3a299..1ec2f67eebd9 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsEditPage.tsx @@ -14,12 +14,12 @@ import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {WorkspaceSplitNavigatorParamList} from '@libs/Navigation/types'; -import * as PolicyUtils from '@libs/PolicyUtils'; +import {goBackFromInvalidPolicy, isPendingDeletePolicy, isPolicyAdmin} from '@libs/PolicyUtils'; import {convertPolicyEmployeesToApprovalWorkflows} from '@libs/WorkflowUtils'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading'; import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading'; -import * as Workflow from '@userActions/Workflow'; +import {clearApprovalWorkflow, removeApprovalWorkflow, setApprovalWorkflow, updateApprovalWorkflow, validateApprovalWorkflow} from '@userActions/Workflow'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import type SCREENS from '@src/SCREENS'; @@ -39,12 +39,12 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); const formRef = useRef(null); - const updateApprovalWorkflow = useCallback(() => { + const updateApprovalWorkflowCallback = useCallback(() => { if (!approvalWorkflow || !initialApprovalWorkflow) { return; } - if (!Workflow.validateApprovalWorkflow(approvalWorkflow)) { + if (!validateApprovalWorkflow(approvalWorkflow)) { return; } @@ -53,11 +53,11 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true const approversToRemove = initialApprovalWorkflow.approvers.filter((initialApprover) => !approvalWorkflow.approvers.some((approver) => approver.email === initialApprover.email)); Navigation.dismissModal(); InteractionManager.runAfterInteractions(() => { - Workflow.updateApprovalWorkflow(route.params.policyID, approvalWorkflow, membersToRemove, approversToRemove); + updateApprovalWorkflow(route.params.policyID, approvalWorkflow, membersToRemove, approversToRemove); }); }, [approvalWorkflow, initialApprovalWorkflow, route.params.policyID]); - const removeApprovalWorkflow = useCallback(() => { + const removeApprovalWorkflowCallback = useCallback(() => { if (!initialApprovalWorkflow) { return; } @@ -66,7 +66,7 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true Navigation.dismissModal(); InteractionManager.runAfterInteractions(() => { // Remove the approval workflow using the initial data as it could be already edited - Workflow.removeApprovalWorkflow(route.params.policyID, initialApprovalWorkflow); + removeApprovalWorkflow(route.params.policyID, initialApprovalWorkflow); }); }, [initialApprovalWorkflow, route.params.policyID]); @@ -92,8 +92,7 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true }, [personalDetails, policy, route.params.firstApproverEmail]); // eslint-disable-next-line rulesdir/no-negated-variables - const shouldShowNotFoundView = - (isEmptyObject(policy) && !isLoadingReportData) || !PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPendingDeletePolicy(policy) || !currentApprovalWorkflow; + const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !isPolicyAdmin(policy) || isPendingDeletePolicy(policy) || !currentApprovalWorkflow; // Set the initial approval workflow when the page is loaded useEffect(() => { @@ -102,10 +101,10 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true } if (!currentApprovalWorkflow) { - return Workflow.clearApprovalWorkflow(); + return clearApprovalWorkflow(); } - Workflow.setApprovalWorkflow({ + setApprovalWorkflow({ ...currentApprovalWorkflow, availableMembers: [...currentApprovalWorkflow.members, ...defaultWorkflowMembers], usedApproverEmails, @@ -127,8 +126,8 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true { formRef.current?.scrollTo({y: 0, animated: true}); }} @@ -160,7 +159,7 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true setIsDeleteModalVisible(false)} prompt={translate('workflowsEditApprovalsPage.deletePrompt')} confirmText={translate('common.delete')}