diff --git a/src/ROUTES.ts b/src/ROUTES.ts index fb99108c7e97..e9cdce4f6ed9 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -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, diff --git a/src/SCREENS.ts b/src/SCREENS.ts index cc7df01524f7..ff3dbfd7f901 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -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', diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx index 527d93c2a3db..545641957c9a 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators.tsx @@ -243,6 +243,7 @@ const SettingsModalStackNavigator = createModalStackNavigator 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, diff --git a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts index 7e38ed99105e..618eddc9f62c 100755 --- a/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts +++ b/src/libs/Navigation/linkingConfig/CENTRAL_PANE_TO_RHP_MAPPING.ts @@ -5,6 +5,7 @@ const CENTRAL_PANE_TO_RHP_MAPPING: Partial> = [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; diff --git a/src/libs/Navigation/linkingConfig/config.ts b/src/libs/Navigation/linkingConfig/config.ts index 7e0e6c028ff1..276829e8c691 100644 --- a/src/libs/Navigation/linkingConfig/config.ts +++ b/src/libs/Navigation/linkingConfig/config.ts @@ -262,6 +262,9 @@ const config: LinkingOptions['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, }, diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 6d680ac7e190..a1e558869ebe 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -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; }; diff --git a/src/libs/OptionsListUtils.ts b/src/libs/OptionsListUtils.ts index 145c72ccd080..342006eca710 100644 --- a/src/libs/OptionsListUtils.ts +++ b/src/libs/OptionsListUtils.ts @@ -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 */ @@ -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 ?? '', }; } @@ -2026,6 +2031,7 @@ export { getMemberInviteOptions, getHeaderMessage, getHeaderMessageForNonUserList, + getSearchValueForPhoneOrEmail, getPersonalDetailsForAccountIDs, getIOUConfirmationOptionsFromPayeePersonalDetail, getIOUConfirmationOptionsFromParticipants, diff --git a/src/libs/PersonalDetailsUtils.ts b/src/libs/PersonalDetailsUtils.ts index 1c517f42637f..4be9ad81184b 100644 --- a/src/libs/PersonalDetailsUtils.ts +++ b/src/libs/PersonalDetailsUtils.ts @@ -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. * @@ -263,6 +267,7 @@ export { isPersonalDetailsEmpty, getDisplayNameOrDefault, getPersonalDetailsByIDs, + getPersonalDetailByEmail, getAccountIDsByLogins, getLoginsByAccountIDs, getNewPersonalDetailsOnyxData, diff --git a/src/pages/workspace/WorkspaceInvitePage.tsx b/src/pages/workspace/WorkspaceInvitePage.tsx index 03fa78367eda..67bf6f8064da 100644 --- a/src/pages/workspace/WorkspaceInvitePage.tsx +++ b/src/pages/workspace/WorkspaceInvitePage.tsx @@ -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'; @@ -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; diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsApproverPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsApproverPage.tsx new file mode 100644 index 000000000000..52406a8033d2 --- /dev/null +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsApproverPage.tsx @@ -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; +}; + +type WorkspaceWorkflowsApproverPageProps = WorkspaceWorkflowsApproverPageOnyxProps & WithPolicyAndFullscreenLoadingProps; +type MemberOption = Omit & {accountID: number}; +type MembersSection = SectionListData>; + +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 = ( + + ); + } + + 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 ( + + + + + + + ); +} + +WorkspaceWorkflowsApproverPage.displayName = 'WorkspaceWorkflowsApproverPage'; + +export default compose( + withOnyx({ + personalDetails: { + key: ONYXKEYS.PERSONAL_DETAILS_LIST, + }, + }), + withPolicyAndFullscreenLoading, +)(WorkspaceWorkflowsApproverPage); diff --git a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx index d9974ed193be..0d8b1d2aced2 100644 --- a/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx +++ b/src/pages/workspace/workflows/WorkspaceWorkflowsPage.tsx @@ -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'; @@ -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); @@ -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]} @@ -132,11 +130,11 @@ function WorkspaceWorkflowsPage({policy, betas, route}: WorkspaceWorkflowsPagePr }, ], [ + policyApproverName, policy, route.params.policyID, styles, translate, - policyOwnerDisplayName, containerStyle, isOffline, StyleUtils,