Skip to content
New issue

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

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

Already on GitHub? Sign in to your account

[Advanced Approval Workflows] Update the hint text to be simpler #47860

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1342,8 +1342,7 @@ export default {
/* eslint-enable @typescript-eslint/naming-convention */
},
},
approverInMultipleWorkflows: ({name1, name2}: ApprovalWorkflowErrorParams) =>
`<strong>${name1}</strong> already approves reports to <strong>${name2}</strong> in a separate workflow. If you change this approval relationship, all other workflows will be updated.`,
approverInMultipleWorkflows: 'This member already belongs to another approval workflow. Any updates here will reflect there too.',
approverCircularReference: ({name1, name2}: ApprovalWorkflowErrorParams) =>
`<strong>${name1}</strong> already approves reports to <strong>${name2}</strong>. Please choose a different approver to avoid a circular workflow.`,
},
Expand Down
3 changes: 1 addition & 2 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1350,8 +1350,7 @@ export default {
/* eslint-enable @typescript-eslint/naming-convention */
},
},
approverInMultipleWorkflows: ({name1, name2}: ApprovalWorkflowErrorParams) =>
`<strong>${name1}</strong> ya aprueba informes a <strong>${name2}</strong> en otro flujo de trabajo Si modificas esta relación de aprobación, se actualizarán todos los demás flujos de trabajo.`,
approverInMultipleWorkflows: 'Este miembro ya pertenece a otro flujo de aprobación. Cualquier actualización aquí se reflejará allí también.',
blazejkustra marked this conversation as resolved.
Show resolved Hide resolved
approverCircularReference: ({name1, name2}: ApprovalWorkflowErrorParams) =>
`<strong>${name1}</strong> ya aprueba informes a <strong>${name2}</strong>. Por favor, elige un aprobador diferente para evitar un flujo de trabajo circular.`,
},
Expand Down
45 changes: 31 additions & 14 deletions src/libs/WorkflowUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const INITIAL_APPROVAL_WORKFLOW: ApprovalWorkflowOnyx = {
members: [],
approvers: [],
availableMembers: [],
usedApproverEmails: [],
isDefault: false,
action: CONST.APPROVAL_WORKFLOW.ACTION.CREATE,
isLoading: false,
Expand Down Expand Up @@ -50,7 +51,6 @@ function calculateApprovers({employees, firstEmail, personalDetailsByEmail}: Get
forwardsTo: employees[nextEmail].forwardsTo,
avatar: personalDetailsByEmail[nextEmail]?.avatar,
displayName: personalDetailsByEmail[nextEmail]?.displayName ?? nextEmail,
isInMultipleWorkflows: false,
isCircularReference,
});

Expand All @@ -67,7 +67,7 @@ function calculateApprovers({employees, firstEmail, personalDetailsByEmail}: Get
return approvers;
}

