From 832749275fd83b361120280ed9494426261136fa Mon Sep 17 00:00:00 2001 From: Qichen Zhu <57348009+QichenZhu@users.noreply.github.com> Date: Sat, 22 Nov 2025 01:29:41 +1300 Subject: [PATCH 01/21] Add bulk change approver option --- src/CONST/index.ts | 2 + src/ROUTES.ts | 2 + src/SCREENS.ts | 4 + src/components/Search/SearchContext.tsx | 3 + src/components/Search/types.ts | 3 + src/languages/en.ts | 3 + .../ModalStackNavigators/index.tsx | 2 + .../linkingConfig/RELATIONS/SEARCH_TO_RHP.ts | 2 + src/libs/Navigation/linkingConfig/config.ts | 2 + src/libs/Navigation/types.ts | 3 + src/libs/SearchUIUtils.ts | 7 +- src/pages/ReportChangeApproverPage.tsx | 2 + src/pages/Search/SearchAddApproverPage.tsx | 195 +++++++++++++++++ src/pages/Search/SearchChangeApproverPage.tsx | 199 ++++++++++++++++++ src/pages/Search/SearchPage.tsx | 29 ++- 15 files changed, 456 insertions(+), 2 deletions(-) create mode 100644 src/pages/Search/SearchAddApproverPage.tsx create mode 100644 src/pages/Search/SearchChangeApproverPage.tsx diff --git a/src/CONST/index.ts b/src/CONST/index.ts index 13fbda2c4812..efc77e8da16f 100755 --- a/src/CONST/index.ts +++ b/src/CONST/index.ts @@ -6587,6 +6587,7 @@ const CONST = { REVIEW: 'review', SUBMIT: 'submit', APPROVE: 'approve', + CHANGE_APPROVER: 'changeApprover', PAY: 'pay', DONE: 'done', EXPORT_TO_ACCOUNTING: 'exportToAccounting', @@ -6602,6 +6603,7 @@ const CONST = { BULK_ACTION_TYPES: { EXPORT: 'export', APPROVE: 'approve', + CHANGE_APPROVER: 'changeApprover', PAY: 'pay', SUBMIT: 'submit', HOLD: 'hold', diff --git a/src/ROUTES.ts b/src/ROUTES.ts index 2010bffe9869..16edcaa9c07d 100644 --- a/src/ROUTES.ts +++ b/src/ROUTES.ts @@ -122,6 +122,8 @@ const ROUTES = { }, TRANSACTION_HOLD_REASON_RHP: 'search/hold', MOVE_TRANSACTIONS_SEARCH_RHP: 'search/move-transactions', + CHANGE_APPROVER_SEARCH_RHP: 'search/change-approver', + CHANGE_APPROVER_ADD_APPROVER_SEARCH_RHP: 'search/change-approver/add', // This is a utility route used to go to the user's concierge chat, or the sign-in page if the user's not authenticated CONCIERGE: 'concierge', diff --git a/src/SCREENS.ts b/src/SCREENS.ts index dcc369f32e80..ceaad8597385 100644 --- a/src/SCREENS.ts +++ b/src/SCREENS.ts @@ -92,6 +92,10 @@ const SCREENS = { ADVANCED_FILTERS_IN_RHP: 'Search_Advanced_Filters_In_RHP', TRANSACTION_HOLD_REASON_RHP: 'Search_Transaction_Hold_Reason_RHP', TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP: 'Search_Transactions_Change_Report_RHP', + CHANGE_APPROVER: { + ROOT: 'Search_Change_Approver_Root', + ADD_APPROVER: 'Search_Change_Approver_Add_Approver', + }, }, SETTINGS: { ROOT: 'Settings_Root', diff --git a/src/components/Search/SearchContext.tsx b/src/components/Search/SearchContext.tsx index 5efee66d492e..e96fbda2b6da 100644 --- a/src/components/Search/SearchContext.tsx +++ b/src/components/Search/SearchContext.tsx @@ -113,6 +113,9 @@ function SearchContextProvider({children}: ChildrenProps) { allActions, currency, chatReportID: report?.chatReportID, + parentReportActionID: report?.parentReportActionID, + parentReportID: report?.parentReportID, + type: report?.type, })); } diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 2e61b1240bf8..8a1adf055521 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -66,6 +66,9 @@ type SelectedReports = { total: number; currency?: string; chatReportID: string | undefined; + parentReportActionID?: string; + parentReportID?: string; + type?: string; }; /** Model of payment data used by Search bulk actions */ diff --git a/src/languages/en.ts b/src/languages/en.ts index e7e438f29070..f97798e2e127 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1487,6 +1487,7 @@ const translations = { changeApprover: { title: 'Change approver', subtitle: 'Choose an option to change the approver for this report.', + bulkSubtitle: 'Choose an option to change the approvers for the selected reports.', description: ({workflowSettingLink}: WorkflowSettingsParam) => `You can also change the approver permanently for all reports in your workflow settings.`, changedApproverMessage: ({managerID}: ChangedApproverMessageParams) => `changed the approver to `, @@ -1498,6 +1499,7 @@ const translations = { }, addApprover: { subtitle: 'Choose an additional approver for this report before we route through the rest of the approval workflow.', + bulkSubtitle: 'Choose an additional approver for the selected reports before we route through the rest of the approval workflow.', }, }, chooseWorkspace: 'Choose a workspace', @@ -2230,6 +2232,7 @@ const translations = { title: 'No members to display', expensesFromSubtitle: 'All workspace members already belong to an existing approval workflow.', approverSubtitle: 'All approvers belong to an existing workflow.', + bulkApproverSubtitle: 'All approvers belong to an existing workflow or cannot approve some of the selected reports.', }, }, workflowsDelayedSubmissionPage: { diff --git a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx index acd80faf5b4c..d2b01096ae00 100644 --- a/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx +++ b/src/libs/Navigation/AppNavigator/ModalStackNavigators/index.tsx @@ -898,6 +898,8 @@ const SearchReportActionsModalStackNavigator = createModalStackNavigator require('../../../../pages/Search/SearchHoldReasonPage').default, [SCREENS.SEARCH.TRANSACTION_HOLD_REASON_RHP]: () => require('../../../../pages/Search/SearchHoldReasonPage').default, [SCREENS.SEARCH.TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP]: () => require('../../../../pages/Search/SearchTransactionsChangeReport').default, + [SCREENS.SEARCH.CHANGE_APPROVER.ROOT]: () => require('../../../../pages/Search/SearchChangeApproverPage').default, + [SCREENS.SEARCH.CHANGE_APPROVER.ADD_APPROVER]: () => require('../../../../pages/Search/SearchAddApproverPage').default, }); // This navigator is reserved for the screen that can be displayed as Wide RHP, other screens should not be added here. diff --git a/src/libs/Navigation/linkingConfig/RELATIONS/SEARCH_TO_RHP.ts b/src/libs/Navigation/linkingConfig/RELATIONS/SEARCH_TO_RHP.ts index 5e56b2534259..87e40a57ce37 100644 --- a/src/libs/Navigation/linkingConfig/RELATIONS/SEARCH_TO_RHP.ts +++ b/src/libs/Navigation/linkingConfig/RELATIONS/SEARCH_TO_RHP.ts @@ -50,6 +50,8 @@ const SEARCH_TO_RHP: Partial['config'] = { [SCREENS.SEARCH.MONEY_REQUEST_REPORT_HOLD_TRANSACTIONS]: ROUTES.SEARCH_MONEY_REQUEST_REPORT_HOLD_TRANSACTIONS.route, [SCREENS.SEARCH.TRANSACTION_HOLD_REASON_RHP]: ROUTES.TRANSACTION_HOLD_REASON_RHP, [SCREENS.SEARCH.TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP]: ROUTES.MOVE_TRANSACTIONS_SEARCH_RHP, + [SCREENS.SEARCH.CHANGE_APPROVER.ROOT]: ROUTES.CHANGE_APPROVER_SEARCH_RHP, + [SCREENS.SEARCH.CHANGE_APPROVER.ADD_APPROVER]: ROUTES.CHANGE_APPROVER_ADD_APPROVER_SEARCH_RHP, }, }, [SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT]: { diff --git a/src/libs/Navigation/types.ts b/src/libs/Navigation/types.ts index 659a0376b718..78cfbbf0f189 100644 --- a/src/libs/Navigation/types.ts +++ b/src/libs/Navigation/types.ts @@ -2615,6 +2615,9 @@ type SearchReportActionsParamList = { /** Selected transactions' report ID */ reportID: string; }; + [SCREENS.SEARCH.TRANSACTIONS_CHANGE_REPORT_SEARCH_RHP]: undefined; + [SCREENS.SEARCH.CHANGE_APPROVER.ROOT]: undefined; + [SCREENS.SEARCH.CHANGE_APPROVER.ADD_APPROVER]: undefined; }; type SearchReportParamList = { diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index ad27a0ee2312..606892cc2b05 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -72,7 +72,7 @@ import {translateLocal} from './Localize'; import Navigation from './Navigation/Navigation'; import Parser from './Parser'; import {getDisplayNameOrDefault} from './PersonalDetailsUtils'; -import {arePaymentsEnabled, canSendInvoice, getGroupPaidPoliciesWithExpenseChatEnabled, getPolicy, isPaidGroupPolicy, isPolicyPayer} from './PolicyUtils'; +import {arePaymentsEnabled, canSendInvoice, getGroupPaidPoliciesWithExpenseChatEnabled, getPolicy, isPaidGroupPolicy, isPolicyAdmin, isPolicyPayer} from './PolicyUtils'; import { getOriginalMessage, isCreatedAction, @@ -100,6 +100,7 @@ import { isClosedReport, isInvoiceReport, isMoneyRequestReport, + isMoneyRequestReportPendingDeletion, isOpenExpenseReport, isOpenReport, isSettled, @@ -1306,6 +1307,10 @@ function getActions( allActions.push(CONST.SEARCH.ACTION_TYPES.APPROVE); } + if (report && policy && isPolicyAdmin(policy) && isMoneyRequestReport(report) && !isMoneyRequestReportPendingDeletion(report)) { + allActions.push(CONST.SEARCH.ACTION_TYPES.CHANGE_APPROVER); + } + // We check for isAllowedToApproveExpenseReport because if the policy has preventSelfApprovals enabled, we disable the Submit action and in that case we want to show the View action instead if (canSubmitReport(report, policy, allReportTransactions, allViolations, isIOUReportArchived || isChatReportArchived) && isAllowedToApproveExpenseReport) { allActions.push(CONST.SEARCH.ACTION_TYPES.SUBMIT); diff --git a/src/pages/ReportChangeApproverPage.tsx b/src/pages/ReportChangeApproverPage.tsx index dc80a1982e19..8c6bbcec9f5c 100644 --- a/src/pages/ReportChangeApproverPage.tsx +++ b/src/pages/ReportChangeApproverPage.tsx @@ -154,3 +154,5 @@ function ReportChangeApproverPage({report, policy, isLoadingReportData}: ReportC ReportChangeApproverPage.displayName = 'ReportChangeApproverPage'; export default withReportOrNotFound()(ReportChangeApproverPage); +export {APPROVER_TYPE}; +export type {ApproverType}; diff --git a/src/pages/Search/SearchAddApproverPage.tsx b/src/pages/Search/SearchAddApproverPage.tsx new file mode 100644 index 000000000000..5a5bd466d946 --- /dev/null +++ b/src/pages/Search/SearchAddApproverPage.tsx @@ -0,0 +1,195 @@ +import _ from 'lodash'; +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import ApproverSelectionList from '@components/ApproverSelectionList'; +import Badge from '@components/Badge'; +import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import {FallbackAvatar} from '@components/Icon/Expensicons'; +import {useSearchContext} from '@components/Search/SearchContext'; +import Text from '@components/Text'; +import type {SelectionListApprover} from '@components/WorkspaceMembersSelectionList'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {addReportApprover} from '@libs/actions/IOU'; +import Navigation from '@libs/Navigation/Navigation'; +import {getMemberAccountIDsForWorkspace} from '@libs/PolicyUtils'; +import {getDisplayNameForParticipant, hasViolations as hasViolationsReportUtils, isAllowedToApproveExpenseReport} from '@libs/ReportUtils'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; + +function SearchAddApproverPage() { + const styles = useThemeStyles(); + const {translate} = useLocalize(); + const [selectedApproverEmail, setSelectedApproverEmail] = useState(undefined); + const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); + const {isBetaEnabled} = usePermissions(); + const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); + const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: true}); + const {selectedReports} = useSearchContext(); + + const currentUserDetails = useCurrentUserPersonalDetails(); + + // Get all possible approvers from all selected reports' policies + // An approver must be able to approve ALL selected reports + const allApprovers = useMemo(() => { + if (selectedReports.length === 0) { + return []; + } + + const uniquePolicyIds = _.uniq(selectedReports.map((selectedReport) => selectedReport.policyID)); + const employeeLists = uniquePolicyIds.map((policyID) => allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.employeeList).filter((employeeList) => !!employeeList); + const intersectedEmployees = employeeLists.length === 0 ? {} : _.pick(employeeLists[0], _.intersection(...employeeLists.map(Object.keys))); + const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(intersectedEmployees, true, false); + return Object.values(intersectedEmployees) + .map((employee): SelectionListApprover | null => { + const isAdmin = employee?.role === CONST.REPORT.ROLE.ADMIN; + const email = employee?.email; + + if (!email) { + return null; + } + const accountID = Number(policyMemberEmailsToAccountIDs[email] ?? CONST.DEFAULT_NUMBER_ID); + const isPendingDelete = intersectedEmployees?.[accountID]?.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE; + + if (isPendingDelete) { + return null; + } + + const isApproverOfAllReports = selectedReports.every((selectedReport) => { + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${selectedReport.reportID}`]; + + if (!report) { + return false; + } + + return report.managerID === accountID; + }); + if (isApproverOfAllReports) { + return null; + } + + const canApproveAllReports = selectedReports.every((selectedReport) => { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedReport.policyID}`]; + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${selectedReport.reportID}`]; + + if (!report || !policy) { + return false; + } + + return isAllowedToApproveExpenseReport(report, accountID, policy); + }); + + if (!canApproveAllReports) { + return null; + } + + const {avatar} = personalDetails?.[accountID] ?? {}; + const displayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails}); + return { + text: displayName, + alternateText: email, + keyForList: email, + isSelected: selectedApproverEmail === email, + login: email, + value: accountID, + icons: [{source: avatar ?? FallbackAvatar, type: CONST.ICON_TYPE_AVATAR, name: displayName, id: accountID}], + rightElement: isAdmin ? : undefined, + }; + }) + .filter((approver): approver is SelectionListApprover => !!approver); + }, [selectedReports, allPolicies, allReports, personalDetails, selectedApproverEmail, translate]); + + const addApprover = useCallback(() => { + const employeeAccountID = allApprovers.find((approver) => approver.login === selectedApproverEmail)?.value; + if (!selectedApproverEmail || !employeeAccountID) { + return; + } + + selectedReports.forEach((selectedReport) => { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedReport.policyID}`]; + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${selectedReport.reportID}`]; + + if (!report || !policy || report.managerID === employeeAccountID) { + return; + } + + const hasViolations = hasViolationsReportUtils(report.reportID, transactionViolations); + addReportApprover( + report, + selectedApproverEmail, + Number(employeeAccountID), + currentUserDetails.accountID, + currentUserDetails.email ?? '', + policy, + hasViolations, + isASAPSubmitBetaEnabled, + ); + }); + + Navigation.closeRHPFlow(); + }, [ + allApprovers, + selectedApproverEmail, + selectedReports, + allPolicies, + allReports, + transactionViolations, + currentUserDetails.accountID, + currentUserDetails.email, + isASAPSubmitBetaEnabled, + ]); + + const button = useMemo(() => { + return ( + + ); + }, [addApprover, selectedApproverEmail, styles.flexBasisAuto, styles.flexGrow0, styles.flexReset, styles.flexShrink0, translate]); + + const toggleApprover = useCallback((approvers: SelectionListApprover[]) => { + setSelectedApproverEmail(approvers.length ? approvers.at(0)?.login : undefined); + }, []); + + useEffect(() => { + if (selectedReports.length === 0) { + Navigation.closeRHPFlow(); + } + }, [selectedReports]); + + return ( + + {selectedReports.length === 1 ? translate('iou.changeApprover.addApprover.subtitle') : translate('iou.changeApprover.addApprover.bulkSubtitle')} + + } + isLoadingReportData={false} + policy={allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedReports[0]?.policyID}`]} + initiallyFocusedOptionKey={selectedApproverEmail} + shouldShowNotFoundViewLink={false} + shouldShowNotFoundView={false} + allApprovers={allApprovers} + listEmptyContentSubtitle={translate('workflowsPage.emptyContent.bulkApproverSubtitle')} + allowMultipleSelection={false} + onSelectApprover={toggleApprover} + footerContent={button} + /> + ); +} + +SearchAddApproverPage.displayName = 'SearchAddApproverPage'; + +export default SearchAddApproverPage; diff --git a/src/pages/Search/SearchChangeApproverPage.tsx b/src/pages/Search/SearchChangeApproverPage.tsx new file mode 100644 index 000000000000..72bbfafea984 --- /dev/null +++ b/src/pages/Search/SearchChangeApproverPage.tsx @@ -0,0 +1,199 @@ +import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import {View} from 'react-native'; +import FormHelpMessage from '@components/FormHelpMessage'; +import HeaderWithBackButton from '@components/HeaderWithBackButton'; +import RenderHTML from '@components/RenderHTML'; +import ScreenWrapper from '@components/ScreenWrapper'; +import {useSearchContext} from '@components/Search/SearchContext'; +import SelectionList from '@components/SelectionListWithSections'; +import RadioListItem from '@components/SelectionListWithSections/RadioListItem'; +import type {ListItem} from '@components/SelectionListWithSections/types'; +import Text from '@components/Text'; +import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import useEnvironment from '@hooks/useEnvironment'; +import useLocalize from '@hooks/useLocalize'; +import useOnyx from '@hooks/useOnyx'; +import usePermissions from '@hooks/usePermissions'; +import useThemeStyles from '@hooks/useThemeStyles'; +import {assignReportToMe} from '@libs/actions/IOU'; +import Navigation from '@libs/Navigation/Navigation'; +import {isControlPolicy} from '@libs/PolicyUtils'; +import {hasViolations as hasViolationsReportUtils, isAllowedToApproveExpenseReport} from '@libs/ReportUtils'; +import {APPROVER_TYPE} from '@pages/ReportChangeApproverPage'; +import type {ApproverType} from '@pages/ReportChangeApproverPage'; +import CONST from '@src/CONST'; +import ONYXKEYS from '@src/ONYXKEYS'; +import ROUTES from '@src/ROUTES'; +import type {Policy} from '@src/types/onyx'; + +function SearchChangeApproverPage() { + const {translate} = useLocalize(); + const styles = useThemeStyles(); + const {environmentURL} = useEnvironment(); + const currentUserDetails = useCurrentUserPersonalDetails(); + const [selectedApproverType, setSelectedApproverType] = useState(); + const [hasError, setHasError] = useState(false); + const {isBetaEnabled} = usePermissions(); + const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); + const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); + const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); + const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: true}); + const {selectedReports} = useSearchContext(); + + const selectedPolicies = useMemo(() => { + const policies = new Map(); + selectedReports.forEach((selectedReport) => { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedReport.policyID}`]; + if (policy?.id) { + policies.set(policy.id, policy); + } + }); + return Array.from(policies.values()); + }, [selectedReports, allPolicies]); + + const changeApprover = useCallback(() => { + if (!selectedApproverType) { + setHasError(true); + return; + } + + if (selectedApproverType === APPROVER_TYPE.ADD_APPROVER) { + const policiesToUpgrade = selectedPolicies.filter((policy) => !isControlPolicy(policy)); + if (policiesToUpgrade.length > 1) { + // Bulk upgrade is not supported, so show a general page to guide the user to upgrade manually + Navigation.navigate(ROUTES.WORKSPACE_UPGRADE.getRoute(undefined, undefined, ROUTES.CHANGE_APPROVER_SEARCH_RHP)); + return; + } + if (policiesToUpgrade.length === 1) { + Navigation.navigate( + ROUTES.WORKSPACE_UPGRADE.getRoute(policiesToUpgrade[0].id, CONST.UPGRADE_FEATURE_INTRO_MAPPING.multiApprovalLevels.alias, ROUTES.CHANGE_APPROVER_SEARCH_RHP), + ); + return; + } + + Navigation.navigate(ROUTES.CHANGE_APPROVER_ADD_APPROVER_SEARCH_RHP); + return; + } + + selectedReports.forEach((selectedReport) => { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedReport.policyID}`]; + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${selectedReport.reportID}`]; + + if (!report || !policy) { + return; + } + + if (report.managerID !== currentUserDetails.accountID) { + const hasViolations = hasViolationsReportUtils(report.reportID, transactionViolations); + assignReportToMe(report, currentUserDetails.accountID, currentUserDetails.email ?? '', policy, hasViolations, isASAPSubmitBetaEnabled); + } + }); + + Navigation.closeRHPFlow(); + }, [ + selectedApproverType, + selectedPolicies, + selectedReports, + allPolicies, + allReports, + currentUserDetails.accountID, + currentUserDetails.email, + transactionViolations, + isASAPSubmitBetaEnabled, + ]); + + const sections = useMemo(() => { + const data: Array> = [ + { + text: translate('iou.changeApprover.actions.addApprover'), + keyForList: APPROVER_TYPE.ADD_APPROVER, + alternateText: translate('iou.changeApprover.actions.addApproverSubtitle'), + isSelected: selectedApproverType === APPROVER_TYPE.ADD_APPROVER, + }, + ]; + + const shouldShowBypassApproversOption = selectedReports.some((selectedReport) => { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedReport.policyID}`]; + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${selectedReport.reportID}`]; + + if (!report || !policy) { + return false; + } + + const isCurrentUserManager = report.managerID === currentUserDetails.accountID; + return !isCurrentUserManager && isAllowedToApproveExpenseReport(report, currentUserDetails.accountID, policy); + }); + + if (shouldShowBypassApproversOption) { + data.push({ + text: translate('iou.changeApprover.actions.bypassApprovers'), + keyForList: APPROVER_TYPE.BYPASS_APPROVER, + alternateText: translate('iou.changeApprover.actions.bypassApproversSubtitle'), + isSelected: selectedApproverType === APPROVER_TYPE.BYPASS_APPROVER, + }); + } + + return [{data}]; + }, [selectedReports, allPolicies, allReports, currentUserDetails.accountID, selectedApproverType, translate]); + + useEffect(() => { + if (selectedReports.length === 0 || sections[0].data.length === 0) { + Navigation.closeRHPFlow(); + } + }, [selectedReports, sections]); + + return ( + + + { + if (!option.keyForList) { + return; + } + setSelectedApproverType(option.keyForList); + setHasError(false); + }} + showConfirmButton + confirmButtonText={translate('iou.changeApprover.title')} + onConfirm={changeApprover} + shouldUpdateFocusedIndex + customListHeader={ + <> + {translate(selectedReports.length === 1 ? 'iou.changeApprover.subtitle' : 'iou.changeApprover.bulkSubtitle')} + {selectedPolicies.length === 1 && ( + + + + )} + + } + > + {hasError && ( + + )} + + + ); +} + +SearchChangeApproverPage.displayName = 'SearchChangeApproverPage'; + +export default SearchChangeApproverPage; diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 4df55d8b9efe..32df16c2f441 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -59,7 +59,7 @@ import {navigateToParticipantPage} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; -import {getActiveAdminWorkspaces, hasDynamicExternalWorkflow, hasVBBA, isPaidGroupPolicy} from '@libs/PolicyUtils'; +import {getActiveAdminWorkspaces, hasDynamicExternalWorkflow, hasVBBA, isPaidGroupPolicy, isPolicyAdmin} from '@libs/PolicyUtils'; import { generateReportID, getPolicyExpenseChat, @@ -68,6 +68,8 @@ import { isExpenseReport as isExpenseReportUtil, isInvoiceReport, isIOUReport as isIOUReportUtil, + isMoneyRequestReport, + isMoneyRequestReportPendingDeletion, } from '@libs/ReportUtils'; import {buildCannedSearchQuery, buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; @@ -132,6 +134,7 @@ function SearchPage({route}: SearchPageProps) { 'Exclamation', 'SmartScan', 'MoneyBag', + 'Workflows', ] as const); // eslint-disable-next-line rulesdir/no-default-id-values @@ -445,6 +448,30 @@ function SearchPage({route}: SearchPageProps) { }); } + const shouldShowChangeApproverOption = + !isOffline && + !isAnyTransactionOnHold && + areSelectedTransactionsIncludedInReports && + selectedReports.length && + selectedReports.every((report) => report.allActions.includes(CONST.SEARCH.ACTION_TYPES.CHANGE_APPROVER)); + + if (shouldShowChangeApproverOption) { + options.push({ + icon: expensifyIcons.Workflows, + text: translate('iou.changeApprover.title'), + value: CONST.SEARCH.BULK_ACTION_TYPES.CHANGE_APPROVER, + shouldCloseModalOnSelect: true, + onSelected: () => { + if (isOffline) { + setIsOfflineModalVisible(true); + return; + } + + Navigation.navigate(ROUTES.CHANGE_APPROVER_SEARCH_RHP); + }, + }); + } + const shouldShowSubmitOption = !isOffline && areSelectedTransactionsIncludedInReports && From 1c20ba3afdbf54a327a62fda2d6f024296f78388 Mon Sep 17 00:00:00 2001 From: Qichen Zhu <57348009+QichenZhu@users.noreply.github.com> Date: Thu, 27 Nov 2025 01:48:23 +1300 Subject: [PATCH 02/21] Fix types --- src/components/SelectionListWithSections/Search/ActionCell.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/components/SelectionListWithSections/Search/ActionCell.tsx b/src/components/SelectionListWithSections/Search/ActionCell.tsx index 223a419f2f52..078275414d8a 100644 --- a/src/components/SelectionListWithSections/Search/ActionCell.tsx +++ b/src/components/SelectionListWithSections/Search/ActionCell.tsx @@ -35,6 +35,7 @@ const actionTranslationsMap: Record = exportToAccounting: 'common.export', done: 'common.done', paid: 'iou.settledExpensify', + changeApprover: 'iou.changeApprover.title', }; type ActionCellProps = { From fce154288876c95dde95a0dfea1f7a1dd8d2150c Mon Sep 17 00:00:00 2001 From: Qichen Zhu <57348009+QichenZhu@users.noreply.github.com> Date: Thu, 27 Nov 2025 02:24:19 +1300 Subject: [PATCH 03/21] Fix lint problems --- src/pages/Search/SearchAddApproverPage.tsx | 27 +++++++++++-------- src/pages/Search/SearchChangeApproverPage.tsx | 20 +++++++------- src/pages/Search/SearchPage.tsx | 4 +-- 3 files changed, 28 insertions(+), 23 deletions(-) diff --git a/src/pages/Search/SearchAddApproverPage.tsx b/src/pages/Search/SearchAddApproverPage.tsx index 5a5bd466d946..bba355126ca1 100644 --- a/src/pages/Search/SearchAddApproverPage.tsx +++ b/src/pages/Search/SearchAddApproverPage.tsx @@ -1,13 +1,14 @@ -import _ from 'lodash'; +import lodashIntersection from 'lodash/intersection'; +import lodashPick from 'lodash/pick'; import React, {useCallback, useEffect, useMemo, useState} from 'react'; import ApproverSelectionList from '@components/ApproverSelectionList'; import Badge from '@components/Badge'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; -import {FallbackAvatar} from '@components/Icon/Expensicons'; import {useSearchContext} from '@components/Search/SearchContext'; import Text from '@components/Text'; import type {SelectionListApprover} from '@components/WorkspaceMembersSelectionList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; +import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; @@ -22,6 +23,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; function SearchAddApproverPage() { const styles = useThemeStyles(); const {translate} = useLocalize(); + const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar'] as const); const [selectedApproverEmail, setSelectedApproverEmail] = useState(undefined); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); const {isBetaEnabled} = usePermissions(); @@ -40,9 +42,10 @@ function SearchAddApproverPage() { return []; } - const uniquePolicyIds = _.uniq(selectedReports.map((selectedReport) => selectedReport.policyID)); + const uniquePolicyIds = Array.from(new Set(selectedReports.map((selectedReport) => selectedReport.policyID))); const employeeLists = uniquePolicyIds.map((policyID) => allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${policyID}`]?.employeeList).filter((employeeList) => !!employeeList); - const intersectedEmployees = employeeLists.length === 0 ? {} : _.pick(employeeLists[0], _.intersection(...employeeLists.map(Object.keys))); + const firstWorkspaceEmployees = employeeLists.at(0); + const intersectedEmployees = firstWorkspaceEmployees ? lodashPick(firstWorkspaceEmployees, lodashIntersection(...employeeLists.map(Object.keys))) : {}; const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(intersectedEmployees, true, false); return Object.values(intersectedEmployees) .map((employee): SelectionListApprover | null => { @@ -96,7 +99,7 @@ function SearchAddApproverPage() { isSelected: selectedApproverEmail === email, login: email, value: accountID, - icons: [{source: avatar ?? FallbackAvatar, type: CONST.ICON_TYPE_AVATAR, name: displayName, id: accountID}], + icons: [{source: avatar ?? icons.FallbackAvatar, type: CONST.ICON_TYPE_AVATAR, name: displayName, id: accountID}], rightElement: isAdmin ? : undefined, }; }) @@ -109,12 +112,12 @@ function SearchAddApproverPage() { return; } - selectedReports.forEach((selectedReport) => { + for (const selectedReport of selectedReports) { const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedReport.policyID}`]; const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${selectedReport.reportID}`]; if (!report || !policy || report.managerID === employeeAccountID) { - return; + continue; } const hasViolations = hasViolationsReportUtils(report.reportID, transactionViolations); @@ -128,7 +131,7 @@ function SearchAddApproverPage() { hasViolations, isASAPSubmitBetaEnabled, ); - }); + } Navigation.closeRHPFlow(); }, [ @@ -161,9 +164,11 @@ function SearchAddApproverPage() { }, []); useEffect(() => { - if (selectedReports.length === 0) { - Navigation.closeRHPFlow(); + if (selectedReports.length) { + return; } + + Navigation.closeRHPFlow(); }, [selectedReports]); return ( @@ -177,7 +182,7 @@ function SearchAddApproverPage() { } isLoadingReportData={false} - policy={allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedReports[0]?.policyID}`]} + policy={allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedReports.at(0)?.policyID}`]} initiallyFocusedOptionKey={selectedApproverEmail} shouldShowNotFoundViewLink={false} shouldShowNotFoundView={false} diff --git a/src/pages/Search/SearchChangeApproverPage.tsx b/src/pages/Search/SearchChangeApproverPage.tsx index 72bbfafea984..2701dce1e378 100644 --- a/src/pages/Search/SearchChangeApproverPage.tsx +++ b/src/pages/Search/SearchChangeApproverPage.tsx @@ -42,12 +42,12 @@ function SearchChangeApproverPage() { const selectedPolicies = useMemo(() => { const policies = new Map(); - selectedReports.forEach((selectedReport) => { + for (const selectedReport of selectedReports) { const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedReport.policyID}`]; if (policy?.id) { policies.set(policy.id, policy); } - }); + } return Array.from(policies.values()); }, [selectedReports, allPolicies]); @@ -66,7 +66,7 @@ function SearchChangeApproverPage() { } if (policiesToUpgrade.length === 1) { Navigation.navigate( - ROUTES.WORKSPACE_UPGRADE.getRoute(policiesToUpgrade[0].id, CONST.UPGRADE_FEATURE_INTRO_MAPPING.multiApprovalLevels.alias, ROUTES.CHANGE_APPROVER_SEARCH_RHP), + ROUTES.WORKSPACE_UPGRADE.getRoute(policiesToUpgrade.at(0)?.id, CONST.UPGRADE_FEATURE_INTRO_MAPPING.multiApprovalLevels.alias, ROUTES.CHANGE_APPROVER_SEARCH_RHP), ); return; } @@ -75,19 +75,19 @@ function SearchChangeApproverPage() { return; } - selectedReports.forEach((selectedReport) => { + for (const selectedReport of selectedReports) { const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedReport.policyID}`]; const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${selectedReport.reportID}`]; if (!report || !policy) { - return; + continue; } if (report.managerID !== currentUserDetails.accountID) { const hasViolations = hasViolationsReportUtils(report.reportID, transactionViolations); assignReportToMe(report, currentUserDetails.accountID, currentUserDetails.email ?? '', policy, hasViolations, isASAPSubmitBetaEnabled); } - }); + } Navigation.closeRHPFlow(); }, [ @@ -137,9 +137,11 @@ function SearchChangeApproverPage() { }, [selectedReports, allPolicies, allReports, currentUserDetails.accountID, selectedApproverType, translate]); useEffect(() => { - if (selectedReports.length === 0 || sections[0].data.length === 0) { - Navigation.closeRHPFlow(); + if (selectedReports.length && sections.at(0)?.data.length) { + return; } + + Navigation.closeRHPFlow(); }, [selectedReports, sections]); return ( @@ -174,7 +176,7 @@ function SearchChangeApproverPage() { diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index fdeddb99d4a5..7a5832a9c769 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -55,7 +55,7 @@ import {navigateToParticipantPage} from '@libs/IOUUtils'; import Navigation from '@libs/Navigation/Navigation'; import type {PlatformStackScreenProps} from '@libs/Navigation/PlatformStackNavigation/types'; import type {SearchFullscreenNavigatorParamList} from '@libs/Navigation/types'; -import {getActiveAdminWorkspaces, hasDynamicExternalWorkflow, hasVBBA, isPaidGroupPolicy, isPolicyAdmin} from '@libs/PolicyUtils'; +import {getActiveAdminWorkspaces, hasDynamicExternalWorkflow, hasVBBA, isPaidGroupPolicy} from '@libs/PolicyUtils'; import { generateReportID, getPolicyExpenseChat, @@ -64,8 +64,6 @@ import { isExpenseReport as isExpenseReportUtil, isInvoiceReport, isIOUReport as isIOUReportUtil, - isMoneyRequestReport, - isMoneyRequestReportPendingDeletion, } from '@libs/ReportUtils'; import {buildSearchQueryJSON} from '@libs/SearchQueryUtils'; import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils'; From f6692d788263cc10a3801d2c2ec98e5c588ffa86 Mon Sep 17 00:00:00 2001 From: Qichen Zhu <57348009+QichenZhu@users.noreply.github.com> Date: Thu, 27 Nov 2025 02:47:33 +1300 Subject: [PATCH 04/21] Fix tests --- src/libs/SearchUIUtils.ts | 10 ++++++---- tests/unit/Search/SearchUIUtilsTest.ts | 6 +++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 48cafab60beb..371d9d8984fd 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -101,11 +101,13 @@ import { isAllowedToApproveExpenseReport as isAllowedToApproveExpenseReportUtils, isArchivedReport, isClosedReport, + isExpenseReport as isExpenseReportUtil, isInvoiceReport, isMoneyRequestReport, isMoneyRequestReportPendingDeletion, isOpenExpenseReport, isOpenReport, + isProcessingReport, isSettled, } from './ReportUtils'; import {buildCannedSearchQuery, buildQueryStringFromFilterFormValues, buildSearchQueryJSON, buildSearchQueryString, getCurrentSearchQueryJSON} from './SearchQueryUtils'; @@ -1329,15 +1331,15 @@ function getActions( allActions.push(CONST.SEARCH.ACTION_TYPES.APPROVE); } - if (report && policy && isPolicyAdmin(policy) && isMoneyRequestReport(report) && !isMoneyRequestReportPendingDeletion(report)) { - allActions.push(CONST.SEARCH.ACTION_TYPES.CHANGE_APPROVER); - } - // We check for isAllowedToApproveExpenseReport because if the policy has preventSelfApprovals enabled, we disable the Submit action and in that case we want to show the View action instead if (canSubmitReport(report, policy, allReportTransactions, allViolations, isIOUReportArchived || isChatReportArchived, currentUserEmail) && isAllowedToApproveExpenseReport) { allActions.push(CONST.SEARCH.ACTION_TYPES.SUBMIT); } + if (report && policy && isPolicyAdmin(policy) && isExpenseReportUtil(report) && isProcessingReport(report) && !isMoneyRequestReportPendingDeletion(report)) { + allActions.push(CONST.SEARCH.ACTION_TYPES.CHANGE_APPROVER); + } + if (reportNVP?.exportFailedTime) { return allActions.length > 0 ? allActions : [CONST.SEARCH.ACTION_TYPES.REVIEW]; } diff --git a/tests/unit/Search/SearchUIUtilsTest.ts b/tests/unit/Search/SearchUIUtilsTest.ts index 7d34e9206ca9..aefea712fe0d 100644 --- a/tests/unit/Search/SearchUIUtilsTest.ts +++ b/tests/unit/Search/SearchUIUtilsTest.ts @@ -823,7 +823,7 @@ const transactionsListItems = [ }, { action: 'review', - allActions: ['review', 'approve'], + allActions: ['review', 'approve', 'changeApprover'], amount: -5000, report: report2, policy, @@ -1108,7 +1108,7 @@ const transactionReportGroupListItems = [ groupedBy: 'expense-report', accountID: 18439984, action: 'review', - allActions: ['review', 'approve'], + allActions: ['review', 'approve', 'changeApprover'], chatReportID: '1706144653204915', created: '2024-12-21 13:05:20', currency: 'USD', @@ -1143,7 +1143,7 @@ const transactionReportGroupListItems = [ transactions: [ { action: 'review', - allActions: ['review', 'approve'], + allActions: ['review', 'approve', 'changeApprover'], report: report2, policy, reportAction: reportAction2, From 63f8fb4a4f7bf04c15f59a4e7cf46ea36af45907 Mon Sep 17 00:00:00 2001 From: Qichen Zhu <57348009+QichenZhu@users.noreply.github.com> Date: Thu, 27 Nov 2025 16:37:10 +1300 Subject: [PATCH 05/21] Update text copies --- src/languages/de.ts | 3 +++ src/languages/en.ts | 6 +++--- src/languages/es.ts | 3 +++ src/languages/fr.ts | 3 +++ src/languages/it.ts | 3 +++ src/languages/ja.ts | 3 +++ src/languages/nl.ts | 3 +++ src/languages/pl.ts | 3 +++ src/languages/pt-BR.ts | 3 +++ src/languages/zh-hans.ts | 6 +++--- 10 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index f3b0cb43e3bf..2d01cf0d9a8c 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -1523,7 +1523,9 @@ const translations: TranslationDeepObject = { }, addApprover: { subtitle: 'Wählen Sie einen zusätzlichen Genehmiger für diesen Bericht, bevor wir ihn durch den Rest des Genehmigungs-Workflows leiten.', + bulkSubtitle: 'Wählen Sie einen zusätzlichen Genehmiger für diese Berichte, bevor wir sie durch den restlichen Genehmigungsworkflow leiten.', }, + bulkSubtitle: 'Wählen Sie eine Option, um den Genehmiger für diese Berichte zu ändern.', }, chooseWorkspace: 'Wählen Sie einen Arbeitsbereich aus', }, @@ -2154,6 +2156,7 @@ ${amount} für ${merchant} - ${date}`, title: 'Keine Mitglieder zum Anzeigen', expensesFromSubtitle: 'Alle Arbeitsbereichsmitglieder gehören bereits zu einem bestehenden Genehmigungsworkflow.', approverSubtitle: 'Alle Genehmigenden gehören zu einem bestehenden Workflow.', + bulkApproverSubtitle: 'Keine Genehmiger entsprechen den Kriterien für die ausgewählten Berichte.', }, }, workflowsDelayedSubmissionPage: { diff --git a/src/languages/en.ts b/src/languages/en.ts index 268727f47efd..ed7c8dbe28ea 100755 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -1494,7 +1494,7 @@ const translations = { changeApprover: { title: 'Change approver', subtitle: 'Choose an option to change the approver for this report.', - bulkSubtitle: 'Choose an option to change the approvers for the selected reports.', + bulkSubtitle: 'Choose an option to change the approver for these reports.', description: ({workflowSettingLink}: WorkflowSettingsParam) => `You can also change the approver permanently for all reports in your workflow settings.`, changedApproverMessage: ({managerID}: ChangedApproverMessageParams) => `changed the approver to `, @@ -1506,7 +1506,7 @@ const translations = { }, addApprover: { subtitle: 'Choose an additional approver for this report before we route through the rest of the approval workflow.', - bulkSubtitle: 'Choose an additional approver for the selected reports before we route through the rest of the approval workflow.', + bulkSubtitle: 'Choose an additional approver for these reports before we route through the rest of the approval workflow.', }, }, chooseWorkspace: 'Choose a workspace', @@ -2256,7 +2256,7 @@ const translations = { title: 'No members to display', expensesFromSubtitle: 'All workspace members already belong to an existing approval workflow.', approverSubtitle: 'All approvers belong to an existing workflow.', - bulkApproverSubtitle: 'All approvers belong to an existing workflow or cannot approve some of the selected reports.', + bulkApproverSubtitle: 'No approvers match the criteria for selected reports.', }, }, workflowsDelayedSubmissionPage: { diff --git a/src/languages/es.ts b/src/languages/es.ts index e28676dc10db..e2fca3e1d57e 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -1152,6 +1152,7 @@ const translations: TranslationDeepObject = { changeApprover: { title: 'Cambiar aprobador', subtitle: 'Elige una opción para cambiar el aprobador de este informe.', + bulkSubtitle: 'Elige una opción para cambiar el aprobador de estos informes.', description: ({workflowSettingLink}) => `También puedes cambiar el aprobador de forma permanente para todos los informes en tu configuración de flujo de trabajo.`, changedApproverMessage: ({managerID}) => `cambió el aprobador a `, @@ -1163,6 +1164,7 @@ const translations: TranslationDeepObject = { }, addApprover: { subtitle: 'Elige un aprobador adicional para este informe antes de que lo enviemos por el resto del flujo de aprobación.', + bulkSubtitle: 'Elige un aprobador adicional para estos informes antes de que los enviemos por el resto del flujo de aprobación.', }, }, chooseWorkspace: 'Elige un espacio de trabajo', @@ -1896,6 +1898,7 @@ ${amount} para ${merchant} - ${date}`, title: 'No hay miembros para mostrar', expensesFromSubtitle: 'Todos los miembros del espacio de trabajo ya pertenecen a un flujo de aprobación existente.', approverSubtitle: 'Todos los aprobadores pertenecen a un flujo de trabajo existente.', + bulkApproverSubtitle: 'Ningún aprobador coincide con los criterios de los informes seleccionados.', }, }, workflowsDelayedSubmissionPage: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 490e628737e3..c58e886b2835 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -1525,7 +1525,9 @@ const translations: TranslationDeepObject = { }, addApprover: { subtitle: "Choisissez un approbateur supplémentaire pour ce rapport avant de le faire passer par le reste du flux de travail d'approbation.", + bulkSubtitle: 'Choisissez un approbateur supplémentaire pour ces rapports avant que nous ne les transmettions pour le reste du processus d’approbation.', }, + bulkSubtitle: 'Choisissez une option pour modifier l’approbateur de ces rapports.', }, chooseWorkspace: 'Choisissez un espace de travail', }, @@ -2154,6 +2156,7 @@ ${amount} pour ${merchant} - ${date}`, title: 'Aucun membre à afficher', expensesFromSubtitle: "Tous les membres de l'espace de travail appartiennent déjà à un flux de travail d'approbation existant.", approverSubtitle: 'Tous les approbateurs appartiennent à un flux de travail existant.', + bulkApproverSubtitle: 'Aucun approbateur ne correspond aux critères des rapports sélectionnés.', }, }, workflowsDelayedSubmissionPage: { diff --git a/src/languages/it.ts b/src/languages/it.ts index 22f55093723e..585015c1c979 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -1519,7 +1519,9 @@ const translations: TranslationDeepObject = { }, addApprover: { subtitle: 'Scegli un approvatore aggiuntivo per questo report prima di instradarlo attraverso il resto del flusso di lavoro di approvazione.', + bulkSubtitle: 'Scegli un approvatore aggiuntivo per questi report prima di inoltrarli attraverso il resto del flusso di approvazione.', }, + bulkSubtitle: "Scegli un'opzione per modificare l'approvatore di questi report.", }, chooseWorkspace: "Scegli un'area di lavoro", }, @@ -2146,6 +2148,7 @@ ${amount} per ${merchant} - ${date}`, title: 'Nessun membro da visualizzare', expensesFromSubtitle: 'Tutti i membri dello spazio di lavoro appartengono già a un flusso di approvazione esistente.', approverSubtitle: 'Tutti gli approvatori appartengono a un flusso di lavoro esistente.', + bulkApproverSubtitle: 'Nessun approvatore corrisponde ai criteri per i report selezionati.', }, }, workflowsDelayedSubmissionPage: { diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 5489423e508d..3f58c1dd2cc9 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -1519,7 +1519,9 @@ const translations: TranslationDeepObject = { }, addApprover: { subtitle: '承認ワークフローの残りの部分を経由する前に、このレポートの追加の承認者を選択してください。', + bulkSubtitle: '承認ワークフローの残りの工程に進める前に、これらのレポートの追加承認者を選択してください。', }, + bulkSubtitle: 'これらのレポートの承認者を変更するオプションを選択してください。', }, chooseWorkspace: 'ワークスペースを選択', }, @@ -2138,6 +2140,7 @@ ${date} - ${merchant}に${amount}`, title: '表示するメンバーがいません', expensesFromSubtitle: 'すべてのワークスペースメンバーは既存の承認ワークフローにすでに属しています。', approverSubtitle: 'すべての承認者は既存のワークフローに属しています。', + bulkApproverSubtitle: '選択したレポートの条件に一致する承認者がいません。', }, }, workflowsDelayedSubmissionPage: { diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 35a1c6aa15b2..eff59376f694 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -1519,7 +1519,9 @@ const translations: TranslationDeepObject = { }, addApprover: { subtitle: 'Kies een extra goedkeurder voor dit rapport voordat we het via de rest van de goedkeuringsworkflow sturen.', + bulkSubtitle: 'Kies een extra goedkeurder voor deze rapporten voordat we ze door de rest van de goedkeuringsworkflow sturen.', }, + bulkSubtitle: 'Kies een optie om de goedkeurder voor deze rapporten te wijzigen.', }, chooseWorkspace: 'Kies een werkruimte', }, @@ -2145,6 +2147,7 @@ ${amount} voor ${merchant} - ${date}`, title: 'Geen leden om weer te geven', expensesFromSubtitle: 'Alle werkruimteleden maken al deel uit van een bestaand goedkeuringsproces.', approverSubtitle: 'Alle goedkeurders behoren tot een bestaand werkstroom.', + bulkApproverSubtitle: 'Geen goedkeurders voldoen aan de criteria voor de geselecteerde rapporten.', }, }, workflowsDelayedSubmissionPage: { diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 6ee7de5d3db6..4a9c62a4b3ea 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -1517,7 +1517,9 @@ const translations: TranslationDeepObject = { }, addApprover: { subtitle: 'Wybierz dodatkowego zatwierdzającego dla tego raportu, zanim poprowadzimy go przez resztę przepływu pracy zatwierdzania.', + bulkSubtitle: 'Wybierz dodatkową osobę zatwierdzającą dla tych raportów, zanim prześlemy je dalej w ramach pozostałej części procesu zatwierdzania.', }, + bulkSubtitle: 'Wybierz opcję, aby zmienić osobę zatwierdzającą dla tych raportów.', }, chooseWorkspace: 'Wybierz przestrzeń roboczą', }, @@ -2142,6 +2144,7 @@ ${amount} dla ${merchant} - ${date}`, title: 'Brak członków do wyświetlenia', expensesFromSubtitle: 'Wszyscy członkowie przestrzeni roboczej już należą do istniejącego procesu zatwierdzania.', approverSubtitle: 'Wszyscy zatwierdzający należą do istniejącego przepływu pracy.', + bulkApproverSubtitle: 'Żadni zatwierdzający nie spełniają kryteriów dla wybranych raportów.', }, }, workflowsDelayedSubmissionPage: { diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 5ebbabf2e8d1..5bc827b49316 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -1515,7 +1515,9 @@ const translations: TranslationDeepObject = { }, addApprover: { subtitle: 'Escolha um aprovador adicional para este relatório antes de o encaminharmos através do restante do fluxo de trabalho de aprovação.', + bulkSubtitle: 'Escolha um aprovador adicional para esses relatórios antes de encaminharmos esses relatórios pelo restante do fluxo de aprovação.', }, + bulkSubtitle: 'Escolha uma opção para alterar o aprovador desses relatórios.', }, chooseWorkspace: 'Escolha um espaço de trabalho', }, @@ -2141,6 +2143,7 @@ ${amount} para ${merchant} - ${date}`, title: 'Nenhum membro para exibir', expensesFromSubtitle: 'Todos os membros do espaço de trabalho já pertencem a um fluxo de aprovação existente.', approverSubtitle: 'Todos os aprovadores pertencem a um fluxo de trabalho existente.', + bulkApproverSubtitle: 'Nenhum aprovador corresponde aos critérios dos relatórios selecionados.', }, }, workflowsDelayedSubmissionPage: { diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 451b6b444bb9..b41b8bb20266 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -1493,9 +1493,8 @@ const translations: TranslationDeepObject = { bypassApprovers: '跳过审批人', bypassApproversSubtitle: '将自己指定为最终审批人并跳过任何剩余的审批人。', }, - addApprover: { - subtitle: '在我们将此报告路由到其余审批工作流之前,为此报告选择一个额外的审批人。', - }, + addApprover: {subtitle: '在我们将此报告路由到其余审批工作流之前,为此报告选择一个额外的审批人。', bulkSubtitle: '在我们将这些报告通过其余审批流程之前,选择一位额外的审批人。'}, + bulkSubtitle: '选择一个选项来更改这些报告的审批人。', }, chooseWorkspace: '选择一个工作区', }, @@ -2112,6 +2111,7 @@ ${merchant}的${amount} - ${date}`, title: '没有成员可显示', expensesFromSubtitle: '所有工作区成员已属于现有的审批工作流程。', approverSubtitle: '所有审批者都属于现有的工作流程。', + bulkApproverSubtitle: '没有审批人符合所选报告的条件。', }, }, workflowsDelayedSubmissionPage: { From 011450f204c17c723040820517c65ab3aebcb244 Mon Sep 17 00:00:00 2001 From: Qichen Zhu <57348009+QichenZhu@users.noreply.github.com> Date: Sat, 29 Nov 2025 04:35:21 +1300 Subject: [PATCH 06/21] Show loading indicator --- src/components/Search/SearchContext.tsx | 38 ++++++-- src/components/Search/types.ts | 2 + src/pages/Search/SearchAddApproverPage.tsx | 32 ++++--- src/pages/Search/SearchChangeApproverPage.tsx | 89 +++++++++++++++---- 4 files changed, 122 insertions(+), 39 deletions(-) diff --git a/src/components/Search/SearchContext.tsx b/src/components/Search/SearchContext.tsx index e96fbda2b6da..08f0f4cdbb3f 100644 --- a/src/components/Search/SearchContext.tsx +++ b/src/components/Search/SearchContext.tsx @@ -93,15 +93,33 @@ function SearchContextProvider({children}: ChildrenProps) { if (data.length && data.every(isTransactionReportGroupListItemType)) { selectedReports = data .filter((item) => isMoneyRequestReport(item) && item.transactions.length > 0 && item.transactions.every(({keyForList}) => selectedTransactions[keyForList]?.isSelected)) - .map(({reportID, action = CONST.SEARCH.ACTION_TYPES.VIEW, total = CONST.DEFAULT_NUMBER_ID, policyID, allActions = [action], currency, chatReportID}) => ({ - reportID, - action, - total, - policyID, - allActions, - currency, - chatReportID, - })); + .map( + ({ + reportID, + action = CONST.SEARCH.ACTION_TYPES.VIEW, + total = CONST.DEFAULT_NUMBER_ID, + policyID, + allActions = [action], + currency, + chatReportID, + managerID, + ownerAccountID, + parentReportActionID, + parentReportID, + }) => ({ + reportID, + action, + total, + policyID, + allActions, + currency, + chatReportID, + managerID, + ownerAccountID, + parentReportActionID, + parentReportID, + }), + ); } else if (data.length && data.every(isTransactionListItemType)) { selectedReports = data .filter(({keyForList}) => !!keyForList && selectedTransactions[keyForList]?.isSelected) @@ -113,6 +131,8 @@ function SearchContextProvider({children}: ChildrenProps) { allActions, currency, chatReportID: report?.chatReportID, + managerID: report?.managerID, + ownerAccountID: report?.ownerAccountID, parentReportActionID: report?.parentReportActionID, parentReportID: report?.parentReportID, type: report?.type, diff --git a/src/components/Search/types.ts b/src/components/Search/types.ts index 8a1adf055521..a4d8c728aa84 100644 --- a/src/components/Search/types.ts +++ b/src/components/Search/types.ts @@ -66,6 +66,8 @@ type SelectedReports = { total: number; currency?: string; chatReportID: string | undefined; + managerID?: number; + ownerAccountID?: number; parentReportActionID?: string; parentReportID?: string; type?: string; diff --git a/src/pages/Search/SearchAddApproverPage.tsx b/src/pages/Search/SearchAddApproverPage.tsx index bba355126ca1..ea58e698999d 100644 --- a/src/pages/Search/SearchAddApproverPage.tsx +++ b/src/pages/Search/SearchAddApproverPage.tsx @@ -1,9 +1,10 @@ import lodashIntersection from 'lodash/intersection'; import lodashPick from 'lodash/pick'; -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import ApproverSelectionList from '@components/ApproverSelectionList'; import Badge from '@components/Badge'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import {useSearchContext} from '@components/Search/SearchContext'; import Text from '@components/Text'; import type {SelectionListApprover} from '@components/WorkspaceMembersSelectionList'; @@ -31,7 +32,8 @@ function SearchAddApproverPage() { const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: true}); - const {selectedReports} = useSearchContext(); + const {clearSelectedTransactions, selectedReports} = useSearchContext(); + const isSavingRef = useRef(false); const currentUserDetails = useCurrentUserPersonalDetails(); @@ -104,7 +106,7 @@ function SearchAddApproverPage() { }; }) .filter((approver): approver is SelectionListApprover => !!approver); - }, [selectedReports, allPolicies, allReports, personalDetails, selectedApproverEmail, translate]); + }, [allPolicies, allReports, icons.FallbackAvatar, personalDetails, selectedApproverEmail, selectedReports, translate]); const addApprover = useCallback(() => { const employeeAccountID = allApprovers.find((approver) => approver.login === selectedApproverEmail)?.value; @@ -112,6 +114,7 @@ function SearchAddApproverPage() { return; } + isSavingRef.current = true; for (const selectedReport of selectedReports) { const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedReport.policyID}`]; const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${selectedReport.reportID}`]; @@ -133,17 +136,18 @@ function SearchAddApproverPage() { ); } - Navigation.closeRHPFlow(); + clearSelectedTransactions(); }, [ allApprovers, - selectedApproverEmail, - selectedReports, allPolicies, allReports, - transactionViolations, + clearSelectedTransactions, currentUserDetails.accountID, currentUserDetails.email, isASAPSubmitBetaEnabled, + selectedApproverEmail, + selectedReports, + transactionViolations, ]); const button = useMemo(() => { @@ -168,8 +172,14 @@ function SearchAddApproverPage() { return; } - Navigation.closeRHPFlow(); - }, [selectedReports]); + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.closeRHPFlow(); + }); + }, [selectedReports.length]); + + if (isSavingRef.current) { + return ; + } return ( - {selectedReports.length === 1 ? translate('iou.changeApprover.addApprover.subtitle') : translate('iou.changeApprover.addApprover.bulkSubtitle')} + {translate(selectedReports.length === 1 ? 'iou.changeApprover.addApprover.subtitle' : 'iou.changeApprover.addApprover.bulkSubtitle')} } isLoadingReportData={false} @@ -187,7 +197,7 @@ function SearchAddApproverPage() { shouldShowNotFoundViewLink={false} shouldShowNotFoundView={false} allApprovers={allApprovers} - listEmptyContentSubtitle={translate('workflowsPage.emptyContent.bulkApproverSubtitle')} + listEmptyContentSubtitle={translate(selectedReports.length === 1 ? 'workflowsPage.emptyContent.approverSubtitle' : 'workflowsPage.emptyContent.bulkApproverSubtitle')} allowMultipleSelection={false} onSelectApprover={toggleApprover} footerContent={button} diff --git a/src/pages/Search/SearchChangeApproverPage.tsx b/src/pages/Search/SearchChangeApproverPage.tsx index 2701dce1e378..ccb8042f9108 100644 --- a/src/pages/Search/SearchChangeApproverPage.tsx +++ b/src/pages/Search/SearchChangeApproverPage.tsx @@ -1,6 +1,8 @@ -import React, {useCallback, useEffect, useMemo, useState} from 'react'; +import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; +import type {OnyxCollection} from 'react-native-onyx'; import FormHelpMessage from '@components/FormHelpMessage'; +import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; import RenderHTML from '@components/RenderHTML'; import ScreenWrapper from '@components/ScreenWrapper'; @@ -16,6 +18,7 @@ import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; import {assignReportToMe} from '@libs/actions/IOU'; +import {openReport} from '@libs/actions/Report'; import Navigation from '@libs/Navigation/Navigation'; import {isControlPolicy} from '@libs/PolicyUtils'; import {hasViolations as hasViolationsReportUtils, isAllowedToApproveExpenseReport} from '@libs/ReportUtils'; @@ -24,7 +27,7 @@ import type {ApproverType} from '@pages/ReportChangeApproverPage'; import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; -import type {Policy} from '@src/types/onyx'; +import type {Policy, Report} from '@src/types/onyx'; function SearchChangeApproverPage() { const {translate} = useLocalize(); @@ -37,8 +40,50 @@ function SearchChangeApproverPage() { const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); - const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: true}); - const {selectedReports} = useSearchContext(); + const {clearSelectedTransactions, selectedReports} = useSearchContext(); + const [hasLoadedApp] = useOnyx(ONYXKEYS.HAS_LOADED_APP, {canBeMissing: true}); + const isSavingRef = useRef(false); + + const getOnyxReports = useCallback( + (allReports: OnyxCollection) => { + const reports = new Map(); + for (const selectedReport of selectedReports) { + const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${selectedReport.reportID}`]; + if (!report?.reportID) { + continue; + } + reports.set(selectedReport.reportID, report); + } + return reports; + }, + [selectedReports], + ); + + const [onyxReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {selector: getOnyxReports, canBeMissing: true}); + const isLoadingOnyxReports = useRef(false); + + useEffect(() => { + if (!hasLoadedApp || !selectedReports.length) { + return; + } + + if (onyxReports?.size === selectedReports.length) { + isLoadingOnyxReports.current = false; + return; + } + + if (isLoadingOnyxReports.current) { + return; + } + + isLoadingOnyxReports.current = true; + for (const selectedReport of selectedReports) { + if (onyxReports?.has(selectedReport.reportID)) { + continue; + } + openReport(selectedReport.reportID); + } + }, [hasLoadedApp, onyxReports, selectedReports]); const selectedPolicies = useMemo(() => { const policies = new Map(); @@ -75,11 +120,11 @@ function SearchChangeApproverPage() { return; } + isSavingRef.current = true; for (const selectedReport of selectedReports) { const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedReport.policyID}`]; - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${selectedReport.reportID}`]; - - if (!report || !policy) { + const report = onyxReports?.get(selectedReport.reportID); + if (!policy || !report) { continue; } @@ -89,17 +134,18 @@ function SearchChangeApproverPage() { } } - Navigation.closeRHPFlow(); + clearSelectedTransactions(); }, [ - selectedApproverType, - selectedPolicies, - selectedReports, allPolicies, - allReports, + clearSelectedTransactions, currentUserDetails.accountID, currentUserDetails.email, - transactionViolations, isASAPSubmitBetaEnabled, + onyxReports, + selectedApproverType, + selectedPolicies, + selectedReports, + transactionViolations, ]); const sections = useMemo(() => { @@ -114,9 +160,8 @@ function SearchChangeApproverPage() { const shouldShowBypassApproversOption = selectedReports.some((selectedReport) => { const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedReport.policyID}`]; - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${selectedReport.reportID}`]; - - if (!report || !policy) { + const report = onyxReports?.get(selectedReport.reportID); + if (!policy || !report) { return false; } @@ -134,15 +179,21 @@ function SearchChangeApproverPage() { } return [{data}]; - }, [selectedReports, allPolicies, allReports, currentUserDetails.accountID, selectedApproverType, translate]); + }, [allPolicies, currentUserDetails.accountID, onyxReports, selectedApproverType, selectedReports, translate]); useEffect(() => { if (selectedReports.length && sections.at(0)?.data.length) { return; } - Navigation.closeRHPFlow(); - }, [selectedReports, sections]); + Navigation.setNavigationActionToMicrotaskQueue(() => { + Navigation.closeRHPFlow(); + }); + }, [sections, selectedReports.length]); + + if (onyxReports?.size !== selectedReports.length || isSavingRef.current) { + return ; + } return ( Date: Sat, 29 Nov 2025 04:52:39 +1300 Subject: [PATCH 07/21] Fix prettier --- src/pages/Search/SearchPage.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 593c97466aa4..63a93d124d07 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -534,7 +534,7 @@ function SearchPage({route}: SearchPageProps) { } Navigation.navigate(ROUTES.CHANGE_APPROVER_SEARCH_RHP); - }, + }, }); } From 9b266cafdc03e88bd8d059b3d63cd09dbf75ac08 Mon Sep 17 00:00:00 2001 From: Qichen Zhu <57348009+QichenZhu@users.noreply.github.com> Date: Sat, 29 Nov 2025 06:54:43 +1300 Subject: [PATCH 08/21] Add unit tests --- tests/unit/SearchContextTest.tsx | 156 +++++++++++++++++++++++++++++++ 1 file changed, 156 insertions(+) create mode 100644 tests/unit/SearchContextTest.tsx diff --git a/tests/unit/SearchContextTest.tsx b/tests/unit/SearchContextTest.tsx new file mode 100644 index 000000000000..43ceee375c39 --- /dev/null +++ b/tests/unit/SearchContextTest.tsx @@ -0,0 +1,156 @@ +import {renderHook} from '@testing-library/react-native'; +import React, {act} from 'react'; +import {SearchContextProvider, useSearchContext} from '@components/Search/SearchContext'; +import type {SelectedTransactionInfo} from '@components/Search/types'; +import type {TransactionListItemType, TransactionReportGroupListItemType} from '@components/SelectionListWithSections/types'; + +const mockSelectedTransaction: SelectedTransactionInfo = { + action: 'approve', + canHold: true, + isHeld: false, + canUnhold: false, + canChangeReport: true, + isSelected: true, + canDelete: true, + policyID: '06F34677820A4D07', + reportID: '515146912679679', + amount: 0, + convertedAmount: 1284, + convertedCurrency: 'USD', + currency: 'USD', + ownerAccountID: 1, +} as const; + +const mockTransaction: TransactionListItemType = { + accountID: 1, + amount: 0, + canDelete: true, + category: '', + convertedAmount: 1284, + convertedCurrency: 'USD', + created: '2025-09-19', + currency: 'USD', + policy: { + id: '06F34677820A4D07', + type: 'team', + role: 'admin', + owner: 'test@test.com', + name: 'Policy', + outputCurrency: 'USD', + isPolicyExpenseChatEnabled: true, + }, + reportAction: { + reportActionID: '2454187434077044186', + actionName: 'IOU', + created: '2025-09-19', + }, + holdReportAction: undefined, + merchant: '(none)', + modifiedAmount: -1284, + modifiedCreated: '2025-09-07', + modifiedCurrency: 'USD', + modifiedMerchant: 'The Home Depot', + policyID: '06F34677820A4D07', + reportID: '515146912679679', + tag: '', + transactionID: '1', + transactionThreadReportID: '2925191332104975', + action: 'approve', + allActions: ['approve'], + formattedFrom: 'Main Applause QA', + formattedTo: 'Main Applause QA', + formattedTotal: -1284, + formattedMerchant: 'The Home Depot', + date: '2025-09-07', + shouldShowMerchant: true, + shouldShowYear: true, + keyForList: '1', + isAmountColumnWide: false, + isTaxAmountColumnWide: false, + shouldAnimateInHighlight: false, + report: { + reportID: '515146912679679', + }, + from: { + accountID: 1, + avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_15.png', + displayName: 'Main Applause QA', + }, + to: { + accountID: 1, + avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_15.png', + displayName: 'Main Applause QA', + }, +}; + +const mockReport: TransactionReportGroupListItemType = { + accountID: 1, + chatReportID: '4735435600700077', + chatType: undefined, + created: '2025-09-19 20:00:47', + currency: 'USD', + isOneTransactionReport: true, + isOwnPolicyExpenseChat: false, + isWaitingOnBankAccount: false, + managerID: 1, + nonReimbursableTotal: 0, + oldPolicyName: '', + ownerAccountID: 1, + parentReportActionID: '2454187434077044186', + parentReportID: '4735435600700077', + policyID: '06F34677820A4D07', + reportID: '515146912679679', + reportName: 'Expense Report #515146912679679', + stateNum: 1, + statusNum: 1, + total: -1284, + type: 'expense', + unheldTotal: -1284, + from: { + accountID: 1, + avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_15.png', + displayName: 'Main Applause QA', + }, + to: { + accountID: 1, + avatar: 'https://d2k5nsl2zxldvw.cloudfront.net/images/avatars/default-avatar_15.png', + displayName: 'Main Applause QA', + }, + shouldShowYear: false, + action: 'view', + transactions: [mockTransaction], + groupedBy: 'expense-report', + keyForList: '515146912679679', +}; + +mockTransaction.report = mockReport; + +const wrapper = ({children}: {children: React.ReactNode}) => {children}; + +describe('SearchContext', () => { + it('returns selectedReports for TransactionReportGroupListItem', () => { + const {result} = renderHook(() => useSearchContext(), {wrapper}); + act(() => { + result.current.setSelectedTransactions({[mockTransaction.keyForList]: mockSelectedTransaction}, [mockReport]); + }); + const selectedReport = result.current.selectedReports.at(0); + + expect(selectedReport?.managerID).toEqual(1); + expect(selectedReport?.ownerAccountID).toBe(1); + expect(selectedReport?.parentReportActionID).toBe('2454187434077044186'); + expect(selectedReport?.parentReportID).toBe('4735435600700077'); + }); + + it('returns selectedReports for TransactionListItemType', () => { + const {result} = renderHook(() => useSearchContext(), {wrapper}); + act(() => { + result.current.setSelectedTransactions({[mockTransaction.keyForList]: mockSelectedTransaction}, [mockTransaction]); + }); + const selectedReport = result.current.selectedReports.at(0); + + expect(selectedReport?.managerID).toEqual(1); + expect(selectedReport?.ownerAccountID).toBe(1); + expect(selectedReport?.parentReportActionID).toBe('2454187434077044186'); + expect(selectedReport?.parentReportID).toBe('4735435600700077'); + }); +}); From f023fda189102ee4f106bd48ce1846789132ee27 Mon Sep 17 00:00:00 2001 From: Qichen Zhu <57348009+QichenZhu@users.noreply.github.com> Date: Sat, 29 Nov 2025 07:50:03 +1300 Subject: [PATCH 09/21] Move test file --- tests/unit/{ => Search}/SearchContextTest.tsx | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename tests/unit/{ => Search}/SearchContextTest.tsx (100%) diff --git a/tests/unit/SearchContextTest.tsx b/tests/unit/Search/SearchContextTest.tsx similarity index 100% rename from tests/unit/SearchContextTest.tsx rename to tests/unit/Search/SearchContextTest.tsx From 469afa16ea9f89530add7e096e3365206fd40d22 Mon Sep 17 00:00:00 2001 From: Qichen Zhu <57348009+QichenZhu@users.noreply.github.com> Date: Wed, 3 Dec 2025 02:54:15 +1300 Subject: [PATCH 10/21] Fix types --- src/pages/Search/SearchAddApproverPage.tsx | 8 ++++---- src/pages/Search/SearchChangeApproverPage.tsx | 2 +- tests/unit/Search/SearchContextTest.tsx | 8 ++++---- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/pages/Search/SearchAddApproverPage.tsx b/src/pages/Search/SearchAddApproverPage.tsx index ea58e698999d..78a9d5315a91 100644 --- a/src/pages/Search/SearchAddApproverPage.tsx +++ b/src/pages/Search/SearchAddApproverPage.tsx @@ -23,7 +23,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; function SearchAddApproverPage() { const styles = useThemeStyles(); - const {translate} = useLocalize(); + const {translate, formatPhoneNumber} = useLocalize(); const icons = useMemoizedLazyExpensifyIcons(['FallbackAvatar'] as const); const [selectedApproverEmail, setSelectedApproverEmail] = useState(undefined); const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {canBeMissing: false}); @@ -93,7 +93,7 @@ function SearchAddApproverPage() { } const {avatar} = personalDetails?.[accountID] ?? {}; - const displayName = getDisplayNameForParticipant({accountID, personalDetailsData: personalDetails}); + const displayName = getDisplayNameForParticipant({accountID, formatPhoneNumber, personalDetailsData: personalDetails}); return { text: displayName, alternateText: email, @@ -106,7 +106,7 @@ function SearchAddApproverPage() { }; }) .filter((approver): approver is SelectionListApprover => !!approver); - }, [allPolicies, allReports, icons.FallbackAvatar, personalDetails, selectedApproverEmail, selectedReports, translate]); + }, [allPolicies, allReports, formatPhoneNumber, icons.FallbackAvatar, personalDetails, selectedApproverEmail, selectedReports, translate]); const addApprover = useCallback(() => { const employeeAccountID = allApprovers.find((approver) => approver.login === selectedApproverEmail)?.value; @@ -123,7 +123,7 @@ function SearchAddApproverPage() { continue; } - const hasViolations = hasViolationsReportUtils(report.reportID, transactionViolations); + const hasViolations = hasViolationsReportUtils(report.reportID, transactionViolations, currentUserDetails.accountID, currentUserDetails.email ?? ''); addReportApprover( report, selectedApproverEmail, diff --git a/src/pages/Search/SearchChangeApproverPage.tsx b/src/pages/Search/SearchChangeApproverPage.tsx index ccb8042f9108..794be757efb1 100644 --- a/src/pages/Search/SearchChangeApproverPage.tsx +++ b/src/pages/Search/SearchChangeApproverPage.tsx @@ -129,7 +129,7 @@ function SearchChangeApproverPage() { } if (report.managerID !== currentUserDetails.accountID) { - const hasViolations = hasViolationsReportUtils(report.reportID, transactionViolations); + const hasViolations = hasViolationsReportUtils(report.reportID, transactionViolations, currentUserDetails.accountID, currentUserDetails.email ?? ''); assignReportToMe(report, currentUserDetails.accountID, currentUserDetails.email ?? '', policy, hasViolations, isASAPSubmitBetaEnabled); } } diff --git a/tests/unit/Search/SearchContextTest.tsx b/tests/unit/Search/SearchContextTest.tsx index 43ceee375c39..378ce7eda063 100644 --- a/tests/unit/Search/SearchContextTest.tsx +++ b/tests/unit/Search/SearchContextTest.tsx @@ -15,8 +15,8 @@ const mockSelectedTransaction: SelectedTransactionInfo = { policyID: '06F34677820A4D07', reportID: '515146912679679', amount: 0, - convertedAmount: 1284, - convertedCurrency: 'USD', + groupAmount: 1284, + groupCurrency: 'USD', currency: 'USD', ownerAccountID: 1, } as const; @@ -26,8 +26,8 @@ const mockTransaction: TransactionListItemType = { amount: 0, canDelete: true, category: '', - convertedAmount: 1284, - convertedCurrency: 'USD', + groupAmount: 1284, + groupCurrency: 'USD', created: '2025-09-19', currency: 'USD', policy: { From d52a3173eed72be5bb9d7de414952c961d7d9899 Mon Sep 17 00:00:00 2001 From: Qichen Zhu <57348009+QichenZhu@users.noreply.github.com> Date: Thu, 4 Dec 2025 04:15:50 +1300 Subject: [PATCH 11/21] Fix tests --- tests/unit/Search/SearchContextTest.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/Search/SearchContextTest.tsx b/tests/unit/Search/SearchContextTest.tsx index 378ce7eda063..261ce7dbdbaa 100644 --- a/tests/unit/Search/SearchContextTest.tsx +++ b/tests/unit/Search/SearchContextTest.tsx @@ -54,7 +54,6 @@ const mockTransaction: TransactionListItemType = { reportID: '515146912679679', tag: '', transactionID: '1', - transactionThreadReportID: '2925191332104975', action: 'approve', allActions: ['approve'], formattedFrom: 'Main Applause QA', From d0731650b545c2b4e9ccea55a9872849073e26f7 Mon Sep 17 00:00:00 2001 From: Qichen Zhu <57348009+QichenZhu@users.noreply.github.com> Date: Fri, 5 Dec 2025 07:00:09 +1300 Subject: [PATCH 12/21] Address review comment --- src/pages/Search/SearchAddApproverPage.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/pages/Search/SearchAddApproverPage.tsx b/src/pages/Search/SearchAddApproverPage.tsx index 78a9d5315a91..6b154c161e82 100644 --- a/src/pages/Search/SearchAddApproverPage.tsx +++ b/src/pages/Search/SearchAddApproverPage.tsx @@ -85,6 +85,10 @@ function SearchAddApproverPage() { return false; } + if (report.managerID === accountID) { + return true; + } + return isAllowedToApproveExpenseReport(report, accountID, policy); }); From 7c0b6d09fbbb55f4828e419d3ef19a60d0042687 Mon Sep 17 00:00:00 2001 From: Qichen Zhu <57348009+QichenZhu@users.noreply.github.com> Date: Wed, 10 Dec 2025 03:53:52 +1300 Subject: [PATCH 13/21] Fix types --- src/pages/Search/SearchAddApproverPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/Search/SearchAddApproverPage.tsx b/src/pages/Search/SearchAddApproverPage.tsx index 6b154c161e82..a270e8475c5f 100644 --- a/src/pages/Search/SearchAddApproverPage.tsx +++ b/src/pages/Search/SearchAddApproverPage.tsx @@ -2,12 +2,12 @@ import lodashIntersection from 'lodash/intersection'; import lodashPick from 'lodash/pick'; import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import ApproverSelectionList from '@components/ApproverSelectionList'; +import type {SelectionListApprover} from '@components/ApproverSelectionList'; import Badge from '@components/Badge'; import FormAlertWithSubmitButton from '@components/FormAlertWithSubmitButton'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import {useSearchContext} from '@components/Search/SearchContext'; import Text from '@components/Text'; -import type {SelectionListApprover} from '@components/WorkspaceMembersSelectionList'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import {useMemoizedLazyExpensifyIcons} from '@hooks/useLazyAsset'; import useLocalize from '@hooks/useLocalize'; @@ -168,7 +168,7 @@ function SearchAddApproverPage() { }, [addApprover, selectedApproverEmail, styles.flexBasisAuto, styles.flexGrow0, styles.flexReset, styles.flexShrink0, translate]); const toggleApprover = useCallback((approvers: SelectionListApprover[]) => { - setSelectedApproverEmail(approvers.length ? approvers.at(0)?.login : undefined); + setSelectedApproverEmail(approvers.at(0)?.login ?? undefined); }, []); useEffect(() => { From eae3256df877b348864954acd52a414592adced1 Mon Sep 17 00:00:00 2001 From: Qichen Zhu <57348009+QichenZhu@users.noreply.github.com> Date: Wed, 10 Dec 2025 04:19:37 +1300 Subject: [PATCH 14/21] Fix types --- src/pages/Search/SearchAddApproverPage.tsx | 4 ++++ src/pages/Search/SearchChangeApproverPage.tsx | 5 ++++- tests/unit/Search/SearchContextTest.tsx | 1 - 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/pages/Search/SearchAddApproverPage.tsx b/src/pages/Search/SearchAddApproverPage.tsx index a270e8475c5f..87d8ee3e7860 100644 --- a/src/pages/Search/SearchAddApproverPage.tsx +++ b/src/pages/Search/SearchAddApproverPage.tsx @@ -32,6 +32,7 @@ function SearchAddApproverPage() { const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); const [allReports] = useOnyx(ONYXKEYS.COLLECTION.REPORT, {canBeMissing: true}); + const [allReportNextSteps] = useOnyx(ONYXKEYS.COLLECTION.NEXT_STEP, {canBeMissing: true}); const {clearSelectedTransactions, selectedReports} = useSearchContext(); const isSavingRef = useRef(false); @@ -128,6 +129,7 @@ function SearchAddApproverPage() { } const hasViolations = hasViolationsReportUtils(report.reportID, transactionViolations, currentUserDetails.accountID, currentUserDetails.email ?? ''); + const reportNextStep = allReportNextSteps?.[`${ONYXKEYS.COLLECTION.NEXT_STEP}${selectedReport.reportID}`]; addReportApprover( report, selectedApproverEmail, @@ -137,6 +139,7 @@ function SearchAddApproverPage() { policy, hasViolations, isASAPSubmitBetaEnabled, + reportNextStep, ); } @@ -145,6 +148,7 @@ function SearchAddApproverPage() { allApprovers, allPolicies, allReports, + allReportNextSteps, clearSelectedTransactions, currentUserDetails.accountID, currentUserDetails.email, diff --git a/src/pages/Search/SearchChangeApproverPage.tsx b/src/pages/Search/SearchChangeApproverPage.tsx index 794be757efb1..cb1d6df25936 100644 --- a/src/pages/Search/SearchChangeApproverPage.tsx +++ b/src/pages/Search/SearchChangeApproverPage.tsx @@ -40,6 +40,7 @@ function SearchChangeApproverPage() { const [transactionViolations] = useOnyx(ONYXKEYS.COLLECTION.TRANSACTION_VIOLATIONS, {canBeMissing: true}); const isASAPSubmitBetaEnabled = isBetaEnabled(CONST.BETAS.ASAP_SUBMIT); const [allPolicies] = useOnyx(ONYXKEYS.COLLECTION.POLICY, {canBeMissing: true}); + const [allReportNextSteps] = useOnyx(ONYXKEYS.COLLECTION.NEXT_STEP, {canBeMissing: true}); const {clearSelectedTransactions, selectedReports} = useSearchContext(); const [hasLoadedApp] = useOnyx(ONYXKEYS.HAS_LOADED_APP, {canBeMissing: true}); const isSavingRef = useRef(false); @@ -130,13 +131,15 @@ function SearchChangeApproverPage() { if (report.managerID !== currentUserDetails.accountID) { const hasViolations = hasViolationsReportUtils(report.reportID, transactionViolations, currentUserDetails.accountID, currentUserDetails.email ?? ''); - assignReportToMe(report, currentUserDetails.accountID, currentUserDetails.email ?? '', policy, hasViolations, isASAPSubmitBetaEnabled); + const reportNextStep = allReportNextSteps?.[`${ONYXKEYS.COLLECTION.NEXT_STEP}${selectedReport.reportID}`]; + assignReportToMe(report, currentUserDetails.accountID, currentUserDetails.email ?? '', policy, hasViolations, isASAPSubmitBetaEnabled, reportNextStep); } } clearSelectedTransactions(); }, [ allPolicies, + allReportNextSteps, clearSelectedTransactions, currentUserDetails.accountID, currentUserDetails.email, diff --git a/tests/unit/Search/SearchContextTest.tsx b/tests/unit/Search/SearchContextTest.tsx index 261ce7dbdbaa..4ca4eb66af95 100644 --- a/tests/unit/Search/SearchContextTest.tsx +++ b/tests/unit/Search/SearchContextTest.tsx @@ -24,7 +24,6 @@ const mockSelectedTransaction: SelectedTransactionInfo = { const mockTransaction: TransactionListItemType = { accountID: 1, amount: 0, - canDelete: true, category: '', groupAmount: 1284, groupCurrency: 'USD', From 74bbd714f27dbfe354b1b8afc61bdc6219218016 Mon Sep 17 00:00:00 2001 From: Qichen Zhu <57348009+QichenZhu@users.noreply.github.com> Date: Wed, 10 Dec 2025 04:55:32 +1300 Subject: [PATCH 15/21] Show the option on Reports page only --- src/pages/Search/SearchPage.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 5f34867c7740..90790544f7a1 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -483,6 +483,7 @@ function SearchPage({route}: SearchPageProps) { const shouldShowChangeApproverOption = !isOffline && + queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && !isAnyTransactionOnHold && areSelectedTransactionsIncludedInReports && selectedReports.length && From 3a98e7ac438b1558b970ffaf631a7aa2ee7edbd1 Mon Sep 17 00:00:00 2001 From: Qichen Zhu <57348009+QichenZhu@users.noreply.github.com> Date: Wed, 10 Dec 2025 05:04:23 +1300 Subject: [PATCH 16/21] Add comments --- src/pages/Search/SearchAddApproverPage.tsx | 2 ++ src/pages/Search/SearchChangeApproverPage.tsx | 1 + 2 files changed, 3 insertions(+) diff --git a/src/pages/Search/SearchAddApproverPage.tsx b/src/pages/Search/SearchAddApproverPage.tsx index 87d8ee3e7860..696fdc8c05da 100644 --- a/src/pages/Search/SearchAddApproverPage.tsx +++ b/src/pages/Search/SearchAddApproverPage.tsx @@ -50,6 +50,7 @@ function SearchAddApproverPage() { const firstWorkspaceEmployees = employeeLists.at(0); const intersectedEmployees = firstWorkspaceEmployees ? lodashPick(firstWorkspaceEmployees, lodashIntersection(...employeeLists.map(Object.keys))) : {}; const policyMemberEmailsToAccountIDs = getMemberAccountIDsForWorkspace(intersectedEmployees, true, false); + // We get the intersection here as we only want to show members who belong to all workspaces when adding an additional approver return Object.values(intersectedEmployees) .map((employee): SelectionListApprover | null => { const isAdmin = employee?.role === CONST.REPORT.ROLE.ADMIN; @@ -143,6 +144,7 @@ function SearchAddApproverPage() { ); } + // This actually clears selected reports as well clearSelectedTransactions(); }, [ allApprovers, diff --git a/src/pages/Search/SearchChangeApproverPage.tsx b/src/pages/Search/SearchChangeApproverPage.tsx index cb1d6df25936..aa9a9048ab66 100644 --- a/src/pages/Search/SearchChangeApproverPage.tsx +++ b/src/pages/Search/SearchChangeApproverPage.tsx @@ -136,6 +136,7 @@ function SearchChangeApproverPage() { } } + // This actually clears selected reports as well clearSelectedTransactions(); }, [ allPolicies, From 20537bc14e5374d7f7ea63489c29acbaf6db3c5e Mon Sep 17 00:00:00 2001 From: Qichen Zhu <57348009+QichenZhu@users.noreply.github.com> Date: Wed, 10 Dec 2025 05:07:36 +1300 Subject: [PATCH 17/21] Check if current user is policy admin --- src/pages/Search/SearchChangeApproverPage.tsx | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/pages/Search/SearchChangeApproverPage.tsx b/src/pages/Search/SearchChangeApproverPage.tsx index aa9a9048ab66..3e551b89b20f 100644 --- a/src/pages/Search/SearchChangeApproverPage.tsx +++ b/src/pages/Search/SearchChangeApproverPage.tsx @@ -20,7 +20,7 @@ import useThemeStyles from '@hooks/useThemeStyles'; import {assignReportToMe} from '@libs/actions/IOU'; import {openReport} from '@libs/actions/Report'; import Navigation from '@libs/Navigation/Navigation'; -import {isControlPolicy} from '@libs/PolicyUtils'; +import {isControlPolicy, isPolicyAdmin} from '@libs/PolicyUtils'; import {hasViolations as hasViolationsReportUtils, isAllowedToApproveExpenseReport} from '@libs/ReportUtils'; import {APPROVER_TYPE} from '@pages/ReportChangeApproverPage'; import type {ApproverType} from '@pages/ReportChangeApproverPage'; @@ -162,17 +162,30 @@ function SearchChangeApproverPage() { }, ]; - const shouldShowBypassApproversOption = selectedReports.some((selectedReport) => { + const hasPermission = selectedReports.every((selectedReport) => { const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedReport.policyID}`]; - const report = onyxReports?.get(selectedReport.reportID); - if (!policy || !report) { + + if (!policy) { return false; } - const isCurrentUserManager = report.managerID === currentUserDetails.accountID; - return !isCurrentUserManager && isAllowedToApproveExpenseReport(report, currentUserDetails.accountID, policy); + return isPolicyAdmin(policy); }); + const shouldShowBypassApproversOption = + hasPermission && + selectedReports.some((selectedReport) => { + const policy = allPolicies?.[`${ONYXKEYS.COLLECTION.POLICY}${selectedReport.policyID}`]; + const report = onyxReports?.get(selectedReport.reportID); + + if (!policy || !report) { + return false; + } + + const isCurrentUserManager = report.managerID === currentUserDetails.accountID; + return !isCurrentUserManager && isAllowedToApproveExpenseReport(report, currentUserDetails.accountID, policy); + }); + if (shouldShowBypassApproversOption) { data.push({ text: translate('iou.changeApprover.actions.bypassApprovers'), From 2e8be434e8eab6ab10499b6a254902e25c3be2c5 Mon Sep 17 00:00:00 2001 From: Qichen Zhu <57348009+QichenZhu@users.noreply.github.com> Date: Wed, 10 Dec 2025 05:24:55 +1300 Subject: [PATCH 18/21] Add comments --- src/pages/Search/SearchChangeApproverPage.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/pages/Search/SearchChangeApproverPage.tsx b/src/pages/Search/SearchChangeApproverPage.tsx index 3e551b89b20f..00b18f2a007f 100644 --- a/src/pages/Search/SearchChangeApproverPage.tsx +++ b/src/pages/Search/SearchChangeApproverPage.tsx @@ -82,6 +82,9 @@ function SearchChangeApproverPage() { if (onyxReports?.has(selectedReport.reportID)) { continue; } + + // Load the report into Onyx, because data from SearchContext contains only a subset of report properties. + // Alternatively, remove this and make sure the backend returns all required properties and SearchContext keeps all of them. openReport(selectedReport.reportID); } }, [hasLoadedApp, onyxReports, selectedReports]); From fd838f929e00a153b7f4fce931da38b1f38fa141 Mon Sep 17 00:00:00 2001 From: Qichen Zhu <57348009+QichenZhu@users.noreply.github.com> Date: Wed, 10 Dec 2025 06:28:03 +1300 Subject: [PATCH 19/21] Show the option when offline --- src/pages/Search/SearchChangeApproverPage.tsx | 89 +++++++++++-------- src/pages/Search/SearchPage.tsx | 10 +-- 2 files changed, 51 insertions(+), 48 deletions(-) diff --git a/src/pages/Search/SearchChangeApproverPage.tsx b/src/pages/Search/SearchChangeApproverPage.tsx index 00b18f2a007f..8100a4312540 100644 --- a/src/pages/Search/SearchChangeApproverPage.tsx +++ b/src/pages/Search/SearchChangeApproverPage.tsx @@ -1,6 +1,7 @@ import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import {View} from 'react-native'; import type {OnyxCollection} from 'react-native-onyx'; +import FullPageOfflineBlockingView from '@components/BlockingViews/FullPageOfflineBlockingView'; import FormHelpMessage from '@components/FormHelpMessage'; import FullScreenLoadingIndicator from '@components/FullscreenLoadingIndicator'; import HeaderWithBackButton from '@components/HeaderWithBackButton'; @@ -14,6 +15,7 @@ import Text from '@components/Text'; import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails'; import useEnvironment from '@hooks/useEnvironment'; import useLocalize from '@hooks/useLocalize'; +import useNetwork from '@hooks/useNetwork'; import useOnyx from '@hooks/useOnyx'; import usePermissions from '@hooks/usePermissions'; import useThemeStyles from '@hooks/useThemeStyles'; @@ -43,6 +45,7 @@ function SearchChangeApproverPage() { const [allReportNextSteps] = useOnyx(ONYXKEYS.COLLECTION.NEXT_STEP, {canBeMissing: true}); const {clearSelectedTransactions, selectedReports} = useSearchContext(); const [hasLoadedApp] = useOnyx(ONYXKEYS.HAS_LOADED_APP, {canBeMissing: true}); + const {isOffline} = useNetwork(); const isSavingRef = useRef(false); const getOnyxReports = useCallback( @@ -211,8 +214,8 @@ function SearchChangeApproverPage() { }); }, [sections, selectedReports.length]); - if (onyxReports?.size !== selectedReports.length || isSavingRef.current) { - return ; + if ((!isOffline && onyxReports?.size !== selectedReports.length) || isSavingRef.current) { + return ; } return ( @@ -220,49 +223,57 @@ function SearchChangeApproverPage() { testID={SearchChangeApproverPage.displayName} includeSafeAreaPaddingBottom shouldEnableMaxHeight + // Show the non-blocking offline indicator if reports are available in Onyx, otherwise show the blocking offline view because this page requires the Onyx data + shouldShowOfflineIndicator={onyxReports?.size === selectedReports.length} > - { - if (!option.keyForList) { - return; + {onyxReports?.size !== selectedReports.length && !!isOffline ? ( + + + + ) : ( + { + if (!option.keyForList) { + return; + } + setSelectedApproverType(option.keyForList); + setHasError(false); + }} + showConfirmButton + confirmButtonText={translate('iou.changeApprover.title')} + onConfirm={changeApprover} + shouldUpdateFocusedIndex + customListHeader={ + <> + {translate(selectedReports.length === 1 ? 'iou.changeApprover.subtitle' : 'iou.changeApprover.bulkSubtitle')} + {selectedPolicies.length === 1 && ( + + + + )} + } - setSelectedApproverType(option.keyForList); - setHasError(false); - }} - showConfirmButton - confirmButtonText={translate('iou.changeApprover.title')} - onConfirm={changeApprover} - shouldUpdateFocusedIndex - customListHeader={ - <> - {translate(selectedReports.length === 1 ? 'iou.changeApprover.subtitle' : 'iou.changeApprover.bulkSubtitle')} - {selectedPolicies.length === 1 && ( - - - - )} - - } - > - {hasError && ( - - )} - + > + {hasError && ( + + )} + + )} ); } diff --git a/src/pages/Search/SearchPage.tsx b/src/pages/Search/SearchPage.tsx index 90790544f7a1..48a69985f822 100644 --- a/src/pages/Search/SearchPage.tsx +++ b/src/pages/Search/SearchPage.tsx @@ -482,7 +482,6 @@ function SearchPage({route}: SearchPageProps) { } const shouldShowChangeApproverOption = - !isOffline && queryJSON?.type === CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT && !isAnyTransactionOnHold && areSelectedTransactionsIncludedInReports && @@ -495,14 +494,7 @@ function SearchPage({route}: SearchPageProps) { text: translate('iou.changeApprover.title'), value: CONST.SEARCH.BULK_ACTION_TYPES.CHANGE_APPROVER, shouldCloseModalOnSelect: true, - onSelected: () => { - if (isOffline) { - setIsOfflineModalVisible(true); - return; - } - - Navigation.navigate(ROUTES.CHANGE_APPROVER_SEARCH_RHP); - }, + onSelected: () => Navigation.navigate(ROUTES.CHANGE_APPROVER_SEARCH_RHP), }); } From 3e137b6fcadc81492ebae86f8e516a7b2904505f Mon Sep 17 00:00:00 2001 From: Qichen Zhu <57348009+QichenZhu@users.noreply.github.com> Date: Wed, 10 Dec 2025 07:50:16 +1300 Subject: [PATCH 20/21] Show View button in action column istead of Change approver --- src/libs/SearchUIUtils.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/libs/SearchUIUtils.ts b/src/libs/SearchUIUtils.ts index 716a3a670687..91056352d4f6 100644 --- a/src/libs/SearchUIUtils.ts +++ b/src/libs/SearchUIUtils.ts @@ -1125,7 +1125,7 @@ function getTransactionsSections( const transactionSection: TransactionListItemType = { ...transactionItem, keyForList: transactionItem.transactionID, - action: allActions.at(0) ?? CONST.SEARCH.ACTION_TYPES.VIEW, + action: getAction(allActions), allActions, report, policy, @@ -1328,6 +1328,17 @@ function getActions( return allActions.length > 0 ? allActions : [CONST.SEARCH.ACTION_TYPES.VIEW]; } +/** + * @private + * Returns the main action that can be taken on a given transaction or report + * + * Do not use directly, use only via `getSections()` facade. + */ +function getAction(allActions: SearchTransactionAction[]) { + // VIEW should take precedence over CHANGE_APPROVER + return allActions.find((action) => action !== CONST.SEARCH.ACTION_TYPES.CHANGE_APPROVER) ?? CONST.SEARCH.ACTION_TYPES.VIEW; +} + /** * @private * Organizes data into List Sections for display, for the TaskListItemType of Search Results. @@ -1601,7 +1612,7 @@ function getReportSections( reportIDToTransactions[reportKey] = { ...reportItem, - action: allActions.at(0) ?? CONST.SEARCH.ACTION_TYPES.VIEW, + action: getAction(allActions), allActions, keyForList: String(reportItem.reportID), groupedBy: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, @@ -1644,7 +1655,7 @@ function getReportSections( const allActions = getActions(data, allViolations, key, currentSearch, currentUserEmail, actions); const transaction = { ...transactionItem, - action: allActions.at(0) ?? CONST.SEARCH.ACTION_TYPES.VIEW, + action: getAction(allActions), allActions, report, reportAction, From 4aadd949f2eba58dabfc064cac27b8818f859dfe Mon Sep 17 00:00:00 2001 From: Qichen Zhu <57348009+QichenZhu@users.noreply.github.com> Date: Wed, 10 Dec 2025 08:33:25 +1300 Subject: [PATCH 21/21] Fix types --- tests/unit/Search/SearchContextTest.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/Search/SearchContextTest.tsx b/tests/unit/Search/SearchContextTest.tsx index 4ca4eb66af95..2d973768314d 100644 --- a/tests/unit/Search/SearchContextTest.tsx +++ b/tests/unit/Search/SearchContextTest.tsx @@ -8,6 +8,8 @@ const mockSelectedTransaction: SelectedTransactionInfo = { action: 'approve', canHold: true, isHeld: false, + canSplit: false, + hasBeenSplit: false, canUnhold: false, canChangeReport: true, isSelected: true,