Skip to content

Commit

Permalink
Merge pull request #46510 from software-mansion-labs/approval-workflo…
Browse files Browse the repository at this point in the history
…ws/approver

[CRITICAL] Adjust <WorkspaceWorkflowsApprovalsApproverPage /> for Approver Selection
  • Loading branch information
tgolen authored Aug 2, 2024
2 parents 7e3e032 + d933469 commit 13c0ef7
Show file tree
Hide file tree
Showing 10 changed files with 243 additions and 16 deletions.
3 changes: 2 additions & 1 deletion src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -633,7 +633,8 @@ const ROUTES = {
},
WORKSPACE_WORKFLOWS_APPROVALS_APPROVER: {
route: 'settings/workspaces/:policyID/workflows/approvals/approver',
getRoute: (policyID: string) => `settings/workspaces/${policyID}/workflows/approvals/approver` as const,
getRoute: (policyID: string, approverIndex?: number) =>
`settings/workspaces/${policyID}/workflows/approvals/approver${approverIndex !== undefined ? `?approverIndex=${approverIndex}` : ''}` as const,
},
WORKSPACE_WORKFLOWS_PAYER: {
route: 'settings/workspaces/:policyID/workflows/payer',
Expand Down
3 changes: 2 additions & 1 deletion src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1294,8 +1294,9 @@ export default {
title: 'Expenses from',
header: 'When the following members submit expenses:',
},
workflowsApprovalPage: {
workflowsApproverPage: {
genericErrorMessage: "The approver couldn't be changed. Please try again or contact support.",
header: 'Send to this member for approval:',
},
workflowsPayerPage: {
title: 'Authorized payer',
Expand Down
3 changes: 2 additions & 1 deletion src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1303,8 +1303,9 @@ export default {
title: 'Gastos de',
header: 'Cuando los siguientes miembros presenten gastos:',
},
workflowsApprovalPage: {
workflowsApproverPage: {
genericErrorMessage: 'El aprobador no pudo ser cambiado. Por favor, inténtelo de nuevo o contacte al soporte.',
header: 'Enviar a este miembro para su aprobación:',
},
workflowsPayerPage: {
title: 'Pagador autorizado',
Expand Down
1 change: 1 addition & 0 deletions src/libs/Navigation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1120,6 +1120,7 @@ type FullScreenNavigatorParamList = {
};
[SCREENS.WORKSPACE.WORKFLOWS_APPROVALS_APPROVER]: {
policyID: string;
approverIndex?: number;
};
[SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY]: {
policyID: string;
Expand Down
2 changes: 1 addition & 1 deletion src/libs/WorkflowUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ function getApprovalWorkflowApprovers({employees, firstEmail, personalDetailsByE
email: nextEmail,
forwardsTo: employees[nextEmail].forwardsTo,
avatar: personalDetailsByEmail[nextEmail]?.avatar,
displayName: personalDetailsByEmail[nextEmail]?.displayName,
displayName: personalDetailsByEmail[nextEmail]?.displayName ?? nextEmail,
isInMultipleWorkflows: false,
isCircularReference,
});
Expand Down
2 changes: 1 addition & 1 deletion src/libs/actions/Policy/Policy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -496,7 +496,7 @@ function setWorkspaceApprovalMode(policyID: string, approver: string, approvalMo
approver: policy?.approver,
approvalMode: policy?.approvalMode,
pendingFields: {approvalMode: null},
errorFields: {approvalMode: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workflowsApprovalPage.genericErrorMessage')},
errorFields: {approvalMode: ErrorUtils.getMicroSecondOnyxErrorWithTranslationKey('workflowsApproverPage.genericErrorMessage')},
},
},
];
Expand Down
15 changes: 14 additions & 1 deletion src/libs/actions/Workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,8 +217,21 @@ function setApprovalWorkflow(approvalWorkflow: ApprovalWorkflow) {
Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, approvalWorkflow);
}

function clearApprovalWorkflowApprovers() {
Onyx.merge(ONYXKEYS.APPROVAL_WORKFLOW, {approvers: []});
}

function clearApprovalWorkflow() {
Onyx.set(ONYXKEYS.APPROVAL_WORKFLOW, null);
}

export {createApprovalWorkflow, updateApprovalWorkflow, removeApprovalWorkflow, setApprovalWorkflowMembers, setApprovalWorkflowApprover, setApprovalWorkflow, clearApprovalWorkflow};
export {
createApprovalWorkflow,
updateApprovalWorkflow,
removeApprovalWorkflow,
setApprovalWorkflowMembers,
setApprovalWorkflowApprover,
setApprovalWorkflow,
clearApprovalWorkflowApprovers,
clearApprovalWorkflow,
};
Original file line number Diff line number Diff line change
@@ -1,44 +1,248 @@
import type {StackScreenProps} from '@react-navigation/stack';
import React, {useCallback, useMemo, useState} from 'react';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import type {SectionListData} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {useOnyx, withOnyx} from 'react-native-onyx';
import Badge from '@components/Badge';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import {FallbackAvatar} from '@components/Icon/Expensicons';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
import InviteMemberListItem from '@components/SelectionList/InviteMemberListItem';
import type {ListItem, Section} from '@components/SelectionList/types';
import UserListItem from '@components/SelectionList/UserListItem';
import Text from '@components/Text';
import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useThemeStyles from '@hooks/useThemeStyles';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import {formatPhoneNumber} from '@libs/LocalePhoneNumber';
import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
import type {FullScreenNavigatorParamList} from '@libs/Navigation/types';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import Permissions from '@libs/Permissions';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
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 Policy from '@userActions/Policy/Policy';
import * as Workflow from '@userActions/Workflow';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type SCREENS from '@src/SCREENS';
import type {PersonalDetailsList, PolicyEmployee} from '@src/types/onyx';
import type {Beta, PolicyEmployee} from '@src/types/onyx';
import type {Icon} from '@src/types/onyx/OnyxCommon';
import {isEmptyObject} from '@src/types/utils/EmptyObject';

type WorkspaceWorkflowsApprovalsApproverPageOnyxProps = {
/** All of the personal details for everyone */
personalDetails: OnyxEntry<PersonalDetailsList>;
/** Beta features list */
// eslint-disable-next-line react/no-unused-prop-types -- This prop is used in the component
betas: OnyxEntry<Beta[]>;
};

type WorkspaceWorkflowsApprovalsApproverPageProps = WorkspaceWorkflowsApprovalsApproverPageOnyxProps &
WithPolicyAndFullscreenLoadingProps &
StackScreenProps<FullScreenNavigatorParamList, typeof SCREENS.WORKSPACE.WORKFLOWS_APPROVALS_APPROVER>;

type SelectionListApprover = {
text: string;
alternateText: string;
keyForList: string;
isSelected: boolean;
login: string;
rightElement?: React.ReactNode;
icons: Icon[];
};
type ApproverSection = SectionListData<SelectionListApprover, Section<SelectionListApprover>>;

function WorkspaceWorkflowsApprovalsApproverPageWrapper(props: WorkspaceWorkflowsApprovalsApproverPageProps) {
if (Permissions.canUseWorkflowsAdvancedApproval(props.betas) && props.route.params.approverIndex !== undefined) {
// eslint-disable-next-line react/jsx-props-no-spreading
return <WorkspaceWorkflowsApprovalsApproverPageBeta {...props} />;
}

// eslint-disable-next-line react/jsx-props-no-spreading
return <WorkspaceWorkflowsApprovalsApproverPage {...props} />;
}

function WorkspaceWorkflowsApprovalsApproverPageBeta({policy, personalDetails, isLoadingReportData = true, route}: WorkspaceWorkflowsApprovalsApproverPageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false);
const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState('');
const [approvalWorkflow, approvalWorkflowMetadata] = useOnyx(ONYXKEYS.APPROVAL_WORKFLOW);
const [selectedApproverEmail, setSelectedApproverEmail] = useState<string | undefined>(undefined);

// 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;

useEffect(() => {
const currentApprover = approvalWorkflow?.approvers[approverIndex];
if (!currentApprover) {
return;
}

setSelectedApproverEmail(currentApprover.email);
}, [approvalWorkflow?.approvers, approverIndex]);

const sections: ApproverSection[] = useMemo(() => {
const approvers: SelectionListApprover[] = [];

if (policy?.employeeList) {
const availableApprovers = Object.values(policy.employeeList)
.map((employee): SelectionListApprover | null => {
const isAdmin = employee?.role === CONST.REPORT.ROLE.ADMIN;
const email = employee.email;

if (!email) {
return null;
}

const policyMemberEmailsToAccountIDs = PolicyUtils.getMemberAccountIDsForWorkspace(policy?.employeeList);
const accountID = Number(policyMemberEmailsToAccountIDs[email] ?? '');
const {avatar, displayName = email} = personalDetails?.[accountID] ?? {};

return {
text: displayName,
alternateText: email,
keyForList: email,
isSelected: selectedApproverEmail === email,
login: email,
icons: [{source: avatar ?? FallbackAvatar, type: CONST.ICON_TYPE_AVATAR, name: displayName, id: accountID}],
rightElement: isAdmin ? <Badge text={translate('common.admin')} /> : undefined,
};
})
.filter((approver): approver is SelectionListApprover => !!approver);

approvers.push(...availableApprovers);
}

const filteredApprovers =
debouncedSearchTerm !== ''
? approvers.filter((option) => {
const searchValue = OptionsListUtils.getSearchValueForPhoneOrEmail(debouncedSearchTerm);
const isPartOfSearchTerm = !!option.text?.toLowerCase().includes(searchValue) || !!option.login?.toLowerCase().includes(searchValue);
return isPartOfSearchTerm;
})
: approvers;

return [
{
title: undefined,
data: filteredApprovers,
shouldShow: true,
},
];
}, [debouncedSearchTerm, personalDetails, policy?.employeeList, selectedApproverEmail, translate]);

const nextStep = useCallback(() => {
if (!selectedApproverEmail) {
return;
}

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));
} else {
Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_NEW.getRoute(route.params.policyID));
}
}, [approvalWorkflow?.approvers, approvalWorkflow?.isBeingEdited, approverIndex, personalDetails, policy?.employeeList, route.params.policyID, selectedApproverEmail]);

