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] Implement <WorkspaceWorkflowsApprovalsExpensesFromPage /> for Member Selection #46484

Merged
Merged
1 change: 1 addition & 0 deletions src/languages/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1286,6 +1286,7 @@ export default {
},
workflowsExpensesFromPage: {
title: 'Expenses from',
header: 'When the following members submit expenses:',
},
workflowsApprovalPage: {
genericErrorMessage: "The approver couldn't be changed. Please try again or contact support.",
Expand Down
1 change: 1 addition & 0 deletions src/languages/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1295,6 +1295,7 @@ export default {
},
workflowsExpensesFromPage: {
title: 'Gastos de',
header: 'Cuando los siguientes miembros presenten gastos:',
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Translation already approved here

},
workflowsApprovalPage: {
genericErrorMessage: 'El aprobador no pudo ser cambiado. Por favor, inténtelo de nuevo o contacte al soporte.',
Expand Down
10 changes: 9 additions & 1 deletion src/libs/WorkflowUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ 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 = {
members: [],
approvers: [],
isDefault: false,
isBeingEdited: false,
isLoading: false,
};

type GetApproversParams = {
/**
* List of employees in the policy
Expand Down Expand Up @@ -170,4 +178,4 @@ function convertApprovalWorkflowToPolicyEmployees({approvalWorkflow, employeeLis
return updatedEmployeeList;
}

export {getApprovalWorkflowApprovers, convertPolicyEmployeesToApprovalWorkflows, convertApprovalWorkflowToPolicyEmployees};
export {getApprovalWorkflowApprovers, convertPolicyEmployeesToApprovalWorkflows, convertApprovalWorkflowToPolicyEmployees, EMPTY_APPROVAL_WORKFLOW};
30 changes: 26 additions & 4 deletions src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import type {StackScreenProps} from '@react-navigation/stack';
import React, {useCallback, useMemo, useState} from 'react';
import {ActivityIndicator, View} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {withOnyx} from 'react-native-onyx';
import {useOnyx, withOnyx} from 'react-native-onyx';
import ConfirmModal from '@components/ConfirmModal';
import getBankIcon from '@components/Icon/BankIcons';
import type {BankName} from '@components/Icon/BankIconsUtils';
Expand All @@ -25,13 +25,15 @@ 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 type {FullScreenNavigatorParamList} from '@navigation/types';
import AccessOrNotFoundWrapper from '@pages/workspace/AccessOrNotFoundWrapper';
import type {WithPolicyProps} from '@pages/workspace/withPolicy';
import withPolicy from '@pages/workspace/withPolicy';
import WorkspacePageWithSections from '@pages/workspace/WorkspacePageWithSections';
import * as Policy from '@userActions/Policy/Policy';
import {navigateToBankAccountRoute} from '@userActions/ReimbursementAccount';
import * as Workflow from '@userActions/Workflow';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
Expand All @@ -58,6 +60,17 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr
const policyApproverName = useMemo(() => PersonalDetailsUtils.getPersonalDetailByEmail(policyApproverEmail ?? '')?.displayName ?? policyApproverEmail, [policyApproverEmail]);
const canUseAdvancedApproval = Permissions.canUseWorkflowsAdvancedApproval(betas);
const [isCurrencyModalOpen, setIsCurrencyModalOpen] = useState(false);
const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);

const approvalWorkflows = useMemo(
() =>
convertPolicyEmployeesToApprovalWorkflows({
personalDetails: personalDetails ?? {},
employees: policy?.employeeList ?? {},
defaultApprover: policyApproverEmail ?? '',
}),
[personalDetails, policy?.employeeList, policyApproverEmail],
);

const displayNameForAuthorizedPayer = useMemo(
() => PersonalDetailsUtils.getPersonalDetailByEmail(policy?.achAccount?.reimburser ?? '')?.displayName ?? policy?.achAccount?.reimburser,
Expand Down Expand Up @@ -88,6 +101,15 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr
}, [fetchData]),
);

const createNewApprovalWorkflow = useCallback(() => {
Workflow.setApprovalWorkflow({
...EMPTY_APPROVAL_WORKFLOW,
availableMembers: approvalWorkflows.at(0)?.members ?? [],
});

Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_EXPENSES_FROM.getRoute(route.params.policyID));
}, [approvalWorkflows, route.params.policyID]);

const optionItems: ToggleSettingOptionRowProps[] = useMemo(() => {
const {accountNumber, addressName, bankName, bankAccountID} = policy?.achAccount ?? {};
const shouldShowBankAccount = !!bankAccountID && policy?.reimbursementChoice === CONST.POLICY.REIMBURSEMENT_CHOICES.REIMBURSEMENT_YES;
Expand Down Expand Up @@ -153,7 +175,6 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr
wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mt3, styles.mbn3]}
brickRoadIndicator={hasApprovalError ? CONST.BRICK_ROAD_INDICATOR_STATUS.ERROR : undefined}
/>
{/* TODO: Functionality for this button will be added in a future PR (https://github.com/Expensify/App/issues/45954) */}
{canUseAdvancedApproval && (
<MenuItem
title={translate('workflowsPage.addApprovalButton')}
Expand All @@ -162,8 +183,8 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr
iconHeight={20}
iconWidth={20}
iconFill={theme.success}
style={[styles.ph2, styles.ml11, styles.widthAuto]}
hoverAndPressStyle={[styles.mr0, styles.br2]}
wrapperStyle={[styles.sectionMenuItemTopDescription, styles.mt3, styles.mbn3]}
onPress={createNewApprovalWorkflow}
/>
)}
</>
Expand Down Expand Up @@ -264,6 +285,7 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr
policyApproverName,
canUseAdvancedApproval,
theme,
createNewApprovalWorkflow,
isOffline,
isPolicyAdmin,
displayNameForAuthorizedPayer,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,28 +1,169 @@
import type {StackScreenProps} from '@react-navigation/stack';
import React from 'react';
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import type {SectionListData} from 'react-native';
import {useOnyx} 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 {Section} from '@components/SelectionList/types';
import Text from '@components/Text';
import useDebouncedState from '@hooks/useDebouncedState';
import useLocalize from '@hooks/useLocalize';
import useThemeStyles from '@hooks/useThemeStyles';
import * as DeviceCapabilities from '@libs/DeviceCapabilities';
import Navigation from '@libs/Navigation/Navigation';
import type {FullScreenNavigatorParamList} from '@libs/Navigation/types';
import * as OptionsListUtils from '@libs/OptionsListUtils';
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 {Member} from '@src/types/onyx/ApprovalWorkflow';
import type {Icon} from '@src/types/onyx/OnyxCommon';
import {isEmptyObject} from '@src/types/utils/EmptyObject';

type SelectionListMember = {
text: string;
alternateText: string;
keyForList: string;
isSelected: boolean;
login: string;
rightElement?: React.ReactNode;
icons?: Icon[];
};

type MembersSection = SectionListData<SelectionListMember, Section<SelectionListMember>>;

type WorkspaceWorkflowsApprovalsExpensesFromPageProps = WithPolicyAndFullscreenLoadingProps &
StackScreenProps<FullScreenNavigatorParamList, typeof SCREENS.WORKSPACE.WORKFLOWS_APPROVALS_EXPENSES_FROM>;

function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportData = true, route}: WorkspaceWorkflowsApprovalsExpensesFromPageProps) {
const styles = useThemeStyles();
const {translate} = useLocalize();
const [didScreenTransitionEnd, setDidScreenTransitionEnd] = useState(false);
const [searchTerm, debouncedSearchTerm, setSearchTerm] = useDebouncedState('');
const [approvalWorkflow, approvalWorkflowMetadata] = useOnyx(ONYXKEYS.APPROVAL_WORKFLOW);
const [selectedMembers, setSelectedMembers] = useState<SelectionListMember[]>([]);

// eslint-disable-next-line rulesdir/no-negated-variables
const shouldShowNotFoundView = (isEmptyObject(policy) && !isLoadingReportData) || !PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPendingDeletePolicy(policy);

useEffect(() => {
if (!approvalWorkflow?.members) {
return;
}

setSelectedMembers(
approvalWorkflow.members.map((member) => {
const policyMemberEmailsToAccountIDs = PolicyUtils.getMemberAccountIDsForWorkspace(policy?.employeeList);
const accountID = Number(policyMemberEmailsToAccountIDs[member.email] ?? '');
const isAdmin = policy?.employeeList?.[member.email].role === CONST.REPORT.ROLE.ADMIN;

return {
text: member.displayName,
alternateText: member.email,
keyForList: member.email,
isSelected: true,
login: member.email,
icons: [{source: member.avatar ?? FallbackAvatar, type: CONST.ICON_TYPE_AVATAR, name: member.displayName, id: accountID}],
rightElement: isAdmin ? <Badge text={translate('common.admin')} /> : undefined,
};
}),
);
}, [approvalWorkflow?.members, policy?.employeeList, translate]);

const sections: MembersSection[] = useMemo(() => {
const members: SelectionListMember[] = [...selectedMembers];

if (approvalWorkflow?.availableMembers) {
const availableMembers = approvalWorkflow.availableMembers
.map((member) => {
const isAdmin = policy?.employeeList?.[member.email].role === CONST.REPORT.ROLE.ADMIN;
const policyMemberEmailsToAccountIDs = PolicyUtils.getMemberAccountIDsForWorkspace(policy?.employeeList);
const accountID = Number(policyMemberEmailsToAccountIDs[member.email] ?? '');

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

members.push(...availableMembers);
}

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

return [
{
title: undefined,
data: filteredMembers,
shouldShow: true,
},
];
}, [approvalWorkflow?.availableMembers, debouncedSearchTerm, policy?.employeeList, selectedMembers, translate]);

const nextStep = useCallback(() => {
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 {
Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVALS_APPROVER.getRoute(route.params.policyID));
}
}, [approvalWorkflow?.approvers, approvalWorkflow?.isBeingEdited, route.params.policyID, selectedMembers]);

const goBack = useCallback(() => {
if (!approvalWorkflow?.isBeingEdited) {
Workflow.clearApprovalWorkflow();
}
Navigation.goBack();
}, [approvalWorkflow?.isBeingEdited]);
Comment on lines +140 to +145
Copy link
Contributor

Choose a reason for hiding this comment

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

Clearing the approval workflow on going back caused the not found view to appear briefly while hiding the modal (#50094). Also this goBack function is not called if we dismiss the modal on outside-click.

We went with a different approach that is to clear the workflow (from WorkspaceWorkflowsPage) before navigating to this page.


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

const toggleMember = (member: SelectionListMember) => {
const isAlreadySelected = selectedMembers.some((selectedOption) => selectedOption.login === member.login);
setSelectedMembers(isAlreadySelected ? selectedMembers.filter((selectedOption) => selectedOption.login !== member.login) : [...selectedMembers, {...member, isSelected: true}]);
};

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

return (
<AccessOrNotFoundWrapper
policyID={route.params.policyID}
Expand All @@ -31,6 +172,7 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat
<ScreenWrapper
includeSafeAreaPaddingBottom={false}
testID={WorkspaceWorkflowsApprovalsExpensesFromPage.displayName}
onEntryTransitionEnd={() => setDidScreenTransitionEnd(true)}
>
<FullPageNotFoundView
shouldShow={shouldShowNotFoundView}
Expand All @@ -40,7 +182,22 @@ function WorkspaceWorkflowsApprovalsExpensesFromPage({policy, isLoadingReportDat
>
<HeaderWithBackButton
title={translate('workflowsExpensesFromPage.title')}
onBackButtonPress={Navigation.goBack}
onBackButtonPress={goBack}
/>
<Text style={[styles.textHeadlineH1, styles.mh5, styles.mv3]}>{translate('workflowsExpensesFromPage.header')}</Text>
<SelectionList
canSelectMultiple
sections={sections}
ListItem={InviteMemberListItem}
textInputLabel={translate('selectionList.findMember')}
textInputValue={searchTerm}
onChangeText={setSearchTerm}
headerMessage={headerMessage}
onSelectRow={toggleMember}
showScrollIndicator
showLoadingPlaceholder={!didScreenTransitionEnd || approvalWorkflowMetadata.status === 'loading'}
shouldPreventDefaultFocusOnSelectRow={!DeviceCapabilities.canUseTouchScreen()}
footerContent={nextButton}
/>
</FullPageNotFoundView>
</ScreenWrapper>
Expand Down
17 changes: 12 additions & 5 deletions src/types/onyx/ApprovalWorkflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,14 @@ type Member = {
email: string;

/**
* Avatar URL of the current user from their personal details
* Display name of the current user from their personal details
*/
avatar?: AvatarSource;
displayName: string;

/**
* Display name of the current user from their personal details
* Avatar URL of the current user from their personal details
*/
displayName?: string;
avatar?: AvatarSource;
};

/**
Expand Down Expand Up @@ -84,8 +84,15 @@ type ApprovalWorkflow = {
*/
isBeingEdited: boolean;

/** Whether we are waiting for the API action to complete */
/**
* Whether we are waiting for the API action to complete
*/
isLoading?: boolean;

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

export default ApprovalWorkflow;
Expand Down
Loading