diff --git a/src/CONST.ts b/src/CONST.ts index 829a26c5cb96..3327a8ad88b6 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -5471,6 +5471,18 @@ const CONST = { NAVIGATION_ACTIONS: { RESET: 'RESET', }, + + APPROVAL_WORKFLOW: { + ACTION: { + CREATE: 'create', + EDIT: 'edit', + }, + TYPE: { + CREATE: 'create', + UPDATE: 'update', + REMOVE: 'remove', + }, + }, } as const; type Country = keyof typeof CONST.ALL_COUNTRIES; diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts index acfa3a70c032..8c339e9120ab 100755 --- a/src/ONYXKEYS.ts +++ b/src/ONYXKEYS.ts @@ -884,7 +884,7 @@ type OnyxValuesMapping = { [ONYXKEYS.NVP_PRIVATE_OWNER_BILLING_GRACE_PERIOD_END]: number; [ONYXKEYS.NVP_WORKSPACE_TOOLTIP]: OnyxTypes.WorkspaceTooltip; [ONYXKEYS.NVP_PRIVATE_CANCELLATION_DETAILS]: OnyxTypes.CancellationDetails[]; - [ONYXKEYS.APPROVAL_WORKFLOW]: OnyxTypes.ApprovalWorkflow; + [ONYXKEYS.APPROVAL_WORKFLOW]: OnyxTypes.ApprovalWorkflowOnyx; }; type OnyxValues = OnyxValuesMapping & OnyxCollectionValuesMapping & OnyxFormValuesMapping & OnyxFormDraftValuesMapping; diff --git a/src/ROUTES.ts b/src/ROUTES.ts index d525cc0ee790..0fa1d30f1b03 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -633,12 +633,12 @@ const ROUTES = { }, WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM: { route: 'settings/workspaces/:policyID/workflows/approvals/expenses-from', - getRoute: (policyID: string) => `settings/workspaces/${policyID}/workflows/approvals/expenses-from` as const, + getRoute: (policyID: string, backTo?: string) => getUrlWithBackToParam(`settings/workspaces/${policyID}/workflows/approvals/expenses-from` as const, backTo), }, WORKSPACE_WORKFLOWS_APPROVALS_APPROVER: { route: 'settings/workspaces/:policyID/workflows/approvals/approver', - getRoute: (policyID: string, approverIndex?: number) => - `settings/workspaces/${policyID}/workflows/approvals/approver${approverIndex !== undefined ? `?approverIndex=${approverIndex}` : ''}` as const, + getRoute: (policyID: string, approverIndex?: number, backTo?: string) => + getUrlWithBackToParam(`settings/workspaces/${policyID}/workflows/approvals/approver${approverIndex !== undefined ? `?approverIndex=${approverIndex}` : ''}` as const, backTo), }, WORKSPACE_WORKFLOWS_PAYER: { route: 'settings/workspaces/:policyID/workflows/payer', diff --git a/src/components/ApprovalWorkflowSection.tsx b/src/components/ApprovalWorkflowSection.tsx index 899e83c9440b..49e509ce533e 100644 --- a/src/components/ApprovalWorkflowSection.tsx +++ b/src/components/ApprovalWorkflowSection.tsx @@ -18,17 +18,17 @@ type ApprovalWorkflowSectionProps = { approvalWorkflow: ApprovalWorkflow; /** ID of the policy */ - policyId?: string; + policyID: string; }; -function ApprovalWorkflowSection({approvalWorkflow, policyId}: ApprovalWorkflowSectionProps) { +function ApprovalWorkflowSection({approvalWorkflow, policyID}: ApprovalWorkflowSectionProps) { const styles = useThemeStyles(); const theme = useTheme(); const {translate, toLocaleOrdinal} = useLocalize(); const {isSmallScreenWidth} = useWindowDimensions(); const openApprovalsEdit = useCallback( - () => Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(policyId ?? '', approvalWorkflow.approvers[0].email)), - [approvalWorkflow.approvers, policyId], + () => Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(policyID, approvalWorkflow.approvers[0].email)), + [approvalWorkflow.approvers, policyID], ); const approverTitle = useCallback( (index: number) => diff --git a/src/components/FormHelpMessage.tsx b/src/components/FormHelpMessage.tsx index 92cdc658b2d7..ab74a7d74b94 100644 --- a/src/components/FormHelpMessage.tsx +++ b/src/components/FormHelpMessage.tsx @@ -1,11 +1,13 @@ import isEmpty from 'lodash/isEmpty'; -import React from 'react'; +import React, {useMemo} from 'react'; import type {StyleProp, ViewStyle} from 'react-native'; import {View} from 'react-native'; import useTheme from '@hooks/useTheme'; import useThemeStyles from '@hooks/useThemeStyles'; +import Parser from '@libs/Parser'; import Icon from './Icon'; import * as Expensicons from './Icon/Expensicons'; +import RenderHTML from './RenderHTML'; import Text from './Text'; type FormHelpMessageProps = { @@ -23,11 +25,29 @@ type FormHelpMessageProps = { /** Whether to show dot indicator */ shouldShowRedDotIndicator?: boolean; + + /** Whether should render error text as HTML or as Text */ + shouldRenderMessageAsHTML?: boolean; }; -function FormHelpMessage({message = '', children, isError = true, style, shouldShowRedDotIndicator = true}: FormHelpMessageProps) { +function FormHelpMessage({message = '', children, isError = true, style, shouldShowRedDotIndicator = true, shouldRenderMessageAsHTML = false}: FormHelpMessageProps) { const theme = useTheme(); const styles = useThemeStyles(); + + const HTMLMessage = useMemo(() => { + if (typeof message !== 'string' || !shouldRenderMessageAsHTML) { + return ''; + } + + const replacedText = Parser.replace(message, {shouldEscapeText: false}); + + if (isError) { + return `${replacedText}`; + } + + return `${replacedText}`; + }, [isError, message, shouldRenderMessageAsHTML]); + if (isEmpty(message) && isEmpty(children)) { return null; } @@ -41,7 +61,7 @@ function FormHelpMessage({message = '', children, isError = true, style, shouldS /> )} - {children ?? {message}} + {children ?? (shouldRenderMessageAsHTML ? : {message})} ); diff --git a/src/components/MenuItem.tsx b/src/components/MenuItem.tsx index 91dac789d7cf..6757d0602691 100644 --- a/src/components/MenuItem.tsx +++ b/src/components/MenuItem.tsx @@ -261,6 +261,12 @@ type MenuItemBaseProps = { /** Whether should render helper text as HTML or as Text */ shouldParseHelperText?: boolean; + /** Whether should render hint text as HTML or as Text */ + shouldRenderHintAsHTML?: boolean; + + /** Whether should render error text as HTML or as Text */ + shouldRenderErrorAsHTML?: boolean; + /** Should check anonymous user in onPress function */ shouldCheckActionAllowedOnPress?: boolean; @@ -394,6 +400,8 @@ function MenuItem( shouldBlockSelection = false, shouldParseTitle = false, shouldParseHelperText = false, + shouldRenderHintAsHTML = false, + shouldRenderErrorAsHTML = false, shouldCheckActionAllowedOnPress = true, onSecondaryInteraction, titleWithTooltips, @@ -802,6 +810,7 @@ function MenuItem( shouldShowRedDotIndicator={!!shouldShowRedDotIndicator} message={errorText} style={[styles.menuItemError, errorTextStyle]} + shouldRenderMessageAsHTML={shouldRenderErrorAsHTML} /> )} {!!hintText && ( @@ -810,6 +819,7 @@ function MenuItem( shouldShowRedDotIndicator={false} message={hintText} style={styles.menuItemError} + shouldRenderMessageAsHTML={shouldRenderHintAsHTML} /> )} diff --git a/src/languages/en.ts b/src/languages/en.ts index 1d6ef0a00c89..95fc367504b4 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7,6 +7,7 @@ import type { AddressLineParams, AdminCanceledRequestParams, AlreadySignedInParams, + ApprovalWorkflowErrorParams, ApprovedAmountParams, BeginningOfChatHistoryAdminRoomPartOneParams, BeginningOfChatHistoryAnnounceRoomPartOneParams, @@ -1310,6 +1311,10 @@ export default { /* eslint-enable @typescript-eslint/naming-convention */ }, }, + approverInMultipleWorkflows: ({name1, name2}: ApprovalWorkflowErrorParams) => + `${name1} already approves reports to ${name2} in a separate workflow. If you change this approval relationship, all other workflows will be updated.`, + approverCircularReference: ({name1, name2}: ApprovalWorkflowErrorParams) => + `${name1} already approves reports to ${name2}. Please choose a different approver to avoid a circular workflow.`, }, workflowsDelayedSubmissionPage: { autoReportingErrorMessage: "Delayed submission couldn't be changed. Please try again or contact support.", @@ -1317,7 +1322,10 @@ export default { monthlyOffsetErrorMessage: "Monthly frequency couldn't be changed. Please try again or contact support.", }, workflowsCreateApprovalsPage: { - title: 'Add approval workflow', + title: 'Confirm', + header: 'Add more approvers and confirm.', + additionalApprover: 'Additional approver', + submitButton: 'Add workflow', }, workflowsEditApprovalsPage: { title: 'Edit approval workflow', diff --git a/src/languages/es.ts b/src/languages/es.ts index 55f2dca91fe6..4658c8307a45 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -5,6 +5,7 @@ import type { AddressLineParams, AdminCanceledRequestParams, AlreadySignedInParams, + ApprovalWorkflowErrorParams, ApprovedAmountParams, BeginningOfChatHistoryAdminRoomPartOneParams, BeginningOfChatHistoryAnnounceRoomPartOneParams, @@ -1319,6 +1320,10 @@ export default { /* eslint-enable @typescript-eslint/naming-convention */ }, }, + approverInMultipleWorkflows: ({name1, name2}: ApprovalWorkflowErrorParams) => + `${name1} ya aprueba informes a ${name2} en otro flujo de trabajo Si modificas esta relación de aprobación, se actualizarán todos los demás flujos de trabajo.`, + approverCircularReference: ({name1, name2}: ApprovalWorkflowErrorParams) => + `${name1} ya aprueba informes a ${name2}. Por favor, elige un aprobador diferente para evitar un flujo de trabajo circular.`, }, workflowsDelayedSubmissionPage: { autoReportingErrorMessage: 'El parámetro de envío retrasado no pudo ser cambiado. Por favor, inténtelo de nuevo o contacte al soporte.', @@ -1326,7 +1331,10 @@ export default { monthlyOffsetErrorMessage: 'La frecuencia mensual no pudo ser cambiada. Por favor, inténtelo de nuevo o contacte al soporte.', }, workflowsCreateApprovalsPage: { - title: 'Añadir flujo de aprobación', + title: 'Confirmar', + header: 'Agrega más aprobadores y confirma.', + additionalApprover: 'Añadir aprobador', + submitButton: 'Añadir flujo de trabajo', }, workflowsEditApprovalsPage: { title: 'Edicion flujo de aprobación', diff --git a/src/languages/types.ts b/src/languages/types.ts index b2e80ae3e973..f3d6f5b677e6 100644 --- a/src/languages/types.ts +++ b/src/languages/types.ts @@ -353,6 +353,11 @@ type IssueVirtualCardParams = { link: string; }; +type ApprovalWorkflowErrorParams = { + name1: string; + name2: string; +}; + export type { AddressLineParams, AdminCanceledRequestParams, @@ -475,4 +480,5 @@ export type { UnapprovedParams, RemoveMembersWarningPrompt, DeleteExpenseTranslationParams, + ApprovalWorkflowErrorParams, }; diff --git a/src/libs/API/parameters/WorkspaceApprovalParams.ts b/src/libs/API/parameters/WorkspaceApprovalParams.ts index 67f96b7852e7..703d4976c045 100644 --- a/src/libs/API/parameters/WorkspaceApprovalParams.ts +++ b/src/libs/API/parameters/WorkspaceApprovalParams.ts @@ -1,9 +1,15 @@ -import type {PolicyEmployee} from '@src/types/onyx'; - type CreateWorkspaceApprovalParams = { authToken: string; policyID: string; - employees: PolicyEmployee[]; + /** + * Stringified JSON object with type of following structure: + * Array<{ + * email: string; + * forwardsTo?: string; + * submitsTo?: string; + * }> + */ + employees: string; }; type UpdateWorkspaceApprovalParams = CreateWorkspaceApprovalParams; diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 25a12c47eadd..bffd7e610741 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -1123,6 +1123,7 @@ type FullScreenNavigatorParamList = { }; [SCREENS.WORKSPACE.WORKFLOWS_APPROVALS_NEW]: { policyID: string; + backTo?: Routes; }; [SCREENS.WORKSPACE.WORKFLOWS_APPROVALS_EDIT]: { policyID: string; @@ -1130,10 +1131,12 @@ type FullScreenNavigatorParamList = { }; [SCREENS.WORKSPACE.WORKFLOWS_APPROVALS_EXPENSES_FROM]: { policyID: string; + backTo?: Routes; }; [SCREENS.WORKSPACE.WORKFLOWS_APPROVALS_APPROVER]: { policyID: string; approverIndex?: number; + backTo?: Routes; }; [SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY]: { policyID: string; diff --git a/src/libs/WorkflowUtils.ts b/src/libs/WorkflowUtils.ts index 1365de4f881b..71348368e0d5 100644 --- a/src/libs/WorkflowUtils.ts +++ b/src/libs/WorkflowUtils.ts @@ -1,14 +1,17 @@ import lodashMapKeys from 'lodash/mapKeys'; -import type {Approver, Member} from '@src/types/onyx/ApprovalWorkflow'; +import type {ValueOf} from 'type-fest'; +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 {PolicyEmployeeList} from '@src/types/onyx/PolicyEmployee'; -const EMPTY_APPROVAL_WORKFLOW: ApprovalWorkflow = { +const INITIAL_APPROVAL_WORKFLOW: ApprovalWorkflowOnyx = { members: [], approvers: [], + availableMembers: [], isDefault: false, - isBeingEdited: false, + action: CONST.APPROVAL_WORKFLOW.ACTION.CREATE, isLoading: false, }; @@ -29,8 +32,8 @@ type GetApproversParams = { firstEmail: string; }; -/** Get the list of approvers for a given workflow */ -function getApprovalWorkflowApprovers({employees, firstEmail, personalDetailsByEmail}: GetApproversParams): Approver[] { +/** Get the list of approvers for a given email */ +function calculateApprovers({employees, firstEmail, personalDetailsByEmail}: GetApproversParams): Approver[] { const approvers: Approver[] = []; // Keep track of approver emails to detect circular references const currentApproverEmails = new Set(); @@ -98,14 +101,13 @@ function convertPolicyEmployeesToApprovalWorkflows({employees, defaultApprover, const member: Member = {email, avatar: personalDetailsByEmail[email]?.avatar, displayName: personalDetailsByEmail[email]?.displayName ?? email}; if (!approvalWorkflows[submitsTo]) { - const approvers = getApprovalWorkflowApprovers({employees, firstEmail: submitsTo, personalDetailsByEmail}); + const approvers = calculateApprovers({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); @@ -117,9 +119,23 @@ function convertPolicyEmployeesToApprovalWorkflows({employees, defaultApprover, return -1; } + if (b.isDefault) { + return 1; + } + return (a.approvers[0]?.displayName ?? '-1').localeCompare(b.approvers[0]?.displayName ?? '-1'); }); + // Add a default workflow if one doesn't exist (no employees submit to the default approver) + const firstWorkflow = sortedApprovalWorkflows.at(0); + if (firstWorkflow && !firstWorkflow.isDefault) { + sortedApprovalWorkflows.unshift({ + members: [], + approvers: calculateApprovers({employees, firstEmail: defaultApprover, personalDetailsByEmail}), + isDefault: true, + }); + } + // Add a flag to each approver to indicate if they are in multiple workflows return sortedApprovalWorkflows.map((workflow) => ({ ...workflow, @@ -142,13 +158,13 @@ type ConvertApprovalWorkflowToPolicyEmployeesParams = { employeeList: PolicyEmployeeList; /** - * Should the workflow be removed from the employees + * Mode to use when converting the approval workflow */ - removeWorkflow?: boolean; + type: ValueOf; }; /** Convert an approval workflow to a list of policy employees */ -function convertApprovalWorkflowToPolicyEmployees({approvalWorkflow, employeeList, removeWorkflow = false}: ConvertApprovalWorkflowToPolicyEmployeesParams): PolicyEmployeeList { +function convertApprovalWorkflowToPolicyEmployees({approvalWorkflow, employeeList, type}: ConvertApprovalWorkflowToPolicyEmployeesParams): PolicyEmployeeList { const updatedEmployeeList: PolicyEmployeeList = {}; const firstApprover = approvalWorkflow.approvers.at(0); @@ -157,25 +173,21 @@ function convertApprovalWorkflowToPolicyEmployees({approvalWorkflow, employeeLis } 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, + forwardsTo: type === CONST.APPROVAL_WORKFLOW.TYPE.REMOVE ? '' : nextApprover?.email ?? '', }; }); approvalWorkflow.members.forEach(({email}) => { updatedEmployeeList[email] = { ...(updatedEmployeeList[email] ? updatedEmployeeList[email] : employeeList[email]), - submitsTo: removeWorkflow ? undefined : firstApprover.email, + submitsTo: type === CONST.APPROVAL_WORKFLOW.TYPE.REMOVE ? '' : firstApprover.email ?? '', }; }); return updatedEmployeeList; } -export {getApprovalWorkflowApprovers, convertPolicyEmployeesToApprovalWorkflows, convertApprovalWorkflowToPolicyEmployees, EMPTY_APPROVAL_WORKFLOW}; +export {calculateApprovers, convertPolicyEmployeesToApprovalWorkflows, convertApprovalWorkflowToPolicyEmployees, INITIAL_APPROVAL_WORKFLOW}; diff --git a/src/libs/actions/Workflow.ts b/src/libs/actions/Workflow.ts index 77607080bc7a..23158308ddd0 100644 --- a/src/libs/actions/Workflow.ts +++ b/src/libs/actions/Workflow.ts @@ -1,15 +1,19 @@ +import lodashDropRightWhile from 'lodash/dropRightWhile'; +import lodashMapKeys from 'lodash/mapKeys'; 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 {calculateApprovers, convertApprovalWorkflowToPolicyEmployees} from '@libs/WorkflowUtils'; import CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {ApprovalWorkflow, Policy} from '@src/types/onyx'; +import type {ApprovalWorkflowOnyx, PersonalDetailsList, Policy} from '@src/types/onyx'; import type {Approver, Member} from '@src/types/onyx/ApprovalWorkflow'; +import type ApprovalWorkflow from '@src/types/onyx/ApprovalWorkflow'; -let currentApprovalWorkflow: ApprovalWorkflow | undefined; +let currentApprovalWorkflow: ApprovalWorkflowOnyx | undefined; Onyx.connect({ key: ONYXKEYS.APPROVAL_WORKFLOW, callback: (approvalWorkflow) => { @@ -32,8 +36,16 @@ Onyx.connect({ }, }); +let personalDetails: PersonalDetailsList | undefined; +Onyx.connect({ + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + callback: (value) => { + personalDetails = value; + }, +}); + function createApprovalWorkflow(policyID: string, approvalWorkflow: ApprovalWorkflow) { - const policy = allPolicies?.[policyID]; + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; if (!authToken || !policy) { return; @@ -41,7 +53,7 @@ function createApprovalWorkflow(policyID: string, approvalWorkflow: ApprovalWork const previousEmployeeList = {...policy.employeeList}; const previousApprovalMode = policy.approvalMode; - const updatedEmployees = convertApprovalWorkflowToPolicyEmployees({approvalWorkflow, employeeList: previousEmployeeList}); + const updatedEmployees = convertApprovalWorkflowToPolicyEmployees({approvalWorkflow, employeeList: previousEmployeeList, type: CONST.APPROVAL_WORKFLOW.TYPE.CREATE}); const optimisticData: OnyxUpdate[] = [ { @@ -57,6 +69,7 @@ function createApprovalWorkflow(policyID: string, approvalWorkflow: ApprovalWork value: { employeeList: updatedEmployees, approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, + pendingFields: {employeeList: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}, }, }, ]; @@ -73,6 +86,7 @@ function createApprovalWorkflow(policyID: string, approvalWorkflow: ApprovalWork value: { employeeList: previousEmployeeList, approvalMode: previousApprovalMode, + pendingFields: {employeeList: null}, }, }, ]; @@ -83,21 +97,28 @@ function createApprovalWorkflow(policyID: string, approvalWorkflow: ApprovalWork key: ONYXKEYS.APPROVAL_WORKFLOW, value: null, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: {employeeList: null}, + }, + }, ]; - const parameters: CreateWorkspaceApprovalParams = {policyID, authToken, employees: Object.values(updatedEmployees)}; + const parameters: CreateWorkspaceApprovalParams = {policyID, authToken, employees: JSON.stringify(Object.values(updatedEmployees))}; API.write(WRITE_COMMANDS.CREATE_WORKSPACE_APPROVAL, parameters, {optimisticData, failureData, successData}); } function updateApprovalWorkflow(policyID: string, approvalWorkflow: ApprovalWorkflow) { - const policy = allPolicies?.[policyID]; + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; if (!authToken || !policy) { return; } const previousEmployeeList = {...policy.employeeList}; - const updatedEmployees = convertApprovalWorkflowToPolicyEmployees({approvalWorkflow, employeeList: previousEmployeeList}); + const updatedEmployees = convertApprovalWorkflowToPolicyEmployees({approvalWorkflow, employeeList: previousEmployeeList, type: CONST.APPROVAL_WORKFLOW.TYPE.UPDATE}); const optimisticData: OnyxUpdate[] = [ { @@ -110,7 +131,10 @@ function updateApprovalWorkflow(policyID: string, approvalWorkflow: ApprovalWork { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: {employeeList: updatedEmployees}, + value: { + employeeList: updatedEmployees, + pendingFields: {employeeList: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, + }, }, ]; @@ -123,7 +147,10 @@ function updateApprovalWorkflow(policyID: string, approvalWorkflow: ApprovalWork { onyxMethod: Onyx.METHOD.MERGE, key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, - value: {employeeList: previousEmployeeList}, + value: { + employeeList: previousEmployeeList, + pendingFields: {employeeList: null}, + }, }, ]; @@ -133,21 +160,28 @@ function updateApprovalWorkflow(policyID: string, approvalWorkflow: ApprovalWork key: ONYXKEYS.APPROVAL_WORKFLOW, value: null, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: {employeeList: null}, + }, + }, ]; - const parameters: UpdateWorkspaceApprovalParams = {policyID, authToken, employees: Object.values(updatedEmployees)}; + const parameters: UpdateWorkspaceApprovalParams = {policyID, authToken, employees: JSON.stringify(Object.values(updatedEmployees))}; API.write(WRITE_COMMANDS.UPDATE_WORKSPACE_APPROVAL, parameters, {optimisticData, failureData, successData}); } function removeApprovalWorkflow(policyID: string, approvalWorkflow: ApprovalWorkflow) { - const policy = allPolicies?.[policyID]; + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; if (!authToken || !policy) { return; } const previousEmployeeList = {...policy.employeeList}; - const updatedEmployees = convertApprovalWorkflowToPolicyEmployees({approvalWorkflow, employeeList: previousEmployeeList, removeWorkflow: true}); + const updatedEmployees = convertApprovalWorkflowToPolicyEmployees({approvalWorkflow, employeeList: previousEmployeeList, type: CONST.APPROVAL_WORKFLOW.TYPE.REMOVE}); const updatedEmployeeList = {...previousEmployeeList, ...updatedEmployees}; // If there is more than one workflow, we need to keep the advanced approval mode (first workflow is the default) @@ -167,6 +201,7 @@ function removeApprovalWorkflow(policyID: string, approvalWorkflow: ApprovalWork value: { employeeList: updatedEmployees, approvalMode: hasMoreThanOneWorkflow ? CONST.POLICY.APPROVAL_MODE.ADVANCED : CONST.POLICY.APPROVAL_MODE.BASIC, + pendingFields: {employeeList: CONST.RED_BRICK_ROAD_PENDING_ACTION.UPDATE}, }, }, ]; @@ -185,6 +220,13 @@ function removeApprovalWorkflow(policyID: string, approvalWorkflow: ApprovalWork approvalMode: CONST.POLICY.APPROVAL_MODE.ADVANCED, }, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: {employeeList: null}, + }, + }, ]; const successData: OnyxUpdate[] = [ @@ -193,38 +235,120 @@ function removeApprovalWorkflow(policyID: string, approvalWorkflow: ApprovalWork key: ONYXKEYS.APPROVAL_WORKFLOW, value: null, }, + { + onyxMethod: Onyx.METHOD.MERGE, + key: `${ONYXKEYS.COLLECTION.POLICY}${policyID}`, + value: { + pendingFields: {employeeList: null}, + }, + }, ]; - const parameters: RemoveWorkspaceApprovalParams = {policyID, authToken, employees: Object.values(updatedEmployees)}; + const parameters: RemoveWorkspaceApprovalParams = {policyID, authToken, employees: JSON.stringify(Object.values(updatedEmployees))}; API.write(WRITE_COMMANDS.REMOVE_WORKSPACE_APPROVAL, parameters, {optimisticData, failureData, successData}); } function setApprovalWorkflowMembers(members: Member[]) { - Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, {members}); + Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, {members, errors: null}); } -function setApprovalWorkflowApprover(approver: Approver, index: number) { - if (!currentApprovalWorkflow) { +/** + * Set the approver at the specified index in the current approval workflow + * @param approver - The new approver to set + * @param approverIndex - The index of the approver to set + * @param policyID - The ID of the policy + */ +function setApprovalWorkflowApprover(approver: Approver, approverIndex: number, policyID: string) { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]; + + if (!currentApprovalWorkflow || !policy?.employeeList) { return; } - const updatedApprovers = [...currentApprovalWorkflow.approvers]; - updatedApprovers[index] = approver; - Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, {approvers: updatedApprovers}); + const approvers: Array = [...currentApprovalWorkflow.approvers]; + approvers[approverIndex] = approver; + + // Check if the approver forwards to other approvers and add them to the list + if (policy.employeeList[approver.email]?.forwardsTo) { + const personalDetailsByEmail = lodashMapKeys(personalDetails, (value, key) => value?.login ?? key); + const additionalApprovers = calculateApprovers({employees: policy.employeeList, firstEmail: approver.email, personalDetailsByEmail}).map((additionalApprover) => ({ + ...additionalApprover, + isInMultipleWorkflows: true, + })); + approvers.splice(approverIndex, approvers.length, ...additionalApprovers); + } + + const errors: Record = {additionalApprover: null}; + // Check for circular references and reset errors + const updatedApprovers = approvers.map((existingApprover, index) => { + if (!existingApprover) { + return; + } + + const hasCircularReference = approvers.slice(0, index).some((previousApprover) => existingApprover.email === previousApprover?.email); + if (hasCircularReference) { + errors[`approver-${index}`] = 'workflowsPage.approverCircularReference'; + } else { + errors[`approver-${index}`] = null; + } + + return { + ...existingApprover, + isCircularReference: hasCircularReference, + }; + }); + + Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, {approvers: updatedApprovers, errors}); } -function setApprovalWorkflow(approvalWorkflow: ApprovalWorkflow) { - Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, approvalWorkflow); +function clearApprovalWorkflowApprover(approverIndex: number) { + if (!currentApprovalWorkflow) { + return; + } + + const approvers: Array = [...currentApprovalWorkflow.approvers]; + approvers[approverIndex] = undefined; + + Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, {approvers: lodashDropRightWhile(approvers, (approver) => !approver), errors: null}); } function clearApprovalWorkflowApprovers() { Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, {approvers: []}); } +function setApprovalWorkflow(approvalWorkflow: ApprovalWorkflowOnyx) { + Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, approvalWorkflow); +} + function clearApprovalWorkflow() { Onyx.set(ONYXKEYS.APPROVAL_WORKFLOW, null); } +function validateApprovalWorkflow(approvalWorkflow: ApprovalWorkflowOnyx): Record { + const errors: Record = {}; + + approvalWorkflow.approvers.forEach((approver, approverIndex) => { + if (!approver) { + errors[`approver-${approverIndex}`] = 'common.error.fieldRequired'; + } + + if (approver?.isCircularReference) { + errors[`approver-${approverIndex}`] = 'workflowsPage.approverCircularReference'; + } + }); + + if (!approvalWorkflow.members.length) { + errors.members = 'common.error.fieldRequired'; + } + + if (!approvalWorkflow.approvers.length) { + errors.additionalApprover = 'common.error.fieldRequired'; + } + + Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, {errors}); + return errors; +} + export { createApprovalWorkflow, updateApprovalWorkflow, @@ -232,6 +356,8 @@ export { setApprovalWorkflowMembers, setApprovalWorkflowApprover, setApprovalWorkflow, + clearApprovalWorkflowApprover, clearApprovalWorkflowApprovers, clearApprovalWorkflow, + validateApprovalWorkflow, }; diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index b513ebccc3b3..4460e1c32961 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -26,7 +26,7 @@ import {getPaymentMethodDescription} from '@libs/PaymentUtils'; import Permissions from '@libs/Permissions'; import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils'; import * as PolicyUtils from '@libs/PolicyUtils'; -import {convertPolicyEmployeesToApprovalWorkflows, EMPTY_APPROVAL_WORKFLOW} from '@libs/WorkflowUtils'; +import {convertPolicyEmployeesToApprovalWorkflows, INITIAL_APPROVAL_WORKFLOW} from '@libs/WorkflowUtils'; import type {FullScreenNavigatorParamList} from '@navigation/types'; import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper'; import type {WithPolicyProps} from '@pages/workspace/withPolicy'; @@ -63,19 +63,24 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST); const policyApproverName = useMemo(() => PersonalDetailsUtils.getPersonalDetailByEmail(policyApproverEmail ?? '')?.displayName ?? policyApproverEmail, [policyApproverEmail]); const approvalWorkflows = useMemo( - () => convertPolicyEmployeesToApprovalWorkflows({employees: policy?.employeeList ?? {}, defaultApprover: policyApproverEmail ?? '', personalDetails: personalDetails ?? {}}), - [personalDetails, policy?.employeeList, policyApproverEmail], + () => + convertPolicyEmployeesToApprovalWorkflows({ + employees: policy?.employeeList ?? {}, + defaultApprover: policyApproverEmail ?? policy?.owner ?? '', + personalDetails: personalDetails ?? {}, + }), + [personalDetails, policy?.employeeList, policy?.owner, policyApproverEmail], ); const displayNameForAuthorizedPayer = useMemo( () => PersonalDetailsUtils.getPersonalDetailByEmail(policy?.achAccount?.reimburser ?? '')?.displayName ?? policy?.achAccount?.reimburser, [policy?.achAccount?.reimburser], ); - const onPressAutoReportingFrequency = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_AUTOREPORTING_FREQUENCY.getRoute(policy?.id ?? '')), [policy?.id]); + const onPressAutoReportingFrequency = useCallback(() => Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_AUTOREPORTING_FREQUENCY.getRoute(route.params.policyID)), [route.params.policyID]); const fetchData = useCallback(() => { - Policy.openPolicyWorkflowsPage(policy?.id ?? route.params.policyID); - }, [policy?.id, route.params.policyID]); + Policy.openPolicyWorkflowsPage(route.params.policyID); + }, [route.params.policyID]); const confirmCurrencyChangeAndHideModal = useCallback(() => { if (!policy) { @@ -104,7 +109,7 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr return; } Workflow.setApprovalWorkflow({ - ...EMPTY_APPROVAL_WORKFLOW, + ...INITIAL_APPROVAL_WORKFLOW, availableMembers: approvalWorkflows.at(0)?.members ?? [], }); Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM.getRoute(route.params.policyID)); @@ -154,7 +159,7 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr isActive: (policy?.autoReportingFrequency !== CONST.POLICY.AUTO_REPORTING_FREQUENCIES.INSTANT && !hasDelayedSubmissionError) ?? false, pendingAction: policy?.pendingFields?.autoReporting, errors: ErrorUtils.getLatestErrorField(policy ?? {}, CONST.POLICY.COLLECTION_KEYS.AUTOREPORTING), - onCloseError: () => Policy.clearPolicyErrorField(policy?.id ?? '-1', CONST.POLICY.COLLECTION_KEYS.AUTOREPORTING), + onCloseError: () => Policy.clearPolicyErrorField(route.params.policyID, CONST.POLICY.COLLECTION_KEYS.AUTOREPORTING), }, { title: translate('workflowsPage.addApprovalsTitle'), @@ -165,11 +170,17 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr }, subMenuItems: canUseAdvancedApproval ? ( <> - {approvalWorkflows.map((w) => ( - + {approvalWorkflows.map((workflow, index) => ( + + + ))} ), - isActive: (policy?.approvalMode === CONST.POLICY.APPROVAL_MODE.BASIC && !hasApprovalError) ?? false, + isActive: + ([CONST.POLICY.APPROVAL_MODE.BASIC, CONST.POLICY.APPROVAL_MODE.ADVANCED].some((approvalMode) => approvalMode === policy?.approvalMode) && !hasApprovalError) ?? false, pendingAction: policy?.pendingFields?.approvalMode, errors: ErrorUtils.getLatestErrorField(policy ?? {}, CONST.POLICY.COLLECTION_KEYS.APPROVAL_MODE), - onCloseError: () => Policy.clearPolicyErrorField(policy?.id ?? '-1', CONST.POLICY.COLLECTION_KEYS.APPROVAL_MODE), + onCloseError: () => Policy.clearPolicyErrorField(route.params.policyID, CONST.POLICY.COLLECTION_KEYS.APPROVAL_MODE), }, { title: translate('workflowsPage.makeOrTrackPaymentsTitle'), @@ -214,7 +226,7 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr } const newReimburserEmail = policy?.achAccount?.reimburser ?? policy?.owner; - Policy.setWorkspaceReimbursement(policy?.id ?? '-1', newReimbursementChoice, newReimburserEmail ?? ''); + Policy.setWorkspaceReimbursement(route.params.policyID, newReimbursementChoice, newReimburserEmail ?? ''); }, subMenuItems: !isOffline && policy?.isLoadingWorkspaceReimbursement === true ? ( @@ -278,7 +290,7 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr isActive: policy?.reimbursementChoice !== CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_NO, pendingAction: policy?.pendingFields?.reimbursementChoice, errors: ErrorUtils.getLatestErrorField(policy ?? {}, CONST.POLICY.COLLECTION_KEYS.REIMBURSEMENT_CHOICE), - onCloseError: () => Policy.clearPolicyErrorField(policy?.id ?? '-1', CONST.POLICY.COLLECTION_KEYS.REIMBURSEMENT_CHOICE), + onCloseError: () => Policy.clearPolicyErrorField(route.params.policyID, CONST.POLICY.COLLECTION_KEYS.REIMBURSEMENT_CHOICE), }, ]; }, [ diff --git a/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx b/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx new file mode 100644 index 000000000000..5c060a0e2fba --- /dev/null +++ b/src/pages/workspace/workflows/approvals/ApprovalWorkflowEditor.tsx @@ -0,0 +1,136 @@ +import type {ForwardedRef} from 'react'; +import React, {forwardRef, useCallback} from 'react'; +import {View} from 'react-native'; +// eslint-disable-next-line no-restricted-imports +import type {ScrollView as ScrollViewRN} from 'react-native'; +import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription'; +import ScrollView from '@components/ScrollView'; +import Text from '@components/Text'; +import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; +import Navigation from '@libs/Navigation/Navigation'; +import CONST from '@src/CONST'; +import ROUTES from '@src/ROUTES'; +import type {ApprovalWorkflowOnyx} from '@src/types/onyx'; +import type {Approver} from '@src/types/onyx/ApprovalWorkflow'; + +type ApprovalWorkflowEditorProps = { + approvalWorkflow: ApprovalWorkflowOnyx; + policyID: string; +}; + +function ApprovalWorkflowEditor({approvalWorkflow, policyID}: ApprovalWorkflowEditorProps, ref: ForwardedRef) { + const styles = useThemeStyles(); + const {translate, toLocaleOrdinal} = useLocalize(); + + const approverDescription = useCallback( + (index: number) => + approvalWorkflow.approvers.length > 1 ? `${toLocaleOrdinal(index + 1, true)} ${translate('workflowsPage.approver').toLowerCase()}` : `${translate('workflowsPage.approver')}`, + [approvalWorkflow.approvers.length, toLocaleOrdinal, translate], + ); + const members = approvalWorkflow.isDefault ? translate('workspace.common.everyone') : approvalWorkflow.members.map((m) => m.displayName).join(', '); + + const approverErrorMessage = useCallback( + (approver: Approver | undefined, approverIndex: number) => { + const previousApprover = approvalWorkflow.approvers.slice(0, approverIndex).filter(Boolean).at(-1); + const error = approvalWorkflow?.errors?.[`approver-${approverIndex}`]; + + if (!error) { + return; + } + + if (error === 'workflowsPage.approverCircularReference') { + if (!previousApprover || !approver) { + return; + } + return translate('workflowsPage.approverCircularReference', { + name1: approver.displayName, + name2: previousApprover.displayName, + }); + } + + return translate(error); + }, + [approvalWorkflow.approvers, approvalWorkflow.errors, translate], + ); + + const approverHintMessage = useCallback( + (approver: Approver | undefined, approverIndex: number) => { + const previousApprover = approvalWorkflow.approvers.slice(0, approverIndex).filter(Boolean).at(-1); + if (approver?.isInMultipleWorkflows && approver.email === previousApprover?.forwardsTo) { + return translate('workflowsPage.approverInMultipleWorkflows', { + name1: approver.displayName, + name2: previousApprover.displayName, + }); + } + }, + [approvalWorkflow.approvers, translate], + ); + + return ( + + + {translate('workflowsCreateApprovalsPage.header')} + + Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM.getRoute(policyID, ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_NEW.getRoute(policyID)))} + shouldShowRightIcon + wrapperStyle={[styles.sectionMenuItemTopDescription]} + errorText={approvalWorkflow?.errors?.members ? translate(approvalWorkflow.errors.members) : undefined} + brickRoadIndicator={approvalWorkflow?.errors?.members ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + /> + + {approvalWorkflow.approvers.map((approver, approverIndex) => { + const errorText = approverErrorMessage(approver, approverIndex); + const hintText = !errorText && approverHintMessage(approver, approverIndex); + return ( + + Navigation.navigate( + ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_APPROVER.getRoute(policyID, approverIndex, ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_NEW.getRoute(policyID)), + ) + } + shouldShowRightIcon + hintText={hintText} + shouldRenderHintAsHTML + brickRoadIndicator={errorText ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + errorText={errorText} + shouldRenderErrorAsHTML + /> + ); + })} + + + Navigation.navigate( + ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_APPROVER.getRoute(policyID, approvalWorkflow.approvers.length, ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_NEW.getRoute(policyID)), + ) + } + shouldShowRightIcon + wrapperStyle={styles.sectionMenuItemTopDescription} + errorText={approvalWorkflow?.errors?.additionalApprover ? translate(approvalWorkflow.errors.additionalApprover) : undefined} + brickRoadIndicator={approvalWorkflow?.errors?.additionalApprover ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined} + /> + + + ); +} + +ApprovalWorkflowEditor.displayName = 'ApprovalWorkflowEditor'; + +export default forwardRef(ApprovalWorkflowEditor); diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx index 0879940f7247..fc9274db4b06 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsApproverPage.tsx @@ -81,7 +81,9 @@ function WorkspaceWorkflowsApprovalsApproverPageBeta({policy, personalDetails, i // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPendingDeletePolicy(policy); - const approverIndex = route.params.approverIndex ?? 0; + const approverIndex = Number(route.params.approverIndex) ?? 0; + const isInitialCreationFlow = approvalWorkflow?.action === CONST.APPROVAL_WORKFLOW.ACTION.CREATE && !route.params.backTo; + const defaultApprover = policy?.approver ?? policy?.owner; useEffect(() => { const currentApprover = approvalWorkflow?.approvers[approverIndex]; @@ -105,6 +107,17 @@ function WorkspaceWorkflowsApprovalsApproverPageBeta({policy, personalDetails, i return null; } + // Do not allow the same email to be added twice + const isEmailAlreadyInApprovers = approvalWorkflow?.approvers.some((approver, index) => approver?.email === email && index !== approverIndex); + if (isEmailAlreadyInApprovers && selectedApproverEmail !== email) { + return null; + } + + // Do not allow the default approver to be added as the first approver + if (!approvalWorkflow?.isDefault && approverIndex === 0 && defaultApprover === email) { + return null; + } + const policyMemberEmailsToAccountIDs = PolicyUtils.getMemberAccountIDsForWorkspace(policy?.employeeList); const accountID = Number(policyMemberEmailsToAccountIDs[email] ?? ''); const {avatar, displayName = email} = personalDetails?.[accountID] ?? {}; @@ -140,54 +153,64 @@ function WorkspaceWorkflowsApprovalsApproverPageBeta({policy, personalDetails, i shouldShow: true, }, ]; - }, [debouncedSearchTerm, personalDetails, policy?.employeeList, selectedApproverEmail, translate]); + }, [ + approvalWorkflow?.approvers, + approvalWorkflow?.isDefault, + approverIndex, + debouncedSearchTerm, + defaultApprover, + personalDetails, + policy?.employeeList, + selectedApproverEmail, + translate, + ]); const nextStep = useCallback(() => { - if (!selectedApproverEmail) { - return; + if (selectedApproverEmail) { + const policyMemberEmailsToAccountIDs = PolicyUtils.getMemberAccountIDsForWorkspace(policy?.employeeList); + const accountID = Number(policyMemberEmailsToAccountIDs[selectedApproverEmail] ?? ''); + const {avatar, displayName = selectedApproverEmail} = personalDetails?.[accountID] ?? {}; + Workflow.setApprovalWorkflowApprover( + { + email: selectedApproverEmail, + avatar, + displayName, + }, + approverIndex, + route.params.policyID, + ); + } else { + Workflow.clearApprovalWorkflowApprover(approverIndex); } - const policyMemberEmailsToAccountIDs = PolicyUtils.getMemberAccountIDsForWorkspace(policy?.employeeList); - const accountID = Number(policyMemberEmailsToAccountIDs[selectedApproverEmail] ?? ''); - const {avatar, displayName = selectedApproverEmail} = personalDetails?.[accountID] ?? {}; - Workflow.setApprovalWorkflowApprover( - { - email: selectedApproverEmail, - avatar, - displayName, - isInMultipleWorkflows: false, - isCircularReference: false, - }, - approverIndex, - ); - - const firstApprover = approvalWorkflow?.approvers?.[0]?.email ?? ''; - if (!approvalWorkflow?.isBeingEdited && firstApprover) { - Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(route.params.policyID, firstApprover)); + if (approvalWorkflow?.action === CONST.APPROVAL_WORKFLOW.ACTION.CREATE) { + Navigation.goBack(); + Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_NEW.getRoute(route.params.policyID), CONST.NAVIGATION.TYPE.UP); } else { - Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_NEW.getRoute(route.params.policyID)); + const firstApprover = approvalWorkflow?.approvers?.[0]?.email ?? ''; + Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(route.params.policyID, firstApprover)); } - }, [approvalWorkflow?.approvers, approvalWorkflow?.isBeingEdited, approverIndex, personalDetails, policy?.employeeList, route.params.policyID, selectedApproverEmail]); + }, [approvalWorkflow, approverIndex, personalDetails, policy?.employeeList, route.params.policyID, selectedApproverEmail]); const nextButton = useMemo( () => ( ), - [nextStep, selectedApproverEmail, styles.flexBasisAuto, styles.flexGrow0, styles.flexReset, styles.flexShrink0, translate], + [isInitialCreationFlow, nextStep, selectedApproverEmail, styles.flexBasisAuto, styles.flexGrow0, styles.flexReset, styles.flexShrink0, translate], ); const goBack = useCallback(() => { - if (!approvalWorkflow?.isBeingEdited) { + if (isInitialCreationFlow) { Workflow.clearApprovalWorkflowApprovers(); } Navigation.goBack(); - }, [approvalWorkflow?.isBeingEdited]); + }, [isInitialCreationFlow]); const toggleApprover = (approver: SelectionListApprover) => { if (selectedApproverEmail === approver.login) { diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx index d7a4696446cc..ecb9de748483 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsCreatePage.tsx @@ -1,27 +1,54 @@ import type {StackScreenProps} from '@react-navigation/stack'; -import React from 'react'; +import React, {useCallback, useRef} from 'react'; +// eslint-disable-next-line no-restricted-imports +import type {ScrollView} from 'react-native'; +import {useOnyx} from 'react-native-onyx'; import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import ScreenWrapper from '@components/ScreenWrapper'; import useLocalize from '@hooks/useLocalize'; +import useThemeStyles from '@hooks/useThemeStyles'; import Navigation from '@libs/Navigation/Navigation'; import type {FullScreenNavigatorParamList} from '@libs/Navigation/types'; import * as PolicyUtils from '@libs/PolicyUtils'; 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 CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; import type SCREENS from '@src/SCREENS'; +import type {Approver} from '@src/types/onyx/ApprovalWorkflow'; import {isEmptyObject} from '@src/types/utils/EmptyObject'; +import ApprovalWorkflowEditor from './ApprovalWorkflowEditor'; type WorkspaceWorkflowsApprovalsCreatePageProps = WithPolicyAndFullscreenLoadingProps & StackScreenProps; function WorkspaceWorkflowsApprovalsCreatePage({policy, isLoadingReportData = true, route}: WorkspaceWorkflowsApprovalsCreatePageProps) { + const styles = useThemeStyles(); const {translate} = useLocalize(); + const [approvalWorkflow, approvalWorkflowMetadata] = useOnyx(ONYXKEYS.APPROVAL_WORKFLOW); + const formRef = useRef(null); // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPendingDeletePolicy(policy); + const createApprovalWorkflow = useCallback(() => { + if (!approvalWorkflow) { + return; + } + + if (!isEmptyObject(Workflow.validateApprovalWorkflow(approvalWorkflow))) { + return; + } + + Workflow.createApprovalWorkflow(route.params.policyID, {...approvalWorkflow, approvers: approvalWorkflow.approvers as Approver[]}); + Navigation.goBack(ROUTES.WORKSPACE_WORKFLOWS.getRoute(route.params.policyID)); + }, [approvalWorkflow, route.params.policyID]); + return ( + {approvalWorkflowMetadata.status === 'loading' && } + {approvalWorkflow && ( + + )} + + { + formRef.current?.scrollTo({y: 0, animated: true}); + }} + isLoading={approvalWorkflow?.isLoading} + buttonText={translate('workflowsCreateApprovalsPage.submitButton')} + containerStyles={[styles.mb5, styles.mh5]} + enabledWhenOffline + /> diff --git a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx index 4a504275bbb2..9b1e0d7f3c23 100644 --- a/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx +++ b/src/pages/workspace/workflows/approvals/WorkspaceWorkflowsApprovalsExpensesFromPage.tsx @@ -57,6 +57,7 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat // eslint-disable-next-line rulesdir/no-negated-variables const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPendingDeletePolicy(policy); + const isInitialCreationFlow = approvalWorkflow?.action === CONST.APPROVAL_WORKFLOW.ACTION.CREATE && !route.params.backTo; useEffect(() => { if (!approvalWorkflow?.members) { @@ -129,32 +130,37 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat const members: Member[] = selectedMembers.map((member) => ({displayName: member.text, avatar: member.icons?.[0]?.source, email: member.login})); Workflow.setApprovalWorkflowMembers(members); - const firstApprover = approvalWorkflow?.approvers?.[0]?.email; - if (approvalWorkflow?.isBeingEdited && firstApprover) { - Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(route.params.policyID, firstApprover)); - } else { + if (route.params.backTo) { + Navigation.navigate(route.params.backTo); + return; + } + + if (approvalWorkflow?.action === CONST.APPROVAL_WORKFLOW.ACTION.CREATE) { Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_APPROVER.getRoute(route.params.policyID, 0)); + } else { + const firstApprover = approvalWorkflow?.approvers?.[0]?.email ?? ''; + Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(route.params.policyID, firstApprover)); } - }, [approvalWorkflow?.approvers, approvalWorkflow?.isBeingEdited, route.params.policyID, selectedMembers]); + }, [approvalWorkflow, route.params.backTo, route.params.policyID, selectedMembers]); const goBack = useCallback(() => { - if (!approvalWorkflow?.isBeingEdited) { + if (isInitialCreationFlow) { Workflow.clearApprovalWorkflow(); } Navigation.goBack(); - }, [approvalWorkflow?.isBeingEdited]); + }, [isInitialCreationFlow]); const nextButton = useMemo( () => ( ), - [nextStep, selectedMembers.length, styles.flexBasisAuto, styles.flexGrow0, styles.flexReset, styles.flexShrink0, translate], + [isInitialCreationFlow, nextStep, selectedMembers.length, styles.flexBasisAuto, styles.flexGrow0, styles.flexReset, styles.flexShrink0, translate], ); const toggleMember = (member: SelectionListMember) => { diff --git a/src/types/onyx/ApprovalWorkflow.ts b/src/types/onyx/ApprovalWorkflow.ts index b5bce8037fdc..44a8932341fc 100644 --- a/src/types/onyx/ApprovalWorkflow.ts +++ b/src/types/onyx/ApprovalWorkflow.ts @@ -1,4 +1,7 @@ +import type {ValueOf} from 'type-fest'; import type {AvatarSource} from '@libs/UserUtils'; +import type CONST from '@src/CONST'; +import type {TranslationPaths} from '@src/languages/types'; /** * Approver in the approval workflow @@ -78,22 +81,39 @@ type ApprovalWorkflow = { * Is this the default workflow for the policy (first approver of this workflow is the same as the policy's default approver) */ isDefault: boolean; +}; + +/** + * Approval workflow for a group of employees with additional properties for the Onyx store + */ +type ApprovalWorkflowOnyx = Omit & { + /** + * 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: Array; /** - * Is this workflow being edited vs created + * The current action of the workflow, used to navigate between different screens */ - isBeingEdited: boolean; + action: ValueOf; /** * Whether we are waiting for the API action to complete */ - isLoading?: boolean; + isLoading: boolean; /** * List of available members that can be selected in the workflow */ - availableMembers?: Member[]; + availableMembers: Member[]; + + /** + * Errors for the workflow + */ + errors?: Record; }; export default ApprovalWorkflow; -export type {Approver, Member}; +export type {ApprovalWorkflowOnyx, Approver, Member}; diff --git a/src/types/onyx/Policy.ts b/src/types/onyx/Policy.ts index 3311959cf8bf..8f48205d8749 100644 --- a/src/types/onyx/Policy.ts +++ b/src/types/onyx/Policy.ts @@ -1555,7 +1555,7 @@ type Policy = OnyxCommon.OnyxValueWithOfflineFeedback< /** Workspace account ID configured for Expensify Card */ workspaceAccountID?: number; } & Partial, - 'generalSettings' | 'addWorkspaceRoom' | keyof ACHAccount | keyof Attributes + 'generalSettings' | 'addWorkspaceRoom' | 'employeeList' | keyof ACHAccount | keyof Attributes >; /** Stages of policy connection sync */ diff --git a/src/types/onyx/index.ts b/src/types/onyx/index.ts index 8c800f32bf1c..2bb129708981 100644 --- a/src/types/onyx/index.ts +++ b/src/types/onyx/index.ts @@ -1,6 +1,6 @@ import type Account from './Account'; import type AccountData from './AccountData'; -import type ApprovalWorkflow from './ApprovalWorkflow'; +import type {ApprovalWorkflowOnyx} from './ApprovalWorkflow'; import type {BankAccountList} from './BankAccount'; import type BankAccount from './BankAccount'; import type Beta from './Beta'; @@ -217,7 +217,7 @@ export type { StripeCustomerID, BillingStatus, CancellationDetails, - ApprovalWorkflow, + ApprovalWorkflowOnyx, MobileSelectionMode, WorkspaceTooltip, }; diff --git a/tests/unit/WorkflowUtilsTest.ts b/tests/unit/WorkflowUtilsTest.ts index 6359ba01ed23..81b1e559e5f6 100644 --- a/tests/unit/WorkflowUtilsTest.ts +++ b/tests/unit/WorkflowUtilsTest.ts @@ -43,7 +43,6 @@ function buildWorkflow(memberIDs: number[], approverIDs: number[], workflow: Par members: memberIDs.map(buildMember), approvers: approverIDs.map((id) => buildApprover(id)), isDefault: false, - isBeingEdited: false, ...workflow, }; } @@ -57,11 +56,11 @@ describe('WorkflowUtils', () => { } }); - describe('getApprovalWorkflowApprovers', () => { + describe('calculateApprovers', () => { it('Should return no approvers for empty employees object', () => { const employees: PolicyEmployeeList = {}; const firstEmail = '1@example.com'; - const approvers = WorkflowUtils.getApprovalWorkflowApprovers({employees, firstEmail, personalDetailsByEmail}); + const approvers = WorkflowUtils.calculateApprovers({employees, firstEmail, personalDetailsByEmail}); expect(approvers).toEqual([]); }); @@ -78,7 +77,7 @@ describe('WorkflowUtils', () => { }, }; const firstEmail = '1@example.com'; - const approvers = WorkflowUtils.getApprovalWorkflowApprovers({employees, firstEmail, personalDetailsByEmail}); + const approvers = WorkflowUtils.calculateApprovers({employees, firstEmail, personalDetailsByEmail}); expect(approvers).toEqual([buildApprover(1)]); }); @@ -95,7 +94,7 @@ describe('WorkflowUtils', () => { }, }; const firstEmail = '1@example.com'; - const approvers = WorkflowUtils.getApprovalWorkflowApprovers({employees, firstEmail, personalDetailsByEmail}); + const approvers = WorkflowUtils.calculateApprovers({employees, firstEmail, personalDetailsByEmail}); expect(approvers).toEqual([buildApprover(1)]); }); @@ -124,18 +123,18 @@ describe('WorkflowUtils', () => { }, }; - expect(WorkflowUtils.getApprovalWorkflowApprovers({employees, firstEmail: '1@example.com', personalDetailsByEmail})).toEqual([ + expect(WorkflowUtils.calculateApprovers({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([ + expect(WorkflowUtils.calculateApprovers({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([ + expect(WorkflowUtils.calculateApprovers({employees, firstEmail: '3@example.com', personalDetailsByEmail})).toEqual([ buildApprover(3, {forwardsTo: '4@example.com'}), buildApprover(4), ]); @@ -165,7 +164,7 @@ describe('WorkflowUtils', () => { }, }; - expect(WorkflowUtils.getApprovalWorkflowApprovers({employees, firstEmail: '1@example.com', personalDetailsByEmail})).toEqual([ + expect(WorkflowUtils.calculateApprovers({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'}), @@ -173,7 +172,7 @@ describe('WorkflowUtils', () => { buildApprover(5, {forwardsTo: '1@example.com'}), buildApprover(1, {forwardsTo: '2@example.com', isCircularReference: true}), ]); - expect(WorkflowUtils.getApprovalWorkflowApprovers({employees, firstEmail: '2@example.com', personalDetailsByEmail})).toEqual([ + expect(WorkflowUtils.calculateApprovers({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'}), @@ -191,7 +190,7 @@ describe('WorkflowUtils', () => { }, }; - expect(WorkflowUtils.getApprovalWorkflowApprovers({employees, firstEmail: '1@example.com', personalDetailsByEmail})).toEqual([ + expect(WorkflowUtils.calculateApprovers({employees, firstEmail: '1@example.com', personalDetailsByEmail})).toEqual([ buildApprover(1, {forwardsTo: '1@example.com'}), buildApprover(1, {forwardsTo: '1@example.com', isCircularReference: true}), ]); @@ -385,17 +384,16 @@ describe('WorkflowUtils', () => { 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}); + const convertedEmployees = WorkflowUtils.convertApprovalWorkflowToPolicyEmployees({approvalWorkflow, employeeList, type: 'create'}); expect(convertedEmployees).toEqual({ - '1@example.com': buildPolicyEmployee(1, {forwardsTo: undefined, submitsTo: '1@example.com', role: 'admin'}), + '1@example.com': buildPolicyEmployee(1, {forwardsTo: '', submitsTo: '1@example.com', role: 'admin'}), '2@example.com': buildPolicyEmployee(2, {forwardsTo: 'previous@example.com', submitsTo: '1@example.com', role: 'admin'}), }); }); @@ -405,7 +403,6 @@ describe('WorkflowUtils', () => { 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'}), @@ -416,53 +413,23 @@ describe('WorkflowUtils', () => { '6@example.com': buildPolicyEmployee(6, {forwardsTo: 'previous@example.com', submitsTo: 'previous@example.com'}), }; - const convertedEmployees = WorkflowUtils.convertApprovalWorkflowToPolicyEmployees({approvalWorkflow, employeeList}); + const convertedEmployees = WorkflowUtils.convertApprovalWorkflowToPolicyEmployees({approvalWorkflow, employeeList, type: 'create'}); 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'}), + '3@example.com': buildPolicyEmployee(3, {forwardsTo: '', 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'}), @@ -473,15 +440,15 @@ describe('WorkflowUtils', () => { '6@example.com': buildPolicyEmployee(6, {forwardsTo: 'previous@example.com', submitsTo: 'previous@example.com'}), }; - const convertedEmployees = WorkflowUtils.convertApprovalWorkflowToPolicyEmployees({approvalWorkflow, employeeList, removeWorkflow: true}); + const convertedEmployees = WorkflowUtils.convertApprovalWorkflowToPolicyEmployees({approvalWorkflow, employeeList, type: 'remove'}); 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}), + '1@example.com': buildPolicyEmployee(1, {forwardsTo: '', submitsTo: 'previous@example.com', role: 'admin'}), + '2@example.com': buildPolicyEmployee(2, {forwardsTo: '', submitsTo: 'previous@example.com'}), + '3@example.com': buildPolicyEmployee(3, {forwardsTo: '', submitsTo: 'previous@example.com'}), + '4@example.com': buildPolicyEmployee(4, {forwardsTo: 'previous@example.com', submitsTo: ''}), + '5@example.com': buildPolicyEmployee(5, {forwardsTo: 'previous@example.com', submitsTo: ''}), + '6@example.com': buildPolicyEmployee(6, {forwardsTo: 'previous@example.com', submitsTo: ''}), }); }); });