const nextButton = useMemo(
() => (
<FormAlertWithSubmitButton
isDisabled={!selectedApproverEmail}
buttonText={translate('common.next')}
onSubmit={nextStep}
containerStyles={[styles.flexReset, styles.flexGrow0, styles.flexShrink0, styles.flexBasisAuto]}
enabledWhenOffline
/>
),
[nextStep, selectedApproverEmail, styles.flexBasisAuto, styles.flexGrow0, styles.flexReset, styles.flexShrink0, translate],
);

const goBack = useCallback(() => {
if (!approvalWorkflow?.isBeingEdited) {
Workflow.clearApprovalWorkflowApprovers();
}
Navigation.goBack();
}, [approvalWorkflow?.isBeingEdited]);

const toggleApprover = (approver: SelectionListApprover) => {
if (selectedApproverEmail === approver.login) {
setSelectedApproverEmail(undefined);
return;
}
setSelectedApproverEmail(approver.login);
};

const headerMessage = useMemo(() => (searchTerm && !sections[0].data.length ? translate('common.noResultsFound') : ''), [searchTerm, sections, translate]);

return (
<AccessOrNotFoundWrapper
policyID={route.params.policyID}
featureName={CONST.POLICY.MORE_FEATURES.ARE_WORKFLOWS_ENABLED}
>
<ScreenWrapper
includeSafeAreaPaddingBottom={false}
testID={WorkspaceWorkflowsApprovalsApproverPageWrapper.displayName}
onEntryTransitionEnd={() => setDidScreenTransitionEnd(true)}
>
<FullPageNotFoundView
shouldShow={shouldShowNotFoundView}
subtitleKey={isEmptyObject(policy) ? undefined : 'workspace.common.notAuthorized'}
onBackButtonPress={PolicyUtils.goBackFromInvalidPolicy}
onLinkPress={PolicyUtils.goBackFromInvalidPolicy}
>
<HeaderWithBackButton
title={translate('workflowsPage.approver')}
onBackButtonPress={goBack}
/>
<Text style={[styles.textHeadlineH1, styles.mh5, styles.mv3]}>{translate('workflowsApproverPage.header')}</Text>
<SelectionList
sections={sections}
ListItem={InviteMemberListItem}
textInputLabel={translate('selectionList.findMember')}
textInputValue={searchTerm}
onChangeText={setSearchTerm}
headerMessage={headerMessage}
onSelectRow={toggleApprover}
showScrollIndicator
showLoadingPlaceholder={!didScreenTransitionEnd || approvalWorkflowMetadata.status === 'loading'}
shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()}
footerContent={nextButton}
/>
</FullPageNotFoundView>
</ScreenWrapper>
</AccessOrNotFoundWrapper>
);
}

