Skip to content

Commit

Permalink
Merge pull request #37391 from rushatgabhane/approval-page
Browse files Browse the repository at this point in the history
[Simplified Collect][Workflows] Select workspace approver
  • Loading branch information
luacmartins authored Mar 1, 2024
2 parents 1d0ec1d + 45585e8 commit 902f2b8
Show file tree
Hide file tree
Showing 11 changed files with 236 additions and 12 deletions.
4 changes: 4 additions & 0 deletions src/ROUTES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,10 @@ const ROUTES = {
route: 'workspace/:policyID/workflows',
getRoute: (policyID: string) => `workspace/${policyID}/workflows` as const,
},
WORKSPACE_WORKFLOWS_APPROVER: {
route: 'workspace/:policyID/settings/workflows/approver',
getRoute: (policyId: string) => `workspace/${policyId}/settings/workflows/approver` as const,
},
WORKSPACE_WORKFLOWS_AUTOREPORTING_FREQUENCY: {
route: 'workspace/:policyID/settings/workflows/auto-reporting-frequency',
getRoute: (policyID: string) => `workspace/${policyID}/settings/workflows/auto-reporting-frequency` as const,
Expand Down
1 change: 1 addition & 0 deletions src/SCREENS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,7 @@ const SCREENS = {
CATEGORIES: 'Workspace_Categories',
CURRENCY: 'Workspace_Profile_Currency',
WORKFLOWS: 'Workspace_Workflows',
WORKFLOWS_APPROVER: 'Workspace_Workflows_Approver',
WORKFLOWS_AUTO_REPORTING_FREQUENCY: 'Workspace_Workflows_Auto_Reporting_Frequency',
WORKFLOWS_AUTO_REPORTING_MONTHLY_OFFSET: 'Workspace_Workflows_Auto_Reporting_Monthly_Offset',
DESCRIPTION: 'Workspace_Profile_Description',
Expand Down
1 change: 1 addition & 0 deletions src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ const SettingsModalStackNavigator = createModalStackNavigator<SettingsNavigatorP
[SCREENS.WORKSPACE.RATE_AND_UNIT_RATE]: () => require('../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/RatePage').default as React.ComponentType,
[SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT]: () => require('../../../pages/workspace/reimburse/WorkspaceRateAndUnitPage/UnitPage').default as React.ComponentType,
[SCREENS.WORKSPACE.INVITE]: () => require('../../../pages/workspace/WorkspaceInvitePage').default as React.ComponentType,
[SCREENS.WORKSPACE.WORKFLOWS_APPROVER]: () => require('../../../pages/workspace/workflows/WorkspaceWorkflowsApproverPage').default as React.ComponentType,
[SCREENS.WORKSPACE.INVITE_MESSAGE]: () => require('../../../pages/workspace/WorkspaceInviteMessagePage').default as React.ComponentType,
[SCREENS.WORKSPACE.NAME]: () => require('../../../pages/workspace/WorkspaceNamePage').default as React.ComponentType,
[SCREENS.WORKSPACE.DESCRIPTION]: () => require('../../../pages/workspace/WorkspaceProfileDescriptionPage').default as React.ComponentType,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const CENTRAL_PANE_TO_RHP_MAPPING: Partial<Record<CentralPaneName, string[]>> =
[SCREENS.WORKSPACE.PROFILE]: [SCREENS.WORKSPACE.NAME, SCREENS.WORKSPACE.CURRENCY, SCREENS.WORKSPACE.DESCRIPTION, SCREENS.WORKSPACE.SHARE],
[SCREENS.WORKSPACE.REIMBURSE]: [SCREENS.WORKSPACE.RATE_AND_UNIT, SCREENS.WORKSPACE.RATE_AND_UNIT_RATE, SCREENS.WORKSPACE.RATE_AND_UNIT_UNIT],
[SCREENS.WORKSPACE.MEMBERS]: [SCREENS.WORKSPACE.INVITE, SCREENS.WORKSPACE.INVITE_MESSAGE],
[SCREENS.WORKSPACE.WORKFLOWS]: [SCREENS.WORKSPACE.WORKFLOWS_APPROVER],
};

export default CENTRAL_PANE_TO_RHP_MAPPING;
3 changes: 3 additions & 0 deletions src/libs/Navigation/linkingConfig/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,6 +262,9 @@ const config: LinkingOptions<RootStackParamList>['config'] = {
[SCREENS.WORKSPACE.INVITE]: {
path: ROUTES.WORKSPACE_INVITE.route,
},
[SCREENS.WORKSPACE.WORKFLOWS_APPROVER]: {
path: ROUTES.WORKSPACE_WORKFLOWS_APPROVER.route,
},
[SCREENS.WORKSPACE.INVITE_MESSAGE]: {
path: ROUTES.WORKSPACE_INVITE_MESSAGE.route,
},
Expand Down
3 changes: 3 additions & 0 deletions src/libs/Navigation/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@ type CentralPaneNavigatorParamList = {
[SCREENS.WORKSPACE.WORKFLOWS]: {
policyID: string;
};
[SCREENS.WORKSPACE.WORKFLOWS_APPROVER]: {
policyID: string;
};
[SCREENS.WORKSPACE.WORKFLOWS_AUTO_REPORTING_FREQUENCY]: {
policyID: string;
};
Expand Down
8 changes: 7 additions & 1 deletion src/libs/OptionsListUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -804,6 +804,11 @@ function getEnabledCategoriesCount(options: PolicyCategories): number {
return Object.values(options).filter((option) => option.enabled).length;
}

function getSearchValueForPhoneOrEmail(searchTerm: string) {
const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchTerm)));
return parsedPhoneNumber.possible ? parsedPhoneNumber.number?.e164 ?? '' : searchTerm.toLowerCase();
}