type ConvertPolicyEmployeesToApprovalWorkflowsParams = {
type PolicyConversionParams = {
/**
* List of employees in the policy
*/
Expand All @@ -82,14 +82,36 @@ type ConvertPolicyEmployeesToApprovalWorkflowsParams = {
* Email of the default approver for the policy
*/
defaultApprover: string;

/**
* Email of the first approver in current edited workflow
*/
firstApprover?: string;
};

type PolicyConversionResult = {
/**
* List of approval workflows
*/
approvalWorkflows: ApprovalWorkflow[];

/**
* List of available members that can be selected in the workflow
*/
availableMembers: Member[];

/**
* Emails that are used as approvers in currently configured workflows
*/
usedApproverEmails: string[];
};

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

// Keep track of how many times each approver is used to detect approvers in multiple workflows
const approverCountsByEmail: Record<string, number> = {};
// Keep track of used approver emails to display hints in the UI
const usedApproverEmails = new Set<string>();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there any reason why we don't use an empty string?

Suggested change
const usedApproverEmails = new Set<string>();
const usedApproverEmails = [];

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could do it but there will be duplicates in the array this way, that's why I believe a Set is a better fit here

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ahh good point. Thanks

const personalDetailsByEmail = lodashMapKeys(personalDetails, (value, key) => value?.login ?? key);

// Add each employee to the appropriate workflow
Expand All @@ -102,7 +124,9 @@ function convertPolicyEmployeesToApprovalWorkflows({employees, defaultApprover,
const member: Member = {email, avatar: personalDetailsByEmail[email]?.avatar, displayName: personalDetailsByEmail[email]?.displayName ?? email};
if (!approvalWorkflows[submitsTo]) {
const approvers = calculateApprovers({employees, firstEmail: submitsTo, personalDetailsByEmail});
approvers.forEach((approver) => (approverCountsByEmail[approver.email] = (approverCountsByEmail[approver.email] ?? 0) + 1));
if (submitsTo !== firstApprover) {
approvers.forEach((approver) => usedApproverEmails.add(approver.email));
}

approvalWorkflows[submitsTo] = {
members: [],
Expand Down Expand Up @@ -136,14 +160,7 @@ function convertPolicyEmployeesToApprovalWorkflows({employees, defaultApprover,
});
}

// 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,
})),
}));
return {approvalWorkflows: sortedApprovalWorkflows, usedApproverEmails: [...usedApproverEmails], availableMembers: sortedApprovalWorkflows.at(0)?.members ?? []};
blazejkustra marked this conversation as resolved.
Show resolved Hide resolved
}

type ConvertApprovalWorkflowToPolicyEmployeesParams = {
Expand Down
9 changes: 3 additions & 6 deletions src/libs/actions/Workflow.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import lodashDropRightWhile from 'lodash/dropRightWhile';
import lodashMapKeys from 'lodash/mapKeys';
import type {OnyxCollection, OnyxMergeInput, OnyxUpdate} from 'react-native-onyx';
import type {NullishDeep, 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';
Expand Down Expand Up @@ -282,10 +282,7 @@ function setApprovalWorkflowApprover(approver: Approver, approverIndex: number,
// 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,
}));
const additionalApprovers = calculateApprovers({employees: policy.employeeList, firstEmail: approver.email, personalDetailsByEmail});
approvers.splice(approverIndex, approvers.length, ...additionalApprovers);
}

Expand Down Expand Up @@ -327,7 +324,7 @@ function clearApprovalWorkflowApprovers() {
Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, {approvers: []});
}

function setApprovalWorkflow(approvalWorkflow: OnyxMergeInput<typeof ONYXKEYS.APPROVAL_WORKFLOW>) {
function setApprovalWorkflow(approvalWorkflow: NullishDeep<ApprovalWorkflowOnyx>) {
Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, approvalWorkflow);
}

Expand Down
7 changes: 4 additions & 3 deletions src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr
const [isCurrencyModalOpen, setIsCurrencyModalOpen] = useState(false);
const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);
const policyApproverName = useMemo(() => PersonalDetailsUtils.getPersonalDetailByEmail(policyApproverEmail ?? '')?.displayName ?? policyApproverEmail, [policyApproverEmail]);
const approvalWorkflows = useMemo(
const {approvalWorkflows, availableMembers, usedApproverEmails} = useMemo(
() =>
convertPolicyEmployeesToApprovalWorkflows({
employees: policy?.employeeList ?? {},
Expand Down Expand Up @@ -111,10 +111,11 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr

Workflow.setApprovalWorkflow({
...INITIAL_APPROVAL_WORKFLOW,
availableMembers: approvalWorkflows.at(0)?.members ?? [],
availableMembers,
usedApproverEmails,
});
Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM.getRoute(route.params.policyID));
}, [approvalWorkflows, policy, route.params.policyID]);
}, [policy, route.params.policyID, availableMembers, usedApproverEmails]);