type MemberOption = Omit<ListItem, 'accountID'> & {accountID: number};
type MembersSection = SectionListData<MemberOption, Section<MemberOption>>;

// TODO: Remove this component when workflowsAdvancedApproval beta is removed
function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoadingReportData = true, route}: WorkspaceWorkflowsApprovalsApproverPageProps) {
const {translate} = useLocalize();
const policyName = policy?.name ?? '';
Expand Down Expand Up @@ -164,7 +368,7 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa
>
<ScreenWrapper
includeSafeAreaPaddingBottom={false}
testID={WorkspaceWorkflowsApprovalsApproverPage.displayName}
testID={WorkspaceWorkflowsApprovalsApproverPageWrapper.displayName}
>
<FullPageNotFoundView
shouldShow={shouldShowNotFoundView}
Expand Down Expand Up @@ -194,6 +398,12 @@ function WorkspaceWorkflowsApprovalsApproverPage({policy, personalDetails, isLoa
);
}

WorkspaceWorkflowsApprovalsApproverPage.displayName = 'WorkspaceWorkflowsApprovalsApproverPage';
WorkspaceWorkflowsApprovalsApproverPageWrapper.displayName = 'WorkspaceWorkflowsApprovalsApproverPage';

export default withPolicyAndFullscreenLoading(WorkspaceWorkflowsApprovalsApproverPage);
export default withPolicyAndFullscreenLoading(
withOnyx<WorkspaceWorkflowsApprovalsApproverPageProps, WorkspaceWorkflowsApprovalsApproverPageOnyxProps>({
betas: {
key: ONYXKEYS.BETAS,
},
})(WorkspaceWorkflowsApprovalsApproverPageWrapper),
);
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat
if (approvalWorkflow?.isBeingEdited && firstApprover) {
Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EDIT.getRoute(route.params.policyID, firstApprover));
} else {
Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_APPROVER.getRoute(route.params.policyID));
Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_APPROVER.getRoute(route.params.policyID, 0));
}
}, [approvalWorkflow?.approvers, approvalWorkflow?.isBeingEdited, route.params.policyID, selectedMembers]);

Expand Down
4 changes: 2 additions & 2 deletions src/types/onyx/ApprovalWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,12 @@ type Approver = {
/**
* Display name of the current user from their personal details
*/
displayName?: string;
displayName: string;

/**
* Is this user used as an approver in more than one workflow (used to show a warning)
*/
isInMultipleWorkflows: boolean;
isInMultipleWorkflows?: boolean;

/**
* Is this approver in a circular reference (approver forwards to themselves, or a cycle of forwards)
Expand Down

0 comments on commit 13c0ef7

Please sign in to comment.