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 945f48f68a6a..884c8265d9d7 100644 --- a/src/hooks/useSearchBulkActions.ts +++ b/src/hooks/useSearchBulkActions.ts @@ -1493,7 +1493,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, @@ -1516,49 +1515,84 @@ 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); + } + } + + // 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>, integrationGroupSize: number) => (exportAction: () => void) => { + const runExport = () => { + if (!hash) { + return; + } + clearSelectedTransactions(); + exportAction(); + }; + + 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); + } - for (const reportID of selectedReportIDs) { - const report = allReports?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`] ?? currentSearchResults?.data?.[`${ONYXKEYS.COLLECTION.REPORT}${reportID}`]; - + // 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; } 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; 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', { - connectionName: connectedIntegration, - reportName: exportedReportNames.join('\n'), - }), + // 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: '', + }).trim(), + prompt: exportedReportNames.join('\n'), confirmText: translate('workspace.exportAgainModal.confirmText'), cancelText: translate('workspace.exportAgainModal.cancelText'), shouldEnablePromptScroll: true, @@ -1566,23 +1600,62 @@ 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, + }), + // Fixed subtitle describes the partial scope; the scrollable prompt lists the report names + // 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'), + shouldEnablePromptScroll: true, + }).then((result) => { + if (result.action !== ModalActions.CONFIRM) { + return; + } + confirmExportAgainThenRun(); + }); }; - if (canExportAllReportsToIntegration) { + // 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 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: connectionNameFriendly, - icon: integrationIcon, - onSelected: () => handleExportAction(() => exportToIntegrationOnSearch(hash, selectedReportIDs, connectedIntegration, currentSearchKey)), + text: CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration], + icon: getIntegrationIcon(integration, expensifyIcons), + onSelected: () => handleExportAction(() => exportToIntegrationOnSearch(hash, exportableReportIDs, integration, currentSearchKey)), shouldCloseModalOnSelect: true, shouldCallAfterModalHide: true, displayInDefaultIconColor: true, @@ -1590,11 +1663,19 @@ function useSearchBulkActions({queryJSON}: UseSearchBulkActionsParams) { }); } - if (canMarkAllReportsAsExported) { + 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 (reportIDsToMark.length > 0) { + const handleMarkAction = buildIntegrationHandleExportAction(reportIDsToMark, integration, integrationGroupSize); exportOptions.push({ text: translate('workspace.common.markAsExported'), - icon: integrationIcon, - onSelected: () => handleExportAction(() => markAsManuallyExported(selectedReportIDs, connectedIntegration)), + // 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, shouldCallAfterModalHide: true, displayInDefaultIconColor: true, diff --git a/src/languages/de.ts b/src/languages/de.ts index 0b54036378b1..d2dff155de5b 100644 --- a/src/languages/de.ts +++ b/src/languages/de.ts @@ -33,6 +33,8 @@ import type { EditActionParams, EmptyViolationSnapshotResultsSubtitleParams, ExportAgainModalDescriptionParams, + ExportPartialModalTitleParams, + ExportPartialModalDescriptionParams, ExportIntegrationSelectedParams, IntacctMappingTitleParams, InvalidPropertyParams, @@ -7192,6 +7194,25 @@ ${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, 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`, + }), + cancelText: 'Abbrechen', + }, upgrade: { reportFields: { title: 'Berichtsfelder', diff --git a/src/languages/en.ts b/src/languages/en.ts index b041d4f678d8..3f7a850a2b46 100644 --- a/src/languages/en.ts +++ b/src/languages/en.ts @@ -21,6 +21,8 @@ import type { EditActionParams, EmptyViolationSnapshotResultsSubtitleParams, ExportAgainModalDescriptionParams, + ExportPartialModalTitleParams, + ExportPartialModalDescriptionParams, ExportIntegrationSelectedParams, IntacctMappingTitleParams, InvalidPropertyParams, @@ -7377,6 +7379,25 @@ 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, 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`, + }), + cancelText: 'Cancel', + }, upgrade: { reportFields: { title: 'Report fields', diff --git a/src/languages/es.ts b/src/languages/es.ts index cf2e328bbbb5..9392ef7c6ed2 100644 --- a/src/languages/es.ts +++ b/src/languages/es.ts @@ -7113,6 +7113,24 @@ 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, 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`, + }), + cancelText: 'Cancelar', + }, planTypePage: { planTypes: { team: { diff --git a/src/languages/fr.ts b/src/languages/fr.ts index ac007d09c5b2..cad86eb545c6 100644 --- a/src/languages/fr.ts +++ b/src/languages/fr.ts @@ -33,6 +33,8 @@ import type { EditActionParams, EmptyViolationSnapshotResultsSubtitleParams, ExportAgainModalDescriptionParams, + ExportPartialModalTitleParams, + ExportPartialModalDescriptionParams, ExportIntegrationSelectedParams, IntacctMappingTitleParams, InvalidPropertyParams, @@ -7218,6 +7220,25 @@ ${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, 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`, + }), + cancelText: 'Annuler', + }, upgrade: { reportFields: { title: 'Champs de note de frais', diff --git a/src/languages/it.ts b/src/languages/it.ts index c9ea8dc64e27..6476cbb5c8ce 100644 --- a/src/languages/it.ts +++ b/src/languages/it.ts @@ -33,6 +33,8 @@ import type { EditActionParams, EmptyViolationSnapshotResultsSubtitleParams, ExportAgainModalDescriptionParams, + ExportPartialModalTitleParams, + ExportPartialModalDescriptionParams, ExportIntegrationSelectedParams, IntacctMappingTitleParams, InvalidPropertyParams, @@ -7168,6 +7170,25 @@ ${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, 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`, + }), + cancelText: 'Annulla', + }, upgrade: { reportFields: { title: 'Campi del report', diff --git a/src/languages/ja.ts b/src/languages/ja.ts index 137fbc5a060b..74260b6d3e13 100644 --- a/src/languages/ja.ts +++ b/src/languages/ja.ts @@ -33,6 +33,8 @@ import type { EditActionParams, EmptyViolationSnapshotResultsSubtitleParams, ExportAgainModalDescriptionParams, + ExportPartialModalTitleParams, + ExportPartialModalDescriptionParams, ExportIntegrationSelectedParams, IntacctMappingTitleParams, InvalidPropertyParams, @@ -7088,6 +7090,25 @@ ${reportName}`, confirmText: 'はい、再度エクスポートします', cancelText: 'キャンセル', }, + exportPartialModal: { + title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => + `${exportableCount}/${selectedCount}件のレポートを${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}にエクスポートしますか?`, + 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}件のレポートをエクスポート`, + }), + cancelText: 'キャンセル', + }, upgrade: { reportFields: { title: 'レポート項目', diff --git a/src/languages/nl.ts b/src/languages/nl.ts index 62698ba1b017..bc278b131a25 100644 --- a/src/languages/nl.ts +++ b/src/languages/nl.ts @@ -33,6 +33,8 @@ import type { EditActionParams, EmptyViolationSnapshotResultsSubtitleParams, ExportAgainModalDescriptionParams, + ExportPartialModalTitleParams, + ExportPartialModalDescriptionParams, ExportIntegrationSelectedParams, IntacctMappingTitleParams, InvalidPropertyParams, @@ -7155,6 +7157,25 @@ ${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, 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`, + }), + cancelText: 'Annuleren', + }, upgrade: { reportFields: { title: 'Rapportvelden', diff --git a/src/languages/params.ts b/src/languages/params.ts index 2c3d07d6ff72..943934aec4aa 100644 --- a/src/languages/params.ts +++ b/src/languages/params.ts @@ -92,6 +92,18 @@ type ExportAgainModalDescriptionParams = { connectionName: ConnectionName; }; +type ExportPartialModalTitleParams = { + exportableCount: number; + selectedCount: number; + integration: ConnectionName; +}; + +type ExportPartialModalDescriptionParams = { + integration: ConnectionName; + hasReportsOnOtherIntegrations: boolean; + hasIneligibleReports: boolean; +}; + type UpdateRoleParams = {email: string; currentRole: string; newRole: string}; type YourPlanPriceParams = {lower: string; upper: string}; @@ -176,6 +188,8 @@ export type { UnsupportedFormulaValueErrorParams, ConnectionNameParams, ExportAgainModalDescriptionParams, + ExportPartialModalTitleParams, + ExportPartialModalDescriptionParams, UpdateRoleParams, OptionalParam, WorkspaceLockedPlanTypeParams, diff --git a/src/languages/pl.ts b/src/languages/pl.ts index 46f1c581c2f8..2a1fed0c6772 100644 --- a/src/languages/pl.ts +++ b/src/languages/pl.ts @@ -33,6 +33,8 @@ import type { EditActionParams, EmptyViolationSnapshotResultsSubtitleParams, ExportAgainModalDescriptionParams, + ExportPartialModalTitleParams, + ExportPartialModalDescriptionParams, ExportIntegrationSelectedParams, IntacctMappingTitleParams, InvalidPropertyParams, @@ -7135,6 +7137,25 @@ ${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, 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`, + }), + cancelText: 'Anuluj', + }, upgrade: { reportFields: { title: 'Pola raportu', diff --git a/src/languages/pt-BR.ts b/src/languages/pt-BR.ts index e664680a4161..b19b1cdab7ee 100644 --- a/src/languages/pt-BR.ts +++ b/src/languages/pt-BR.ts @@ -33,6 +33,8 @@ import type { EditActionParams, EmptyViolationSnapshotResultsSubtitleParams, ExportAgainModalDescriptionParams, + ExportPartialModalTitleParams, + ExportPartialModalDescriptionParams, ExportIntegrationSelectedParams, IntacctMappingTitleParams, InvalidPropertyParams, @@ -7147,6 +7149,25 @@ ${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, 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`, + }), + cancelText: 'Cancelar', + }, upgrade: { reportFields: { title: 'Campos do relatório', diff --git a/src/languages/zh-hans.ts b/src/languages/zh-hans.ts index 1a6cfd712ba7..82259acb2844 100644 --- a/src/languages/zh-hans.ts +++ b/src/languages/zh-hans.ts @@ -33,6 +33,8 @@ import type { EditActionParams, EmptyViolationSnapshotResultsSubtitleParams, ExportAgainModalDescriptionParams, + ExportPartialModalTitleParams, + ExportPartialModalDescriptionParams, ExportIntegrationSelectedParams, IntacctMappingTitleParams, InvalidPropertyParams, @@ -6928,6 +6930,25 @@ ${reportName}`, confirmText: '是,再次导出', cancelText: '取消', }, + exportPartialModal: { + title: ({exportableCount, selectedCount, integration}: ExportPartialModalTitleParams) => + `将 ${exportableCount}/${selectedCount} 份报表导出到 ${CONST.POLICY.CONNECTIONS.NAME_USER_FRIENDLY[integration]}?`, + 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} 份报表`, + }), + cancelText: '取消', + }, upgrade: { reportFields: { title: '报表字段', diff --git a/tests/unit/hooks/useSearchBulkActionsExportTest.ts b/tests/unit/hooks/useSearchBulkActionsExportTest.ts index a2c7aa365276..eec4de069ba3 100644 --- a/tests/unit/hooks/useSearchBulkActionsExportTest.ts +++ b/tests/unit/hooks/useSearchBulkActionsExportTest.ts @@ -5,11 +5,13 @@ 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'; 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'; @@ -74,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'), @@ -245,7 +254,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 +311,11 @@ function makeSelectedTransaction(overrides: Partial = {}): 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, @@ -355,6 +388,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. @@ -437,4 +473,374 @@ describe('useSearchBulkActions - report export options resolve from the search s expect(subMenuItems.some((item) => 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 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']); + + expect(markAsManuallyExported).not.toHaveBeenCalled(); + }); + + 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]: {}}, + }); + + 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). 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', + subtitle: 'workspace.exportPartialModal.description', + prompt: 'Approved report', + shouldEnablePromptScroll: true, + }), + ); + }); + + 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, + 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 (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(), + 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. 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', 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 () => { + /** + * 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([makeExportedSnapshotReport()]); + 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', + subtitle: 'workspace.exportAgainModal.description', + prompt: 'Approved report', + shouldEnablePromptScroll: true, + }), + ); + }); + + it('detects an already-exported report from a pending export field on the report', async () => { + /** + * 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 shown, proving detection reads the report's export fields. + */ + mockCurrentSearchResults = makeSearchResults([{...makeSnapshotReport(), isExportedToIntegration: false, pendingFields: {export: CONST.RED_BRICK_ROAD_PENDING_ACTION.ADD}}]); + 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([makeExportedSnapshotReport(), 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 === '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(); + }); });