/**
* Verifies that there is at least one enabled option
*/
Expand Down Expand Up @@ -1882,7 +1887,7 @@ function formatMemberForList(member: ReportUtils.OptionData): MemberForList {
login: member.login ?? '',
icons: member.icons,
pendingAction: member.pendingAction,
reportID: member.reportID,
reportID: member.reportID ?? '',
};
}

Expand Down Expand Up @@ -2026,6 +2031,7 @@ export {
getMemberInviteOptions,
getHeaderMessage,
getHeaderMessageForNonUserList,
getSearchValueForPhoneOrEmail,
getPersonalDetailsForAccountIDs,
getIOUConfirmationOptionsFromPayeePersonalDetail,
getIOUConfirmationOptionsFromParticipants,
Expand Down
5 changes: 5 additions & 0 deletions src/libs/PersonalDetailsUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,10 @@ function getPersonalDetailsByIDs(accountIDs: number[], currentUserAccountID: num
return result;
}

function getPersonalDetailByEmail(email: string): PersonalDetails | undefined {
return (Object.values(allPersonalDetails ?? {}) as PersonalDetails[]).find((detail) => detail?.login === email);
}

/**
* Given a list of logins, find the associated personal detail and return related accountIDs.
*
Expand Down Expand Up @@ -263,6 +267,7 @@ export {
isPersonalDetailsEmpty,
getDisplayNameOrDefault,
getPersonalDetailsByIDs,
getPersonalDetailByEmail,
getAccountIDsByLogins,
getLoginsByAccountIDs,
getNewPersonalDetailsOnyxData,
Expand Down
5 changes: 2 additions & 3 deletions src/pages/workspace/WorkspaceInvitePage.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import {useNavigation} from '@react-navigation/native';
import type {StackNavigationProp, StackScreenProps} from '@react-navigation/stack';
import Str from 'expensify-common/lib/str';
import React, {useEffect, useMemo, useState} from 'react';
import type {SectionListData} from 'react-native';
import {View} from 'react-native';
Expand Down Expand Up @@ -176,8 +175,8 @@ function WorkspaceInvitePage({
filterSelectedOptions = selectedOptions.filter((option) => {
const accountID = option.accountID;
const isOptionInPersonalDetails = Object.values(personalDetails).some((personalDetail) => personalDetail.accountID === accountID);
const parsedPhoneNumber = PhoneNumber.parsePhoneNumber(LoginUtils.appendCountryCode(Str.removeSMSDomain(searchTerm)));
const searchValue = parsedPhoneNumber.possible ? parsedPhoneNumber.number?.e164 ?? '' : searchTerm.toLowerCase();

const searchValue = OptionsListUtils.getSearchValueForPhoneOrEmail(searchTerm);

const isPartOfSearchTerm = !!option.text?.toLowerCase().includes(searchValue) || !!option.login?.toLowerCase().includes(searchValue);
return isPartOfSearchTerm || isOptionInPersonalDetails;
Expand Down
203 changes: 203 additions & 0 deletions src/pages/workspace/workflows/WorkspaceWorkflowsApproverPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
import React, {useCallback, useMemo, useState} from 'react';
import type {SectionListData} from 'react-native';
import {withOnyx} from 'react-native-onyx';
import type {OnyxEntry} from 'react-native-onyx';
import Badge from '@components/Badge';
import FullPageNotFoundView from '@components/BlockingViews/FullPageNotFoundView';
import HeaderWithBackButton from '@components/HeaderWithBackButton';
import ScreenWrapper from '@components/ScreenWrapper';
import SelectionList from '@components/SelectionList';
import type {ListItem, Section} from '@components/SelectionList/types';
import UserListItem from '@components/SelectionList/UserListItem';
import useLocalize from '@hooks/useLocalize';
import useNetwork from '@hooks/useNetwork';
import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import compose from '@libs/compose';
import {formatPhoneNumber} from '@libs/LocalePhoneNumber';
import Log from '@libs/Log';
import Navigation from '@libs/Navigation/Navigation';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as UserUtils from '@libs/UserUtils';
import withPolicyAndFullscreenLoading from '@pages/workspace/withPolicyAndFullscreenLoading';
import type {WithPolicyAndFullscreenLoadingProps} from '@pages/workspace/withPolicyAndFullscreenLoading';
import * as Policy from '@userActions/Policy';
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {PersonalDetailsList, PolicyMember} from '@src/types/onyx';
import {isEmptyObject} from '@src/types/utils/EmptyObject';

type WorkspaceWorkflowsApproverPageOnyxProps = {
/** All of the personal details for everyone */
personalDetails: OnyxEntry<PersonalDetailsList>;
};

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

