diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 1b13d270b6891..bcd4212d561ce 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -4,6 +4,7 @@ import CONST from '@src/CONST'; import type {ApprovalWorkflowOnyx, Approver, Member} from '@src/types/onyx/ApprovalWorkflow'; import type ApprovalWorkflow from '@src/types/onyx/ApprovalWorkflow'; import type {PersonalDetailsList} from '@src/types/onyx/PersonalDetails'; +import type PersonalDetails from '@src/types/onyx/PersonalDetails'; import type {PolicyEmployeeList} from '@src/types/onyx/PolicyEmployee'; const INITIAL_APPROVAL_WORKFLOW: ApprovalWorkflowOnyx = { @@ -157,7 +158,7 @@ function convertPolicyEmployeesToApprovalWorkflows({employees, defaultApprover, return 1; } - return (a.approvers.at(0)?.displayName ?? '-1').localeCompare(b.approvers.at(0)?.displayName ?? '-1'); + return (a.approvers.at(0)?.displayName ?? CONST.DEFAULT_NUMBER_ID).toString().localeCompare((b.approvers.at(0)?.displayName ?? CONST.DEFAULT_NUMBER_ID).toString()); }); // Add a default workflow if one doesn't exist (no employees submit to the default approver) @@ -200,6 +201,32 @@ type ConvertApprovalWorkflowToPolicyEmployeesParams = { type: ValueOf; }; +type UpdateWorkflowDataOnApproverRemovalParams = { + /** + * An array of approval workflows that need to be updated. + */ + approvalWorkflows: ApprovalWorkflow[]; + /** + * The email of the approver being removed + */ + removedApprover: PersonalDetails; + /** + * The email of the workspace owner + */ + ownerDetails: PersonalDetails; +}; + +type UpdateWorkflowDataOnApproverRemovalResult = Array< + ApprovalWorkflow & { + /** + * @property {boolean} [removeApprovalWorkflow] - A flag that determines if the approval workflow should be removed. + * - `true`: Indicates the approval workflow needs to be removed. + * - `false` or `undefined`: No removal is required; the workflow will be updated instead. + */ + removeApprovalWorkflow?: boolean; + } +>; + /** * This function converts an approval workflow into a list of policy employees. * An optimized list is created that contains only the updated employees to maintain minimal data changes. @@ -281,5 +308,114 @@ function convertApprovalWorkflowToPolicyEmployees({ return updatedEmployeeList; } +function updateWorkflowDataOnApproverRemoval({approvalWorkflows, removedApprover, ownerDetails}: UpdateWorkflowDataOnApproverRemovalParams): UpdateWorkflowDataOnApproverRemovalResult { + const defaultWorkflow = approvalWorkflows.find((workflow) => workflow.isDefault); + const removedApproverEmail = removedApprover.login; + const ownerEmail = ownerDetails.login; + const ownerAvatar = ownerDetails.avatar ?? ''; + const ownerDisplayName = ownerDetails.displayName ?? ''; + + return approvalWorkflows.flatMap((workflow) => { + const [currentApprover] = workflow.approvers; + const isSingleApprover = workflow.approvers.length === 1; + const isMultipleApprovers = workflow.approvers.length > 1; + const isApproverToRemove = currentApprover?.email === removedApproverEmail; + const defaultHasOwner = defaultWorkflow?.approvers.some((approver) => approver.email === ownerEmail); + + if (workflow.isDefault) { + // Handle default workflow + if (isSingleApprover && isApproverToRemove && currentApprover?.email !== ownerEmail) { + return { + ...workflow, + approvers: [ + { + ...currentApprover, + avatar: ownerAvatar, + displayName: ownerDisplayName, + email: ownerEmail ?? '', + }, + ], + }; + } + return workflow; + } + + if (isSingleApprover) { + // Remove workflows with a single approver when owner is the approver + if (currentApprover?.email === ownerEmail) { + return { + ...workflow, + removeApprovalWorkflow: true, + }; + } + + // Handle case where the approver is to be removed + if (isApproverToRemove) { + // Remove workflow if the default workflow includes the owner or approver is to be replaced + if (defaultHasOwner) { + return { + ...workflow, + removeApprovalWorkflow: true, + }; + } + + // Replace the approver with owner details + return { + ...workflow, + approvers: [ + { + ...currentApprover, + avatar: ownerAvatar, + displayName: ownerDisplayName, + email: ownerEmail ?? '', + }, + ], + }; + } + } + + if (isMultipleApprovers && workflow.approvers.some((item) => item.email === removedApproverEmail)) { + const removedApproverIndex = workflow.approvers.findIndex((item) => item.email === removedApproverEmail); + + // If the removed approver is the first in the list, return an empty array + if (removedApproverIndex === 0) { + return { + ...workflow, + removeApprovalWorkflow: true, + }; + } + + const updateApprovers = workflow.approvers.slice(0, removedApproverIndex); + const updateApproversHasOwner = updateApprovers.some((approver) => approver.email === ownerEmail); + + // If the owner is already in the approvers list, return the workflow with the updated approvers + if (updateApproversHasOwner) { + return { + ...workflow, + approvers: updateApprovers, + }; + } + + // Update forwardsTo if necessary and prepare the new approver object + const updatedApprovers = updateApprovers.flatMap((item) => (item.forwardsTo === removedApproverEmail ? {...item, forwardsTo: ownerEmail} : item)); + + const newApprover = { + email: ownerEmail ?? '', + forwardsTo: undefined, + avatar: ownerDetails?.avatar ?? '', + displayName: ownerDetails?.displayName ?? '', + isCircularReference: workflow.approvers.at(removedApproverIndex)?.isCircularReference, + }; + + return { + ...workflow, + approvers: [...updatedApprovers, newApprover], + }; + } + + // Return the unchanged workflow in other cases + return workflow; + }); +} -export {calculateApprovers, convertPolicyEmployeesToApprovalWorkflows, convertApprovalWorkflowToPolicyEmployees, INITIAL_APPROVAL_WORKFLOW}; +export {calculateApprovers, convertPolicyEmployeesToApprovalWorkflows, convertApprovalWorkflowToPolicyEmployees, INITIAL_APPROVAL_WORKFLOW, updateWorkflowDataOnApproverRemoval}; diff --git a/src/pages/workspace/WorkspaceMembersPage.tsx b/src/pages/workspace/WorkspaceMembersPage.tsx index c2e34e1ceb9e9..daf6d6d972ab7 100644 --- a/src/pages/workspace/WorkspaceMembersPage.tsx +++ b/src/pages/workspace/WorkspaceMembersPage.tsx @@ -42,6 +42,7 @@ import { removeMembers, updateWorkspaceMembersRole, } from '@libs/actions/Policy/Member'; +import {removeApprovalWorkflow as removeApprovalWorkflowAction, updateApprovalWorkflow} from '@libs/actions/Workflow'; import {canUseTouchScreen} from '@libs/DeviceCapabilities'; import {formatPhoneNumber as formatPhoneNumberUtil} from '@libs/LocalePhoneNumber'; import Log from '@libs/Log'; @@ -52,13 +53,14 @@ import {isPersonalDetailsReady, sortAlphabetically} from '@libs/OptionsListUtils import {getDisplayNameOrDefault, getPersonalDetailsByIDs} from '@libs/PersonalDetailsUtils'; import {getMemberAccountIDsForWorkspace, isDeletedPolicyEmployee, isExpensifyTeam, isPaidGroupPolicy, isPolicyAdmin as isPolicyAdminUtils} from '@libs/PolicyUtils'; import {getDisplayNameForParticipant} from '@libs/ReportUtils'; +import {convertPolicyEmployeesToApprovalWorkflows, updateWorkflowDataOnApproverRemoval} from '@libs/WorkflowUtils'; import {close} from '@userActions/Modal'; import {dismissAddedWithPrimaryLoginMessages, setPolicyPreventSelfApproval} from '@userActions/Policy/Policy'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; -import type {PersonalDetailsList, PolicyEmployeeList} from '@src/types/onyx'; +import type {PersonalDetails, PersonalDetailsList, PolicyEmployeeList} from '@src/types/onyx'; import type {Errors, PendingAction} from '@src/types/onyx/OnyxCommon'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; import type {WithPolicyAndFullscreenLoadingProps} from './withPolicyAndFullscreenLoading'; @@ -114,6 +116,18 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson const isFocused = useIsFocused(); const policyID = route.params.policyID; + const ownerDetails = personalDetails?.[policy?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? ({} as PersonalDetails); + const policyApproverEmail = policy?.approver; + const {approvalWorkflows} = useMemo( + () => + convertPolicyEmployeesToApprovalWorkflows({ + employees: policy?.employeeList ?? {}, + defaultApprover: policyApproverEmail ?? policy?.owner ?? '', + personalDetails: personalDetails ?? {}, + }), + [personalDetails, policy?.employeeList, policy?.owner, policyApproverEmail], + ); + const canSelectMultiple = isPolicyAdmin && (shouldUseNarrowLayout ? selectionMode?.isEnabled : true); const confirmModalPrompt = useMemo(() => { @@ -229,6 +243,33 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson // 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)); + + if (hasApprovers) { + const ownerEmail = ownerDetails.login; + accountIDsToRemove.forEach((accountID) => { + const removedApprover = personalDetails?.[accountID]; + if (!removedApprover?.login || !ownerEmail) { + return; + } + const updatedWorkflows = updateWorkflowDataOnApproverRemoval({ + approvalWorkflows, + removedApprover, + ownerDetails, + }); + updatedWorkflows.forEach((workflow) => { + if (workflow?.removeApprovalWorkflow) { + const {removeApprovalWorkflow, ...updatedWorkflow} = workflow; + removeApprovalWorkflowAction(policyID, updatedWorkflow); + } else { + updateApprovalWorkflow(policyID, workflow, [], []); + } + }); + }); + } + setSelectedEmployees([]); setRemoveMembersConfirmModalVisible(false); InteractionManager.runAfterInteractions(() => { diff --git a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx index 5ef131abf32d2..32513e6db3c77 100644 --- a/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx +++ b/src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx @@ -21,12 +21,14 @@ import usePrevious from '@hooks/usePrevious'; import useStyleUtils from '@hooks/useStyleUtils'; import useThemeStyles from '@hooks/useThemeStyles'; import {setPolicyPreventSelfApproval} from '@libs/actions/Policy/Policy'; +import {removeApprovalWorkflow as removeApprovalWorkflowAction, updateApprovalWorkflow} from '@libs/actions/Workflow'; import {getAllCardsForWorkspace, getCardFeedIcon, getCompanyFeeds, maskCardNumber} from '@libs/CardUtils'; import {convertToDisplayString} from '@libs/CurrencyUtils'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import {getDisplayNameOrDefault} from '@libs/PersonalDetailsUtils'; import {getWorkspaceAccountID} from '@libs/PolicyUtils'; import shouldRenderTransferOwnerButton from '@libs/shouldRenderTransferOwnerButton'; +import {convertPolicyEmployeesToApprovalWorkflows, updateWorkflowDataOnApproverRemoval} from '@libs/WorkflowUtils'; import Navigation from '@navigation/Navigation'; import type {SettingsNavigatorParamList} from '@navigation/types'; import NotFoundPage from '@pages/ErrorPage/NotFoundPage'; @@ -79,13 +81,24 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM const isSelectedMemberCurrentUser = accountID === currentUserPersonalDetails?.accountID; const isCurrentUserAdmin = policy?.employeeList?.[personalDetails?.[currentUserPersonalDetails?.accountID]?.login ?? '']?.role === CONST.POLICY.ROLE.ADMIN; const isCurrentUserOwner = policy?.owner === currentUserPersonalDetails?.login; - const ownerDetails = personalDetails?.[policy?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? ({} as PersonalDetails); + const ownerDetails = useMemo(() => personalDetails?.[policy?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? ({} as PersonalDetails), [personalDetails, policy?.ownerAccountID]); const policyOwnerDisplayName = formatPhoneNumber(getDisplayNameOrDefault(ownerDetails)) ?? policy?.owner ?? ''; const hasMultipleFeeds = Object.values(getCompanyFeeds(cardFeeds)).filter((feed) => !feed.pending).length > 0; const workspaceCards = getAllCardsForWorkspace(workspaceAccountID, cardList); const hasWorkspaceCardsAssigned = !!workspaceCards && !!Object.values(workspaceCards).length; + const policyApproverEmail = policy?.approver; + const {approvalWorkflows} = useMemo( + () => + convertPolicyEmployeesToApprovalWorkflows({ + employees: policy?.employeeList ?? {}, + defaultApprover: policyApproverEmail ?? policy?.owner ?? '', + personalDetails: personalDetails ?? {}, + }), + [personalDetails, policy?.employeeList, policy?.owner, policyApproverEmail], + ); + useEffect(() => { openPolicyCompanyCardsPage(policyID, workspaceAccountID); }, [policyID, workspaceAccountID]); @@ -146,7 +159,8 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM setIsRemoveMemberConfirmModalVisible(true); }; - const removeUser = useCallback(() => { + // Function to remove a member and close the modal + const removeMemberAndCloseModal = useCallback(() => { removeMembers([accountID], policyID); const previousEmployeesCount = Object.keys(policy?.employeeList ?? {}).length; const remainingEmployeeCount = previousEmployeesCount - 1; @@ -157,6 +171,37 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM setIsRemoveMemberConfirmModalVisible(false); }, [accountID, policy?.employeeList, policy?.preventSelfApproval, policyID, route.params.policyID]); + const removeUser = useCallback(() => { + const ownerEmail = ownerDetails?.login; + const removedApprover = personalDetails?.[accountID]; + + // If the user is not an approver, proceed with member removal + if (!isApproverUserAction(policy, accountID) || !removedApprover?.login || !ownerEmail) { + removeMemberAndCloseModal(); + return; + } + + // Update approval workflows after approver removal + const updatedWorkflows = updateWorkflowDataOnApproverRemoval({ + approvalWorkflows, + removedApprover, + ownerDetails, + }); + + updatedWorkflows.forEach((workflow) => { + if (workflow?.removeApprovalWorkflow) { + const {removeApprovalWorkflow, ...updatedWorkflow} = workflow; + + removeApprovalWorkflowAction(policyID, updatedWorkflow); + } else { + updateApprovalWorkflow(policyID, workflow, [], []); + } + }); + + // Remove the member and close the modal + removeMemberAndCloseModal(); + }, [accountID, approvalWorkflows, ownerDetails, personalDetails, policy, policyID, removeMemberAndCloseModal]); + const navigateToProfile = useCallback(() => { Navigation.navigate(ROUTES.PROFILE.getRoute(accountID, Navigation.getActiveRoute())); }, [accountID]); diff --git a/tests/unit/WorkflowUtilsTest.ts b/tests/unit/WorkflowUtilsTest.ts index effc35f2609d0..4fe8d1384aa1e 100644 --- a/tests/unit/WorkflowUtilsTest.ts +++ b/tests/unit/WorkflowUtilsTest.ts @@ -440,4 +440,172 @@ describe('WorkflowUtils', () => { }); }); }); + + describe('updateWorkflowDataOnApproverRemoval', () => { + it('Should remove Workflow 2 if its approvers are removed and it has no approvers, with Workspace (default) having the approver as the Workspace Owner.', () => { + const approvalWorkflow1: ApprovalWorkflow = { + members: [buildMember(1), buildMember(2)], + approvers: [buildApprover(1)], + isDefault: true, + }; + const approvalWorkflow2: ApprovalWorkflow = { + members: [buildMember(1), buildMember(2)], + approvers: [buildApprover(2)], + isDefault: false, + }; + + const ownerDetails = personalDetails[1]; + const removedApprover = personalDetails[2]; + + if (!removedApprover || !ownerDetails) { + return; + } + + const updateWorkflowDataOnApproverRemoval = WorkflowUtils.updateWorkflowDataOnApproverRemoval({ + approvalWorkflows: [approvalWorkflow1, approvalWorkflow2], + removedApprover, + ownerDetails, + }); + + expect(updateWorkflowDataOnApproverRemoval).toEqual([approvalWorkflow1, {...approvalWorkflow2, removeApprovalWorkflow: true}]); + }); + it('Should replace the approvers in Workflow 2 with the Workspace Owner if it has no approvers and the approver in Workspace (default) is different from the Workspace Owner', () => { + const approvalWorkflow1: ApprovalWorkflow = { + members: [buildMember(1), buildMember(2)], + approvers: [buildApprover(3)], + isDefault: true, + }; + const approvalWorkflow2: ApprovalWorkflow = { + members: [buildMember(1), buildMember(2)], + approvers: [buildApprover(2)], + isDefault: false, + }; + + const ownerDetails = personalDetails[1]; + const removedApprover = personalDetails[2]; + + if (!removedApprover || !ownerDetails) { + return; + } + + const updateWorkflowDataOnApproverRemoval = WorkflowUtils.updateWorkflowDataOnApproverRemoval({ + approvalWorkflows: [approvalWorkflow1, approvalWorkflow2], + removedApprover, + ownerDetails, + }); + + expect(updateWorkflowDataOnApproverRemoval).toEqual([approvalWorkflow1, {...approvalWorkflow2, approvers: [buildApprover(1)]}]); + }); + it('Should remove Workflow 2 if its approver is the Workspace Owner and the default Workspace approver is removed.', () => { + const approvalWorkflow1: ApprovalWorkflow = { + members: [buildMember(1), buildMember(2)], + approvers: [buildApprover(3)], + isDefault: true, + }; + const approvalWorkflow2: ApprovalWorkflow = { + members: [buildMember(1), buildMember(2)], + approvers: [buildApprover(1)], + isDefault: false, + }; + + const ownerDetails = personalDetails[1]; + const removedApprover = personalDetails[3]; + + if (!removedApprover || !ownerDetails) { + return; + } + + const updateWorkflowDataOnApproverRemoval = WorkflowUtils.updateWorkflowDataOnApproverRemoval({ + approvalWorkflows: [approvalWorkflow1, approvalWorkflow2], + removedApprover, + ownerDetails, + }); + + expect(updateWorkflowDataOnApproverRemoval).toEqual([ + {...approvalWorkflow1, approvers: [buildApprover(1)]}, + {...approvalWorkflow2, removeApprovalWorkflow: true}, + ]); + }); + it('Should replace the latest approver of Workflow 2 with the Workspace Owner if the latest approver of Workflow 2 is removed', () => { + const approvalWorkflow1: ApprovalWorkflow = { + members: [buildMember(1), buildMember(2)], + approvers: [buildApprover(1)], + isDefault: true, + }; + const approvalWorkflow2: ApprovalWorkflow = { + members: [buildMember(1), buildMember(2)], + approvers: [buildApprover(2), buildApprover(3), buildApprover(4)], + isDefault: false, + }; + + const ownerDetails = personalDetails[1]; + const removedApprover = personalDetails[4]; + + if (!removedApprover || !ownerDetails) { + return; + } + + const updateWorkflowDataOnApproverRemoval = WorkflowUtils.updateWorkflowDataOnApproverRemoval({ + approvalWorkflows: [approvalWorkflow1, approvalWorkflow2], + removedApprover, + ownerDetails, + }); + + expect(updateWorkflowDataOnApproverRemoval).toEqual([approvalWorkflow1, {...approvalWorkflow2, approvers: [buildApprover(2), buildApprover(3), buildApprover(1)]}]); + }); + it('Should remove the approvers that have submitsTo set to the removed approver, update the removed approver to the Workspace Owner, and ensure there was a previous approver before this one', () => { + const approvalWorkflow1: ApprovalWorkflow = { + members: [buildMember(1), buildMember(2)], + approvers: [buildApprover(1)], + isDefault: true, + }; + const approvalWorkflow2: ApprovalWorkflow = { + members: [buildMember(1), buildMember(2)], + approvers: [buildApprover(2), buildApprover(3), buildApprover(4)], + isDefault: false, + }; + + const ownerDetails = personalDetails[1]; + const removedApprover = personalDetails[3]; + + if (!removedApprover || !ownerDetails) { + return; + } + + const updateWorkflowDataOnApproverRemoval = WorkflowUtils.updateWorkflowDataOnApproverRemoval({ + approvalWorkflows: [approvalWorkflow1, approvalWorkflow2], + removedApprover, + ownerDetails, + }); + + expect(updateWorkflowDataOnApproverRemoval).toEqual([approvalWorkflow1, {...approvalWorkflow2, approvers: [buildApprover(2), buildApprover(1)]}]); + }); + it('Should remove Workflow 2 if it has no approvers and the default Workspace approver is the approve', () => { + const approvalWorkflow1: ApprovalWorkflow = { + members: [buildMember(1), buildMember(2)], + approvers: [buildApprover(1)], + isDefault: true, + }; + const approvalWorkflow2: ApprovalWorkflow = { + members: [buildMember(1), buildMember(2)], + approvers: [buildApprover(2), buildApprover(3), buildApprover(4)], + isDefault: false, + }; + + const ownerDetails = personalDetails[1]; + const removedApprover = personalDetails[2]; + + if (!removedApprover || !ownerDetails) { + return; + } + + const updateWorkflowDataOnApproverRemoval = WorkflowUtils.updateWorkflowDataOnApproverRemoval({ + approvalWorkflows: [approvalWorkflow1, approvalWorkflow2], + removedApprover, + ownerDetails, + }); + + expect(updateWorkflowDataOnApproverRemoval).toEqual([approvalWorkflow1, {...approvalWorkflow2, removeApprovalWorkflow: true}]); + }); + }); });