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

[CRITICAL] Adjust <WorkspaceWorkflowsApprovalsApproverPage /> for Approver Selection #46510

Merged
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 @@ -1292,8 +1292,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 @@ -1301,8 +1301,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 @@ -488,7 +488,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];
blazejkustra marked this conversation as resolved.
Show resolved Hide resolved
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
Loading