function WorkspaceWorkflowsApproverPage({policy, policyMembers, personalDetails, isLoadingReportData = true}: WorkspaceWorkflowsApproverPageProps) {
const {translate} = useLocalize();
const policyName = policy?.name ?? '';
const [searchTerm, setSearchTerm] = useState('');
const {isOffline} = useNetwork();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();

const isDeletedPolicyMember = useCallback(
(policyMember: PolicyMember) => !isOffline && policyMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE && isEmptyObject(policyMember.errors),
[isOffline],
);

const [formattedPolicyMembers, formattedApprover] = useMemo(() => {
const policyMemberDetails: MemberOption[] = [];
const approverDetails: MemberOption[] = [];

Object.entries(policyMembers ?? {}).forEach(([accountIDKey, policyMember]) => {
const accountID = Number(accountIDKey);
if (isDeletedPolicyMember(policyMember)) {
return;
}

const details = personalDetails?.[accountID];
if (!details) {
Log.hmmm(`[WorkspaceMembersPage] no personal details found for policy member with accountID: ${accountID}`);
return;
}

const isOwner = policy?.owner === details.login;
const isAdmin = policyMember.role === CONST.POLICY.ROLE.ADMIN;

let roleBadge = null;
if (isOwner || isAdmin) {
roleBadge = (
<Badge
text={isOwner ? translate('common.owner') : translate('common.admin')}
textStyles={styles.textStrong}
badgeStyles={[styles.justifyContentCenter, StyleUtils.getMinimumWidth(60), styles.badgeBordered]}
/>
);
}

const formattedMember = {
keyForList: accountIDKey,
accountID,
isSelected: policy?.approver === details.login,
isDisabled: policyMember.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE || !isEmptyObject(policyMember.errors),
text: formatPhoneNumber(PersonalDetailsUtils.getDisplayNameOrDefault(details)),
alternateText: formatPhoneNumber(details?.login ?? ''),
rightElement: roleBadge,
icons: [
{
source: UserUtils.getAvatar(details.avatar, accountID),
name: formatPhoneNumber(details?.login ?? ''),
type: CONST.ICON_TYPE_AVATAR,
id: accountID,
},
],
errors: policyMember.errors,
pendingAction: policyMember.pendingAction,
};

if (policy?.approver === details.login) {
approverDetails.push(formattedMember);
} else {
policyMemberDetails.push(formattedMember);
}
});
return [policyMemberDetails, approverDetails];
}, [personalDetails, policyMembers, translate, policy?.approver, StyleUtils, isDeletedPolicyMember, policy?.owner, styles]);

const sections: MembersSection[] = useMemo(() => {
const sectionsArray: MembersSection[] = [];

if (searchTerm !== '') {
const filteredOptions = [...formattedApprover, ...formattedPolicyMembers].filter((option) => {
const searchValue = OptionsListUtils.getSearchValueForPhoneOrEmail(searchTerm);
return !!option.text?.toLowerCase().includes(searchValue) || !!option.login?.toLowerCase().includes(searchValue);
});
return [
{
title: undefined,
data: filteredOptions,
shouldShow: true,
},
];
}

sectionsArray.push({
title: undefined,
data: formattedApprover,
shouldShow: formattedApprover.length > 0,
indexOffset: 0,
});

sectionsArray.push({
title: translate('common.all'),
data: formattedPolicyMembers,
shouldShow: true,
indexOffset: formattedApprover.length,
});

return sectionsArray;
}, [formattedPolicyMembers, formattedApprover, searchTerm, translate]);

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

// eslint-disable-next-line react-hooks/exhaustive-deps
[translate, sections],
);

