From 9e18a7ed77e3ecedf21ef1948dcd653005713d69 Mon Sep 17 00:00:00 2001 From: "ahmedGaber93 (via MelvinBot)" Date: Wed, 22 Jul 2026 10:55:13 +0000 Subject: [PATCH 1/8] Group bulk export options by integration for multi-workspace selections Co-authored-by: ahmedGaber93 --- src/hooks/useSearchBulkActions.ts | 96 +++++++++++-------- .../hooks/useSearchBulkActionsExportTest.ts | 62 +++++++++++- 2 files changed, 116 insertions(+), 42 deletions(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 6e70828aa668..c2992db656d0 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -1481,7 +1481,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const exportOptions: PopoverMenuItem[] = []; - const connectedIntegration = getConnectedIntegration(policy); const isReportsTab = isExpenseReportType; const includesGroupExport = Object.entries(selectedTransactions).some( ([key, selectedTransaction]) => key.startsWith(CONST.SEARCH.GROUP_PREFIX) && !selectedTransaction?.transaction, @@ -1504,26 +1503,30 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { return reportExportOptions.includes(exportOption); }; - const canExportAllReportsToIntegration = - isReportsTab && - selectedReportIDs.length > 0 && - includeReportLevelExport && - selectedReports.every((report) => canReportBeExported(report, CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION)); - const canMarkAllReportsAsExported = - isReportsTab && - selectedReportIDs.length > 0 && - includeReportLevelExport && - selectedReports.every((report) => canReportBeExported(report, CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED)); - - if (connectedIntegration) { - const connectionNameFriendly = CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[connectedIntegration]; - const integrationIcon = getIntegrationIcon(connectedIntegration, expensifyIcons); - - const handleExportAction = (exportAction: () => void) => { + // Group the selected reports by their connected accounting integration. A single-workspace + // selection collapses to one group (unchanged behavior), while a multi-workspace selection + // surfaces one export + one "Mark as exported" option per integration, each scoped to the + // reports that belong to it. + const reportsByIntegration = new Map>, typeof selectedReports>(); + if (isReportsTab && selectedReportIDs.length > 0 && includeReportLevelExport) { + for (const report of selectedReports) { + const reportPolicy = report.policyID ? policies?.[`${ONYXKEYS.COLLECTION.POLICY}${report.policyID}`] : undefined; + const reportIntegration = getConnectedIntegration(reportPolicy); + if (!reportIntegration) { + continue; + } + const reportsForIntegration = reportsByIntegration.get(reportIntegration) ?? []; + reportsForIntegration.push(report); + reportsByIntegration.set(reportIntegration, reportsForIntegration); + } + } + + const buildIntegrationHandleExportAction = + (integrationReportIDs: string[], integration: NonNullable>) => (exportAction: () => void) => { const exportedReportNames: string[] = []; let areAnyReportsExported = false; - for (const reportID of selectedReportIDs) { + for (const reportID of integrationReportIDs) { const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; if (!report?.pendingFields?.export && !report?.isExportedToIntegration) { @@ -1544,7 +1547,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { showConfirmModal({ title: translate('workspace.exportAgainModal.title'), prompt: translate('workspace.exportAgainModal.description', { - connectionName: connectedIntegration, + connectionName: integration, reportName: exportedReportNames.join('\n'), }), confirmText: translate('workspace.exportAgainModal.confirmText'), @@ -1566,29 +1569,44 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { } }; - if (canExportAllReportsToIntegration) { - exportOptions.push({ - text: connectionNameFriendly, - icon: integrationIcon, - onSelected: () => handleExportAction(() => exportToIntegrationOnSearch(hash, selectedReportIDs, connectedIntegration, currentSearchKey)), - shouldCloseModalOnSelect: true, - shouldCallAfterModalHide: true, - displayInDefaultIconColor: true, - additionalIconStyles: styles.integrationIcon, - }); + // Add every "Export to " option first, then every "Mark as exported" option, + // so a multi-integration selection lists all export actions before the mark-as-exported ones. + for (const [integration, reportsForIntegration] of reportsByIntegration) { + const integrationReportIDs = reportsForIntegration.map((report) => report.reportID).filter((reportID): reportID is string => reportID !== undefined); + const canExportGroupToIntegration = + integrationReportIDs.length > 0 && reportsForIntegration.every((report) => canReportBeExported(report, CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION)); + if (!canExportGroupToIntegration) { + continue; } + const handleExportAction = buildIntegrationHandleExportAction(integrationReportIDs, integration); + exportOptions.push({ + text: CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration], + icon: getIntegrationIcon(integration, expensifyIcons), + onSelected: () => handleExportAction(() => exportToIntegrationOnSearch(hash, integrationReportIDs, integration, currentSearchKey)), + shouldCloseModalOnSelect: true, + shouldCallAfterModalHide: true, + displayInDefaultIconColor: true, + additionalIconStyles: styles.integrationIcon, + }); + } - if (canMarkAllReportsAsExported) { - exportOptions.push({ - text: translate('workspace.common.markAsExported'), - icon: integrationIcon, - onSelected: () => handleExportAction(() => markAsManuallyExported(selectedReportIDs, connectedIntegration)), - shouldCloseModalOnSelect: true, - shouldCallAfterModalHide: true, - displayInDefaultIconColor: true, - additionalIconStyles: styles.integrationIcon, - }); + for (const [integration, reportsForIntegration] of reportsByIntegration) { + const integrationReportIDs = reportsForIntegration.map((report) => report.reportID).filter((reportID): reportID is string => reportID !== undefined); + const canMarkGroupAsExported = + integrationReportIDs.length > 0 && reportsForIntegration.every((report) => canReportBeExported(report, CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED)); + if (!canMarkGroupAsExported) { + continue; } + const handleExportAction = buildIntegrationHandleExportAction(integrationReportIDs, integration); + exportOptions.push({ + text: translate('workspace.common.markAsExported'), + icon: getIntegrationIcon(integration, expensifyIcons), + onSelected: () => handleExportAction(() => markAsManuallyExported(integrationReportIDs, integration)), + shouldCloseModalOnSelect: true, + shouldCallAfterModalHide: true, + displayInDefaultIconColor: true, + additionalIconStyles: styles.integrationIcon, + }); } const isGroupedSearch = !!getValidGroupBy(queryJSON?.groupBy); diff --git a/tests/unit/hooks/useSearchBulkActionsExportTest.ts b/tests/unit/hooks/useSearchBulkActionsExportTest.ts index e39922e4977e..a1d3cd7bd548 100644 --- a/tests/unit/hooks/useSearchBulkActionsExportTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsExportTest.ts @@ -5,6 +5,8 @@ import type {SearchQueryJSON, SelectedReports, SelectedTransactions} from '@comp import useSearchBulkActions from '@hooks/useSearchBulkActions'; +import {markAsManuallyExported} from '@libs/actions/Report'; +import {exportToIntegrationOnSearch} from '@libs/actions/Search'; import type * as ReportSecondaryActionUtilsModule from '@libs/ReportSecondaryActionUtils'; import CONST from '@src/CONST'; @@ -245,7 +247,10 @@ jest.mock('@hooks/usePaymentContext', () => { const REPORT_ID = 'report1'; const POLICY_ID = 'policy1'; +const REPORT_ID_2 = 'report2'; +const POLICY_ID_2 = 'policy2'; const NETSUITE_FRIENDLY_NAME = CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[CONST.POLICY.CONNECTIONS.NAME.NETSUITE]; +const QBO_FRIENDLY_NAME = CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[CONST.POLICY.CONNECTIONS.NAME.QBO]; const expenseReportQueryJSON: SearchQueryJSON = { inputQuery: 'type:expense-report status:all', @@ -299,11 +304,11 @@ function makeSelectedTransaction(overrides: Partial item.text === NETSUITE_FRIENDLY_NAME)).toBe(false); expect(subMenuItems.some((item) => item.text === 'workspace.common.markAsExported')).toBe(false); }); + + it('offers per-integration export options when reports span workspaces connected to different integrations', async () => { + /** + * Given: two selected reports on two workspaces connected to different integrations + * (report1 → NetSuite, report2 → QuickBooks Online). + * + * When: the export bulk-action menu is built. + * + * Then: instead of hiding all integration options (the previous behavior, which only showed + * them when every selected report shared a single workspace), the menu surfaces one + * "Export to " option and one "Mark as exported" option per integration, + * and each action is scoped to the reports for that integration. + */ + // A second workspace connected to QBO. + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID_2}`, { + id: POLICY_ID_2, + connections: {[CONST.POLICY.CONNECTIONS.NAME.QBO]: {}}, + }); + + mockCurrentSearchResults = makeSearchResults([makeSnapshotReport(), makeSnapshotReport(REPORT_ID_2, POLICY_ID_2)]); + + mockSelectedReports = [makeSelectedReport(), makeSelectedReport({reportID: REPORT_ID_2, policyID: POLICY_ID_2})]; + mockSelectedTransactions = { + tx1: makeSelectedTransaction(), + tx2: makeSelectedTransaction({reportID: REPORT_ID_2, policyID: POLICY_ID_2}), + }; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}), {wrapper: OnyxListItemProvider}); + + await waitFor(() => { + const subMenuItems = getExportSubMenuItems(result.current.headerButtonsOptions); + expect(subMenuItems?.some((item) => item.text === QBO_FRIENDLY_NAME)).toBe(true); + }); + + const subMenuItems = getExportSubMenuItems(result.current.headerButtonsOptions) ?? []; + + // Both integrations' export options are present... + expect(subMenuItems.some((item) => item.text === NETSUITE_FRIENDLY_NAME)).toBe(true); + expect(subMenuItems.some((item) => item.text === QBO_FRIENDLY_NAME)).toBe(true); + // ...along with one "Mark as exported" option per integration. + expect(subMenuItems.filter((item) => item.text === 'workspace.common.markAsExported')).toHaveLength(2); + + // Each export action is scoped to only the reports for its integration. + subMenuItems.find((item) => item.text === QBO_FRIENDLY_NAME)?.onSelected?.(); + expect(exportToIntegrationOnSearch).toHaveBeenCalledWith(expect.anything(), [REPORT_ID_2], CONST.POLICY.CONNECTIONS.NAME.QBO, undefined); + + subMenuItems.find((item) => item.text === NETSUITE_FRIENDLY_NAME)?.onSelected?.(); + expect(exportToIntegrationOnSearch).toHaveBeenCalledWith(expect.anything(), [REPORT_ID], CONST.POLICY.CONNECTIONS.NAME.NETSUITE, undefined); + + expect(markAsManuallyExported).not.toHaveBeenCalled(); + }); }); From cdbe299850a7043d79b9365861f3cbbb5d53ff2f Mon Sep 17 00:00:00 2001 From: "ahmedGaber93 (via MelvinBot)" Date: Thu, 23 Jul 2026 08:29:49 +0000 Subject: [PATCH 2/8] Group each integration's bulk export and mark-as-exported actions together Co-authored-by: ahmedGaber93 --- src/hooks/useSearchBulkActions.ts | 58 +++++++++---------- .../hooks/useSearchBulkActionsExportTest.ts | 7 +++ 2 files changed, 34 insertions(+), 31 deletions(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index c2992db656d0..1792c2ab3a28 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -1569,44 +1569,40 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { } }; - // Add every "Export to " option first, then every "Mark as exported" option, - // so a multi-integration selection lists all export actions before the mark-as-exported ones. + // Group each integration's actions together, listing its "Export to " option + // immediately followed by its "Mark as exported" option, before moving on to the next integration. for (const [integration, reportsForIntegration] of reportsByIntegration) { const integrationReportIDs = reportsForIntegration.map((report) => report.reportID).filter((reportID): reportID is string => reportID !== undefined); - const canExportGroupToIntegration = - integrationReportIDs.length > 0 && reportsForIntegration.every((report) => canReportBeExported(report, CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION)); - if (!canExportGroupToIntegration) { + if (integrationReportIDs.length === 0) { continue; } const handleExportAction = buildIntegrationHandleExportAction(integrationReportIDs, integration); - exportOptions.push({ - text: CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration], - icon: getIntegrationIcon(integration, expensifyIcons), - onSelected: () => handleExportAction(() => exportToIntegrationOnSearch(hash, integrationReportIDs, integration, currentSearchKey)), - shouldCloseModalOnSelect: true, - shouldCallAfterModalHide: true, - displayInDefaultIconColor: true, - additionalIconStyles: styles.integrationIcon, - }); - } - for (const [integration, reportsForIntegration] of reportsByIntegration) { - const integrationReportIDs = reportsForIntegration.map((report) => report.reportID).filter((reportID): reportID is string => reportID !== undefined); - const canMarkGroupAsExported = - integrationReportIDs.length > 0 && reportsForIntegration.every((report) => canReportBeExported(report, CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED)); - if (!canMarkGroupAsExported) { - continue; + const canExportGroupToIntegration = reportsForIntegration.every((report) => canReportBeExported(report, CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION)); + if (canExportGroupToIntegration) { + exportOptions.push({ + text: CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration], + icon: getIntegrationIcon(integration, expensifyIcons), + onSelected: () => handleExportAction(() => exportToIntegrationOnSearch(hash, integrationReportIDs, integration, currentSearchKey)), + shouldCloseModalOnSelect: true, + shouldCallAfterModalHide: true, + displayInDefaultIconColor: true, + additionalIconStyles: styles.integrationIcon, + }); + } + + const canMarkGroupAsExported = reportsForIntegration.every((report) => canReportBeExported(report, CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED)); + if (canMarkGroupAsExported) { + exportOptions.push({ + text: translate('workspace.common.markAsExported'), + icon: getIntegrationIcon(integration, expensifyIcons), + onSelected: () => handleExportAction(() => markAsManuallyExported(integrationReportIDs, integration)), + shouldCloseModalOnSelect: true, + shouldCallAfterModalHide: true, + displayInDefaultIconColor: true, + additionalIconStyles: styles.integrationIcon, + }); } - const handleExportAction = buildIntegrationHandleExportAction(integrationReportIDs, integration); - exportOptions.push({ - text: translate('workspace.common.markAsExported'), - icon: getIntegrationIcon(integration, expensifyIcons), - onSelected: () => handleExportAction(() => markAsManuallyExported(integrationReportIDs, integration)), - shouldCloseModalOnSelect: true, - shouldCallAfterModalHide: true, - displayInDefaultIconColor: true, - additionalIconStyles: styles.integrationIcon, - }); } const isGroupedSearch = !!getValidGroupBy(queryJSON?.groupBy); diff --git a/tests/unit/hooks/useSearchBulkActionsExportTest.ts b/tests/unit/hooks/useSearchBulkActionsExportTest.ts index a1d3cd7bd548..66f50181711a 100644 --- a/tests/unit/hooks/useSearchBulkActionsExportTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsExportTest.ts @@ -482,6 +482,13 @@ describe('useSearchBulkActions - report export options resolve from the search s // ...along with one "Mark as exported" option per integration. expect(subMenuItems.filter((item) => item.text === 'workspace.common.markAsExported')).toHaveLength(2); + // Each integration's actions are grouped together: its "Export to " option is + // immediately followed by its own "Mark as exported" option, before the next integration. + const integrationOptionTexts = subMenuItems + .map((item) => item.text) + .filter((text) => text === NETSUITE_FRIENDLY_NAME || text === QBO_FRIENDLY_NAME || text === 'workspace.common.markAsExported'); + expect(integrationOptionTexts).toEqual([NETSUITE_FRIENDLY_NAME, 'workspace.common.markAsExported', QBO_FRIENDLY_NAME, 'workspace.common.markAsExported']); + // Each export action is scoped to only the reports for its integration. subMenuItems.find((item) => item.text === QBO_FRIENDLY_NAME)?.onSelected?.(); expect(exportToIntegrationOnSearch).toHaveBeenCalledWith(expect.anything(), [REPORT_ID_2], CONST.POLICY.CONNECTIONS.NAME.QBO, undefined); From ea3fcb9cad8d91195fa1d9a6fcdc7c9f0be80d8c Mon Sep 17 00:00:00 2001 From: "ahmedGaber93 (via MelvinBot)" Date: Sun, 26 Jul 2026 05:09:16 +0000 Subject: [PATCH 3/8] Add partial-export confirmation modal and fix already-exported detection - Show a partial-export modal when Export/Mark-as-exported to an integration only covers a subset of the multi-integration selection - Present the partial modal and the existing export-again modal sequentially - Detect already-exported reports from report actions (live Onyx first, then the search snapshot) instead of the stale isExportedToIntegration field - Route Mark as exported through the same shared confirmation flow - Cover the new flows in useSearchBulkActionsExportTest Co-authored-by: ahmedGaber93 --- src/hooks/useSearchBulkActions.ts | 90 ++++-- src/languages/de.ts | 13 + src/languages/en.ts | 13 + src/languages/es.ts | 10 + src/languages/fr.ts | 13 + src/languages/it.ts | 13 + src/languages/ja.ts | 13 + src/languages/nl.ts | 13 + src/languages/params.ts | 12 + src/languages/pl.ts | 13 + src/languages/pt-BR.ts | 13 + src/languages/zh-hans.ts | 13 + .../hooks/useSearchBulkActionsExportTest.ts | 277 +++++++++++++++++- 13 files changed, 479 insertions(+), 27 deletions(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 1792c2ab3a28..631d91b301fe 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -56,6 +56,7 @@ import { isIndividualInvoiceRoom, isInvoiceReport, isIOUReport as isIOUReportUtil, + isExported, isSelfDM, shouldShowMarkAsDone, } from '@libs/ReportUtils'; @@ -91,7 +92,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; import {doesPersonalDetailExistSelector} from '@src/selectors/PersonalDetails'; -import type {BillingGraceEndPeriod, Policy, Report, ReportAction, ReportNameValuePairs, SearchResults, Transaction, TransactionViolations} from '@src/types/onyx'; +import type {BillingGraceEndPeriod, Policy, Report, ReportAction, ReportActions, ReportNameValuePairs, SearchResults, Transaction, TransactionViolations} from '@src/types/onyx'; import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; @@ -1521,29 +1522,65 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { } } + // A partial export happens when the chosen integration's reports are only a subset of the full + // selection — the rest belong to other integrations (or to no integration) and are skipped. + const totalSelectedReportsCount = selectedReportIDs.length; + + // Shared confirmation flow used by BOTH "Export to " and "Mark as exported" so the + // two actions behave identically. When applicable, the partial-export modal is shown first and, + // only after it resolves, the existing "export again" modal — the two are never combined. const buildIntegrationHandleExportAction = (integrationReportIDs: string[], integration: NonNullable>) => (exportAction: () => void) => { + const runExport = () => { + if (!hash) { + return; + } + clearSelectedTransactions(); + exportAction(); + }; + + // Detect already-exported reports from the report actions (the same source that drives the + // "exported" icon in the search list), not the report's `isExportedToIntegration` field which + // can be stale/false in the search snapshot. const exportedReportNames: string[] = []; let areAnyReportsExported = false; - for (const reportID of integrationReportIDs) { - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; - - if (!report?.pendingFields?.export && !report?.isExportedToIntegration) { + const liveReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + const snapshotReport = currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + // Prefer live Onyx report actions, falling back to the search snapshot. + const reportActions = + allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? + (currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] as OnyxEntry); + + const wasExported = + !!liveReport?.pendingFields?.export || + !!snapshotReport?.pendingFields?.export || + liveReport?.isExportedToIntegration === true || + snapshotReport?.isExportedToIntegration === true || + // Pass ONLY the report actions so `isExported` evaluates the actions instead of + // short-circuiting on the (possibly stale) `isExportedToIntegration` field. + isExported(reportActions); + + if (!wasExported) { continue; } areAnyReportsExported = true; - - // The live Onyx report can be an incomplete optimistic record (e.g. exported offline before it - // was ever loaded) that lacks `reportName`, so fall back to the Search snapshot for the name. - const reportName = report.reportName ?? currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]?.reportName; + // The live Onyx report can be an incomplete optimistic record (e.g. exported offline before + // it was ever loaded) that lacks `reportName`, so fall back to the Search snapshot for the name. + const reportName = liveReport?.reportName ?? snapshotReport?.reportName; if (reportName) { exportedReportNames.push(reportName); } } - if (areAnyReportsExported) { + // Ask about the already-exported reports (if any) and then run the export. + const confirmExportAgainThenRun = () => { + if (!areAnyReportsExported) { + runExport(); + return; + } + showConfirmModal({ title: translate('workspace.exportAgainModal.title'), prompt: translate('workspace.exportAgainModal.description', { @@ -1557,16 +1594,33 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { if (result.action !== ModalActions.CONFIRM) { return; } - - if (hash) { - clearSelectedTransactions(); - exportAction(); - } + runExport(); }); - } else if (hash) { - exportAction(); - clearSelectedTransactions(); + }; + + // For a partial export, confirm the partial scope first. Cancelling aborts; confirming moves + // on to the export-again check. The modals are presented sequentially so they don't overlap. + const isPartialExport = integrationReportIDs.length < totalSelectedReportsCount; + if (!isPartialExport) { + confirmExportAgainThenRun(); + return; } + + showConfirmModal({ + title: translate('workspace.exportPartialModal.title', { + exportableCount: integrationReportIDs.length, + selectedCount: totalSelectedReportsCount, + integration, + }), + prompt: translate('workspace.exportPartialModal.description', {integration}), + confirmText: translate('workspace.exportPartialModal.confirmText', {count: integrationReportIDs.length}), + cancelText: translate('workspace.exportPartialModal.cancelText'), + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + return; + } + confirmExportAgainThenRun(); + }); }; // Group each integration's actions together, listing its "Export to " option diff --git a/src/languages/de.ts b/src/languages/de.ts index 3a4ecce5de65..4eb8203a4e6d 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -32,6 +32,8 @@ import type { DeleteConfirmationParams, EditActionParams, ExportAgainModalDescriptionParams, + ExportPartialModalTitleParams, + ExportPartialModalDescriptionParams, ExportIntegrationSelectedParams, IntacctMappingTitleParams, InvalidPropertyParams, @@ -7186,6 +7188,17 @@ ${reportName}`, confirmText: 'Ja, erneut exportieren', cancelText: 'Abbrechen', }, + exportPartialModal: { + title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => + `${exportableCount}/${selectedCount} Berichte nach ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} exportieren?`, + description: ({integration}: ExportPartialModalDescriptionParams) => + `Nur mit ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} verbundene Berichte werden exportiert. Der Rest deiner Auswahl gehört zu anderen Integrationen und wird übersprungen.`, + confirmText: ({count}: {count: number}) => ({ + one: `${count} Bericht exportieren`, + other: `${count} Berichte exportieren`, + }), + cancelText: 'Abbrechen', + }, upgrade: { reportFields: { title: 'Berichtsfelder', diff --git a/src/languages/en.ts b/src/languages/en.ts index de88505538ad..b3c86fce1937 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -20,6 +20,8 @@ import type { DeleteConfirmationParams, EditActionParams, ExportAgainModalDescriptionParams, + ExportPartialModalTitleParams, + ExportPartialModalDescriptionParams, ExportIntegrationSelectedParams, IntacctMappingTitleParams, InvalidPropertyParams, @@ -7356,6 +7358,17 @@ const translations = { confirmText: 'Yes, export again', cancelText: 'Cancel', }, + exportPartialModal: { + title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => + `Export ${exportableCount}/${selectedCount} reports to ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}?`, + description: ({integration}: ExportPartialModalDescriptionParams) => + `Only reports connected to ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} will be exported. The rest of your selection belongs to other integrations and will be skipped.`, + confirmText: ({count}: {count: number}) => ({ + one: `Export ${count} report`, + other: `Export ${count} reports`, + }), + cancelText: 'Cancel', + }, upgrade: { reportFields: { title: 'Report fields', diff --git a/src/languages/es.ts b/src/languages/es.ts index 573cf112f4fa..d9b2e7c96afe 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7086,6 +7086,16 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, confirmText: 'Sí, exportar de nuevo', cancelText: 'Cancelar', }, + exportPartialModal: { + title: ({exportableCount, selectedCount, integration}) => `¿Exportar ${exportableCount}/${selectedCount} informes a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}?`, + description: ({integration}) => + `Solo se exportarán los informes conectados a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}. El resto de tu selección pertenece a otras integraciones y se omitirá.`, + confirmText: ({count}) => ({ + one: `Exportar ${count} informe`, + other: `Exportar ${count} informes`, + }), + cancelText: 'Cancelar', + }, planTypePage: { planTypes: { team: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 0278151b59e3..bfe922ec979c 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -32,6 +32,8 @@ import type { DeleteConfirmationParams, EditActionParams, ExportAgainModalDescriptionParams, + ExportPartialModalTitleParams, + ExportPartialModalDescriptionParams, ExportIntegrationSelectedParams, IntacctMappingTitleParams, InvalidPropertyParams, @@ -7213,6 +7215,17 @@ ${reportName}`, confirmText: 'Oui, exporter à nouveau', cancelText: 'Annuler', }, + exportPartialModal: { + title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => + `Exporter ${exportableCount}/${selectedCount} rapports vers ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} ?`, + description: ({integration}: ExportPartialModalDescriptionParams) => + `Seuls les rapports connectés à ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} seront exportés. Le reste de votre sélection appartient à d’autres intégrations et sera ignoré.`, + confirmText: ({count}: {count: number}) => ({ + one: `Exporter ${count} rapport`, + other: `Exporter ${count} rapports`, + }), + cancelText: 'Annuler', + }, upgrade: { reportFields: { title: 'Champs de note de frais', diff --git a/src/languages/it.ts b/src/languages/it.ts index 887108e34bb2..b90524ee4646 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -32,6 +32,8 @@ import type { DeleteConfirmationParams, EditActionParams, ExportAgainModalDescriptionParams, + ExportPartialModalTitleParams, + ExportPartialModalDescriptionParams, ExportIntegrationSelectedParams, IntacctMappingTitleParams, InvalidPropertyParams, @@ -7166,6 +7168,17 @@ ${reportName}`, confirmText: 'Sì, esporta di nuovo', cancelText: 'Annulla', }, + exportPartialModal: { + title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => + `Esportare ${exportableCount}/${selectedCount} report in ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}?`, + description: ({integration}: ExportPartialModalDescriptionParams) => + `Verranno esportati solo i report collegati a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}. Il resto della selezione appartiene ad altre integrazioni e verrà ignorato.`, + confirmText: ({count}: {count: number}) => ({ + one: `Esporta ${count} report`, + other: `Esporta ${count} report`, + }), + cancelText: 'Annulla', + }, upgrade: { reportFields: { title: 'Campi del report', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 98b0fa71d88e..09f50a2dedf2 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -32,6 +32,8 @@ import type { DeleteConfirmationParams, EditActionParams, ExportAgainModalDescriptionParams, + ExportPartialModalTitleParams, + ExportPartialModalDescriptionParams, ExportIntegrationSelectedParams, IntacctMappingTitleParams, InvalidPropertyParams, @@ -7083,6 +7085,17 @@ ${reportName}`, confirmText: 'はい、再度エクスポートします', cancelText: 'キャンセル', }, + exportPartialModal: { + title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => + `${exportableCount}/${selectedCount}件のレポートを${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}にエクスポートしますか?`, + description: ({integration}: ExportPartialModalDescriptionParams) => + `${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}に接続されているレポートのみがエクスポートされます。選択した残りのレポートは他の連携に属しているためスキップされます。`, + confirmText: ({count}: {count: number}) => ({ + one: `${count}件のレポートをエクスポート`, + other: `${count}件のレポートをエクスポート`, + }), + cancelText: 'キャンセル', + }, upgrade: { reportFields: { title: 'レポート項目', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index ba89d01c4ff1..fb2fc3326384 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -32,6 +32,8 @@ import type { DeleteConfirmationParams, EditActionParams, ExportAgainModalDescriptionParams, + ExportPartialModalTitleParams, + ExportPartialModalDescriptionParams, ExportIntegrationSelectedParams, IntacctMappingTitleParams, InvalidPropertyParams, @@ -7150,6 +7152,17 @@ ${reportName}`, confirmText: 'Ja, opnieuw exporteren', cancelText: 'Annuleren', }, + exportPartialModal: { + title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => + `${exportableCount}/${selectedCount} rapporten exporteren naar ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}?`, + description: ({integration}: ExportPartialModalDescriptionParams) => + `Alleen rapporten die zijn verbonden met ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} worden geëxporteerd. De rest van je selectie hoort bij andere integraties en wordt overgeslagen.`, + confirmText: ({count}: {count: number}) => ({ + one: `${count} rapport exporteren`, + other: `${count} rapporten exporteren`, + }), + cancelText: 'Annuleren', + }, upgrade: { reportFields: { title: 'Rapportvelden', diff --git a/src/languages/params.ts b/src/languages/params.ts index 8fde4a9be595..e12c0bef2046 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -92,6 +92,16 @@ type ExportAgainModalDescriptionParams = { connectionName: ConnectionName; }; +type ExportPartialModalTitleParams = { + exportableCount: number; + selectedCount: number; + integration: ConnectionName; +}; + +type ExportPartialModalDescriptionParams = { + integration: ConnectionName; +}; + type UpdateRoleParams = {email: string; currentRole: string; newRole: string}; type YourPlanPriceParams = {lower: string; upper: string}; @@ -171,6 +181,8 @@ export type { UnsupportedFormulaValueErrorParams, ConnectionNameParams, ExportAgainModalDescriptionParams, + ExportPartialModalTitleParams, + ExportPartialModalDescriptionParams, UpdateRoleParams, OptionalParam, WorkspaceLockedPlanTypeParams, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index e4675ae635ac..d221f457d2d2 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -32,6 +32,8 @@ import type { DeleteConfirmationParams, EditActionParams, ExportAgainModalDescriptionParams, + ExportPartialModalTitleParams, + ExportPartialModalDescriptionParams, ExportIntegrationSelectedParams, IntacctMappingTitleParams, InvalidPropertyParams, @@ -7130,6 +7132,17 @@ ${reportName}`, confirmText: 'Tak, wyeksportuj ponownie', cancelText: 'Anuluj', }, + exportPartialModal: { + title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => + `Wyeksportować ${exportableCount}/${selectedCount} raportów do ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}?`, + description: ({integration}: ExportPartialModalDescriptionParams) => + `Wyeksportowane zostaną tylko raporty połączone z ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}. Reszta zaznaczenia należy do innych integracji i zostanie pominięta.`, + confirmText: ({count}: {count: number}) => ({ + one: `Wyeksportuj ${count} raport`, + other: `Wyeksportuj ${count} raportów`, + }), + cancelText: 'Anuluj', + }, upgrade: { reportFields: { title: 'Pola raportu', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index a383e5b011d2..c03968ed48ec 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -32,6 +32,8 @@ import type { DeleteConfirmationParams, EditActionParams, ExportAgainModalDescriptionParams, + ExportPartialModalTitleParams, + ExportPartialModalDescriptionParams, ExportIntegrationSelectedParams, IntacctMappingTitleParams, InvalidPropertyParams, @@ -7142,6 +7144,17 @@ ${reportName}`, confirmText: 'Sim, exportar novamente', cancelText: 'Cancelar', }, + exportPartialModal: { + title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => + `Exportar ${exportableCount}/${selectedCount} relatórios para ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}?`, + description: ({integration}: ExportPartialModalDescriptionParams) => + `Somente os relatórios conectados a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} serão exportados. O restante da sua seleção pertence a outras integrações e será ignorado.`, + confirmText: ({count}: {count: number}) => ({ + one: `Exportar ${count} relatório`, + other: `Exportar ${count} relatórios`, + }), + cancelText: 'Cancelar', + }, upgrade: { reportFields: { title: 'Campos do relatório', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index a2bee0a352cd..8299f7c711b7 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -32,6 +32,8 @@ import type { DeleteConfirmationParams, EditActionParams, ExportAgainModalDescriptionParams, + ExportPartialModalTitleParams, + ExportPartialModalDescriptionParams, ExportIntegrationSelectedParams, IntacctMappingTitleParams, InvalidPropertyParams, @@ -6933,6 +6935,17 @@ ${reportName}`, confirmText: '是,再次导出', cancelText: '取消', }, + exportPartialModal: { + title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => + `将 ${exportableCount}/${selectedCount} 份报表导出到 ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}?`, + description: ({integration}: ExportPartialModalDescriptionParams) => + `只有连接到 ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} 的报表会被导出。所选的其余报表属于其他集成,将被跳过。`, + confirmText: ({count}: {count: number}) => ({ + one: `导出 ${count} 份报表`, + other: `导出 ${count} 份报表`, + }), + cancelText: '取消', + }, upgrade: { reportFields: { title: '报表字段', diff --git a/tests/unit/hooks/useSearchBulkActionsExportTest.ts b/tests/unit/hooks/useSearchBulkActionsExportTest.ts index 66f50181711a..324e832b1653 100644 --- a/tests/unit/hooks/useSearchBulkActionsExportTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsExportTest.ts @@ -11,13 +11,14 @@ import type * as ReportSecondaryActionUtilsModule from '@libs/ReportSecondaryAct import CONST from '@src/CONST'; import ONYXKEYS from '@src/ONYXKEYS'; -import type {Report, SearchResults} from '@src/types/onyx'; +import type {Report, ReportActions, SearchResults} from '@src/types/onyx'; import Onyx from 'react-native-onyx'; import type * as MockUsePaymentContextUtil from '../../utils/mockUsePaymentContext'; import {createRandomReport} from '../../utils/collections/reports'; +import createMock from '../../utils/createMock'; // --------------------------------------------------------------------------- // Module mocks @@ -317,12 +318,27 @@ function makeSnapshotReport(reportID: string = REPORT_ID, policyID: string = POL }; } -/** Build a minimal expense-report search snapshot containing the given reports keyed by their collection key. */ -function makeSearchResults(reports: Report[]): SearchResults { +/** A report action that marks a report as exported to an integration (the source the "exported" icon reads). */ +function makeExportedReportActions(label: string = CONST.EXPORT_LABELS.NETSUITE): ReportActions { + return createMock({ + exportAction: { + reportActionID: 'exportAction', + actionName: CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION, + created: '2024-01-01 00:00:00.000', + originalMessage: {label, markedManually: true}, + }, + }); +} + +/** Build a minimal expense-report search snapshot containing the given reports (and optional report actions) keyed by their collection key. */ +function makeSearchResults(reports: Report[], reportActionsByReportID: Record = {}): SearchResults { const data: SearchResults['data'] = {}; for (const report of reports) { data[`${ONYXKEYS.COLLECTION.REPORT}${report.reportID}`] = report; } + for (const [reportID, reportActions] of Object.entries(reportActionsByReportID)) { + data[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] = reportActions; + } return { search: { type: CONST.SEARCH.DATA_TYPES.EXPENSE_REPORT, @@ -358,6 +374,9 @@ describe('useSearchBulkActions - report export options resolve from the search s mockSelectedTransactions = {}; mockSelectedReports = []; mockCurrentSearchResults = undefined; + // Both confirmation modals (partial-export and export-again) resolve to CONFIRM by default; individual + // tests override with mockResolvedValueOnce to exercise the cancel path. + mockShowConfirmModal.mockResolvedValue({action: 'CONFIRM'}); await Onyx.merge(ONYXKEYS.SESSION, {accountID: CURRENT_USER_ACCOUNT_ID, email: 'test@example.com'}); // A policy connected to NetSuite so the integration export branch is reachable. @@ -489,13 +508,253 @@ describe('useSearchBulkActions - report export options resolve from the search s .filter((text) => text === NETSUITE_FRIENDLY_NAME || text === QBO_FRIENDLY_NAME || text === 'workspace.common.markAsExported'); expect(integrationOptionTexts).toEqual([NETSUITE_FRIENDLY_NAME, 'workspace.common.markAsExported', QBO_FRIENDLY_NAME, 'workspace.common.markAsExported']); - // Each export action is scoped to only the reports for its integration. - subMenuItems.find((item) => item.text === QBO_FRIENDLY_NAME)?.onSelected?.(); - expect(exportToIntegrationOnSearch).toHaveBeenCalledWith(expect.anything(), [REPORT_ID_2], CONST.POLICY.CONNECTIONS.NAME.QBO, undefined); + expect(markAsManuallyExported).not.toHaveBeenCalled(); + }); - subMenuItems.find((item) => item.text === NETSUITE_FRIENDLY_NAME)?.onSelected?.(); - expect(exportToIntegrationOnSearch).toHaveBeenCalledWith(expect.anything(), [REPORT_ID], CONST.POLICY.CONNECTIONS.NAME.NETSUITE, undefined); + it('shows the partial-export modal for a multi-integration selection, then exports only the chosen integration subset', async () => { + /** + * Given: two reports on workspaces connected to different integrations (report1 → NetSuite, + * report2 → QBO), so exporting to one integration only covers a subset of the selection. + * + * When: the user clicks "Export to NetSuite" and confirms. + * + * Then: the partial-export confirmation modal is shown first (never the export-again modal, since + * nothing was exported yet), and after confirming, only report1 is exported to NetSuite. + */ + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID_2}`, { + id: POLICY_ID_2, + connections: {[CONST.POLICY.CONNECTIONS.NAME.QBO]: {}}, + }); - expect(markAsManuallyExported).not.toHaveBeenCalled(); + mockCurrentSearchResults = makeSearchResults([makeSnapshotReport(), makeSnapshotReport(REPORT_ID_2, POLICY_ID_2)]); + mockSelectedReports = [makeSelectedReport(), makeSelectedReport({reportID: REPORT_ID_2, policyID: POLICY_ID_2})]; + mockSelectedTransactions = { + tx1: makeSelectedTransaction(), + tx2: makeSelectedTransaction({reportID: REPORT_ID_2, policyID: POLICY_ID_2}), + }; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}), {wrapper: OnyxListItemProvider}); + + await waitFor(() => { + expect(getExportSubMenuItems(result.current.headerButtonsOptions)?.some((item) => item.text === NETSUITE_FRIENDLY_NAME)).toBe(true); + }); + + getExportSubMenuItems(result.current.headerButtonsOptions) + ?.find((item) => item.text === NETSUITE_FRIENDLY_NAME) + ?.onSelected?.(); + + await waitFor(() => { + expect(exportToIntegrationOnSearch).toHaveBeenCalledWith(expect.anything(), [REPORT_ID], CONST.POLICY.CONNECTIONS.NAME.NETSUITE, undefined); + }); + + // Only the partial-export modal is shown (there is nothing already-exported to warn about). + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + expect(mockShowConfirmModal).toHaveBeenCalledWith(expect.objectContaining({title: 'workspace.exportPartialModal.title'})); + }); + + it('aborts the export when the partial-export modal is cancelled', async () => { + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID_2}`, { + id: POLICY_ID_2, + connections: {[CONST.POLICY.CONNECTIONS.NAME.QBO]: {}}, + }); + + mockCurrentSearchResults = makeSearchResults([makeSnapshotReport(), makeSnapshotReport(REPORT_ID_2, POLICY_ID_2)]); + mockSelectedReports = [makeSelectedReport(), makeSelectedReport({reportID: REPORT_ID_2, policyID: POLICY_ID_2})]; + mockSelectedTransactions = { + tx1: makeSelectedTransaction(), + tx2: makeSelectedTransaction({reportID: REPORT_ID_2, policyID: POLICY_ID_2}), + }; + // Cancel the partial-export modal. + mockShowConfirmModal.mockResolvedValueOnce({action: 'CLOSE'}); + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}), {wrapper: OnyxListItemProvider}); + + await waitFor(() => { + expect(getExportSubMenuItems(result.current.headerButtonsOptions)?.some((item) => item.text === NETSUITE_FRIENDLY_NAME)).toBe(true); + }); + + getExportSubMenuItems(result.current.headerButtonsOptions) + ?.find((item) => item.text === NETSUITE_FRIENDLY_NAME) + ?.onSelected?.(); + + // Flush the (cancelled) modal promise chain, then confirm nothing was exported. + await waitFor(() => expect(mockShowConfirmModal).toHaveBeenCalledTimes(1)); + expect(exportToIntegrationOnSearch).not.toHaveBeenCalled(); + }); + + it('shows the partial-export modal first and then the export-again modal when the chosen subset was already exported', async () => { + /** + * Given: a multi-integration selection (report1 → NetSuite, report2 → QBO) where the NetSuite + * report has already been exported (detected from its report actions). + * + * When: the user clicks "Export to NetSuite". + * + * Then: the two confirmations are presented in sequence — the partial-export modal first, then the + * existing export-again modal — and only after confirming both is report1 exported. + */ + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID_2}`, { + id: POLICY_ID_2, + connections: {[CONST.POLICY.CONNECTIONS.NAME.QBO]: {}}, + }); + + // report1 is already exported (via its report actions), report2 is not. + mockCurrentSearchResults = makeSearchResults([makeSnapshotReport(), makeSnapshotReport(REPORT_ID_2, POLICY_ID_2)], {[REPORT_ID]: makeExportedReportActions()}); + mockSelectedReports = [makeSelectedReport(), makeSelectedReport({reportID: REPORT_ID_2, policyID: POLICY_ID_2})]; + mockSelectedTransactions = { + tx1: makeSelectedTransaction(), + tx2: makeSelectedTransaction({reportID: REPORT_ID_2, policyID: POLICY_ID_2}), + }; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}), {wrapper: OnyxListItemProvider}); + + await waitFor(() => { + expect(getExportSubMenuItems(result.current.headerButtonsOptions)?.some((item) => item.text === NETSUITE_FRIENDLY_NAME)).toBe(true); + }); + + getExportSubMenuItems(result.current.headerButtonsOptions) + ?.find((item) => item.text === NETSUITE_FRIENDLY_NAME) + ?.onSelected?.(); + + await waitFor(() => { + expect(exportToIntegrationOnSearch).toHaveBeenCalledWith(expect.anything(), [REPORT_ID], CONST.POLICY.CONNECTIONS.NAME.NETSUITE, undefined); + }); + + // Both modals were shown, in order: partial-export first, export-again second. + expect(mockShowConfirmModal).toHaveBeenCalledTimes(2); + expect(mockShowConfirmModal).toHaveBeenNthCalledWith(1, expect.objectContaining({title: 'workspace.exportPartialModal.title'})); + expect(mockShowConfirmModal).toHaveBeenNthCalledWith(2, expect.objectContaining({title: 'workspace.exportAgainModal.title'})); + }); + + it('shows only the export-again modal (no partial modal) for a single-integration already-exported selection', async () => { + /** + * Given: a single-workspace selection where every selected report belongs to NetSuite and has + * already been exported. + * + * When: the user clicks "Export to NetSuite". + * + * Then: only the export-again modal is shown (the export is not partial), and after confirming the + * report is exported. + */ + mockCurrentSearchResults = makeSearchResults([makeSnapshotReport()], {[REPORT_ID]: makeExportedReportActions()}); + mockSelectedReports = [makeSelectedReport()]; + mockSelectedTransactions = {tx1: makeSelectedTransaction()}; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}), {wrapper: OnyxListItemProvider}); + + await waitFor(() => { + expect(getExportSubMenuItems(result.current.headerButtonsOptions)?.some((item) => item.text === NETSUITE_FRIENDLY_NAME)).toBe(true); + }); + + getExportSubMenuItems(result.current.headerButtonsOptions) + ?.find((item) => item.text === NETSUITE_FRIENDLY_NAME) + ?.onSelected?.(); + + await waitFor(() => { + expect(exportToIntegrationOnSearch).toHaveBeenCalledWith(expect.anything(), [REPORT_ID], CONST.POLICY.CONNECTIONS.NAME.NETSUITE, undefined); + }); + + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + expect(mockShowConfirmModal).toHaveBeenCalledWith(expect.objectContaining({title: 'workspace.exportAgainModal.title'})); + }); + + it('detects an already-exported report from report actions even when isExportedToIntegration is stale/false', async () => { + /** + * Given: a report whose `isExportedToIntegration` field is stale (false) in the snapshot but whose + * report actions show it was exported — the same source that drives the search list icon. + * + * When: the user clicks "Export to NetSuite". + * + * Then: the export-again modal is still shown, proving detection reads the report actions and does + * not trust the stale `isExportedToIntegration` field. + */ + mockCurrentSearchResults = makeSearchResults([{...makeSnapshotReport(), isExportedToIntegration: false}], {[REPORT_ID]: makeExportedReportActions()}); + mockSelectedReports = [makeSelectedReport()]; + mockSelectedTransactions = {tx1: makeSelectedTransaction()}; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}), {wrapper: OnyxListItemProvider}); + + await waitFor(() => { + expect(getExportSubMenuItems(result.current.headerButtonsOptions)?.some((item) => item.text === NETSUITE_FRIENDLY_NAME)).toBe(true); + }); + + getExportSubMenuItems(result.current.headerButtonsOptions) + ?.find((item) => item.text === NETSUITE_FRIENDLY_NAME) + ?.onSelected?.(); + + await waitFor(() => { + expect(mockShowConfirmModal).toHaveBeenCalledWith(expect.objectContaining({title: 'workspace.exportAgainModal.title'})); + }); + }); + + it('shows no confirmation modal when every selected report belongs to the chosen integration and none were exported', async () => { + /** + * Given: two reports that both belong to NetSuite and were never exported. + * + * When: the user clicks "Export to NetSuite". + * + * Then: no modal is shown (the export is neither partial nor a re-export) and both reports are + * exported straight away. + */ + mockCurrentSearchResults = makeSearchResults([makeSnapshotReport(), makeSnapshotReport(REPORT_ID_2, POLICY_ID)]); + mockSelectedReports = [makeSelectedReport(), makeSelectedReport({reportID: REPORT_ID_2})]; + mockSelectedTransactions = { + tx1: makeSelectedTransaction(), + tx2: makeSelectedTransaction({reportID: REPORT_ID_2}), + }; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}), {wrapper: OnyxListItemProvider}); + + await waitFor(() => { + expect(getExportSubMenuItems(result.current.headerButtonsOptions)?.some((item) => item.text === NETSUITE_FRIENDLY_NAME)).toBe(true); + }); + + getExportSubMenuItems(result.current.headerButtonsOptions) + ?.find((item) => item.text === NETSUITE_FRIENDLY_NAME) + ?.onSelected?.(); + + expect(mockShowConfirmModal).not.toHaveBeenCalled(); + expect(exportToIntegrationOnSearch).toHaveBeenCalledWith(expect.anything(), [REPORT_ID, REPORT_ID_2], CONST.POLICY.CONNECTIONS.NAME.NETSUITE, undefined); + }); + + it('routes "Mark as exported" through the same flow: partial modal first, then export-again, then marks the subset', async () => { + /** + * Given: a multi-integration selection (report1 → NetSuite already exported, report2 → QBO). + * + * When: the user clicks NetSuite's "Mark as exported". + * + * Then: it goes through the identical shared flow as export — partial-export modal first, then the + * export-again modal — and only report1 is marked as exported to NetSuite. + */ + await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID_2}`, { + id: POLICY_ID_2, + connections: {[CONST.POLICY.CONNECTIONS.NAME.QBO]: {}}, + }); + + mockCurrentSearchResults = makeSearchResults([makeSnapshotReport(), makeSnapshotReport(REPORT_ID_2, POLICY_ID_2)], {[REPORT_ID]: makeExportedReportActions()}); + mockSelectedReports = [makeSelectedReport(), makeSelectedReport({reportID: REPORT_ID_2, policyID: POLICY_ID_2})]; + mockSelectedTransactions = { + tx1: makeSelectedTransaction(), + tx2: makeSelectedTransaction({reportID: REPORT_ID_2, policyID: POLICY_ID_2}), + }; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}), {wrapper: OnyxListItemProvider}); + + await waitFor(() => { + expect(getExportSubMenuItems(result.current.headerButtonsOptions)?.some((item) => item.text === 'workspace.common.markAsExported')).toBe(true); + }); + + // The first "Mark as exported" option belongs to NetSuite (its group is listed first). + getExportSubMenuItems(result.current.headerButtonsOptions) + ?.find((item) => item.text === 'workspace.common.markAsExported') + ?.onSelected?.(); + + await waitFor(() => { + expect(markAsManuallyExported).toHaveBeenCalledWith([REPORT_ID], CONST.POLICY.CONNECTIONS.NAME.NETSUITE); + }); + + expect(mockShowConfirmModal).toHaveBeenCalledTimes(2); + expect(mockShowConfirmModal).toHaveBeenNthCalledWith(1, expect.objectContaining({title: 'workspace.exportPartialModal.title'})); + expect(mockShowConfirmModal).toHaveBeenNthCalledWith(2, expect.objectContaining({title: 'workspace.exportAgainModal.title'})); + expect(exportToIntegrationOnSearch).not.toHaveBeenCalled(); }); }); From a5a54e796fdab1af5c7b32454c9274227272667b Mon Sep 17 00:00:00 2001 From: "ahmedGaber93 (via MelvinBot)" Date: Sun, 26 Jul 2026 14:06:05 +0000 Subject: [PATCH 4/8] Add subtitle prop to confirm modal; split bulk export modals into fixed subtitle + scrollable report list - Add subtitle/subtitleStyles props to ConfirmContent + ConfirmModal; subtitle renders between title and prompt and stays fixed above a scrollable prompt - Partial-export modal: descriptive text moves to the subtitle, the scrollable prompt lists the report names that will be exported - Export-again modal: reuse exportAgainModal.description (without the inline list) as the subtitle, list already-exported report names in the prompt - Update useSearchBulkActionsExportTest to assert the subtitle/prompt split Co-authored-by: ahmedGaber93 --- src/components/ConfirmContent.tsx | 14 +++++++ src/components/ConfirmModal.tsx | 10 +++++ src/hooks/useSearchBulkActions.ts | 27 +++++++++---- src/languages/de.ts | 2 +- src/languages/en.ts | 2 +- src/languages/es.ts | 2 +- src/languages/fr.ts | 2 +- src/languages/it.ts | 2 +- src/languages/ja.ts | 2 +- src/languages/nl.ts | 2 +- src/languages/pl.ts | 2 +- src/languages/pt-BR.ts | 2 +- src/languages/zh-hans.ts | 2 +- .../hooks/useSearchBulkActionsExportTest.ts | 39 ++++++++++++++++--- 14 files changed, 87 insertions(+), 23 deletions(-) diff --git a/src/components/ConfirmContent.tsx b/src/components/ConfirmContent.tsx index c658a997cf73..a26aac94325d 100644 --- a/src/components/ConfirmContent.tsx +++ b/src/components/ConfirmContent.tsx @@ -45,6 +45,9 @@ type ConfirmContentProps = { /** Modal content text/element */ prompt?: string | ReactNode; + /** Subtitle shown between the title and the prompt. Stays fixed above the prompt when the prompt is scrollable. */ + subtitle?: string | ReactNode; + /** Whether we should use the success button color */ success?: boolean; @@ -93,6 +96,9 @@ type ConfirmContentProps = { /** Styles for prompt */ promptStyles?: StyleProp; + /** Styles for subtitle */ + subtitleStyles?: StyleProp; + /** Styles for view */ contentStyles?: StyleProp; @@ -131,6 +137,8 @@ function ConfirmContent({ confirmText = '', cancelText = '', prompt = '', + subtitle, + subtitleStyles, success = true, danger = false, shouldDisableConfirmButtonWhenOffline = false, @@ -168,6 +176,11 @@ function ConfirmContent({ const isCentered = shouldCenterContent; const promptContent = typeof prompt === 'string' ? {prompt} : prompt; + // Rendered outside the (optionally scrollable) prompt so it stays fixed above the prompt. + let subtitleContent: ReactNode = subtitle; + if (typeof subtitle === 'string') { + subtitleContent = {subtitle}; + } return ( <> @@ -226,6 +239,7 @@ function ConfirmContent({ /> )} + {subtitleContent} {shouldEnablePromptScroll ? {promptContent} : promptContent} diff --git a/src/components/ConfirmModal.tsx b/src/components/ConfirmModal.tsx index 9495966312ac..eb753752979b 100755 --- a/src/components/ConfirmModal.tsx +++ b/src/components/ConfirmModal.tsx @@ -40,6 +40,9 @@ type ConfirmModalProps = { /** Modal content text/element */ prompt?: string | ReactNode; + /** Subtitle shown between the title and the prompt. Stays fixed above the prompt when the prompt is scrollable. */ + subtitle?: string | ReactNode; + /** Whether we should use the success button color */ success?: boolean; @@ -85,6 +88,9 @@ type ConfirmModalProps = { /** Styles for prompt */ promptStyles?: StyleProp; + /** Styles for subtitle */ + subtitleStyles?: StyleProp; + /** Styles for icon */ iconAdditionalStyles?: StyleProp; @@ -144,6 +150,8 @@ function ConfirmModal({ confirmText = '', cancelText = '', prompt = '', + subtitle, + subtitleStyles, success = true, danger = false, onCancel = () => {}, @@ -218,6 +226,8 @@ function ConfirmModal({ confirmText={confirmText} cancelText={cancelText} prompt={prompt} + subtitle={subtitle} + subtitleStyles={subtitleStyles} success={success} danger={danger} isVisible={isVisible} diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 631d91b301fe..77d38684dba7 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -1542,11 +1542,19 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { // Detect already-exported reports from the report actions (the same source that drives the // "exported" icon in the search list), not the report's `isExportedToIntegration` field which // can be stale/false in the search snapshot. + const exportableReportNames: string[] = []; const exportedReportNames: string[] = []; let areAnyReportsExported = false; for (const reportID of integrationReportIDs) { const liveReport = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; const snapshotReport = currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; + // The live Onyx report can be an incomplete optimistic record (e.g. exported offline before + // it was ever loaded) that lacks `reportName`, so fall back to the Search snapshot for the name. + const reportName = liveReport?.reportName ?? snapshotReport?.reportName; + if (reportName) { + exportableReportNames.push(reportName); + } + // Prefer live Onyx report actions, falling back to the search snapshot. const reportActions = allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? @@ -1566,9 +1574,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { } areAnyReportsExported = true; - // The live Onyx report can be an incomplete optimistic record (e.g. exported offline before - // it was ever loaded) that lacks `reportName`, so fall back to the Search snapshot for the name. - const reportName = liveReport?.reportName ?? snapshotReport?.reportName; if (reportName) { exportedReportNames.push(reportName); } @@ -1583,10 +1588,14 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { showConfirmModal({ title: translate('workspace.exportAgainModal.title'), - prompt: translate('workspace.exportAgainModal.description', { + // Reuse the existing description for the fixed subtitle, passing an empty report list so + // only the "already exported, export again?" text remains; the report names go in the + // scrollable prompt below. + subtitle: translate('workspace.exportAgainModal.description', { connectionName: integration, - reportName: exportedReportNames.join('\n'), - }), + reportName: '', + }).trim(), + prompt: exportedReportNames.join('\n'), confirmText: translate('workspace.exportAgainModal.confirmText'), cancelText: translate('workspace.exportAgainModal.cancelText'), shouldEnablePromptScroll: true, @@ -1612,9 +1621,13 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { selectedCount: totalSelectedReportsCount, integration, }), - prompt: translate('workspace.exportPartialModal.description', {integration}), + // Fixed subtitle describes the partial scope; the scrollable prompt lists the report names + // that will actually be exported for the chosen integration. + subtitle: translate('workspace.exportPartialModal.description', {integration}), + prompt: exportableReportNames.join('\n'), confirmText: translate('workspace.exportPartialModal.confirmText', {count: integrationReportIDs.length}), cancelText: translate('workspace.exportPartialModal.cancelText'), + shouldEnablePromptScroll: true, }).then((result) => { if (result.action !== ModalActions.CONFIRM) { return; diff --git a/src/languages/de.ts b/src/languages/de.ts index 4eb8203a4e6d..c04d20fd4d26 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7192,7 +7192,7 @@ ${reportName}`, title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => `${exportableCount}/${selectedCount} Berichte nach ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} exportieren?`, description: ({integration}: ExportPartialModalDescriptionParams) => - `Nur mit ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} verbundene Berichte werden exportiert. Der Rest deiner Auswahl gehört zu anderen Integrationen und wird übersprungen.`, + `Nur mit ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} verbundene Berichte werden exportiert. Der Rest deiner Auswahl gehört zu anderen Integrationen und wird übersprungen.\n\nDie folgenden Berichte werden exportiert:`, confirmText: ({count}: {count: number}) => ({ one: `${count} Bericht exportieren`, other: `${count} Berichte exportieren`, diff --git a/src/languages/en.ts b/src/languages/en.ts index b3c86fce1937..cac991bdf757 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7362,7 +7362,7 @@ const translations = { title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => `Export ${exportableCount}/${selectedCount} reports to ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}?`, description: ({integration}: ExportPartialModalDescriptionParams) => - `Only reports connected to ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} will be exported. The rest of your selection belongs to other integrations and will be skipped.`, + `Only reports connected to ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} will be exported. The rest of your selection belongs to other integrations and will be skipped.\n\nThe following reports will be exported:`, confirmText: ({count}: {count: number}) => ({ one: `Export ${count} report`, other: `Export ${count} reports`, diff --git a/src/languages/es.ts b/src/languages/es.ts index d9b2e7c96afe..31af46815956 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7089,7 +7089,7 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, exportPartialModal: { title: ({exportableCount, selectedCount, integration}) => `¿Exportar ${exportableCount}/${selectedCount} informes a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}?`, description: ({integration}) => - `Solo se exportarán los informes conectados a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}. El resto de tu selección pertenece a otras integraciones y se omitirá.`, + `Solo se exportarán los informes conectados a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}. El resto de tu selección pertenece a otras integraciones y se omitirá.\n\nSe exportarán los siguientes informes:`, confirmText: ({count}) => ({ one: `Exportar ${count} informe`, other: `Exportar ${count} informes`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index bfe922ec979c..1b4985d99003 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7219,7 +7219,7 @@ ${reportName}`, title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => `Exporter ${exportableCount}/${selectedCount} rapports vers ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} ?`, description: ({integration}: ExportPartialModalDescriptionParams) => - `Seuls les rapports connectés à ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} seront exportés. Le reste de votre sélection appartient à d’autres intégrations et sera ignoré.`, + `Seuls les rapports connectés à ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} seront exportés. Le reste de votre sélection appartient à d’autres intégrations et sera ignoré.\n\nLes rapports suivants seront exportés :`, confirmText: ({count}: {count: number}) => ({ one: `Exporter ${count} rapport`, other: `Exporter ${count} rapports`, diff --git a/src/languages/it.ts b/src/languages/it.ts index b90524ee4646..f18bedbe00e7 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7172,7 +7172,7 @@ ${reportName}`, title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => `Esportare ${exportableCount}/${selectedCount} report in ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}?`, description: ({integration}: ExportPartialModalDescriptionParams) => - `Verranno esportati solo i report collegati a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}. Il resto della selezione appartiene ad altre integrazioni e verrà ignorato.`, + `Verranno esportati solo i report collegati a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}. Il resto della selezione appartiene ad altre integrazioni e verrà ignorato.\n\nVerranno esportati i seguenti report:`, confirmText: ({count}: {count: number}) => ({ one: `Esporta ${count} report`, other: `Esporta ${count} report`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 09f50a2dedf2..2d0542b6fa6d 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7089,7 +7089,7 @@ ${reportName}`, title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => `${exportableCount}/${selectedCount}件のレポートを${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}にエクスポートしますか?`, description: ({integration}: ExportPartialModalDescriptionParams) => - `${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}に接続されているレポートのみがエクスポートされます。選択した残りのレポートは他の連携に属しているためスキップされます。`, + `${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}に接続されているレポートのみがエクスポートされます。選択した残りのレポートは他の連携に属しているためスキップされます。\n\n以下のレポートがエクスポートされます:`, confirmText: ({count}: {count: number}) => ({ one: `${count}件のレポートをエクスポート`, other: `${count}件のレポートをエクスポート`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index fb2fc3326384..6de8905be7be 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7156,7 +7156,7 @@ ${reportName}`, title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => `${exportableCount}/${selectedCount} rapporten exporteren naar ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}?`, description: ({integration}: ExportPartialModalDescriptionParams) => - `Alleen rapporten die zijn verbonden met ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} worden geëxporteerd. De rest van je selectie hoort bij andere integraties en wordt overgeslagen.`, + `Alleen rapporten die zijn verbonden met ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} worden geëxporteerd. De rest van je selectie hoort bij andere integraties en wordt overgeslagen.\n\nDe volgende rapporten worden geëxporteerd:`, confirmText: ({count}: {count: number}) => ({ one: `${count} rapport exporteren`, other: `${count} rapporten exporteren`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index d221f457d2d2..d01a8b5d94a2 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7136,7 +7136,7 @@ ${reportName}`, title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => `Wyeksportować ${exportableCount}/${selectedCount} raportów do ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}?`, description: ({integration}: ExportPartialModalDescriptionParams) => - `Wyeksportowane zostaną tylko raporty połączone z ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}. Reszta zaznaczenia należy do innych integracji i zostanie pominięta.`, + `Wyeksportowane zostaną tylko raporty połączone z ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}. Reszta zaznaczenia należy do innych integracji i zostanie pominięta.\n\nWyeksportowane zostaną następujące raporty:`, confirmText: ({count}: {count: number}) => ({ one: `Wyeksportuj ${count} raport`, other: `Wyeksportuj ${count} raportów`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index c03968ed48ec..ba6fe39034d0 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7148,7 +7148,7 @@ ${reportName}`, title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => `Exportar ${exportableCount}/${selectedCount} relatórios para ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}?`, description: ({integration}: ExportPartialModalDescriptionParams) => - `Somente os relatórios conectados a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} serão exportados. O restante da sua seleção pertence a outras integrações e será ignorado.`, + `Somente os relatórios conectados a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} serão exportados. O restante da sua seleção pertence a outras integrações e será ignorado.\n\nOs seguintes relatórios serão exportados:`, confirmText: ({count}: {count: number}) => ({ one: `Exportar ${count} relatório`, other: `Exportar ${count} relatórios`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 8299f7c711b7..f65849dedd71 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6939,7 +6939,7 @@ ${reportName}`, title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => `将 ${exportableCount}/${selectedCount} 份报表导出到 ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}?`, description: ({integration}: ExportPartialModalDescriptionParams) => - `只有连接到 ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} 的报表会被导出。所选的其余报表属于其他集成,将被跳过。`, + `只有连接到 ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} 的报表会被导出。所选的其余报表属于其他集成,将被跳过。\n\n将导出以下报表:`, confirmText: ({count}: {count: number}) => ({ one: `导出 ${count} 份报表`, other: `导出 ${count} 份报表`, diff --git a/tests/unit/hooks/useSearchBulkActionsExportTest.ts b/tests/unit/hooks/useSearchBulkActionsExportTest.ts index 324e832b1653..889e0f902459 100644 --- a/tests/unit/hooks/useSearchBulkActionsExportTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsExportTest.ts @@ -547,9 +547,17 @@ describe('useSearchBulkActions - report export options resolve from the search s expect(exportToIntegrationOnSearch).toHaveBeenCalledWith(expect.anything(), [REPORT_ID], CONST.POLICY.CONNECTIONS.NAME.NETSUITE, undefined); }); - // Only the partial-export modal is shown (there is nothing already-exported to warn about). + // Only the partial-export modal is shown (there is nothing already-exported to warn about). Its + // descriptive text lives in the fixed subtitle, and the scrollable prompt lists the report names. expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); - expect(mockShowConfirmModal).toHaveBeenCalledWith(expect.objectContaining({title: 'workspace.exportPartialModal.title'})); + expect(mockShowConfirmModal).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'workspace.exportPartialModal.title', + subtitle: 'workspace.exportPartialModal.description', + prompt: 'Approved report', + shouldEnablePromptScroll: true, + }), + ); }); it('aborts the export when the partial-export modal is cancelled', async () => { @@ -619,10 +627,22 @@ describe('useSearchBulkActions - report export options resolve from the search s expect(exportToIntegrationOnSearch).toHaveBeenCalledWith(expect.anything(), [REPORT_ID], CONST.POLICY.CONNECTIONS.NAME.NETSUITE, undefined); }); - // Both modals were shown, in order: partial-export first, export-again second. + // Both modals were shown, in order: partial-export first, export-again second. Each splits its + // descriptive text into the fixed subtitle and its report list into the scrollable prompt. expect(mockShowConfirmModal).toHaveBeenCalledTimes(2); - expect(mockShowConfirmModal).toHaveBeenNthCalledWith(1, expect.objectContaining({title: 'workspace.exportPartialModal.title'})); - expect(mockShowConfirmModal).toHaveBeenNthCalledWith(2, expect.objectContaining({title: 'workspace.exportAgainModal.title'})); + expect(mockShowConfirmModal).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({title: 'workspace.exportPartialModal.title', subtitle: 'workspace.exportPartialModal.description', shouldEnablePromptScroll: true}), + ); + expect(mockShowConfirmModal).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + title: 'workspace.exportAgainModal.title', + subtitle: 'workspace.exportAgainModal.description', + prompt: 'Approved report', + shouldEnablePromptScroll: true, + }), + ); }); it('shows only the export-again modal (no partial modal) for a single-integration already-exported selection', async () => { @@ -654,7 +674,14 @@ describe('useSearchBulkActions - report export options resolve from the search s }); expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); - expect(mockShowConfirmModal).toHaveBeenCalledWith(expect.objectContaining({title: 'workspace.exportAgainModal.title'})); + expect(mockShowConfirmModal).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'workspace.exportAgainModal.title', + subtitle: 'workspace.exportAgainModal.description', + prompt: 'Approved report', + shouldEnablePromptScroll: true, + }), + ); }); it('detects an already-exported report from report actions even when isExportedToIntegration is stale/false', async () => { From b29fe7e72b65cf068c6584bb334d9eb31318a304 Mon Sep 17 00:00:00 2001 From: "ahmedGaber93 (via MelvinBot)" Date: Mon, 27 Jul 2026 16:09:48 +0000 Subject: [PATCH 5/8] Export eligible subset per integration and split partial-export reasons - Show Export/Mark-as-exported when at least one report in a group is eligible; act only on the eligible subset - Revert already-exported detection to the backend-aligned pendingFields.export/isExportedToIntegration check - Split the partial-export subtitle into per-case reasons (other integrations and/or ineligible reports) - Update and add useSearchBulkActionsExport tests Co-authored-by: ahmedGaber93 --- src/hooks/useSearchBulkActions.ts | 67 ++++++------ src/languages/en.ts | 12 ++- src/languages/params.ts | 2 + .../hooks/useSearchBulkActionsExportTest.ts | 101 ++++++++++++++---- 4 files changed, 123 insertions(+), 59 deletions(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index d8eb9de6bbfc..19c498a79717 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -56,7 +56,6 @@ import { isIndividualInvoiceRoom, isInvoiceReport, isIOUReport as isIOUReportUtil, - isExported, isSelfDM, shouldShowMarkAsDone, } from '@libs/ReportUtils'; @@ -92,7 +91,7 @@ import ONYXKEYS from '@src/ONYXKEYS'; import ROUTES from '@src/ROUTES'; import {columnsSelector} from '@src/selectors/AdvancedSearchFiltersForm'; import {doesPersonalDetailExistSelector} from '@src/selectors/PersonalDetails'; -import type {BillingGraceEndPeriod, ExportTemplate, Policy, Report, ReportAction, ReportActions, ReportNameValuePairs, SearchResults, Transaction, TransactionViolations} from '@src/types/onyx'; +import type {BillingGraceEndPeriod, ExportTemplate, Policy, Report, ReportAction, ReportNameValuePairs, SearchResults, Transaction, TransactionViolations} from '@src/types/onyx'; import type {SearchResultDataType} from '@src/types/onyx/SearchResults'; import type DeepValueOf from '@src/types/utils/DeepValueOf'; @@ -1542,7 +1541,7 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { // two actions behave identically. When applicable, the partial-export modal is shown first and, // only after it resolves, the existing "export again" modal — the two are never combined. const buildIntegrationHandleExportAction = - (integrationReportIDs: string[], integration: NonNullable>) => (exportAction: () => void) => { + (integrationReportIDs: string[], integration: NonNullable>, integrationGroupSize: number) => (exportAction: () => void) => { const runExport = () => { if (!hash) { return; @@ -1551,9 +1550,6 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { exportAction(); }; - // Detect already-exported reports from the report actions (the same source that drives the - // "exported" icon in the search list), not the report's `isExportedToIntegration` field which - // can be stale/false in the search snapshot. const exportableReportNames: string[] = []; const exportedReportNames: string[] = []; let areAnyReportsExported = false; @@ -1567,21 +1563,10 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { exportableReportNames.push(reportName); } - // Prefer live Onyx report actions, falling back to the search snapshot. - const reportActions = - allReportActions?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] ?? - (currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${reportID}`] as OnyxEntry); - - const wasExported = - !!liveReport?.pendingFields?.export || - !!snapshotReport?.pendingFields?.export || - liveReport?.isExportedToIntegration === true || - snapshotReport?.isExportedToIntegration === true || - // Pass ONLY the report actions so `isExported` evaluates the actions instead of - // short-circuiting on the (possibly stale) `isExportedToIntegration` field. - isExported(reportActions); - - if (!wasExported) { + // Align already-exported detection with the backend response, which sets + // `pendingFields.export`/`isExportedToIntegration` on the report itself. + const report = liveReport ?? snapshotReport; + if (!report?.pendingFields?.export && !report?.isExportedToIntegration) { continue; } @@ -1634,8 +1619,14 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { integration, }), // Fixed subtitle describes the partial scope; the scrollable prompt lists the report names - // that will actually be exported for the chosen integration. - subtitle: translate('workspace.exportPartialModal.description', {integration}), + // that will actually be exported for the chosen integration. A partial export can happen for + // two independent reasons: part of the selection belongs to other integrations, and/or some + // reports in this integration are not eligible to export. + subtitle: translate('workspace.exportPartialModal.description', { + integration, + hasReportsOnOtherIntegrations: integrationGroupSize < totalSelectedReportsCount, + hasIneligibleReports: integrationReportIDs.length < integrationGroupSize, + }), prompt: exportableReportNames.join('\n'), confirmText: translate('workspace.exportPartialModal.confirmText', {count: integrationReportIDs.length}), cancelText: translate('workspace.exportPartialModal.cancelText'), @@ -1651,18 +1642,20 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { // Group each integration's actions together, listing its "Export to " option // immediately followed by its "Mark as exported" option, before moving on to the next integration. for (const [integration, reportsForIntegration] of reportsByIntegration) { - const integrationReportIDs = reportsForIntegration.map((report) => report.reportID).filter((reportID): reportID is string => reportID !== undefined); - if (integrationReportIDs.length === 0) { - continue; - } - const handleExportAction = buildIntegrationHandleExportAction(integrationReportIDs, integration); - - const canExportGroupToIntegration = reportsForIntegration.every((report) => canReportBeExported(report, CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION)); - if (canExportGroupToIntegration) { + const integrationGroupSize = reportsForIntegration.length; + + // Show the option when AT LEAST ONE report in the group is eligible, and act only on the eligible + // subset. Mixing eligible + ineligible reports naturally triggers the partial-export confirmation. + const exportableReportIDs = reportsForIntegration + .filter((report) => canReportBeExported(report, CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION)) + .map((report) => report.reportID) + .filter((reportID): reportID is string => reportID !== undefined); + if (exportableReportIDs.length > 0) { + const handleExportAction = buildIntegrationHandleExportAction(exportableReportIDs, integration, integrationGroupSize); exportOptions.push({ text: CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration], icon: getIntegrationIcon(integration, expensifyIcons), - onSelected: () => handleExportAction(() => exportToIntegrationOnSearch(hash, integrationReportIDs, integration, currentSearchKey)), + onSelected: () => handleExportAction(() => exportToIntegrationOnSearch(hash, exportableReportIDs, integration, currentSearchKey)), shouldCloseModalOnSelect: true, shouldCallAfterModalHide: true, displayInDefaultIconColor: true, @@ -1670,12 +1663,16 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }); } - const canMarkGroupAsExported = reportsForIntegration.every((report) => canReportBeExported(report, CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED)); - if (canMarkGroupAsExported) { + const markableReportIDs = reportsForIntegration + .filter((report) => canReportBeExported(report, CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED)) + .map((report) => report.reportID) + .filter((reportID): reportID is string => reportID !== undefined); + if (markableReportIDs.length > 0) { + const handleMarkAction = buildIntegrationHandleExportAction(markableReportIDs, integration, integrationGroupSize); exportOptions.push({ text: translate('workspace.common.markAsExported'), icon: getIntegrationIcon(integration, expensifyIcons), - onSelected: () => handleExportAction(() => markAsManuallyExported(integrationReportIDs, integration)), + onSelected: () => handleMarkAction(() => markAsManuallyExported(markableReportIDs, integration)), shouldCloseModalOnSelect: true, shouldCallAfterModalHide: true, displayInDefaultIconColor: true, diff --git a/src/languages/en.ts b/src/languages/en.ts index 8d441cfbd6ab..3f7a850a2b46 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -7382,8 +7382,16 @@ const translations = { exportPartialModal: { title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => `Export ${exportableCount}/${selectedCount} reports to ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}?`, - description: ({integration}: ExportPartialModalDescriptionParams) => - `Only reports connected to ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} will be exported. The rest of your selection belongs to other integrations and will be skipped.\n\nThe following reports will be exported:`, + description: ({integration, hasReportsOnOtherIntegrations, hasIneligibleReports}: ExportPartialModalDescriptionParams) => { + const reasons: string[] = []; + if (hasReportsOnOtherIntegrations) { + reasons.push(`Only reports connected to ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} will be exported.`); + } + if (hasIneligibleReports) { + reasons.push(`Only reports that are eligible to export will be exported.`); + } + return `${reasons.join('\n\n')}\n\nThe following reports will be exported:`; + }, confirmText: ({count}: {count: number}) => ({ one: `Export ${count} report`, other: `Export ${count} reports`, diff --git a/src/languages/params.ts b/src/languages/params.ts index c12abc960bdd..943934aec4aa 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -100,6 +100,8 @@ type ExportPartialModalTitleParams = { type ExportPartialModalDescriptionParams = { integration: ConnectionName; + hasReportsOnOtherIntegrations: boolean; + hasIneligibleReports: boolean; }; type UpdateRoleParams = {email: string; currentRole: string; newRole: string}; diff --git a/tests/unit/hooks/useSearchBulkActionsExportTest.ts b/tests/unit/hooks/useSearchBulkActionsExportTest.ts index 78012d67a30a..eec4de069ba3 100644 --- a/tests/unit/hooks/useSearchBulkActionsExportTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsExportTest.ts @@ -18,7 +18,6 @@ import Onyx from 'react-native-onyx'; import type * as MockUsePaymentContextUtil from '../../utils/mockUsePaymentContext'; import {createRandomReport} from '../../utils/collections/reports'; -import createMock from '../../utils/createMock'; // --------------------------------------------------------------------------- // Module mocks @@ -77,7 +76,14 @@ jest.mock('@libs/actions/Search', () => ({ // the report is undefined and canReportBeExported bails before ever calling it. const mockGetSecondaryExportReportActions = jest.fn((...args: Parameters) => { const report = args[2]; - return report ? [CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION, CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED] : []; + if (!report) { + return []; + } + // A submitted (not yet approved) report is not eligible to export, so it drops out of the eligible subset. + if (report.statusNum === CONST.REPORT.STATUS_NUM.SUBMITTED) { + return []; + } + return [CONST.REPORT.EXPORT_OPTIONS.EXPORT_TO_INTEGRATION, CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED]; }); jest.mock('@libs/ReportSecondaryActionUtils', () => ({ ...jest.requireActual('@libs/ReportSecondaryActionUtils'), @@ -318,16 +324,22 @@ function makeSnapshotReport(reportID: string = REPORT_ID, policyID: string = POL }; } -/** A report action that marks a report as exported to an integration (the source the "exported" icon reads). */ -function makeExportedReportActions(label: string = CONST.EXPORT_LABELS.NETSUITE): ReportActions { - return createMock({ - exportAction: { - reportActionID: 'exportAction', - actionName: CONST.REPORT.ACTIONS.TYPE.EXPORTED_TO_INTEGRATION, - created: '2024-01-01 00:00:00.000', - originalMessage: {label, markedManually: true}, - }, - }); +/** A complete snapshot report that has already been exported (backend sets `isExportedToIntegration`). */ +function makeExportedSnapshotReport(reportID: string = REPORT_ID, policyID: string = POLICY_ID): Report { + return { + ...makeSnapshotReport(reportID, policyID), + isExportedToIntegration: true, + }; +} + +/** A submitted (not yet approved) report — ineligible to export, so it is filtered out of the eligible subset. */ +function makeSubmittedReport(reportID: string = REPORT_ID, policyID: string = POLICY_ID): Report { + return { + ...makeSnapshotReport(reportID, policyID), + reportName: 'Submitted report', + stateNum: CONST.REPORT.STATE_NUM.SUBMITTED, + statusNum: CONST.REPORT.STATUS_NUM.SUBMITTED, + }; } /** Build a minimal expense-report search snapshot containing the given reports (and optional report actions) keyed by their collection key. */ @@ -562,6 +574,51 @@ describe('useSearchBulkActions - report export options resolve from the search s ); }); + it('shows the export option for a mixed eligible/ineligible selection on one workspace and exports only the eligible report', async () => { + /** + * Given: two reports on the SAME NetSuite workspace — one approved (eligible to export) and one + * submitted (not yet eligible). + * + * When: the export bulk-action menu is built and the user clicks "Export to NetSuite" and confirms. + * + * Then: the option still appears (at least one report is eligible), the partial-export modal is shown + * for the eligible subset, and only the approved report is exported. + */ + mockCurrentSearchResults = makeSearchResults([makeSnapshotReport(), makeSubmittedReport(REPORT_ID_2, POLICY_ID)]); + mockSelectedReports = [makeSelectedReport(), makeSelectedReport({reportID: REPORT_ID_2})]; + mockSelectedTransactions = { + tx1: makeSelectedTransaction(), + tx2: makeSelectedTransaction({reportID: REPORT_ID_2}), + }; + + const {result} = renderHook(() => useSearchBulkActions({queryJSON: expenseReportQueryJSON}), {wrapper: OnyxListItemProvider}); + + // The option shows even though only one of the two selected reports is eligible. + await waitFor(() => { + expect(getExportSubMenuItems(result.current.headerButtonsOptions)?.some((item) => item.text === NETSUITE_FRIENDLY_NAME)).toBe(true); + }); + + getExportSubMenuItems(result.current.headerButtonsOptions) + ?.find((item) => item.text === NETSUITE_FRIENDLY_NAME) + ?.onSelected?.(); + + // Only the approved report (report1) is exported; the submitted report is skipped. + await waitFor(() => { + expect(exportToIntegrationOnSearch).toHaveBeenCalledWith(expect.anything(), [REPORT_ID], CONST.POLICY.CONNECTIONS.NAME.NETSUITE, undefined); + }); + + // The partial-export modal is shown for the eligible subset (1 of 2 selected reports). + expect(mockShowConfirmModal).toHaveBeenCalledTimes(1); + expect(mockShowConfirmModal).toHaveBeenCalledWith( + expect.objectContaining({ + title: 'workspace.exportPartialModal.title', + subtitle: 'workspace.exportPartialModal.description', + prompt: 'Approved report', + shouldEnablePromptScroll: true, + }), + ); + }); + it('aborts the export when the partial-export modal is cancelled', async () => { await Onyx.merge(`${ONYXKEYS.COLLECTION.POLICY}${POLICY_ID_2}`, { id: POLICY_ID_2, @@ -607,8 +664,8 @@ describe('useSearchBulkActions - report export options resolve from the search s connections: {[CONST.POLICY.CONNECTIONS.NAME.QBO]: {}}, }); - // report1 is already exported (via its report actions), report2 is not. - mockCurrentSearchResults = makeSearchResults([makeSnapshotReport(), makeSnapshotReport(REPORT_ID_2, POLICY_ID_2)], {[REPORT_ID]: makeExportedReportActions()}); + // report1 is already exported (backend set `isExportedToIntegration`), report2 is not. + mockCurrentSearchResults = makeSearchResults([makeExportedSnapshotReport(), makeSnapshotReport(REPORT_ID_2, POLICY_ID_2)]); mockSelectedReports = [makeSelectedReport(), makeSelectedReport({reportID: REPORT_ID_2, policyID: POLICY_ID_2})]; mockSelectedTransactions = { tx1: makeSelectedTransaction(), @@ -657,7 +714,7 @@ describe('useSearchBulkActions - report export options resolve from the search s * Then: only the export-again modal is shown (the export is not partial), and after confirming the * report is exported. */ - mockCurrentSearchResults = makeSearchResults([makeSnapshotReport()], {[REPORT_ID]: makeExportedReportActions()}); + mockCurrentSearchResults = makeSearchResults([makeExportedSnapshotReport()]); mockSelectedReports = [makeSelectedReport()]; mockSelectedTransactions = {tx1: makeSelectedTransaction()}; @@ -686,17 +743,17 @@ describe('useSearchBulkActions - report export options resolve from the search s ); }); - it('detects an already-exported report from report actions even when isExportedToIntegration is stale/false', async () => { + it('detects an already-exported report from a pending export field on the report', async () => { /** - * Given: a report whose `isExportedToIntegration` field is stale (false) in the snapshot but whose - * report actions show it was exported — the same source that drives the search list icon. + * Given: a report the backend reports as mid-export via `pendingFields.export` (rather than + * `isExportedToIntegration`). Detection is aligned with the backend response, which sets these + * fields on the report itself. * * When: the user clicks "Export to NetSuite". * - * Then: the export-again modal is still shown, proving detection reads the report actions and does - * not trust the stale `isExportedToIntegration` field. + * Then: the export-again modal is shown, proving detection reads the report's export fields. */ - mockCurrentSearchResults = makeSearchResults([{...makeSnapshotReport(), isExportedToIntegration: false}], {[REPORT_ID]: makeExportedReportActions()}); + mockCurrentSearchResults = makeSearchResults([{...makeSnapshotReport(), isExportedToIntegration: false, pendingFields: {export: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}}]); mockSelectedReports = [makeSelectedReport()]; mockSelectedTransactions = {tx1: makeSelectedTransaction()}; @@ -759,7 +816,7 @@ describe('useSearchBulkActions - report export options resolve from the search s connections: {[CONST.POLICY.CONNECTIONS.NAME.QBO]: {}}, }); - mockCurrentSearchResults = makeSearchResults([makeSnapshotReport(), makeSnapshotReport(REPORT_ID_2, POLICY_ID_2)], {[REPORT_ID]: makeExportedReportActions()}); + mockCurrentSearchResults = makeSearchResults([makeExportedSnapshotReport(), makeSnapshotReport(REPORT_ID_2, POLICY_ID_2)]); mockSelectedReports = [makeSelectedReport(), makeSelectedReport({reportID: REPORT_ID_2, policyID: POLICY_ID_2})]; mockSelectedTransactions = { tx1: makeSelectedTransaction(), From 9739a1a016e963bb781f39e457f2b1fae61582d2 Mon Sep 17 00:00:00 2001 From: "{\"message\":\"Not Found\",\"documentation_url\":\"https://docs.github.com/rest/issues/comments#get-an-issue-comment\",\"status\":\"404\"} (via MelvinBot)" Date: Tue, 28 Jul 2026 05:09:18 +0000 Subject: [PATCH 6/8] Sync non-English exportPartialModal translations with latest English copy Update the exportPartialModal.description in all non-English locales to match the new English logic, which conditionally builds up to two reasons from the hasReportsOnOtherIntegrations and hasIneligibleReports params (previously the translations only handled the single other-integrations reason). Co-authored-by: ahmedGaber93 Co-authored-by: {"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#get-an-issue-comment","status":"404"} <{"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#get-an-issue-comment","status":"404"}@users.noreply.github.com> --- src/languages/de.ts | 12 ++++++++++-- src/languages/es.ts | 12 ++++++++++-- src/languages/fr.ts | 12 ++++++++++-- src/languages/it.ts | 12 ++++++++++-- src/languages/ja.ts | 12 ++++++++++-- src/languages/nl.ts | 12 ++++++++++-- src/languages/pl.ts | 12 ++++++++++-- src/languages/pt-BR.ts | 12 ++++++++++-- src/languages/zh-hans.ts | 12 ++++++++++-- 9 files changed, 90 insertions(+), 18 deletions(-) diff --git a/src/languages/de.ts b/src/languages/de.ts index 055e1576a3aa..d2dff155de5b 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -7197,8 +7197,16 @@ ${reportName}`, exportPartialModal: { title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => `${exportableCount}/${selectedCount} Berichte nach ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} exportieren?`, - description: ({integration}: ExportPartialModalDescriptionParams) => - `Nur mit ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} verbundene Berichte werden exportiert. Der Rest deiner Auswahl gehört zu anderen Integrationen und wird übersprungen.\n\nDie folgenden Berichte werden exportiert:`, + description: ({integration, hasReportsOnOtherIntegrations, hasIneligibleReports}: ExportPartialModalDescriptionParams) => { + const reasons: string[] = []; + if (hasReportsOnOtherIntegrations) { + reasons.push(`Nur mit ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} verbundene Berichte werden exportiert.`); + } + if (hasIneligibleReports) { + reasons.push(`Nur exportierbare Berichte werden exportiert.`); + } + return `${reasons.join('\n\n')}\n\nDie folgenden Berichte werden exportiert:`; + }, confirmText: ({count}: {count: number}) => ({ one: `${count} Bericht exportieren`, other: `${count} Berichte exportieren`, diff --git a/src/languages/es.ts b/src/languages/es.ts index 82a8dde20948..9392ef7c6ed2 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7115,8 +7115,16 @@ El plan Controlar empieza en 9 $ por miembro activo al mes.`, }, exportPartialModal: { title: ({exportableCount, selectedCount, integration}) => `¿Exportar ${exportableCount}/${selectedCount} informes a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}?`, - description: ({integration}) => - `Solo se exportarán los informes conectados a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}. El resto de tu selección pertenece a otras integraciones y se omitirá.\n\nSe exportarán los siguientes informes:`, + description: ({integration, hasReportsOnOtherIntegrations, hasIneligibleReports}) => { + const reasons: string[] = []; + if (hasReportsOnOtherIntegrations) { + reasons.push(`Solo se exportarán los informes conectados a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}.`); + } + if (hasIneligibleReports) { + reasons.push(`Solo se exportarán los informes aptos para la exportación.`); + } + return `${reasons.join('\n\n')}\n\nSe exportarán los siguientes informes:`; + }, confirmText: ({count}) => ({ one: `Exportar ${count} informe`, other: `Exportar ${count} informes`, diff --git a/src/languages/fr.ts b/src/languages/fr.ts index 909a1bbd13d5..cad86eb545c6 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -7223,8 +7223,16 @@ ${reportName}`, exportPartialModal: { title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => `Exporter ${exportableCount}/${selectedCount} rapports vers ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} ?`, - description: ({integration}: ExportPartialModalDescriptionParams) => - `Seuls les rapports connectés à ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} seront exportés. Le reste de votre sélection appartient à d’autres intégrations et sera ignoré.\n\nLes rapports suivants seront exportés :`, + description: ({integration, hasReportsOnOtherIntegrations, hasIneligibleReports}: ExportPartialModalDescriptionParams) => { + const reasons: string[] = []; + if (hasReportsOnOtherIntegrations) { + reasons.push(`Seuls les rapports connectés à ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} seront exportés.`); + } + if (hasIneligibleReports) { + reasons.push(`Seuls les rapports éligibles à l’exportation seront exportés.`); + } + return `${reasons.join('\n\n')}\n\nLes rapports suivants seront exportés :`; + }, confirmText: ({count}: {count: number}) => ({ one: `Exporter ${count} rapport`, other: `Exporter ${count} rapports`, diff --git a/src/languages/it.ts b/src/languages/it.ts index ada0fbf74568..6476cbb5c8ce 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -7173,8 +7173,16 @@ ${reportName}`, exportPartialModal: { title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => `Esportare ${exportableCount}/${selectedCount} report in ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}?`, - description: ({integration}: ExportPartialModalDescriptionParams) => - `Verranno esportati solo i report collegati a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}. Il resto della selezione appartiene ad altre integrazioni e verrà ignorato.\n\nVerranno esportati i seguenti report:`, + description: ({integration, hasReportsOnOtherIntegrations, hasIneligibleReports}: ExportPartialModalDescriptionParams) => { + const reasons: string[] = []; + if (hasReportsOnOtherIntegrations) { + reasons.push(`Verranno esportati solo i report collegati a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}.`); + } + if (hasIneligibleReports) { + reasons.push(`Verranno esportati solo i report idonei all’esportazione.`); + } + return `${reasons.join('\n\n')}\n\nVerranno esportati i seguenti report:`; + }, confirmText: ({count}: {count: number}) => ({ one: `Esporta ${count} report`, other: `Esporta ${count} report`, diff --git a/src/languages/ja.ts b/src/languages/ja.ts index bcc9bdbc0942..74260b6d3e13 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -7093,8 +7093,16 @@ ${reportName}`, exportPartialModal: { title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => `${exportableCount}/${selectedCount}件のレポートを${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}にエクスポートしますか?`, - description: ({integration}: ExportPartialModalDescriptionParams) => - `${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}に接続されているレポートのみがエクスポートされます。選択した残りのレポートは他の連携に属しているためスキップされます。\n\n以下のレポートがエクスポートされます:`, + description: ({integration, hasReportsOnOtherIntegrations, hasIneligibleReports}: ExportPartialModalDescriptionParams) => { + const reasons: string[] = []; + if (hasReportsOnOtherIntegrations) { + reasons.push(`${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}に接続されているレポートのみがエクスポートされます。`); + } + if (hasIneligibleReports) { + reasons.push(`エクスポート可能なレポートのみがエクスポートされます。`); + } + return `${reasons.join('\n\n')}\n\n以下のレポートがエクスポートされます:`; + }, confirmText: ({count}: {count: number}) => ({ one: `${count}件のレポートをエクスポート`, other: `${count}件のレポートをエクスポート`, diff --git a/src/languages/nl.ts b/src/languages/nl.ts index f25f8251985f..bc278b131a25 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -7160,8 +7160,16 @@ ${reportName}`, exportPartialModal: { title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => `${exportableCount}/${selectedCount} rapporten exporteren naar ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}?`, - description: ({integration}: ExportPartialModalDescriptionParams) => - `Alleen rapporten die zijn verbonden met ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} worden geëxporteerd. De rest van je selectie hoort bij andere integraties en wordt overgeslagen.\n\nDe volgende rapporten worden geëxporteerd:`, + description: ({integration, hasReportsOnOtherIntegrations, hasIneligibleReports}: ExportPartialModalDescriptionParams) => { + const reasons: string[] = []; + if (hasReportsOnOtherIntegrations) { + reasons.push(`Alleen rapporten die zijn verbonden met ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} worden geëxporteerd.`); + } + if (hasIneligibleReports) { + reasons.push(`Alleen rapporten die in aanmerking komen voor export worden geëxporteerd.`); + } + return `${reasons.join('\n\n')}\n\nDe volgende rapporten worden geëxporteerd:`; + }, confirmText: ({count}: {count: number}) => ({ one: `${count} rapport exporteren`, other: `${count} rapporten exporteren`, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 1e7ccdb839bb..2a1fed0c6772 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -7140,8 +7140,16 @@ ${reportName}`, exportPartialModal: { title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => `Wyeksportować ${exportableCount}/${selectedCount} raportów do ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}?`, - description: ({integration}: ExportPartialModalDescriptionParams) => - `Wyeksportowane zostaną tylko raporty połączone z ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}. Reszta zaznaczenia należy do innych integracji i zostanie pominięta.\n\nWyeksportowane zostaną następujące raporty:`, + description: ({integration, hasReportsOnOtherIntegrations, hasIneligibleReports}: ExportPartialModalDescriptionParams) => { + const reasons: string[] = []; + if (hasReportsOnOtherIntegrations) { + reasons.push(`Wyeksportowane zostaną tylko raporty połączone z ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}.`); + } + if (hasIneligibleReports) { + reasons.push(`Wyeksportowane zostaną tylko raporty kwalifikujące się do eksportu.`); + } + return `${reasons.join('\n\n')}\n\nWyeksportowane zostaną następujące raporty:`; + }, confirmText: ({count}: {count: number}) => ({ one: `Wyeksportuj ${count} raport`, other: `Wyeksportuj ${count} raportów`, diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index 22d4b5adf682..b19b1cdab7ee 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -7152,8 +7152,16 @@ ${reportName}`, exportPartialModal: { title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => `Exportar ${exportableCount}/${selectedCount} relatórios para ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}?`, - description: ({integration}: ExportPartialModalDescriptionParams) => - `Somente os relatórios conectados a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} serão exportados. O restante da sua seleção pertence a outras integrações e será ignorado.\n\nOs seguintes relatórios serão exportados:`, + description: ({integration, hasReportsOnOtherIntegrations, hasIneligibleReports}: ExportPartialModalDescriptionParams) => { + const reasons: string[] = []; + if (hasReportsOnOtherIntegrations) { + reasons.push(`Somente os relatórios conectados a ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} serão exportados.`); + } + if (hasIneligibleReports) { + reasons.push(`Somente os relatórios elegíveis para exportação serão exportados.`); + } + return `${reasons.join('\n\n')}\n\nOs seguintes relatórios serão exportados:`; + }, confirmText: ({count}: {count: number}) => ({ one: `Exportar ${count} relatório`, other: `Exportar ${count} relatórios`, diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 427756ff5a03..82259acb2844 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -6933,8 +6933,16 @@ ${reportName}`, exportPartialModal: { title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => `将 ${exportableCount}/${selectedCount} 份报表导出到 ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}?`, - description: ({integration}: ExportPartialModalDescriptionParams) => - `只有连接到 ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} 的报表会被导出。所选的其余报表属于其他集成,将被跳过。\n\n将导出以下报表:`, + description: ({integration, hasReportsOnOtherIntegrations, hasIneligibleReports}: ExportPartialModalDescriptionParams) => { + const reasons: string[] = []; + if (hasReportsOnOtherIntegrations) { + reasons.push(`只有连接到 ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]} 的报表会被导出。`); + } + if (hasIneligibleReports) { + reasons.push(`只有符合导出条件的报表会被导出。`); + } + return `${reasons.join('\n\n')}\n\n将导出以下报表:`; + }, confirmText: ({count}: {count: number}) => ({ one: `导出 ${count} 份报表`, other: `导出 ${count} 份报表`, From bbdbb8481390d047d3521a19756b0e1901d4c65d Mon Sep 17 00:00:00 2001 From: "ahmedGaber93 (via MelvinBot)" Date: Tue, 28 Jul 2026 09:31:29 +0000 Subject: [PATCH 7/8] Fix spellcheck: rename coined word markableReportIDs to reportIDsToMark Co-authored-by: ahmedGaber93 --- src/hooks/useSearchBulkActions.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 19c498a79717..64817c0ff74d 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -1663,16 +1663,16 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }); } - const markableReportIDs = reportsForIntegration + const reportIDsToMark = reportsForIntegration .filter((report) => canReportBeExported(report, CONST.REPORT.EXPORT_OPTIONS.MARK_AS_EXPORTED)) .map((report) => report.reportID) .filter((reportID): reportID is string => reportID !== undefined); - if (markableReportIDs.length > 0) { - const handleMarkAction = buildIntegrationHandleExportAction(markableReportIDs, integration, integrationGroupSize); + if (reportIDsToMark.length > 0) { + const handleMarkAction = buildIntegrationHandleExportAction(reportIDsToMark, integration, integrationGroupSize); exportOptions.push({ text: translate('workspace.common.markAsExported'), icon: getIntegrationIcon(integration, expensifyIcons), - onSelected: () => handleMarkAction(() => markAsManuallyExported(markableReportIDs, integration)), + onSelected: () => handleMarkAction(() => markAsManuallyExported(reportIDsToMark, integration)), shouldCloseModalOnSelect: true, shouldCallAfterModalHide: true, displayInDefaultIconColor: true, From dd5c36d281dafb7ddef27f8f79e3d6c71239f9a5 Mon Sep 17 00:00:00 2001 From: "{\"message\":\"Not Found\",\"documentation_url\":\"https://docs.github.com/rest/issues/comments#get-an-issue-comment\",\"status\":\"404\"} (via MelvinBot)" Date: Tue, 28 Jul 2026 16:16:58 +0000 Subject: [PATCH 8/8] Add integration-scoped accessibilityLabel to per-integration Mark as exported option Co-authored-by: {"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#get-an-issue-comment","status":"404"} <{"message":"Not Found","documentation_url":"https://docs.github.com/rest/issues/comments#get-an-issue-comment","status":"404"}@users.noreply.github.com> --- src/hooks/useSearchBulkActions.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/hooks/useSearchBulkActions.ts b/src/hooks/useSearchBulkActions.ts index 64817c0ff74d..884c8265d9d7 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -1671,6 +1671,9 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { const handleMarkAction = buildIntegrationHandleExportAction(reportIDsToMark, integration, integrationGroupSize); exportOptions.push({ text: translate('workspace.common.markAsExported'), + // Every integration's "Mark as exported" option shares the same visible text and differs only by icon, + // which screen readers can't announce. Append the integration name so assistive tech can distinguish them. + accessibilityLabel: `${translate('workspace.common.markAsExported')}, ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}`, icon: getIntegrationIcon(integration, expensifyIcons), onSelected: () => handleMarkAction(() => markAsManuallyExported(reportIDsToMark, integration)), shouldCloseModalOnSelect: true,