Skip to content

Commit

Permalink
Merge pull request Expensify#46189 from software-mansion-labs/approva…
Browse files Browse the repository at this point in the history
…l-workflows/onyx-setup

[No QA][CRITICAL] [Advanced Approval Workflows] Implement Onyx Actions (API calls & Onyx writes)
  • Loading branch information
tgolen authored Jul 26, 2024
2 parents 4727f72 + 2845492 commit 2583e96
Show file tree
Hide file tree
Showing 9 changed files with 1,003 additions and 0 deletions.
4 changes: 4 additions & 0 deletions src/ONYXKEYS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,9 @@ const ONYXKEYS = {

NVP_PRIVATE_CANCELLATION_DETAILS: 'nvp_private_cancellationDetails',

/** Stores the information about currently edited advanced approval workflow */
APPROVAL_WORKFLOW: 'approvalWorkflow',

/** Collection Keys */
COLLECTION: {
DOWNLOAD: 'download_',
Expand Down Expand Up @@ -856,6 +859,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.NVP_PRIVATE_AMOUNT_OWED]: number;
[ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: number;
[ONYXKEYS.NVP_PRIVATE_CANCELLATION_DETAILS]: OnyxTypes.CancellationDetails[];
[ONYXKEYS.APPROVAL_WORKFLOW]: OnyxTypes.ApprovalWorkflow;
};

type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping;
Expand Down
13 changes: 13 additions & 0 deletions src/libs/API/parameters/WorkspaceApprovalParams.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type {PolicyEmployee} from '@src/types/onyx';

type CreateWorkspaceApprovalParams = {
authToken: string;
policyID: string;
employees: PolicyEmployee[];
};

type UpdateWorkspaceApprovalParams = CreateWorkspaceApprovalParams;

type RemoveWorkspaceApprovalParams = CreateWorkspaceApprovalParams;

export type {CreateWorkspaceApprovalParams, UpdateWorkspaceApprovalParams, RemoveWorkspaceApprovalParams};
1 change: 1 addition & 0 deletions src/libs/API/parameters/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -267,3 +267,4 @@ export type {default as UpdateNetSuiteCustomersJobsParams} from './UpdateNetSuit
export type {default as CopyExistingPolicyConnectionParams} from './CopyExistingPolicyConnectionParams';
export type {default as ExportSearchItemsToCSVParams} from './ExportSearchItemsToCSVParams';
export type {default as UpdateExpensifyCardLimitParams} from './UpdateExpensifyCardLimitParams';
export type {CreateWorkspaceApprovalParams, UpdateWorkspaceApprovalParams, RemoveWorkspaceApprovalParams} from './WorkspaceApprovalParams';
6 changes: 6 additions & 0 deletions src/libs/API/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,9 @@ const WRITE_COMMANDS = {
UPDATE_SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES_EXPORT_ACCOUNT: 'UpdateSageIntacctNonreimbursableExpensesExportAccount',
UPDATE_SAGE_INTACCT_NON_REIMBURSABLE_EXPENSES_EXPORT_VENDOR: 'UpdateSageIntacctNonreimbursableExpensesExportVendor',
EXPORT_SEARCH_ITEMS_TO_CSV: 'ExportSearchToCSV',
CREATE_WORKSPACE_APPROVAL: 'CreateWorkspaceApproval',
UPDATE_WORKSPACE_APPROVAL: 'UpdateWorkspaceApproval',
REMOVE_WORKSPACE_APPROVAL: 'RemoveWorkspaceApproval',
} as const;

type WriteCommand = ValueOf<typeof WRITE_COMMANDS>;
Expand Down Expand Up @@ -644,6 +647,9 @@ type WriteCommandParameters = {
[WRITE_COMMANDS.UPDATE_SAGE_INTACCT_SYNC_TAX_CONFIGURATION]: Parameters.UpdateSageIntacctGenericTypeParams<'enabled', boolean>;
[WRITE_COMMANDS.UPDATE_SAGE_INTACCT_USER_DIMENSION]: Parameters.UpdateSageIntacctGenericTypeParams<'dimensions', string>;
[WRITE_COMMANDS.EXPORT_SEARCH_ITEMS_TO_CSV]: Parameters.ExportSearchItemsToCSVParams;
[WRITE_COMMANDS.CREATE_WORKSPACE_APPROVAL]: Parameters.CreateWorkspaceApprovalParams;
[WRITE_COMMANDS.UPDATE_WORKSPACE_APPROVAL]: Parameters.UpdateWorkspaceApprovalParams;
[WRITE_COMMANDS.REMOVE_WORKSPACE_APPROVAL]: Parameters.RemoveWorkspaceApprovalParams;
};

const READ_COMMANDS = {
Expand Down
173 changes: 173 additions & 0 deletions src/libs/WorkflowUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import lodashMapKeys from 'lodash/mapKeys';
import type {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 {PolicyEmployeeList} from '@src/types/onyx/PolicyEmployee';

type GetApproversParams = {
/**
* List of employees in the policy
*/
employees: PolicyEmployeeList;

/**
* Personal details of the employees where the key is the email
*/
personalDetailsByEmail: PersonalDetailsList;

/**
* Email of the first approver
*/
firstEmail: string;
};

/** Get the list of approvers for a given workflow */
function getApprovalWorkflowApprovers({employees, firstEmail, personalDetailsByEmail}: GetApproversParams): Approver[] {
const approvers: Approver[] = [];
// Keep track of approver emails to detect circular references
const currentApproverEmails = new Set<string>();

let nextEmail: string | undefined = firstEmail;
while (nextEmail) {
if (!employees[nextEmail]) {
break;
}

const isCircularReference = currentApproverEmails.has(nextEmail);
approvers.push({
email: nextEmail,
forwardsTo: employees[nextEmail].forwardsTo,
avatar: personalDetailsByEmail[nextEmail]?.avatar,
displayName: personalDetailsByEmail[nextEmail]?.displayName,
isInMultipleWorkflows: false,
isCircularReference,
});

// If we've already seen this approver, break to prevent infinite loop
if (isCircularReference) {
break;
}
currentApproverEmails.add(nextEmail);

// If there is a forwardsTo, set the next approver to the forwardsTo
nextEmail = employees[nextEmail].forwardsTo;
}

return approvers;
}

type ConvertPolicyEmployeesToApprovalWorkflowsParams = {
/**
* List of employees in the policy
*/
employees: PolicyEmployeeList;

/**
* Personal details of the employees
*/
personalDetails: PersonalDetailsList;

/**
* Email of the default approver for the policy
*/
defaultApprover: string;
};

/** Convert a list of policy employees to a list of approval workflows */
function convertPolicyEmployeesToApprovalWorkflows({employees, defaultApprover, personalDetails}: ConvertPolicyEmployeesToApprovalWorkflowsParams): ApprovalWorkflow[] {
const approvalWorkflows: Record<string, ApprovalWorkflow> = {};

// Keep track of how many times each approver is used to detect approvers in multiple workflows
const approverCountsByEmail: Record<string, number> = {};
const personalDetailsByEmail = lodashMapKeys(personalDetails, (value, key) => value?.login ?? key);

// Add each employee to the appropriate workflow
Object.values(employees).forEach((employee) => {
const {email, submitsTo} = employee;
if (!email || !submitsTo) {
return;
}

const member: Member = {email, avatar: personalDetailsByEmail[email]?.avatar, displayName: personalDetailsByEmail[email]?.displayName ?? email};
if (!approvalWorkflows[submitsTo]) {
const approvers = getApprovalWorkflowApprovers({employees, firstEmail: submitsTo, personalDetailsByEmail});
approvers.forEach((approver) => (approverCountsByEmail[approver.email] = (approverCountsByEmail[approver.email] ?? 0) + 1));

approvalWorkflows[submitsTo] = {
members: [],
approvers,
isDefault: defaultApprover === submitsTo,
isBeingEdited: false,
};
}
approvalWorkflows[submitsTo].members.push(member);
});

// Sort the workflows by the first approver's name (default workflow has priority)
const sortedApprovalWorkflows = Object.values(approvalWorkflows).sort((a, b) => {
if (a.isDefault) {
return -1;
}

return (a.approvers[0]?.displayName ?? '-1').localeCompare(b.approvers[0]?.displayName ?? '-1');
});

// Add a flag to each approver to indicate if they are in multiple workflows
return sortedApprovalWorkflows.map((workflow) => ({
...workflow,
approvers: workflow.approvers.map((approver) => ({
...approver,
isInMultipleWorkflows: approverCountsByEmail[approver.email] > 1,
})),
}));
}

type ConvertApprovalWorkflowToPolicyEmployeesParams = {
/**
* Approval workflow to convert
*/
approvalWorkflow: ApprovalWorkflow;

/**
* Current list of employees in the policy
*/
employeeList: PolicyEmployeeList;

/**
* Should the workflow be removed from the employees
*/
removeWorkflow?: boolean;
};

/** Convert an approval workflow to a list of policy employees */
function convertApprovalWorkflowToPolicyEmployees({approvalWorkflow, employeeList, removeWorkflow = false}: ConvertApprovalWorkflowToPolicyEmployeesParams): PolicyEmployeeList {
const updatedEmployeeList: PolicyEmployeeList = {};
const firstApprover = approvalWorkflow.approvers.at(0);

if (!firstApprover) {
throw new Error('Approval workflow must have at least one approver');
}

approvalWorkflow.approvers.forEach((approver, index) => {
if (updatedEmployeeList[approver.email]) {
return;
}

const nextApprover = approvalWorkflow.approvers.at(index + 1);
updatedEmployeeList[approver.email] = {
...employeeList[approver.email],
forwardsTo: removeWorkflow ? undefined : nextApprover?.email,
};
});

approvalWorkflow.members.forEach(({email}) => {
updatedEmployeeList[email] = {
...(updatedEmployeeList[email] ? updatedEmployeeList[email] : employeeList[email]),
submitsTo: removeWorkflow ? undefined : firstApprover.email,
};
});

return updatedEmployeeList;
}

export {getApprovalWorkflowApprovers, convertPolicyEmployeesToApprovalWorkflows, convertApprovalWorkflowToPolicyEmployees};
Loading

0 comments on commit 2583e96

Please sign in to comment.