diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index 00f37508612d..4bf7987f9ceb 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -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_', @@ -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; diff --git a/src/libs/API/parameters/WorkspaceApprovalParams.ts b/src/libs/API/parameters/WorkspaceApprovalParams.ts new file mode 100644 index 000000000000..67f96b7852e7 --- /dev/null +++ b/src/libs/API/parameters/WorkspaceApprovalParams.ts @@ -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}; diff --git a/src/libs/API/parameters/index.ts b/src/libs/API/parameters/index.ts index 997eb415a848..e16691c992f2 100644 --- a/src/libs/API/parameters/index.ts +++ b/src/libs/API/parameters/index.ts @@ -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'; diff --git a/src/libs/API/types.ts b/src/libs/API/types.ts index 70a0e91aba10..c4218b44f165 100644 --- a/src/libs/API/types.ts +++ b/src/libs/API/types.ts @@ -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; @@ -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 = { diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts new file mode 100644 index 000000000000..6e8fc3322da0 --- /dev/null +++ b/src/libs/WorkflowUtils.ts @@ -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(); + + 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 = {}; + + // Keep track of how many times each approver is used to detect approvers in multiple workflows + const approverCountsByEmail: Record = {}; + 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}; diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts new file mode 100644 index 000000000000..fe336c3d51bd --- /dev/null +++ b/src/libs/actions/Workflow.ts @@ -0,0 +1,224 @@ +import type {OnyxCollection, OnyxUpdate} from 'react-native-onyx'; +import Onyx from 'react-native-onyx'; +import * as API from '@libs/API'; +import type {CreateWorkspaceApprovalParams, RemoveWorkspaceApprovalParams, UpdateWorkspaceApprovalParams} from '@libs/API/parameters'; +import {WRITE_COMMANDS} from '@libs/API/types'; +import {convertApprovalWorkflowToPolicyEmployees} from '@libs/WorkflowUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import type {ApprovalWorkflow, Policy} from '@src/types/onyx'; +import type {Approver, Member} from '@src/types/onyx/ApprovalWorkflow'; + +let currentApprovalWorkflow: ApprovalWorkflow | undefined; +Onyx.connect({ + key: ONYXKEYS.APPROVAL_WORKFLOW, + callback: (approvalWorkflow) => { + currentApprovalWorkflow = approvalWorkflow; + }, +}); + +let allPolicies: OnyxCollection; +Onyx.connect({ + key: ONYXKEYS.COLLECTION.POLICY, + waitForCollectionCallback: true, + callback: (value) => (allPolicies = value), +}); + +let authToken: string | undefined; +Onyx.connect({ + key: ONYXKEYS.SESSION, + callback: (value) => { + authToken = value?.authToken; + }, +}); + +function createApprovalWorkflow(policyID: string, approvalWorkflow: ApprovalWorkflow) { + const policy = allPolicies?.[policyID]; + + if (!authToken || !policy) { + return; + } + + const previousEmployeeList = {...policy.employeeList}; + const previousApprovalMode = policy.approvalMode; + const updatedEmployees = convertApprovalWorkflowToPolicyEmployees({approvalWorkflow, employeeList: previousEmployeeList}); + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.APPROVAL_WORKFLOW, + value: { + isLoading: true, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + employeeList: updatedEmployees, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.APPROVAL_WORKFLOW, + value: {...approvalWorkflow, isLoading: false}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + employeeList: previousEmployeeList, + approvalMode: previousApprovalMode, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.APPROVAL_WORKFLOW, + value: null, + }, + ]; + + const parameters: CreateWorkspaceApprovalParams = {policyID, authToken, employees: Object.values(updatedEmployees)}; + API.write(WRITE_COMMANDS.CREATE_WORKSPACE_APPROVAL, parameters, {optimisticData, failureData, successData}); +} + +function updateApprovalWorkflow(policyID: string, approvalWorkflow: ApprovalWorkflow) { + const policy = allPolicies?.[policyID]; + + if (!authToken || !policy) { + return; + } + + const previousEmployeeList = {...policy.employeeList}; + const updatedEmployees = convertApprovalWorkflowToPolicyEmployees({approvalWorkflow, employeeList: previousEmployeeList}); + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.APPROVAL_WORKFLOW, + value: { + isLoading: true, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: {employeeList: updatedEmployees}, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.APPROVAL_WORKFLOW, + value: {...approvalWorkflow, isLoading: false}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: {employeeList: previousEmployeeList}, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.APPROVAL_WORKFLOW, + value: null, + }, + ]; + + const parameters: UpdateWorkspaceApprovalParams = {policyID, authToken, employees: Object.values(updatedEmployees)}; + API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_APPROVAL, parameters, {optimisticData, failureData, successData}); +} + +function removeApprovalWorkflow(policyID: string, approvalWorkflow: ApprovalWorkflow) { + const policy = allPolicies?.[policyID]; + + if (!authToken || !policy) { + return; + } + + const previousEmployeeList = {...policy.employeeList}; + const updatedEmployees = convertApprovalWorkflowToPolicyEmployees({approvalWorkflow, employeeList: previousEmployeeList, removeWorkflow: true}); + const updatedEmployeeList = {...previousEmployeeList, ...updatedEmployees}; + + // If there is more than one workflow, we need to keep the advanced approval mode (first workflow is the default) + const hasMoreThanOneWorkflow = Object.values(updatedEmployeeList).some((employee) => !!employee.submitsTo && employee.submitsTo !== policy.approver); + + const optimisticData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.APPROVAL_WORKFLOW, + value: { + isLoading: true, + }, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + employeeList: updatedEmployees, + approvalMode: hasMoreThanOneWorkflow ? CONST.POLICY.APPROVAL_MODE.ADVANCED : CONST.POLICY.APPROVAL_MODE.BASIC, + }, + }, + ]; + + const failureData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.MERGE, + key: ONYXKEYS.APPROVAL_WORKFLOW, + value: {...approvalWorkflow, isLoading: false}, + }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + employeeList: previousEmployeeList, + approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + }, + }, + ]; + + const successData: OnyxUpdate[] = [ + { + onyxMethod: Onyx.METHOD.SET, + key: ONYXKEYS.APPROVAL_WORKFLOW, + value: null, + }, + ]; + + const parameters: RemoveWorkspaceApprovalParams = {policyID, authToken, employees: Object.values(updatedEmployees)}; + API.write(WRITE_COMMANDS.REMOVE_WORKSPACE_APPROVAL, parameters, {optimisticData, failureData, successData}); +} + +function setApprovalWorkflowMembers(members: Member[]) { + Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, {members}); +} + +function setApprovalWorkflowApprover(approver: Approver, index: number) { + if (!currentApprovalWorkflow) { + return; + } + + const updatedApprovers = [...currentApprovalWorkflow.approvers]; + updatedApprovers[index] = approver; + Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, {approvers: updatedApprovers}); +} + +function setApprovalWorkflow(approvalWorkflow: ApprovalWorkflow) { + Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, approvalWorkflow); +} + +function clearApprovalWorkflow() { + Onyx.set(ONYXKEYS.APPROVAL_WORKFLOW, null); +} + +export {createApprovalWorkflow, updateApprovalWorkflow, removeApprovalWorkflow, setApprovalWorkflowMembers, setApprovalWorkflowApprover, setApprovalWorkflow, clearApprovalWorkflow}; diff --git a/src/types/onyx/ApprovalWorkflow.ts b/src/types/onyx/ApprovalWorkflow.ts new file mode 100644 index 000000000000..e1d0dd9b302a --- /dev/null +++ b/src/types/onyx/ApprovalWorkflow.ts @@ -0,0 +1,92 @@ +import type {AvatarSource} from '@libs/UserUtils'; + +/** + * Approver in the approval workflow + */ +type Approver = { + /** + * Email of the approver + */ + email: string; + + /** + * Email of the user this user forwards all approved reports to + */ + forwardsTo?: string; + + /** + * Avatar URL of the current user from their personal details + */ + avatar?: AvatarSource; + + /** + * Display name of the current user from their personal details + */ + displayName?: string; + + /** + * Is this user used as an approver in more than one workflow (used to show a warning) + */ + isInMultipleWorkflows: boolean; + + /** + * Is this approver in a circular reference (approver forwards to themselves, or a cycle of forwards) + * + * example: A -> A (self forwards) + * example: A -> B -> C -> A (cycle) + */ + isCircularReference?: boolean; +}; + +/** + * Member in the approval workflow + */ +type Member = { + /** + * Email of the member + */ + email: string; + + /** + * Avatar URL of the current user from their personal details + */ + avatar?: AvatarSource; + + /** + * Display name of the current user from their personal details + */ + displayName?: string; +}; + +/** + * Approval workflow for a group of employees + */ +type ApprovalWorkflow = { + /** + * List of member emails in the workflow + */ + members: Member[]; + + /** + * List of approvers in the workflow (the order of approvers in this array is important) + * + * The first approver in the array is the first approver in the workflow, next approver is the one they forward to, etc. + */ + approvers: Approver[]; + + /** + * Is this the default workflow for the policy (first approver of this workflow is the same as the policy's default approver) + */ + isDefault: boolean; + + /** + * Is this workflow being edited vs created + */ + isBeingEdited: boolean; + + /** Whether we are waiting for the API action to complete */ + isLoading?: boolean; +}; + +export default ApprovalWorkflow; +export type {Approver, Member}; diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 8f1a6f43dd4a..7c68211f0621 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -1,5 +1,6 @@ import type Account from './Account'; import type AccountData from './AccountData'; +import type ApprovalWorkflow from './ApprovalWorkflow'; import type {BankAccountList} from './BankAccount'; import type BankAccount from './BankAccount'; import type Beta from './Beta'; @@ -214,4 +215,5 @@ export type { StripeCustomerID, BillingStatus, CancellationDetails, + ApprovalWorkflow, }; diff --git a/tests/unit/WorkflowUtilsTest.ts b/tests/unit/WorkflowUtilsTest.ts new file mode 100644 index 000000000000..6359ba01ed23 --- /dev/null +++ b/tests/unit/WorkflowUtilsTest.ts @@ -0,0 +1,488 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import * as WorkflowUtils from '@src/libs/WorkflowUtils'; +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'; +import type PolicyEmployee from '@src/types/onyx/PolicyEmployee'; +import * as TestHelper from '../utils/TestHelper'; + +const personalDetails: PersonalDetailsList = {}; +const personalDetailsByEmail: PersonalDetailsList = {}; + +function buildPolicyEmployee(accountID: number, policyEmployee: Partial = {}): PolicyEmployee { + return { + email: `${accountID}@example.com`, + role: 'user', + ...policyEmployee, + }; +} + +function buildMember(accountID: number): Member { + return { + email: `${accountID}@example.com`, + avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/avatar_7.png', + displayName: `${accountID}@example.com User`, + }; +} + +function buildApprover(accountID: number, approver: Partial = {}): Approver { + return { + email: `${accountID}@example.com`, + forwardsTo: undefined, + avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/avatar_7.png', + displayName: `${accountID}@example.com User`, + isInMultipleWorkflows: false, + isCircularReference: false, + ...approver, + }; +} + +function buildWorkflow(memberIDs: number[], approverIDs: number[], workflow: Partial = {}): ApprovalWorkflow { + return { + members: memberIDs.map(buildMember), + approvers: approverIDs.map((id) => buildApprover(id)), + isDefault: false, + isBeingEdited: false, + ...workflow, + }; +} + +describe('WorkflowUtils', () => { + beforeAll(() => { + for (let accountID = 0; accountID < 10; accountID++) { + const email = `${accountID}@example.com`; + personalDetails[accountID] = TestHelper.buildPersonalDetails(email, accountID, email); + personalDetailsByEmail[email] = personalDetails[accountID]; + } + }); + + describe('getApprovalWorkflowApprovers', () => { + it('Should return no approvers for empty employees object', () => { + const employees: PolicyEmployeeList = {}; + const firstEmail = '1@example.com'; + const approvers = WorkflowUtils.getApprovalWorkflowApprovers({employees, firstEmail, personalDetailsByEmail}); + + expect(approvers).toEqual([]); + }); + + it('Should return just one approver if there is no forwardsTo', () => { + const employees: PolicyEmployeeList = { + '1@example.com': { + email: '1@example.com', + forwardsTo: undefined, + }, + '2@example.com': { + email: '2@example.com', + forwardsTo: undefined, + }, + }; + const firstEmail = '1@example.com'; + const approvers = WorkflowUtils.getApprovalWorkflowApprovers({employees, firstEmail, personalDetailsByEmail}); + + expect(approvers).toEqual([buildApprover(1)]); + }); + + it('Should return just one approver if there is no forwardsTo', () => { + const employees: PolicyEmployeeList = { + '1@example.com': { + email: '1@example.com', + forwardsTo: undefined, + }, + '2@example.com': { + email: '2@example.com', + forwardsTo: undefined, + }, + }; + const firstEmail = '1@example.com'; + const approvers = WorkflowUtils.getApprovalWorkflowApprovers({employees, firstEmail, personalDetailsByEmail}); + + expect(approvers).toEqual([buildApprover(1)]); + }); + + it('Should return a list of approvers when forwardsTo is defined', () => { + const employees: PolicyEmployeeList = { + '1@example.com': { + email: '1@example.com', + forwardsTo: '2@example.com', + }, + '2@example.com': { + email: '2@example.com', + forwardsTo: '3@example.com', + }, + '3@example.com': { + email: '3@example.com', + forwardsTo: '4@example.com', + }, + '4@example.com': { + email: '4@example.com', + forwardsTo: undefined, + }, + '5@example.com': { + email: '5@example.com', + forwardsTo: undefined, + }, + }; + + expect(WorkflowUtils.getApprovalWorkflowApprovers({employees, firstEmail: '1@example.com', personalDetailsByEmail})).toEqual([ + buildApprover(1, {forwardsTo: '2@example.com'}), + buildApprover(2, {forwardsTo: '3@example.com'}), + buildApprover(3, {forwardsTo: '4@example.com'}), + buildApprover(4), + ]); + expect(WorkflowUtils.getApprovalWorkflowApprovers({employees, firstEmail: '2@example.com', personalDetailsByEmail})).toEqual([ + buildApprover(2, {forwardsTo: '3@example.com'}), + buildApprover(3, {forwardsTo: '4@example.com'}), + buildApprover(4), + ]); + expect(WorkflowUtils.getApprovalWorkflowApprovers({employees, firstEmail: '3@example.com', personalDetailsByEmail})).toEqual([ + buildApprover(3, {forwardsTo: '4@example.com'}), + buildApprover(4), + ]); + }); + + it('Should return a list of approvers with circular references', () => { + const employees: PolicyEmployeeList = { + '1@example.com': { + email: '1@example.com', + forwardsTo: '2@example.com', + }, + '2@example.com': { + email: '2@example.com', + forwardsTo: '3@example.com', + }, + '3@example.com': { + email: '3@example.com', + forwardsTo: '4@example.com', + }, + '4@example.com': { + email: '4@example.com', + forwardsTo: '5@example.com', + }, + '5@example.com': { + email: '5@example.com', + forwardsTo: '1@example.com', + }, + }; + + expect(WorkflowUtils.getApprovalWorkflowApprovers({employees, firstEmail: '1@example.com', personalDetailsByEmail})).toEqual([ + buildApprover(1, {forwardsTo: '2@example.com'}), + buildApprover(2, {forwardsTo: '3@example.com'}), + buildApprover(3, {forwardsTo: '4@example.com'}), + buildApprover(4, {forwardsTo: '5@example.com'}), + buildApprover(5, {forwardsTo: '1@example.com'}), + buildApprover(1, {forwardsTo: '2@example.com', isCircularReference: true}), + ]); + expect(WorkflowUtils.getApprovalWorkflowApprovers({employees, firstEmail: '2@example.com', personalDetailsByEmail})).toEqual([ + buildApprover(2, {forwardsTo: '3@example.com'}), + buildApprover(3, {forwardsTo: '4@example.com'}), + buildApprover(4, {forwardsTo: '5@example.com'}), + buildApprover(5, {forwardsTo: '1@example.com'}), + buildApprover(1, {forwardsTo: '2@example.com'}), + buildApprover(2, {forwardsTo: '3@example.com', isCircularReference: true}), + ]); + }); + + it('Should return a list of approvers with circular references', () => { + const employees: PolicyEmployeeList = { + '1@example.com': { + email: '1@example.com', + forwardsTo: '1@example.com', + }, + }; + + expect(WorkflowUtils.getApprovalWorkflowApprovers({employees, firstEmail: '1@example.com', personalDetailsByEmail})).toEqual([ + buildApprover(1, {forwardsTo: '1@example.com'}), + buildApprover(1, {forwardsTo: '1@example.com', isCircularReference: true}), + ]); + }); + }); + + describe('convertPolicyEmployeesToApprovalWorkflows', () => { + it('Should return an empty list if there are no employees', () => { + const employees: PolicyEmployeeList = {}; + const defaultApprover = '1@example.com'; + + const workflows = WorkflowUtils.convertPolicyEmployeesToApprovalWorkflows({employees, defaultApprover, personalDetails}); + + expect(workflows).toEqual([]); + }); + + it('Should transform all users into one default workflow', () => { + const employees: PolicyEmployeeList = { + '1@example.com': { + email: '1@example.com', + forwardsTo: undefined, + submitsTo: '1@example.com', + }, + '2@example.com': { + email: '2@example.com', + forwardsTo: undefined, + submitsTo: '1@example.com', + }, + }; + const defaultApprover = '1@example.com'; + + const workflows = WorkflowUtils.convertPolicyEmployeesToApprovalWorkflows({employees, defaultApprover, personalDetails}); + + expect(workflows).toEqual([buildWorkflow([1, 2], [1], {isDefault: true})]); + }); + + it('Should transform all users into two workflows', () => { + const employees: PolicyEmployeeList = { + '1@example.com': { + email: '1@example.com', + forwardsTo: undefined, + submitsTo: '4@example.com', + }, + '2@example.com': { + email: '2@example.com', + forwardsTo: undefined, + submitsTo: '1@example.com', + }, + '3@example.com': { + email: '3@example.com', + forwardsTo: undefined, + submitsTo: '1@example.com', + }, + '4@example.com': { + email: '4@example.com', + forwardsTo: undefined, + submitsTo: '4@example.com', + }, + }; + const defaultApprover = '1@example.com'; + + const workflows = WorkflowUtils.convertPolicyEmployeesToApprovalWorkflows({employees, defaultApprover, personalDetails}); + + expect(workflows).toEqual([buildWorkflow([2, 3], [1], {isDefault: true}), buildWorkflow([1, 4], [4])]); + }); + + it('Should sort the workflows (first the default and then based on the first approver display name)', () => { + const employees: PolicyEmployeeList = { + '5@example.com': { + email: '5@example.com', + forwardsTo: undefined, + submitsTo: '3@example.com', + }, + '4@example.com': { + email: '4@example.com', + forwardsTo: undefined, + submitsTo: '4@example.com', + }, + '3@example.com': { + email: '3@example.com', + forwardsTo: undefined, + submitsTo: '1@example.com', + }, + '2@example.com': { + email: '2@example.com', + forwardsTo: undefined, + submitsTo: '1@example.com', + }, + '1@example.com': { + email: '1@example.com', + forwardsTo: undefined, + submitsTo: '4@example.com', + }, + }; + const defaultApprover = '1@example.com'; + + const workflows = WorkflowUtils.convertPolicyEmployeesToApprovalWorkflows({employees, defaultApprover, personalDetails}); + + expect(workflows).toEqual([buildWorkflow([3, 2], [1], {isDefault: true}), buildWorkflow([5], [3]), buildWorkflow([4, 1], [4])]); + }); + + it('Should mark approvers that are used in multiple workflows', () => { + const employees: PolicyEmployeeList = { + '1@example.com': { + email: '1@example.com', + forwardsTo: '3@example.com', + submitsTo: '2@example.com', + }, + '2@example.com': { + email: '2@example.com', + forwardsTo: '3@example.com', + submitsTo: '1@example.com', + }, + '3@example.com': { + email: '3@example.com', + forwardsTo: '4@example.com', + submitsTo: '1@example.com', + }, + '4@example.com': { + email: '4@example.com', + forwardsTo: undefined, + submitsTo: '1@example.com', + }, + }; + const defaultApprover = '1@example.com'; + + const workflows = WorkflowUtils.convertPolicyEmployeesToApprovalWorkflows({employees, defaultApprover, personalDetails}); + + const defaultWorkflow = buildWorkflow([2, 3, 4], [1, 3, 4], {isDefault: true}); + defaultWorkflow.approvers[0].forwardsTo = '3@example.com'; + defaultWorkflow.approvers[1].forwardsTo = '4@example.com'; + defaultWorkflow.approvers[1].isInMultipleWorkflows = true; + defaultWorkflow.approvers[2].isInMultipleWorkflows = true; + const secondWorkflow = buildWorkflow([1], [2, 3, 4]); + secondWorkflow.approvers[0].forwardsTo = '3@example.com'; + secondWorkflow.approvers[1].forwardsTo = '4@example.com'; + secondWorkflow.approvers[1].isInMultipleWorkflows = true; + secondWorkflow.approvers[2].isInMultipleWorkflows = true; + + expect(workflows).toEqual([defaultWorkflow, secondWorkflow]); + }); + + it('Should build multiple workflows with many approvers', () => { + const employees: PolicyEmployeeList = { + '1@example.com': { + email: '1@example.com', + forwardsTo: undefined, + submitsTo: '1@example.com', + }, + '2@example.com': { + email: '2@example.com', + forwardsTo: undefined, + submitsTo: '4@example.com', + }, + '3@example.com': { + email: '3@example.com', + forwardsTo: undefined, + submitsTo: '4@example.com', + }, + '4@example.com': { + email: '4@example.com', + forwardsTo: '5@example.com', + submitsTo: '1@example.com', + }, + '5@example.com': { + email: '5@example.com', + forwardsTo: '6@example.com', + submitsTo: '1@example.com', + }, + '6@example.com': { + email: '6@example.com', + forwardsTo: undefined, + submitsTo: '1@example.com', + }, + }; + const defaultApprover = '1@example.com'; + + const workflows = WorkflowUtils.convertPolicyEmployeesToApprovalWorkflows({employees, defaultApprover, personalDetails}); + + const defaultWorkflow = buildWorkflow([1, 4, 5, 6], [1], {isDefault: true}); + const secondWorkflow = buildWorkflow([2, 3], [4, 5, 6]); + secondWorkflow.approvers[0].forwardsTo = '5@example.com'; + secondWorkflow.approvers[1].forwardsTo = '6@example.com'; + expect(workflows).toEqual([defaultWorkflow, secondWorkflow]); + }); + }); + + describe('convertApprovalWorkflowToPolicyEmployees', () => { + it('Should return an updated employee list for a simple default workflow', () => { + const approvalWorkflow: ApprovalWorkflow = { + members: [buildMember(1), buildMember(2)], + approvers: [buildApprover(1)], + isDefault: true, + isBeingEdited: false, + }; + const employeeList: PolicyEmployeeList = { + '1@example.com': buildPolicyEmployee(1, {forwardsTo: 'previous@example.com', submitsTo: 'previous@example.com', role: 'admin'}), + '2@example.com': buildPolicyEmployee(2, {forwardsTo: 'previous@example.com', submitsTo: 'previous@example.com', role: 'admin'}), + }; + + const convertedEmployees = WorkflowUtils.convertApprovalWorkflowToPolicyEmployees({approvalWorkflow, employeeList}); + + expect(convertedEmployees).toEqual({ + '1@example.com': buildPolicyEmployee(1, {forwardsTo: undefined, submitsTo: '1@example.com', role: 'admin'}), + '2@example.com': buildPolicyEmployee(2, {forwardsTo: 'previous@example.com', submitsTo: '1@example.com', role: 'admin'}), + }); + }); + + it('Should return an updated employee list for a complex workflow', () => { + const approvalWorkflow: ApprovalWorkflow = { + members: [buildMember(4), buildMember(5), buildMember(6)], + approvers: [buildApprover(1, {forwardsTo: '2@example.com'}), buildApprover(2, {forwardsTo: '2@example.com'}), buildApprover(3)], + isDefault: false, + isBeingEdited: false, + }; + const employeeList: PolicyEmployeeList = { + '1@example.com': buildPolicyEmployee(1, {forwardsTo: 'previous@example.com', submitsTo: 'previous@example.com', role: 'admin'}), + '2@example.com': buildPolicyEmployee(2, {forwardsTo: 'previous@example.com', submitsTo: 'previous@example.com'}), + '3@example.com': buildPolicyEmployee(3, {forwardsTo: 'previous@example.com', submitsTo: 'previous@example.com'}), + '4@example.com': buildPolicyEmployee(4, {forwardsTo: 'previous@example.com', submitsTo: 'previous@example.com'}), + '5@example.com': buildPolicyEmployee(5, {forwardsTo: 'previous@example.com', submitsTo: 'previous@example.com'}), + '6@example.com': buildPolicyEmployee(6, {forwardsTo: 'previous@example.com', submitsTo: 'previous@example.com'}), + }; + + const convertedEmployees = WorkflowUtils.convertApprovalWorkflowToPolicyEmployees({approvalWorkflow, employeeList}); + + expect(convertedEmployees).toEqual({ + '1@example.com': buildPolicyEmployee(1, {forwardsTo: '2@example.com', submitsTo: 'previous@example.com', role: 'admin'}), + '2@example.com': buildPolicyEmployee(2, {forwardsTo: '3@example.com', submitsTo: 'previous@example.com'}), + '3@example.com': buildPolicyEmployee(3, {forwardsTo: undefined, submitsTo: 'previous@example.com'}), + '4@example.com': buildPolicyEmployee(4, {forwardsTo: 'previous@example.com', submitsTo: '1@example.com'}), + '5@example.com': buildPolicyEmployee(5, {forwardsTo: 'previous@example.com', submitsTo: '1@example.com'}), + '6@example.com': buildPolicyEmployee(6, {forwardsTo: 'previous@example.com', submitsTo: '1@example.com'}), + }); + }); + + it('Should return an updated employee list for a workflow with a circular reference', () => { + const approvalWorkflow: ApprovalWorkflow = { + members: [buildMember(4)], + approvers: [ + buildApprover(1, {forwardsTo: '2@example.com'}), + buildApprover(2, {forwardsTo: '2@example.com'}), + buildApprover(3, {forwardsTo: '1@example.com'}), + buildApprover(1, {forwardsTo: '2@example.com'}), + ], + isDefault: false, + isBeingEdited: false, + }; + const employeeList: PolicyEmployeeList = { + '1@example.com': buildPolicyEmployee(1, {forwardsTo: 'previous@example.com', submitsTo: 'previous@example.com', role: 'admin'}), + '2@example.com': buildPolicyEmployee(2, {forwardsTo: 'previous@example.com', submitsTo: 'previous@example.com'}), + '3@example.com': buildPolicyEmployee(3, {forwardsTo: 'previous@example.com', submitsTo: 'previous@example.com'}), + '4@example.com': buildPolicyEmployee(4, {forwardsTo: 'previous@example.com', submitsTo: 'previous@example.com'}), + }; + + const convertedEmployees = WorkflowUtils.convertApprovalWorkflowToPolicyEmployees({approvalWorkflow, employeeList}); + + expect(convertedEmployees).toEqual({ + '1@example.com': buildPolicyEmployee(1, {forwardsTo: '2@example.com', submitsTo: 'previous@example.com', role: 'admin'}), + '2@example.com': buildPolicyEmployee(2, {forwardsTo: '3@example.com', submitsTo: 'previous@example.com'}), + '3@example.com': buildPolicyEmployee(3, {forwardsTo: '1@example.com', submitsTo: 'previous@example.com'}), + '4@example.com': buildPolicyEmployee(4, {forwardsTo: 'previous@example.com', submitsTo: '1@example.com'}), + }); + }); + + it('Should return an updated employee list for a complex workflow when removing', () => { + const approvalWorkflow: ApprovalWorkflow = { + members: [buildMember(4), buildMember(5), buildMember(6)], + approvers: [buildApprover(1, {forwardsTo: '2@example.com'}), buildApprover(2, {forwardsTo: '2@example.com'}), buildApprover(3)], + isDefault: false, + isBeingEdited: false, + }; + const employeeList: PolicyEmployeeList = { + '1@example.com': buildPolicyEmployee(1, {forwardsTo: 'previous@example.com', submitsTo: 'previous@example.com', role: 'admin'}), + '2@example.com': buildPolicyEmployee(2, {forwardsTo: 'previous@example.com', submitsTo: 'previous@example.com'}), + '3@example.com': buildPolicyEmployee(3, {forwardsTo: 'previous@example.com', submitsTo: 'previous@example.com'}), + '4@example.com': buildPolicyEmployee(4, {forwardsTo: 'previous@example.com', submitsTo: 'previous@example.com'}), + '5@example.com': buildPolicyEmployee(5, {forwardsTo: 'previous@example.com', submitsTo: 'previous@example.com'}), + '6@example.com': buildPolicyEmployee(6, {forwardsTo: 'previous@example.com', submitsTo: 'previous@example.com'}), + }; + + const convertedEmployees = WorkflowUtils.convertApprovalWorkflowToPolicyEmployees({approvalWorkflow, employeeList, removeWorkflow: true}); + + expect(convertedEmployees).toEqual({ + '1@example.com': buildPolicyEmployee(1, {forwardsTo: undefined, submitsTo: 'previous@example.com', role: 'admin'}), + '2@example.com': buildPolicyEmployee(2, {forwardsTo: undefined, submitsTo: 'previous@example.com'}), + '3@example.com': buildPolicyEmployee(3, {forwardsTo: undefined, submitsTo: 'previous@example.com'}), + '4@example.com': buildPolicyEmployee(4, {forwardsTo: 'previous@example.com', submitsTo: undefined}), + '5@example.com': buildPolicyEmployee(5, {forwardsTo: 'previous@example.com', submitsTo: undefined}), + '6@example.com': buildPolicyEmployee(6, {forwardsTo: 'previous@example.com', submitsTo: undefined}), + }); + }); + }); +});