const optionItems: ToggleSettingOptionRowProps[] = useMemo(() => {
const {accountNumber, addressName, bankName, bankAccountID} = policy?.achAccount ?? {};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,19 +79,6 @@ function ApprovalWorkflowEditor({approvalWorkflow, removeApprovalWorkflow, polic
[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],
);

const editMembers = useCallback(() => {
const backTo = approvalWorkflow.action === CONST.APPROVAL_WORKFLOW.ACTION.CREATE ? ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_NEW.getRoute(policyID) : undefined;
Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM.getRoute(policyID, backTo), CONST.NAVIGATION.ACTION_TYPE.PUSH);
Expand Down Expand Up @@ -143,7 +130,11 @@ function ApprovalWorkflowEditor({approvalWorkflow, removeApprovalWorkflow, polic

{approvalWorkflow.approvers.map((approver, approverIndex) => {
const errorText = approverErrorMessage(approver, approverIndex);
const hintText = !errorText && approverHintMessage(approver, approverIndex);
const hintText =
!errorText && approvalWorkflow.usedApproverEmails.some((approverEmail) => approverEmail === approver?.email)
? translate('workflowsPage.approverInMultipleWorkflows')
: undefined;

return (
<MenuItemWithTopDescription
// eslint-disable-next-line react/no-array-index-key
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,21 +62,24 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true
Navigation.dismissModal();
}, [initialApprovalWorkflow, route.params.policyID]);

const {currentApprovalWorkflow, defaultWorkflowMembers} = useMemo(() => {
const {currentApprovalWorkflow, defaultWorkflowMembers, usedApproverEmails} = useMemo(() => {
if (!policy || !personalDetails) {
return {};
}

const defaultApprover = policy?.approver ?? policy.owner;
const allApprovalWorkflows = convertPolicyEmployeesToApprovalWorkflows({
const firstApprover = route.params.firstApproverEmail;
const result = convertPolicyEmployeesToApprovalWorkflows({
employees: policy.employeeList ?? {},
defaultApprover,
personalDetails,
firstApprover,
});

return {
defaultWorkflowMembers: allApprovalWorkflows.at(0)?.members ?? [],
currentApprovalWorkflow: allApprovalWorkflows.find((workflow) => workflow.approvers.at(0)?.email === route.params.firstApproverEmail),
defaultWorkflowMembers: result.availableMembers,
usedApproverEmails: result.usedApproverEmails,
currentApprovalWorkflow: result.approvalWorkflows.find((workflow) => workflow.approvers.at(0)?.email === firstApprover),
};
}, [personalDetails, policy, route.params.firstApproverEmail]);

Expand All @@ -97,12 +100,13 @@ function WorkspaceWorkflowsApprovalsEditPage({policy, isLoadingReportData = true
Workflow.setApprovalWorkflow({
...currentApprovalWorkflow,
availableMembers: [...currentApprovalWorkflow.members, ...defaultWorkflowMembers],
usedApproverEmails,
action: CONST.APPROVAL_WORKFLOW.ACTION.EDIT,
isLoading: false,
errors: null,
});
setInitialApprovalWorkflow(currentApprovalWorkflow);
}, [currentApprovalWorkflow, defaultWorkflowMembers, initialApprovalWorkflow]);
}, [currentApprovalWorkflow, defaultWorkflowMembers, initialApprovalWorkflow, usedApproverEmails]);

return (
<AccessOrNotFoundWrapper
Expand Down
10 changes: 5 additions & 5 deletions src/types/onyx/ApprovalWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,6 @@ type Approver = {
*/
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)
*
Expand Down Expand Up @@ -109,6 +104,11 @@ type ApprovalWorkflowOnyx = Omit<ApprovalWorkflow, 'approvers'> & {
*/
availableMembers: Member[];

/**
* List of emails that are already in use in other workflows
*/
usedApproverEmails: string[];

/**
* Errors for the workflow
*/
Expand Down
29 changes: 12 additions & 17 deletions tests/unit/WorkflowUtilsTest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ function buildApprover(accountID: number, approver: Partial<Approver> = {}): App
forwardsTo: undefined,
avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/avatar_7.png',
displayName: `${accountID}@example.com User`,
isInMultipleWorkflows: false,
isCircularReference: false,
...approver,
};
Expand Down Expand Up @@ -201,9 +200,9 @@ describe('WorkflowUtils', () => {
const employees: PolicyEmployeeList = {};
const defaultApprover = '1@example.com';

const workflows = WorkflowUtils.convertPolicyEmployeesToApprovalWorkflows({employees, defaultApprover, personalDetails});
const {approvalWorkflows} = WorkflowUtils.convertPolicyEmployeesToApprovalWorkflows({employees, defaultApprover, personalDetails});

expect(workflows).toEqual([]);
expect(approvalWorkflows).toEqual([]);
});

it('Should transform all users into one default workflow', () => {
Expand All @@ -221,9 +220,9 @@ describe('WorkflowUtils', () => {
};
const defaultApprover = '1@example.com';

const workflows = WorkflowUtils.convertPolicyEmployeesToApprovalWorkflows({employees, defaultApprover, personalDetails});
const {approvalWorkflows} = WorkflowUtils.convertPolicyEmployeesToApprovalWorkflows({employees, defaultApprover, personalDetails});

expect(workflows).toEqual([buildWorkflow([1, 2], [1], {isDefault: true})]);
expect(approvalWorkflows).toEqual([buildWorkflow([1, 2], [1], {isDefault: true})]);
});

it('Should transform all users into two workflows', () => {
Expand Down Expand Up @@ -251,9 +250,9 @@ describe('WorkflowUtils', () => {
};
const defaultApprover = '1@example.com';

const workflows = WorkflowUtils.convertPolicyEmployeesToApprovalWorkflows({employees, defaultApprover, personalDetails});
const {approvalWorkflows} = WorkflowUtils.convertPolicyEmployeesToApprovalWorkflows({employees, defaultApprover, personalDetails});

expect(workflows).toEqual([buildWorkflow([2, 3], [1], {isDefault: true}), buildWorkflow([1, 4], [4])]);
expect(approvalWorkflows).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)', () => {
Expand Down Expand Up @@ -286,9 +285,9 @@ describe('WorkflowUtils', () => {
};
const defaultApprover = '1@example.com';

const workflows = WorkflowUtils.convertPolicyEmployeesToApprovalWorkflows({employees, defaultApprover, personalDetails});
const {approvalWorkflows} = WorkflowUtils.convertPolicyEmployeesToApprovalWorkflows({employees, defaultApprover, personalDetails});

expect(workflows).toEqual([buildWorkflow([3, 2], [1], {isDefault: true}), buildWorkflow([5], [3]), buildWorkflow([4, 1], [4])]);
expect(approvalWorkflows).toEqual([buildWorkflow([3, 2], [1], {isDefault: true}), buildWorkflow([5], [3]), buildWorkflow([4, 1], [4])]);
});

it('Should mark approvers that are used in multiple workflows', () => {
Expand Down Expand Up @@ -316,20 +315,16 @@ describe('WorkflowUtils', () => {
};
const defaultApprover = '1@example.com';

const workflows = WorkflowUtils.convertPolicyEmployeesToApprovalWorkflows({employees, defaultApprover, personalDetails});
const {approvalWorkflows} = 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]);
expect(approvalWorkflows).toEqual([defaultWorkflow, secondWorkflow]);
});

it('Should build multiple workflows with many approvers', () => {
Expand Down Expand Up @@ -367,13 +362,13 @@ describe('WorkflowUtils', () => {
};
const defaultApprover = '1@example.com';

const workflows = WorkflowUtils.convertPolicyEmployeesToApprovalWorkflows({employees, defaultApprover, personalDetails});
const {approvalWorkflows} = 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]);
expect(approvalWorkflows).toEqual([defaultWorkflow, secondWorkflow]);
});
});

Expand Down
Loading