const setPolicyApprover = (member: MemberOption) => {
if (!policy?.approvalMode || !personalDetails?.[member.accountID]?.login) {
return;
}
const approver: string = personalDetails?.[member.accountID]?.login ?? policy.approver ?? policy.owner;
Policy.setWorkspaceApprovalMode(policy.id, approver, policy.approvalMode);
Navigation.goBack();
};

return (
<ScreenWrapper
includeSafeAreaPaddingBottom={false}
testID={WorkspaceWorkflowsApproverPage.displayName}
>
<FullPageNotFoundView
shouldShow={(isEmptyObject(policy) && !isLoadingReportData) || !PolicyUtils.isPolicyAdmin(policy) || PolicyUtils.isPendingDeletePolicy(policy)}
subtitleKey={isEmptyObject(policy) ? undefined : 'workspace.common.notAuthorized'}
onBackButtonPress={PolicyUtils.goBackFromInvalidPolicy}
onLinkPress={PolicyUtils.goBackFromInvalidPolicy}
>
<HeaderWithBackButton
title={translate('workflowsPage.approver')}
subtitle={policyName}
onBackButtonPress={Navigation.goBack}
/>
<SelectionList
sections={sections}
textInputLabel={translate('optionsSelector.findMember')}
textInputValue={searchTerm}
onChangeText={setSearchTerm}
headerMessage={headerMessage}
ListItem={UserListItem}
onSelectRow={setPolicyApprover}
showScrollIndicator
/>
</FullPageNotFoundView>
</ScreenWrapper>
);
}

WorkspaceWorkflowsApproverPage.displayName = 'WorkspaceWorkflowsApproverPage';

export default compose(
withOnyx<WorkspaceWorkflowsApproverPageProps, WorkspaceWorkflowsApproverPageOnyxProps>({
personalDetails: {
key: ONYXKEYS.PERSONAL_DETAILS_LIST,
},
}),
withPolicyAndFullscreenLoading,
)(WorkspaceWorkflowsApproverPage);
14 changes: 6 additions & 8 deletions src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,9 @@ import useStyleUtils from '@hooks/useStyleUtils';
import useThemeStyles from '@hooks/useThemeStyles';
import useWindowDimensions from '@hooks/useWindowDimensions';
import Navigation from '@libs/Navigation/Navigation';
import * as OptionsListUtils from '@libs/OptionsListUtils';
import Permissions from '@libs/Permissions';
import * as PersonalDetailsUtils from '@libs/PersonalDetailsUtils';
import * as PolicyUtils from '@libs/PolicyUtils';
import * as ReportUtils from '@libs/ReportUtils';
import type {CentralPaneNavigatorParamList} from '@navigation/types';
import type {WithPolicyProps} from '@pages/workspace/withPolicy';
import withPolicy from '@pages/workspace/withPolicy';
Expand Down Expand Up @@ -45,8 +44,8 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr
const {isSmallScreenWidth} = useWindowDimensions();
const {isOffline} = useNetwork();

const ownerPersonalDetails = ReportUtils.getDisplayNamesWithTooltips(OptionsListUtils.getPersonalDetailsForAccountIDs([policy?.ownerAccountID ?? 0], CONST.EMPTY_OBJECT), false);
const policyOwnerDisplayName = ownerPersonalDetails[0]?.displayName;
const policyApproverEmail = policy?.approver;
const policyApproverName = useMemo(() => PersonalDetailsUtils.getPersonalDetailByEmail(policyApproverEmail ?? '')?.displayName ?? policyApproverEmail, [policyApproverEmail]);
const containerStyle = useMemo(() => [styles.ph8, styles.mhn8, styles.ml11, styles.pv3, styles.pr0, styles.pl4, styles.mr0, styles.widthAuto, styles.mt4], [styles]);
const canUseDelayedSubmission = Permissions.canUseWorkflowsDelayedSubmission(betas);

Expand Down Expand Up @@ -96,9 +95,8 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr
title={translate('workflowsPage.approver')}
titleStyle={styles.textLabelSupportingNormal}
descriptionTextStyle={styles.textNormalThemeText}
description={policyOwnerDisplayName ?? ''}
// onPress={() => Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVER.getRoute(route.params.policyID))}
// TODO will be done in https://github.com/Expensify/Expensify/issues/368334
description={policyApproverName ?? ''}
onPress={() => Navigation.navigate(ROUTES.WORKSPACE_WORKFLOWS_APPROVER.getRoute(route.params.policyID))}
shouldShowRightIcon
wrapperStyle={containerStyle}
hoverAndPressStyle={[styles.mr0, styles.br2]}
Expand Down Expand Up @@ -132,11 +130,11 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr
},
],
[
policyApproverName,
policy,
route.params.policyID,
styles,
translate,
policyOwnerDisplayName,
containerStyle,
isOffline,
StyleUtils,
Expand Down

0 comments on commit 902f2b8

Please sign in to comment.