Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

49954 approval workflow editing #54178

Open
wants to merge 14 commits into
base: main
Choose a base branch
from
Open
135 changes: 133 additions & 2 deletions src/libs/WorkflowUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -200,6 +201,27 @@ type ConvertApprovalWorkflowToPolicyEmployeesParams = {
type: ValueOf<typeof CONST.APPROVAL_WORKFLOW.TYPE>;
};

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 & {
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.
Expand Down Expand Up @@ -281,5 +303,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};
58 changes: 57 additions & 1 deletion src/pages/workspace/WorkspaceMembersPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,14 +41,16 @@ import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
import {getDisplayNameForParticipant} from '@libs/ReportUtils';
import {convertPolicyEmployeesToApprovalWorkflows, updateWorkflowDataOnApproverRemoval} from '@libs/WorkflowUtils';
import * as Modal from '@userActions/Modal';
import * as Member from '@userActions/Policy/Member';
import * as Policy from '@userActions/Policy/Policy';
import * as Workflow from '@userActions/Workflow';
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';
Expand Down Expand Up @@ -86,6 +88,7 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
const isOfflineAndNoMemberDataAvailable = isEmptyObject(policy?.employeeList) && isOffline;
const prevPersonalDetails = usePrevious(personalDetails);
const {translate, formatPhoneNumber, preferredLocale} = useLocalize();
const ownerDetails = personalDetails?.[policy?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? ({} as PersonalDetails);

// We need to use isSmallScreenWidth instead of shouldUseNarrowLayout to apply the correct modal type for the decision modal
// eslint-disable-next-line rulesdir/prefer-shouldUseNarrowLayout-instead-of-isSmallScreenWidth
Expand All @@ -104,6 +107,17 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson
const isFocused = useIsFocused();
const policyID = route.params.policyID;

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(() => {
Expand Down Expand Up @@ -222,8 +236,50 @@ function WorkspaceMembersPage({personalDetails, route, policy, currentUserPerson

// Remove the admin from the list
const accountIDsToRemove = session?.accountID ? selectedEmployees.filter((id) => id !== session.accountID) : selectedEmployees;

// Check if any of the account IDs are approvers
const hasApprovers = accountIDsToRemove.some((accountID) => Member.isApprover(policy, accountID));

if (!hasApprovers) {
setSelectedEmployees([]);
setRemoveMembersConfirmModalVisible(false);

InteractionManager.runAfterInteractions(() => {
Member.removeMembers(accountIDsToRemove, route.params.policyID);
});

return;
}

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;

Workflow.removeApprovalWorkflow(policyID, updatedWorkflow);
} else {
Workflow.updateApprovalWorkflow(policyID, workflow, [], []);
}
});
});

setSelectedEmployees([]);
setRemoveMembersConfirmModalVisible(false);

InteractionManager.runAfterInteractions(() => {
Member.removeMembers(accountIDsToRemove, route.params.policyID);
});
Expand Down
50 changes: 48 additions & 2 deletions src/pages/workspace/members/WorkspaceMemberDetailsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavig
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import * as PolicyUtils 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';
Expand All @@ -36,6 +37,7 @@ import variables from '@styles/variables';
import * as Card from '@userActions/Card';
import * as CompanyCards from '@userActions/CompanyCards';
import * as Member from '@userActions/Policy/Member';
import * as Workflow from '@userActions/Workflow';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
Expand Down Expand Up @@ -81,10 +83,22 @@ 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(() => {
return personalDetails?.[policy?.ownerAccountID ?? CONST.DEFAULT_NUMBER_ID] ?? ({} as PersonalDetails);
}, [personalDetails, policy?.ownerAccountID]);
const policyOwnerDisplayName = formatPhoneNumber(PersonalDetailsUtils.getDisplayNameOrDefault(ownerDetails)) ?? policy?.owner ?? '';
const hasMultipleFeeds = Object.values(CardUtils.getCompanyFeeds(cardFeeds)).filter((feed) => !feed.pending).length > 0;
const paymentAccountID = cardSettings?.paymentBankAccountID ?? CONST.DEFAULT_NUMBER_ID;
const policyApproverEmail = policy?.approver;
const {approvalWorkflows} = useMemo(
() =>
convertPolicyEmployeesToApprovalWorkflows({
employees: policy?.employeeList ?? {},
defaultApprover: policyApproverEmail ?? policy?.owner ?? '',
personalDetails: personalDetails ?? {},
}),
[personalDetails, policy?.employeeList, policy?.owner, policyApproverEmail],
);

useEffect(() => {
CompanyCards.openPolicyCompanyCardsPage(policyID, workspaceAccountID);
Expand Down Expand Up @@ -157,11 +171,43 @@ function WorkspaceMemberDetailsPage({personalDetails, policy, route}: WorkspaceM
setIsRemoveMemberConfirmModalVisible(true);
};

const removeUser = useCallback(() => {
// Function to remove a member and close the modal
const removeMemberAndCloseModal = useCallback(() => {
Member.removeMembers([accountID], policyID);
setIsRemoveMemberConfirmModalVisible(false);
}, [accountID, policyID]);

const removeUser = useCallback(() => {
const ownerEmail = ownerDetails?.login;
const removedApprover = personalDetails?.[accountID];

// If the user is not an approver, proceed with member removal
if (!Member.isApprover(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;

Workflow.removeApprovalWorkflow(policyID, updatedWorkflow);
} else {
Workflow.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]);
Expand Down
Loading
Loading